Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions test/helpers/onboard-child-process-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

/**
* Child-process setup mechanics for onboarding suites that spawn the CLI or a
* generated scenario script in a real Node process. The harness owns the
* temporary workspace, fake-bin executables, environment composition, spawn
* call, and trailing JSON payload extraction. Stub script contents, scenario
* environment values, and assertions stay in each test. Every helper returns
* fresh mutable state and touches no parent-process global.
*/

/** The repository root the spawned processes run from. */
export const testRepoRoot = path.join(import.meta.dirname, "..", "..");

/** A disposable workspace holding the spawned process's home and fake bin. */
export interface OnboardProcessWorkspace {
/** The mkdtemp root; also the default HOME. */
root: string;
/** The directory HOME points at; equals root unless separateHome is set. */
homeDir: string;
/** The created bin directory for stub executables. */
binDir: string;
/** Writes an executable stub into binDir and returns its path. */
writeExecutable: (name: string, contents: string) => string;
/** Resolves a path under the workspace root. */
path: (...segments: string[]) => string;
/** Removes the whole workspace. */
remove: () => void;
}

/** Creation options for createOnboardProcessWorkspace. */
export interface OnboardProcessWorkspaceOptions {
/** Create HOME as a `home/` directory beside bin instead of the root. */
separateHome?: boolean;
}

/** Creates a fresh temporary workspace with a created bin directory. */
export function createOnboardProcessWorkspace(
prefix: string,
options?: OnboardProcessWorkspaceOptions,
): OnboardProcessWorkspace {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const binDir = path.join(root, "bin");
fs.mkdirSync(binDir, { recursive: true });
const homeDir = options?.separateHome ? path.join(root, "home") : root;
fs.mkdirSync(homeDir, { recursive: true });
return {
root,
homeDir,
binDir,
writeExecutable: (name, contents) => {
const target = path.join(binDir, name);
fs.writeFileSync(target, contents, { mode: 0o755 });
return target;
},
path: (...segments) => path.join(root, ...segments),
remove: () => {
fs.rmSync(root, { recursive: true, force: true });
},
};
}

/**
* The inherited-process environment for a workspace: HOME at the workspace
* home and the fake bin prepended to PATH. Returns a fresh object per call.
*/
export function workspaceEnv(
workspace: OnboardProcessWorkspace,
overrides?: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv {
return {
...process.env,
HOME: workspace.homeDir,
PATH: `${workspace.binDir}:${process.env.PATH || ""}`,
...overrides,
};
}

/**
* A minimal spawn environment that inherits nothing but PATH plus the
* Windows keys Node needs to spawn at all. Returns a fresh object per call.
*/
export function minimalSpawnEnv(home: string, overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
HOME: home,
PATH: process.env.PATH || "/usr/bin:/bin",
NO_COLOR: "1",
};
for (const key of ["ComSpec", "PATHEXT", "SystemRoot", "WINDIR"]) {
const value = process.env[key];
if (value !== undefined) env[key] = value;
}
return { ...env, ...overrides };
}

/** Spawn options for runOnboardProcess. */
export interface RunOnboardProcessOptions {
env: NodeJS.ProcessEnv;
/** Working directory; defaults to the repository root. */
cwd?: string;
/** Kill the child after this many milliseconds. */
timeoutMs?: number;
}

/** The decoded outcome of one spawned process run. */
export interface OnboardProcessResult {
status: number | null;
signal: NodeJS.Signals | null;
error: Error | undefined;
stdout: string;
stderr: string;
/** stdout and stderr joined with a newline. */
output: string;
}

/** Runs `node <argv...>` synchronously from the repository root. */
export function runOnboardProcess(
argv: readonly string[],
options: RunOnboardProcessOptions,
): OnboardProcessResult {
const result = spawnSync(process.execPath, [...argv], {
cwd: options.cwd ?? testRepoRoot,
encoding: "utf-8",
env: options.env,
...(options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs }),
});
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
return {
status: result.status,
signal: result.signal,
error: result.error,
stdout,
stderr,
output: `${stdout}\n${stderr}`,
};
}

/**
* Parses the last stdout line that is a JSON object; scenario scripts print
* their result payload after any incidental logging. Throws with the full
* stdout when no payload line exists.
*/
export function trailingJsonPayload<T>(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;
}
55 changes: 24 additions & 31 deletions test/onboard-gateway-port-conflict-fast-fail.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createOnboardProcessWorkspace,
type OnboardProcessWorkspace,
runOnboardProcess,
workspaceEnv,
} from "./helpers/onboard-child-process-harness";
import { testTimeoutOptions } from "./helpers/timeouts";

const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js");
const GATEWAY_PORT = "18080";

