Skip to content

fix(e2e): stop the harness reporting checks it never made - #595

Draft
latekvo wants to merge 8 commits into
mainfrom
fix/e2e-harness-robustness
Draft

fix(e2e): stop the harness reporting checks it never made#595
latekvo wants to merge 8 commits into
mainfrom
fix/e2e-harness-robustness

Conversation

@latekvo

@latekvo latekvo commented Jul 30, 2026

Copy link
Copy Markdown
Member

Found while running the full E2E harness against the packed 0.17.0 bundle for the release check. All of these are harness bugs, not product bugs.

The core problem

The harness reported results it had not actually measured.

assert_reject looked for zod issues on stdout, but the CLI prints them on stderr and leaves stdout empty on a rejection. Both arms of the check called pass(), so the miss was invisible: the zod-path argument every case passes in was decorative, and a CLI that simply exited 1 for every input would have scored the same 88/88. All 88 validation cases asserted nothing beyond "exit code was non-zero."

Once that check could fail, it exposed the rest:

  • awk has no \b word boundary. /\bnumber\b/ matches nothing, so every flag kind resolved to unknown, model_number_flags was always empty, and the bad-type matrix never emitted a single case - while _build_args handed the string "x" to every required numeric/boolean/object field.
  • The describe parser matched --[a-zA-Z0-9-]+, truncating every snake_case flag at the underscore (--device_iddevice), and kept the -json display suffix (--selector-json) that is not part of the schema key. Both produced payloads carrying keys no schema knows.
  • missing-required asserted invalid_type for enum flags, which zod reports as invalid_value.

Parsed kinds went from 270 unknown / 21 enum / 1 array to 131 string, 68 number, 28 boolean, 25 integer, 21 enum, 16 array, 3 object, 2 unknown (the last two are any, which is correct).

Teardown and the exit code

run-e2e.sh had no trap. An unbound variable inside a phase is fatal to the whole shell - run_phase || warn does not catch it - so an abort skipped the cleanup phase, the report, and rm -rf $E2E_WORK, leaking a ~500MB sandbox plus a tool-server, Electron, Metro and a fixture http server. Report generation, teardown and the exit code now live in an EXIT trap, with a completion flag: without it an aborted run exits 0 on "0 failures", which is verified below by mutation.

Teardown runs before the sandbox is removed - $E2E_HOME holds the detached tool-server's pid file and that server is started with no idle timeout, so deleting the sandbox first strands a process argent server stop can no longer find.

90-cleanup.sh gated stop-all-simulator-servers and stop-metro on ARGENT_TOOLS_URL, which the harness never sets (ensure_server deliberately does not pin it), so neither ever ran and every run left a simulator-server and Metro alive - while teardown-complete passed regardless.

Other findings fixed

  • --android-avd could never work: boot-device was asked for 840s inside run_tool's 120s timeout, and the booted serial stayed in a local, so the RN tier blanket-skipped all 24 debugger/profiler tools for want of a device that was right there. Nothing shut the emulator down either - the tool-server only reaps devices Lens booted, not ones boot-device started.
  • The validation tier was byte-identical green against a dead server. argent run does no client-side validation, so every call failed with a transport error that no one distinguished from a rejection. It refuses to run instead, and no longer counts timeout(1)'s 124-127 as a rejection.
  • The post-uninstall restore hardcoded --omit=optional and the sandbox prefix - dropping electron on a full run (the chromium tier then skipped itself as "electron not installed"), and under --system restoring the machine's real global argent into the sandbox instead of putting it back.
  • The Android teardown claimed to leave an injected device alone and then issued the same stop-simulator-server call as the self-booted branch, discarded the result, and recorded a skip saying it had not - tearing down the allocator's device service immediately before the RN tier drove that same serial.
  • The chromium port-owner fallback matched :$port as a substring and took the first pid anywhere in ss -ltnp output, so cleaning up :1234 would kill a process listening on :12345.
  • The RN tier's stop-metro sat after every early return (a failed debugger-connect left Metro running) and ran unconditionally, killing a developer's own Metro on :8081 that the harness never started.
  • The chromium display gate accepted an installed xvfb-run as a substitute for DISPLAY, but nothing wraps the Electron spawn in it - so a headless box with it installed cleared the gate and failed boot-device, turning the gate red for exactly the environment it exists to skip.
  • A phase file with a syntax error silently re-ran the previous phase under the new phase's name; an unknown --phase selected nothing and exited 0; -h printed 7 lines of source code; a value-taking flag with no value died on an unbound $2; two runs in the same second shared one results file.
  • flow-add-step declares its own args property, which makes --args that field's value rather than the whole-payload escape hatch, so run_tool cannot address its fields. Skipped with that reason rather than failing on a payload the tool never received.

