Skip to content

e2e: add morph mode that route-switches via window.ema.switchRoute#675

Merged
srid merged 8 commits intomasterfrom
e2e-morph-mode
Apr 26, 2026
Merged

e2e: add morph mode that route-switches via window.ema.switchRoute#675
srid merged 8 commits intomasterfrom
e2e-morph-mode

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented Apr 26, 2026

Morph-survival of every behavior is now a structural property of the e2e suite, not an opt-in @morph tag. 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 via page.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=morph reuses the live backend but rewrites every When I open step into an Ema-internal route switch. A Before hook primes each scenario with page.goto("/") + await window.ema.ready, then subsequent I open calls go through window.ema.switchRoute(...) and wait for EMAHotReload. Every behavior assertion in the suite now implicitly runs against "the page that landed via morph", not via fresh load. just e2e-morph and a third E2E (morph) CI step join the existing live and static runs.

The mode-axis volatility is encapsulated in two new tiny modules: tests/support/mode.ts for env validation and the Mode union, tests/support/navigation.ts for openRoute / morphNav / primeMorph. Step definitions stay completely mode-agnostic — they call openRoute(page, url) and the dispatch happens behind the boundary. hooks.ts is now just Cucumber lifecycle plumbing.

Note for reviewers: a subtle bug surfaced once two morph navigations ran back-to-back in a single scenario (which never happened before — live only morphed once per @morph scenario). Splitting await ema.ready and the addEventListener("EMAHotReload") + switchRoute(...) into separate page.evaluate round-trips opens a Node-side gap where the script-reload that morph triggers can destroy the execution context before the listener is registered. The fix in 44ac526e keeps the entire ready→listener→switchRoute sequence in one async evaluate so it runs back-to-back in browser context.

Closes #673.

Generated by /do on Claude Code (model claude-opus-4-7).

srid added 8 commits April 26, 2026 19:15
…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.
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 26, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey polling loop duplicated in priming hook and morphNav Fixed in this PR (a44c1764, partially reverted in 44ac526e for correctness)
2 Hickey three Before hooks with implicit ordering coupling Partially fixed — dropped the unnecessary morph-mode bypass (3e5c03db); kept three hooks separate per Lowy axis 4
3 Hickey @morph tag vs morph mode — orthogonal axes No-op
4 Hickey openRoute boundary correctly encapsulates mode No-op
5 Hickey BeforeAll "default to live" pattern No-op (Hickey: informational)
6 Lowy modes axis multi-file edits are irreducible minimum No-op
7 Lowy openRoute is a textbook volatility boundary No-op
8 Lowy mode-to-backend mapping correct for current scope No-op (revisit at third backend)
9 Lowy three Before hooks span three distinct volatility axes No-op
10 Lowy hooks.ts accreting four volatility axes Fixed (partial) — extracted mode.ts + navigation.ts (b25ec3be); backend split deferred to next-mode PR per Lowy's own framing

Hickey rationale

Hickey caught a real polling-loop duplication and an implicit temporal coupling between the three Before hooks. The polling-loop duplication landed as a waitForEmaReady extraction; the implicit coupling came from a "in morph mode the fail-fast check is meaningless" early-return whose stated rationale didn't actually hold (the rule encodes a static-mode authoring contract, not a runtime gate, and applies uniformly across all three modes). Hickey's third structural finding — collapse all three Before hooks into one — was rejected in favour of Lowy's axis-4 view that the three hooks span three distinct volatility axes (filtering, validation, initialization) and consolidating would complect them; the surgical bypass-removal addresses the implicit coupling without flattening the decomposition.

Lowy rationale

Lowy's volatility audit confirmed the new boundaries are placed correctly: openRoute is a textbook encapsulation of "how navigation happens per mode," and the multi-file mode-axis edit (hooks + justfile + CI + docs) is the irreducible minimum, not a shotgun. The one structural finding was that hooks.ts had crossed its threshold — backend lifecycle, mode dispatch, navigation strategy, and hook plumbing each represent a distinct volatility axis. The new code (mode constants, navigation helpers) was extracted into mode.ts and navigation.ts; the pre-existing backend lifecycle stayed in hooks.ts since splitting it would have touched code outside this PR's morph axis, and Lowy explicitly framed that part as "fix in this PR or next mode PR."

@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 26, 2026

Evidence

This PR is test-infrastructure only. The diff touches .agency/do.md, .github/workflows/ci.yaml, justfile, and four files under tests/ (step_definitions/smoke_steps.ts, support/hooks.ts, plus new support/mode.ts and support/navigation.ts). No templates, CSS, JS bundles, or Haskell render paths are modified.

The new EMANOTE_MODE=morph simply swaps page.goto for an in-page window.ema.switchRoute call — that morph code path is existing, unchanged production behavior, so a before/after screenshot would render the same page either way and capture nothing meaningful.

Substantive evidence is the e2e suite itself, verified locally across all three modes:

  • just e2e-morph: 22/22 passing
  • just e2e-live: 22/22 passing
  • just e2e-static: 20/22 passing, 2 skipped (expected — static skips @morph scenarios)

CI runs all three modes as separate steps per the updated .github/workflows/ci.yaml.

@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 26, 2026

/do results

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

  • test dominated 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 with just e2e-morph only — drops ~7m. The full three-mode sweep is still cheap to delegate to CI's E2E (live/static/morph) steps.
  • police pass-1 introduced a regression (prefer-focused-library swap from inline poll to page.waitForFunction split the ready+switchRoute across two evaluate calls, opening a Node-side gap that broke consecutive morphs). The bug was caught in test, 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+lowy at 9m 43s was high-value (caught a real fragmentation in hooks.ts and the polling-loop duplication), but ~30% of the time was the parallel-agent overhead. For trivial diffs, --review-model=haiku would 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.

@srid srid marked this pull request as ready for review April 26, 2026 23:49
@srid srid merged commit c07e6db into master Apr 26, 2026
5 checks passed
@srid srid deleted the e2e-morph-mode branch April 26, 2026 23:49
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.

e2e: run all scenarios after a single Ema morph nav as a third mode

1 participant