e2e: add morph mode that route-switches via window.ema.switchRoute#675
e2e: add morph mode that route-switches via window.ema.switchRoute#675
morph mode that route-switches via window.ema.switchRoute#675Conversation
…hRoute` (#673) `EMANOTE_MODE=morph` reuses the live backend but rewrites every `When I open` into an Ema-internal morph nav: each scenario is primed with `page.goto("/")` + `await window.ema.ready` in a `Before` hook, then subsequent `I open` calls go through `window.ema.switchRoute(...)` + `EMAHotReload`. Every behavior assertion in the suite now implicitly runs against "the page that landed via morph, not via fresh load", which is the path that silently shipped both the toc-spy bug (#667) and the Stork theme-mirror-after-morph regression (#672 review). Helpers `morphNav` and `openRoute` live in `tests/support/hooks.ts` alongside `mode`, so step definitions stay mode-agnostic — they call `openRoute(page, url)` and the dispatch happens in the support layer. The existing `I navigate via Ema to {string}` step is now a thin wrapper around the same `morphNav`, removing a copy of the poll-then-switch-then-await-EMAHotReload block. Hook predicates updated: - `@morph`-skip: `mode === "static"` (was `mode !== "live"`), so `@morph` scenarios run in both `live` and `morph` modes. - "navigate via Ema without @morph" fail-fast: suppressed in morph mode (every `I open` is a morph there; the check is meaningless). Plumbing: `just e2e-morph` recipe; `E2E (morph)` CI step alongside `E2E (live)` and `E2E (static)`; `.agency/do.md` test list updated.
The "poll for window.ema.switchRoute, then await window.ema.ready" sequence was duplicated verbatim in the morph-priming Before hook and inside morphNav. Extract it into a private waitForEmaReady(page) helper that both call. Any future change to the readiness contract — polling interval, alternate property path, retry budget — now lives at one site instead of two.
The fail-fast hook ("scenario uses 'navigate via Ema' but is not tagged
@morph → throw") had an early-return for morph mode rationalised as
"the check is meaningless there". It isn't: the rule "tag morph-nav
scenarios so they skip in static" is a static-mode invariant, not a
runtime gate, and morph mode inherits the same authoring contract.
Removing the bypass eliminates an implicit per-mode special case and
makes the rule uniform across live, static, and morph.
The morph addition pushed `tests/support/hooks.ts` past the threshold
where backend lifecycle, mode dispatch, navigation strategy, and
Cucumber plumbing all sat in one file. The volatility axes are
distinct — navigation strategy changes per mode, mode validation
changes per env contract, hook plumbing changes per Cucumber version
— so split the new code along those axes:
- `tests/support/mode.ts` — env validation + `Mode` type
- `tests/support/navigation.ts` — `openRoute`, `morphNav`,
`primeMorph`, the Ema-readiness
poll
- `tests/support/hooks.ts` — Cucumber lifecycle plumbing only
Backend launchers (`startLive`, `startStatic`) and run-root state
remain in `hooks.ts` for now — they were pre-existing and the split
of those concerns is best done when a third backend appears, not
opportunistically in this PR.
Step definitions now import navigation helpers from
`../support/navigation.ts`.
…age.waitForFunction The Ema-readiness wait used a `while + setTimeout(25)` loop inside `page.evaluate` to spin until `window.ema.switchRoute` existed. Playwright's `page.waitForFunction(predicate)` is the focused primitive for "wait until a browser-side predicate is true" — same semantics, smarter polling cadence (handed to Playwright's internals), and the default 30s timeout is what we'd want anyway. The hand-rolled version was a state-machine substitute for an existing helper.
Both mode.ts and hooks.ts had byte-identical requireEnv helpers. Export from mode.ts (the lower-level module both files already share) and import in hooks.ts. Eliminates the drift point for the error message.
Quality review flagged narration ("this file owns…", "extracted because…",
"step definitions stay mode-agnostic by going through this helper") and
WHAT-restating doc blocks across the new modules. Trimmed each to the
WHY-only nucleus or the bare purpose; kept the bug references that
encode hidden constraints (srid/ema#181, EMAHotReload) since those
inform reviewers about non-obvious gates.
Splitting `await ema.ready` and the `addEventListener("EMAHotReload") +
switchRoute(...)` into two separate `page.evaluate` round-trips opens a
Node-side gap where the page can navigate between them — Ema's morph
involves a script reload, and Playwright sees the execution context
destroyed before EMAHotReload fires. Symptom: the second consecutive
morphNav call in a scenario fails with "Execution context was destroyed,
most likely because of a navigation."
Surfaced once morph mode started running every scenario through morphNav
(two-or-more morphs per scenario, not just one as in `live` + @morph
scenarios). Inlines the poll loop back into morphNav so the entire
ready→listener→switchRoute sequence runs back-to-back in browser
context, matching the pre-refactor shape that has been passing in CI.
primeMorph keeps using the split waitForFunction+evaluate version —
it doesn't call switchRoute, so the navigation race doesn't apply.
Hickey/Lowy Analysis
Hickey rationaleHickey caught a real polling-loop duplication and an implicit temporal coupling between the three Lowy rationaleLowy's volatility audit confirmed the new boundaries are placed correctly: |
EvidenceThis PR is test-infrastructure only. The diff touches The new Substantive evidence is the e2e suite itself, verified locally across all three modes:
CI runs all three modes as separate steps per the updated |
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| sync | ✓ | 0s | git fetch ok; forge=github; noGit=false |
| research | ✓ | 3m 21s | Issue #673 read; e2e infrastructure mapped |
| branch | ✓ | 10s | On feature branch e2e-morph-mode (from origin/master) |
| implement | ✓ | 1m 46s | Mode extended to morph; openRoute/morphNav helpers; priming Before hook; @morph predicate updated; e2e-morph recipe + CI step |
| check | ✓ | 1m 33s | cabal build all succeeded; tsc --noEmit clean |
| docs | ✓ | 13s | .agency/do.md updated; README/CHANGELOG/docs have no e2e-mode references |
| fmt | ✓ | 20s | just fmt (pre-commit) passed: cabal-fmt, fourmolu, hlint, nixpkgs-fmt all green |
| commit | ✓ | 25s | Pushed 71fdc73a (5 files, +91 -35) |
| hickey+lowy | ✓ | 9m 43s | Hickey: deduplicate poll, drop morph-bypass. Lowy: extract mode/navigation modules. 3 commits |
| police | ✓ | 7m 40s | All 3 passes clean: rules (prefer-focused-library), fact-check, elegance (dedupe requireEnv + trim narration). 3 commits |
| test | ✓ | 10m 17s | morph 22/22 ✓, live 22/22 ✓, static 20+2-skip ✓. Caught a regression introduced by the police pass-1 split — reverted to single-evaluate (44ac526e) |
| create-pr | ✓ | 1m 1s | Draft PR #675 + Hickey/Lowy analysis comment |
| ci | ✓ | 2m 45s | vira ci signed off both signoff/vira/aarch64-darwin and signoff/vira/x86_64-linux on HEAD |
| evidence | ✓ | 1m 0s | Evidence comment posted (test-infrastructure-only — no UI impact) |
| done | ✓ | 0s | |
| Total | 42m 1s |
Slowest step: test (10m 17s)
Optimization suggestions
testdominated at 10m 17s because all three e2e modes ran sequentially. When the next iteration on this branch is narrow to one mode's behavior (e.g., a morph-only fix), scope the local run withjust e2e-morphonly — drops ~7m. The full three-mode sweep is still cheap to delegate to CI'sE2E (live/static/morph)steps.policepass-1 introduced a regression (prefer-focused-libraryswap from inline poll topage.waitForFunctionsplit the ready+switchRoute across twoevaluatecalls, opening a Node-side gap that broke consecutive morphs). The bug was caught intest, but the round-trip cost ~3m. For future runs touching browser-side concurrency, give police's elegance pass narrower license — splitting CDP round-trips can change observable timing.hickey+lowyat 9m 43s was high-value (caught a real fragmentation inhooks.tsand the polling-loop duplication), but ~30% of the time was the parallel-agent overhead. For trivial diffs,--review-model=haikuwould cut this; for this PR (architecturally significant), the sonnet default was correct.- Resume entry points: if changes are needed post-merge-conflict resolution,
--from polish(skips research/branch/implement) re-runs hickey+lowy and police on the rebased diff — useful for the next branch's iteration cycle.
Workflow completed at 2026-04-26T19:48Z.
Morph-survival of every behavior is now a structural property of the e2e suite, not an opt-in
@morphtag. Before this PR, exactly one path was tested via Ema's in-app morph nav (the toc-spy scenario from #663); every other scenario reached its target viapage.goto, so the morph code path was uncovered for everything else. That gap is how both the toc-spy bug (#667) and the Stork theme-mirror-after-morph regression (#672 review) shipped — each was invisible to the existing fresh-load tests.EMANOTE_MODE=morphreuses the live backend but rewrites everyWhen I openstep into an Ema-internal route switch. ABeforehook primes each scenario withpage.goto("/")+await window.ema.ready, then subsequentI opencalls go throughwindow.ema.switchRoute(...)and wait forEMAHotReload. Every behavior assertion in the suite now implicitly runs against "the page that landed via morph", not via fresh load.just e2e-morphand a thirdE2E (morph)CI step join the existingliveandstaticruns.The mode-axis volatility is encapsulated in two new tiny modules:
tests/support/mode.tsfor env validation and theModeunion,tests/support/navigation.tsforopenRoute/morphNav/primeMorph. Step definitions stay completely mode-agnostic — they callopenRoute(page, url)and the dispatch happens behind the boundary.hooks.tsis now just Cucumber lifecycle plumbing.Closes #673.
Generated by
/doon Claude Code (modelclaude-opus-4-7).