Verification

Against swmansion-argent-0.17.0.tgz, introspection,validation:

before after
totals 177 pass / 0 fail / 5 skip 221 pass / 0 fail / 6 skip
rejection cases naming the field they test 0 of 88 130 of 131
bad-type cases emitted 0 43
  • assert_reject checked against right-field, wrong-field, wrong-code, no-path and unreachable-binary inputs. Every one of those cases passes on the un-fixed code.
  • Abort path: injected an unbound variable into a phase - cleanup phase runs, report written, sandbox removed, exit 1, PARTIAL reported. With the completion flag mutated to always-set, the same abort exits 0, which is what pins it.
  • awk \b, the -json suffix, the underscore truncation, the ss port collision and the timeout 124-127 contract were each reproduced standalone before being fixed.

Found by running against a live Android emulator

The android tier executed end-to-end for the first time: 23 pass / 1 fail / 10 skip, including the screenshot-diff pair that the original unbound-variable bug aborted on. Three further defects only surfaced by running:

  • Nine pass/fail/skip calls passed their message as the third argument, which _record() takes as the case name — so the detail column was empty and the case string changed every run. The chromium boot failure was the visible cost: it recorded case=" " with no diagnostic whatsoever.
  • A call killed by timeout(1) writes nothing, so RT_OUT is empty exactly when the operator most needs to know what happened. A hung boot-device was recorded as a failure with a blank detail. All nine ad-hoc detail formatters now share rt_detail(), which names the timeout (124) and a command timeout could not run (125-127).
  • The chromium display gate tested that $DISPLAY was non-empty, not that it was usable. A display-manager session switch on this box left $DISPLAY pointing at a server the process holds no cookie for; Electron then bound its CDP port and never answered on it (verified: the TCP connection is accepted, the HTTP response never arrives), so the tier hard-failed instead of skipping.

Moving profiler-stack-query and profiler-combined-report into the native block also closed a hole their removal had opened: both were listed in RN_TOOLS and in the failure-arm skips but had no case on the success path, so a successful react-profiler-start left them with no pass, fail or skip at all.

Not covered here

  • The postinstall assertion is left alone - fix(e2e): pin the absence of an npm postinstall hook #556 already fixes it, and this branch does not touch it. Until that merges the install phase still records that one failure.
  • The RN tier still has not executed. The emulator itself is fine — it cold-boots in ~24-30s and ran the whole android tier — but it dies from CPU starvation (detected a hanging thread 'QEMU2 main loop') whenever this machine's load average climbs, which it does to 90-120 on 16 cores from concurrent work. Four attempts: it survived the android tier at load ~10, and was starved each time by a competing npm install or by Metro's own bundler startup. The four RN profiler fixes are verified against the tools' source contracts (the throw sites and their required params), not against a live run.
  • The chromium tier still has not executed past boot-device, for the display reason above.
  • Known remaining, deliberately left: assert_ok cannot fail for calls whose result is a constant true; the cookie/storage set→get pair never verifies the round-trip value; a tier-level skip writes one row so report.py's coverage matrix silently drops the tools that tier would have recorded; report.py renders an empty or partial log as a clean green report.

latekvo added 8 commits July 30, 2026 22:57
The validation tier reported 88 green cases while asserting nothing beyond
"exit code was non-zero", and any unbound variable in a phase killed the run
before it could write a report or remove its ~500MB sandbox.

assert_reject looked for zod issues on stdout, but the CLI prints them on
stderr and leaves stdout empty on a rejection, so the structured branch never
matched once. Both of its arms called pass(), so the miss was invisible and
the zod-path argument every case passes in was decorative: a tool that simply
exited 1 for every input would have scored the same 88/88. It now reads
either stream, requires the issue to name the asserted field when the output
carries issues at all, and refuses to count timeout(1)'s 124-127 as a
rejection - those mean the schema never judged the input.

That check, once it could fail, exposed two more: the describe parser matched
--[a-zA-Z0-9-]+ and so truncated every snake_case flag at the underscore
(--device_id -> device), making the generated arguments carry keys no schema
knows; and missing-required asserted invalid_type for enum flags, which zod
reports as invalid_value. 87 of 88 cases now verify the field they name.