describe("onboard gateway port conflict fast-fail (#6752)", () => {
let home: string;
let binDir: string;
let workspace: OnboardProcessWorkspace;
let openshellCallLog: string;

beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-6752-"));
binDir = path.join(home, "bin");
openshellCallLog = path.join(home, "openshell-calls.log");
fs.mkdirSync(binDir, { recursive: true });
workspace = createOnboardProcessWorkspace("nemoclaw-6752-");
openshellCallLog = workspace.path("openshell-calls.log");

for (const component of ["openshell", "openshell-gateway", "openshell-sandbox"]) {
fs.writeFileSync(
path.join(binDir, component),
workspace.writeExecutable(
component,
[
"#!/usr/bin/env bash",
"# openshell capabilities: request-body-credential-rewrite websocket-credential-rewrite allow_all_known_mcp_methods",
Expand All @@ -36,23 +37,21 @@ describe("onboard gateway port conflict fast-fail (#6752)", () => {
"esac",
"exit 1",
].join("\n"),
{ mode: 0o755 },
);
}

fs.writeFileSync(
path.join(binDir, "docker"),
workspace.writeExecutable(
"docker",
[
"#!/usr/bin/env bash",
'if [ "$1" = info ]; then echo "Server Version: 24.0.0"; exit 0; fi',
'if [ "$1" = ps ]; then exit 0; fi',
"exit 0",
].join("\n"),
{ mode: 0o755 },
);

fs.writeFileSync(
path.join(binDir, "lsof"),
workspace.writeExecutable(
"lsof",
[
"#!/usr/bin/env bash",
'port=""',
Expand All @@ -65,40 +64,34 @@ describe("onboard gateway port conflict fast-fail (#6752)", () => {
"fi",
"exit 1",
].join("\n"),
{ mode: 0o755 },
);
});

afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
workspace.remove();
});

it(
"reports a foreign listener before OpenShell gateway inspection can hang",
testTimeoutOptions(10_000),
() => {
const result = spawnSync(
process.execPath,
const result = runOnboardProcess(
[CLI, "onboard", "--name", "foreign-port", "--no-gpu", "--non-interactive"],
{
encoding: "utf-8",
timeout: 5_000,
env: {
...process.env,
HOME: home,
PATH: `${binDir}:${process.env.PATH || ""}`,
timeoutMs: 5_000,
env: workspaceEnv(workspace, {
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1",
NEMOCLAW_GATEWAY_PORT: GATEWAY_PORT,
NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"),
NEMOCLAW_OPENSHELL_BIN: path.join(workspace.binDir, "openshell"),
NEMOCLAW_OPENSHELL_CHANNEL: "stable",
NEMOCLAW_OPENSHELL_GATEWAY_BIN: path.join(binDir, "openshell-gateway"),
NEMOCLAW_OPENSHELL_SANDBOX_BIN: path.join(binDir, "openshell-sandbox"),
NEMOCLAW_OPENSHELL_GATEWAY_BIN: path.join(workspace.binDir, "openshell-gateway"),
NEMOCLAW_OPENSHELL_SANDBOX_BIN: path.join(workspace.binDir, "openshell-sandbox"),
NEMOCLAW_TEST_NO_SLEEP: "1",
},
}),
},
);

const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
const combined = result.output;
const calls = fs.existsSync(openshellCallLog)
? fs.readFileSync(openshellCallLog, "utf8")
: "";
Expand Down
46 changes: 15 additions & 31 deletions test/onboard-prepared-gateway-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, it } from "vitest";

import {
createOnboardProcessWorkspace,
minimalSpawnEnv,
runOnboardProcess,
trailingJsonPayload,
} from "./helpers/onboard-child-process-harness";

type HandoffScenario = "prepared" | "ordinary" | "mismatch";

type HandoffResult = {
Expand All @@ -21,8 +26,9 @@ const repoRoot = path.join(import.meta.dirname, "..");
const sourceRequireHook = path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs");

function runHandoffScenario(scenario: HandoffScenario): HandoffResult {
const home = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-gateway-handoff-${scenario}-`));
const scriptPath = path.join(home, "scenario.cjs");
const workspace = createOnboardProcessWorkspace(`nemoclaw-gateway-handoff-${scenario}-`);
const home = workspace.homeDir;
const scriptPath = workspace.path("scenario.cjs");
const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts"));
const sessionPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"),
Expand Down Expand Up @@ -117,38 +123,16 @@ const { onboard } = require(${onboardPath});
`,
);

const env: NodeJS.ProcessEnv = {
HOME: home,
PATH: process.env.PATH || "/usr/bin:/bin",
NO_COLOR: "1",
};
Object.assign(
env,
Object.fromEntries(
["ComSpec", "PATHEXT", "SystemRoot", "WINDIR"]
.map((key) => [key, process.env[key]] as const)
.filter((entry): entry is readonly [string, string] => entry[1] !== undefined),
),
);

const result = spawnSync(process.execPath, ["--require", sourceRequireHook, scriptPath], {
cwd: repoRoot,
encoding: "utf-8",
env,
timeout: 15_000,
const result = runOnboardProcess(["--require", sourceRequireHook, scriptPath], {
env: minimalSpawnEnv(home),
timeoutMs: 15_000,
});

try {
assert.equal(result.status, 0, result.stderr || result.stdout);
const payload = result.stdout
.trim()
.split(/\r?\n/)
.reverse()
.find((line) => line.startsWith("{") && line.endsWith("}"));
assert.ok(payload, `expected JSON payload in stdout:\n${result.stdout}`);
return JSON.parse(payload) as HandoffResult;
return trailingJsonPayload<HandoffResult>(result.stdout);
} finally {
fs.rmSync(home, { recursive: true, force: true });
workspace.remove();
}
}

Expand Down
Loading
Loading