Files

411 lines
17 KiB
TypeScript
Raw Permalink Normal View History

fix(test): deterministic temp-dir cleanup across node --test lanes (#1661) * 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.
2026-08-07 13:26:20 +02:00
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';
refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088) * refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1) The shared src/utils closure that blocked the platform-family moves lands on declared owners: generic host mechanics form a new private @agent-device/host-kit package between kernel and capture-kit, and capture-kit keeps capture, snapshot, and recording behavior, depending on host-kit for the mechanics it needs. tar-stream and yauzl move with the archive code. Every seam's exported subpaths are pinned in package-boundaries.test.ts, the layering model ranks the new zone, R13's allow-list names it, and each seam carries an exact eager-closure row. ADR-0019's substrate amendment describes the layout. Tests that mocked two of the moved modules separately became duplicate same-seam vi.mock factories, where the second silently replaced the first; those are merged, and the mocks that production code reaches past are pinned at their injection points instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(host-kit): one narrow capability port per export The four technical barrels (exec/fs/values/request) grouped by category rather than by capability, so a consumer needing one mechanic evaluated unrelated ones. Each export is now a single capability over the host machine: command, process, diagnostics, retry, archive, file, request, version. A port re-exports only what a consumer of that capability uses, and every port carries its own eager-closure row. Most of the old values barrel was never host mechanics. Pure record readers, config-source values, result text, memoization, async scoping, coordinate validation, and device-scope parsing touch no process, file, or environment, so they join kernel's other primitives instead. Closures fall accordingly: capture-kit's png-worker-client from 20 to 10, png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from 386 to 380. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * chore: drop the migration inventories and trim the touched comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: trim the touched host-kit and mutation-lane comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only in the touched files Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only across the touched tree Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: point the Swift parity comment at the real TS twin and test The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts, which does not exist: the module went to capture-kit while isTapPointInsideViewport itself went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at the pre-move path. Both now resolve. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: repoint comment citations at the homes this refactor moved them to The W1 move left ~20 comment citations pointing at src/utils/*.ts and src/request/*.ts paths that no longer exist. Each now names the capability port that owns the symbol, which survives further file moves: exec -> host-kit/command host-process, owner-identity -> host-kit/process diagnostics -> host-kit/diagnostics atomic-file, process-lock -> host-kit/file retry -> host-kit/retry request progress/cancel -> host-kit/request version -> host-kit/version ttl-memo, source-value, parsing, device-isolation, keyed-lock, success-text -> kernel subpaths Comment-only; no closure, budget, or behavior change. ADR citations are left as written, being dated records of the decision rather than live references. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:48 +02:00
import { runCmd, runCmdBackground } from '@agent-device/host-kit/command';
test: prune abandoned test-run tmp directories at run setup (#1834) * test: prune abandoned test-run tmp directories at run setup A run killed before its teardown (tool-timeout SIGKILL, OOM, cancelled job) left /tmp/agent-device-test-run-<pid>-* behind, and check:tmpdir-leaks — which runs after test:unit in check:unit — flagged every dead-pid directory it found. It could not tell this run's leak from a historical one, so one killed run made every later, otherwise-green gate on the host fail. Both TMPDIR redirection entry points (the Vitest global setup and the node --test wrapper) now prune dead-pid run directories before creating their own, printing one [tmpdir] line when they did; the post-run check keeps its semantics and can now only ever name the run that just finished. Live owners (a concurrent run in another worktree) are never touched. The root/prefix constants move into check-tmpdir-leaks-model.ts, next to the liveness classification, so the setup can import the prune without a cycle. * test(tmpdir): a run directory is live while any process still holds it as TMPDIR, not only while its owner runs Review (P1): owner-pid liveness alone would prune a directory out from under the orphaned children of a SIGKILLed run — the node --test chain, Vitest forks, or a daemon a test spawned all keep running with that TMPDIR. The liveness model now reads every process's TMPDIR (ps -E on macOS, /proc/<pid>/environ on Linux) and treats a run directory as live while its owner pid is alive OR any process's TMPDIR points into it; both the prune and the post-run leak check use it. Regression: a wrapped probe spawns a detached long-lived child, only the wrapper is SIGKILLed, the next prune preserves the directory; after every consumer exits, the next prune removes it. Planted red with owner-only liveness: the orphaned directory is pruned.
2026-08-18 17:48:12 +02:00
import {
liveRunDirectoryConsumers,
pruneAbandonedRunDirectories,
TEST_RUN_TMP_PREFIX,
TEST_RUN_TMP_ROOT,
} from './check-tmpdir-leaks-model.ts';
fix(test): deterministic temp-dir cleanup across node --test lanes (#1661) * 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.
2026-08-07 13:26:20 +02:00
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 });
}
});
test('the wrapper isolates advisory device claims inside its disposable run directory', async () => {
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-claims-dir-'));
const evidencePath = path.join(evidenceRoot, 'claims-dir.txt');
const probeName = `node-test-claims-dir-probe-${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_CLAIMS_DIR', () => {
fs.writeFileSync(${JSON.stringify(evidencePath)}, process.env.AGENT_DEVICE_CLAIMS_DIR ?? '');
});
`,
);
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_CLAIMS_DIR: '/host/device-claims' },
},
);
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
const claimsDir = fs.readFileSync(evidencePath, 'utf8');
assert.equal(path.basename(claimsDir), 'device-claims');
assert.equal(
fs.existsSync(path.dirname(claimsDir)),
false,
'the claims directory must be removed with the wrapper-owned run directory',
);
assert.notEqual(claimsDir, '/host/device-claims');
} finally {
fs.rmSync(probePath, { force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
});
fix(test): deterministic temp-dir cleanup across node --test lanes (#1661) * 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.
2026-08-07 13:26:20 +02:00
// 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.`,
);
});
test: prune abandoned test-run tmp directories at run setup (#1834) * test: prune abandoned test-run tmp directories at run setup A run killed before its teardown (tool-timeout SIGKILL, OOM, cancelled job) left /tmp/agent-device-test-run-<pid>-* behind, and check:tmpdir-leaks — which runs after test:unit in check:unit — flagged every dead-pid directory it found. It could not tell this run's leak from a historical one, so one killed run made every later, otherwise-green gate on the host fail. Both TMPDIR redirection entry points (the Vitest global setup and the node --test wrapper) now prune dead-pid run directories before creating their own, printing one [tmpdir] line when they did; the post-run check keeps its semantics and can now only ever name the run that just finished. Live owners (a concurrent run in another worktree) are never touched. The root/prefix constants move into check-tmpdir-leaks-model.ts, next to the liveness classification, so the setup can import the prune without a cycle. * test(tmpdir): a run directory is live while any process still holds it as TMPDIR, not only while its owner runs Review (P1): owner-pid liveness alone would prune a directory out from under the orphaned children of a SIGKILLed run — the node --test chain, Vitest forks, or a daemon a test spawned all keep running with that TMPDIR. The liveness model now reads every process's TMPDIR (ps -E on macOS, /proc/<pid>/environ on Linux) and treats a run directory as live while its owner pid is alive OR any process's TMPDIR points into it; both the prune and the post-run leak check use it. Regression: a wrapped probe spawns a detached long-lived child, only the wrapper is SIGKILLed, the next prune preserves the directory; after every consumer exits, the next prune removes it. Planted red with owner-only liveness: the orphaned directory is pruned.
2026-08-18 17:48:12 +02:00
// INT32_MAX exceeds every platform's pid range (Linux pid_max caps at 2^22,
// macOS at 99999), so kill(pid, 0) is ESRCH by construction — an owner that
// is dead and can never be reused mid-test, unlike a freshly exited child's pid.
const NEVER_A_PID = 2_147_483_647;
test('the wrapper prunes a run directory abandoned by an earlier killed run and keeps a live one', async () => {
const stamp = crypto.randomUUID();
const abandoned = path.join(
TEST_RUN_TMP_ROOT,
`${TEST_RUN_TMP_PREFIX}${NEVER_A_PID}-planted-${stamp}`,
);
const live = path.join(
TEST_RUN_TMP_ROOT,
`${TEST_RUN_TMP_PREFIX}${process.pid}-planted-${stamp}`,
);
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-prune-'));
const probePath = writeProbe(path.join(evidenceRoot, 'child-tmpdir.txt'));
fs.mkdirSync(path.join(abandoned, 'nested'), { recursive: true });
fs.writeFileSync(path.join(abandoned, 'nested', 'leftover.txt'), 'from a killed run');
fs.mkdirSync(live);
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}`);
assert.equal(
fs.existsSync(abandoned),
false,
'the wrapper must prune the abandoned run directory',
);
assert.equal(
fs.existsSync(live),
true,
'the wrapper must never touch a live owner’s run directory',
);
} finally {
fs.rmSync(abandoned, { recursive: true, force: true });
fs.rmSync(live, { recursive: true, force: true });
fs.rmSync(probePath, { force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
});
test('a run whose owner alone was killed keeps its directory while a child still uses it, and loses it once the child exits', async () => {
// The motivating case for consumer-aware pruning: a tool timeout SIGKILLs the wrapper (the
// owner pid in the directory name) while something it started — here a detached child the
// probe spawned, the shape of a daemon a test brought up — is still running with TMPDIR
// pointing into the run directory. Owner-pid liveness alone would prune the directory out
// from under that child on the next run.
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-orphan-'));
const evidencePath = path.join(evidenceRoot, 'evidence.json');
const readyPath = path.join(evidenceRoot, 'ready');
const probeName = `node-test-tmpdir-probe-orphan-${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 { test } from 'node:test';
refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088) * refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1) The shared src/utils closure that blocked the platform-family moves lands on declared owners: generic host mechanics form a new private @agent-device/host-kit package between kernel and capture-kit, and capture-kit keeps capture, snapshot, and recording behavior, depending on host-kit for the mechanics it needs. tar-stream and yauzl move with the archive code. Every seam's exported subpaths are pinned in package-boundaries.test.ts, the layering model ranks the new zone, R13's allow-list names it, and each seam carries an exact eager-closure row. ADR-0019's substrate amendment describes the layout. Tests that mocked two of the moved modules separately became duplicate same-seam vi.mock factories, where the second silently replaced the first; those are merged, and the mocks that production code reaches past are pinned at their injection points instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(host-kit): one narrow capability port per export The four technical barrels (exec/fs/values/request) grouped by category rather than by capability, so a consumer needing one mechanic evaluated unrelated ones. Each export is now a single capability over the host machine: command, process, diagnostics, retry, archive, file, request, version. A port re-exports only what a consumer of that capability uses, and every port carries its own eager-closure row. Most of the old values barrel was never host mechanics. Pure record readers, config-source values, result text, memoization, async scoping, coordinate validation, and device-scope parsing touch no process, file, or environment, so they join kernel's other primitives instead. Closures fall accordingly: capture-kit's png-worker-client from 20 to 10, png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from 386 to 380. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * chore: drop the migration inventories and trim the touched comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: trim the touched host-kit and mutation-lane comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only in the touched files Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only across the touched tree Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: point the Swift parity comment at the real TS twin and test The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts, which does not exist: the module went to capture-kit while isTapPointInsideViewport itself went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at the pre-move path. Both now resolve. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: repoint comment citations at the homes this refactor moved them to The W1 move left ~20 comment citations pointing at src/utils/*.ts and src/request/*.ts paths that no longer exist. Each now names the capability port that owns the symbol, which survives further file moves: exec -> host-kit/command host-process, owner-identity -> host-kit/process diagnostics -> host-kit/diagnostics atomic-file, process-lock -> host-kit/file retry -> host-kit/retry request progress/cancel -> host-kit/request version -> host-kit/version ttl-memo, source-value, parsing, device-isolation, keyed-lock, success-text -> kernel subpaths Comment-only; no closure, budget, or behavior change. ADR citations are left as written, being dated records of the decision rather than live references. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:48 +02:00
import { runCmdDetached } from '@agent-device/host-kit/command';
test: prune abandoned test-run tmp directories at run setup (#1834) * test: prune abandoned test-run tmp directories at run setup A run killed before its teardown (tool-timeout SIGKILL, OOM, cancelled job) left /tmp/agent-device-test-run-<pid>-* behind, and check:tmpdir-leaks — which runs after test:unit in check:unit — flagged every dead-pid directory it found. It could not tell this run's leak from a historical one, so one killed run made every later, otherwise-green gate on the host fail. Both TMPDIR redirection entry points (the Vitest global setup and the node --test wrapper) now prune dead-pid run directories before creating their own, printing one [tmpdir] line when they did; the post-run check keeps its semantics and can now only ever name the run that just finished. Live owners (a concurrent run in another worktree) are never touched. The root/prefix constants move into check-tmpdir-leaks-model.ts, next to the liveness classification, so the setup can import the prune without a cycle. * test(tmpdir): a run directory is live while any process still holds it as TMPDIR, not only while its owner runs Review (P1): owner-pid liveness alone would prune a directory out from under the orphaned children of a SIGKILLed run — the node --test chain, Vitest forks, or a daemon a test spawned all keep running with that TMPDIR. The liveness model now reads every process's TMPDIR (ps -E on macOS, /proc/<pid>/environ on Linux) and treats a run directory as live while its owner pid is alive OR any process's TMPDIR points into it; both the prune and the post-run leak check use it. Regression: a wrapped probe spawns a detached long-lived child, only the wrapper is SIGKILLed, the next prune preserves the directory; after every consumer exits, the next prune removes it. Planted red with owner-only liveness: the orphaned directory is pruned.
2026-08-18 17:48:12 +02:00
test('probe starts a long-lived detached child that inherits TMPDIR, then waits to be killed', async () => {
const childPid = runCmdDetached(process.execPath, ['-e', 'setTimeout(() => {}, 60_000)']);
fs.writeFileSync(
${JSON.stringify(evidencePath)},
JSON.stringify({ tmpdir: os.tmpdir(), childPid, probePid: process.pid, runnerPid: process.ppid }),
);
fs.writeFileSync(${JSON.stringify(readyPath)}, '');
await new Promise((resolve) => setTimeout(resolve, 60_000));
});
`,
);
let evidence:
| { tmpdir: string; childPid: number; probePid: number; runnerPid: number }
| undefined;
const consumersOf = (e: NonNullable<typeof evidence>) => [e.childPid, e.probePid, e.runnerPid];
const wrapper = runCmdBackground(
process.execPath,
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
{ cwd: REPOSITORY_ROOT, captureOutput: false, stdio: 'ignore', allowFailure: true },
);
try {
await waitFor(() => fs.existsSync(readyPath), 20_000, 'probe never reported ready');
evidence = JSON.parse(fs.readFileSync(evidencePath, 'utf8'));
const runDir = evidence!.tmpdir;
assert.equal(path.dirname(runDir), TEST_RUN_TMP_ROOT);
assert.equal(isAlive(evidence!.childPid), true, 'the detached child must be running');
// Kill only the owner. Its exit handler cannot run on SIGKILL, so the directory survives it.
process.kill(wrapper.child.pid!, 'SIGKILL');
await wrapper.wait;
assert.equal(fs.existsSync(runDir), true, 'SIGKILL of the owner leaves the directory behind');
assert.equal(isAlive(evidence!.childPid), true, 'the detached child outlives its owner');
// The next run's prune must see the child holding TMPDIR and leave the directory alone.
assert.deepEqual(
pruneAbandonedRunDirectories(TEST_RUN_TMP_ROOT).filter((name) => runDir.endsWith(name)),
[],
);
assert.equal(fs.existsSync(runDir), true, 'a directory with a live consumer is not pruned');
assert.equal(fs.existsSync(path.join(runDir)), true);
// Once every consumer is gone — the detached child AND the orphaned node --test chain,
// which holds the same TMPDIR — it is an ordinary abandoned directory.
for (const pid of consumersOf(evidence!)) if (isAlive(pid)) process.kill(pid, 'SIGKILL');
await waitFor(
() => !liveRunDirectoryConsumers().has(path.basename(runDir)),
20_000,
'the run directory still shows a live consumer after every child exited',
);
assert.deepEqual(
pruneAbandonedRunDirectories(TEST_RUN_TMP_ROOT).filter((name) => runDir.endsWith(name)),
[path.basename(runDir)],
);
assert.equal(fs.existsSync(runDir), false, 'with no owner and no consumer it is pruned');
} finally {
for (const pid of evidence ? consumersOf(evidence) : []) {
if (isAlive(pid)) process.kill(pid, 'SIGKILL');
}
if (wrapper.child.exitCode === null && wrapper.child.signalCode === null) {
wrapper.child.kill('SIGKILL');
}
if (evidence) fs.rmSync(evidence.tmpdir, { recursive: true, force: true });
fs.rmSync(probePath, { force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
});
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(
condition: () => boolean,
timeoutMs: number,
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!condition()) {
if (Date.now() > deadline) throw new Error(message);
await new Promise((resolve) => setTimeout(resolve, 100));
}
}