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
1 change: 1 addition & 0 deletions nemoclaw/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default defineConfig({
},
],
environment: "node",
globalSetup: path.resolve(import.meta.dirname, "../test/helpers/vitest-temp-root.ts"),
include: ["src/**/*.test.ts"],
},
});
7 changes: 6 additions & 1 deletion src/lib/tunnel/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,12 @@ describe("sandbox name validation", () => {
});

it("accepts valid alphanumeric names", () => {
expect(() => getServiceStatuses({ sandboxName: "my-sandbox.1" })).not.toThrow();
const pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-valid-name-test-"));
try {
expect(() => getServiceStatuses({ pidDir, sandboxName: "my-sandbox.1" })).not.toThrow();
} finally {
rmSync(pidDir, { recursive: true, force: true });
}
});
});

Expand Down
43 changes: 26 additions & 17 deletions test/e2e-advisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { validateE2eAdvisorEventBoundary } from "../tools/e2e-advisor/workflow-boundary.mts";

const REPO_ROOT = path.resolve(import.meta.dirname, "..");
const E2E_ADVISOR_TARGET_DIR = "/tmp/e2e-advisor-target";

interface WorkflowStep {
env?: Record<string, string>;
Expand Down Expand Up @@ -74,22 +75,30 @@ function runPrepareTargetCheckout(env: {
'#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> "$FAKE_GIT_LOG"\nif [[ "$*" == *"rev-parse HEAD" ]]; then\n printf \'%s\\n\' "$FAKE_HEAD_SHA"\nfi\n',
{ mode: 0o755 },
);
const result = spawnSync("bash", ["-c", prepareTargetCheckoutScript()], {
cwd: REPO_ROOT,
encoding: "utf8",
env: {
...process.env,
...env,
FAKE_GIT_LOG: gitLog,
GITHUB_ENV: githubEnv,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
const targetDir = path.join(tmp, "target");
const workflowScript = prepareTargetCheckoutScript();
expect(workflowScript).toContain(E2E_ADVISOR_TARGET_DIR);
const result = spawnSync(
"bash",
["-c", workflowScript.replaceAll(E2E_ADVISOR_TARGET_DIR, targetDir)],
{
cwd: REPO_ROOT,
encoding: "utf8",
env: {
...process.env,
...env,
FAKE_GIT_LOG: gitLog,
GITHUB_ENV: githubEnv,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
},
},
});
);
return {
...result,
cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }),
gitCalls: fs.existsSync(gitLog) ? fs.readFileSync(gitLog, "utf8").trim().split(/\r?\n/u) : [],
githubEnv: fs.existsSync(githubEnv) ? fs.readFileSync(githubEnv, "utf8") : "",
targetDir,
};
}

