From 8c4c6eb92146180a83068995ec242505c587b6a9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 12 Aug 2026 01:15:55 -0700 Subject: [PATCH] test(integration): consolidate isolated host process scenarios Consolidate host workspaces, child execution, prompts, and command routing. Reuse the host boundary through installer and onboarding fixtures. Keep scenario inputs and assertions at test call sites. Test/support lines: 829785 -> 829091 (-694). Migrated files: 3906 -> 3358 and 4767 -> 4186. Focused behavioral cases: 163 -> 163. Installer focused tests: 99 passed. Onboarding-selection focused tests: 64 passed. Project, title, type, and repository checks passed. Signed-off-by: Carlos Villela --- ci/test-file-size-budget.json | 4 +- test/helpers/host-process-harness.ts | 205 +++ test/helpers/installer-run-fixture.ts | 149 ++- test/helpers/onboard-child-process-harness.ts | 59 +- test/helpers/onboard-child-runtime.cjs | 40 + test/install-preflight.test.ts | 1080 ++++------------ test/onboard-selection.test.ts | 1107 ++++------------- .../support/onboard-selection-test-helpers.ts | 108 ++ 8 files changed, 1029 insertions(+), 1723 deletions(-) create mode 100644 test/helpers/host-process-harness.ts create mode 100644 test/helpers/onboard-child-runtime.cjs diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index ff12d403408..6cde35279f8 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -6,9 +6,9 @@ "src/lib/inference/nim.test.ts": 2068, "src/lib/onboard/preflight.test.ts": 1875, "test/generate-openclaw-config.test.ts": 1915, - "test/install-preflight.test.ts": 3906, + "test/install-preflight.test.ts": 3358, "test/nemoclaw-start.test.ts": 4790, "test/onboard-messaging.test.ts": 2036, - "test/onboard-selection.test.ts": 4767 + "test/onboard-selection.test.ts": 4186 } } diff --git a/test/helpers/host-process-harness.ts b/test/helpers/host-process-harness.ts new file mode 100644 index 00000000000..2b8530bec75 --- /dev/null +++ b/test/helpers/host-process-harness.ts @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type SpawnSyncOptionsWithStringEncoding, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type HostCommandRoute = { + args?: readonly string[]; + argsPrefix?: readonly string[]; + stdout?: string; + stderr?: string; + exitCode?: number; + repeat?: boolean; +}; + +export type HostCommandRecord = { + command: string; + args: string[]; + environment: Record; + route: number | null; + stdout: string; + stderr: string; + exitCode: number; +}; + +export type HostProcessResult = { + status: number | null; + signal: NodeJS.Signals | null; + error: Error | undefined; + stdout: string; + stderr: string; + output: string; +}; + +export type HostProcessWorkspace = { + root: string; + homeDir: string; + binDir: string; + path: (...segments: string[]) => string; + environment: (overrides?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; + writeExecutable: (name: string, contents: string) => string; + writeCommand: ( + name: string, + routes: readonly HostCommandRoute[], + environmentKeys?: readonly string[], + ) => string; + commandRecords: () => HostCommandRecord[]; + assertCommandRoutesUsed: () => void; + run: ( + command: string, + args: readonly string[], + options?: Omit, + ) => HostProcessResult; + runNodeSource: ( + source: string, + options?: Omit & { name?: string }, + ) => HostProcessResult; + remove: () => void; +}; + +export type HostProcessWorkspaceOptions = { + separateHome?: boolean; +}; + +function decodedResult(result: ReturnType): HostProcessResult { + const stdout = + typeof result.stdout === "string" ? result.stdout : (result.stdout?.toString() ?? ""); + const stderr = + typeof result.stderr === "string" ? result.stderr : (result.stderr?.toString() ?? ""); + return { + status: result.status, + signal: result.signal, + error: result.error, + stdout, + stderr, + output: `${stdout}\n${stderr}`, + }; +} + +function commandSource( + name: string, + routes: readonly HostCommandRoute[], + recordPath: string, + environmentKeys: readonly string[], +): string { + return `#!${process.execPath} +const fs = require("node:fs"); +const routes = ${JSON.stringify(routes)}; +const recordPath = ${JSON.stringify(recordPath)}; +const argv = process.argv.slice(2); +const previous = fs.existsSync(recordPath) + ? fs.readFileSync(recordPath, "utf8").trim().split("\\n").filter(Boolean).map(JSON.parse) + : []; +const used = new Set(previous.filter((entry) => entry.command === ${JSON.stringify(name)} && entry.route !== null).map((entry) => entry.route)); +const route = routes.findIndex((candidate, index) => + (candidate.repeat || !used.has(index)) && + (candidate.args + ? candidate.args.length === argv.length && candidate.args.every((arg, argIndex) => arg === argv[argIndex]) + : candidate.argsPrefix && candidate.argsPrefix.every((arg, argIndex) => arg === argv[argIndex])) +); +const selected = route === -1 + ? { stdout: "", stderr: "unmatched ${name} command: " + argv.join(" ") + "\\n", exitCode: 97 } + : routes[route]; +const record = { + command: ${JSON.stringify(name)}, + args: argv, + environment: Object.fromEntries(${JSON.stringify(environmentKeys)}.map((key) => [key, process.env[key] ?? null])), + route: route === -1 ? null : route, + stdout: selected.stdout || "", + stderr: selected.stderr || "", + exitCode: selected.exitCode ?? 0, +}; +fs.appendFileSync(recordPath, JSON.stringify(record) + "\\n"); +if (record.stdout) process.stdout.write(record.stdout); +if (record.stderr) process.stderr.write(record.stderr); +process.exit(record.exitCode); +`; +} + +export function createHostProcessWorkspace( + prefix: string, + options: HostProcessWorkspaceOptions = {}, +): HostProcessWorkspace { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const binDir = path.join(root, "bin"); + const homeDir = options.separateHome ? path.join(root, "home") : root; + const recordPath = path.join(root, "host-commands.jsonl"); + const configuredRoutes = new Map(); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); + + const writeExecutable = (name: string, contents: string): string => { + const target = path.join(binDir, name); + fs.writeFileSync(target, contents, { mode: 0o755 }); + return target; + }; + const commandRecords = (): HostCommandRecord[] => { + if (!fs.existsSync(recordPath)) return []; + return fs + .readFileSync(recordPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as HostCommandRecord); + }; + const environment = (overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv => ({ + ...process.env, + HOME: homeDir, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + ...overrides, + }); + const run = ( + command: string, + args: readonly string[], + runOptions: Omit = {}, + ): HostProcessResult => + decodedResult(spawnSync(command, [...args], { ...runOptions, encoding: "utf8" })); + + return { + root, + homeDir, + binDir, + path: (...segments) => path.join(root, ...segments), + environment, + writeExecutable, + writeCommand: (name, routes, environmentKeys = []) => { + configuredRoutes.set(name, routes); + return writeExecutable(name, commandSource(name, routes, recordPath, environmentKeys)); + }, + commandRecords, + assertCommandRoutesUsed: () => { + const records = commandRecords(); + const unused: string[] = []; + for (const [name, routes] of configuredRoutes) { + const used = new Set( + records.filter((record) => record.command === name).map((record) => record.route), + ); + routes.forEach((route, index) => { + if (!route.repeat && !used.has(index)) unused.push(`${name}[${index}]`); + }); + } + if (unused.length > 0) throw new Error(`unused host command routes: ${unused.join(", ")}`); + }, + run, + runNodeSource: (source, runOptions = {}) => { + const { name = "scenario.cjs", ...spawnOptions } = runOptions; + const scriptPath = path.join(root, name); + fs.writeFileSync(scriptPath, source); + return run(process.execPath, [scriptPath], spawnOptions); + }, + remove: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} + +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/helpers/installer-run-fixture.ts b/test/helpers/installer-run-fixture.ts index f83720afd69..b296cfb675d 100644 --- a/test/helpers/installer-run-fixture.ts +++ b/test/helpers/installer-run-fixture.ts @@ -1,11 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { + createHostProcessWorkspace, + type HostCommandRecord, + type HostCommandRoute, + type HostProcessResult, +} from "./host-process-harness"; import { INSTALLER_PAYLOAD, TEST_SYSTEM_PATH, writeExecutable } from "./installer-sourced-env"; /** @@ -30,30 +36,51 @@ export interface InstallerCheckout { writeExecutable: (name: string, contents: string) => string; /** Resolves a path under the checkout root. */ path: (...segments: string[]) => string; + /** Composes the inherited environment with this HOME, fake PATH, and npm prefix. */ + environment: (overrides?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; + /** Writes an ordered, fail-on-unmatched command route set into binDir. */ + writeCommand: ( + name: string, + routes: readonly HostCommandRoute[], + environmentKeys?: readonly string[], + ) => string; + /** Returns fake-command argument, environment, output, and exit records. */ + commandRecords: () => HostCommandRecord[]; + /** Fails when a configured non-repeating route was not used. */ + assertCommandRoutesUsed: () => void; + /** Runs a process with decoded output. */ + run: ( + command: string, + args: readonly string[], + options?: Parameters["run"]>[2], + ) => HostProcessResult; /** Removes the whole checkout. */ remove: () => void; } /** Creates a fresh installer checkout with created bin and prefix/bin dirs. */ export function createInstallerCheckout(prefix: string): InstallerCheckout { - const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - const binDir = path.join(root, "bin"); + const workspace = createHostProcessWorkspace(prefix); + const { root, binDir } = workspace; const prefixDir = path.join(root, "prefix"); - fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(path.join(prefixDir, "bin"), { recursive: true }); return { root, binDir, prefixDir, - writeExecutable: (name, contents) => { - const target = path.join(binDir, name); - writeExecutable(target, contents); - return target; - }, - path: (...segments) => path.join(root, ...segments), - remove: () => { - fs.rmSync(root, { recursive: true, force: true }); - }, + writeExecutable: workspace.writeExecutable, + path: workspace.path, + environment: (overrides = {}) => + workspace.environment({ + PATH: `${binDir}:${TEST_SYSTEM_PATH}`, + NPM_PREFIX: prefixDir, + ...overrides, + }), + writeCommand: workspace.writeCommand, + commandRecords: workspace.commandRecords, + assertCommandRoutesUsed: workspace.assertCommandRoutesUsed, + run: workspace.run, + remove: workspace.remove, }; } @@ -133,6 +160,17 @@ export interface NpmStubOptions { handleCi?: boolean; } +export type SourceCheckoutNpmStubOptions = { + commandLog?: boolean; + onboardLog?: boolean; + rewriteRootLockfile?: boolean; +}; + +export type InstallerLinkNpmStubOptions = { + cliVersion?: string; + createCli: boolean; +}; + /** * Writes an npm stub that reports a fixed version, resolves the prefix from * NPM_PREFIX, runs installSnippet for install-family commands, and fails @@ -156,3 +194,88 @@ fi echo "unexpected npm invocation: $*" >&2; exit 98`, ); } + +/** Writes the npm routes used by a source-checkout install that links a runnable CLI. */ +export function writeSourceCheckoutNpmStub( + fakeBin: string, + options: SourceCheckoutNpmStubOptions = {}, +): void { + const commandLog = options.commandLog ? `printf '%s\\n' "$*" >> "$NPM_LOG_PATH"\n` : ""; + const rewriteLockfile = options.rewriteRootLockfile + ? `printf '{"rewritten":true}\\n' > package-lock.json; ` + : ""; + const onboard = options.onboardLog + ? `printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG"` + : `if [ "$1" = "onboard" ]; then exit 0; fi`; + writeNpmStub(fakeBin, { + installSnippet: `${commandLog}if [ "$1" = "pack" ]; then + tmpdir="$4" + mkdir -p "$tmpdir/package" + tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package + exit 0 +fi +if [ "$1" = "install" ]; then ${rewriteLockfile}exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi +${onboard} +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + handleCi: true, + }); +} + +/** Writes the package files that make a temporary root a source checkout. */ +export function writeSourceCheckoutPackages(root: string): void { + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), + ); + fs.mkdirSync(path.join(root, "nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(root, "nemoclaw", "package.json"), + JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), + ); +} + +/** Writes npm routes for an installer payload that links or intentionally omits the CLI. */ +export function writeInstallerLinkNpmStub( + fakeBin: string, + { cliVersion = "0.1.0-test", createCli }: InstallerLinkNpmStubOptions, +): void { + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = "--version" ]; then echo "10.9.2"; exit 0; fi +if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then + echo "$NPM_PREFIX" + exit 0 +fi +if [ "$1" = "pack" ]; then exit 1; fi +if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then exit 0; fi +if [ "$1" = "run" ] || [ "$1" = "uninstall" ]; then exit 0; fi +if [ "$1" = "link" ]; then + ${ + createCli + ? `cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "onboard" ]; then exit 0; fi +if [ "$1" = "--version" ]; then echo "nemoclaw v${cliVersion}"; exit 0; fi +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw"` + : ":" + } + exit 0 +fi +echo "unexpected npm invocation: $*" >&2 +exit 98 +`, + ); +} diff --git a/test/helpers/onboard-child-process-harness.ts b/test/helpers/onboard-child-process-harness.ts index 72a860b7ad8..15452e274b8 100644 --- a/test/helpers/onboard-child-process-harness.ts +++ b/test/helpers/onboard-child-process-harness.ts @@ -2,9 +2,12 @@ // 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 { + createHostProcessWorkspace, + type HostProcessWorkspace, + trailingJsonPayload, +} from "./host-process-harness"; /** * Child-process setup mechanics for onboarding suites that spawn the CLI or a @@ -19,20 +22,7 @@ import path from "node:path"; 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; -} +export type OnboardProcessWorkspace = HostProcessWorkspace; /** Creation options for createOnboardProcessWorkspace. */ export interface OnboardProcessWorkspaceOptions { @@ -45,25 +35,7 @@ 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 }); - }, - }; + return createHostProcessWorkspace(prefix, options); } /** @@ -74,12 +46,7 @@ export function workspaceEnv( workspace: OnboardProcessWorkspace, overrides?: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv { - return { - ...process.env, - HOME: workspace.homeDir, - PATH: `${workspace.binDir}:${process.env.PATH || ""}`, - ...overrides, - }; + return workspace.environment(overrides); } /** @@ -147,12 +114,4 @@ export function runOnboardProcess( * 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; -} +export { trailingJsonPayload }; diff --git a/test/helpers/onboard-child-runtime.cjs b/test/helpers/onboard-child-runtime.cjs new file mode 100644 index 00000000000..5bddc90af93 --- /dev/null +++ b/test/helpers/onboard-child-runtime.cjs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +function installPromptQueue(target, configuredAnswers) { + const answers = [...configuredAnswers]; + const messages = []; + const prompts = []; + target.prompt = async (message, options = {}) => { + messages.push(message); + prompts.push({ message, secret: options.secret === true }); + return answers.shift() ?? ""; + }; + return { answers, messages, prompts }; +} + +async function captureChildConsole(run) { + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + return { value: await run(), lines }; + } finally { + console.log = originalLog; + console.error = originalError; + } +} + +function reportChildScenario(run) { + const hostLog = console.log; + captureChildConsole(run) + .then(({ value, lines }) => hostLog(JSON.stringify({ ...value, lines }))) + .catch((error) => { + console.error(error instanceof Error ? error.stack : String(error)); + process.exitCode = 1; + }); +} + +module.exports = { installPromptQueue, reportChildScenario }; diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index d6b7058f56f..8f0fcc5b5c9 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -5,14 +5,21 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; import { runStorageRemediationInstallerPreflight, writeFailedOnboardSession, writeInstallerReadinessModuleStubs, writeNodeStub, } from "./helpers/installer-readiness-stubs"; -import { writeNpmStub } from "./helpers/installer-run-fixture"; +import { + createInstallerCheckout, + type InstallerCheckout, + writeInstallerLinkNpmStub, + writeNpmStub, + writeSourceCheckoutNpmStub, + writeSourceCheckoutPackages, +} from "./helpers/installer-run-fixture"; import { INSTALLER_PAYLOAD, readShellConstant, @@ -27,14 +34,18 @@ const GITHUB_INSTALL_URL = "git+https://github.com/NVIDIA/NemoClaw.git"; const INSTALLER_ONBOARD_MODULE_DIR = path.join("dist", "lib", "onboard"); const INSTALLER_READINESS_MODULE_DIR = path.join("dist", "lib", "readiness"); +function installerCheckout(prefix: string): InstallerCheckout { + const checkout = createInstallerCheckout(prefix); + onTestFinished(() => checkout.remove()); + return checkout; +} + /** Minimal npm stub with an injectable install/link/run handler. */ function runFailedSessionPromptChoice(answer: string) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-failed-choice-")); - const fakeBin = path.join(tmp, "bin"); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-install-failed-choice-"); const onboardLog = path.join(tmp, "onboard.log"); const promptInput = path.join(tmp, "prompt-input.txt"); - fs.mkdirSync(fakeBin); writeFailedOnboardSession(tmp); fs.writeFileSync(promptInput, answer); writeNodeStub(fakeBin); @@ -92,56 +103,70 @@ run_onboard < "$PROMPT_INPUT_FILE" describe("installer runtime preflight", { timeout: 90_000 }, () => { it("attempts nvm upgrade when system Node.js is below minimum version", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-preflight-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); - - writeExecutable( - path.join(fakeBin, "node"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "v18.19.1" - exit 0 -fi -echo "unexpected node invocation: $*" >&2 -exit 99 -`, - ); - - writeExecutable( - path.join(fakeBin, "npm"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "9.8.1" - exit 0 -fi -echo "unexpected npm invocation: $*" >&2 -exit 98 -`, - ); - - // Fake curl that fails — prevents real nvm download and keeps the test fast. - writeExecutable( - path.join(fakeBin, "curl"), - `#!/usr/bin/env bash -exit 1 -`, + const checkout = installerCheckout("nemoclaw-install-preflight-"); + checkout.writeCommand("node", [{ args: ["--version"], stdout: "v18.19.1\n" }], ["HOME"]); + checkout.writeCommand("npm", [{ args: ["--version"], stdout: "9.8.1\n" }], ["HOME"]); + // Failing the download keeps the test on the nvm upgrade error path. + checkout.writeCommand( + "curl", + [ + { + argsPrefix: [ + "-fsSL", + "https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh", + "-o", + ], + exitCode: 1, + }, + ], + ["HOME"], ); - const result = spawnSync("bash", [INSTALLER], { + const result = checkout.run("bash", [INSTALLER], { cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: { - ...process.env, - HOME: tmp, - PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + env: checkout.environment({ // Bypass the #2671 fail-fast license gate — this test exercises the // Node-version-detection / nvm-upgrade path, not the license path. NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", - }, + }), }); - const output = `${result.stdout}${result.stderr}`; + checkout.assertCommandRoutesUsed(); + expect(checkout.commandRecords()).toEqual([ + { + command: "node", + args: ["--version"], + environment: { HOME: checkout.root }, + route: 0, + stdout: "v18.19.1\n", + stderr: "", + exitCode: 0, + }, + { + command: "npm", + args: ["--version"], + environment: { HOME: checkout.root }, + route: 0, + stdout: "9.8.1\n", + stderr: "", + exitCode: 0, + }, + { + command: "curl", + args: [ + "-fsSL", + "https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh", + "-o", + expect.any(String), + ], + environment: { HOME: checkout.root }, + route: 0, + stdout: "", + stderr: "", + exitCode: 1, + }, + ]); + const { output } = result; expect(result.status).not.toBe(0); expect(output).toMatch(/v18\.19\.1.*found but NemoClaw requires/); expect(output).toMatch(/upgrading via nvm/); @@ -149,12 +174,12 @@ exit 1 }); it("treats the installer script's checkout as the source root even when cwd is elsewhere", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-fallback-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-fallback-"); const gitLog = path.join(tmp, "git.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeExecutable( path.join(fakeBin, "node"), @@ -192,49 +217,7 @@ exit 0 `, ); - writeExecutable( - path.join(fakeBin, "npm"), - `#!/usr/bin/env bash -set -euo pipefail -if [ "$1" = "--version" ]; then - echo "10.9.2" - exit 0 -fi -if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then - echo "$NPM_PREFIX" - exit 0 -fi -if [ "$1" = "pack" ]; then - exit 1 -fi -if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then - exit 0 -fi -if [ "$1" = "run" ]; then - exit 0 -fi -if [ "$1" = "uninstall" ]; then - exit 0 -fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "onboard" ]; then - exit 0 -fi -if [ "$1" = "--version" ]; then - echo "nemoclaw v0.1.0-test" - exit 0 -fi -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi -echo "unexpected npm invocation: $*" >&2 -exit 98 -`, - ); + writeInstallerLinkNpmStub(fakeBin, { createCli: true, cliVersion: "0.1.0-test" }); const result = spawnSync("bash", [INSTALLER], { cwd: tmp, @@ -258,11 +241,11 @@ exit 98 }, 60_000); it("prints the HTTPS GitHub remediation when the binary is missing", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-remediation-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-remediation-"); writeExecutable( path.join(fakeBin, "node"), @@ -296,37 +279,7 @@ exit 0 `, ); - writeExecutable( - path.join(fakeBin, "npm"), - `#!/usr/bin/env bash -set -euo pipefail -if [ "$1" = "--version" ]; then - echo "10.9.2" - exit 0 -fi -if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then - echo "$NPM_PREFIX" - exit 0 -fi -if [ "$1" = "pack" ]; then - exit 1 -fi -if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then - exit 0 -fi -if [ "$1" = "run" ]; then - exit 0 -fi -if [ "$1" = "uninstall" ]; then - exit 0 -fi -if [ "$1" = "link" ]; then - exit 0 -fi -echo "unexpected npm invocation: $*" >&2 -exit 98 -`, - ); + writeInstallerLinkNpmStub(fakeBin, { createCli: false }); const result = spawnSync("bash", [INSTALLER], { cwd: tmp, @@ -362,7 +315,7 @@ exit 98 it("scripts/install.sh --help works when run directly outside a repo checkout", () => { const scriptContents = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-payload-stdin-")); + const { root: tmp } = installerCheckout("nemoclaw-installer-payload-stdin-"); const stagedFixturePath = `/tmp/nemoclaw-installer-${path.basename(tmp).slice(-6)}`; try { fs.writeFileSync(stagedFixturePath, scriptContents, { flag: "wx", mode: 0o600 }); @@ -471,15 +424,15 @@ exit 98 expect(output).not.toMatch(/0\.1\.0/); }); it("preserves the sandbox payload lockfile with npm ci (#3798)", { timeout: 20000 }, () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-source-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-source-"); const npmLog = path.join(tmp, "npm.log"); const pythonLog = path.join(tmp, "python.log"); const gitLog = path.join(tmp, "git.log"); - fs.mkdirSync(fakeBin); fs.mkdirSync(path.join(tmp, ".git")); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeNodeStub(fakeBin); writeDockerOkStub(fakeBin); @@ -505,38 +458,9 @@ printf 'pip3 %s\\n' "$*" >> "$PYTHON_LOG_PATH" exit 89 `, ); - writeNpmStub(fakeBin, { - installSnippet: `printf '%s\\n' "$*" >> "$NPM_LOG_PATH" -if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then printf '{"rewritten":true}\n' > package-lock.json; exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -if [ "$1" = "onboard" ]; then exit 0; fi -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); + writeSourceCheckoutNpmStub(fakeBin, { commandLog: true, rewriteRootLockfile: true }); - fs.writeFileSync( - path.join(tmp, "package.json"), - JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), - ); - fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); - fs.writeFileSync( - path.join(tmp, "nemoclaw", "package.json"), - JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), - ); + writeSourceCheckoutPackages(tmp); const payloadLockPath = path.join(tmp, "nemoclaw", "package-lock.json"); fs.writeFileSync(payloadLockPath, "payload lock sentinel\n"); fs.mkdirSync(path.join(tmp, "nemoclaw-blueprint", "router", "llm-router"), { @@ -580,49 +504,20 @@ fi`, it("source-checkout: installs OpenShell when missing from PATH (#3989)", { timeout: 20000, }, () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-source-osh-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-source-osh-"); const npmLog = path.join(tmp, "npm.log"); const openshellLog = path.join(tmp, "install-openshell.log"); - fs.mkdirSync(fakeBin); fs.mkdirSync(path.join(tmp, ".git")); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeNodeStub(fakeBin); writeDockerOkStub(fakeBin); - writeNpmStub(fakeBin, { - installSnippet: `printf '%s\\n' "$*" >> "$NPM_LOG_PATH" -if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -if [ "$1" = "onboard" ]; then exit 0; fi -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); + writeSourceCheckoutNpmStub(fakeBin, { commandLog: true }); - fs.writeFileSync( - path.join(tmp, "package.json"), - JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), - ); - fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); - fs.writeFileSync( - path.join(tmp, "nemoclaw", "package.json"), - JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), - ); + writeSourceCheckoutPackages(tmp); fs.mkdirSync(path.join(tmp, "scripts"), { recursive: true }); writeExecutable( @@ -660,14 +555,14 @@ exit 0 it("source-checkout: skips OpenShell install when openshell is already on PATH (#3989)", { timeout: 20000, }, () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-source-osh-skip-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-source-osh-skip-"); const npmLog = path.join(tmp, "npm.log"); const openshellLog = path.join(tmp, "install-openshell.log"); - fs.mkdirSync(fakeBin); fs.mkdirSync(path.join(tmp, ".git")); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeNodeStub(fakeBin); writeExecutable( @@ -677,38 +572,9 @@ if [ "$1" = "--version" ]; then echo "openshell 0.0.39"; exit 0; fi exit 0 `, ); - writeNpmStub(fakeBin, { - installSnippet: `printf '%s\\n' "$*" >> "$NPM_LOG_PATH" -if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -if [ "$1" = "onboard" ]; then exit 0; fi -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); + writeSourceCheckoutNpmStub(fakeBin, { commandLog: true }); - fs.writeFileSync( - path.join(tmp, "package.json"), - JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), - ); - fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); - fs.writeFileSync( - path.join(tmp, "nemoclaw", "package.json"), - JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), - ); + writeSourceCheckoutPackages(tmp); fs.mkdirSync(path.join(tmp, "scripts"), { recursive: true }); writeExecutable( @@ -743,12 +609,12 @@ exit 0 }); it("auto-resumes an interrupted onboarding session after Ubuntu 26.04 installer preflight (#3245)", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-resume-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-resume-"); const onboardLog = path.join(tmp, "onboard.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); fs.mkdirSync(path.join(tmp, ".nemoclaw"), { recursive: true }); fs.writeFileSync( @@ -767,47 +633,10 @@ fi exit 0 `, ); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "openshell 0.0.22" - exit 0 -fi -exit 0 -`, - ); - writeNpmStub(fakeBin, { - installSnippet: `if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); + writeOpenShellOkStub(fakeBin, "0.0.22"); + writeSourceCheckoutNpmStub(fakeBin, { onboardLog: true }); - fs.writeFileSync( - path.join(tmp, "package.json"), - JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), - ); - fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); - fs.writeFileSync( - path.join(tmp, "nemoclaw", "package.json"), - JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), - ); + writeSourceCheckoutPackages(tmp); const result = spawnSync("bash", [INSTALLER], { cwd: tmp, @@ -837,12 +666,12 @@ fi`, // choice at step 3 (no way to pick a different provider). In // non-interactive mode there is no safe default, so we refuse instead. it("refuses to auto-resume a failed onboarding session in non-interactive mode (#2430)", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-failed-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-failed-"); const onboardLog = path.join(tmp, "onboard.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); fs.mkdirSync(path.join(tmp, ".nemoclaw"), { recursive: true }); fs.writeFileSync( @@ -859,57 +688,11 @@ fi`, ); writeNodeStub(fakeBin); - writeExecutable( - path.join(fakeBin, "docker"), - `#!/usr/bin/env bash -if [ "$1" = "info" ]; then - echo '{"ServerVersion":"29.3.1","Name":"Docker Desktop","OperatingSystem":"Ubuntu 24.04","CgroupVersion":"2"}' - exit 0 -fi -exit 0 -`, - ); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "openshell 0.0.22" - exit 0 -fi -exit 0 -`, - ); - writeNpmStub(fakeBin, { - installSnippet: `if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); + writeDockerOkStub(fakeBin); + writeOpenShellOkStub(fakeBin, "0.0.22"); + writeSourceCheckoutNpmStub(fakeBin, { onboardLog: true }); - fs.writeFileSync( - path.join(tmp, "package.json"), - JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), - ); - fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); - fs.writeFileSync( - path.join(tmp, "nemoclaw", "package.json"), - JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), - ); + writeSourceCheckoutPackages(tmp); const result = spawnSync("bash", [INSTALLER], { cwd: tmp, @@ -940,84 +723,38 @@ fi`, const { result, onboardLog } = runFailedSessionPromptChoice(testCase.answer); const output = `${result.stdout}${result.stderr}`; - expect(result.status, output).toBe(0); - expect(output).toMatch(/Previous onboarding session failed/); - const log = fs.readFileSync(onboardLog, "utf-8"); - expect(log).toMatch(new RegExp(`^${testCase.expectedArgs}$`, "m")); - expect(log).not.toMatch(testCase.unexpectedFlag); - }); - - // #2430: --fresh is the escape hatch. Even with a session file on disk - // (failed or otherwise), the installer should skip the auto-resume check - // and let the onboard command create a new session. - it("skips auto-resume with --fresh regardless of session state (#2430)", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-fresh-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); - const onboardLog = path.join(tmp, "onboard.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); - fs.mkdirSync(path.join(tmp, ".nemoclaw"), { recursive: true }); - - // A session that WOULD auto-resume (status=in_progress) without --fresh. - fs.writeFileSync( - path.join(tmp, ".nemoclaw", "onboard-session.json"), - JSON.stringify({ resumable: true, status: "in_progress" }, null, 2), - ); - - writeNodeStub(fakeBin); - writeExecutable( - path.join(fakeBin, "docker"), - `#!/usr/bin/env bash -if [ "$1" = "info" ]; then - echo '{"ServerVersion":"29.3.1","Name":"Docker Desktop","OperatingSystem":"Ubuntu 24.04","CgroupVersion":"2"}' - exit 0 -fi -exit 0 -`, - ); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "openshell 0.0.22" - exit 0 -fi -exit 0 -`, - ); - writeNpmStub(fakeBin, { - installSnippet: `if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); - - fs.writeFileSync( - path.join(tmp, "package.json"), - JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), - ); - fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); + expect(result.status, output).toBe(0); + expect(output).toMatch(/Previous onboarding session failed/); + const log = fs.readFileSync(onboardLog, "utf-8"); + expect(log).toMatch(new RegExp(`^${testCase.expectedArgs}$`, "m")); + expect(log).not.toMatch(testCase.unexpectedFlag); + }); + + // #2430: --fresh is the escape hatch. Even with a session file on disk + // (failed or otherwise), the installer should skip the auto-resume check + // and let the onboard command create a new session. + it("skips auto-resume with --fresh regardless of session state (#2430)", () => { + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-fresh-"); + const onboardLog = path.join(tmp, "onboard.log"); + fs.mkdirSync(path.join(tmp, ".nemoclaw"), { recursive: true }); + + // A session that WOULD auto-resume (status=in_progress) without --fresh. fs.writeFileSync( - path.join(tmp, "nemoclaw", "package.json"), - JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), + path.join(tmp, ".nemoclaw", "onboard-session.json"), + JSON.stringify({ resumable: true, status: "in_progress" }, null, 2), ); + writeNodeStub(fakeBin); + writeDockerOkStub(fakeBin); + writeOpenShellOkStub(fakeBin, "0.0.22"); + writeSourceCheckoutNpmStub(fakeBin, { onboardLog: true }); + + writeSourceCheckoutPackages(tmp); + const result = spawnSync("bash", [INSTALLER, "--fresh"], { cwd: tmp, encoding: "utf-8", @@ -1047,24 +784,15 @@ fi`, }); it("fails non-interactive install when shared host preflight detects Docker is missing", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-missing-docker-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-missing-docker-"); const onboardLog = path.join(tmp, "onboard.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeNodeStub(fakeBin); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "openshell 0.0.22" - exit 0 -fi -exit 0 -`, - ); + writeOpenShellOkStub(fakeBin, "0.0.22"); writeExecutable( path.join(fakeBin, "docker"), `#!/usr/bin/env bash @@ -1091,27 +819,7 @@ if [ "$1" = "is-enabled" ] && [ "$2" = "docker" ]; then echo "disabled"; exit 1; exit 0 `, ); - writeNpmStub(fakeBin, { - installSnippet: `if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); + writeSourceCheckoutNpmStub(fakeBin, { onboardLog: true }); const result = spawnSync("bash", [INSTALLER], { cwd: path.join(import.meta.dirname, ".."), @@ -1172,14 +880,12 @@ fi`, stale?: boolean; toolkitInstalled?: boolean; }) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-cdi-repair-")); - const fakeBin = path.join(tmp, "bin"); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-install-cdi-repair-"); const sourceRoot = path.join(tmp, "source"); const cdiDir = path.join(tmp, "cdi"); const cdiState = path.join(tmp, "cdi-generated"); const sudoLog = path.join(tmp, "sudo.log"); const systemctlLog = path.join(tmp, "systemctl.log"); - fs.mkdirSync(fakeBin); fs.mkdirSync(path.join(sourceRoot, "dist", "lib", "onboard"), { recursive: true }); fs.writeFileSync( @@ -1447,45 +1153,16 @@ exit 0 }); it("rejects Podman through canonical installer admission (#7411)", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-podman-warning-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-podman-warning-"); const onboardLog = path.join(tmp, "onboard.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeNodeStub(fakeBin); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "openshell 0.0.22" - exit 0 -fi -exit 0 -`, - ); - writeNpmStub(fakeBin, { - installSnippet: `if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); + writeOpenShellOkStub(fakeBin, "0.0.22"); + writeSourceCheckoutNpmStub(fakeBin, { onboardLog: true }); writeExecutable( path.join(fakeBin, "docker"), `#!/usr/bin/env bash @@ -1523,55 +1200,17 @@ exit 0 }); it("requires explicit terms acceptance in non-interactive install mode", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-terms-required-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-terms-required-"); const onboardLog = path.join(tmp, "onboard.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeNodeStub(fakeBin); - writeExecutable( - path.join(fakeBin, "docker"), - `#!/usr/bin/env bash -if [ "$1" = "info" ]; then - echo '{"ServerVersion":"29.3.1","Name":"Docker Desktop","OperatingSystem":"Ubuntu 24.04","CgroupVersion":"2"}' - exit 0 -fi -exit 0 -`, - ); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "openshell 0.0.22" - exit 0 -fi -exit 0 -`, - ); - writeNpmStub(fakeBin, { - installSnippet: `if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); + writeDockerOkStub(fakeBin); + writeOpenShellOkStub(fakeBin, "0.0.22"); + writeSourceCheckoutNpmStub(fakeBin, { onboardLog: true }); const result = spawnSync("bash", [INSTALLER, "--non-interactive"], { cwd: path.join(import.meta.dirname, ".."), @@ -1594,55 +1233,17 @@ fi`, }); it("passes the acceptance flag through to non-interactive onboard", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-terms-accept-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-terms-accept-"); const onboardLog = path.join(tmp, "onboard.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeNodeStub(fakeBin); - writeExecutable( - path.join(fakeBin, "docker"), - `#!/usr/bin/env bash -if [ "$1" = "info" ]; then - echo '{"ServerVersion":"29.3.1","Name":"Docker Desktop","OperatingSystem":"Ubuntu 24.04","CgroupVersion":"2"}' - exit 0 -fi -exit 0 -`, - ); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "openshell 0.0.22" - exit 0 -fi -exit 0 -`, - ); - writeNpmStub(fakeBin, { - installSnippet: `if [ "$1" = "pack" ]; then - tmpdir="$4" - mkdir -p "$tmpdir/package" - tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package - exit 0 -fi -if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi`, - handleCi: true, - }); + writeDockerOkStub(fakeBin); + writeOpenShellOkStub(fakeBin, "0.0.22"); + writeSourceCheckoutNpmStub(fakeBin, { onboardLog: true }); const result = spawnSync( "bash", @@ -1667,11 +1268,11 @@ fi`, }); it("spin() non-TTY: dumps wrapped-command output and exits non-zero on failure", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-spin-fail-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-spin-fail-"); writeNodeStub(fakeBin); writeExecutable( @@ -1720,11 +1321,11 @@ fi`, }); it("creates a user-local shim when npm installs outside the current PATH", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-shim-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-shim-"); fs.mkdirSync(path.join(tmp, ".local"), { recursive: true }); writeExecutable( @@ -1772,49 +1373,7 @@ exit 0 `, ); - writeExecutable( - path.join(fakeBin, "npm"), - `#!/usr/bin/env bash -set -euo pipefail -if [ "$1" = "--version" ]; then - echo "10.9.2" - exit 0 -fi -if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then - echo "$NPM_PREFIX" - exit 0 -fi -if [ "$1" = "pack" ]; then - exit 1 -fi -if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then - exit 0 -fi -if [ "$1" = "run" ]; then - exit 0 -fi -if [ "$1" = "uninstall" ]; then - exit 0 -fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "onboard" ]; then - exit 0 -fi -if [ "$1" = "--version" ]; then - echo "nemoclaw v0.1.0-test" - exit 0 -fi -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi -echo "unexpected npm invocation: $*" >&2 -exit 98 -`, - ); + writeInstallerLinkNpmStub(fakeBin, { createCli: true, cliVersion: "0.1.0-test" }); writeExecutable( path.join(fakeBin, "docker"), @@ -1860,12 +1419,13 @@ exit 0 }); it("preserves ready output when nemoclaw is already resolvable after install", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-ready-shell-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-ready-shell-"); const prefixBin = path.join(prefix, "bin"); const nvmDir = path.join(tmp, ".nvm"); - fs.mkdirSync(fakeBin); fs.mkdirSync(prefixBin, { recursive: true }); fs.mkdirSync(nvmDir, { recursive: true }); fs.writeFileSync(path.join(nvmDir, "nvm.sh"), "# stub nvm\n"); @@ -1904,44 +1464,7 @@ exit 0 `, ); - writeExecutable( - path.join(fakeBin, "npm"), - `#!/usr/bin/env bash -set -euo pipefail -if [ "$1" = "--version" ]; then - echo "10.9.2" - exit 0 -fi -if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then - echo "$NPM_PREFIX" - exit 0 -fi -if [ "$1" = "pack" ]; then - exit 1 -fi -if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then - exit 0 -fi -if [ "$1" = "run" ]; then - exit 0 -fi -if [ "$1" = "uninstall" ]; then - exit 0 -fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi -if [ "$1" = "onboard" ]; then exit 0; fi -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi -echo "unexpected npm invocation: $*" >&2 -exit 98 -`, - ); + writeInstallerLinkNpmStub(fakeBin, { createCli: true, cliVersion: "0.1.0-test" }); writeExecutable( path.join(fakeBin, "docker"), @@ -1989,36 +1512,18 @@ exit 0 }); it("makes current-shell PATH refresh obvious when the installer added the bin dir", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-reload-hint-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-reload-hint-"); const nvmDir = path.join(tmp, ".nvm"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); fs.mkdirSync(nvmDir, { recursive: true }); fs.writeFileSync(path.join(nvmDir, "nvm.sh"), "# stub nvm\n"); writeNodeStub(fakeBin); - writeExecutable( - path.join(fakeBin, "docker"), - `#!/usr/bin/env bash -if [ "$1" = "info" ]; then - echo '{"ServerVersion":"29.3.1","Name":"Docker Desktop","OperatingSystem":"Ubuntu 24.04","CgroupVersion":"2"}' - exit 0 -fi -exit 0 -`, - ); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "openshell 0.0.22" - exit 0 -fi -exit 0 -`, - ); + writeDockerOkStub(fakeBin); + writeOpenShellOkStub(fakeBin, "0.0.22"); writeNpmStub(fakeBin, { installSnippet: `if [ "$1" = "pack" ]; then exit 1; fi if [ "$1" = "install" ] || [ "$1" = "run" ]; then exit 0; fi @@ -2035,15 +1540,7 @@ fi`, handleCi: true, }); - fs.writeFileSync( - path.join(tmp, "package.json"), - JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), - ); - fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); - fs.writeFileSync( - path.join(tmp, "nemoclaw", "package.json"), - JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), - ); + writeSourceCheckoutPackages(tmp); const result = spawnSync("bash", [INSTALLER], { cwd: tmp, @@ -2102,9 +1599,7 @@ describe("installer release-tag resolution", () => { } it("defaults to the installer default ref with no env override", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-tag-default-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-resolve-tag-default-"); writeExecutable(path.join(fakeBin, "node"), "#!/usr/bin/env bash\nexit 1"); @@ -2115,9 +1610,7 @@ describe("installer release-tag resolution", () => { }); it("uses NEMOCLAW_INSTALL_TAG override", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-tag-override-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-resolve-tag-override-"); // curl stub that would fail — must NOT be called writeExecutable( @@ -2137,12 +1630,12 @@ exit 99`, }); it("source-checkout path does NOT call resolve_release_tag / git clone", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-source-notag-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-source-notag-"); const gitLog = path.join(tmp, "git.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeNodeStub(fakeBin); writeDockerOkStub(fakeBin); @@ -2222,12 +1715,12 @@ exit 0`, }); it("repo-checkout install does not clone a separate ref even when cwd is elsewhere", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-tag-e2e-")); - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const { + root: tmp, + binDir: fakeBin, + prefixDir: prefix, + } = installerCheckout("nemoclaw-install-tag-e2e-"); const gitLog = path.join(tmp, "git.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeNodeStub(fakeBin); writeDockerOkStub(fakeBin); @@ -2299,9 +1792,7 @@ fi`, // "Node.js installed" line, not only in the generic bottom-of-output Next // block where it's easy to miss. it("install_nodejs upgrade path emits a Node-specific shell-reload hint", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-nvm-upgrade-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-nvm-upgrade-"); writeExecutable( path.join(fakeBin, "node"), @@ -2410,9 +1901,7 @@ describe("installer pure helpers", () => { } it("verify_nemoclaw checks the active CLI alias", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-verify-cli-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemohermes-verify-cli-"); writeExecutable( path.join(fakeBin, "nemohermes"), `#!/usr/bin/env bash @@ -2448,7 +1937,7 @@ exit 1 }); it("is_real_nemoclaw_cli accepts the active NemoHermes binary name", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-real-cli-")); + const { root: tmp } = installerCheckout("nemohermes-real-cli-"); const fakeCli = path.join(tmp, "nemohermes"); writeExecutable( fakeCli, @@ -2468,7 +1957,7 @@ exit 1 }); it("is_real_nemoclaw_cli accepts semver prerelease plus build metadata", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-real-cli-")); + const { root: tmp } = installerCheckout("nemohermes-real-cli-"); const fakeCli = path.join(tmp, "nemohermes"); writeExecutable( fakeCli, @@ -2488,7 +1977,7 @@ exit 1 }); it("is_real_nemoclaw_cli rejects mismatched CLI aliases", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-real-cli-")); + const { root: tmp } = installerCheckout("nemohermes-real-cli-"); const fakeCli = path.join(tmp, "nemohermes"); writeExecutable( fakeCli, @@ -2565,7 +2054,7 @@ exit 1 }); it("resolve_openclaw_version: falls back to Dockerfile.base when package.json omits it", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-version-")); + const { root: tmp } = installerCheckout("nemoclaw-openclaw-version-"); fs.writeFileSync(path.join(tmp, "package.json"), JSON.stringify({ name: "fixture" })); fs.writeFileSync(path.join(tmp, "Dockerfile.base"), "ARG OPENCLAW_VERSION=1.2.3\n"); const r = callInstallerFn(`resolve_openclaw_version ${JSON.stringify(tmp)}`); @@ -2573,7 +2062,7 @@ exit 1 }); it("is_source_checkout: rejects a payload-like checkout without git metadata", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-checkout-")); + const { root: tmp } = installerCheckout("nemoclaw-source-checkout-"); fs.writeFileSync( path.join(tmp, "package.json"), JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), @@ -2595,7 +2084,7 @@ exit 1 }); it("is_source_checkout: accepts an explicit source checkout with git metadata", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-checkout-git-")); + const { root: tmp } = installerCheckout("nemoclaw-source-checkout-git-"); fs.mkdirSync(path.join(tmp, ".git")); fs.writeFileSync( path.join(tmp, "package.json"), @@ -2618,7 +2107,7 @@ exit 1 }); it("is_source_checkout: rejects bootstrap payload clones even when git metadata exists", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-checkout-bootstrap-")); + const { root: tmp } = installerCheckout("nemoclaw-source-checkout-bootstrap-"); fs.mkdirSync(path.join(tmp, ".git")); fs.writeFileSync( path.join(tmp, "package.json"), @@ -2641,7 +2130,7 @@ exit 1 }); it("resolve_installer_version: falls back to package.json when git tags are unavailable", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-ver-pkg-")); + const { root: tmp } = installerCheckout("nemoclaw-resolve-ver-pkg-"); fs.mkdirSync(path.join(tmp, ".git")); fs.writeFileSync( path.join(tmp, "package.json"), @@ -2664,7 +2153,7 @@ exit 1 }); it("resolve_installer_version: falls back to DEFAULT when no package.json", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-ver-")); + const { root: tmp } = installerCheckout("nemoclaw-resolve-ver-"); // source overwrites SCRIPT_DIR, so we re-set it after sourcing. // The temp dir has no .git, no .version, and no package.json, // so the function should fall back to DEFAULT_NEMOCLAW_VERSION. @@ -2705,7 +2194,7 @@ exit 1 }); it("prefer_user_local_openshell: exports the freshly installed OpenShell path", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-path-")); + const { root: tmp } = installerCheckout("nemoclaw-openshell-path-"); const localBin = path.join(tmp, ".local", "bin"); const openshell = path.join(localBin, "openshell"); fs.mkdirSync(localBin, { recursive: true }); @@ -2725,11 +2214,9 @@ exit 1 }); it("restore_onboard_forward_after_post_checks: restores Hermes forward from session", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-forward-restore-")); - const fakeBin = path.join(tmp, "bin"); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemohermes-forward-restore-"); const stateDir = path.join(tmp, ".nemoclaw"); const openshellLog = path.join(tmp, "openshell.log"); - fs.mkdirSync(fakeBin, { recursive: true }); fs.mkdirSync(stateDir, { recursive: true }); fs.writeFileSync( path.join(stateDir, "onboard-session.json"), @@ -2775,13 +2262,13 @@ exit 0 // -- resolve_default_sandbox_name -- it("resolve_default_sandbox_name: returns 'my-assistant' with no registry", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-")); + const { root: tmp } = installerCheckout("nemoclaw-sandbox-name-"); const r = callInstallerFn("resolve_default_sandbox_name", { HOME: tmp }); expect(r.stdout.trim()).toBe("my-assistant"); }); it("resolve_default_sandbox_name: defaults to 'hermes' for NemoHermes with no state", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-sandbox-name-")); + const { root: tmp } = installerCheckout("nemohermes-sandbox-name-"); const r = callInstallerFn("resolve_default_sandbox_name", { HOME: tmp, NEMOCLAW_AGENT: "hermes", @@ -2790,7 +2277,7 @@ exit 0 }); it("resolve_default_sandbox_name: reads defaultSandbox from registry", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-reg-")); + const { root: tmp } = installerCheckout("nemoclaw-sandbox-name-reg-"); const registryDir = path.join(tmp, ".nemoclaw"); fs.mkdirSync(registryDir, { recursive: true }); fs.writeFileSync( @@ -2808,7 +2295,7 @@ exit 0 }); it("resolve_default_sandbox_name: honors NEMOCLAW_SANDBOX_NAME env var", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-env-")); + const { root: tmp } = installerCheckout("nemoclaw-sandbox-name-env-"); const r = callInstallerFn("resolve_default_sandbox_name", { HOME: tmp, NEMOCLAW_SANDBOX_NAME: "my-custom-name", @@ -2817,7 +2304,7 @@ exit 0 }); it("resolve_default_sandbox_name: current onboard session wins over env and registry", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-session-")); + const { root: tmp } = installerCheckout("nemoclaw-sandbox-name-session-"); const registryDir = path.join(tmp, ".nemoclaw"); fs.mkdirSync(registryDir, { recursive: true }); fs.writeFileSync( @@ -2840,7 +2327,7 @@ exit 0 }); it("resolve_default_sandbox_name: payload session lookup wins even when node is absent", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-payload-session-")); + const { root: tmp } = installerCheckout("nemoclaw-sandbox-name-payload-session-"); const registryDir = path.join(tmp, ".nemoclaw"); fs.mkdirSync(registryDir, { recursive: true }); fs.writeFileSync( @@ -2922,9 +2409,7 @@ describe("installer runtime checks (sourced)", () => { } it("fails with clear message when node is missing entirely", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-no-node-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-no-node-"); // npm exists but node does not writeExecutable( @@ -2940,9 +2425,7 @@ echo "10.9.2"`, }); it("fails with clear message when npm is missing entirely", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-no-npm-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-no-npm-"); writeExecutable( path.join(fakeBin, "node"), @@ -2958,9 +2441,7 @@ exit 0`, }); it("succeeds with acceptable Node.js 22.19 and npm 10", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-ok-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-runtime-ok-"); writeExecutable( path.join(fakeBin, "node"), @@ -2982,9 +2463,7 @@ exit 0`, }); it("rejects Node.js 22.18 which is below the 22.19 minimum", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-node22-18-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-runtime-node22-18-"); writeExecutable( path.join(fakeBin, "node"), @@ -3008,9 +2487,7 @@ exit 0`, }); it("rejects node that returns a non-numeric version", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-badver-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-runtime-badver-"); writeExecutable( path.join(fakeBin, "node"), @@ -3040,10 +2517,8 @@ describe("installer license acceptance (sourced)", () => { * or evaluating the real notice. */ function callShowUsageNotice(env: Record) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-show-usage-")); - const fakeBin = path.join(tmp, "bin"); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-show-usage-"); const sourceRoot = path.join(tmp, "src"); - fs.mkdirSync(fakeBin); fs.mkdirSync(path.join(sourceRoot, "bin", "lib"), { recursive: true }); const argLog = path.join(tmp, "notice-args.log"); @@ -3163,14 +2638,13 @@ describe("curl-pipe installer release-tag resolution", () => { * uname stubs because it runs everything top-to-bottom with no main(). */ function buildCurlPipeEnv( - tmp: string, + checkout: InstallerCheckout, { curlStub, gitStub }: { curlStub: string; gitStub: string }, ) { - const fakeBin = path.join(tmp, "bin"); - const prefix = path.join(tmp, "prefix"); + const tmp = checkout.root; + const fakeBin = checkout.binDir; + const prefix = checkout.prefixDir; const gitLog = path.join(tmp, "git.log"); - fs.mkdirSync(fakeBin); - fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); writeExecutable( path.join(fakeBin, "node"), @@ -3183,27 +2657,7 @@ if [ "$1" = "-e" ]; then exit 1; fi exit 99`, ); - writeExecutable( - path.join(fakeBin, "npm"), - `#!/usr/bin/env bash -set -euo pipefail -if [ "$1" = "--version" ]; then echo "10.9.2"; exit 0; fi -if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then echo "$NPM_PREFIX"; exit 0; fi -if [ "$1" = "pack" ]; then exit 1; fi -if { [ "$1" = "ci" ] || [ "$1" = "install" ]; } && [[ "$*" == *"--ignore-scripts"* ]]; then exit 0; fi -if [ "$1" = "run" ]; then exit 0; fi -if [ "$1" = "uninstall" ]; then exit 0; fi -if [ "$1" = "link" ]; then - cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' -#!/usr/bin/env bash -if [ "$1" = "--version" ]; then echo "nemoclaw v0.5.0-test"; exit 0; fi -exit 0 -EOS - chmod +x "$NPM_PREFIX/bin/nemoclaw" - exit 0 -fi -echo "unexpected npm invocation: $*" >&2; exit 98`, - ); + writeInstallerLinkNpmStub(fakeBin, { createCli: true, cliVersion: "0.5.0-test" }); writeExecutable( path.join(fakeBin, "docker"), @@ -3226,8 +2680,9 @@ exit 0`, } it("repo-checkout install ignores release-tag cloning when invoked by path", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-tag-e2e-")); - const { fakeBin, prefix, gitLog } = buildCurlPipeEnv(tmp, { + const checkout = installerCheckout("nemoclaw-curl-pipe-tag-e2e-"); + const { root: tmp } = checkout; + const { fakeBin, prefix, gitLog } = buildCurlPipeEnv(checkout, { curlStub: `#!/usr/bin/env bash /usr/bin/curl "$@"`, gitStub: `#!/usr/bin/env bash @@ -3266,8 +2721,9 @@ exit 0`, }); it("repo-checkout install ignores NEMOCLAW_INSTALL_TAG when invoked by path", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-tag-override-")); - const { fakeBin, prefix, gitLog } = buildCurlPipeEnv(tmp, { + const checkout = installerCheckout("nemoclaw-curl-pipe-tag-override-"); + const { root: tmp } = checkout; + const { fakeBin, prefix, gitLog } = buildCurlPipeEnv(checkout, { curlStub: `#!/usr/bin/env bash for arg in "$@"; do if [[ "$arg" == *"api.github.com"* ]]; then @@ -3314,7 +2770,7 @@ exit 0`, }); it("piped root installer does not source a local payload from the caller cwd", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-piped-root-cwd-")); + const { root: tmp } = installerCheckout("nemoclaw-piped-root-cwd-"); const repoLike = path.join(tmp, "repo"); fs.mkdirSync(path.join(repoLike, "scripts"), { recursive: true }); const rootInstaller = path.join(repoLike, "install.sh"); @@ -3347,10 +2803,8 @@ main() { }); it("piped root installer fails clearly when the selected ref is unavailable", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-missing-ref-")); - const fakeBin = path.join(tmp, "bin"); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-curl-pipe-missing-ref-"); const gitLog = path.join(tmp, "git.log"); - fs.mkdirSync(fakeBin); writeExecutable( path.join(fakeBin, "git"), `#!/usr/bin/env bash @@ -3393,9 +2847,10 @@ exit 0`, }); it("falls back to the legacy root installer when the selected ref only has the old scripts/install.sh wrapper", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-legacy-ref-")); + const checkout = installerCheckout("nemoclaw-curl-pipe-legacy-ref-"); + const { root: tmp } = checkout; const legacyLog = path.join(tmp, "legacy.log"); - const { fakeBin, prefix } = buildCurlPipeEnv(tmp, { + const { fakeBin, prefix } = buildCurlPipeEnv(checkout, { curlStub: `#!/usr/bin/env bash /usr/bin/curl "$@"`, gitStub: `#!/usr/bin/env bash @@ -3453,8 +2908,9 @@ exit 0`, }); it("resolves the usage notice helper from the cloned source during piped installs", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-usage-notice-")); - const { fakeBin, prefix } = buildCurlPipeEnv(tmp, { + const checkout = installerCheckout("nemoclaw-curl-pipe-usage-notice-"); + const { root: tmp } = checkout; + const { fakeBin, prefix } = buildCurlPipeEnv(checkout, { curlStub: `#!/usr/bin/env bash /usr/bin/curl "$@"`, gitStub: `#!/usr/bin/env bash @@ -3522,10 +2978,8 @@ describe("installer atomicity (#2671)", () => { env: Record, options: { stdinIsTty?: boolean } = {}, ) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-2671-")); - const fakeBin = path.join(tmp, "bin"); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-install-2671-"); const phaseLog = path.join(tmp, "phases.log"); - fs.mkdirSync(fakeBin); // Stub node + npm — both record their own invocation so we can detect // whether phase 1 (install_nodejs) or phase 2 (install_nemoclaw) ran. @@ -3585,10 +3039,8 @@ exit 0`, stdinMode: "pipe" | "tty" = "pipe", env: Record = {}, ) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-tty-pipe-")); - const fakeBin = path.join(tmp, "bin"); + const { root: tmp, binDir: fakeBin } = installerCheckout("nemoclaw-install-tty-pipe-"); const phaseLog = path.join(tmp, "phases.log"); - fs.mkdirSync(fakeBin); writeExecutable( path.join(fakeBin, "node"), diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 308ee3e3771..e7851a49d75 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { normalizeProviderBaseUrl } from "../src/lib/core/url-utils.js"; import { resetOllamaHostCache } from "../src/lib/inference/local.js"; import { @@ -53,7 +53,10 @@ import { import { createValidationRecoveryPromptHelpers } from "../src/lib/onboard/validation-recovery-prompt.js"; import { detectWindowsHostOllama } from "../src/lib/onboard/windows-host-ollama.js"; import { getTransportRecoveryMessage } from "../src/lib/validation-recovery.js"; - +import { + createOnboardProcessWorkspace, + type OnboardProcessWorkspace, +} from "./helpers/onboard-child-process-harness.js"; import { testTimeout } from "./helpers/timeouts"; import { createWindowsHostOllamaRunCapture, @@ -62,6 +65,7 @@ import { requireSelectedProviderResolution, restoreProcessEnvValue, runNativeDockerWindowsProviderBoundary, + runOllamaPullScenario, } from "./support/onboard-selection-test-helpers.js"; const CREDENTIAL_RETRY_PROMPT = @@ -71,6 +75,24 @@ const CREDENTIAL_RETRY_PROMPT_RE = const OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE = '{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"type":"function","function":{"name":"emit_ok","arguments":"{\\"ok\\":true}"}}]}}]}'; const PROVIDER_SELECTION_TEST_TIMEOUT_MS = testTimeout(60_000); +const repoRoot = path.join(import.meta.dirname, ".."); +const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); +const credentialsPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "credentials", "store.ts"), +); +const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); +const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); +const waitPath = JSON.stringify(path.join(repoRoot, "src", "lib", "core", "wait.ts")); +const nimPath = JSON.stringify(path.join(repoRoot, "src", "lib", "inference", "nim.ts")); +const localInferencePath = JSON.stringify( + path.join(repoRoot, "src", "lib", "inference", "local.ts"), +); +const ollamaSystemdPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "ollama-systemd.ts"), +); +const childRuntimePath = JSON.stringify( + path.join(repoRoot, "test", "helpers", "onboard-child-runtime.cjs"), +); const TEST_REMOTE_PROVIDER_CONFIG = { build: { label: "NVIDIA Endpoints", providerName: "nvidia-prod" }, openai: { label: "OpenAI", providerName: "openai-api" }, @@ -86,6 +108,12 @@ const TEST_REMOTE_PROVIDER_CONFIG = { gemini: { label: "Google Gemini", providerName: "gemini-api" }, }; +function onboardProcessWorkspace(prefix: string): OnboardProcessWorkspace { + const workspace = createOnboardProcessWorkspace(prefix); + onTestFinished(() => workspace.remove()); + return workspace; +} + const TEST_SETUP_NIM_REMOTE_PROVIDER_CONFIG: SetupNimFlowDeps["remoteProviderConfig"] = { build: { ...TEST_REMOTE_PROVIDER_CONFIG.build, @@ -658,23 +686,16 @@ type CredentialBackPayload = { let credentialBackBatchResults: Map | undefined; function runCredentialBackScenarioBatch(): Map { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-credential-back-batch-")); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-credential-back-batch-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "credential-back-batch.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); const agentDefsPath = JSON.stringify(path.join(repoRoot, "src", "lib", "agent", "defs.ts")); - const nimPath = JSON.stringify(path.join(repoRoot, "src", "lib", "inference", "nim.ts")); const childScenarios = PROCESS_CREDENTIAL_BACK_SCENARIOS; - - fs.mkdirSync(fakeBin, { recursive: true }); writeAlwaysOkCurl(fakeBin); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const scenarios = ${JSON.stringify(childScenarios)}; const clearCredentialEnv = [ "NVIDIA_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", @@ -819,10 +840,9 @@ async function runScenario(scenario) { `; try { - fs.writeFileSync(scriptPath, script); - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -1235,20 +1255,14 @@ describe("onboard provider selection UX", { timeout: PROVIDER_SELECTION_TEST_TIM }); it("warms and validates Ollama via 127.0.0.1 before moving on", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-validation-")); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-ollama-validation-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "ollama-validation-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); writeAlwaysOkCurl(fakeBin, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const child_process = require("child_process"); @@ -1262,14 +1276,8 @@ child_process.spawnSync = (cmd, args, opts) => { return originalSpawnSync(cmd, args, opts); }; -const answers = ["8", "1"]; -const messages = []; const commands = []; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; +const { messages } = installPromptQueue(credentials, ["8", "1"]); runner.run = (command, opts = {}) => { commands.push(Array.isArray(command) ? command.join(" ") : command); return { status: 0 }; @@ -1294,37 +1302,14 @@ runner.runCapture = (command) => { const { setupNim } = require(${onboardPath}); -(async () => { - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog( - JSON.stringify({ - result, - messages, - lines, - commands, - contextWindow: process.env.NEMOCLAW_CONTEXT_WINDOW, - }), - ); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); +reportChildScenario(async () => { + const result = await setupNim(null); + return { result, messages, commands, contextWindow: process.env.NEMOCLAW_CONTEXT_WINDOW }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -1407,22 +1392,14 @@ const { setupNim } = require(${onboardPath}); }); it("starts managed Ollama on loopback before exposing the auth proxy", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-loopback-")); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-ollama-loopback-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "ollama-loopback-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); - const waitPath = JSON.stringify(path.join(repoRoot, "src", "lib", "core", "wait.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); writeAlwaysOkCurl(fakeBin, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); @@ -1441,15 +1418,9 @@ child_process.spawnSync = (cmd, args, opts) => { return originalSpawnSync(cmd, args, opts); }; -const messages = []; const runCommands = []; const shellCommands = []; -const answers = ["8", "1"]; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; +const { messages } = installPromptQueue(credentials, ["8", "1"]); credentials.ensureApiKey = async () => {}; runner.runCapture = (command) => { const cmd = Array.isArray(command) ? command.join(" ") : command; @@ -1481,26 +1452,14 @@ wait.waitForHttp = (_url, tries) => (tries ?? 0) > 1; const { setupNim } = require(${onboardPath}); -(async () => { - const originalLog = console.log; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines, runCommands, shellCommands })); - } finally { - console.log = originalLog; - } -})().catch((error) => { - console.error(error); - process.exit(1); +reportChildScenario(async () => { + const result = await setupNim(null); + return { result, messages, runCommands, shellCommands }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -1533,18 +1492,14 @@ const { setupNim } = require(${onboardPath}); it("treats an implicit latest Ollama model as installed during systemd repair", { timeout: PROVIDER_SELECTION_TEST_TIMEOUT_MS, }, () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-systemd-")); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-ollama-systemd-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "ollama-systemd-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); writeAlwaysOkCurl(fakeBin, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); const child_process = require("child_process"); @@ -1588,26 +1543,14 @@ platform.isWsl = () => false; const { setupNim } = require(${onboardPath}); -(async () => { - const originalLog = console.log; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, lines, runCommands, shellCommands })); - } finally { - console.log = originalLog; - } -})().catch((error) => { - console.error(error); - process.exit(1); +reportChildScenario(async () => { + const result = await setupNim(null); + return { result, runCommands, shellCommands }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -1645,18 +1588,14 @@ const { setupNim } = require(${onboardPath}); it("preserves existing Ollama systemd override settings while repairing loopback", { timeout: 10_000, }, () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-systemd-merge-")); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-ollama-systemd-merge-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "ollama-systemd-merge-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); writeAlwaysOkCurl(fakeBin, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const fs = require("fs"); const runner = require(${runnerPath}); const platform = require(${platformPath}); @@ -1717,25 +1656,14 @@ platform.isWsl = () => false; const { setupNim } = require(${onboardPath}); -(async () => { - const originalLog = console.log; - console.log = () => {}; - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, shellCommands, shellCalls, installedBody })); - } finally { - console.log = originalLog; - } -})().catch((error) => { - console.error(error); - process.exit(1); +reportChildScenario(async () => { + const result = await setupNim(null); + return { result, shellCommands, shellCalls, installedBody }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -1796,19 +1724,12 @@ const { setupNim } = require(${onboardPath}); it("adds Spark CUDA v13 and enables the Ollama systemd service on managed install", { timeout: 10_000, }, () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-ollama-systemd-spark-")); + const workspace = onboardProcessWorkspace("nemoclaw-ollama-systemd-spark-"); + const { root: tmpDir } = workspace; const scriptPath = path.join(tmpDir, "ollama-systemd-spark-check.js"); - const ollamaSystemdPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "ollama-systemd.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); - const localInferencePath = JSON.stringify( - path.join(repoRoot, "src", "lib", "inference", "local.ts"), - ); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const fs = require("fs"); const runner = require(${runnerPath}); const platform = require(${platformPath}); @@ -1843,19 +1764,19 @@ localInference.findReachableOllamaHost = () => true; Object.defineProperty(process, "platform", { value: "linux" }); const { ensureOllamaLoopbackSystemdOverride } = require(${ollamaSystemdPath}); -const result = ensureOllamaLoopbackSystemdOverride({ - isNonInteractive: () => true, - enableService: true, - detectNvidiaPlatformImpl: () => "spark", - hasOllamaCudaV13LibraryImpl: () => true, +reportChildScenario(() => { + const result = ensureOllamaLoopbackSystemdOverride({ + isNonInteractive: () => true, + enableService: true, + detectNvidiaPlatformImpl: () => "spark", + hasOllamaCudaV13LibraryImpl: () => true, + }); + return { result, installedBody, shellCommands }; }); -console.log(JSON.stringify({ result, installedBody, shellCommands })); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -1879,19 +1800,12 @@ console.log(JSON.stringify({ result, installedBody, shellCommands })); it("allows prompt-capable sudo in non-interactive Ollama systemd setup", { timeout: 10_000, }, () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-ollama-systemd-sudo-mode-")); + const workspace = onboardProcessWorkspace("nemoclaw-ollama-systemd-sudo-mode-"); + const { root: tmpDir } = workspace; const scriptPath = path.join(tmpDir, "ollama-systemd-sudo-mode-check.js"); - const ollamaSystemdPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "ollama-systemd.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); - const localInferencePath = JSON.stringify( - path.join(repoRoot, "src", "lib", "inference", "local.ts"), - ); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); const localInference = require(${localInferencePath}); @@ -1911,14 +1825,14 @@ localInference.findReachableOllamaHost = () => true; Object.defineProperty(process, "platform", { value: "linux" }); const { ensureOllamaLoopbackSystemdOverride } = require(${ollamaSystemdPath}); -const result = ensureOllamaLoopbackSystemdOverride({ isNonInteractive: () => true }); -console.log(JSON.stringify({ result, shellCommands })); +reportChildScenario(() => { + const result = ensureOllamaLoopbackSystemdOverride({ isNonInteractive: () => true }); + return { result, shellCommands }; +}); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -1976,20 +1890,14 @@ console.log(JSON.stringify({ result, shellCommands })); it("repairs already-loopback systemd Ollama without starting a duplicate daemon", { timeout: 10_000, }, () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-onboard-ollama-systemd-loopback-"), - ); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-ollama-systemd-loopback-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "ollama-systemd-loopback-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); writeAlwaysOkCurl(fakeBin, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); const child_process = require("child_process"); @@ -2034,26 +1942,14 @@ platform.isWsl = () => false; const { setupNim } = require(${onboardPath}); -(async () => { - const originalLog = console.log; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, lines, shellCommands, events })); - } finally { - console.log = originalLog; - } -})().catch((error) => { - console.error(error); - process.exit(1); +reportChildScenario(async () => { + const result = await setupNim(null); + return { result, shellCommands, events }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -2096,17 +1992,12 @@ const { setupNim } = require(${onboardPath}); it("fails closed instead of starting unmanaged Ollama when systemd restart stays unreachable", { timeout: 15_000, }, () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-onboard-existing-systemd-restart-fail-"), - ); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-existing-systemd-restart-fail-"); + const { root: tmpDir } = workspace; const scriptPath = path.join(tmpDir, "existing-systemd-restart-fail-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); - const waitPath = JSON.stringify(path.join(repoRoot, "src", "lib", "core", "wait.ts")); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); const wait = require(${waitPath}); @@ -2147,11 +2038,9 @@ const { setupNim } = require(${onboardPath}); process.exit(1); }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -2167,16 +2056,12 @@ const { setupNim } = require(${onboardPath}); }); it("fails closed when an existing Ollama systemd override cannot be applied", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-onboard-existing-systemd-fail-"), - ); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-existing-systemd-fail-"); + const { root: tmpDir } = workspace; const scriptPath = path.join(tmpDir, "existing-systemd-fail-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); @@ -2206,11 +2091,9 @@ const { setupNim } = require(${onboardPath}); process.exit(1); }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -2276,409 +2159,96 @@ const { setupNim } = require(${onboardPath}); assert.equal(handleRemoteProviderSelection.mock.calls.length, 1); }); - it("waits for delayed Ollama registration after pulling a starter model", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-bootstrap-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "ollama-bootstrap-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const pullLog = path.join(tmpDir, "pulls.log"); - - fs.mkdirSync(fakeBin, { recursive: true }); - writeAlwaysOkCurl(fakeBin, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); - fs.writeFileSync( - path.join(fakeBin, "ollama"), - `#!/usr/bin/env bash -if [ "$1" = "pull" ]; then - echo "$2" >> ${JSON.stringify(pullLog)} - exit 0 -fi -exit 0 -`, - { mode: 0o755 }, - ); - - const script = String.raw` -const fs = require("fs"); -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); - -const answers = ["8", "1", "y"]; -const messages = []; -const pullLog = ${JSON.stringify(pullLog)}; -let listAttempts = 0; -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; -runner.runCapture = (command) => { - const cmd = Array.isArray(command) ? command.join(" ") : command; - if (cmd.includes("command -v ollama")) return "/usr/bin/ollama"; - if (cmd.includes("127.0.0.1:11434/api/tags")) return JSON.stringify({ models: [] }); - if (cmd.includes("ollama list")) return fs.existsSync(pullLog) && ++listAttempts >= 7 ? "qwen3.5:9b" : ""; - if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; - if (cmd.includes("api/generate")) return '{"response":"hello"}'; - if (cmd.includes("-o args=")) return "node ollama-auth-proxy.js"; - return ""; -}; - -const { setupNim } = require(${onboardPath}); - -(async () => { - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines, listAttempts })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - NEMOCLAW_TEST_NO_SLEEP: "1", - PATH: `${fakeBin}:${process.env.PATH || ""}`, + it.each([ + { + title: "waits for delayed Ollama registration after pulling a starter model", + scenario: { + answers: ["8", "1", "y"], + environment: { NEMOCLAW_TEST_NO_SLEEP: "1" }, + listAfter: { attempts: 7 }, + } as const, + verify: ({ payload, pulls }: ReturnType) => { + assert.equal(payload.result.model, "qwen3.5:9b"); + assert.equal(payload.listAttempts, 7); + assert.ok(payload.lines.some((line) => line.includes("Ollama starter models:"))); + assert.match(payload.lines.join("\n"), /Waiting for Ollama to register model: qwen3\.5:9b/); + assert.ok(payload.lines.some((line) => line.includes("Pulling Ollama model: qwen3.5:9b"))); + assert.equal(pulls, "qwen3.5:9b"); }, - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "ollama-local"); - assert.equal(payload.result.model, "qwen3.5:9b"); - assert.equal(payload.listAttempts, 7); - assert.ok(payload.lines.some((line: string) => line.includes("Ollama starter models:"))); - assert.match(payload.lines.join("\n"), /Waiting for Ollama to register model: qwen3\.5:9b/); - assert.ok( - payload.lines.some((line: string) => line.includes("Pulling Ollama model: qwen3.5:9b")), - ); - assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen3.5:9b"); - }); - - it("reprompts when a pulled Ollama model does not appear in discovery (#6038)", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-retry-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "ollama-retry-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const pullLog = path.join(tmpDir, "pulls.log"); - - fs.mkdirSync(fakeBin, { recursive: true }); - writeAlwaysOkCurl(fakeBin, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); - fs.writeFileSync( - path.join(fakeBin, "ollama"), - `#!/usr/bin/env bash -if [ "$1" = "pull" ]; then - echo "$2" >> ${JSON.stringify(pullLog)} - exit 0 -fi -exit 0 -`, - { mode: 0o755 }, - ); - - const script = String.raw` -const fs = require("fs"); -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); - -const answers = ["8", "1", "y", "2", "llama3.2:3b", "y"]; -const messages = []; -const pullLog = ${JSON.stringify(pullLog)}; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; -runner.runCapture = (command) => { - const cmd = Array.isArray(command) ? command.join(" ") : command; - if (cmd.includes("command -v ollama")) return "/usr/bin/ollama"; - if (cmd.includes("127.0.0.1:11434/api/tags")) return JSON.stringify({ models: [] }); - if (cmd.includes("ollama list")) return fs.existsSync(pullLog) && fs.readFileSync(pullLog, "utf8").includes("llama3.2:3b") ? "llama3.2:3b" : ""; - if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; - if (cmd.includes("api/generate")) return '{"response":"hello"}'; - if (cmd.includes("-o args=")) return "node ollama-auth-proxy.js"; - return ""; -}; - -const { setupNim } = require(${onboardPath}); - -(async () => { - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - 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 || ""}`, - NEMOCLAW_TEST_NO_SLEEP: "1", + }, + { + title: "reprompts when a pulled Ollama model does not appear in discovery (#6038)", + scenario: { + answers: ["8", "1", "y", "2", "llama3.2:3b", "y"], + environment: { NEMOCLAW_TEST_NO_SLEEP: "1" }, + listAfter: { model: "llama3.2:3b" }, + } as const, + verify: ({ payload, pulls }: ReturnType) => { + assert.equal(payload.result.model, "llama3.2:3b"); + assert.ok( + payload.lines.some((line) => + line.includes("Ollama pull for 'qwen3.5:9b' completed, but Ollama did not list"), + ), + ); + assert.ok( + payload.lines.some((line) => + line.includes("Choose a different Ollama model or select Other."), + ), + ); + assert.equal( + payload.messages.filter((message) => /Ollama model id:/.test(message)).length, + 1, + ); + assert.equal(pulls, "qwen3.5:9b\nllama3.2:3b"); }, - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "ollama-local"); - assert.equal(payload.result.model, "llama3.2:3b"); - assert.ok( - payload.lines.some((line: string) => - line.includes("Ollama pull for 'qwen3.5:9b' completed, but Ollama did not list"), - ), - ); - assert.ok( - payload.lines.some((line: string) => - line.includes("Choose a different Ollama model or select Other."), - ), - ); - assert.equal( - payload.messages.filter((message: string) => /Ollama model id:/.test(message)).length, - 1, - ); - assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen3.5:9b\nllama3.2:3b"); - }); - - it("re-prompts for a model when the user declines the size confirmation", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-decline-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "ollama-decline-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const pullLog = path.join(tmpDir, "pulls.log"); - - fs.mkdirSync(fakeBin, { recursive: true }); - writeAlwaysOkCurl(fakeBin, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); - fs.writeFileSync( - path.join(fakeBin, "ollama"), - `#!/usr/bin/env bash -if [ "$1" = "pull" ]; then - echo "$2" >> ${JSON.stringify(pullLog)} - exit 0 -fi -exit 0 -`, - { mode: 0o755 }, - ); - - const script = String.raw` -const fs = require("fs"); -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); - -const answers = ["8", "1", "n", "1", "y"]; -const messages = []; -const pullLog = ${JSON.stringify(pullLog)}; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; -runner.runCapture = (command) => { - const cmd = Array.isArray(command) ? command.join(" ") : command; - if (cmd.includes("command -v ollama")) return "/usr/bin/ollama"; - if (cmd.includes("127.0.0.1:11434/api/tags")) return JSON.stringify({ models: [] }); - if (cmd.includes("ollama list")) return fs.existsSync(pullLog) ? "qwen3.5:9b" : ""; - if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; - if (cmd.includes("api/generate")) return '{"response":"hello"}'; - if (cmd.includes("-o args=")) return "node ollama-auth-proxy.js"; - return ""; -}; - -const { setupNim } = require(${onboardPath}); - -(async () => { - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - 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 || ""}`, + }, + { + title: "re-prompts for a model when the user declines the size confirmation", + scenario: { + answers: ["8", "1", "n", "1", "y"], + listAfter: "first-pull", + } as const, + verify: ({ payload, pulls }: ReturnType) => { + assert.equal(payload.result.model, "qwen3.5:9b"); + assert.ok( + payload.lines.some((line) => line.includes("Skipped pulling Ollama model 'qwen3.5:9b'")), + ); + assert.equal(pulls, "qwen3.5:9b"); + const downloadPrompts = payload.messages.filter((message) => + /Download Ollama model/.test(message), + ); + assert.equal(downloadPrompts.length, 2); + const sizePattern = /\((\d+(\.\d+)? (B|KB|MB|GB|TB)( \(estimated\))?|size unknown)\)/; + for (const prompt of downloadPrompts) assert.match(prompt, sizePattern); }, - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "ollama-local"); - assert.equal(payload.result.model, "qwen3.5:9b"); - assert.ok( - payload.lines.some((line: string) => - line.includes("Skipped pulling Ollama model 'qwen3.5:9b'"), - ), - ); - assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen3.5:9b"); - const downloadPrompts = payload.messages.filter((message: string) => - /Download Ollama model/.test(message), - ); - assert.equal(downloadPrompts.length, 2); - const sizePattern = /\((\d+(\.\d+)? (B|KB|MB|GB|TB)( \(estimated\))?|size unknown)\)/; - for (const prompt of downloadPrompts) { - assert.match(prompt, sizePattern); - } - }); - - it("bypasses the size confirmation when NEMOCLAW_YES=1 is set", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-yes-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "ollama-yes-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const pullLog = path.join(tmpDir, "pulls.log"); - - fs.mkdirSync(fakeBin, { recursive: true }); - writeAlwaysOkCurl(fakeBin, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); - fs.writeFileSync( - path.join(fakeBin, "ollama"), - `#!/usr/bin/env bash -if [ "$1" = "pull" ]; then - echo "$2" >> ${JSON.stringify(pullLog)} - exit 0 -fi -exit 0 -`, - { mode: 0o755 }, - ); - - const script = String.raw` -const fs = require("fs"); -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); - -const answers = ["8", "1"]; -const messages = []; -const pullLog = ${JSON.stringify(pullLog)}; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; -runner.runCapture = (command) => { - const cmd = Array.isArray(command) ? command.join(" ") : command; - if (cmd.includes("command -v ollama")) return "/usr/bin/ollama"; - if (cmd.includes("127.0.0.1:11434/api/tags")) return JSON.stringify({ models: [] }); - if (cmd.includes("ollama list")) return fs.existsSync(pullLog) ? "qwen3.5:9b" : ""; - if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; - if (cmd.includes("api/generate")) return '{"response":"hello"}'; - if (cmd.includes("-o args=")) return "node ollama-auth-proxy.js"; - return ""; -}; - -const { setupNim } = require(${onboardPath}); - -(async () => { - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - 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 || ""}`, - NEMOCLAW_YES: "1", + }, + { + title: "bypasses the size confirmation when NEMOCLAW_YES=1 is set", + scenario: { + answers: ["8", "1"], + environment: { NEMOCLAW_YES: "1" }, + listAfter: "first-pull", + } as const, + verify: ({ payload, pulls }: ReturnType) => { + assert.equal(payload.result.model, "qwen3.5:9b"); + assert.equal(pulls, "qwen3.5:9b"); + assert.equal( + payload.messages.filter((message) => /Download Ollama model/.test(message)).length, + 0, + ); + // The auto-yes path still reports a size label or the "size unknown" fallback. + const sizePattern = /\((\d+(\.\d+)? (B|KB|MB|GB|TB)( \(estimated\))?|size unknown)\)/; + const pullingLine = payload.lines.find((line) => + /Pulling Ollama model 'qwen3.5:9b'/.test(line), + ); + assert.ok(pullingLine, "expected a 'Pulling Ollama model' log line under NEMOCLAW_YES=1"); + assert.match(pullingLine, sizePattern); }, - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "ollama-local"); - assert.equal(payload.result.model, "qwen3.5:9b"); - assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen3.5:9b"); - assert.equal( - payload.messages.filter((message: string) => /Download Ollama model/.test(message)).length, - 0, - ); - // The size is still surfaced in the auto-yes path so unattended installs - // record what was downloaded — assert the "Pulling Ollama model" log line - // includes a size label or the "size unknown" fallback. - const sizePattern = /\((\d+(\.\d+)? (B|KB|MB|GB|TB)( \(estimated\))?|size unknown)\)/; - const pullingLine = payload.lines.find((line: string) => - /Pulling Ollama model 'qwen3.5:9b'/.test(line), - ); - assert.ok(pullingLine, "expected a 'Pulling Ollama model' log line under NEMOCLAW_YES=1"); - assert.match(pullingLine, sizePattern); + }, + ])("$title", ({ scenario, verify }) => { + const result = runOllamaPullScenario(scenario, OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE); + assert.equal(result.payload.result.provider, "ollama-local"); + verify(result); }); it("reprompts for an OpenAI Other model when /models validation rejects it", async () => { @@ -3098,60 +2668,31 @@ const { setupNim } = require(${onboardPath}); }); it("returns to provider selection instead of exiting on blank custom endpoint input", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-onboard-custom-endpoint-blank-"), - ); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-custom-endpoint-blank-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "custom-endpoint-blank-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); writeAlwaysOkCurl(fakeBin, '{"id":"ok"}'); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const answers = ["4", "", "", ""]; -const messages = []; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; +const { messages } = installPromptQueue(credentials, ["4", "", "", ""]); credentials.ensureApiKey = async () => {}; runner.runCapture = () => ""; const { setupNim } = require(${onboardPath}); -(async () => { - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); +reportChildScenario(async () => { + const result = await setupNim(null); + return { result, messages }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -3533,17 +3074,10 @@ const { setupNim } = require(${onboardPath}); }); it("lets users re-enter an NVIDIA API key after authorization failure without restarting selection", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-auth-retry-")); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-build-auth-retry-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "build-auth-retry-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync( path.join(fakeBin, "curl"), `#!/usr/bin/env bash @@ -3578,46 +3112,24 @@ printf '%s' "$status" ); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const answers = ["", "", "retry", "nvapi-good"]; -const messages = []; -const prompts = []; - -credentials.prompt = async (message, opts = {}) => { - messages.push(message); - prompts.push({ message, secret: opts.secret === true }); - return answers.shift() || ""; -}; +const { messages, prompts } = installPromptQueue(credentials, ["", "", "retry", "nvapi-good"]); runner.runCapture = () => ""; const { setupNim } = require(${onboardPath}); -(async () => { +reportChildScenario(async () => { process.env.NVIDIA_INFERENCE_API_KEY = "nvapi-bad"; - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, prompts, lines, key: process.env.NVIDIA_INFERENCE_API_KEY })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); + const result = await setupNim(null); + return { result, messages, prompts, key: process.env.NVIDIA_INFERENCE_API_KEY }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -3727,64 +3239,35 @@ const { setupNim } = require(${onboardPath}); }); it("lets users re-enter a custom OpenAI-compatible API key without re-entering the endpoint URL", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-onboard-custom-openai-auth-retry-"), - ); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-custom-openai-auth-retry-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "custom-openai-auth-retry-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); writeOpenAiStyleAuthRetryCurl(fakeBin, "proxy-good", ["custom-model"]); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const answers = ["4", "https://proxy.example.com/v1/chat/completions?token=secret#frag", "custom-model", "retry", "proxy-good", "custom-model"]; -const messages = []; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; +const { messages } = installPromptQueue(credentials, ["4", "https://proxy.example.com/v1/chat/completions?token=secret#frag", "custom-model", "retry", "proxy-good", "custom-model"]); runner.runCapture = () => ""; const { setupNim } = require(${onboardPath}); -(async () => { +reportChildScenario(async () => { process.env.COMPATIBLE_API_KEY = "proxy-bad"; // The endpoint SSRF preflight now runs unconditionally (#6293); stub the DNS // resolver to a public address so the fixture hostname resolves and the flow // reaches validation instead of being refused (mirrors credentials/runner stubs). require("node:dns/promises").lookup = async () => [{ address: "93.184.216.34", family: 4 }]; - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines, key: process.env.COMPATIBLE_API_KEY })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); + const result = await setupNim(null); + return { result, messages, key: process.env.COMPATIBLE_API_KEY }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -3825,17 +3308,10 @@ const { setupNim } = require(${onboardPath}); }); it("forces openai-completions for vLLM even when probe detects openai-responses", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-vllm-override-")); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-vllm-override-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "vllm-override-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); // Fake curl: /v1/responses returns 200 (so probe detects openai-responses), // /v1/models returns a vLLM model list fs.writeFileSync( @@ -3866,16 +3342,11 @@ printf '%s' "$status" // vLLM is option 8 (build, openrouter, openai, custom, anthropic, anthropicCompatible, gemini, vllm) const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const answers = ["8"]; -const messages = []; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; +const { messages } = installPromptQueue(credentials, ["8"]); credentials.ensureApiKey = async () => {}; runner.runCapture = (command) => { // Normalize: onboard.ts still sends strings, local-inference.ts sends arrays. @@ -3889,26 +3360,14 @@ runner.runCapture = (command) => { const { setupNim } = require(${onboardPath}); -(async () => { - const originalLog = console.log; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - } -})().catch((error) => { - console.error(error); - process.exit(1); +reportChildScenario(async () => { + const result = await setupNim(null); + return { result, messages }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -3929,18 +3388,10 @@ const { setupNim } = require(${onboardPath}); }); it("forces openai-completions for NIM-local even when probe detects openai-responses", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-nim-override-")); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-nim-override-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const scriptPath = path.join(tmpDir, "nim-override-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const nimPath = JSON.stringify(path.join(repoRoot, "src", "lib", "inference", "nim.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); // Fake curl: /v1/responses returns 200 (probe detects openai-responses) fs.writeFileSync( path.join(fakeBin, "curl"), @@ -3971,6 +3422,7 @@ printf '%s' "$status" // NIM-local is option 8 (build, openrouter, openai, custom, anthropic, anthropicCompatible, gemini, nim-local) // No ollama, no vLLM — only NIM-local shows up as experimental option const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); @@ -3984,13 +3436,7 @@ nimMod.waitForNimHealth = () => true; nimMod.isNgcLoggedIn = () => true; // Select option 8 (nim-local), then model 1 -const answers = ["8", "1"]; -const messages = []; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; +const { messages } = installPromptQueue(credentials, ["8", "1"]); credentials.ensureApiKey = async () => {}; runner.runCapture = (command) => { // Normalize: onboard.ts still sends strings, local-inference.ts sends arrays. @@ -4004,27 +3450,15 @@ runner.runCapture = (command) => { const { setupNim } = require(${onboardPath}); -(async () => { - const originalLog = console.log; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - try { - // Pass a GPU object with nimCapable: true - const result = await setupNim({ type: "nvidia", totalMemoryMB: 16000, nimCapable: true }); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - } -})().catch((error) => { - console.error(error); - process.exit(1); +reportChildScenario(async () => { + // Pass a GPU object with nimCapable: true + const result = await setupNim({ type: "nvidia", totalMemoryMB: 16000, nimCapable: true }); + return { result, messages }; }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -4126,18 +3560,12 @@ const { setupNim } = require(${onboardPath}); }); it("fails closed when the Linux systemd loopback override cannot be applied", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-systemd-fail-")); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-systemd-fail-"); + const { root: tmpDir } = workspace; const scriptPath = path.join(tmpDir, "systemd-fail-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); - const waitPath = JSON.stringify(path.join(repoRoot, "src", "lib", "core", "wait.ts")); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); @@ -4195,11 +3623,9 @@ const { setupNim } = require(${onboardPath}); process.exit(1); }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, @@ -4688,17 +4114,11 @@ const { setupNim } = require(${onboardPath}); }); it("honours NEMOCLAW_LOCAL_INFERENCE_TIMEOUT for compatible-endpoint during inference setup (#2403)", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-onboard-compatible-endpoint-timeout-"), - ); - const fakeBin = path.join(tmpDir, "bin"); + const workspace = onboardProcessWorkspace("nemoclaw-onboard-compatible-endpoint-timeout-"); + const { root: tmpDir } = workspace; + const fakeBin = workspace.binDir; const stateFile = path.join(tmpDir, "state.json"); const scriptPath = path.join(tmpDir, "compatible-timeout-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(stateFile, JSON.stringify({ inferenceSetArgs: null })); // Fake openshell: records inference set args, stubs provider/gateway ops @@ -4722,6 +4142,7 @@ process.exit(0); ); const script = String.raw` +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); const runner = require(${runnerPath}); // Mock runCapture before onboard.js is required so the destructured reference picks up the mock. runner.runCapture = (cmd) => { @@ -4738,11 +4159,9 @@ const { setupInference } = require(${onboardPath}); process.exit(0); })().catch((err) => { console.error(err); process.exit(1); }); `; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { + const result = workspace.runNodeSource(script, { + name: path.basename(scriptPath), cwd: repoRoot, - encoding: "utf-8", env: { ...process.env, HOME: tmpDir, diff --git a/test/support/onboard-selection-test-helpers.ts b/test/support/onboard-selection-test-helpers.ts index 71273f13a53..4fbb4b53e36 100644 --- a/test/support/onboard-selection-test-helpers.ts +++ b/test/support/onboard-selection-test-helpers.ts @@ -14,6 +14,7 @@ import type { ProviderSelectionSuccess, } from "../../src/lib/onboard/provider-selection.js"; import type { DetectWindowsHostOllamaDeps } from "../../src/lib/onboard/windows-host-ollama.js"; +import { createHostProcessWorkspace } from "../helpers/host-process-harness.js"; const PROVIDER_CREDENTIAL_ENV_KEYS = new Set([ "ANTHROPIC_API_KEY", @@ -34,6 +35,113 @@ const PROVIDER_CREDENTIAL_ENV_KEYS = new Set([ "OPENROUTER_API_KEY", ]); +export type OllamaPullScenario = { + answers: readonly string[]; + environment?: NodeJS.ProcessEnv; + listAfter: { attempts: number } | { model: string } | "first-pull"; +}; + +export type OllamaPullScenarioResult = { + payload: { + result: { provider: string; model: string }; + messages: string[]; + lines: string[]; + listAttempts: number; + }; + pulls: string; +}; + +export function runOllamaPullScenario( + scenario: OllamaPullScenario, + curlResponse: string, +): OllamaPullScenarioResult { + const repoRoot = path.join(import.meta.dirname, "..", ".."); + const workspace = createHostProcessWorkspace("nemoclaw-onboard-ollama-pull-"); + const pullLog = workspace.path("pulls.log"); + const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); + const credentialsPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "credentials", "store.ts"), + ); + const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); + const childRuntimePath = JSON.stringify( + path.join(repoRoot, "test", "helpers", "onboard-child-runtime.cjs"), + ); + const listResult = + scenario.listAfter === "first-pull" + ? `fs.existsSync(pullLog) ? "qwen3.5:9b" : ""` + : "attempts" in scenario.listAfter + ? `fs.existsSync(pullLog) && ++listAttempts >= ${scenario.listAfter.attempts} ? "qwen3.5:9b" : ""` + : `fs.existsSync(pullLog) && fs.readFileSync(pullLog, "utf8").includes(${JSON.stringify(scenario.listAfter.model)}) ? ${JSON.stringify(scenario.listAfter.model)} : ""`; + + workspace.writeExecutable( + "curl", + `#!/usr/bin/env bash +body='${curlResponse}' +status="200" +outfile="" +url="" +has_config=0 +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + --config) has_config=1; shift 2 ;; + http://*|https://*) url="$1"; shift ;; + *) shift ;; + esac +done +if [ "$has_config" -eq 0 ] && [[ "$url" == *:11435/* ]]; then status="401"; fi +if [ -n "$outfile" ]; then printf '%s' "$body" > "$outfile"; fi +printf '%s' "$status" +`, + ); + workspace.writeExecutable( + "ollama", + `#!/usr/bin/env bash +if [ "$1" = "pull" ]; then echo "$2" >> ${JSON.stringify(pullLog)}; exit 0; fi +exit 0 +`, + ); + + const source = String.raw` +const fs = require("fs"); +const { installPromptQueue, reportChildScenario } = require(${childRuntimePath}); +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const { messages } = installPromptQueue(credentials, ${JSON.stringify(scenario.answers)}); +const pullLog = ${JSON.stringify(pullLog)}; +let listAttempts = 0; +runner.runCapture = (command) => { + const cmd = Array.isArray(command) ? command.join(" ") : command; + if (cmd.includes("command -v ollama")) return "/usr/bin/ollama"; + if (cmd.includes("127.0.0.1:11434/api/tags")) return JSON.stringify({ models: [] }); + if (cmd.includes("ollama list")) return ${listResult}; + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + if (cmd.includes("api/generate")) return '{"response":"hello"}'; + if (cmd.includes("-o args=")) return "node ollama-auth-proxy.js"; + return ""; +}; +const { setupNim } = require(${onboardPath}); +reportChildScenario(async () => { + const result = await setupNim(null); + return { result, messages, listAttempts }; +}); +`; + + try { + const result = workspace.runNodeSource(source, { + cwd: repoRoot, + env: workspace.environment(scenario.environment), + }); + if (result.status !== 0) throw new Error(`Ollama pull scenario failed: ${result.stderr}`); + return { + payload: JSON.parse(result.stdout.trim()), + pulls: fs.readFileSync(pullLog, "utf8").trim(), + }; + } finally { + workspace.remove(); + } +} + export function requirePresent(value: T | null | undefined, message: string): T { if (value === null || value === undefined) throw new Error(message); return value;