Use shared OS-available memory readings so macOS cache/inactive pages are not counted as used. - Add a core available-memory helper that prefers `process.availableMemory()` and flags `freemem` fallback reliability. - Route core metrics, dashboard system stats, and the dashboard TUI through the shared helper. - Move and expand memory tests across core, dashboard routes, and verification coverage while documenting the telemetry behavior. - Add a patch changeset for the published CLI package. Files changed: .changeset/fix-macos-memory-used.md | 5 ++ docs/architecture.md | 2 +- docs/dashboard-guide.md | 2 +- .../__tests__/available-memory.test.ts | 54 -------------- .../cli/src/commands/dashboard-tui/controller.ts | 37 +--------- .../core/src/__tests__/available-memory.test.ts | 83 ++++++++++++++++++++++ packages/core/src/__tests__/system-metrics.test.ts | 36 +++++++++- packages/core/src/available-memory.ts | 32 +++++++++ packages/core/src/index.ts | 1 + packages/core/src/system-metrics.ts | 9 ++- .../dashboard/src/__tests__/routes-system.test.ts | 27 +++++++ packages/dashboard/src/routes.ts | 7 +- .../src/__tests__/run-verification-command.test.ts | 18 +++++ 13 files changed, 218 insertions(+), 95 deletions(-) Fusion-Task-Id: FN-6834 Fusion-Task-Lineage: 2e651551-6c35-46d1-b517-535758c7ace2
610 lines
23 KiB
TypeScript
610 lines
23 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { tmpdir } from "node:os";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
BOUNDED_VERIFICATION_GUIDANCE,
|
|
MARATHON_SOFT_CAP_SEC,
|
|
MAX_TIMEOUT_SEC,
|
|
createRunVerificationTool,
|
|
detectMarathonVerification,
|
|
normalizeVerificationCommand,
|
|
runVerificationCommand,
|
|
__testOnlyReapVerificationProcessGroup,
|
|
type RunVerificationOptions,
|
|
} from "../run-verification-tool.js";
|
|
|
|
// Some tests use platform-appropriate shell syntax. On Windows, sh-style
|
|
// quoting and pipes through `printf` are different — these tests are skipped
|
|
// when running on win32. The implementation itself is portable via
|
|
// `shell: true` (Node picks cmd.exe on Windows, /bin/sh on POSIX).
|
|
const onPosix = process.platform !== "win32";
|
|
const itPosix = onPosix ? it : it.skip;
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Tests for runVerificationCommand - the core verification execution logic.
|
|
* These tests validate basic command execution, output capture, and error handling.
|
|
*
|
|
* NOTE: Timeout testing is intentionally excluded because the tool enforces its
|
|
* own timeouts which conflict with test timeouts. The timeout behavior is validated
|
|
* during integration testing in the main test suite.
|
|
*/
|
|
// Pick a sandbox-safe cwd. On macOS/Linux we use "/tmp" rather than
|
|
// os.tmpdir() because some sandboxed runners cannot reach the per-user
|
|
// $TMPDIR (e.g. /var/folders/.../T on macOS). On Windows /tmp does not exist
|
|
// so we fall back to os.tmpdir() which is always C:\Users\…\Temp there.
|
|
describe("runVerificationCommand", { timeout: 30000 }, () => {
|
|
const tempDir = onPosix ? "/tmp" : tmpdir();
|
|
const workspaceRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
|
|
describe("command normalization", () => {
|
|
it("rewrites package test -- --run filters to direct vitest with package-relative files", () => {
|
|
const result = normalizeVerificationCommand(
|
|
[
|
|
"pnpm --filter @fusion/dashboard test -- --run",
|
|
"packages/dashboard/src/__tests__/routes-tasks.test.ts",
|
|
"packages/dashboard/src/__tests__/routes-settings.test.ts",
|
|
].join(" "),
|
|
workspaceRoot,
|
|
);
|
|
|
|
expect(result.command).toBe(
|
|
[
|
|
"pnpm --filter @fusion/dashboard exec vitest run",
|
|
"src/__tests__/routes-tasks.test.ts",
|
|
"src/__tests__/routes-settings.test.ts",
|
|
"--silent=passed-only --reporter=dot",
|
|
].join(" "),
|
|
);
|
|
expect(result.warnings).toEqual([
|
|
expect.stringContaining("rewrote package test file filter"),
|
|
]);
|
|
});
|
|
|
|
it("leaves ordinary package tests unchanged when no file filter is forwarded", () => {
|
|
const command = "pnpm --filter @fusion/dashboard test";
|
|
expect(normalizeVerificationCommand(command, workspaceRoot)).toEqual({ command, warnings: [] });
|
|
});
|
|
|
|
it("leaves commands with unterminated shell quotes unchanged", () => {
|
|
const command = "pnpm --filter @fusion/dashboard test -- --run 'src/__tests__/routes-tasks.test.ts";
|
|
expect(normalizeVerificationCommand(command, workspaceRoot)).toEqual({ command, warnings: [] });
|
|
});
|
|
|
|
it("preserves pnpm global flags that precede --filter", () => {
|
|
const result = normalizeVerificationCommand(
|
|
"pnpm -w --filter @fusion/dashboard test -- --run packages/dashboard/src/__tests__/routes-tasks.test.ts",
|
|
workspaceRoot,
|
|
);
|
|
|
|
expect(result.command).toBe(
|
|
"pnpm -w --filter @fusion/dashboard exec vitest run src/__tests__/routes-tasks.test.ts --silent=passed-only --reporter=dot",
|
|
);
|
|
});
|
|
|
|
it("verifies the CLI package directory through package.json before rewriting", () => {
|
|
const result = normalizeVerificationCommand(
|
|
"pnpm --filter @runfusion/fusion test -- --run packages/cli/src/__tests__/cli.test.ts",
|
|
workspaceRoot,
|
|
);
|
|
|
|
expect(result.command).toBe(
|
|
"pnpm --filter @runfusion/fusion exec vitest run src/__tests__/cli.test.ts --silent=passed-only --reporter=dot",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("marathon verification detection", () => {
|
|
it.each([
|
|
["pnpm test", "root workspace test suite"],
|
|
["pnpm -w test", "root workspace test suite"],
|
|
["pnpm test:full", "full workspace verification script"],
|
|
["pnpm verify:workspace", "full workspace verification script"],
|
|
["pnpm --filter @fusion/core test", "whole-package test script"],
|
|
["for i in $(seq 1 20); do pnpm --filter @fusion/core exec vitest run src/foo.test.ts; done", "shell loop repeats"],
|
|
["while true; do pnpm test; done", "shell loop repeats"],
|
|
["seq 1 20 | xargs -I{} pnpm --filter @fusion/core exec vitest run src/foo.test.ts", "seq/xargs pipeline"],
|
|
["pnpm --filter @fusion/core exec vitest run src/a.test.ts && pnpm --filter @fusion/core exec vitest run src/a.test.ts", "&& chain repeats"],
|
|
])("flags marathon command %s", (command, reason) => {
|
|
const detection = detectMarathonVerification(command, "workspace");
|
|
|
|
expect(detection.isMarathon).toBe(true);
|
|
expect(detection.reason).toContain(reason);
|
|
expect(detection.guidance).toContain("allowFullSuite");
|
|
});
|
|
|
|
it.each([
|
|
"pnpm --filter @fusion/core exec vitest run src/__tests__/settings-consistency.test.ts --silent=passed-only --reporter=dot",
|
|
"pnpm --filter @fusion/dashboard test -- --run src/__tests__/routes-tasks.test.ts",
|
|
"pnpm lint",
|
|
"pnpm build",
|
|
])("passes targeted or non-test command %s", (command) => {
|
|
expect(detectMarathonVerification(command, "package").isMarathon).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("tool verification budgets and marathon caps", () => {
|
|
it("uses the project verification timeout default when provided", async () => {
|
|
const onVerificationStart = vi.fn();
|
|
const tool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6608",
|
|
recordActivity: vi.fn(),
|
|
verificationCommandTimeoutMs: 1_500,
|
|
onVerificationStart,
|
|
onVerificationEnd: vi.fn(),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
await tool.execute("call-budget", { command: "exit 0", scope: "workspace" });
|
|
|
|
expect(onVerificationStart).toHaveBeenCalledWith(2_000);
|
|
});
|
|
|
|
it("falls back to legacy package/workspace defaults when the setting is absent or disabled", async () => {
|
|
const packageStart = vi.fn();
|
|
const disabledWorkspaceStart = vi.fn();
|
|
const packageTool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6608",
|
|
recordActivity: vi.fn(),
|
|
onVerificationStart: packageStart,
|
|
onVerificationEnd: vi.fn(),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
const disabledTool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6608",
|
|
recordActivity: vi.fn(),
|
|
verificationCommandTimeoutMs: 0,
|
|
onVerificationStart: disabledWorkspaceStart,
|
|
onVerificationEnd: vi.fn(),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
await packageTool.execute("call-package-default", { command: "exit 0", scope: "package" });
|
|
await disabledTool.execute("call-workspace-default", { command: "exit 0", scope: "workspace" });
|
|
|
|
expect(packageStart).toHaveBeenCalledWith(300_000);
|
|
expect(disabledWorkspaceStart).toHaveBeenCalledWith(900_000);
|
|
});
|
|
|
|
it("applies the hard timeout cap to configured defaults and explicit overrides", async () => {
|
|
const configuredStart = vi.fn();
|
|
const explicitStart = vi.fn();
|
|
const configuredTool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6608",
|
|
recordActivity: vi.fn(),
|
|
verificationCommandTimeoutMs: (MAX_TIMEOUT_SEC + 60) * 1000,
|
|
onVerificationStart: configuredStart,
|
|
onVerificationEnd: vi.fn(),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
const explicitTool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6608",
|
|
recordActivity: vi.fn(),
|
|
onVerificationStart: explicitStart,
|
|
onVerificationEnd: vi.fn(),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
await configuredTool.execute("call-configured-cap", { command: "exit 0", scope: "package" });
|
|
await explicitTool.execute("call-explicit-cap", { command: "exit 0", scope: "package", timeoutSec: MAX_TIMEOUT_SEC + 1 });
|
|
|
|
expect(configuredStart).toHaveBeenCalledWith(MAX_TIMEOUT_SEC * 1000);
|
|
expect(explicitStart).toHaveBeenCalledWith(MAX_TIMEOUT_SEC * 1000);
|
|
});
|
|
|
|
itPosix("reports an actionable timeout without relying on stuck detection", async () => {
|
|
const tool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6608",
|
|
recordActivity: vi.fn(),
|
|
onVerificationStart: vi.fn(),
|
|
onVerificationEnd: vi.fn(),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
const result = await tool.execute("call-timeout", { command: "sh -c 'sleep 10 & wait'", scope: "package", timeoutSec: 1 });
|
|
|
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
expect(result.details).toEqual(expect.objectContaining({ success: false, timedOut: true }));
|
|
expect(text).toContain("Command timed out after 1s");
|
|
expect(text).toContain(BOUNDED_VERIFICATION_GUIDANCE);
|
|
});
|
|
|
|
itPosix("soft-caps marathon commands unless allowFullSuite is provided", async () => {
|
|
const cappedStart = vi.fn();
|
|
const allowedStart = vi.fn();
|
|
const recordActivity = vi.fn();
|
|
const command = "pnpm() { echo pulse; }; pnpm test";
|
|
const cappedTool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6608",
|
|
recordActivity: vi.fn(),
|
|
onVerificationStart: cappedStart,
|
|
onVerificationEnd: vi.fn(),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
const allowedTool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6608",
|
|
recordActivity,
|
|
onVerificationStart: allowedStart,
|
|
onVerificationEnd: vi.fn(),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
const capped = await cappedTool.execute("call-capped", { command, scope: "workspace", timeoutSec: 600 });
|
|
const allowed = await allowedTool.execute("call-allowed", { command, scope: "workspace", timeoutSec: 600, allowFullSuite: true });
|
|
|
|
const cappedText = capped.content[0]?.type === "text" ? capped.content[0].text : "";
|
|
const allowedText = allowed.content[0]?.type === "text" ? allowed.content[0].text : "";
|
|
expect(cappedStart).toHaveBeenCalledWith(MARATHON_SOFT_CAP_SEC * 1000);
|
|
expect(cappedText).toContain("marathon verification detected");
|
|
expect(allowedStart).toHaveBeenCalledWith(600_000);
|
|
expect(allowedText).toContain("allowFullSuite=true acknowledged");
|
|
expect(allowed.details).toEqual(expect.objectContaining({ success: true, timedOut: false }));
|
|
expect(recordActivity).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("tool verification lifecycle callbacks", () => {
|
|
it("brackets a successful verification run with start and end callbacks", async () => {
|
|
const onVerificationStart = vi.fn();
|
|
const onVerificationEnd = vi.fn();
|
|
const tool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6598",
|
|
recordActivity: vi.fn(),
|
|
onVerificationStart,
|
|
onVerificationEnd,
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
await tool.execute("call-1", { command: "exit 0", scope: "package" });
|
|
|
|
expect(onVerificationStart).toHaveBeenCalledTimes(1);
|
|
expect(onVerificationStart).toHaveBeenCalledWith(300_000);
|
|
expect(onVerificationEnd).toHaveBeenCalledTimes(1);
|
|
expect(onVerificationStart.mock.invocationCallOrder[0]).toBeLessThan(onVerificationEnd.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it("fires the end callback when the verification command fails", async () => {
|
|
const onVerificationStart = vi.fn();
|
|
const onVerificationEnd = vi.fn();
|
|
const tool = createRunVerificationTool({
|
|
worktreePath: tempDir,
|
|
rootDir: workspaceRoot,
|
|
taskId: "FN-6598",
|
|
recordActivity: vi.fn(),
|
|
onVerificationStart,
|
|
onVerificationEnd,
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
const result = await tool.execute("call-2", { command: "exit 7", scope: "package" });
|
|
|
|
expect(result.details).toEqual(expect.objectContaining({ success: false, exitCode: 7 }));
|
|
expect(onVerificationStart).toHaveBeenCalledTimes(1);
|
|
expect(onVerificationEnd).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("basic command execution", () => {
|
|
it("executes a simple echo command and captures output", async () => {
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "echo test-output",
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain("test-output");
|
|
expect(result.timedOut).toBe(false);
|
|
expect(result.durationMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("returns correct exit code for failed command", async () => {
|
|
// `exit N` is recognised by both POSIX sh and Windows cmd.exe.
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "exit 42",
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.exitCode).toBe(42);
|
|
});
|
|
|
|
it("returns success when expectFailure=true and command exits non-zero", async () => {
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "exit 3",
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
expectFailure: true,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.exitCode).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("timeouts", () => {
|
|
itPosix("times out and kills a quiet long-running process group", async () => {
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "sh -c 'sleep 10 & wait'",
|
|
cwd: tempDir,
|
|
timeoutMs: 100,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.timedOut).toBe(true);
|
|
expect(result.durationMs).toBeLessThan(5_000);
|
|
});
|
|
|
|
itPosix("reaps background children after a command exits cleanly", async () => {
|
|
/*
|
|
* FNXC:Verification 2026-06-21-10:00:
|
|
* A clean shell exit is not enough evidence that verification is fully done; background children must be gone too or later task completion can stall behind leaked test workers.
|
|
*/
|
|
const childScript = "setInterval(() => {}, 1000)";
|
|
const parentScript = [
|
|
"const { spawn } = require('node:child_process');",
|
|
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
|
|
"console.log(child.pid);",
|
|
"child.unref();",
|
|
].join(" ");
|
|
const result = await runVerificationCommand({
|
|
command: `${process.execPath} -e ${JSON.stringify(parentScript)}`,
|
|
cwd: tempDir,
|
|
timeoutMs: 30_000,
|
|
onHeartbeat: vi.fn(),
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
const leakedPid = Number.parseInt(result.stdout.trim(), 10);
|
|
expect(Number.isFinite(leakedPid)).toBe(true);
|
|
expect(result.timedOut).toBe(false);
|
|
|
|
for (let i = 0; i < 15 && isProcessAlive(leakedPid); i++) {
|
|
await sleep(100);
|
|
}
|
|
expect(isProcessAlive(leakedPid)).toBe(false);
|
|
});
|
|
|
|
it("escalates non-timeout process-group reaping with fake timers", () => {
|
|
/*
|
|
* FNXC:Verification 2026-06-21-10:26:
|
|
* Keep timer assertions on a narrow seam with fake timers so the integration test above never polls wall-clock time while still pinning SIGTERM -> SIGKILL escalation.
|
|
*/
|
|
vi.useFakeTimers();
|
|
const kill = vi.fn();
|
|
const supervised = { kill } as unknown as Parameters<typeof __testOnlyReapVerificationProcessGroup>[0];
|
|
|
|
try {
|
|
__testOnlyReapVerificationProcessGroup(supervised);
|
|
expect(kill).toHaveBeenCalledTimes(1);
|
|
expect(kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
vi.advanceTimersByTime(499);
|
|
expect(kill).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(kill).toHaveBeenCalledTimes(2);
|
|
expect(kill).toHaveBeenLastCalledWith("SIGKILL");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("output capture", () => {
|
|
itPosix("captures multi-line stdout (POSIX shell)", async () => {
|
|
// POSIX uses `;` as a command separator; cmd.exe uses `&`. Skip on Windows.
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "echo line1; echo line2; echo line3",
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.stdout).toContain("line1");
|
|
expect(result.stdout).toContain("line2");
|
|
expect(result.stdout).toContain("line3");
|
|
});
|
|
|
|
itPosix("captures stderr separately (POSIX shell)", async () => {
|
|
// `>&2` redirect syntax is POSIX-specific. Skip on Windows.
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "echo to-stdout; echo to-stderr >&2",
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.stdout).toContain("to-stdout");
|
|
expect(result.stderr).toContain("to-stderr");
|
|
});
|
|
});
|
|
|
|
describe("heartbeat callbacks", () => {
|
|
itPosix("fires onHeartbeat for each output line (POSIX shell)", async () => {
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "echo a; echo b; echo c",
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.success).toBe(true);
|
|
// Should call heartbeat at least once per line
|
|
expect(onHeartbeat.mock.calls.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
itPosix("fires onLine callback with each line when provided (POSIX shell)", async () => {
|
|
const onHeartbeat = vi.fn();
|
|
const onLine = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "echo hello; echo world",
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
onHeartbeat,
|
|
onLine,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(onLine.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
itPosix("handles missing commands gracefully (POSIX sh reports exit 127)", async () => {
|
|
// The implementation runs commands via the platform shell. POSIX sh
|
|
// returns exit 127 for "command not found"; cmd.exe returns 1 (or
|
|
// 9009 in some cases). This test pins the POSIX behaviour.
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "/nonexistent/command/path",
|
|
cwd: tempDir,
|
|
timeoutMs: 5000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.exitCode).toBe(127);
|
|
expect(result.timedOut).toBe(false);
|
|
});
|
|
|
|
it("includes all result fields", async () => {
|
|
// `exit 0` is portable across POSIX sh and cmd.exe; `true` is POSIX-only.
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "exit 0",
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result).toHaveProperty("success");
|
|
expect(result).toHaveProperty("exitCode");
|
|
expect(result).toHaveProperty("durationMs");
|
|
expect(result).toHaveProperty("stdout");
|
|
expect(result).toHaveProperty("stderr");
|
|
expect(result).toHaveProperty("timedOut");
|
|
expect(result).toHaveProperty("killed");
|
|
expect(result).toHaveProperty("command");
|
|
expect(result).toHaveProperty("cwd");
|
|
expect(result).toHaveProperty("warnings");
|
|
});
|
|
|
|
it("preserves command and cwd in result", async () => {
|
|
const onHeartbeat = vi.fn();
|
|
const command = "echo preserved";
|
|
const opts: RunVerificationOptions = {
|
|
command,
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.command).toBe(command);
|
|
expect(result.cwd).toBe(tempDir);
|
|
});
|
|
});
|
|
|
|
describe("complex shell commands", () => {
|
|
itPosix("handles piped commands (POSIX shell)", async () => {
|
|
// The implementation runs commands through the platform shell. POSIX
|
|
// pipes + printf differ from Windows cmd.exe syntax, so this test is
|
|
// POSIX-only.
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "printf 'test1\\ntest2\\ntest3\\n' | grep test",
|
|
cwd: tempDir,
|
|
timeoutMs: 5000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stdout).toContain("test1");
|
|
});
|
|
|
|
itPosix("executes commands with environment variables (POSIX shell)", async () => {
|
|
// POSIX shell expansion ($USER) differs from Windows (%USERNAME%).
|
|
const onHeartbeat = vi.fn();
|
|
const opts: RunVerificationOptions = {
|
|
command: "echo $USER",
|
|
cwd: tempDir,
|
|
timeoutMs: 30000,
|
|
onHeartbeat,
|
|
};
|
|
|
|
const result = await runVerificationCommand(opts);
|
|
|
|
expect(result.success).toBe(true);
|
|
// Should have output (USER is typically set)
|
|
expect(result.stdout.trim().length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|