Skip to content

Commit ac9e4d0

Browse files
thymikeeclaude
andauthored
test: measure oracle liveness suite-wide; pin the one dead-path oracle (#1679)
* test: measure oracle liveness suite-wide; pin the one dead-path oracle - docs/agents/oracle-negation-spike.md: assertion-negation sweep over 677 test files (5,687 verdicts). Zero vacuous tests: all 150 negation survivors decompose into assert.rejects-validator artifacts (112), helper-oracles (31), in-file-fake breakage (6), and one conditional oracle. Records the companion mock-coupled coverage-uniqueness numbers and the follow-ups they motivate (diff-scoped mutation gate, provider seam closures, transcript provenance). - watchos-sentinel: the non-watchOS test's only assertion sat in a catch block that never fires (tvOS interactor creation succeeds), so no assertion executed on the observed path. Pin creation success instead. Red-run proof: the old shape survived the negation sweep; the new shape fails under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test(android): inject fake adb through the provider scope, not PATH stubs Adds withFakeAdb to test-utils: a scripted in-process AndroidAdbProvider installed through the production withAndroidAdbProvider seam — the same scope the daemon installs per request — replacing PATH-stub shell scripts that spawn a real subprocess per adb call. No PATH mutation, no spawns, no real subprocess waits. Converts settings.test.ts (15 tests, 23ms; waiver said "waits real settings-apply poll time") and notifications.test.ts (2 tests, 9ms). Assertions move from args-log regex greps to structural checks on the recorded call list; the fake receives device-scoped args with the -s serial pair stripped, so serial routing is enforced by the scoped provider matching device.id instead of asserted per call. Remaining PATH-stub files convert next; their contention-retry waiver entries lift together with the conversions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test(android): convert device-input-state to fake adb provider injection 10 PATH-stub cases move to withFakeAdb through the production provider scope; the 2 tests that already inject an executor directly are unchanged. Cross-invocation shell STATE_FILE state becomes a closure boolean; args-log regex asserts become structural checks on recorded calls. 12/12 green at 386ms — the residue is dismissAndroidKeyboard's two fixed 120ms retry sleeps, not stub subprocess waits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test(android): convert app-lifecycle-install adb stubbing to fake provider The adb half of every case moves to withFakeAdb through the production provider scope; installs take the documented exec-shaped fallback (exec(['install','-r',...])), matching what the PATH stub saw minus the serial pair. bundletool/zip/unzip stay real or PATH-stubbed — they run via runCmd outside the adb seam, so this file remains in the serialized subprocess-stub lane with its waiver reason to be corrected from adb to bundletool. 13/13 green at ~130ms; no case enters a retry/poll loop. Conversion note: manifest identity's `unzip -p` failure is silently swallowed (readZipEntry catch -> undefined, aapt fallback) — an invisible degradation path worth a future explicit diagnostic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test(android): convert input-actions adb stubbing to fake provider 9 PATH-stub cases move to withFakeAdb; the 3 tests already injecting providers directly are unchanged. Chunked shell-input assertions become ordered deepEqual on the recorded calls; never-called negatives and call-count checks preserved 1:1. 12/12 green. File time drops to 2.2s, all of it production sleeps: verifyAndroidFilledText unconditionally waits its [0,150,350]ms verification cadence even when the first inspection matches, so each fill verification pass costs ~500ms with an instant fake. A budget-derived cadence there (testing.md pattern 1) would put this file near 25ms; flagged as follow-up rather than changed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test(android): extract shared oracles; make fake adb failure-faithful Three test-utils extractions applied across the six converted files: - assertRejectsAppError collapses the hand-rolled AppError code+message rejection validator (10 sites here; ~30 more repo-wide can adopt it incrementally). Validators asserting details or multiple differently- flagged regexes stay explicit on purpose. - withFakeAdb gains a `provider` option for extra capabilities (snapshotHelperArtifact, reverse, ...), replacing input-actions' nested re-scoping bridge. - withFakeAdb now mirrors the local executor's contract: a scripted nonzero exit throws androidAdbResultError unless the call site passed allowFailure. Provider-scoped exec bypasses exec.ts's throw-on-close- failure, so returning {exitCode:1} took a different production path than the PATH-stub `exit 1` these fakes replaced. All 75 tests hold under the corrected semantics. Also swaps settings' inline emulator DeviceInfo literals for the shared ANDROID_EMULATOR fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test: lift five converted Android files from the contention-retry waiver settings, notifications, device-input-state, input-actions, and app-lifecycle-open no longer stub binaries on PATH or spawn subprocesses, so their contention mechanism is gone: they leave CONTENTION_RETRY_FILES and, through the derived SUBPROCESS_STUB_TESTS constant, the serialized subprocess-stub project (17 -> 12 files). app-lifecycle-install stays with its reason corrected: adb is now in-process, but bundletool stays PATH-stubbed and zip/unzip spawn for .aab packaging paths. Full unit suite green at the new membership: 638 files, 5,724 tests, with the five files running at unit-core's default parallelism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test: apply review findings to the fake-adb conversion batch - app-lifecycle-open: the missing-package launch failure returns {stderr, exitCode: 1} and lets withFakeAdb's throw path produce the production-shaped androidAdbResultError instead of hand-modeling the thrown AppError — the drift the helper exists to eliminate. - withScriptedAdb deleted: the six converted files were its only callers, and a live PATH-stub export invites new tests back into the serialized lane this batch shrank. withMockedAdb stays (dispatch and runtime-hints tests still stub other binaries). - android-snapshot-helper gains androidSnapshotHelperScriptResponse so the version-probe detection and versionCode reply have one source of truth; input-actions' local copy delegates to it. - withFakeAdb's provider option becomes a distributed Omit over the AndroidAdbProvider union, so touch without gestureViewport is a compile error at the fake's boundary (planted and verified) instead of a TypeError inside production gesture planning. - spike-doc re-run checklist restores wider than the codemod globs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test: drop the consumer-less FakeAdbScript barrel re-export Fallow's dead-code gate flagged it: scripts are always passed as inline lambdas, so only FakeAdbResponse needs a name at the barrel. The type stays exported from fake-adb.ts where the withFakeAdb signature uses it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test(apple): inject fake xcrun through the tool-provider scope, not PATH stubs withFakeAppleTool mirrors withFakeAdb for the Apple seam: a scripted provider installed via the production withAppleToolProvider scope, flat simctl/devicectl invocations recorded exactly as the PATH-stub shell scripts saw them, throw-on-nonzero fidelity matching exec.ts unless the call site passed allowFailure, and the canned `simctl privacy help` listing served by default (the block withMockedXcrun injected into every script). screenshot-status-bar.test.ts converts as the exemplar: 3/3 green at 9ms with deepEqual call-sequence assertions replacing the args-log regexes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test(apple): convert apps.test.ts xcrun stubbing to fake tool provider All withMockedXcrun scripts and hand-rolled PATH stubs move to withFakeAppleTool; args-log regexes become structural call assertions (exact deepEqual where order is deterministic, presence checks where the 5s simulatorBootedMemo TTL makes boot-probe order test-dependent). 12 hand-rolled AppError validators collapse into assertRejectsAppError. 54/54 green; file test time 1172ms -> ~400ms with no test over 201ms. Five .ipa install tests keep a minimal PATH stub for unzip only: install-artifact.ts:112 and install-source.ts:438 call runCmd('unzip') directly, outside the Apple tool provider seam — the file therefore stays in the serialized subprocess-stub lane with its waiver reason corrected from xcrun to unzip. Also observed: getSimctlPrivacyServices caches per PATH+simulatorSetPath and simulatorBootedMemo keys on deviceId|setPath, so neither cache accounts for the provider scope — worked around per test, follow-up worthy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf * test: lift six Apple waivers; fix format and fallow findings from CI - interactions, simulator, screenshot, physical-device-screenshot, devicectl, and screenshot-status-bar leave CONTENTION_RETRY_FILES: the first five stopped stubbing PATH binaries in earlier refactors (measured 3-64ms per file, no subprocess activity), and screenshot-status-bar now injects through the fake tool provider. apps.test.ts stays with its reason corrected to the unzip PATH stub (xcrun is in-process; install-artifact.ts:112 / install-source.ts:438 call runCmd('unzip') outside the Apple seam). Serialized lane 12 -> 6. - oxfmt: fake-apple-tool.ts and contention-retry.ts were pushed unformatted (local check piped through tail masked the failure). - fallow complexity: the three fake-script arrows in apps.test.ts drop under threshold via shared predicates (isSimctlMainScreenScale, isSimctlScreenshot, isDevicectlDevice), which also deduplicate the screenshot pair. Full unit suite green at the new membership: 638 files, 5,724 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S5sZnmPn4A9Ct7sTJdfAf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c0fcb6 commit ac9e4d0

17 files changed

Lines changed: 2252 additions & 3110 deletions
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Oracle-negation spike: are our unit tests load-bearing?
2+
3+
**Question** (2026-08-07): with a large unit suite and a mock-free
4+
provider-integration lane, which unit tests are decorative and safe to remove?
5+
Coverage intersection cannot answer this — two tests on the same covered path
6+
can check entirely different behaviour (the checked-coverage argument:
7+
coverage says a line *ran*, not that any assertion *depended* on it). So this
8+
spike measured oracle liveness directly.
9+
10+
## Method
11+
12+
Invert every assertion in every `*.test.ts` file and re-run the suite. A test
13+
that **passes with all its assertions inverted** has no live oracle: its
14+
assertions either never execute or cannot distinguish anything. A test that
15+
fails is load-bearing on at least one executed assertion.
16+
17+
- `expect`: a proxy routes every terminal matcher through one extra `.not`.
18+
Chai's `.not` is a flag-*set*, not a toggle, so stacking `.not` on a
19+
user-negated chain is a no-op — the proxy instead tracks user `.not` as
20+
parity and inverts those chains by *removing* the negation (calling the raw
21+
matcher). `resolves`/`rejects` wrap recursively; statics pass through.
22+
- `node:assert/strict`: a wrapper module inverts each method (holding throws,
23+
failing swallows; promise-returning methods invert asynchronously).
24+
`assert.fail` keeps its always-throw semantics — a reached
25+
`fail('unreachable')` is a live oracle.
26+
- A codemod rewrites the two (uniform) import shapes in test files only;
27+
shared helpers/worlds keep natural assertions. Setup files and
28+
`test/contention-retry-fixtures/` are excluded.
29+
- Self-check per the vacuity doctrine in `testing.md`: seven planted tests
30+
(dead-branch expect/assert, never-invoked callback, plain matcher, user
31+
`.not`, async `rejects`, executed assert) all produced the expected verdict
32+
before the sweep ran.
33+
34+
Harness lives in session scratch (`.tmp/negation/`): `neg.ts` (proxy),
35+
`negated-assert.ts` (wrapper), `codemod.mjs` (import rewriter). Re-creating it
36+
from this description is ~150 lines.
37+
38+
## Result: zero vacuous tests
39+
40+
Sweep across all five Vitest projects, 677 files codemodded, 5,687
41+
verdict-bearing tests: **5,537 failed under negation (live), 150 passed**
42+
and every survivor decomposed into a harness artifact or a real-but-indirect
43+
oracle, verified by reading each cluster:
44+
45+
| Survivors | Class | Verdict |
46+
| --- | --- | --- |
47+
| 112 | `assert.rejects`/`assert.throws` with a validator callback: the negated validator throws *inside* the un-negated outer wrapper, which reports "rejected as required" either way | Live — the pattern itself proves an executed error-path oracle |
48+
| 31 | Oracle lives in a shared un-negated helper (`assertRpcError`, `assertInvalidArgsMessage`, `gesture-plan-test-utils.ts`, interaction-contract helpers) | Live — sampled four distinct clusters and confirmed each |
49+
| 6 | A guard assertion *inside an in-file fake* (e.g. `readSessionPort`'s `assert.notEqual(index, -1)` in `snapshot-helper-session.test.ts`) inverted and broke the fake, flipping the production path so the real assertions went false-and-swallowed | Live — artifact of negating the whole file |
50+
| 1 | `watchos-sentinel.test.ts`: the only assertion sat in a `catch` that never fires (tvOS interactor creation succeeds) | Conditionally live; strengthened to an unconditional success pin in this spike — the strengthened oracle flips from survivor to failure under negation |
51+
52+
So the suite's oracles are in excellent shape, and **no deletions are
53+
justified by vacuity evidence**. This matches the assertion-density audit run
54+
alongside: 3.4 assertions/test overall, only ~2.4% of assertions are pure
55+
mock-introspection (`toHaveBeenCalledWith`-style with no behavioural check).
56+
57+
## Companion measurement: coverage uniqueness of mock-coupled tests
58+
59+
150 test files mock first-party modules (306 of 316 `vi.mock` calls target our
60+
own code). Excluding them from a full coverage run:
61+
62+
| Run | Lines | Branches |
63+
| --- | --- | --- |
64+
| Full suite | 89.73% | 78.94% |
65+
| Minus the 150 mock-coupled files | 77.85% | 67.25% |
66+
| Mock-free integration lanes only | 48.41% | 37.86% |
67+
68+
The mock-coupled files uniquely hold 4,847 lines across 283 production files —
69+
but only **47 files drop below 20% coverage** without them. Those 47 are not a
70+
test-discipline problem; they map onto production modules that bypass the
71+
provider seams (`src/platforms/android/devices.ts` calls `runCmd('adb', …)`
72+
around `AndroidAdbProvider`; `runner-session.ts` calls `runCmdBackground('xcodebuild', …)`
73+
because `AppleToolProvider` has no background member; `agent-browser-provider.ts`,
74+
`daemon-client-lifecycle.ts`, `perf-xctrace.ts` likewise). Close a seam and its
75+
mock-only tests become provider-scenario-reachable — and the module becomes
76+
eligible for the mutation registry, whose membership rule excludes
77+
subprocess-spawning code by construction.
78+
79+
## What would license pruning (and what to do instead)
80+
81+
Neither coverage overlap (unpredictable fault-detection loss in the suite-
82+
minimization literature) nor oracle liveness (this spike: everything is live)
83+
identifies removable tests here. The remaining mechanical instrument is
84+
**mutation score deltas**: a test whose removal does not lower its module's
85+
mutation score is redundant *with evidence*. That is `pnpm mutation:affected`
86+
scope-widened, and it becomes affordable per-PR by mutating only changed lines.
87+
88+
Follow-ups this spike motivates, in value order:
89+
90+
1. **Diff-scoped mutation gate** — mechanize the `testing.md` red-run rule:
91+
mutate the lines a PR changes; require the PR's tests to kill them.
92+
2. **Seam closures** for the 47 sole-owner modules above; each closure moves
93+
files out of the serialized `subprocess-stub` project and shrinks the
94+
contention-retry waiver list.
95+
3. **Transcript provenance** — provider-scenario worlds are hand-authored
96+
beliefs about `simctl`/`adb` output. Add a capture mode on the live-device
97+
lanes and a nightly drift diff, the same shape as the replay-compat corpus
98+
and its provenance check.
99+
4. **Wiring-assertion strengthening** — the files where mock-introspection
100+
assertions concentrate (`snapshot-handler.test.ts`, `interaction.test.ts`,
101+
`session-open-url-prewarm.test.ts`, `react-native.test.ts`) are candidates
102+
for asserting response payloads instead of dispatch call shapes.
103+
104+
## Re-run checklist
105+
106+
1. Rebuild the three harness files (see Method) under `.tmp/negation/`.
107+
2. Codemod, then `pnpm exec vitest run --reporter=json --outputFile=…`.
108+
3. Classify survivors *before* believing them: peel `assert.rejects`
109+
validators, in-file-fake breakage, and helper-oracle files first — in this
110+
run, 149 of 150 survivors were exactly those three classes.
111+
4. `git restore src packages test scripts` — the codemod globs stop at src/packages/test today, but restore wider than you codemodded; the negated imports must never reach a commit.

scripts/lib/contention-retry.ts

Lines changed: 4 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -55,42 +55,8 @@ const REVIEW_BY = '2026-10-31';
5555
export const CONTENTION_RETRY_FILES: readonly ContentionRetryEntry[] = [
5656
{
5757
file: 'src/platforms/android/__tests__/app-lifecycle-install.test.ts',
58-
reason: 'Stubs adb on PATH and spawns it per case, waiting real install retry/poll time.',
59-
trackingIssue: SUBPROCESS_STUB_ISSUE,
60-
reviewBy: REVIEW_BY,
61-
serializedStub: true,
62-
},
63-
{
64-
file: 'src/platforms/android/__tests__/app-lifecycle-open.test.ts',
65-
reason: 'Stubs adb on PATH and spawns it, waiting real activity-launch poll time.',
66-
trackingIssue: SUBPROCESS_STUB_ISSUE,
67-
reviewBy: REVIEW_BY,
68-
serializedStub: true,
69-
},
70-
{
71-
file: 'src/platforms/android/__tests__/device-input-state.test.ts',
72-
reason: 'Stubs adb on PATH and spawns it, waiting real input-state poll time.',
73-
trackingIssue: SUBPROCESS_STUB_ISSUE,
74-
reviewBy: REVIEW_BY,
75-
serializedStub: true,
76-
},
77-
{
78-
file: 'src/platforms/android/__tests__/input-actions.test.ts',
79-
reason: 'Stubs adb on PATH and spawns it once per input action, waiting real retry time.',
80-
trackingIssue: SUBPROCESS_STUB_ISSUE,
81-
reviewBy: REVIEW_BY,
82-
serializedStub: true,
83-
},
84-
{
85-
file: 'src/platforms/android/__tests__/notifications.test.ts',
86-
reason: 'Stubs adb on PATH and spawns it, waiting real shade-settle poll time.',
87-
trackingIssue: SUBPROCESS_STUB_ISSUE,
88-
reviewBy: REVIEW_BY,
89-
serializedStub: true,
90-
},
91-
{
92-
file: 'src/platforms/android/__tests__/settings.test.ts',
93-
reason: 'Stubs adb on PATH and spawns it, waiting real settings-apply poll time.',
58+
reason:
59+
'Stubs bundletool on PATH (adb is in-process) and spawns zip/unzip for .aab packaging paths.',
9460
trackingIssue: SUBPROCESS_STUB_ISSUE,
9561
reviewBy: REVIEW_BY,
9662
serializedStub: true,
@@ -104,49 +70,8 @@ export const CONTENTION_RETRY_FILES: readonly ContentionRetryEntry[] = [
10470
},
10571
{
10672
file: 'src/platforms/apple/core/__tests__/apps.test.ts',
107-
reason: 'Stubs xcrun/simctl on PATH and spawns them, waiting real app-state poll budgets.',
108-
trackingIssue: SUBPROCESS_STUB_ISSUE,
109-
reviewBy: REVIEW_BY,
110-
serializedStub: true,
111-
},
112-
{
113-
file: 'src/platforms/apple/core/__tests__/interactions.test.ts',
114-
reason: 'Stubs xcrun/simctl on PATH and spawns them, waiting real interaction settle budgets.',
115-
trackingIssue: SUBPROCESS_STUB_ISSUE,
116-
reviewBy: REVIEW_BY,
117-
serializedStub: true,
118-
},
119-
{
120-
file: 'src/platforms/apple/core/__tests__/simulator.test.ts',
121-
reason: 'Stubs xcrun/simctl on PATH and spawns them, waiting real boot-poll budgets.',
122-
trackingIssue: SUBPROCESS_STUB_ISSUE,
123-
reviewBy: REVIEW_BY,
124-
serializedStub: true,
125-
},
126-
{
127-
file: 'src/platforms/apple/core/__tests__/physical-device-screenshot.test.ts',
128-
reason: 'Stubs devicectl on PATH and spawns it, waiting real capture budgets.',
129-
trackingIssue: SUBPROCESS_STUB_ISSUE,
130-
reviewBy: REVIEW_BY,
131-
serializedStub: true,
132-
},
133-
{
134-
file: 'src/platforms/apple/core/__tests__/screenshot.test.ts',
135-
reason: 'Stubs xcrun/simctl on PATH and spawns them, waiting real capture budgets.',
136-
trackingIssue: SUBPROCESS_STUB_ISSUE,
137-
reviewBy: REVIEW_BY,
138-
serializedStub: true,
139-
},
140-
{
141-
file: 'src/platforms/apple/core/__tests__/screenshot-status-bar.test.ts',
142-
reason: 'Stubs xcrun/simctl on PATH and spawns them, waiting real status-bar override time.',
143-
trackingIssue: SUBPROCESS_STUB_ISSUE,
144-
reviewBy: REVIEW_BY,
145-
serializedStub: true,
146-
},
147-
{
148-
file: 'src/platforms/apple/core/__tests__/devicectl.test.ts',
149-
reason: 'Stubs devicectl on PATH and spawns it, waiting real device-poll budgets.',
73+
reason:
74+
'Stubs unzip on PATH for .ipa extraction (xcrun is in-process) and spawns it per install case.',
15075
trackingIssue: SUBPROCESS_STUB_ISSUE,
15176
reviewBy: REVIEW_BY,
15277
serializedStub: true,

src/__tests__/test-utils/android-snapshot-helper.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,24 @@ export function androidSnapshotHelperOutput(xml: string): string {
7373
function isAndroidSnapshotHelperVersionProbe(args: readonly string[]): boolean {
7474
return args.includes('--show-versioncode') && args.includes(SNAPSHOT_HELPER_PACKAGE);
7575
}
76+
77+
/**
78+
* Script-shaped variant of {@link createAndroidSnapshotHelperExecutor} for
79+
* `withFakeAdb` scripts: answers the helper version probe and capture
80+
* invocations, and returns `undefined` for everything else so the caller's
81+
* script keeps handling ordinary adb args. Keeping probe detection and the
82+
* versionCode reply here means a helper-protocol change has one source of
83+
* truth.
84+
*/
85+
export function androidSnapshotHelperScriptResponse(
86+
args: readonly string[],
87+
captureXml: () => string,
88+
): string | undefined {
89+
if (isAndroidSnapshotHelperVersionProbe(args)) {
90+
return `package:${SNAPSHOT_HELPER_PACKAGE} versionCode:999999`;
91+
}
92+
if (isAndroidSnapshotHelperCapture(args)) {
93+
return androidSnapshotHelperOutput(captureXml());
94+
}
95+
return undefined;
96+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import assert from 'node:assert/strict';
2+
import { AppError } from '@agent-device/kernel/errors';
3+
4+
/**
5+
* Asserts that `run` rejects with an {@link AppError} carrying `code` and,
6+
* when given, a message matching `message`. Replaces the hand-rolled
7+
* `assert.rejects(..., error instanceof AppError + code + match)` validator
8+
* repeated across platform tests.
9+
*/
10+
export async function assertRejectsAppError(
11+
run: () => Promise<unknown>,
12+
expected: { code: string; message?: RegExp },
13+
): Promise<void> {
14+
await assert.rejects(run, (error: unknown) => {
15+
assert.ok(
16+
error instanceof AppError,
17+
`expected AppError, got ${error?.constructor?.name ?? typeof error}: ${String(error)}`,
18+
);
19+
assert.equal(error.code, expected.code);
20+
if (expected.message) assert.match(error.message, expected.message);
21+
return true;
22+
});
23+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { DeviceInfo } from '@agent-device/kernel/device';
2+
import {
3+
androidAdbResultError,
4+
withAndroidAdbProvider,
5+
type AndroidAdbExecutorOptions,
6+
type AndroidAdbExecutorResult,
7+
type AndroidAdbProvider,
8+
} from '../../platforms/android/adb-executor.ts';
9+
import { ANDROID_EMULATOR } from './device-fixtures.ts';
10+
11+
/**
12+
* A scripted response for one fake adb invocation: a string is shorthand for
13+
* that stdout with exit 0, a partial result overrides the success defaults,
14+
* an Error simulates a transport-level failure, and `undefined` falls back to
15+
* empty success (the same default the PATH-stub shell scripts expressed as
16+
* `exit 0`).
17+
*/
18+
export type FakeAdbResponse = string | Partial<AndroidAdbExecutorResult> | Error;
19+
20+
export type FakeAdbScript = (args: string[]) => FakeAdbResponse | undefined;
21+
22+
/**
23+
* Extra provider capabilities for {@link withFakeAdb}. The conditional
24+
* distributes over the AndroidAdbProvider union so the touch capability
25+
* pairing survives: `touch` without `gestureViewport` stays a compile error
26+
* here instead of a TypeError inside production gesture planning.
27+
*/
28+
export type FakeAdbProviderExtras = AndroidAdbProvider extends infer P
29+
? P extends AndroidAdbProvider
30+
? Omit<P, 'exec'>
31+
: never
32+
: never;
33+
34+
/**
35+
* Runs `run` with a scripted in-process adb provider installed through the
36+
* production {@link withAndroidAdbProvider} scope — the same seam the daemon
37+
* installs per request and the provider-scenario lane exercises. Prefer this
38+
* over PATH-stub subprocess helpers (`withMockedAdb`): no PATH
39+
* mutation, no spawns, no real subprocess waits, so converted files can leave
40+
* the serialized `subprocess-stub` project and its contention-retry waiver.
41+
*
42+
* The fake `exec` receives device-scoped args without a leading
43+
* `-s <serial>`: scoped providers are per-device, and raw `runCmd('adb', …)`
44+
* calls inside the scope are intercepted with serial args stripped. Nonzero
45+
* `exitCode` responses flow through the production failure classification
46+
* (`coerceAdbResults`/failure hints), exactly like a real adb failure.
47+
*/
48+
export async function withFakeAdb<T>(
49+
script: FakeAdbScript,
50+
run: (ctx: { calls: string[][]; device: DeviceInfo }) => Promise<T>,
51+
options: {
52+
device?: DeviceInfo;
53+
/**
54+
* Extra provider capabilities (snapshotHelperArtifact, text, spawn, ...)
55+
* merged into the installed fake. `exec` always stays the scripted one so
56+
* `calls` keeps recording.
57+
*/
58+
provider?: FakeAdbProviderExtras;
59+
} = {},
60+
): Promise<T> {
61+
// Fresh copy per call: tests may tailor the device without leaking
62+
// mutations into the shared fixture.
63+
const device: DeviceInfo = { ...(options.device ?? ANDROID_EMULATOR) };
64+
const calls: string[][] = [];
65+
const exec = async (
66+
args: string[],
67+
execOptions?: AndroidAdbExecutorOptions,
68+
): Promise<AndroidAdbExecutorResult> => {
69+
calls.push([...args]);
70+
const response = script(args);
71+
if (response instanceof Error) throw response;
72+
const result: AndroidAdbExecutorResult =
73+
typeof response === 'string'
74+
? { stdout: response, stderr: '', exitCode: 0 }
75+
: { stdout: '', stderr: '', exitCode: 0, ...response };
76+
// Mirror the local executor's contract: nonzero exit throws unless the
77+
// call site opted into allowFailure. Provider-scoped exec skips exec.ts's
78+
// throw-on-close-failure, so without this a scripted {exitCode: 1} would
79+
// sail through call sites that rely on the throw — a different production
80+
// path than the PATH-stub `exit 1` these fakes replaced.
81+
if (result.exitCode !== 0 && !execOptions?.allowFailure) {
82+
throw androidAdbResultError(
83+
`adb ${args.join(' ')} exited with code ${result.exitCode}`,
84+
result,
85+
);
86+
}
87+
return result;
88+
};
89+
return await withAndroidAdbProvider(
90+
{ ...options.provider, exec } as AndroidAdbProvider,
91+
{ serial: device.id },
92+
async () => await run({ calls, device }),
93+
);
94+
}

0 commit comments

Comments
 (0)