Also:
- run-e2e.sh had no EXIT trap, so an abort lost the report and leaked the
  sandbox. Report generation, teardown and the exit code move into one, with
  a completion flag - without it an aborted run exits 0 on "0 failures".
- run_one sourced each phase over the last one's run_phase, so a phase with a
  syntax error silently re-ran the previous phase under the new phase's name.
- An unknown --phase selected nothing and exited 0, reporting success for a
  release it never tested.
- 90-cleanup gated stop-all-simulator-servers and stop-metro on
  ARGENT_TOOLS_URL, which the harness never sets, so neither ran and every
  run left a simulator-server and Metro alive; teardown-complete passed
  regardless. It now gates on the server answering and verifies it stopped.
- 30-android read $b before the guard that protects it, aborting the tier
  whenever the first screenshot failed. The copy it guarded was redundant.
- capture_screenshot left the previous call's SHOT_SIZE/SHOT_PATH in place,
  so a capture producing no file reported the last successful one's size.
- usage() printed a pinned line range that had drifted into the code.

Verified: introspection+validation 179 pass / 0 fail / 5 skip against
swmansion-argent-0.17.0.tgz (was 177/0/5, of which 88 asserted nothing).
assert_reject checked against right-field, wrong-field, wrong-code, no-path
and unreachable-binary inputs; every one of those cases passes on the old
code. Abort path checked by injecting an unbound variable into a phase:
report written, sandbox removed, exit 1 - and exit 0 with the completion
flag mutated away.
Findings from a review pass over the android and chromium phases, each
reproduced before being fixed.

--android-avd drove the whole Android tier and then handed the RN tier
nothing: the booted serial stayed in a `local`, and 50-rn-bluesky resolves its
device from E2E_ANDROID_SERIAL, so all 24 debugger/profiler/network tools
blanket-skipped with "no Android device" on a run that had one. It is exported
now, with a separate flag deciding teardown so the meaning of "we booted this"
does not ride on that variable.

The Android teardown claimed to leave an injected device alone and then issued
the same stop-simulator-server call as the self-booted branch, discarded the
result, and recorded a skip saying it had not. On a shared machine that tore
down the allocator's device service immediately before the RN tier drove that
same serial.

The chromium display gate accepted an installed xvfb-run as a substitute for
DISPLAY, but nothing wraps the Electron spawn in it, so a headless box with it
installed cleared the gate and failed boot-device on a missing X server - a red
release gate for the environment the gate exists to skip. It requires DISPLAY
now; running the harness under xvfb-run supplies one. README updated to match.

The port-owner fallback in the chromium teardown matched ":$port" as a
substring and then took the first pid anywhere in `ss -ltnp` output, so with a
listener on :12345 present it would kill that process while cleaning up :1234.
It now compares the port as the whole last field of the local address and reads
the pid from that same line. Checked against synthetic colliding-port output
and against two live listeners.

Also:
- The chromium teardown recorded electron-stopped unconditionally, including
  when it killed nothing. It waits for the process to go, escalates to SIGKILL,
  and fails if the pid survives.
- chromium-tabs select/close got no record at all when `new` failed for a
  reason outside the tolerated string, so the report still counted the tool
  covered off `list`. Both arms record the skips now.
- The RN tier's stop-metro sat after every early return, so a failed
  debugger-connect left Metro running; and it ran unconditionally, so a
  developer's own Metro on :8081 was killed by a harness that never started it.
  It now stops only a Metro this phase started, on both paths.
- _shot_ok recorded its byte count as the case name rather than the detail,
  unlike the fail beside it, giving every run a different case string.
…n survive an abort

The validation tier's type parser resolved every flag to `unknown`, because awk
has no `\b` word boundary - `/\bnumber\b/` is a backspace-number-backspace
literal that matches nothing. So `model_number_flags` was always empty and the
bad-type matrix never produced a single case, while `_build_args` handed the
string "x" to every required numeric, boolean and object field. Reading the type
from its own column (and adding the integer arm the CLI emits) takes the parsed
kinds from 270 unknown / 21 enum / 1 array to 131 string, 68 number, 28 boolean,
25 integer, 21 enum, 16 array, 3 object, 2 unknown - the last two being `any`,
which is correct. bad-type goes from 0 cases to 43.