Expand Down Expand Up @@ -264,13 +273,13 @@ describe("E2E recommendation advisor prompt", () => {
try {
expect(valid.status).toBe(0);
expect(valid.gitCalls).toEqual([
"-C /tmp/e2e-advisor-target init",
"-C /tmp/e2e-advisor-target remote add target https://github.com/NVIDIA/NemoClaw.git",
"-C /tmp/e2e-advisor-target fetch --no-tags target refs/heads/main:refs/remotes/target/main",
"-C /tmp/e2e-advisor-target fetch --no-tags target pull/5756/head:refs/remotes/target/pr-5756",
"-C /tmp/e2e-advisor-target checkout --detach refs/remotes/target/pr-5756",
`-C ${valid.targetDir} init`,
`-C ${valid.targetDir} remote add target https://github.com/NVIDIA/NemoClaw.git`,
`-C ${valid.targetDir} fetch --no-tags target refs/heads/main:refs/remotes/target/main`,
`-C ${valid.targetDir} fetch --no-tags target pull/5756/head:refs/remotes/target/pr-5756`,
`-C ${valid.targetDir} checkout --detach refs/remotes/target/pr-5756`,
]);
expect(valid.githubEnv).toBe("ADVISOR_WORKDIR=/tmp/e2e-advisor-target\nPR_NUMBER=5756\n");
expect(valid.githubEnv).toBe(`ADVISOR_WORKDIR=${valid.targetDir}\nPR_NUMBER=5756\n`);
} finally {
valid.cleanup();
}
Expand All @@ -287,7 +296,7 @@ describe("E2E recommendation advisor prompt", () => {
expect(mismatchedHead.stdout).toContain(
"Fetched pull ref does not match the triggering PR head SHA",
);
expect(mismatchedHead.gitCalls).toContain("-C /tmp/e2e-advisor-target rev-parse HEAD");
expect(mismatchedHead.gitCalls).toContain(`-C ${mismatchedHead.targetDir} rev-parse HEAD`);
expect(mismatchedHead.githubEnv).toBe("");
} finally {
mismatchedHead.cleanup();
Expand Down
74 changes: 74 additions & 0 deletions test/helpers/vitest-temp-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const KEEP_TEMP_ENV = "NEMOCLAW_TEST_KEEP_TEMP";
const TEMP_ENV_KEYS = ["TMPDIR", "TMP", "TEMP"] as const;

type TempEnvKey = (typeof TEMP_ENV_KEYS)[number];

function restoreTempEnv(previous: ReadonlyMap<TempEnvKey, string | undefined>): void {
for (const key of TEMP_ENV_KEYS) {
const value = previous.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}

export function setupVitestTempRoot(): () => void {
const previous = new Map<TempEnvKey, string | undefined>(
TEMP_ENV_KEYS.map((key) => [key, process.env[key]]),
);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-vitest-"));
const keepTemp = process.env[KEEP_TEMP_ENV] === "1";
let cleanupComplete = false;

for (const key of TEMP_ENV_KEYS) process.env[key] = root;

const cleanup = (): void => {
if (cleanupComplete) return;
if (keepTemp) {
process.stderr.write(`Kept Vitest temp files at ${root}\n`);
} else {
fs.rmSync(root, {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 50,
});
}
cleanupComplete = true;
};

// Vitest's signal handler can call process.exit() without running global
// teardown. Keep a synchronous fallback for that path.
const cleanupOnExit = (): void => {
try {
cleanup();
} catch (error) {
process.stderr.write(`Failed to remove Vitest temp files at ${root}: ${String(error)}\n`);
if (!process.exitCode) process.exitCode = 1;
}
};

process.once("exit", cleanupOnExit);

return () => {
try {
cleanup();
// If removal throws, leave the exit fallback armed to retry after the
// worker pool closes.
process.off("exit", cleanupOnExit);
} finally {
restoreTempEnv(previous);
}
};
}

export default setupVitestTempRoot;
32 changes: 16 additions & 16 deletions test/install-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,23 +396,23 @@ exit 98
});

it("scripts/install.sh --help works when run directly outside a repo checkout", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-payload-stdin-"));
const scriptContents = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8");
const result = spawnSync("bash", ["-s", "--", "--help"], {
cwd: tmp,
input: scriptContents,
encoding: "utf-8",
env: {
...process.env,
HOME: tmp,
PATH: TEST_SYSTEM_PATH,
},
});

const output = `${result.stdout}${result.stderr}`;
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
expect(output).toMatch(/NemoClaw Installer/);
expect(output).not.toMatch(/deprecated compatibility wrapper/);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-payload-stdin-"));
const stagedFixturePath = `/tmp/nemoclaw-installer-${path.basename(tmp).slice(-6)}`;
try {
fs.writeFileSync(stagedFixturePath, scriptContents, { flag: "wx", mode: 0o600 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const result = spawnSync("bash", ["-s", "--", "--help"], {
cwd: tmp,
input: scriptContents,
encoding: "utf-8",
env: { ...process.env, NEMOCLAW_INSTALLER_STAGED: stagedFixturePath },
});
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
expect(fs.existsSync(stagedFixturePath)).toBe(false);
} finally {
fs.rmSync(stagedFixturePath, { force: true });
fs.rmSync(tmp, { recursive: true, force: true });
}
});

it("exits 0 and shows install usage for --help", () => {
Expand Down
41 changes: 33 additions & 8 deletions test/install-stage-from-stdin.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// 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 { spawnSync } from "node:child_process";

import { describe, expect, it } from "vitest";

Expand All @@ -16,18 +16,19 @@ type StagingOutcome = {
stagedFileContent: string | null;
};

type EntryGuardOptions = {
bashSourceOverride?: string; // simulate disk-file invocation
envOverrides?: Record<string, string>;
curlSucceeds?: boolean;
curlOutputContent?: string;
};

// Inlines the entry-guard staging block from install.sh into a bash
// subshell, replacing `exec bash "$_staged" "$@"` with a capture step so
// the test sees the intended argv without actually launching a new
// installer process. Keep the inlined block in sync with
// scripts/install.sh:2486-2505.
function runEntryGuard(opts: {
bashSourceOverride?: string; // simulate disk-file invocation
envOverrides?: Record<string, string>;
curlSucceeds?: boolean;
curlOutputContent?: string;
}): StagingOutcome {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-stage-"));
function runEntryGuardInFixture(tmp: string, opts: EntryGuardOptions): StagingOutcome {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const execLog = path.join(tmp, "exec-intent.txt");
const fallthrough = path.join(tmp, "fallthrough.flag");

Expand Down Expand Up @@ -126,6 +127,29 @@ function runEntryGuard(opts: {
};
}

function ownedStagedFileOrPlaceholder(execLog: string, tmp: string): string {
const placeholder = path.join(tmp, "no-owned-staged-file");
const recordedPath = fs.existsSync(execLog)
? fs.readFileSync(execLog, "utf-8").split("\n")[0]
: placeholder;
return /^\/tmp\/nemoclaw-installer-[A-Za-z0-9]+$/.test(recordedPath) ? recordedPath : placeholder;
}

function runEntryGuard(opts: EntryGuardOptions): StagingOutcome {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-stage-"));
const execLog = path.join(tmp, "exec-intent.txt");

try {
return runEntryGuardInFixture(tmp, opts);
} finally {
try {
fs.rmSync(ownedStagedFileOrPlaceholder(execLog, tmp), { force: true });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
}

describe("install.sh entry-guard staging for curl|bash stdin self-stage (#4414)", () => {
it("stages to /tmp and would exec bash on the staged file when invoked via curl|bash", () => {
// Pipe-mode invocation: BASH_SOURCE[0] empty. Without staging,
Expand All @@ -136,6 +160,7 @@ describe("install.sh entry-guard staging for curl|bash stdin self-stage (#4414)"
expect(outcome.execIntent.length).toBeGreaterThan(0);
const stagedPath = outcome.execIntent[0];
expect(stagedPath).toMatch(/^\/tmp\/nemoclaw-installer-[A-Za-z0-9]+$/);
expect(fs.existsSync(stagedPath)).toBe(false);

// Original installer args are preserved across the would-be exec
expect(outcome.execIntent).toContain("--non-interactive");
Expand Down
4 changes: 2 additions & 2 deletions test/nemo-deepagents-alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function runDeepAgents(
timeout: execTimeout(),
env: {
...process.env,
HOME: "/tmp/nemo-deepagents-test-" + Date.now(),
HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemo-deepagents-test-")),
// Clear inherited markers so the launcher under test sets them itself.
NEMOCLAW_AGENT: undefined,
NEMOCLAW_INVOKED_AS: undefined,
Expand Down Expand Up @@ -57,7 +57,7 @@ function runNemoClaw(
timeout: execTimeout(),
env: {
...process.env,
HOME: "/tmp/nemo-deepagents-test-" + Date.now(),
HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemo-deepagents-test-")),
// Clear inherited markers so the base nemoclaw bin has a clean slate.
NEMOCLAW_AGENT: undefined,
NEMOCLAW_INVOKED_AS: undefined,
Expand Down
5 changes: 3 additions & 2 deletions test/nemohermes-alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { execSync } 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";

Expand All @@ -21,7 +22,7 @@ function runHermes(
timeout: execTimeout(),
env: {
...process.env,
HOME: "/tmp/nemohermes-test-" + Date.now(),
HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-test-")),
// Clear inherited markers so the launcher under test sets them itself.
NEMOCLAW_AGENT: undefined,
NEMOCLAW_INVOKED_AS: undefined,
Expand Down Expand Up @@ -49,7 +50,7 @@ function runNemoClaw(
timeout: execTimeout(),
env: {
...process.env,
HOME: "/tmp/nemohermes-test-" + Date.now(),
HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-test-")),
// Clear inherited markers so the base nemoclaw bin has a clean slate.
// The base launcher does not set NEMOCLAW_INVOKED_AS, so leaving an
// inherited value would silently re-brand the CLI as the alias.
Expand Down
Loading
Loading