mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
ac52281448
* fix(test): deterministic temp-dir cleanup across node --test lanes node --test has no global setup/teardown hook, so unlike Vitest (#1593) every node --test package.json script (maestro:conformance, mutation:test, check:affected:test, check:coverage-changed:test, check:layering, depgraph:test, check:tmpdir-leaks:test, check:contention-retry, test:fixture-cache, test:smoke(:web), test:integration:node, test:concurrency-torture) still created scratch directories against the real, unredirected os.tmpdir(), with cleanup only as reliable as each call site's own try/finally — which a crash, OOM, or timeout kill bypasses entirely. Add scripts/node-test-tmpdir.ts: it wraps the whole `node --test` invocation as a child process, redirecting TMPDIR to one disposable, pid-tagged directory (shared root/prefix with the Vitest lane) and removing it from the process 'exit' event, which fires on normal completion, a thrown error, or a forwarded SIGINT/SIGTERM alike. Every node --test script now runs through it. check-tmpdir-leaks.ts already scans by root/prefix, so it covers both mechanisms with no changes to its detection logic. Verified: a node --test process that mkdtemp's then gets SIGKILL'd leaves a directory behind unwrapped; wrapped and SIGTERM'd, TMPDIR is redirected and the directory is gone with no orphaned processes. All 13 wrapped lanes and the full Vitest suite (5,591 tests) pass with zero residual agent-device-test-run-* directories after the run. Fixes #1595 * test(tmpdir): ratchet every node --test script through the wrapper The 13 lanes wrapped in package.json were a one-time hand sweep with nothing enforcing the pattern going forward — a 14th node --test script added later without scripts/node-test-tmpdir.ts would silently reopen #1595 for that one lane. Add a structural check to scripts/node-test-tmpdir.test.ts (now part of check:tmpdir-leaks:test) that reads package.json and fails if any script invokes `node ... --test` without routing through the wrapper. Dumb string matching over the scripts map, no shell parsing, with an explicit (currently empty) NODE_TEST_WRAPPER_BYPASS_ALLOWLIST for any lane that must legitimately bypass it. Verified it both passes on the current package.json and fails when a synthetic unwrapped `node --test` script is added. * fix(test): preserve the Swift cache and close the raw node --test bypasses Review on #1661 found two gaps: 1. The wrapper only overrode TMPDIR, so it discarded and forced a recompile of the durable Swift compiler cache every run instead of mirroring vitest-tmpdir-global-setup.ts's carve-out for it. Read os.tmpdir() before the child's TMPDIR redirect takes effect and set AGENT_DEVICE_SWIFT_CACHE_DIR from that (only when unset), same as the Vitest lane — the two now share one durable cache instead of each discarding and recompiling their own. Added a probe assertion (scripts/node-test-tmpdir.test.ts) that fails without the fix and passes with it (verified both ways). 2. docs/agents/testing.md documented raw `node --test` commands for the iOS smoke files, and the android/ios/conformance-regenerate/nightly workflows invoked `node --test` directly outside package.json. Routed all of them through scripts/node-test-tmpdir.ts so the documented local commands and CI lanes get the same crash/timeout-safe cleanup the package.json scripts already have.
211 lines
9.3 KiB
TypeScript
211 lines
9.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { test } from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { runCmd } from '../src/utils/exec.ts';
|
|
import { TEST_RUN_TMP_PREFIX, TEST_RUN_TMP_ROOT } from './vitest-tmpdir-global-setup.ts';
|
|
|
|
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const WRAPPER = path.join(REPOSITORY_ROOT, 'scripts', 'node-test-tmpdir.ts');
|
|
|
|
// package.json scripts that may legitimately invoke `node --test` without
|
|
// routing through the wrapper below. Empty on purpose: every current
|
|
// node --test lane is wrapped (#1595). Add a script name here only alongside
|
|
// a comment explaining why that lane can't be wrapped — the ratchet test
|
|
// below fails closed on anything else, so a 14th `node --test` script added
|
|
// later without the wrapper fails CI instead of silently leaking again.
|
|
const NODE_TEST_WRAPPER_BYPASS_ALLOWLIST = new Set<string>([]);
|
|
|
|
// Dumb string matching on purpose (no shell parsing, per the scripts map's
|
|
// own style: '&&'-chained commands, nothing fancier). True when a `node ...`
|
|
// segment enables the built-in test runner via a word-bounded `--test` flag
|
|
// and hasn't already been routed through the wrapper.
|
|
function isUnwrappedNodeTestSegment(segment: string): boolean {
|
|
const trimmed = segment.trim();
|
|
if (!/^node(\s|$)/.test(trimmed)) return false;
|
|
if (trimmed.includes('node-test-tmpdir.ts')) return false;
|
|
return /(^|\s)--test(\s|$)/.test(trimmed);
|
|
}
|
|
|
|
// Date.now() alone collides when this file's top-level tests happen to start
|
|
// within the same millisecond (observed in practice), which overwrites one
|
|
// probe's source with another's; the random suffix makes each probe path
|
|
// unique regardless of scheduling.
|
|
function writeProbe(evidencePath: string): string {
|
|
const probeName = `node-test-tmpdir-probe-${process.pid}-${crypto.randomUUID()}.test.ts`;
|
|
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
|
|
fs.writeFileSync(
|
|
probePath,
|
|
`import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { test } from 'node:test';
|
|
|
|
test('probe records its inherited TMPDIR', () => {
|
|
fs.writeFileSync(${JSON.stringify(evidencePath)}, os.tmpdir());
|
|
});
|
|
`,
|
|
);
|
|
return probePath;
|
|
}
|
|
|
|
test('the wrapper redirects a node --test child TMPDIR and removes it after the run', async () => {
|
|
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-lifecycle-test-'));
|
|
const evidencePath = path.join(evidenceRoot, 'child-tmpdir.txt');
|
|
const probePath = writeProbe(evidencePath);
|
|
let childTmpDir: string | undefined;
|
|
|
|
try {
|
|
const result = await runCmd(
|
|
process.execPath,
|
|
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
|
|
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000 },
|
|
);
|
|
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
|
|
|
|
childTmpDir = fs.readFileSync(evidencePath, 'utf8');
|
|
assert.equal(path.dirname(childTmpDir), TEST_RUN_TMP_ROOT);
|
|
assert.match(path.basename(childTmpDir), new RegExp(`^${TEST_RUN_TMP_PREFIX}\\d+-`));
|
|
assert.equal(
|
|
fs.existsSync(childTmpDir),
|
|
false,
|
|
'the wrapper must remove the run directory after the child exits',
|
|
);
|
|
} finally {
|
|
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
|
|
fs.rmSync(probePath, { force: true });
|
|
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('the wrapper still cleans up and forwards a nonzero exit code when the child fails', async () => {
|
|
// Diffing the whole shared TEST_RUN_TMP_ROOT listing is racy here: this
|
|
// file itself runs as one of several node --test files/workers sharing
|
|
// that root (e.g. alongside vitest-tmpdir-global-setup.test.ts in
|
|
// check:tmpdir-leaks:test), any of which can create and remove their own
|
|
// sibling run directory mid-diff. Recording the probe's actual TMPDIR (the
|
|
// same technique as the test above) and checking only that one path avoids
|
|
// the shared-directory race entirely.
|
|
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-lifecycle-fail-'));
|
|
const evidencePath = path.join(evidenceRoot, 'child-tmpdir.txt');
|
|
const probeName = `node-test-tmpdir-probe-fail-${process.pid}-${crypto.randomUUID()}.test.ts`;
|
|
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
|
|
fs.writeFileSync(
|
|
probePath,
|
|
`import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import { test } from 'node:test';
|
|
|
|
test('deliberately failing probe', () => {
|
|
fs.writeFileSync(${JSON.stringify(evidencePath)}, os.tmpdir());
|
|
assert.fail('intentional failure to verify exit-code forwarding');
|
|
});
|
|
`,
|
|
);
|
|
|
|
let childTmpDir: string | undefined;
|
|
try {
|
|
const result = await runCmd(
|
|
process.execPath,
|
|
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
|
|
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000, allowFailure: true },
|
|
);
|
|
assert.notEqual(result.exitCode, 0, 'a failing child must propagate a nonzero exit code');
|
|
|
|
childTmpDir = fs.readFileSync(evidencePath, 'utf8');
|
|
assert.equal(path.dirname(childTmpDir), TEST_RUN_TMP_ROOT);
|
|
assert.match(path.basename(childTmpDir), new RegExp(`^${TEST_RUN_TMP_PREFIX}\\d+-`));
|
|
assert.equal(fs.existsSync(childTmpDir), false, `wrapper left behind: ${childTmpDir}`);
|
|
} finally {
|
|
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
|
|
fs.rmSync(probePath, { force: true });
|
|
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('the wrapper keeps AGENT_DEVICE_SWIFT_CACHE_DIR outside the disposable TMPDIR', async () => {
|
|
// Mirrors vitest-tmpdir-global-setup.ts's own carve-out (and its test's
|
|
// technique below): the Swift compiler cache must survive across runs, so
|
|
// it must resolve outside whichever directory this invocation's TMPDIR
|
|
// redirect will remove afterward. Passing '' forces the "unset" branch
|
|
// regardless of what this test process itself inherited.
|
|
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-swift-cache-'));
|
|
const evidencePath = path.join(evidenceRoot, 'swift-cache-dir.txt');
|
|
const probeName = `node-test-tmpdir-probe-swift-cache-${process.pid}-${crypto.randomUUID()}.test.ts`;
|
|
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
|
|
fs.writeFileSync(
|
|
probePath,
|
|
`import fs from 'node:fs';
|
|
import { test } from 'node:test';
|
|
|
|
test('probe records its inherited AGENT_DEVICE_SWIFT_CACHE_DIR', () => {
|
|
fs.writeFileSync(${JSON.stringify(evidencePath)}, process.env.AGENT_DEVICE_SWIFT_CACHE_DIR ?? '');
|
|
});
|
|
`,
|
|
);
|
|
|
|
// Computed the same way the wrapper computes it: from THIS process's
|
|
// os.tmpdir(), before the child's TMPDIR gets redirected. If this file is
|
|
// itself already running nested inside another wrapper's redirect (e.g.
|
|
// as part of check:tmpdir-leaks:test), that's the correct anchor too — the
|
|
// cache chains to whichever temp scope was current right before this
|
|
// specific invocation, exactly like vitest's setup() would if nested the
|
|
// same way.
|
|
const expectedCacheDir = path.join(os.tmpdir(), 'agent-device-swift-cache');
|
|
|
|
try {
|
|
const result = await runCmd(
|
|
process.execPath,
|
|
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
|
|
{
|
|
cwd: REPOSITORY_ROOT,
|
|
timeoutMs: 30_000,
|
|
env: { ...process.env, AGENT_DEVICE_SWIFT_CACHE_DIR: '' },
|
|
},
|
|
);
|
|
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
|
|
|
|
const cacheDir = fs.readFileSync(evidencePath, 'utf8');
|
|
assert.equal(
|
|
cacheDir,
|
|
expectedCacheDir,
|
|
'the wrapper must set AGENT_DEVICE_SWIFT_CACHE_DIR from the pre-redirect os.tmpdir(), ' +
|
|
'not leave it to default inside the disposable TMPDIR it is about to remove',
|
|
);
|
|
} finally {
|
|
fs.rmSync(probePath, { force: true });
|
|
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// The 13 lanes wrapped in package.json when this fix landed were a one-time
|
|
// hand sweep; nothing stopped a 14th `node --test` script from being added
|
|
// later without the wrapper, silently reopening #1595 for that one lane.
|
|
// This turns the sweep into an invariant instead.
|
|
test('every node --test package.json script routes through scripts/node-test-tmpdir.ts', () => {
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
|
|
) as {
|
|
scripts?: Record<string, string>;
|
|
};
|
|
const scripts = manifest.scripts ?? {};
|
|
|
|
const unwrapped = Object.entries(scripts)
|
|
.filter(([name]) => !NODE_TEST_WRAPPER_BYPASS_ALLOWLIST.has(name))
|
|
.filter(([, command]) => command.split('&&').some(isUnwrappedNodeTestSegment))
|
|
.map(([name]) => name);
|
|
|
|
assert.deepEqual(
|
|
unwrapped,
|
|
[],
|
|
`these package.json scripts invoke \`node --test\` directly instead of through ` +
|
|
`scripts/node-test-tmpdir.ts, so a crash/timeout kill during their run leaks a scratch ` +
|
|
`directory again: ${unwrapped.join(', ')}. Route them through the wrapper, or add to ` +
|
|
`NODE_TEST_WRAPPER_BYPASS_ALLOWLIST above with a reason if one must legitimately bypass it.`,
|
|
);
|
|
});
|