`-json` is how the CLI renders an object field (`--selector-json <json>`); the
schema key is the name without it, so three required fields were being sent
under a key no schema knows.

flow-add-step declares its own `args` property, which makes `--args` that
field's value rather than the whole-payload escape hatch, so run_tool cannot
address its fields at all. It is skipped with that reason instead of failing on
a payload the tool never received.

Teardown now runs from the EXIT trap rather than the happy path, and runs before
the sandbox is removed: $E2E_HOME holds the detached tool-server's pid file and
that server has no idle timeout, so deleting the sandbox first stranded a
process `argent server stop` could no longer find - along with any Electron,
Metro and fixture server the tiers spawned. The trap is installed after
lib/common.sh is sourced, since the handler calls into it.

Also:
- `--android-avd` asked boot-device for 840s inside run_tool's 120s timeout, so
  the documented flag could never succeed; and nothing shut the emulator down
  afterwards (the tool-server only reaps devices Lens booted, not boot-device).
- The introspection describe loop recorded nothing at all when describe failed -
  no pass, no fail - and the validation tier then reported the tool as having no
  flags. The flags round-trip asserted the flag name appears in `argent flags`,
  which is registry-driven and prints every flag whether or not anything was
  stored; it asserts the state token now.
- The post-uninstall restore hardcoded --omit=optional and the sandbox prefix,
  dropping electron on a full run (the chromium tier then skipped itself as
  "electron not installed") and, under --system, restoring the machine's real
  global argent into the sandbox instead of putting it back.
- A value-taking flag with no value died on an unbound $2 instead of printing
  usage; two runs starting in the same second shared one results file.
- Dropped the unused artifact_path helper and corrected the comment claiming the
  server is targeted via ARGENT_TOOLS_URL, which the harness never sets.

Verified against swmansion-argent-0.17.0.tgz: introspection+validation
221 pass / 0 fail / 6 skip, with 130 of 131 rejection cases now naming the field
they were built to exercise (previously 0 of 88 did - every one passed on
"exit code was non-zero" alone). Abort path re-checked by injecting an unbound
variable into a phase: cleanup phase runs, report written, sandbox removed,
exit 1, PARTIAL reported - and exit 0 with the completion flag mutated away.
Nine pass/fail/skip calls passed their message as the third argument, which
_record() takes as the case name — so the detail column was empty and the case
string changed every run. The chromium boot failure was the visible cost: a
failed boot recorded case=" " with no detail at all, because RT_OUT was empty
and the only field carrying diagnostics was the one being used as an id.

Found by running the android and chromium tiers against a live emulator.
A tool call killed by timeout(1) writes nothing, so RT_OUT is empty exactly
when the operator most needs to know what happened: a hung boot-device recorded
a failure whose detail was the empty string, indistinguishable from a tool that
answered with nothing. Nine sites formatted that detail by hand; they now share
rt_detail(), which names the timeout (124), a command timeout could not run
(125-127), and a silent non-zero exit.

Found by running the chromium tier against a live Electron: the CDP port was
bound but never answered, and the harness reported the failure with no text at
all.
A display-manager session switch leaves $DISPLAY pointing at a server the
process holds no cookie for. The gate only tested that the variable was
non-empty, so the tier ran anyway: Electron bound its CDP port and never
answered on it, and the run recorded an opaque readiness timeout instead of
'no display' — a red release gate for an environment the gate exists to skip.

Probe the display with xdpyinfo or xset when either is installed. When neither
is, the skip reason says the display was assumed usable rather than implying it
was checked.
Four calls in the RN tier fail on every healthy build, each confirmed against
the tool's own source:

- profiler-stack-query and profiler-combined-report read the native trace and
  throw "No Android trace loaded. Run native-profiler-stop → native-profiler-
  analyze first." They sat in the react block, thirteen lines before any native
  profiling starts. Moved into the native block, after analyze.
- profiler-load load_native throws without session_id. It now takes the newest
  id from `list` mode output, and skips rather than fails when there is none.
- profiler-commit-query by_time_range throws without time_range_ms.

Moving the first two also closed a hole they had opened: both were listed in
RN_TOOLS and in the failure-arm skip list but had no case on the success path,
so a successful react-profiler-start left them with no pass, fail or skip at
all. Both arms of the native block now record them.

Verified against the tools' source contracts (the throw sites and their required
params), not against a live device: this box could not hold an emulator up long
enough to reach the RN tier - see the PR body.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant