Skip to content

test: route test scratch through mkdtempForTest; forbid node:os in tests - #2629

Merged
thymikee merged 3 commits into
mainfrom
t3code/use-mkdtemp-for-tests
Sep 15, 2026
Merged

thymikee merged 3 commits into
mainfrom
t3code/use-mkdtemp-for-tests

Conversation

@thymikee

@thymikee thymikee commented Sep 15, 2026

Copy link
Copy Markdown
Member

Summary

Route every product-test scratch directory through the per-package mkdtempForTest/mkdtempForTestSync helpers so it lands under the run's redirected TMPDIR and is removed once per run, instead of path.join(os.tmpdir(), name), which reuses a fixed path across the whole suite.

  • ~80 test/fixture files migrated; adds the missing tmp-dir helpers to platform-harmonyos, provider-webdriver, replay-test and trims platform-apple's to the variant each tree uses. Also migrates a raw os.tmpdir() scratch dir that arrived mid-review (session-test-attempt.test.ts) and drops a dead node:os re-export (session-close-shutdown.fixtures.ts).
  • Simplifications: the repeated daemon.log idiom collapses to one scratch dir per site; redundant randomUUID/Date.now()/pid suffixes inside already-unique dirs dropped (fixed tunnel.json/screenshot.png); two ad-hoc Android screenshot paths fold into the existing withTempScreenshot helper.
  • Enforcement is one no-restricted-imports override in oxlint.config.ts: a product test file that imports node:os is a lint error pointing at mkdtempForTest, mirroring the existing node:child_process ban. Scope is the test topology (*.test.ts, *.fixtures.ts, and .ts under __tests__/ and test-utils/ in src and packages/*/src); only the tmp-dir helper modules are exempt by path. Any other justified reader (mocking production os.tmpdir/os.homedir, a length-limited Unix socket path, or the TMPDIR mechanism itself) carries a co-located // oxlint-disable-next-line no-restricted-imports -- <reason> on its single import — there is no per-file allowlist in the config to drift. packages/maestro/test/** is out of scope.

Scope: 95 files, ~977 gross lines, inherently all-or-nothing — the rule cannot land before its migration.

Validation

Tested at 23c99ebb79. pnpm check:affected --runall runnable checks passed (exit 0). pnpm lint, format:check, full typecheck, fallow --base origin/main, gate-manifest(+test), affected:test, and the unit run for every changed file are green. The rule is proven reachable: it errors on a planted node:os import in a *.fixtures.ts/helper file and stays silent on the tmp-dir helpers and the 12 disabled readers.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at dea914c. The migration looks sound, but the new gate has three holes that let raw os.tmpdir() reads through, and the branch conflicts with main.

isHelperModule in scripts/check-test-tmpdir-model.ts:49 exempts every file ending in /test-utils.ts, not only the mkdtemp helper. packages/platform-web/src/__tests__/test-utils.ts:126-127 reads os.tmpdir() for runtimeHomeDir and socketDir, and the gate never sees it, while the same code in src/__tests__/test-utils/web-managed-*-browser.ts has to be allowlisted by hand. Can the exemption be limited to the helper itself, either by exact path like RAW_TMPDIR_ALLOWLIST (with platform-web's test-utils.ts added for its socket-length reason), or by skipping only calls inside functions named mkdtempForTest/mkdtempForTestSync?

The scan at scripts/check-test-tmpdir-model.ts:97 matches by identifier name, so import { tmpdir as t } from 'node:os'; t(), os['tmpdir']() and const t = os.tmpdir; t() all pass. Can it resolve the local bindings from the import specifiers (aliases and destructuring included) and flag computed ['tmpdir'] access? A model test with an aliased import and a computed-access probe would prove it.

The allowlist at scripts/check-test-tmpdir-model.ts:36 covers whole files. A new, unrelated path.join(os.tmpdir(), 'x') added later to cli-diff.test.ts or runner-xctestrun.test.ts would pass, though each file has only one justified read today. Can the allowlist be per call, with a count per file or a marker comment on the justified line?

The "allowlist holds" test in scripts/check-test-tmpdir-model.test.ts:37 copies the six paths and checks membership only. It does not check that those files still contain a raw read, and findRawTmpdirViolations never runs against a fixture tree, so the exemption rule has no test. Could it assert that each allowlisted file still has a raw read, and add a fixture case with a non-helper read in a test-utils.ts?

A few cleanup leftovers: packages/replay-test/src/internal/__tests__/session-test-artifacts.test.ts:14 keeps a comment about a function that was removed, several files keep empty import lines (snapshot.test.ts:4, request-router-lock-policy.test.ts:7, runner-transport.test.ts:4, adapter.test.ts:5, screenshot-crop.test.ts:15), and runner-transport.test.ts:58 and screenshot-crop.test.ts:93 still add pid/Date.now/random suffixes inside an already-unique directory, which the PR body says were dropped. Please remove them or adjust the PR body.

On design: this adds the 11th and 12th copy of the two-line mkdtemp helper, and the gate then finds helpers by file name. Would exempting only functions named mkdtempForTest/mkdtempForTestSync be smaller, so the file-name rule and the helper-module predicate can go away? One shared helper that per-package rootDir builds can import would remove the copies, but that needs a decision on the package test surface first.

The branch conflicts with main. #2618 already removed the snapshot-helper installArgs, and main also changed packages/platform-android/src/__tests__/app-deployment.test.ts since the merge base. A rebase should drop the now-empty installArgs hunk.

The 5 reported checks pass, and the new gate runs in the tooling job.

Next step: rebase, then close the three gate holes with model tests that fail without each fix.

@thymikee
thymikee force-pushed the t3code/use-mkdtemp-for-tests branch from dea914c to 6a17e00 Compare September 15, 2026 10:33
@thymikee thymikee changed the title test: route test scratch through mkdtempForTest; add check-test-tmpdir gate test: route test scratch through mkdtempForTest; forbid node:os in tests Sep 15, 2026
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.58 MB 4.58 MB -25 B
Package (unpacked) 4.58 MB 4.58 MB -25 B
Package (download) 1.36 MB 1.36 MB -11 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.0 ms 28.3 ms +0.2 ms
CLI --help 79.9 ms 79.2 ms -0.7 ms

@thymikee
thymikee force-pushed the t3code/use-mkdtemp-for-tests branch from 6a17e00 to 853f612 Compare September 15, 2026 12:35
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 6a17e00, as a follow-up to the review at dea914c. Banning the node:os import closes the aliased and computed-access routes, but two gaps still let a raw os.tmpdir() scratch dir back in.

The enforced glob at oxlint.config.ts#L152 matches only *.test.ts. *.fixtures.ts files and helper files under __tests__/ or test-utils/ are outside it, and those are the files where scratch-dir helpers live. For example, packages/platform-web/src/__tests__/test-utils.ts still imports node:os and calls os.tmpdir() twice, and nothing flags or lists it. Can the override also cover *.fixtures.ts and the helper files under __tests__/ and test-utils/, other than the tmp-dir helper itself?

The override for the 9 exempt tests at oxlint.config.ts#L174 clears no-restricted-imports for the whole file, not only the one justified read each comment describes. A new, unrelated os.tmpdir() scratch dir in one of those files would pass silently. Can the exemption be scoped to the call, for example with oxlint-disable-next-line on the justified line?

A small leftover: session-close-shutdown.fixtures.ts#L3 imports node:os and re-exports it as sessionCloseShutdownFixture.os, but none of its five consumers read .os. It can go.

All checks passed at 6a17e00 and there were no conflicts. The newer head 853f612 rebases onto main and adapts one fixture to an upstream mock; it does not change the lint config or the files named above, so these points still apply. Next: widen the glob and scope the exemptions to the justified calls.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed in 853f612904. Rebased onto latest origin/main (#2625/#2600/#2601/#2602/#2633); GitHub reports CLEAN/MERGEABLE. The now-empty installArgs hunk is gone (#2618 landed), and app-deployment.test.ts rebased without conflict.

Gate reworked per your design note. The bespoke scripts/check-test-tmpdir* model is gone, so the three holes it had (whole-test-utils.ts exemption, identifier-name matching, whole-file allowlist) no longer exist. Enforcement is now one no-restricted-imports override banning node:os in src/**/*.test.ts and packages/*/src/**/*.test.ts, mirroring the node:child_process ban and riding the existing pnpm lint gate — no new command, model, allowlist file, or CI step.

  • The alias/computed/held-reference probes are closed by construction: any os.tmpdir() use requires importing node:os, and the ban is on the import, so import { tmpdir as t }, os['tmpdir'](), and const t = os.tmpdir are all caught at the specifier. A planted-import check confirms it fires, and the set of node:os-importing *.test.ts under the globs equals the 9 exceptions exactly.
  • Scope is *.test.ts, so .ts helpers fall outside the ban. That is deliberate (each helper is the sanctioned place to touch os.tmpdir()), but it is a real judgment call: packages/platform-web/src/__tests__/test-utils.ts (socketDir/runtimeHomeDir) is not checked, consistent with the other wrapper helpers but not "guarded." Say the word and I will widen the glob to **/__tests__/**/*.ts and clear the ~15 wrapper files individually so an unrelated read inside a helper is flagged too.

Cleanup leftovers fixed. Removed the node:os-removal blank-line artifacts in snapshot.test.ts, request-router-lock-policy.test.ts, runner-transport.test.ts, adapter.test.ts, screenshot-crop.test.ts; removed the orphan comment in session-test-artifacts.test.ts; and replaced the redundant pid/Date.now/random suffixes in runner-transport.test.ts and screenshot-crop.test.ts with fixed names (tunnel.json, screenshot.png) since the mkdtempForTest dir is already unique.

On the 11th/12th helper copy — agreed it is duplication, but a single shared root test-surface helper importable by each package rootDir build is a package test-surface decision (your call), so I left it out of scope; happy to do it as a follow-up.

Validation at 853f612904: pnpm lint (incl. the new rule), format:check, full typecheck, fallow --base origin/main, gate-manifest(+test), affected:test, and the unit run for every changed file are green. check:affected --run halts on two things, neither from this change: mutation-model (reproduces at clean origin/main with this change absent) and a no-findings lint halt (oxlint exits 0 standalone; ban scope provably equals the exceptions).

@thymikee

Copy link
Copy Markdown
Member Author

Checked at 853f612. Yes, please widen the ban to the helper files: *.fixtures.ts and the .ts files under __tests__/ and test-utils/, with only the tmp-dir helpers exempt. packages/platform-web/src/__tests__/test-utils.ts can keep its two socket-path reads behind a line-level oxlint-disable-next-line that states the reason.

The other two points from the 6a17e00 review still apply to this head. The nine exemptions at oxlint.config.ts#L171-L179 clear the rule for whole files, so a new unrelated os.tmpdir() in one of them would pass. Can each become a line-level disable on the justified read? And session-close-shutdown.fixtures.ts#L3 still imports node:os for an .os export that no consumer reads.

The shared helper for all packages is fine as a follow-up. All checks pass and there are no conflicts. Next: widen the glob, scope the exemptions to lines, and drop the unused import.

Route every product-test scratch directory through the per-package
mkdtempForTest/mkdtempForTestSync helpers so it lands under the run's redirected
TMPDIR and is removed once per run, instead of path.join(os.tmpdir(), name),
which reuses a fixed path across the whole suite. Adds the missing tmp-dir
helpers to platform-harmonyos, provider-webdriver, and replay-test and rounds
out platform-apple's.
@thymikee
thymikee force-pushed the t3code/use-mkdtemp-for-tests branch from 853f612 to 23c99eb Compare September 15, 2026 15:39
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto latest origin/main and addressed all three points at 23c99ebb79.

  1. Widened the glob. The ban now covers *.fixtures.ts and the .ts files under __tests__/ and test-utils/ (in src and packages/*/src), not just *.test.ts. Only the tmp-dir/tmp-dir.fixtures helpers are exempt, by path. A planted raw read in a non-helper __tests__/*.fixtures.ts file now fails lint — this is exactly the packages/platform-web/src/__tests__/test-utils.ts hole you flagged (it is in scope now and carries a line-level disable).
  2. Exemptions scoped to lines. The nine whole-file clears are gone; there is no per-file allowlist in oxlint.config.ts at all. Each justified reader now carries // oxlint-disable-next-line no-restricted-imports -- <reason> on its single import, and session-close-shutdown.fixtures.ts' dead node:os/.os re-export is removed (no consumer read it). A raw scratch dir that landed mid-review (session-test-attempt.test.ts) was migrated to mkdtempForTestSync rather than disabled.
    • One honest limit: no-restricted-imports is import-grained, so the disable sits on the one import; a new, unrelated os.tmpdir() call on an already-disabled line is not caught (there is only one import per file). Full per-call enforcement needs a usage-based rule (no-restricted-properties/no-restricted-syntax) that oxlint does not implement. What we do gain: the exception is co-located and reason-bearing, the config carries no path list to drift, and every new file that reads node:os must add its own visible disable.
  3. Rebase/conflicts. Clean; GitHub MERGEABLE. The shared-helper dedup stays a follow-up as agreed.

Validation at this head: pnpm check:affected --run passes outright now (the mutation-model ownership test was fixed upstream, so the earlier halt is gone); lint/format/typecheck/fallow/gate-manifest and the changed-file unit run are all green.

Add a no-restricted-imports override so a product *.test.ts that imports
node:os is a lint error pointing at mkdtempForTest, mirroring the existing
node:child_process ban. A follow-up override clears it for the handful of tests
that mock production's os.tmpdir()/os.homedir() or assert a real /tmp socket.
@thymikee
thymikee force-pushed the t3code/use-mkdtemp-for-tests branch from 23c99eb to a605029 Compare September 15, 2026 15:54
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at a605029, as a follow-up to the review at 853f612. The scratch routing looks good, but the new node:os override drops two existing import bans for the files it matches.

oxlint does not merge no-restricted-imports options across overrides; a later matching override replaces them. The src/**/*.ts, packages/host-kit/src/**/*.ts override bans node:child_process, and the src/commands/**/*.ts, src/cli/commands/**/*.ts override bans @agent-device/provider-*. The PRODUCT_TEST_FILES override at oxlint.config.ts#L168 matches *.fixtures.ts and test-utils files under those paths and replaces both bans there. The tmp-dir override at line 186 (paths: []) also drops the child_process ban for packages/host-kit/src/internal/tmp-dir.fixtures.ts. With probe files, src/commands/probe.fixtures.ts reported the child_process and provider errors on main and only the node:os error on this head, and packages/host-kit/src/probe.fixtures.ts lost its child_process error the same way. No current file imports either, so CI stays green, but the gate is weaker with no signal.

Can the node:os override repeat the earlier entries (the child_process path, and the provider patterns under src/commands and src/cli/commands) instead of starting a new list? The tmp-dir exemption needs the child_process path too.

Smoke Tests are still running; they exercise the device path, which this diff does not touch. There are no conflicts. Next: keep the earlier bans in the new overrides.

…:os override matches

oxlint doesn't merge no-restricted-imports options across overrides: when
several overrides match a file, the last match's options replace the
earlier ones instead of accumulating. The new PRODUCT_TEST_FILES override
(node:os ban) and the tmp-dir exemption override both matched files that
were already covered by the child_process/provider bans (fixtures.ts and
test-utils files under src/**, packages/host-kit/src/**, and the
host-kit tmp-dir helper), silently dropping those bans for those files.

Compose every no-restricted-imports override from shared path/pattern
constants so overlapping overrides restate the full union of bans that
should apply, instead of one override's options clobbering another's.
@thymikee

Copy link
Copy Markdown
Member Author

Pushed a7dc020 to fix the lost import bans. Each override that touches no-restricted-imports now builds its list from shared node:child_process, provider and node:os entries, so fixture and test-utils files keep the older bans, and the tmp-dir helpers keep the child_process ban. Probe files now report all three bans under src/commands, child_process plus node:os under packages/host-kit/src, and only child_process for a tmp-dir fixture. pnpm exec oxlint . --deny-warnings and pnpm check:affected --run pass on a7dc020.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at a7dc020. The a605029 finding is fixed: the node:os overrides now repeat the node:child_process and @agent-device/provider-* bans for the roots that had them, and the tmp-dir exemption keeps the child_process ban. oxlint with this head's config still reports those bans on the fixture and test-utils paths the earlier review named.

CI is green and there are no conflicts, so this is ready for human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 15, 2026
@thymikee
thymikee merged commit 4a56565 into main Sep 15, 2026
18 checks passed
@thymikee
thymikee deleted the t3code/use-mkdtemp-for-tests branch September 15, 2026 17:59
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-15 17:59 UTC

thymikee added a commit that referenced this pull request Sep 15, 2026
Rebasing onto main picked up the ban on node:os in tests (#2629's mkdtempForTest
routing), and this test's own scratch dir predates that change. Route it through
the same helper so the branch's own new test stays lint-clean; behavior is
unchanged, only where the temp directory comes from.
thymikee added a commit that referenced this pull request Sep 16, 2026
)

* fix(ios-snapshot): prepare the AX bridge off the capture deadline

A cold host pays for the AX bridge inside the capture that happens to ask for it first, so the
first capture of a session spent its whole deadline in a toolchain probe or a clang build and the
lane lost the `wait` that was polling for a screen (#2491). Preparation is now a detached
single-flight per runtime: the first capture that finds it running waits out a short budget and is
served by the XCTest runner, the build keeps going for whoever asks next, and a failed attempt is
answered as-is until a retry window measured from the failure expires.

The grant is once per attempt rather than once per capture. A `wait` poll cycles every 200 ms or
so, and a budget paid per poll would cost a cold build more in captured polls than the build itself
costs, with every one of those captures ending up on the runner anyway.

Both this and the pending target discovery in `snapshot-target.ts` are the same shape — one attempt
per key, detached from whoever started it, a bounded wait, a typed answer while it runs — and they
had already started to forget failures differently. One seam owns that shape; each owner declares
its own wait budget, wait grant, retry window and pending error, and keeps its own cache of a
finished value, because only the owner knows when that value stops being valid.

The preparation carries the signal `close()` aborts. Once a capture has answered, no request owns
this attempt any more: without that, `xcrun` could keep running for two minutes past shutdown and
its cache write would land in a directory belonging to a source that is gone.

* fix(ios-snapshot): end a detached wait the answer already settled

`value()` raced the attempt against the caller's wait and left the loser running. A capture answered
by the attempt still held its timer and abort listener until the budget ran out — 1.5 s for discovery,
2 s for preparation — so a poll loop could stack one pending timer and one listener per capture. The
`awaitDiscovery` it replaced cleared both in a `finally`.

`wait` now takes a per-call stop signal that `value()` aborts in a `finally`, and both owners release
their timer and listener on it: `waitForDiscoveryAttempt` resolves, and `waitForSnapshotSourceDelay`
grows an optional `stop` that ends the sleep without spending the deadline it is measured against.
Waiting inside the caller's own deadline still matters, so a client abort keeps rejecting with the
typed cancellation; the stop only ever lands after the race is already decided, which is why the
losing wait resolves rather than rejecting, and why the race keeps a handler of its own.

* test(ios-snapshot): prove the discovery wait stops when the discovery settles

The stop path inside `waitForDiscoveryAttempt` had no test: the listener registration, the listener
removal and the already-aborted check could each be deleted with every suite green.

`snapshot-target.test.ts` now runs a deferred spawn through `createSimulatorSnapshotTargetResolver`,
lets a second caller join the pending discovery, settles it, and asserts on the two things a leaked
wait costs — a timer still pending and a listener still on the caller's `AbortSignal` — with fake
timers, so neither is a timing race. `deadline.test.ts` measures the same two properties for the
preparation owner and drops its already-aborted case.

That case was one of the things the review asked about, and it is unreachable rather than untested:
`value()` creates the stop moments before calling and aborts it in a `finally`, so the check can never
be true and the stop's `{ once: true }` listener is always released by that abort. Both waits now
drop the check and the redundant removal, and keep the cleanup that does matter, which is the
caller's own signal: it outlives the wait, and forgetting it would leave a listener per capture.

* test(ios-snapshot): count the discovery wait's listeners the way the type says

* refactor(ios-snapshot): build a detached wait once, and read the retirement rule where it applies

Three reviewers in a row had to be satisfied about the same promise plumbing because it existed twice:
the discovery wait and the bridge-preparation wait each hand-rolled the timer, the caller's abort
listener, the stop listener and the cleanup that keeps a leak from costing a timer and a listener per
capture. `waitForDetachedAttempt` now implements the wait that `value()`'s contract describes, and both
owners hand it their own sleep length and their own cancellation error — which also means the invariant
is deleted-and-caught in one place instead of two.

`disableGenerationFor` said one thing about one call site, so the rule moves inline next to the set it
edits: a failed bridge retires the app generation, a bridge that is merely still building does not.

* fix(ios-snapshot): remove the stop listener when a detached wait settles

waitForDetachedAttempt added an abort listener to stop but only ever removed
the one on signal, so every call with no explicit stop leaked one listener
onto the module-level NO_STOP signal per production caller (lifecycle.ts's
bridge-connect retry sleep and every bridge request's waitForSimulatorTurn).
Make stop optional, remove its listener on every settle path, and delete
NO_STOP now that the wait tolerates a missing stop directly.

* test(ios-snapshot): end a wait on an already-aborted stop and drop a case that cannot fail

waitForDetachedAttempt only checked the caller signal for an already-aborted
case; a pre-aborted `stop` would sit until waitMs expired instead of ending
at once. Add the same already-aborted check for `stop`, guarded so the first
settle wins if both signals happen to be aborted together.

deadline.test.ts's "a delay with no stop leaves no listener behind" case only
exercised the caller signal and the timer, both of which the pre-fix code
already cleaned up correctly, so it could never fail. Delete it; the
detached-attempt.test.ts cases pin the actual leak.

* fix(ios-snapshot): route preparation test scratch through mkdtempForTest

Rebasing onto main picked up the ban on node:os in tests (#2629's mkdtempForTest
routing), and this test's own scratch dir predates that change. Route it through
the same helper so the branch's own new test stays lint-clean; behavior is
unchanged, only where the temp directory comes from.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant