mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
test: make the local pnpm test gate green on macOS hosts (#100069)
Fixes 21 macOS-only failing test cases across three classes: canonicalized fixture roots (macOS /var -> /private/var tmpdir symlink vs production realpathing), load-tolerant process-spawn tests (content-gated pid files, readiness-sequenced kill windows, bounded-window timer assertions), and cross-file worker leak guards (skip-channel env, gateway token env, imessage runtime singleton). Test-only; no production changes. Fixes #100025.
This commit is contained in:
committed by
GitHub
parent
0221544190
commit
bbb744269f
@@ -246,7 +246,18 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
|
||||
await harness.waitForMethod("turn/start");
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expectedTerminalIdleTimeoutMs);
|
||||
// Real timers: the delay is timeout minus real ms elapsed since the last
|
||||
// activity timestamp, so under suite load it lands slightly below the
|
||||
// configured value. Accept a small window instead of exact equality.
|
||||
const terminalIdleDelays = setTimeoutSpy.mock.calls
|
||||
.map(([, delay]) => delay)
|
||||
.filter((delay): delay is number => typeof delay === "number");
|
||||
expect(
|
||||
terminalIdleDelays.some(
|
||||
(delay) =>
|
||||
delay <= expectedTerminalIdleTimeoutMs && delay > expectedTerminalIdleTimeoutMs - 5_000,
|
||||
),
|
||||
).toBe(true);
|
||||
await harness.notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
@@ -4986,7 +4997,11 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 200;
|
||||
// Generous overall cap: promptness is proven by the 5ms completion-idle
|
||||
// timeout producing the idle promptError below, not by this bound. A tight
|
||||
// cap races attempt startup under parallel-suite load and turn/start never
|
||||
// happens.
|
||||
params.timeoutMs = 30_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 5,
|
||||
@@ -4995,7 +5010,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(request).toHaveBeenCalledWith("turn/start", expect.anything(), expect.anything()),
|
||||
{ interval: 1 },
|
||||
{ interval: 5, timeout: 10_000 },
|
||||
);
|
||||
await notify({
|
||||
method: "item/started",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
describeIMessageInboundDropDiagnostic,
|
||||
shouldThrottleIMessageInboundDropDiagnostic,
|
||||
} from "./monitor/monitor-provider.js";
|
||||
import { clearIMessageRuntime } from "./runtime.js";
|
||||
|
||||
const waitForTransportReadyMock = vi.hoisted(() =>
|
||||
vi.fn<typeof waitForTransportReady>(async () => {}),
|
||||
@@ -67,6 +68,10 @@ function createRpcClient(overrides?: {
|
||||
describe("monitorIMessageProvider watch.subscribe startup retry", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Sibling suites install the imessage runtime singleton without clearing
|
||||
// it; a leaked runtime resurrects another file's recovery cursor and
|
||||
// watch.subscribe then gains an unexpected since_rowid.
|
||||
clearIMessageRuntime();
|
||||
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
|
||||
createIMessageRpcClientMock.mockReset();
|
||||
attachIMessageMonitorAbortHandlerMock.mockReset().mockReturnValue(() => {});
|
||||
|
||||
@@ -101,10 +101,15 @@ const withoutGatewayAuthEnv = {
|
||||
};
|
||||
|
||||
const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture();
|
||||
// gateway run exports --token/--password into process.env as a side effect
|
||||
// (see runGatewayCli auth wiring); snapshot and clear them so shared vitest
|
||||
// workers do not leak credentials into later files' gateway connects.
|
||||
const serviceEnvSnapshot = captureEnv([
|
||||
"OPENCLAW_SERVICE_MARKER",
|
||||
"OPENCLAW_SERVICE_KIND",
|
||||
GATEWAY_SERVICE_RUNTIME_PID_ENV,
|
||||
"OPENCLAW_GATEWAY_TOKEN",
|
||||
"OPENCLAW_GATEWAY_PASSWORD",
|
||||
]);
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
@@ -304,6 +309,8 @@ describe("gateway run option collisions", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENCLAW_SERVICE_MARKER;
|
||||
delete process.env.OPENCLAW_SERVICE_KIND;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
deleteTestEnvValue(GATEWAY_SERVICE_RUNTIME_PID_ENV);
|
||||
resetRuntimeCapture();
|
||||
configState.cfg = {};
|
||||
|
||||
@@ -535,7 +535,11 @@ describe("logs cli", () => {
|
||||
const stdoutWrites = captureStdoutWrites();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||
|
||||
await runLogsCli(["logs", "--follow", "--plain", "--interval", "1", "--timeout", "250"]);
|
||||
// Pin UTC: the recovered-line assertion below checks a rendered
|
||||
// timestamp, which otherwise follows the host time zone.
|
||||
await withTimeZone("UTC", () =>
|
||||
runLogsCli(["logs", "--follow", "--plain", "--interval", "1", "--timeout", "250"]),
|
||||
);
|
||||
|
||||
expect(readConfiguredLogTail).not.toHaveBeenCalled();
|
||||
expect(execFileUtf8Tail).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -383,6 +383,11 @@ describe("runCli exit behavior", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENCLAW_SERVICE_MARKER;
|
||||
delete process.env.OPENCLAW_SERVICE_KIND;
|
||||
// Sibling CLI suites run `gateway run --token/--password`, which exports
|
||||
// credentials into process.env; leaked values change gateway preflight
|
||||
// auth in shared vitest workers.
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV];
|
||||
vi.clearAllMocks();
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Gateway config reload handler tests.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConfigWriteNotification } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { consumeGatewaySigusr1RestartIntent } from "../infra/restart.js";
|
||||
@@ -215,6 +215,15 @@ function createReloadHandlersForTest(
|
||||
return { ...handlers, cron, heartbeatRunner, setState, stopExitWatchers };
|
||||
}
|
||||
|
||||
// Other gateway test helpers (test-helpers.mocks.ts, test-helpers.server.ts)
|
||||
// set OPENCLAW_SKIP_CHANNELS / OPENCLAW_SKIP_PROVIDERS at module load. When a
|
||||
// shared vitest worker imports those helpers before this file runs, the leaked
|
||||
// env routes reloads into the skip branch and channel restarts never fire.
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENCLAW_SKIP_CHANNELS;
|
||||
delete process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
hoisted.startGmailWatcherWithLogs.mockClear();
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolvePlannedSegmentArgv,
|
||||
windowsEscapeArg,
|
||||
} from "./exec-approvals-analysis.js";
|
||||
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
|
||||
import { makeExecutable, makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
|
||||
|
||||
describe("exec argv analysis", () => {
|
||||
it("parses argv commands", () => {
|
||||
@@ -188,9 +188,15 @@ describe("Windows shell analysis", () => {
|
||||
|
||||
describe("Windows enforced shell command rendering", () => {
|
||||
it("builds enforced command for simple Windows command", () => {
|
||||
// Resolve from a fixture PATH: host python3 locations (e.g. Homebrew's
|
||||
// python@3.x Cellar path) contain characters that trigger quoting and make
|
||||
// the rendered command host-dependent.
|
||||
const dir = makeTempDir();
|
||||
const python3 = makeExecutable(dir, "python3");
|
||||
const analysis = analyzeWindowsShellCommand({
|
||||
command: "python3 a.py",
|
||||
platform: "win32",
|
||||
env: makePathEnv(dir),
|
||||
});
|
||||
|
||||
expect(analysis.ok).toBe(true);
|
||||
@@ -201,7 +207,7 @@ describe("Windows enforced shell command rendering", () => {
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.command).toMatch(/^& .+python3(?:\.\d+)? a\.py$/);
|
||||
expect(result.command).toBe(`& ${python3} a.py`);
|
||||
});
|
||||
|
||||
it("rejects Windows commands with unsafe tokens", () => {
|
||||
|
||||
@@ -272,13 +272,16 @@ describe("secret ref resolver", () => {
|
||||
await expect(
|
||||
resolveExecSecret(scriptPath, {
|
||||
env: { NODE_BINARY: process.execPath, PID_FILE: pidPath },
|
||||
noOutputTimeoutMs: 150,
|
||||
timeoutMs: 2000,
|
||||
// The first no-output window must absorb shell spawn latency under
|
||||
// parallel-suite load; the script's readiness byte then pins the
|
||||
// killing silence window after the pid write.
|
||||
noOutputTimeoutMs: 1000,
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
).rejects.toThrow('Exec provider "execmain" produced no output');
|
||||
|
||||
childPid = await readPidFile(pidPath);
|
||||
expect(await waitForPidToExit(childPid)).toBe(true);
|
||||
expect(await waitForPidToExit(childPid, 5_000)).toBe(true);
|
||||
} finally {
|
||||
killPidIfAlive(childPid);
|
||||
}
|
||||
|
||||
@@ -235,8 +235,11 @@ describe("runInstallPolicy", () => {
|
||||
command: forkScriptPath,
|
||||
env: { NODE_BINARY: process.execPath, PID_FILE: pidPath },
|
||||
allowInsecurePath: true,
|
||||
noOutputTimeoutMs: 150,
|
||||
timeoutMs: 2000,
|
||||
// The first no-output window must absorb shell spawn latency
|
||||
// under parallel-suite load; the script's readiness byte then
|
||||
// pins the killing silence window after the pid write.
|
||||
noOutputTimeoutMs: 1000,
|
||||
timeoutMs: 10_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -246,7 +249,7 @@ describe("runInstallPolicy", () => {
|
||||
|
||||
expect(result?.blocked?.reason).toContain("policy command produced no output");
|
||||
childPid = await readPidFile(pidPath);
|
||||
expect(await waitForPidToExit(childPid)).toBe(true);
|
||||
expect(await waitForPidToExit(childPid, 5_000)).toBe(true);
|
||||
} finally {
|
||||
killPidIfAlive(childPid);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import path from "node:path";
|
||||
|
||||
// Temp-dir helpers share one mkdtemp root per suite prefix and hand out numbered
|
||||
// case dirs. That reduces filesystem churn while preserving per-test cleanup.
|
||||
// Roots are canonicalized (realpath) because macOS tmpdir sits behind a symlink
|
||||
// (/var -> /private/var) while production code realpaths state/session paths;
|
||||
// symlinked roots break tests that compare or intercept fs paths by equality.
|
||||
type PrefixRootState = {
|
||||
path: string;
|
||||
activeCount: number;
|
||||
@@ -41,6 +44,7 @@ async function acquireAsyncPrefixRoot(options: {
|
||||
}
|
||||
const create = fs
|
||||
.mkdtemp(path.join(options.parentDir ?? os.tmpdir(), options.prefix))
|
||||
.then((root) => fs.realpath(root))
|
||||
.then((root) => ({ path: root, activeCount: 0 }));
|
||||
pendingAsyncPrefixRoots.set(key, create);
|
||||
try {
|
||||
@@ -60,7 +64,9 @@ function acquireSyncPrefixRoot(options: { prefix: string; parentDir?: string }):
|
||||
cached.activeCount += 1;
|
||||
return cached;
|
||||
}
|
||||
const root = fsSync.mkdtempSync(path.join(options.parentDir ?? os.tmpdir(), options.prefix));
|
||||
const root = fsSync.realpathSync(
|
||||
fsSync.mkdtempSync(path.join(options.parentDir ?? os.tmpdir(), options.prefix)),
|
||||
);
|
||||
const state = { path: root, activeCount: 1 };
|
||||
syncPrefixRoots.set(key, state);
|
||||
return state;
|
||||
@@ -144,7 +150,9 @@ export function createSuiteTempRootTracker(options: { prefix: string; parentDir?
|
||||
|
||||
return {
|
||||
async setup(): Promise<string> {
|
||||
root = await fs.mkdtemp(path.join(options.parentDir ?? os.tmpdir(), options.prefix));
|
||||
root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(options.parentDir ?? os.tmpdir(), options.prefix)),
|
||||
);
|
||||
nextIndex = 0;
|
||||
return root;
|
||||
},
|
||||
|
||||
@@ -10,7 +10,10 @@ export function createFixtureSuite(rootPrefix: string) {
|
||||
|
||||
return {
|
||||
async setup(): Promise<void> {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), rootPrefix));
|
||||
// Canonicalize: macOS tmpdir sits behind a symlink (/var -> /private/var)
|
||||
// and production realpaths state/session paths, so symlinked roots break
|
||||
// path-equality assertions.
|
||||
fixtureRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), rootPrefix)));
|
||||
},
|
||||
async cleanup(): Promise<void> {
|
||||
if (!fixtureRoot) {
|
||||
|
||||
@@ -269,7 +269,10 @@ export async function createOpenClawTestState(
|
||||
): Promise<OpenClawTestState> {
|
||||
const label = normalizeLabel(options.label ?? options.scenario);
|
||||
const prefix = options.prefix ?? `${DEFAULT_PREFIX}${label}-`;
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
// Canonicalize: macOS tmpdir sits behind a symlink (/var -> /private/var) and
|
||||
// production code realpaths state paths, so symlinked roots break tests that
|
||||
// intercept or compare fs paths by equality.
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)));
|
||||
const layout = options.layout ?? "home";
|
||||
const paths = resolveLayout(root, layout);
|
||||
|
||||
|
||||
@@ -3,12 +3,17 @@ import path from "node:path";
|
||||
|
||||
export async function writeForkingNoOutputScript(dir: string): Promise<string> {
|
||||
const scriptPath = path.join(dir, "fork-no-output.sh");
|
||||
// The readiness byte on stderr re-arms the caller's rolling no-output timer,
|
||||
// so the silence window that kills the tree starts only after the forked pid
|
||||
// is on disk; without it, slow spawns under suite load race the first window
|
||||
// and the test reads a missing/empty pid file.
|
||||
await fs.writeFile(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
'"$NODE_BINARY" -e "setInterval(() => {}, 1000)" &',
|
||||
'printf "%s" "$!" > "$PID_FILE"',
|
||||
"echo ready >&2",
|
||||
"sleep 30",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
|
||||
@@ -116,7 +116,11 @@ function writeFakeCrabbox(binDir: string, helpText: string): string {
|
||||
' deleted_cwd="$PWD"',
|
||||
" cd / || exit 1",
|
||||
' rm -rf "$deleted_cwd"',
|
||||
" deadline=100",
|
||||
// Fail-safe only: the wrapper normally kills this child mid-loop. The
|
||||
// deadline just has to outlast wrapper timer starvation under
|
||||
// parallel-suite load, or the child exits 66 before the wrapper reacts
|
||||
// and the test asserts on the wrong stderr message.
|
||||
" deadline=1000",
|
||||
' while [ "$deadline" -gt 0 ] && [ ! -d "$deleted_cwd" ]; do',
|
||||
" deadline=$((deadline - 1))",
|
||||
" sleep 0.01",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Dev Tooling Safety tests cover dev tooling safety script behavior.
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -42,6 +42,23 @@ async function waitForCondition(predicate: () => boolean, timeoutMs = 5_000): Pr
|
||||
throw new Error("timed out waiting for condition");
|
||||
}
|
||||
|
||||
// writeFileSync is not atomic for concurrent readers: the pid file can exist
|
||||
// before its payload is flushed, so wait for non-empty content or the parse
|
||||
// races into NaN under parallel-suite load. Generous budget: probe children
|
||||
// boot node + tsx before the descendant pid lands.
|
||||
async function waitForPidFile(pidPath: string, timeoutMs = 15_000): Promise<number> {
|
||||
let content = "";
|
||||
await waitForCondition(() => {
|
||||
try {
|
||||
content = readFileSync(pidPath, "utf8").trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return content.length > 0;
|
||||
}, timeoutMs);
|
||||
return Number.parseInt(content, 10);
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
@@ -191,9 +208,7 @@ describe("script-specific dev tooling hardening", () => {
|
||||
"--channel requires a value",
|
||||
);
|
||||
for (const flag of ["--channel", "--token", "--timeout-ms", "--state-dir"]) {
|
||||
expect(() => discordSmokeTesting.parseArgs([flag, "-h"])).toThrow(
|
||||
`${flag} requires a value`,
|
||||
);
|
||||
expect(() => discordSmokeTesting.parseArgs([flag, "-h"])).toThrow(`${flag} requires a value`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -767,8 +782,7 @@ describe("script-specific dev tooling hardening", () => {
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForCondition(() => existsSync(descendantPidPath));
|
||||
descendantPid = Number.parseInt(await fs.readFile(descendantPidPath, "utf8"), 10);
|
||||
descendantPid = await waitForPidFile(descendantPidPath);
|
||||
expect(Number.isInteger(descendantPid)).toBe(true);
|
||||
expect(isProcessAlive(descendantPid)).toBe(true);
|
||||
|
||||
@@ -810,8 +824,7 @@ describe("script-specific dev tooling hardening", () => {
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForCondition(() => existsSync(descendantPidPath));
|
||||
descendantPid = Number.parseInt(await fs.readFile(descendantPidPath, "utf8"), 10);
|
||||
descendantPid = await waitForPidFile(descendantPidPath);
|
||||
expect(Number.isInteger(descendantPid)).toBe(true);
|
||||
expect(isProcessAlive(descendantPid)).toBe(true);
|
||||
|
||||
@@ -927,8 +940,8 @@ describe("script-specific dev tooling hardening", () => {
|
||||
let closeCalls = 0;
|
||||
|
||||
try {
|
||||
await waitForCondition(() => isProcessAlive(child.pid!) && existsSync(descendantPidPath));
|
||||
descendantPid = Number.parseInt(await fs.readFile(descendantPidPath, "utf8"), 10);
|
||||
await waitForCondition(() => isProcessAlive(child.pid!));
|
||||
descendantPid = await waitForPidFile(descendantPidPath);
|
||||
expect(Number.isInteger(descendantPid)).toBe(true);
|
||||
expect(isProcessAlive(descendantPid)).toBe(true);
|
||||
|
||||
@@ -1007,8 +1020,8 @@ describe("script-specific dev tooling hardening", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForCondition(() => existsSync(readyPath) && existsSync(descendantPidPath));
|
||||
descendantPid = Number.parseInt(await fs.readFile(descendantPidPath, "utf8"), 10);
|
||||
await waitForCondition(() => existsSync(readyPath));
|
||||
descendantPid = await waitForPidFile(descendantPidPath);
|
||||
expect(Number.isInteger(descendantPid)).toBe(true);
|
||||
expect(isProcessAlive(descendantPid)).toBe(true);
|
||||
|
||||
|
||||
@@ -44,11 +44,19 @@ afterEach(() => {
|
||||
tempRoots.clear();
|
||||
});
|
||||
|
||||
async function waitForFile(filePath: string, timeoutMs: number) {
|
||||
async function waitForFile(filePath: string, timeoutMs: number): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return;
|
||||
try {
|
||||
// writeFileSync is not atomic for concurrent readers: the path can exist
|
||||
// before the payload is flushed. Wait for non-empty content, or pid
|
||||
// parsing races into NaN under parallel-suite load.
|
||||
const content = fs.readFileSync(filePath, "utf8").trim();
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
} catch {
|
||||
// Not created yet.
|
||||
}
|
||||
await delay(25);
|
||||
}
|
||||
@@ -219,12 +227,21 @@ describe("prepare-extension-package-boundary-artifacts", () => {
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
|
||||
// Fail the sibling only once the descendant reported its pid so the
|
||||
// group abort cannot race the descendant's boot under suite load.
|
||||
const failWhenDescendantReady = [
|
||||
"const fs = require('node:fs');",
|
||||
"setInterval(() => {",
|
||||
` try { if (fs.readFileSync(${JSON.stringify(descendantPidPath)}, 'utf8').trim()) { process.exit(2); } } catch {}`,
|
||||
"}, 25);",
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const command = runNodeStepsInParallel([
|
||||
{
|
||||
label: "delayed-fail",
|
||||
args: ["--eval", "setTimeout(() => process.exit(2), 150)"],
|
||||
timeoutMs: 5_000,
|
||||
args: ["--eval", failWhenDescendantReady],
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
label: "abort-group-prep",
|
||||
@@ -235,8 +252,7 @@ describe("prepare-extension-package-boundary-artifacts", () => {
|
||||
const expectedFailure = expect(command).rejects.toThrow(
|
||||
"delayed-fail failed with exit code 2",
|
||||
);
|
||||
await waitForFile(descendantPidPath, 1_000);
|
||||
descendantPid = Number.parseInt(fs.readFileSync(descendantPidPath, "utf8"), 10);
|
||||
descendantPid = Number.parseInt(await waitForFile(descendantPidPath, 10_000), 10);
|
||||
|
||||
await expectedFailure;
|
||||
await waitForDead(descendantPid, 2_000);
|
||||
@@ -272,11 +288,19 @@ describe("prepare-extension-package-boundary-artifacts", () => {
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
|
||||
// Fail the sibling only once the descendant installed its SIGTERM trap
|
||||
// (signalled via readyPath) so the group abort cannot race its boot.
|
||||
const failWhenDescendantReady = [
|
||||
"const fs = require('node:fs');",
|
||||
"setInterval(() => {",
|
||||
` try { if (fs.readFileSync(${JSON.stringify(readyPath)}, 'utf8').trim()) { process.exit(2); } } catch {}`,
|
||||
"}, 25);",
|
||||
].join("\n");
|
||||
const command = runNodeStepsInParallel([
|
||||
{
|
||||
label: "delayed-fail",
|
||||
args: ["--eval", "setTimeout(() => process.exit(2), 150)"],
|
||||
timeoutMs: 5_000,
|
||||
args: ["--eval", failWhenDescendantReady],
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
label: "abort-group-drain",
|
||||
@@ -285,9 +309,9 @@ describe("prepare-extension-package-boundary-artifacts", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForFile(readyPath, 1_000);
|
||||
await waitForFile(readyPath, 10_000);
|
||||
await expect(command).rejects.toThrow("delayed-fail failed with exit code 2");
|
||||
expect(fs.readFileSync(drainedPath, "utf8")).toBe("drained");
|
||||
expect(await waitForFile(drainedPath, 10_000)).toBe("drained");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -346,12 +370,14 @@ describe("prepare-extension-package-boundary-artifacts", () => {
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const command = runNodeStep("hung-group-prep", ["--eval", parentScript], 750);
|
||||
// The timeout clock starts at spawn, so it must leave room for two Node
|
||||
// boots (parent + descendant) under parallel-suite load; otherwise the
|
||||
// group is killed before the descendant ever writes its pid.
|
||||
const command = runNodeStep("hung-group-prep", ["--eval", parentScript], 5_000);
|
||||
const expectedFailure = expect(command).rejects.toThrow(
|
||||
"hung-group-prep timed out after 750ms",
|
||||
"hung-group-prep timed out after 5000ms",
|
||||
);
|
||||
await waitForFile(descendantPidPath, 500);
|
||||
descendantPid = Number.parseInt(fs.readFileSync(descendantPidPath, "utf8"), 10);
|
||||
descendantPid = Number.parseInt(await waitForFile(descendantPidPath, 4_000), 10);
|
||||
|
||||
await expectedFailure;
|
||||
await waitForDead(descendantPid, 2_000);
|
||||
@@ -395,9 +421,8 @@ describe("prepare-extension-package-boundary-artifacts", () => {
|
||||
runnerPid = runner.pid ?? 0;
|
||||
|
||||
try {
|
||||
await waitForFile(descendantPidPath, 2_000);
|
||||
descendantPid = Number.parseInt(fs.readFileSync(descendantPidPath, "utf8"), 10);
|
||||
const runnerExit = waitForProcessExit(runner, 2_000);
|
||||
descendantPid = Number.parseInt(await waitForFile(descendantPidPath, 10_000), 10);
|
||||
const runnerExit = waitForProcessExit(runner, 10_000);
|
||||
runner.kill("SIGTERM");
|
||||
|
||||
expect(await runnerExit).toEqual({ code: 143, signal: null });
|
||||
|
||||
@@ -34,6 +34,22 @@ async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<voi
|
||||
throw new Error("condition was not met before timeout");
|
||||
}
|
||||
|
||||
// writeFileSync is not atomic for concurrent readers: the pid file can exist
|
||||
// before its payload is flushed, so wait for non-empty content or the parse
|
||||
// races into NaN under parallel-suite load.
|
||||
async function waitForPidFile(pidPath: string, timeoutMs = 10_000): Promise<number> {
|
||||
let content = "";
|
||||
await waitFor(() => {
|
||||
try {
|
||||
content = fs.readFileSync(pidPath, "utf8").trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return content.length > 0;
|
||||
}, timeoutMs);
|
||||
return Number.parseInt(content, 10);
|
||||
}
|
||||
|
||||
async function waitForChildClose(child: ReturnType<typeof spawn>, timeoutMs = 5_000) {
|
||||
return await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
||||
(resolve, reject) => {
|
||||
@@ -481,8 +497,8 @@ describe("secret provider integration proof harness", () => {
|
||||
{ timeoutKillGraceMs: 50, timeoutMs: 2_000 },
|
||||
);
|
||||
result.catch(() => {});
|
||||
await waitFor(() => fs.existsSync(readyPath) && fs.existsSync(descendantPidPath));
|
||||
descendantPid = Number.parseInt(fs.readFileSync(descendantPidPath, "utf8"), 10);
|
||||
await waitFor(() => fs.existsSync(readyPath));
|
||||
descendantPid = await waitForPidFile(descendantPidPath);
|
||||
expect(Number.isInteger(descendantPid)).toBe(true);
|
||||
expect(isProcessAlive(descendantPid)).toBe(true);
|
||||
|
||||
@@ -792,8 +808,7 @@ describe("secret provider integration proof harness", () => {
|
||||
timeoutMs: 150,
|
||||
});
|
||||
|
||||
await waitFor(() => fs.existsSync(descendantPidPath));
|
||||
descendantPid = Number.parseInt(fs.readFileSync(descendantPidPath, "utf8"), 10);
|
||||
descendantPid = await waitForPidFile(descendantPidPath);
|
||||
expect(Number.isInteger(descendantPid)).toBe(true);
|
||||
expect(isProcessAlive(descendantPid)).toBe(true);
|
||||
|
||||
@@ -838,8 +853,7 @@ describe("secret provider integration proof harness", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(() => fs.existsSync(descendantPidPath));
|
||||
descendantPid = Number.parseInt(fs.readFileSync(descendantPidPath, "utf8"), 10);
|
||||
descendantPid = await waitForPidFile(descendantPidPath);
|
||||
expect(Number.isInteger(descendantPid)).toBe(true);
|
||||
expect(isProcessAlive(descendantPid)).toBe(true);
|
||||
|
||||
@@ -931,8 +945,8 @@ describe("secret provider integration proof harness", () => {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
await waitFor(() => fs.existsSync(readyPath) && fs.existsSync(descendantPidPath));
|
||||
descendantPid = Number.parseInt(fs.readFileSync(descendantPidPath, "utf8"), 10);
|
||||
await waitFor(() => fs.existsSync(readyPath));
|
||||
descendantPid = await waitForPidFile(descendantPidPath);
|
||||
expect(Number.isInteger(descendantPid)).toBe(true);
|
||||
expect(isProcessAlive(descendantPid)).toBe(true);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user