From bfa3edb139af1b30c1db17bd1e3429489b787e58 Mon Sep 17 00:00:00 2001 From: JulienAu <16043912+JulienAu@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:16:06 +0200 Subject: [PATCH 1/2] test(onboard): add a child-process harness for four pilot suites Four onboarding suites each hand-rolled the same child-process mechanics: mkdtemp workspace with a fake bin, 0o755 stub writer, HOME/PATH environment composition, a synchronous node spawn from the repository root, and trailing JSON payload extraction. Move those mechanics into test/helpers/onboard-child-process-harness.ts and migrate the pilots. Stub script contents, scenario environment values, and assertions stay in each test, and every migrated file has a negative line delta. Refs #8289 Signed-off-by: JulienAu <16043912+JulienAu@users.noreply.github.com> --- test/helpers/onboard-child-process-harness.ts | 158 ++++++++++++++++++ ...rd-gateway-port-conflict-fast-fail.test.ts | 55 +++--- test/onboard-prepared-gateway-handoff.test.ts | 46 ++--- ...d-remote-recreate-credential-reuse.test.ts | 137 +++++---------- test/onboard-reservation-recreate.test.ts | 41 ++--- 5 files changed, 260 insertions(+), 177 deletions(-) create mode 100644 test/helpers/onboard-child-process-harness.ts diff --git a/test/helpers/onboard-child-process-harness.ts b/test/helpers/onboard-child-process-harness.ts new file mode 100644 index 00000000000..72a860b7ad8 --- /dev/null +++ b/test/helpers/onboard-child-process-harness.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Child-process setup mechanics for onboarding suites that spawn the CLI or a + * generated scenario script in a real Node process. The harness owns the + * temporary workspace, fake-bin executables, environment composition, spawn + * call, and trailing JSON payload extraction. Stub script contents, scenario + * environment values, and assertions stay in each test. Every helper returns + * fresh mutable state and touches no parent-process global. + */ + +/** The repository root the spawned processes run from. */ +export const testRepoRoot = path.join(import.meta.dirname, "..", ".."); + +/** A disposable workspace holding the spawned process's home and fake bin. */ +export interface OnboardProcessWorkspace { + /** The mkdtemp root; also the default HOME. */ + root: string; + /** The directory HOME points at; equals root unless separateHome is set. */ + homeDir: string; + /** The created bin directory for stub executables. */ + binDir: string; + /** Writes an executable stub into binDir and returns its path. */ + writeExecutable: (name: string, contents: string) => string; + /** Resolves a path under the workspace root. */ + path: (...segments: string[]) => string; + /** Removes the whole workspace. */ + remove: () => void; +} + +/** Creation options for createOnboardProcessWorkspace. */ +export interface OnboardProcessWorkspaceOptions { + /** Create HOME as a `home/` directory beside bin instead of the root. */ + separateHome?: boolean; +} + +/** Creates a fresh temporary workspace with a created bin directory. */ +export function createOnboardProcessWorkspace( + prefix: string, + options?: OnboardProcessWorkspaceOptions, +): OnboardProcessWorkspace { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const binDir = path.join(root, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const homeDir = options?.separateHome ? path.join(root, "home") : root; + fs.mkdirSync(homeDir, { recursive: true }); + return { + root, + homeDir, + binDir, + writeExecutable: (name, contents) => { + const target = path.join(binDir, name); + fs.writeFileSync(target, contents, { mode: 0o755 }); + return target; + }, + path: (...segments) => path.join(root, ...segments), + remove: () => { + fs.rmSync(root, { recursive: true, force: true }); + }, + }; +} + +/** + * The inherited-process environment for a workspace: HOME at the workspace + * home and the fake bin prepended to PATH. Returns a fresh object per call. + */ +export function workspaceEnv( + workspace: OnboardProcessWorkspace, + overrides?: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + return { + ...process.env, + HOME: workspace.homeDir, + PATH: `${workspace.binDir}:${process.env.PATH || ""}`, + ...overrides, + }; +} + +/** + * A minimal spawn environment that inherits nothing but PATH plus the + * Windows keys Node needs to spawn at all. Returns a fresh object per call. + */ +export function minimalSpawnEnv(home: string, overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + HOME: home, + PATH: process.env.PATH || "/usr/bin:/bin", + NO_COLOR: "1", + }; + for (const key of ["ComSpec", "PATHEXT", "SystemRoot", "WINDIR"]) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + return { ...env, ...overrides }; +} + +/** Spawn options for runOnboardProcess. */ +export interface RunOnboardProcessOptions { + env: NodeJS.ProcessEnv; + /** Working directory; defaults to the repository root. */ + cwd?: string; + /** Kill the child after this many milliseconds. */ + timeoutMs?: number; +} + +/** The decoded outcome of one spawned process run. */ +export interface OnboardProcessResult { + status: number | null; + signal: NodeJS.Signals | null; + error: Error | undefined; + stdout: string; + stderr: string; + /** stdout and stderr joined with a newline. */ + output: string; +} + +/** Runs `node ` synchronously from the repository root. */ +export function runOnboardProcess( + argv: readonly string[], + options: RunOnboardProcessOptions, +): OnboardProcessResult { + const result = spawnSync(process.execPath, [...argv], { + cwd: options.cwd ?? testRepoRoot, + encoding: "utf-8", + env: options.env, + ...(options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs }), + }); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + return { + status: result.status, + signal: result.signal, + error: result.error, + stdout, + stderr, + output: `${stdout}\n${stderr}`, + }; +} + +/** + * Parses the last stdout line that is a JSON object; scenario scripts print + * their result payload after any incidental logging. Throws with the full + * stdout when no payload line exists. + */ +export function trailingJsonPayload(stdout: string): T { + const line = stdout + .trim() + .split(/\r?\n/) + .reverse() + .find((candidate) => candidate.startsWith("{") && candidate.endsWith("}")); + if (!line) throw new Error(`expected JSON payload in stdout:\n${stdout}`); + return JSON.parse(line) as T; +} diff --git a/test/onboard-gateway-port-conflict-fast-fail.test.ts b/test/onboard-gateway-port-conflict-fast-fail.test.ts index 525e3fcb474..83dfac427d3 100644 --- a/test/onboard-gateway-port-conflict-fast-fail.test.ts +++ b/test/onboard-gateway-port-conflict-fast-fail.test.ts @@ -1,31 +1,32 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createOnboardProcessWorkspace, + type OnboardProcessWorkspace, + runOnboardProcess, + workspaceEnv, +} from "./helpers/onboard-child-process-harness"; import { testTimeoutOptions } from "./helpers/timeouts"; const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); const GATEWAY_PORT = "18080"; describe("onboard gateway port conflict fast-fail (#6752)", () => { - let home: string; - let binDir: string; + let workspace: OnboardProcessWorkspace; let openshellCallLog: string; beforeEach(() => { - home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-6752-")); - binDir = path.join(home, "bin"); - openshellCallLog = path.join(home, "openshell-calls.log"); - fs.mkdirSync(binDir, { recursive: true }); + workspace = createOnboardProcessWorkspace("nemoclaw-6752-"); + openshellCallLog = workspace.path("openshell-calls.log"); for (const component of ["openshell", "openshell-gateway", "openshell-sandbox"]) { - fs.writeFileSync( - path.join(binDir, component), + workspace.writeExecutable( + component, [ "#!/usr/bin/env bash", "# openshell capabilities: request-body-credential-rewrite websocket-credential-rewrite allow_all_known_mcp_methods", @@ -36,23 +37,21 @@ describe("onboard gateway port conflict fast-fail (#6752)", () => { "esac", "exit 1", ].join("\n"), - { mode: 0o755 }, ); } - fs.writeFileSync( - path.join(binDir, "docker"), + workspace.writeExecutable( + "docker", [ "#!/usr/bin/env bash", 'if [ "$1" = info ]; then echo "Server Version: 24.0.0"; exit 0; fi', 'if [ "$1" = ps ]; then exit 0; fi', "exit 0", ].join("\n"), - { mode: 0o755 }, ); - fs.writeFileSync( - path.join(binDir, "lsof"), + workspace.writeExecutable( + "lsof", [ "#!/usr/bin/env bash", 'port=""', @@ -65,40 +64,34 @@ describe("onboard gateway port conflict fast-fail (#6752)", () => { "fi", "exit 1", ].join("\n"), - { mode: 0o755 }, ); }); afterEach(() => { - fs.rmSync(home, { recursive: true, force: true }); + workspace.remove(); }); it( "reports a foreign listener before OpenShell gateway inspection can hang", testTimeoutOptions(10_000), () => { - const result = spawnSync( - process.execPath, + const result = runOnboardProcess( [CLI, "onboard", "--name", "foreign-port", "--no-gpu", "--non-interactive"], { - encoding: "utf-8", - timeout: 5_000, - env: { - ...process.env, - HOME: home, - PATH: `${binDir}:${process.env.PATH || ""}`, + timeoutMs: 5_000, + env: workspaceEnv(workspace, { NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NEMOCLAW_GATEWAY_PORT: GATEWAY_PORT, - NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"), + NEMOCLAW_OPENSHELL_BIN: path.join(workspace.binDir, "openshell"), NEMOCLAW_OPENSHELL_CHANNEL: "stable", - NEMOCLAW_OPENSHELL_GATEWAY_BIN: path.join(binDir, "openshell-gateway"), - NEMOCLAW_OPENSHELL_SANDBOX_BIN: path.join(binDir, "openshell-sandbox"), + NEMOCLAW_OPENSHELL_GATEWAY_BIN: path.join(workspace.binDir, "openshell-gateway"), + NEMOCLAW_OPENSHELL_SANDBOX_BIN: path.join(workspace.binDir, "openshell-sandbox"), NEMOCLAW_TEST_NO_SLEEP: "1", - }, + }), }, ); - const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + const combined = result.output; const calls = fs.existsSync(openshellCallLog) ? fs.readFileSync(openshellCallLog, "utf8") : ""; diff --git a/test/onboard-prepared-gateway-handoff.test.ts b/test/onboard-prepared-gateway-handoff.test.ts index 2c5b1e3e97c..8a49bd53500 100644 --- a/test/onboard-prepared-gateway-handoff.test.ts +++ b/test/onboard-prepared-gateway-handoff.test.ts @@ -2,13 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, it } from "vitest"; +import { + createOnboardProcessWorkspace, + minimalSpawnEnv, + runOnboardProcess, + trailingJsonPayload, +} from "./helpers/onboard-child-process-harness"; + type HandoffScenario = "prepared" | "ordinary" | "mismatch"; type HandoffResult = { @@ -21,8 +26,9 @@ const repoRoot = path.join(import.meta.dirname, ".."); const sourceRequireHook = path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"); function runHandoffScenario(scenario: HandoffScenario): HandoffResult { - const home = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-gateway-handoff-${scenario}-`)); - const scriptPath = path.join(home, "scenario.cjs"); + const workspace = createOnboardProcessWorkspace(`nemoclaw-gateway-handoff-${scenario}-`); + const home = workspace.homeDir; + const scriptPath = workspace.path("scenario.cjs"); const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); const sessionPath = JSON.stringify( path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"), @@ -117,38 +123,16 @@ const { onboard } = require(${onboardPath}); `, ); - const env: NodeJS.ProcessEnv = { - HOME: home, - PATH: process.env.PATH || "/usr/bin:/bin", - NO_COLOR: "1", - }; - Object.assign( - env, - Object.fromEntries( - ["ComSpec", "PATHEXT", "SystemRoot", "WINDIR"] - .map((key) => [key, process.env[key]] as const) - .filter((entry): entry is readonly [string, string] => entry[1] !== undefined), - ), - ); - - const result = spawnSync(process.execPath, ["--require", sourceRequireHook, scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env, - timeout: 15_000, + const result = runOnboardProcess(["--require", sourceRequireHook, scriptPath], { + env: minimalSpawnEnv(home), + timeoutMs: 15_000, }); try { assert.equal(result.status, 0, result.stderr || result.stdout); - const payload = result.stdout - .trim() - .split(/\r?\n/) - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payload, `expected JSON payload in stdout:\n${result.stdout}`); - return JSON.parse(payload) as HandoffResult; + return trailingJsonPayload(result.stdout); } finally { - fs.rmSync(home, { recursive: true, force: true }); + workspace.remove(); } } diff --git a/test/onboard-remote-recreate-credential-reuse.test.ts b/test/onboard-remote-recreate-credential-reuse.test.ts index 27d21c5bed0..d8719a28daa 100644 --- a/test/onboard-remote-recreate-credential-reuse.test.ts +++ b/test/onboard-remote-recreate-credential-reuse.test.ts @@ -2,13 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, it } from "vitest"; +import { + createOnboardProcessWorkspace, + runOnboardProcess, + workspaceEnv, +} from "./helpers/onboard-child-process-harness"; import { testTimeoutOptions } from "./helpers/timeouts"; const REPO_ROOT = path.join(import.meta.dirname, ".."); @@ -18,21 +21,19 @@ describe("onboard recovered remote-provider credential reuse", () => { "re-applies an exact compatible route without exporting or directly validating its gateway credential", testTimeoutOptions(90_000), () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-recreate-")); - const fakeBin = path.join(tmpDir, "bin"); - const home = path.join(tmpDir, "home"); - const scriptPath = path.join(tmpDir, "remote-recreate.cjs"); - const curlLogPath = path.join(tmpDir, "curl-probes.log"); - const openshellLogPath = path.join(tmpDir, "openshell.log"); + const workspace = createOnboardProcessWorkspace("nemoclaw-remote-recreate-", { + separateHome: true, + }); + const scriptPath = workspace.path("remote-recreate.cjs"); + const curlLogPath = workspace.path("curl-probes.log"); + const openshellLogPath = workspace.path("openshell.log"); const onboardPath = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "onboard.ts")); const registryPath = JSON.stringify( path.join(REPO_ROOT, "src", "lib", "state", "registry.ts"), ); - fs.mkdirSync(fakeBin, { recursive: true }); - fs.mkdirSync(home, { recursive: true }); - fs.writeFileSync( - path.join(fakeBin, "openshell"), + workspace.writeExecutable( + "openshell", `#!/usr/bin/env bash printf '%s\\n' "$*" >> "$OPENSHELL_FAKE_COMMAND_LOG" if [ "$1" = "inference" ] && [ "$2" = "get" ]; then @@ -57,15 +58,13 @@ EOF fi exit 0 `, - { mode: 0o755 }, ); - fs.writeFileSync( - path.join(fakeBin, "curl"), + workspace.writeExecutable( + "curl", `#!/usr/bin/env bash printf '%s\\n' "$*" >> "$OPENSHELL_FAKE_CURL_LOG" exit 1 `, - { mode: 0o755 }, ); fs.writeFileSync( scriptPath, @@ -140,26 +139,25 @@ const { setupNim, setupInference } = require(${onboardPath}); `, ); + const scenarioEnv = (overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv => + workspaceEnv(workspace, { + VITEST: "false", + NEMOCLAW_OPENSHELL_BIN: path.join(workspace.binDir, "openshell"), + NEMOCLAW_TEST_NO_SLEEP: "1", + OPENSHELL_FAKE_CURL_LOG: curlLogPath, + OPENSHELL_FAKE_COMMAND_LOG: openshellLogPath, + COMPATIBLE_API_KEY: "", + NVIDIA_INFERENCE_API_KEY: "", + NVIDIA_API_KEY: "", + ...overrides, + }); + try { - const result = spawnSync(process.execPath, [scriptPath], { - cwd: REPO_ROOT, - encoding: "utf8", - env: { - ...process.env, - HOME: home, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - VITEST: "false", - NEMOCLAW_OPENSHELL_BIN: path.join(fakeBin, "openshell"), - NEMOCLAW_TEST_NO_SLEEP: "1", - OPENSHELL_FAKE_CURL_LOG: curlLogPath, - OPENSHELL_FAKE_COMMAND_LOG: openshellLogPath, - COMPATIBLE_API_KEY: "", - NVIDIA_INFERENCE_API_KEY: "", - NVIDIA_API_KEY: "", - }, - timeout: 80_000, + const result = runOnboardProcess([scriptPath], { + env: scenarioEnv(), + timeoutMs: 80_000, }); - const output = `${result.stdout || ""}\n${result.stderr || ""}`; + const output = result.output; assert.equal(result.status, 0, output); assert.match(output, /Reusing existing gateway credential for 'compatible-endpoint'/); @@ -193,27 +191,14 @@ const { setupNim, setupInference } = require(${onboardPath}); assert.ok(!openshellLog.includes("--credential"), openshellLog); fs.writeFileSync(openshellLogPath, ""); - const overrideResult = spawnSync(process.execPath, [scriptPath], { - cwd: REPO_ROOT, - encoding: "utf8", - env: { - ...process.env, - HOME: home, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - VITEST: "false", - NEMOCLAW_OPENSHELL_BIN: path.join(fakeBin, "openshell"), - NEMOCLAW_TEST_NO_SLEEP: "1", + const overrideResult = runOnboardProcess([scriptPath], { + env: scenarioEnv({ NEMOCLAW_TEST_KEEP_MODEL_OVERRIDE: "1", NEMOCLAW_MODEL: "different/model-override", - OPENSHELL_FAKE_CURL_LOG: curlLogPath, - OPENSHELL_FAKE_COMMAND_LOG: openshellLogPath, - COMPATIBLE_API_KEY: "", - NVIDIA_INFERENCE_API_KEY: "", - NVIDIA_API_KEY: "", - }, - timeout: 80_000, + }), + timeoutMs: 80_000, }); - const overrideOutput = `${overrideResult.stdout || ""}\n${overrideResult.stderr || ""}`; + const overrideOutput = overrideResult.output; assert.notEqual(overrideResult.status, 0, overrideOutput); assert.match(overrideOutput, /recovered model is missing or invalid/); const overrideOpenshellLog = fs.readFileSync(openshellLogPath, "utf8"); @@ -223,26 +208,11 @@ const { setupNim, setupInference } = require(${onboardPath}); ); fs.writeFileSync(openshellLogPath, ""); - const unauthorizedResult = spawnSync(process.execPath, [scriptPath], { - cwd: REPO_ROOT, - encoding: "utf8", - env: { - ...process.env, - HOME: home, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - VITEST: "false", - NEMOCLAW_OPENSHELL_BIN: path.join(fakeBin, "openshell"), - NEMOCLAW_TEST_NO_SLEEP: "1", - NEMOCLAW_TEST_OMIT_REUSE_AUTHORIZATION: "1", - OPENSHELL_FAKE_CURL_LOG: curlLogPath, - OPENSHELL_FAKE_COMMAND_LOG: openshellLogPath, - COMPATIBLE_API_KEY: "", - NVIDIA_INFERENCE_API_KEY: "", - NVIDIA_API_KEY: "", - }, - timeout: 80_000, + const unauthorizedResult = runOnboardProcess([scriptPath], { + env: scenarioEnv({ NEMOCLAW_TEST_OMIT_REUSE_AUTHORIZATION: "1" }), + timeoutMs: 80_000, }); - const unauthorizedOutput = `${unauthorizedResult.stdout || ""}\n${unauthorizedResult.stderr || ""}`; + const unauthorizedOutput = unauthorizedResult.output; assert.notEqual(unauthorizedResult.status, 0, unauthorizedOutput); assert.match(unauthorizedOutput, /A host credential is required to configure provider/); const unauthorizedOpenshellLog = fs.readFileSync(openshellLogPath, "utf8"); @@ -253,26 +223,11 @@ const { setupNim, setupInference } = require(${onboardPath}); ); fs.writeFileSync(openshellLogPath, ""); - const conflictingEndpointResult = spawnSync(process.execPath, [scriptPath], { - cwd: REPO_ROOT, - encoding: "utf8", - env: { - ...process.env, - HOME: home, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - VITEST: "false", - NEMOCLAW_OPENSHELL_BIN: path.join(fakeBin, "openshell"), - NEMOCLAW_TEST_NO_SLEEP: "1", - NEMOCLAW_TEST_CONFLICTING_ENDPOINT: "1", - OPENSHELL_FAKE_CURL_LOG: curlLogPath, - OPENSHELL_FAKE_COMMAND_LOG: openshellLogPath, - COMPATIBLE_API_KEY: "", - NVIDIA_INFERENCE_API_KEY: "", - NVIDIA_API_KEY: "", - }, - timeout: 80_000, + const conflictingEndpointResult = runOnboardProcess([scriptPath], { + env: scenarioEnv({ NEMOCLAW_TEST_CONFLICTING_ENDPOINT: "1" }), + timeoutMs: 80_000, }); - const conflictingEndpointOutput = `${conflictingEndpointResult.stdout || ""}\n${conflictingEndpointResult.stderr || ""}`; + const conflictingEndpointOutput = conflictingEndpointResult.output; assert.notEqual(conflictingEndpointResult.status, 0, conflictingEndpointOutput); assert.match( conflictingEndpointOutput, @@ -290,7 +245,7 @@ const { setupNim, setupInference } = require(${onboardPath}); `endpoint drift must fail before provider or route mutation: ${conflictingEndpointOpenshellLog}`, ); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + workspace.remove(); } }, ); diff --git a/test/onboard-reservation-recreate.test.ts b/test/onboard-reservation-recreate.test.ts index beda0935598..c91e8d299c5 100644 --- a/test/onboard-reservation-recreate.test.ts +++ b/test/onboard-reservation-recreate.test.ts @@ -2,11 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, it } from "vitest"; +import { + createOnboardProcessWorkspace, + runOnboardProcess, + trailingJsonPayload, + workspaceEnv, +} from "./helpers/onboard-child-process-harness"; import { writeOkOpenshell } from "./helpers/onboard-openshell-fixture"; const repoRoot = path.join(import.meta.dirname, ".."); @@ -35,9 +39,8 @@ describe("onboard sandbox recreate reservation safety", () => { reservationSessionId, expectedRemoval, }) => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-reservation-survives-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "reservation-survives.js"); + const workspace = createOnboardProcessWorkspace("nemoclaw-onboard-reservation-survives-"); + const scriptPath = workspace.path("reservation-survives.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts")); @@ -45,8 +48,7 @@ describe("onboard sandbox recreate reservation safety", () => { path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"), ); - fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(workspace.binDir); const script = String.raw` const runner = require(${runnerPath}); @@ -132,30 +134,21 @@ const { createSandbox } = require(${onboardPath}); `; fs.writeFileSync(scriptPath, script); - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, + const result = runOnboardProcess([scriptPath], { + env: workspaceEnv(workspace, { NEMOCLAW_NON_INTERACTIVE: "1", NEMOCLAW_TEST_MANAGED_IMAGE_FALLBACK: "1", - }, + }), }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = trailingJsonPayload<{ + sandboxName: string; + events: Array<{ kind: string; cmd?: string; name?: string }>; + }>(result.stdout); assert.equal(payload.sandboxName, "my-assistant"); - const events = payload.events as Array<{ kind: string; cmd?: string; name?: string }>; + const events = payload.events; const removedReservation = events.some( (e) => e.kind === "removeSandbox" && e.name === "my-assistant", ); From 93123b6b4d7abf05371ece4ad659c94bebb30c85 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 7 Aug 2026 02:40:24 -0700 Subject: [PATCH 2/2] test(onboard): clean up reservation workspaces Signed-off-by: Apurv Kumaria --- test/onboard-reservation-recreate.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/onboard-reservation-recreate.test.ts b/test/onboard-reservation-recreate.test.ts index c91e8d299c5..38d87768f54 100644 --- a/test/onboard-reservation-recreate.test.ts +++ b/test/onboard-reservation-recreate.test.ts @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import { describe, it } from "vitest"; +import { describe, it, onTestFinished } from "vitest"; import { createOnboardProcessWorkspace, runOnboardProcess, @@ -40,6 +40,7 @@ describe("onboard sandbox recreate reservation safety", () => { expectedRemoval, }) => { const workspace = createOnboardProcessWorkspace("nemoclaw-onboard-reservation-survives-"); + onTestFinished(() => workspace.remove()); const scriptPath = workspace.path("reservation-survives.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts"));