test(android): add comprehensive emulator E2E coverage - #1482
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
5512f89 to
bfca2fd
Compare
bfca2fd to
d8de04d
Compare
thymikee
left a comment
There was a problem hiding this comment.
Thermo-nuclear code quality review
The coverage idea here is good — catalog-complete ownership with runtime evidence is the right model. The implementation is a second copy of a model we already have, plus a static checker that greps source text, plus a wall-clock assertion in CI. I'd hold this until 1–3 are resolved.
Nothing in this diff crosses the 1k-line boundary. src/platforms/android/app-lifecycle.ts sits at 909 and this PR adds ~2 lines — fine for now, but the next Android lifecycle feature should split it rather than push it over.
1. ~450 lines of harness are a verbatim fork of ios-simulator-e2e/ — blocker
live-harness.ts, live-coverage-report.ts, scenarios.ts, and live-assertions.ts are the iOS files with the names swapped. runScenario, runStep, verifyCommand, verifyBehavior, cleanupSession, writeStepHistory, withCommonFlags, withJson, requiredEnv, stringValue, assertCoverageComplete, writeCoverageReport, liveBehaviorsForScenario, bind*Scenarios — all duplicated.
Worse, the copy has already drifted on day one: runStep silently dropped allowFailure/expectFailure, sessionOpen now flips before the command instead of after a successful exit, verifyCommand's failure message was reworded, assertScenarioCommandsVerified became assertNewEvidence. Two copies that diverged before the PR even merged will keep diverging, and every future harness fix will land in one of them.
The genuinely Android-specific surface is tiny: --serial vs --udid, the artifact root, and the two manifests. Everything else is identical.
Judo: extract test/integration/live-e2e/ and parameterize by a platform descriptor —
type LivePlatform<BehaviorId extends string> = {
artifactRoot: string; // 'test/artifacts/android-emulator'
deviceFlags: () => string[]; // ['--platform','android','--serial', serial]
sessionPrefix: string; // 'android-e2e'
behaviors: Record<BehaviorId, BehaviorEntry>;
coverage: Record<PublicCommand, CoverageEntry>;
scenarios: readonly Scenario[];
};smoke-ios-simulator-coverage.test.ts and smoke-android-emulator-coverage.test.ts are ~80% the same too and should become one suite run over both descriptors. Done this way, this PR deletes net lines instead of adding 1299. If the extraction is too much to bundle, land it as a prep PR — but please don't merge a second copy.
2. The static coverage checker greps source code — blocker
new RegExp(`verifyCommand\\(\\s*context,\\s*(?:C\\.${commandKey}|['"]${command}['"])`)This reads scenario files as strings and regexes for a call expression. It breaks on reformatting, on renaming the C alias, on extracting a helper, on calling it in a loop. Note the alternation (?:C.wait|'wait') exists only because live-assertions.ts writes verifyCommand(context, 'wait', …) while every other site writes C.wait — the test was widened to accommodate inconsistent code rather than the code being fixed. It also forces the hardcoded source: path in scenarios.ts, which is already off: smoke:capture-close points at live-runner.ts, the runner, not a scenario module.
And it's redundant — runScenario → assertNewEvidence already enforces exactly this at runtime, precisely, with no regexes.
Judo: delete source, delete the two regex tests, and make the static guarantee structural by declaring owned commands as data:
export const inventoryInstall = {
id: 'smoke:inventory-install',
commands: [C.devices, C.capabilities, C.install, C.apps, C.doctor],
run: assertInventoryAndInstall,
};The static test then becomes one assert.deepEqual against the manifest — no filesystem reads, no regexes, no path strings to drift.
3. Wall-clock assertion buried in the coverage assertion — blocker
MAX_ANDROID_EMULATOR_SCENARIO_BODY_MS = 4 * 60 * 1000 is asserted inside assertCoverageComplete. Two unrelated concerns in one function, and a hard wall-clock assert on shared CI emulator runners is flaky by construction: a slow runner turns a functionally-green run red with zero diagnostic value. The report already records per-scenario timings — report the number, don't assert it. If you want regression protection, compare against recorded timings out-of-band.
4. Two src behavior changes ride along in a test(android): PR, and one leaves dead code
alert.tsdroppinghelperWaitForIdleTimeoutMs: 0removed the only production caller of that option. After this PR,AndroidSnapshotOptions.helperWaitForIdleTimeoutMsis a knob nothing sets outsidesnapshot.test.ts. Delete the field and the??fallback atsnapshot.ts:295— that's a real deletion, and it makes the new unit test moot.alert.test.tsis a change-detector: it deep-equals the arguments handed to a mockedsnapshotAndroidand calls that "retains the normal bounded helper settle policy". It asserts an argument shape, not a settle policy. (Separately,vi.restoreAllMocks()inafterEachdoes not clear avi.mockfactory's call log —mock.calls[0]will read stale calls the moment a second test is added; you'd wantclearAllMocks.)- The deep-link quoting fix is a genuine bug fix —
&in a deep link was being re-tokenised by the device shell — and the implementation reusingquoteAndroidShellArgis exactly right. But it's invisible under atest(android)title. Please split it out so it's reviewable and greppable on its own.
5. android-fixture-cache-smoke.sh is now dead, but still asserted against
The workflow no longer invokes it and nothing else references it, yet trusted-fixture-artifact.test.mjs still does readFileSync('test/scripts/android-fixture-cache-smoke.sh') and asserts /snapshot -i .*--json > "$SNAPSHOT_PATH"/ on it — under a test you renamed to "Android smoke consumes the restored APK through catalog fixture E2E". The test now guards a fossil. Either delete the script along with those assertions, or keep running it.
6. The workflow embeds a JS program inside a YAML string inside a shell script
COVERAGE_REPORT="$(find test/artifacts/android-emulator -name coverage-report.json -print | tail -n 1)"
node --input-type=module -e 'import fs from "node:fs"; …'find | tail -n 1 picks by traversal order, not by newest — with more than one run directory it silently reports the wrong run's timings. And the process that wrote the report already knows artifactDir.
Judo: delete both lines and have writeCoverageReport (or the tail of runAndroidEmulatorE2E) console.log the timing summary. The data is already in hand, CI already captures stdout, and the YAML collapses back to a single node --test line. Same argument for the inline pnpm build / pnpm clean:daemon — those belong in a checked-in script or a prior step, not in emulator-runner's script:.
Smaller, still structural
bindAndroidEmulatorScenarios<Context>earns nothing. The generic is only ever instantiated withLiveContext; theScenarioRunnerKeyunion plusRecord<key, fn>lookup exists solely to keep two lists in sync, which a plain{id, run}array does for free.live-runner.tshas no import side effects, so the coverage test can import the array directly andscenarios.tsdisappears entirely.- Vestigial fields from the copy.
BehaviorCoverageEntry.levelhas exactly one possible value — it drives two.filter(e => e.level === 'live')calls and oneassert.equal(entry.level, 'live'). Delete the field, three checks vanish. Likewiseknown-gap+trackingIssuein the coverage manifest has zero Android entries but forces branches inliveCommandsForScenario,assertCoverageComplete, and two tests — drop it until Android has a real gap. owner: string | RepositoryEvidenceis the reason the coverage test needstypeof entry.owner === 'string'. Name the fields for what they are (scenario: stringon live entries,evidence: {path, test}on the rest) and the narrowing disappears.requireAndroidResourceIdcasts three times againstany.CliJsonResult.jsonisany, so(node as { rect?: unknown }).rect,rect as {…}, and(node as { identifier: string }).identifierbuy zero safety while reading as though they do. Every scenario readsjson.data.*blind. Parse once against the real snapshot node type fromsrc/and let scenarios consume typed data.- Redundant artifacts.
step-history.jsonis rewritten on every step, andcoverage-report.jsonnow embeds the samestepHistoryarray and is rewritten after every scenario plus twice more at the end. Pick one file. assertJsonContainssubstring-matches serialized JSON.assertJsonContains(capabilities, 'click')passes on any response containing those five characters anywhere. Inherited from iOS, so not this PR's sin — but the manifest leans on it forC.capabilitiesandC.appsownership. Tighten where a real field exists.examples/test-app: reordering theWindowStateRow to the top of the Runtime state card is a product-fixture change made to suit a test. Probably fine, but call it out in the PR body — right now it reads as an unexplained drive-by.
Not approving. 1–3 are the ones I'd treat as blocking: merging a forked harness locks in the duplication, the regex-over-source checker is brittle magic standing in for a runtime check that already exists, and the wall-clock assert will produce red builds that mean nothing. 4–6 are cheap deletions that make the diff smaller rather than bigger.
Generated by Claude Code
|
Re-review at The Form→Home follow-up plausibly corrects the prior Android failure by asserting the actual Three structural blockers remain unchanged:
No |
thymikee
left a comment
There was a problem hiding this comment.
Re-review @ 7a8d56c
Three new commits since d8de04d. They're all live-scenario robustness fixes, and they're good ones on the merits:
25a9562— thediffassertion now proves an actual added/removed line instead ofadditions + removals > 0. That's a real upgrade from a tautology to evidence.50f07fd— restoration waits onid="automation-open-sheet"instead of heading text. More specific, less racy.7a8d56c— separating the test IME from the emulator IME is the right call; assertingkeyboard statusbefore the dismissal screenshots closes a genuine hole.
I checked the new flags against src/ — --no-test-ime (flag-definitions-target.ts:255), --settle on click, and keyboard status (commands/system/index.ts:49) all exist. No typos waiting to fire on the emulator.
But none of the six structural findings were touched. I re-verified each against 7a8d56c:
| # | Finding | Status |
|---|---|---|
| 1 | live-harness.ts fork of ios-simulator-e2e/ |
byte-identical to d8de04d |
| 2 | new RegExp(\verifyCommand\(...`)` source-grep |
present, smoke-android-emulator-coverage.test.ts:62,103 |
| 3 | 4-minute wall-clock assert | present, live-coverage-report.ts:13,35 |
| 4 | helperWaitForIdleTimeoutMs with zero prod callers |
present, snapshot.ts:61,295 |
| 5 | dead android-fixture-cache-smoke.sh still asserted |
present, trusted-fixture-artifact.test.mjs:79 |
| 6 | find | tail -n 1 + inline node -e in YAML |
present, android.yml:72-73 |
The diff went 1299 → 1357 lines. Direction of travel is more lines, not fewer.
New in these commits
A. live-form-scenario.ts went 73 → 127 lines and now repeats a five-step block three times.
Lines 18-19, 54-62, and 100-108 are all variations of:
runStep(context, '…', ['close']);
runStep(context, '…', ['open', context.appId, /* --no-test-ime | --relaunch */]);
assertWaitText(context, 'Agent Device Tester');
runStep(context, 'open form tab …', ['click', 'label="Form"']);
assertWaitText(context, 'Checkout form');Two full session teardown/reopen/re-navigate cycles pasted inline. This is the direct cost of putting three IME configurations into one linear function — exactly the "narrow edge-case handling in the middle of an already busy function" pattern.
IME mode is a scenario dimension, not an inline step. The manifest already models per-scenario ownership, so use it: split into smoke:form-input (test IME — owns fill/type/focus) and smoke:keyboard-ime (emulator IME — owns keyboard and the safe-keyboard-dismissal behavior). Each scenario opens its own session once, runScenario already handles per-scenario evidence, and the two mid-scenario close/open pairs disappear entirely. If you'd rather not split, at minimum extract openFormTab(context, { ime }) so three copies become three one-line calls.
B. The back-then-re-enter dance exists only to feed diff a transition.
C.diff is owned by smoke:form-input, but its evidence is now a navigation transition (Form → Home) — so the scenario navigates away from the form and then has to navigate back in. That's what makes the first restart block necessary.
Judo: move the diff evidence to smoke:automation-system, which already walks Catalog → Settings → Automation lab and has a transition sitting right there for free. smoke:form-input then becomes a clean linear form journey: no back, no re-entry, one fewer restart block. The manifest row is a one-line owner change.
C. The new assertion re-declares a type that already exists in src/.
diff.json.data.lines as { kind?: unknown; text?: unknown }[]src/snapshot/snapshot-diff.ts:9 exports SnapshotDiffLine = { kind: 'added' | 'removed' | 'unchanged'; text: string; ref?: string }, and src/contracts/diff.ts:13 already types the wire payload as lines: SnapshotDiffLine[]. Import the contract rather than re-describing it with unknown fields — this is the same untyped-payload problem as requireAndroidResourceId, now spreading to a second call site.
D. Two nine-line blocks that differ in two string literals.
lines.some((line) => line.kind === 'removed' && typeof line.text === 'string' && line.text.includes('Checkout form'))
lines.some((line) => line.kind === 'added' && typeof line.text === 'string' && line.text.includes('Agent Device Tester'))That's a missing helper, and it belongs in live-assertions.ts beside the other shared assertions:
assertDiffLine(diff, 'removed', 'Checkout form');
assertDiffLine(diff, 'added', 'Agent Device Tester');18 lines → 2, and with SnapshotDiffLine imported per (C) the typeof line.text === 'string' guard goes away too.
E. Minor. 50f07fd swapped assertWaitText for a raw runStep(…, ['wait', …]), which no longer credits C.wait. Harmless — wait is credited elsewhere in the same scenario — but it's one more instance of the raw-runStep-vs-helper inconsistency that forced the §2 regex to carry a (?:C.wait|'wait') alternation in the first place.
Still not approving. The assertion quality genuinely improved in these three commits — I'd keep all of that behavior. What hasn't moved is the structure, and (A) added a fresh copy-paste triple on top of it. My ask is unchanged and unblocked by any of this: extract the shared harness (§1), delete the source-grep checker (§2), and drop the wall-clock assert (§3). §§4-6 are pure deletions that shrink the diff.
Generated by Claude Code
|
Re-review at The good news: exact-head CI is green and the Android live fixture journey now completed in 200.9s with all four scenarios, declared command/behavior evidence, and the expected Home/Recents and keyboard state artifacts. The prior missing-smoke-evidence concern is resolved. Three P2 structural blockers remain unchanged:
The only exact-head delta, No |
19a1f55 to
d835e27
Compare
|
Re-review at Resolved:
Remaining failure: job Use stable transition evidence that is actually present in the baseline/result, then rerun the exact Android lane. No |
|
Exact-head review found three blockers:
The registry-derived manifest, command-credit checks, URL quoting fix, and bounded helper-shutdown fallback otherwise look sound. No readiness label while the required smoke is failing. |
|
Exact head |
9bef921 to
c6a379a
Compare
c6a379a to
30b8aba
Compare
|
Clean re-review at |
Summary
Adds catalog-complete Android emulator coverage: every public command is assigned to either a live release-APK scenario, an executable repository contract, or an explicit capability denial.
The live suite reuses the cached test-app APK and runs focused inventory/install, automation/system, form input, real-keyboard dismissal, and capture/close journeys. Runtime evidence is declared structurally and checked per scenario; timing remains diagnostic evidence rather than a pass/fail threshold.
The iOS and Android suites now share one live-device runtime, assertion layer, and coverage reporter. CI builds the CLI before reserving the emulator, emits the exact report path and timings from the runner, and removes the redundant throwaway native APK build.
The generated report includes the manifest-derived classification rollup: 28 live, 22 command-contract, 3 capability-denial, and 0 gap owners across 53 public commands.
This comprehensive live scope is the project-coordinator amendment now recorded on #1426. It supersedes that issue's original request to classify only existing Android evidence after the cached release test-app APK made focused live journeys cheap to reuse.
Also fixes three Android defects exposed by the suite:
UiAutomationbefore force termination and force-stops the device-side instrumentation runtime before reuse, preventing the next accessibility capture from wedgingValidation
pnpm check