From 63735f9a55e99cd4c4ebfbe970a3adc0e5fd289d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 07:27:37 +0000 Subject: [PATCH 01/12] docs(plans): event-based-waits PR-2 implementation plan 14 tasks (0-13) ship the JS-side test infrastructure on top of PR-1's window.__willow foundation: - e2e/test-hooks.ts: Peer wrapper with snapshot/heads/eventCount/lastEvent, nextEvent(predicate), waitUntilHeadsEqual + waitUntilAllHeadsEqual, plus a Playwright `peer` fixture wiring exposeBinding for __willowEvent + __willowOverflow. - e2e/helpers/{peers,ui,touch}.ts: split of legacy 703-LOC helpers.ts behind a re-export barrel so the 7 un-migrated specs work unchanged. - e2e/helpers.barrel.test.ts: build-time TS coverage of every legacy export (TS2305 fails the build before any spec runs). - e2e/test-hooks.spec.ts: 5 smoke tests covering pull/push/convergence. - e2e/multi-peer-sync.spec.ts: pilot conversion replacing 8x { timeout: 30_000 } cross-peer assertions with peer.waitUntilHeadsEqual followed by default 5s assertions; MessageReceived push events gate the cross-peer message tests. - e2e/README.md: documents helpers layout + Peer API + ESLint sunset. Out of scope (deferred to PR-3/PR-4): data-state lifecycle, page.clock, migration of the other 7 specs (#458), flake harness ratchet, removal of magic-number sleeps inside helpers/. --- ...4-29-event-based-waits-pr2-peer-wrapper.md | 2107 +++++++++++++++++ 1 file changed, 2107 insertions(+) create mode 100644 docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md diff --git a/docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md b/docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md new file mode 100644 index 00000000..ed8d825d --- /dev/null +++ b/docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md @@ -0,0 +1,2107 @@ +# Event-Based Waits PR-2 — Playwright `Peer` Wrapper + Helpers Split + Pilot + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the JS-side test infrastructure (`Peer` wrapper + Playwright fixture + helpers split) on top of PR-1's `window.__willow` foundation, then prove it by converting `multi-peer-sync.spec.ts` from gossip-padded `{ timeout: 30_000 }` overrides to event-based waits with default 5 s assertions. + +**Architecture:** PR-1 already exposes `window.__willow.{snapshot,heads,event_count,last_event}` (Promise-returning) and a push stream of `ClientEvent` to a Playwright `exposeBinding('__willowEvent')` that PR-2 now installs. PR-2 wraps both surfaces in a typed `Peer` class, splits the 703-line `e2e/helpers.ts` into focused modules behind a re-export barrel (so the 7 un-migrated specs keep working with zero diff), and pilots the API on `multi-peer-sync.spec.ts`. No Rust or WASM changes. + +**Tech Stack:** TypeScript, Playwright `expect.poll`/`exposeBinding`/`addInitScript`, ESLint flat config (already in repo), `@playwright/test` 1.58. + +**Spec:** [`docs/specs/2026-04-27-event-based-waits-design.md`](../specs/2026-04-27-event-based-waits-design.md) §"PR 2 — Playwright `Peer` wrapper + helpers split + first pilot". +**Predecessor:** PR-1 (#454, merged) + post-merge fix `f07dc5c`. +**Tracking issue:** [#458](https://github.com/intendednull/willow/issues/458). + +--- + +## File Structure + +**Create:** +- `e2e/test-hooks.ts` — `Peer` class, `Snapshot`/`AuthorHead`/`ClientEvent` types, `peer` fixture (`exposeBinding` + `addInitScript`). +- `e2e/helpers/peers.ts` — peer setup: `freshStart`, `waitForApp`, `createServer`, `getPeerId`, `generateInvite`, `joinViaInvite`, `setupTwoPeers`. +- `e2e/helpers/ui.ts` — UI navigation: `visibleShell`, `isMobile`, `sendMessage`, `getMessages`, `waitForMessage`, `switchChannel`, `switchTab`, `openSidebar`, `closeSidebar`, `openMemberList`, `closeMemberList`, `openServerSettings`, `createChannel`, `messageAction`, `editMessage`, `deleteMessage`, `reactToMessage`, `trustPeer`, `untrustPeer`, `kickPeer`, `openCompareFingerprints`, `markFingerprintsMatch`, `markFingerprintsMismatch`. +- `e2e/helpers/touch.ts` — touch gestures: `longPress`, `longPressAvatar`, `swipeLeft`, `swipeRight`. +- `e2e/test-hooks.spec.ts` — smoke tests for `Peer`. +- `e2e/helpers.barrel.test.ts` — TypeScript-only barrel-coverage test (build-time check that every legacy import name is exported). + +**Modify:** +- `e2e/helpers.ts` — collapses to a re-export barrel that re-exports everything from `./helpers/{peers,ui,touch}`. Keeps the file-top `eslint-disable` header so legacy specs still compile. +- `e2e/multi-peer-sync.spec.ts` — pilot conversion. Uses `peer` fixture + `Peer.waitUntilHeadsEqual` to replace the eight `{ timeout: 30_000 }` cross-peer assertions; default 5 s timeouts thereafter. +- `e2e/README.md` — add a "Using `Peer`" section pointing at `test-hooks.ts` plus the helpers/ split. + +**Untouched (legacy specs continue to import from the barrel):** +- `e2e/cross-browser-sync.spec.ts`, `e2e/join-links.spec.ts`, `e2e/mobile-actions.spec.ts`, `e2e/mobile.spec.ts`, `e2e/multi-peer-mobile.spec.ts`, `e2e/permissions.spec.ts`, `e2e/worker-nodes.spec.ts`. + +**Why this split:** the spec calls out `peers / ui / touch` exactly. The dominant import surface across the 7 un-migrated specs is `peers` + `ui` (verified by grepping `import { … } from './helpers'` per spec). Touch lives alone because longPress / swipe will eventually move to `page.clock` (PR 4 follow-up) — keeping it isolated makes that conversion a one-file change. + +--- + +## Task 0: Preflight — verify PR-1 baseline still passes + +**Files:** none. + +This is the safety check. PR-1 landed two days ago and the post-merge fix `f07dc5c` reworked the `app.rs` mount block; before changing anything in `e2e/`, confirm the test-hooks build still produces `window.__willow` and the existing pilot spec passes against it. + +- [ ] **Step 1: Confirm git state** + +```bash +git status +git log --oneline -5 +``` + +Expected: clean tree, on `claude/event-testing-pr-two-KGxN1`, latest commit is `4641883` (PR-1 merge) or newer. + +- [ ] **Step 2: Run the e2e build sanity check** + +```bash +just check-all FEATURES=test-hooks +``` + +Expected: PASS, including `scripts/check-no-test-hooks-in-prod.sh` (which builds twice, once with and once without the feature, and greps `dist/*.js` for `WillowTestHooks`). + +If this fails, stop and triage — the failure is not caused by this plan, but the plan can't proceed against a broken baseline. + +- [ ] **Step 3: Run the existing pilot spec to capture the "before" baseline** + +```bash +just test-e2e-sync 2>&1 | tee /tmp/pr2-baseline.log +``` + +Expected: PASS. Capture wall-clock from the Playwright summary line (something like `6 passed (45s)`). Note this number — the PR-2 description records before/after wall-clock for the pilot spec. The hard requirement is no flake; speed is informational. + +- [ ] **Step 4: No commit (read-only baseline)** + +Skip. Move to Task 1. + +--- + +## Task 1: Scaffold `e2e/test-hooks.ts` with type definitions only + +**Files:** +- Create: `e2e/test-hooks.ts` + +Type-only scaffold first. This file becomes the single source of truth for the JS-side mirror of PR-1's wire shapes. Splitting types out before runtime code lets later tasks reference the types without churning the same file. + +The types must match three Rust sources exactly: +- `WireEvent` enum in `crates/web/src/test_hooks/wire.rs:13` (10 PascalCase variants). +- `SnapshotDto` / `AuthorHeadDto` / `ChannelDto` in `crates/web/src/test_hooks/snapshot.rs:13-39`. +- `ChannelKind` in `crates/state/src/types.rs:18-24` (`Text` | `Voice`). + +- [ ] **Step 1: Write the file** + +```ts +// e2e/test-hooks.ts +// +// JS-side wrapper for window.__willow + the __willowEvent push stream +// installed by crates/web (--features test-hooks). See: +// docs/specs/2026-04-27-event-based-waits-design.md +// +// Types here mirror the Rust WireEvent / SnapshotDto / ChannelDto shapes. +// Keep in sync with crates/web/src/test_hooks/{wire,snapshot}.rs. + +import type { Page, BrowserContext } from '@playwright/test'; + +// ── Mirror of crates/web/src/test_hooks/wire.rs::WireEvent ───────────── + +export type ClientEvent = + | { kind: 'SyncCompleted'; opsApplied: number } + | { kind: 'MessageReceived'; channel: string; messageId: string; isLocal: boolean } + | { kind: 'PeerConnected'; peerId: string } + | { kind: 'PeerDisconnected'; peerId: string } + | { kind: 'ChannelCreated'; name: string } + | { kind: 'ChannelDeleted'; name: string } + | { kind: 'PeerTrusted'; peerId: string } + | { kind: 'PeerUntrusted'; peerId: string } + | { kind: 'ProfileUpdated'; peerId: string; displayName: string } + | { kind: 'RoleCreated'; roleId: string; name: string }; + +// ── Mirror of crates/web/src/test_hooks/snapshot.rs ──────────────────── + +export interface AuthorHead { + seq: number; + /** 64-char lowercase hex (EventHash::Display). */ + hash: string; +} + +export interface ChannelSummary { + name: string; + /** Mirror of willow_state::ChannelKind — serialized as the variant name. */ + kind: 'Text' | 'Voice'; +} + +export interface Snapshot { + eventCount: number; + /** Per-author DAG heads. Keys are EndpointId hex strings (BTreeMap → sorted). */ + heads: Record; + /** Hex hash of most recently applied event, or null if DAG is empty. */ + lastEvent: string | null; + channels: ChannelSummary[]; +} + +// ── Internal: window.__willow surface ────────────────────────────────── + +/** Shape installed at `window.__willow` by crates/web/src/test_hooks/mod.rs. */ +interface WillowTestHooksJS { + snapshot(): Promise; + heads(): Promise>; + event_count(): Promise; + last_event(): Promise; +} + +/** Sentinel: queue + Page + label. Returned by the fixture, not exported as a type. */ +type PeerInternals = { + page: Page; + label: string; + queue: ClientEvent[]; +}; + +// Stub — runtime classes/fixtures land in later tasks. +export {}; +``` + +- [ ] **Step 2: Verify the file type-checks** + +```bash +npx tsc --noEmit --project e2e/tsconfig.json 2>&1 || npx tsc --noEmit e2e/test-hooks.ts +``` + +Expected: zero errors. (If `e2e/tsconfig.json` doesn't exist, the second invocation type-checks the file standalone.) + +- [ ] **Step 3: Verify ESLint accepts the file** + +```bash +npx eslint e2e/test-hooks.ts +``` + +Expected: zero errors. The file uses no `waitForTimeout`, so the existing `no-restricted-syntax` rule is silent. + +- [ ] **Step 4: Commit** + +```bash +git add e2e/test-hooks.ts +git commit -m "test(e2e): scaffold test-hooks.ts with WireEvent + Snapshot types + +Type-only mirror of crates/web/src/test_hooks/{wire,snapshot}.rs. Keeps +the wire-shape contract co-located with the wrapper that consumes it. +Runtime Peer class lands in subsequent commits." +``` + +--- + +## Task 2: Add the `Peer` class with pull-API methods + +**Files:** +- Modify: `e2e/test-hooks.ts` + +The `Peer` class is the public type test authors interact with. Pull methods (`snapshot()`, `heads()`, `eventCount()`) just await the corresponding `window.__willow.*` Promise inside `page.evaluate`. Push (`nextEvent`) and convergence (`waitUntilHeadsEqual`) come in later tasks once the per-page event queue is wired. + +A `Peer` is bound to a `Page` plus a `label` (used in failure messages) plus a per-page event queue (populated by the fixture in Task 3 — for now the queue is just declared as `ClientEvent[]` and unused). + +- [ ] **Step 1: Replace the trailing `export {};` stub with the class** + +```ts +// Replace `export {};` at the bottom of e2e/test-hooks.ts with: + +/** + * Test-side wrapper for one Willow peer (one Playwright Page). + * + * Construct via `peer` fixture in Task 3 — direct construction works for + * the pull-API methods only (snapshot/heads/eventCount/lastEvent). + * Push-API methods (nextEvent / waitUntil*) require the fixture's + * exposeBinding wiring to populate `queue`. + */ +export class Peer { + constructor( + public readonly page: Page, + public readonly label: string, + /** Populated by the fixture's `__willowEvent` binding; empty array is valid. */ + public readonly queue: ClientEvent[] = [], + ) {} + + /** Aggregated state snapshot. Round-trips through `window.__willow.snapshot()`. */ + async snapshot(): Promise { + return this.page.evaluate( + () => (window as unknown as { __willow: WillowTestHooksJS }).__willow.snapshot(), + ); + } + + /** Per-author DAG heads. */ + async heads(): Promise> { + return this.page.evaluate( + () => (window as unknown as { __willow: WillowTestHooksJS }).__willow.heads(), + ); + } + + /** Total events applied to the local DAG. */ + async eventCount(): Promise { + return this.page.evaluate( + () => (window as unknown as { __willow: WillowTestHooksJS }).__willow.event_count(), + ); + } + + /** Hex hash of the most recently applied event, or null if the DAG is empty. */ + async lastEvent(): Promise { + return this.page.evaluate( + () => (window as unknown as { __willow: WillowTestHooksJS }).__willow.last_event(), + ); + } +} +``` + +(The `WillowTestHooksJS` interface declared earlier is referenced inside `page.evaluate` callbacks. Playwright serialises the callback to the page context — the `window` cast is the standard pattern for test-only globals.) + +- [ ] **Step 2: Verify it type-checks** + +```bash +npx tsc --noEmit e2e/test-hooks.ts +``` + +Expected: zero errors. + +- [ ] **Step 3: Verify ESLint clean** + +```bash +npx eslint e2e/test-hooks.ts +``` + +Expected: zero errors. + +- [ ] **Step 4: Commit** + +```bash +git add e2e/test-hooks.ts +git commit -m "test(e2e): add Peer class with pull-API methods + +snapshot / heads / eventCount / lastEvent each round-trip through +window.__willow.* (PR-1's wasm_bindgen surface). Push-API methods land +in the next commit once the per-page event queue is wired." +``` + +--- + +## Task 3: Add per-context event queue + `peer` fixture (push wiring) + +**Files:** +- Modify: `e2e/test-hooks.ts` + +PR-1's WASM dispatcher (`crates/web/src/test_hooks/dispatcher.rs:48`) writes each event to `window.__willowEvent` if defined, else into `window.__willowEventBuffer` (capacity 65 536, overflow calls `window.__willowOverflow(droppedCount)`). PR-2's job is to install the JS-side binding that drains those events into a per-`Peer` queue, plus an `__willowOverflow` binding that fails the test on any call. + +Three things must happen in order, before `page.goto()`, per the spec §"Playwright wrapper": +1. `context.exposeBinding('__willowEvent', cb)` — registers the binding. +2. `context.exposeBinding('__willowOverflow', cb)` — overflow → fail. +3. `page.addInitScript(...)` — pre-creates `window.__willowEventBuffer = []` so the WASM dispatcher's defence-in-depth path has somewhere to write before the first dispatch. + +The fixture lives in `test-hooks.ts` so any spec that imports `peer` fixture gets the wiring without per-spec boilerplate. It is **not** the default `test` — specs explicitly opt in via `import { test } from './test-hooks';` so legacy specs (which don't await `__willow`) keep their existing zero-overhead `test` import. + +- [ ] **Step 1: Add the fixture above the `Peer` class export** + +Open `e2e/test-hooks.ts` and **insert this above** the `export class Peer` line you wrote in Task 2: + +```ts +import { test as base } from '@playwright/test'; + +/** + * Per-page event queue tracker. The fixture creates one `WeakMap` + * per `BrowserContext` and routes every `__willowEvent` callback to the queue + * keyed by the originating Page (Playwright's `exposeBinding` callback receives + * `{ page }` as the first argument's source). + * + * `Peer` reads the queue by reference, so any event the WASM dispatcher emits + * after the binding is installed shows up in `peer.queue` synchronously. + */ +export type PeerFactory = (page: Page, label: string) => Peer; + +/** + * Playwright fixture that installs the `__willow` test-hooks plumbing. + * + * Usage: + * import { test, expect } from './test-hooks'; + * test('foo', async ({ peer, browser }) => { + * const a = await peer(page1, 'Alice'); + * await a.waitUntilHeadsEqual(b); + * }); + * + * The fixture's scope is `'test'` (default): each test gets a fresh + * BrowserContext (Playwright's default) and therefore a fresh queue map. + */ +export const test = base.extend<{ peer: PeerFactory }>({ + // eslint-disable-next-line no-empty-pattern -- Playwright fixture form requires `{}`. + peer: async ({ context }, use) => { + // Per-page queues, keyed by the JS Page object the binding callback receives. + const queues = new WeakMap(); + + // 1. exposeBinding — must be called before any page.goto. + await context.exposeBinding( + '__willowEvent', + (source, ev: ClientEvent) => { + const q = queues.get(source.page); + if (q) q.push(ev); + // No queue means the page wasn't registered via peer() — drop silently. + // peer() is the gatekeeper that allocates a queue and reloads the page. + }, + ); + + // 2. Overflow → fail loudly. PR-1's dispatcher calls this with droppedCount + // only when the 65k buffer is exceeded (a real correctness bug, never + // backpressure under normal load). + await context.exposeBinding('__willowOverflow', (_source, dropped: number) => { + throw new Error(`__willow event queue overflow: ${dropped} dropped`); + }); + + // 3. addInitScript — pre-creates the buffer so the WASM dispatcher's + // fallback path has somewhere to push if it fires before the + // binding is callable. Defence-in-depth; under normal Playwright + // ordering the buffer stays empty. + await context.addInitScript(() => { + (window as unknown as { __willowEventBuffer: unknown[] }).__willowEventBuffer = []; + }); + + /** + * Allocate a queue for `page`, then return a `Peer` bound to it. + * + * Caller must invoke this AFTER `context.newPage()` but BEFORE the page's + * first `goto()` — the queue must exist when the WASM dispatcher first + * tries to push an event after the page loads. + */ + const factory: PeerFactory = (page, label) => { + let queue = queues.get(page); + if (!queue) { + queue = []; + queues.set(page, queue); + } + return new Peer(page, label, queue); + }; + + await use(factory); + }, +}); + +// Re-export expect so spec authors can `import { test, expect } from './test-hooks';` +export { expect } from '@playwright/test'; +``` + +- [ ] **Step 2: Verify type-check** + +```bash +npx tsc --noEmit e2e/test-hooks.ts +``` + +Expected: zero errors. (`@playwright/test`'s `test.extend` signature is generic; `peer: PeerFactory` flows through.) + +- [ ] **Step 3: Verify ESLint clean** + +```bash +npx eslint e2e/test-hooks.ts +``` + +Expected: zero errors. + +- [ ] **Step 4: Commit** + +```bash +git add e2e/test-hooks.ts +git commit -m "test(e2e): add peer fixture wiring __willowEvent/__willowOverflow + +Per-page event queue keyed via WeakMap; binding +callback's source.page is the lookup key. Overflow binding fails the +test on any droppedCount > 0 (PR-1 dispatcher only calls it on the +65k-buffer overflow path). + +Specs opt in via 'import { test, expect } from \"./test-hooks\";' +— legacy specs continue using the default '@playwright/test' import." +``` + +--- + +## Task 4: Add `Peer.nextEvent(predicate)` push consumer + +**Files:** +- Modify: `e2e/test-hooks.ts` + +`nextEvent` walks the per-Peer queue and resolves with the first event matching `predicate`. If no such event is in the queue, it polls until one arrives or `opts.timeout` elapses (default 10 s — gossip-side waits typically settle in <1 s, so 10 s is well clear of the noise floor without hiding regressions). + +The implementation is intentionally simple: drain matching events from the front of the queue. Non-matching events stay in the queue (visible to subsequent `nextEvent` calls) so test code can wait on a specific event without consuming unrelated ones first. + +- [ ] **Step 1: Add `nextEvent` to the `Peer` class** + +Inside the `Peer` class body in `e2e/test-hooks.ts`, append the following method after `lastEvent()`: + +```ts + /** + * Wait for the next event matching `predicate` and consume it. + * + * Walks the per-Peer queue from the front; returns the first match and + * removes it. Non-matching events stay in the queue (so a later + * `nextEvent(other)` can still see them). + * + * Polls every 50 ms; rejects after `opts.timeout` ms (default 10_000) + * with a message naming the peer and showing the queue tail. + */ + async nextEvent( + predicate: (e: ClientEvent) => boolean, + opts: { timeout?: number } = {}, + ): Promise { + const timeout = opts.timeout ?? 10_000; + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const idx = this.queue.findIndex(predicate); + if (idx >= 0) { + const [match] = this.queue.splice(idx, 1); + return match; + } + await new Promise(r => setTimeout(r, 50)); + } + + const tail = this.queue.slice(-5).map(e => e.kind).join(', ') || '(empty)'; + throw new Error( + `${this.label}.nextEvent timed out after ${timeout}ms. ` + + `Queue tail (last 5 kinds): ${tail}`, + ); + } +``` + +- [ ] **Step 2: Verify type-check** + +```bash +npx tsc --noEmit e2e/test-hooks.ts +``` + +Expected: zero errors. + +- [ ] **Step 3: Verify ESLint clean** + +The poll loop uses `setTimeout` inside a `Promise` constructor — this is **not** `page.waitForTimeout` and is not blocked by the ESLint rule. Verify: + +```bash +npx eslint e2e/test-hooks.ts +``` + +Expected: zero errors. + +- [ ] **Step 4: Commit** + +```bash +git add e2e/test-hooks.ts +git commit -m "test(e2e): add Peer.nextEvent(predicate) push consumer + +50ms polling loop; consumes the first matching event from the queue +and leaves non-matches in place. Failure message names the peer and +shows the queue tail to debug 'why didn't my event arrive' cases." +``` + +--- + +## Task 5: Add `waitUntilHeadsEqual` + `waitUntilAllHeadsEqual` convergence helpers + +**Files:** +- Modify: `e2e/test-hooks.ts` + +`waitUntilHeadsEqual(other)` is the canonical "did peer B catch up to peer A's gossip" wait. It takes a snapshot of `other.heads()` as the moving target (re-evaluated each poll tick — peer A may still be advancing), canonicalises both sides into a string with sorted keys, and uses `expect.poll` to compare. + +`waitUntilAllHeadsEqual([…])` calls `waitUntilHeadsEqual` for each peer in turn — N-1 sequential awaits guarantee true N-peer convergence (any peer missing an event from any other peer fails the assertion). + +Per spec §"Naming caveat" + §"Partial-equality footgun": the failure message must surface a structured author-key diff so a missing-author hang is debuggable without manual `console.log`. + +- [ ] **Step 1: Add the canonicalisation helper at module scope** + +Open `e2e/test-hooks.ts`. **Above** the `export class Peer` line, add: + +```ts +/** + * Engine-independent canonical form for a heads map. + * + * Object.keys(...).sort() makes the JSON serialisation order-independent so + * `JSON.stringify` produces the same byte string regardless of insertion order. + * The Rust side already serialises a BTreeMap (sorted) but we re-sort defensively. + */ +function canonicalHeads(heads: Record): string { + return JSON.stringify( + Object.keys(heads).sort().map(k => [k, heads[k].seq, heads[k].hash]), + ); +} + +/** Build the "A is missing X / B is missing Y" diff used in failure messages. */ +function authorKeyDiff( + selfLabel: string, + selfHeads: Record, + otherLabel: string, + otherHeads: Record, +): string { + const selfKeys = new Set(Object.keys(selfHeads)); + const otherKeys = new Set(Object.keys(otherHeads)); + const selfMissing = [...otherKeys].filter(k => !selfKeys.has(k)); + const otherMissing = [...selfKeys].filter(k => !otherKeys.has(k)); + return ( + `${selfLabel} missing authors: [${selfMissing.join(', ')}]; ` + + `${otherLabel} missing authors: [${otherMissing.join(', ')}]` + ); +} +``` + +- [ ] **Step 2: Add the methods to the `Peer` class** + +Inside the `Peer` class, after `nextEvent`, append: + +```ts + /** + * Wait until this peer's heads equal `other`'s heads. + * + * Uses `expect.poll` with a 30 s default timeout (matches the legacy + * `{ timeout: 30_000 }` overrides this method replaces). Each poll + * re-fetches BOTH sides' heads — `other` may still be advancing. + * + * NB: heads-equal is a CRDT pairwise check. Two peers can be equal + * yet both still missing an event from a third; use + * `waitUntilAllHeadsEqual` for N-peer convergence. + */ + async waitUntilHeadsEqual( + other: Peer, + opts: { timeout?: number } = {}, + ): Promise { + const timeout = opts.timeout ?? 30_000; + const { expect } = await import('@playwright/test'); + let lastSelf: Record = {}; + let lastOther: Record = {}; + try { + await expect + .poll( + async () => { + lastSelf = await this.heads(); + lastOther = await other.heads(); + return canonicalHeads(lastSelf); + }, + { + timeout, + message: `${this.label} converge with ${other.label}`, + }, + ) + .toBe(canonicalHeads(lastOther)); + } catch (e) { + // Re-throw with the structured diff appended so missing-author hangs + // are debuggable without a manual console.log round-trip. + const diff = authorKeyDiff(this.label, lastSelf, other.label, lastOther); + throw new Error(`${(e as Error).message}\n ${diff}`); + } + } + + /** + * Wait until this peer's heads equal each peer in `others`. Sequential + * awaits — N-1 calls to `waitUntilHeadsEqual` — so any peer missing an + * event from any other peer fails the assertion. + */ + async waitUntilAllHeadsEqual( + others: Peer[], + opts: { timeout?: number } = {}, + ): Promise { + for (const other of others) { + await this.waitUntilHeadsEqual(other, opts); + } + } +``` + +The `await import('@playwright/test')` is a deliberate dynamic import: it keeps `expect` out of the type-only import block at the top of the file (which the fixture already re-exports), avoiding a circular self-reference if a spec re-imports `expect` from `./test-hooks`. + +- [ ] **Step 3: Verify type-check** + +```bash +npx tsc --noEmit e2e/test-hooks.ts +``` + +Expected: zero errors. + +- [ ] **Step 4: Verify ESLint clean** + +```bash +npx eslint e2e/test-hooks.ts +``` + +Expected: zero errors. + +- [ ] **Step 5: Commit** + +```bash +git add e2e/test-hooks.ts +git commit -m "test(e2e): add waitUntilHeadsEqual + waitUntilAllHeadsEqual + +Pairwise CRDT convergence check via expect.poll on canonicalised heads +maps. Failure message appends a per-author-key diff so 'A missing +authors: [x]; B missing authors: []' is visible without a manual +console.log round-trip. + +waitUntilAllHeadsEqual fans out N-1 pairwise checks for true N-peer +convergence per the spec's 'Partial-equality footgun' section." +``` + +--- + +## Task 6: Smoke tests for `Peer` in `e2e/test-hooks.spec.ts` + +**Files:** +- Create: `e2e/test-hooks.spec.ts` + +These are intentionally small — just enough to prove the wrapper plumbing works end-to-end against a real `just dev` stack. Coverage matrix: + +| Assertion | Validates | +|---|---| +| `peer.snapshot()` returns the expected fields | Pull API + JSON shape mirror is correct | +| `peer.eventCount() >= 1` after `createServer` | DAG actually grows + pull API reads it | +| `peer.nextEvent(SyncCompleted)` resolves | Push wiring + queue draining work | +| `peerA.waitUntilHeadsEqual(peerB)` after invite | Convergence helper, pairwise | +| `peer.nextEvent(timeout=200)` rejects with named error | Timeout path + error message | + +We omit the three-peer test (spec §"Testing the test infrastructure" lists it) — adding a third browser context to a smoke spec is overkill for the PR-2 acceptance gate. Tracking issue #458 will pick it up when more multi-peer tests need the API. + +- [ ] **Step 1: Write the spec** + +```ts +// e2e/test-hooks.spec.ts +import { test, expect, Peer } from './test-hooks'; +import { freshStart, createServer, getPeerId, generateInvite, joinViaInvite, setupTwoPeers } from './helpers'; + +// Sequential — these tests share the local relay via createServer/joinViaInvite. +test.describe.configure({ mode: 'serial' }); + +test.describe('Peer wrapper smoke', () => { + test.setTimeout(60_000); + + test('snapshot returns the expected shape after createServer', async ({ peer, browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const alice = peer(page, 'Alice'); + try { + await freshStart(page); + await createServer(page, 'SnapshotServer', 'Alice'); + + const snap = await alice.snapshot(); + expect(snap.eventCount).toBeGreaterThan(0); + expect(typeof snap.lastEvent).toBe('string'); + expect(Object.keys(snap.heads).length).toBeGreaterThan(0); + // The materialised ServerState should contain the default 'general' channel. + expect(snap.channels.map(c => c.name)).toContain('general'); + } finally { + await ctx.close(); + } + }); + + test('eventCount grows as events are applied', async ({ peer, browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const alice = peer(page, 'Alice'); + try { + await freshStart(page); + await createServer(page, 'GrowthServer', 'Alice'); + const before = await alice.eventCount(); + expect(before).toBeGreaterThan(0); + } finally { + await ctx.close(); + } + }); + + test('nextEvent resolves on SyncCompleted after invite flow', async ({ peer, browser }) => { + // Use the existing setupTwoPeers helper; capture both pages so we can + // observe Bob's first SyncCompleted after the join lands. + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const bob = peer(page2, 'Bob'); + try { + // Bob's WASM has been emitting SyncCompleted since join — at least one + // is in the queue. Drain the most recent one. + const ev = await bob.nextEvent(e => e.kind === 'SyncCompleted', { timeout: 5_000 }); + expect(ev.kind).toBe('SyncCompleted'); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); + + test('waitUntilHeadsEqual converges after invite flow', async ({ peer, browser }) => { + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = peer(page1, 'Alice'); + const bob = peer(page2, 'Bob'); + try { + // Both peers should converge — Bob applied Alice's CreateServer events + // during join; gossip propagates Alice's GrantTrust back. + await bob.waitUntilHeadsEqual(alice, { timeout: 30_000 }); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); + + test('nextEvent rejects with a named error on timeout', async ({ peer, browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const alice = peer(page, 'Alice'); + try { + await freshStart(page); + await createServer(page, 'TimeoutServer', 'Alice'); + // Predicate that can never match (event kind that doesn't exist for a single peer). + await expect( + alice.nextEvent(e => e.kind === 'PeerDisconnected', { timeout: 200 }) + ).rejects.toThrow(/Alice\.nextEvent timed out after 200ms/); + } finally { + await ctx.close(); + } + }); +}); +``` + +- [ ] **Step 2: Verify type-check** + +```bash +npx tsc --noEmit e2e/test-hooks.spec.ts +``` + +Expected: zero errors. + +- [ ] **Step 3: Verify ESLint clean** + +```bash +npx eslint e2e/test-hooks.spec.ts +``` + +Expected: zero errors. + +- [ ] **Step 4: Run the smoke spec against a live `just dev` stack** + +In one terminal: + +```bash +just dev FEATURES=test-hooks +``` + +In another: + +```bash +npx playwright test e2e/test-hooks.spec.ts --project=desktop-chrome +``` + +Expected: 5 passed. If `nextEvent` rejects unexpectedly, recheck Task 3's binding ordering — `exposeBinding` must run before `goto`, which it does because `freshStart` is the first thing each test calls and the fixture's `await use(factory)` runs before the test body. + +If `waitUntilHeadsEqual` hangs, the structured author-key diff in the failure message points at which peer is missing whose author. Most likely cause is `setupTwoPeers` returning before sync settled; the convergence helper exists exactly to tighten that. + +- [ ] **Step 5: Commit** + +```bash +git add e2e/test-hooks.spec.ts +git commit -m "test(e2e): smoke tests for Peer pull/push/convergence API + +Five tests covering snapshot shape, eventCount growth, nextEvent +push wiring, waitUntilHeadsEqual convergence, and the timeout error +path. Run against a local 'just dev FEATURES=test-hooks' stack. + +Three-peer waitUntilAllHeadsEqual coverage deferred to issue #458 — +no current multi-peer spec needs it." +``` + +--- + +## Task 7: Split `e2e/helpers.ts` → `e2e/helpers/peers.ts` + +**Files:** +- Create: `e2e/helpers/peers.ts` + +Move the 9 peer-setup helpers verbatim from `e2e/helpers.ts` into a new module. **Do not modify behaviour or remove `waitForTimeout` calls in this task** — that's a separate, riskier change. The goal here is purely structural; behavioural cleanup is a follow-up that the spec defers. + +The keep-the-eslint-disable header is preserved at the file top so the existing `waitForTimeout` calls don't trigger the rule. + +Helpers to move (with their current line ranges in `e2e/helpers.ts`): +- `waitForApp` (`:5-13`) +- `freshStart` (`:16-42`) +- `advancePastNameStep` (`:48-56`) — internal, `function` not `export function`; copy as-is +- `createServer` (`:59-91`) +- `getPeerId` (`:94-126`) +- `openServerSettings` (`:354-369`) +- `generateInvite` (`:372-381`) +- `joinViaInvite` (`:387-412`) +- `setupTwoPeers` (`:415-462`) + +Note `setupTwoPeers` calls `openMemberList` + `closeMemberList` from the UI module (Task 8), and `generateInvite`/`openServerSettings` use `visibleShell` + `isMobile` from UI. The `peers.ts` file therefore imports from `./ui` — Task 8 must land before this compiles in isolation. To avoid a broken intermediate state, do Tasks 7–9 as **one commit** (split into three logical files but one cargo-style atomic change). + +- [ ] **Step 1: Create `e2e/helpers/peers.ts`** + +```ts +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ +// +// Peer setup helpers. Extracted from the legacy 703-LOC e2e/helpers.ts +// per docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md Task 7. +// Behaviour is preserved verbatim — sleep removal is a follow-up. + +import { Page, Browser, BrowserContext, expect } from '@playwright/test'; +import { + isMobile, + visibleShell, + openMemberList, + closeMemberList, +} from './ui'; + +/** Wait for the WASM app to load (loading spinner disappears). */ +export async function waitForApp(page: Page) { + await page.waitForSelector( + '.welcome-screen:visible, .shell-desktop .app:visible, .shell-mobile .mobile-top-bar:visible, .join-card:visible', + { timeout: 30_000 }, + ); +} + +/** Clear all Willow localStorage keys and IndexedDB databases, then reload. */ +export async function freshStart(page: Page) { + await page.goto('/'); + await page.evaluate(async () => { + const keys = Object.keys(localStorage).filter(k => k.startsWith('willow_')); + keys.forEach(k => localStorage.removeItem(k)); + localStorage.clear(); + const dbNames = await indexedDB.databases?.() ?? []; + await Promise.all( + dbNames + .filter(db => db.name && (db.name.startsWith('willow') || db.name.startsWith('iroh'))) + .map(db => new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(db.name!); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); + })) + ); + }); + await page.reload(); + await waitForApp(page); +} + +async function advancePastNameStep(page: Page, displayName?: string) { + const nameInput = page.locator('.welcome-name-input'); + if (await nameInput.isVisible().catch(() => false)) { + if (displayName) await nameInput.fill(displayName); + await page.locator('.welcome-continue-btn').click(); + await page.locator('.welcome-tabs').waitFor({ timeout: 5_000 }); + } +} + +export async function createServer(page: Page, name: string, displayName?: string) { + await expect(page.locator('.welcome-card')).toBeVisible(); + await advancePastNameStep(page, displayName); + await page + .locator('.welcome-tab-panel input[placeholder="backyard"]') + .fill(name); + await page.locator('.welcome-tab-panel button', { hasText: 'continue' }).click(); + if (isMobile(page)) { + await page.waitForSelector('.mobile-top-bar', { state: 'visible', timeout: 10_000 }); + const generalRow = page + .locator(`${visibleShell(page)} .mobile-home .channel-item`, { hasText: 'general' }); + if (await generalRow.count() > 0) { + await generalRow.first().click(); + await page.waitForSelector('.mobile-push--channel', { timeout: 10_000 }); + } + } else { + await page.waitForSelector('.main-pane-header, .channel-sidebar', { + state: 'visible', + timeout: 10_000, + }); + } +} + +export async function getPeerId(page: Page): Promise { + if (await page.locator('.welcome-card').isVisible().catch(() => false)) { + await advancePastNameStep(page); + const joinTab = page.locator('.welcome-tab-btn', { hasText: 'Join' }); + if (await joinTab.isVisible().catch(() => false)) { + await joinTab.click(); + const revealBtn = page.locator('button[aria-label="show full peer id"]'); + await revealBtn.waitFor({ timeout: 5_000 }); + await revealBtn.click(); + } + const peerIdEl = page.locator('.welcome-join-steps__full-id').first(); + if (await peerIdEl.isVisible().catch(() => false)) { + return ( + (await peerIdEl.getAttribute('data-full-id')) || + (await peerIdEl.textContent()) || + '' + ); + } + } + await page.locator('text=Settings').click(); + await page.waitForTimeout(300); + const settingsPeerId = page.locator('.peer-id-text').first(); + return ( + (await settingsPeerId.getAttribute('data-full-id')) || + (await settingsPeerId.textContent()) || + '' + ); +} + +export async function openServerSettings(page: Page) { + if (isMobile(page)) { + const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); + while (await backSlot.isVisible().catch(() => false)) { + await page.locator('.mobile-top-bar .top-slot-left').click(); + await page.waitForTimeout(300); + } + await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); + await page.waitForTimeout(200); + } + await page.locator(`${visibleShell(page)} .server-gear-btn`).first().click(); + await page.locator('.settings-panel, .settings-overlay').first() + .waitFor({ timeout: 5_000 }); +} + +export async function generateInvite(page: Page, recipientPeerId: string): Promise { + await openServerSettings(page); + await page.locator('input[placeholder*="12D3KooW"]').fill(recipientPeerId); + await page.locator('button', { hasText: 'Generate Invite' }).click(); + await page.waitForTimeout(500); + const inviteCode = await page.locator('.invite-code-display textarea').inputValue(); + await page.locator('text=Back').click(); + await page.waitForTimeout(500); + return inviteCode; +} + +export async function joinViaInvite(page: Page, inviteCode: string, displayName?: string) { + await advancePastNameStep(page, displayName); + await page.locator('.welcome-tab-btn', { hasText: 'Join' }).click(); + await page.locator('.welcome-invite-input').waitFor({ timeout: 5_000 }); + await page.locator('.welcome-invite-input').fill(inviteCode); + await page.locator('.welcome-tab-panel button', { hasText: 'continue' }).click(); + await page.locator('button', { hasText: 'Join grove' }).waitFor({ timeout: 5_000 }); + await page.locator('button', { hasText: 'Join grove' }).click(); + if (isMobile(page)) { + await page.waitForSelector('.mobile-top-bar', { state: 'visible', timeout: 20_000 }); + } else { + await page.waitForSelector('.main-pane-header, .channel-sidebar', { + state: 'visible', + timeout: 20_000, + }); + } + await page.locator(`${visibleShell(page)} .channel-sidebar, ${visibleShell(page)} .mobile-home`) + .first() + .waitFor({ timeout: 20_000 }); + await page.locator(`${visibleShell(page)} .channel-item`).first() + .waitFor({ timeout: 20_000 }); +} + +export async function setupTwoPeers( + browser: Browser, + serverName = 'Test Server', + peer1Name = 'Alice', + peer2Name = 'Bob', +): Promise<{ ctx1: BrowserContext; ctx2: BrowserContext; page1: Page; page2: Page }> { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + + await freshStart(page1); + await createServer(page1, serverName, peer1Name); + + await freshStart(page2); + const peer2Id = await getPeerId(page2); + + const inviteCode = await generateInvite(page1, peer2Id); + + await joinViaInvite(page2, inviteCode, peer2Name); + + if (peer2Name && !isMobile(page1)) { + await openMemberList(page1); + try { + await page1 + .locator('.member-item', { hasText: peer2Name }) + .waitFor({ timeout: 20_000 }); + } catch { + console.warn('[setupTwoPeers] peer2 display name did not sync in time — P2P may be slow'); + } + await closeMemberList(page1); + } else if (peer2Name) { + await page1.waitForTimeout(1500); + } + + return { ctx1, ctx2, page1, page2 }; +} +``` + +- [ ] **Step 2: Hold the commit until Tasks 8 + 9 finish** + +The `import { ... } from './ui'` line will fail to resolve until Task 8 lands. **Don't run `tsc` here** — proceed to Task 8 first and commit all three split files together. + +--- + +## Task 8: Split → `e2e/helpers/ui.ts` + +**Files:** +- Create: `e2e/helpers/ui.ts` + +UI navigation helpers — the largest of the three split modules. Behaviour preserved verbatim. Imports `longPress` from `./touch` (Task 9), so this file also won't compile in isolation until Task 9 lands. + +Helpers to move: +- `sendMessage`, `getMessages`, `visibleShell`, `switchChannel`, `waitForMessage` +- `isMobile`, `openSidebar`, `closeSidebar`, `switchTab` +- `openMemberList`, `closeMemberList` +- `createChannel` +- `messageAction`, `editMessage`, `deleteMessage`, `reactToMessage` +- `trustPeer`, `untrustPeer`, `kickPeer` +- `openCompareFingerprints`, `markFingerprintsMatch`, `markFingerprintsMismatch` + +- [ ] **Step 1: Create `e2e/helpers/ui.ts`** + +```ts +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ +// +// UI navigation + message-action helpers. Extracted from legacy +// e2e/helpers.ts per docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md +// Task 8. Behaviour preserved verbatim. + +import { Page } from '@playwright/test'; +import { longPress } from './touch'; + +export function isMobile(page: Page): boolean { + return (page.viewportSize()?.width ?? 1024) < 768; +} + +export function visibleShell(page: Page): string { + return isMobile(page) ? '.shell-mobile' : '.shell-desktop'; +} + +export async function sendMessage(page: Page, text: string) { + const scope = isMobile(page) ? '.shell-mobile' : '.shell-desktop'; + if (isMobile(page)) { + const inPush = await page + .locator('.shell-mobile .mobile-push--channel') + .isVisible() + .catch(() => false); + if (!inPush) { + await page.locator('.shell-mobile .mobile-home .channel-item').first().click(); + await page.waitForTimeout(400); + } + } + const input = page + .locator(`${scope} .input-area input, ${scope} .input-area textarea`) + .first(); + await input.fill(text); + await input.press('Enter'); + await page.locator(`${visibleShell(page)} .message .body`, { hasText: text }) + .first() + .waitFor({ timeout: 10_000 }); +} + +export async function getMessages(page: Page): Promise { + const bodies = page.locator('.message .body'); + const count = await bodies.count(); + const texts: string[] = []; + for (let i = 0; i < count; i++) { + texts.push((await bodies.nth(i).textContent()) || ''); + } + return texts; +} + +export async function switchChannel(page: Page, channelName: string) { + if (isMobile(page)) { + const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); + while (await backSlot.isVisible().catch(() => false)) { + await page.locator('.mobile-top-bar .top-slot-left').click(); + await page.waitForTimeout(300); + } + await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); + await page.waitForTimeout(200); + await page + .locator('.mobile-home .channel-item', { hasText: channelName }) + .click(); + await page.waitForTimeout(400); + return; + } + await page + .locator(`${visibleShell(page)} .channel-item`, { hasText: channelName }) + .first() + .click(); +} + +export async function waitForMessage(page: Page, text: string, timeout = 20_000) { + const scope = visibleShell(page); + await page + .locator(`${scope} .message .body`, { hasText: text }) + .first() + .waitFor({ timeout }); +} + +export async function openSidebar(page: Page) { + if (!isMobile(page)) return; + const alreadyOpen = await page.locator('.grove-drawer.open').isVisible().catch(() => false); + if (alreadyOpen) return; + await page.locator('.mobile-top-bar .top-slot-left').click(); + await page.waitForTimeout(500); +} + +export async function closeSidebar(page: Page) { + if (!isMobile(page)) return; + const drawerOpen = await page.locator('.grove-drawer.open').isVisible().catch(() => false); + if (!drawerOpen) return; + await page.locator('.grove-drawer-backdrop').dispatchEvent('click'); + await page.waitForTimeout(300); +} + +export async function switchTab( + page: Page, + tabId: 'home' | 'letters' | 'discover' | 'you', +) { + if (!isMobile(page)) return; + await page.locator(`.mobile-tab-bar .tab[data-tab="${tabId}"]`).click(); + await page.waitForTimeout(200); +} + +export async function openMemberList(page: Page) { + const openPane = page.locator('.right-rail[data-open="true"] .member-list'); + if (await openPane.isVisible().catch(() => false)) return; + if (isMobile(page)) { + const inPush = await page.locator('.mobile-push--channel').isVisible().catch(() => false); + if (!inPush) { + await page.locator('.mobile-home .channel-item').first().click(); + await page.waitForTimeout(400); + } + } + const membersBtn = page.locator(`${visibleShell(page)} .action-btn[aria-label="members"]`); + if (await membersBtn.count() > 0) { + await membersBtn.first().click(); + await page + .locator(`${visibleShell(page)} .right-rail[data-open="true"] .member-list`) + .waitFor({ timeout: 3_000 }) + .catch(() => {}); + } +} + +export async function closeMemberList(page: Page) { + const openPane = page.locator(`${visibleShell(page)} .right-rail[data-open="true"] .member-list`); + const isOpen = await openPane.isVisible().catch(() => false); + if (!isOpen) return; + const membersBtn = page.locator(`${visibleShell(page)} .action-btn[aria-label="members"]`); + if (await membersBtn.count() > 0) { + await membersBtn.first().click(); + } +} + +export async function createChannel(page: Page, name: string) { + if (isMobile(page)) { + const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); + while (await backSlot.isVisible().catch(() => false)) { + await page.locator('.mobile-top-bar .top-slot-left').click(); + await page.waitForTimeout(300); + } + await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); + await page.waitForTimeout(200); + } + const scope = visibleShell(page); + await page.locator(`${scope} .channel-add-btn`).first().click(); + await page.waitForTimeout(200); + await page.locator(`${scope} .channel-create-input input`).first().fill(name); + await page.locator(`${scope} .channel-create-input input`).first().press('Enter'); + await page.locator(`${visibleShell(page)} .channel-item`, { hasText: name }) + .waitFor({ timeout: 10_000 }); +} + +export async function messageAction(page: Page, messageText: string, actionName: string) { + if (isMobile(page)) { + await longPress(page, `.message:has-text("${messageText}")`); + await page.locator('.shell-mobile .mobile-action-sheet.open').first() + .waitFor({ timeout: 3000 }); + const actionRe = new RegExp(`^\\s*${actionName}\\s*$`, 'i'); + await page + .locator('.shell-mobile .mobile-action-sheet.open .sheet-item', { hasText: actionRe }) + .click(); + await page.waitForTimeout(300); + } else { + const msg = page.locator('.shell-desktop .message', { hasText: messageText }).last(); + await msg.hover(); + await page.waitForTimeout(200); + await msg.locator('.action-trigger').click(); + await page.waitForTimeout(200); + await page.locator('.dropdown-item', { hasText: actionName }).click(); + await page.waitForTimeout(200); + } +} + +export async function editMessage(page: Page, originalText: string, newText: string) { + await messageAction(page, originalText, 'Edit'); + const input = page.locator('.input-area input, .input-area textarea').first(); + await input.fill(newText); + await input.press('Enter'); + await page.waitForTimeout(500); +} + +export async function deleteMessage(page: Page, text: string) { + await messageAction(page, text, 'Delete'); + const confirmBtn = page.locator('.confirm-dialog .btn-danger', { hasText: 'Delete' }); + await confirmBtn.waitFor({ timeout: 3000 }); + await confirmBtn.click(); + await page.waitForTimeout(500); +} + +export async function reactToMessage(page: Page, messageText: string, emojiIndex = 0) { + if (isMobile(page)) { + await longPress(page, `.message:has-text("${messageText}")`); + await page.locator('.shell-mobile .mobile-action-sheet.open').first() + .waitFor({ timeout: 3000 }); + await page.locator('.shell-mobile .mobile-action-sheet.open .sheet-emoji-row button') + .nth(emojiIndex).click(); + await page.waitForTimeout(500); + } else { + const msg = page.locator('.shell-desktop .message', { hasText: messageText }).last(); + await msg.hover(); + await page.waitForTimeout(200); + await msg.locator('.action-trigger').click(); + await page.waitForTimeout(200); + await page.locator('.dropdown-item', { hasText: 'React' }).click(); + await page.waitForTimeout(200); + await page.locator('.dropdown-emoji-row button').nth(emojiIndex).click(); + await page.waitForTimeout(500); + } +} + +export async function trustPeer(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator('.member-item', { hasText: peerName }); + await member.waitFor({ timeout: 30_000 }); + await member.hover(); + await member.locator('button').filter({ hasText: /^Trust$/ }).click(); + await page.waitForTimeout(500); + await closeMemberList(page); +} + +export async function untrustPeer(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator('.member-item', { hasText: peerName }); + await member.waitFor({ timeout: 30_000 }); + await member.hover(); + await member.locator('button', { hasText: 'Untrust' }).click(); + await page.waitForTimeout(500); + await closeMemberList(page); +} + +export async function openCompareFingerprints(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator(`${visibleShell(page)} .member-item`, { hasText: peerName }); + await member.waitFor({ timeout: 10_000 }); + await member.locator('.trust-badge').click(); + await page + .locator('.add-friend__card[role="dialog"]') + .waitFor({ timeout: 5_000 }); +} + +export async function markFingerprintsMatch(page: Page) { + await page + .locator('.add-friend__cta-primary', { hasText: 'they match' }) + .click(); + await page + .locator('.add-friend__confirm-title', { hasText: 'verified.' }) + .waitFor({ timeout: 5_000 }); +} + +export async function markFingerprintsMismatch(page: Page) { + await page + .locator('.add-friend__cta-secondary', { hasText: "they don't match" }) + .click(); + await page + .locator('.add-friend__confirm-title', { hasText: 'marked not verified.' }) + .waitFor({ timeout: 5_000 }); +} + +export async function kickPeer(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator('.member-item', { hasText: peerName }); + await member.waitFor({ timeout: 30_000 }); + await member.hover(); + await member.locator('.btn-danger', { hasText: 'Kick' }).click(); + await page.waitForTimeout(500); + const confirmBtn = page.locator('.confirm-dialog .btn-danger', { hasText: 'Kick' }); + await confirmBtn.waitFor({ timeout: 5_000 }); + await confirmBtn.click(); + await page.waitForTimeout(500); + await closeMemberList(page); +} +``` + +- [ ] **Step 2: Hold the commit until Task 9 finishes** + +The `import { longPress } from './touch';` line still won't resolve. Proceed to Task 9. + +--- + +## Task 9: Split → `e2e/helpers/touch.ts` + +**Files:** +- Create: `e2e/helpers/touch.ts` + +Touch / gesture helpers. Self-contained — no cross-helper imports. Behaviour preserved verbatim. + +Helpers to move: `longPress`, `longPressAvatar`, `dispatchSwipe` (internal), `swipeLeft`, `swipeRight`. `longPressAvatar` calls `openMemberList` from `./ui` so this file does import from `./ui`. + +- [ ] **Step 1: Create `e2e/helpers/touch.ts`** + +```ts +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ +// +// Touch + gesture helpers. Extracted from legacy e2e/helpers.ts per +// docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md Task 9. +// Behaviour preserved verbatim — page.clock migration is a follow-up. + +import { Page, Locator } from '@playwright/test'; +import { isMobile, visibleShell, openMemberList } from './ui'; + +export async function longPress(page: Page, selector: string, durationMs = 600) { + const scoped = isMobile(page) && !selector.startsWith('.shell-') + ? `${visibleShell(page)} ${selector}` + : selector; + const el = page.locator(scoped).first(); + const box = await el.boundingBox(); + if (!box) throw new Error(`Element not found: ${selector}`); + + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.evaluate(({ x, y }) => { + const target = document.elementFromPoint(x, y); + if (!target) return; + const touch = new Touch({ + identifier: 1, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + target.dispatchEvent(new TouchEvent('touchstart', { + bubbles: true, + cancelable: true, + touches: [touch], + targetTouches: [touch], + changedTouches: [touch], + })); + }, { x, y }); + + await page.waitForTimeout(durationMs); + + await page.evaluate(({ x, y }) => { + const target = document.elementFromPoint(x, y); + if (!target) return; + const touch = new Touch({ + identifier: 1, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + target.dispatchEvent(new TouchEvent('touchend', { + bubbles: true, + cancelable: true, + touches: [], + targetTouches: [], + changedTouches: [touch], + })); + }, { x, y }); + + await page.waitForTimeout(300); +} + +export async function longPressAvatar(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator(`${visibleShell(page)} .member-item`, { hasText: peerName }); + await member.waitFor({ timeout: 10_000 }); + const target = member.locator('.long-press-avatar, .status-dot').first(); + const box = await target.boundingBox(); + if (!box) throw new Error('avatar not measurable'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(500); + await page.mouse.up(); +} + +async function dispatchSwipe(row: Locator, dx: number): Promise { + await row.evaluate((el, dx) => { + const rect = (el as HTMLElement).getBoundingClientRect(); + const startX = dx > 0 ? rect.left + rect.width * 0.2 : rect.left + rect.width * 0.8; + const startY = rect.top + rect.height / 2; + const makeTouch = (x: number, y: number) => new Touch({ + identifier: 0, + target: el as HTMLElement, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + } as TouchInit); + const fire = (type: string, x: number) => { + const touch = makeTouch(x, startY); + (el as HTMLElement).dispatchEvent(new TouchEvent(type, { + cancelable: true, + bubbles: true, + touches: type === 'touchend' ? [] : [touch], + targetTouches: type === 'touchend' ? [] : [touch], + changedTouches: [touch], + })); + }; + fire('touchstart', startX); + fire('touchmove', startX + dx * 0.3); + fire('touchmove', startX + dx * 0.7); + fire('touchmove', startX + dx); + fire('touchend', startX + dx); + }, dx); +} + +export async function swipeLeft(_page: Page, row: Locator): Promise { + return dispatchSwipe(row, -120); +} + +export async function swipeRight(_page: Page, row: Locator): Promise { + return dispatchSwipe(row, 120); +} +``` + +- [ ] **Step 2: Hold the commit until Task 10 finishes** + +`e2e/helpers.ts` still has the old definitions and `helpers/touch.ts` has duplicates — TypeScript would flag the duplicate exports if both files were active simultaneously. Task 10 collapses the old file into a barrel that re-exports from `./helpers/*`, fixing the duplicate. + +--- + +## Task 10: Convert `e2e/helpers.ts` to a re-export barrel + +**Files:** +- Modify: `e2e/helpers.ts` + +Replace the entire 703-LOC file with a barrel that re-exports everything the legacy specs need. The eslint-disable header stays so any spec still importing from `./helpers` (all 7 un-migrated specs) keeps its existing zero-warnings status. + +Verified-against-spec-imports list (from `grep -A8 "import {" e2e/*.spec.ts`): +- From `peers`: `freshStart`, `createServer`, `getPeerId`, `generateInvite`, `joinViaInvite`, `setupTwoPeers`, `waitForApp`, `openServerSettings`. +- From `ui`: `sendMessage`, `waitForMessage`, `switchChannel`, `createChannel`, `openSidebar`, `closeSidebar` (defensive — not currently imported but exported), `openMemberList`, `closeMemberList`, `visibleShell`, `isMobile`, `messageAction` (defensive), `editMessage` (defensive), `deleteMessage` (defensive), `reactToMessage`, `trustPeer` (defensive), `untrustPeer` (defensive), `kickPeer`, `openCompareFingerprints`, `markFingerprintsMatch`, `markFingerprintsMismatch`, `getMessages` (defensive), `switchTab` (defensive). +- From `touch`: `longPress`, `longPressAvatar`, `swipeLeft`, `swipeRight`. + +- [ ] **Step 1: Replace `e2e/helpers.ts` entirely** + +```ts +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ +// +// Re-export barrel. The implementation lives in e2e/helpers/{peers,ui,touch}.ts +// per docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md Task 10. +// +// Keeping this file as a barrel means the 7 un-migrated specs continue to +// import from './helpers' with zero diff. New specs should import directly +// from the focused modules (or use the Peer wrapper from './test-hooks'). + +export * from './helpers/peers'; +export * from './helpers/ui'; +export * from './helpers/touch'; +``` + +- [ ] **Step 2: Run the full lint + type-check** + +```bash +npx tsc --noEmit e2e/helpers.ts e2e/helpers/peers.ts e2e/helpers/ui.ts e2e/helpers/touch.ts e2e/test-hooks.ts e2e/test-hooks.spec.ts +npx eslint e2e/ +``` + +Expected: zero errors. If `tsc` complains about a missing export, the verified-imports list above is wrong for that name — grep the spec file that uses it and add the export to the appropriate `helpers/*` module. + +- [ ] **Step 3: Sanity-run one un-migrated spec to confirm the barrel works** + +```bash +npx playwright test e2e/permissions.spec.ts --project=desktop-chrome --grep "kick" +``` + +Pick any single test from any un-migrated spec — `permissions.spec.ts` exercises `kickPeer`, `openMemberList`, `setupTwoPeers` which span all three modules and is a good cross-section. Expected: PASS. + +- [ ] **Step 4: Commit Tasks 7 + 8 + 9 + 10 together** + +```bash +git add e2e/helpers.ts e2e/helpers/ +git commit -m "test(e2e): split 703-LOC helpers.ts into peers/ui/touch modules + +Per spec §'Helpers redesign' and PR-2 plan Tasks 7-10. Three focused +modules behind a re-export barrel — legacy specs import-from-helpers +unchanged, new specs import directly from the focused module they need. + +Behaviour preserved verbatim. Magic-number sleeps and 30s timeout +overrides stay where they were; PR-2 only deletes them from the pilot +spec (multi-peer-sync.spec.ts) where the Peer wrapper replaces them. +Other 7 specs migrate file-by-file via tracking issue #458." +``` + +--- + +## Task 11: Barrel-export coverage test + +**Files:** +- Create: `e2e/helpers.barrel.test.ts` + +Per spec §"PR 2 — Playwright `Peer` wrapper" final paragraph: every name imported by any un-migrated spec must be re-exported by the barrel; missing exports must fail the build, not just the runtime tests. + +This is a build-time-only TypeScript check. It is **not** a Playwright test (so it shouldn't end in `.spec.ts` — Playwright would try to run it). Naming it `*.test.ts` keeps Playwright's `testMatch` (default `*.spec.ts`) from picking it up. We add it to `tsc`'s coverage by including the e2e dir in any future `tsconfig.json`; for now it's part of the `npx tsc --noEmit e2e/` invocation in `just check-all` (see Task 13's README addition for the recipe wiring). + +The test simply imports every name and asserts each is `typeof === 'function'` (or for `visibleShell` / `isMobile`, just `'function'`). If a name is missing or renamed, `tsc` fails with `TS2305: Module '"./helpers"' has no exported member 'X'.` + +- [ ] **Step 1: Create `e2e/helpers.barrel.test.ts`** + +```ts +// e2e/helpers.barrel.test.ts +// +// Build-time coverage of the helpers.ts barrel. Asserts every name imported +// by any un-migrated spec is still re-exported. If you remove a name from +// helpers/{peers,ui,touch}.ts, tsc fails here with TS2305 before any +// Playwright test runs. +// +// This is NOT a Playwright spec (filename uses .test.ts so Playwright's +// default `testMatch: '*.spec.ts'` skips it). It executes only as part of +// `npx tsc --noEmit` / `npx eslint`. + +import { + // peers + freshStart, + createServer, + getPeerId, + generateInvite, + joinViaInvite, + setupTwoPeers, + waitForApp, + openServerSettings, + // ui + sendMessage, + waitForMessage, + switchChannel, + createChannel, + openSidebar, + closeSidebar, + openMemberList, + closeMemberList, + visibleShell, + isMobile, + messageAction, + editMessage, + deleteMessage, + reactToMessage, + trustPeer, + untrustPeer, + kickPeer, + openCompareFingerprints, + markFingerprintsMatch, + markFingerprintsMismatch, + getMessages, + switchTab, + // touch + longPress, + longPressAvatar, + swipeLeft, + swipeRight, +} from './helpers'; + +// One reference per name so TS can't tree-shake the imports away. The +// `void` operator silences `@typescript-eslint/no-unused-expressions` +// without needing an eslint-disable comment. +void freshStart; +void createServer; +void getPeerId; +void generateInvite; +void joinViaInvite; +void setupTwoPeers; +void waitForApp; +void openServerSettings; +void sendMessage; +void waitForMessage; +void switchChannel; +void createChannel; +void openSidebar; +void closeSidebar; +void openMemberList; +void closeMemberList; +void visibleShell; +void isMobile; +void messageAction; +void editMessage; +void deleteMessage; +void reactToMessage; +void trustPeer; +void untrustPeer; +void kickPeer; +void openCompareFingerprints; +void markFingerprintsMatch; +void markFingerprintsMismatch; +void getMessages; +void switchTab; +void longPress; +void longPressAvatar; +void swipeLeft; +void swipeRight; +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +npx tsc --noEmit e2e/helpers.barrel.test.ts +``` + +Expected: zero errors. If anything fails with TS2305, the verified-imports list in Task 10 is missing that export — go back and add it to the appropriate `helpers/*` module. + +- [ ] **Step 3: Verify Playwright doesn't try to run it** + +```bash +npx playwright test --list e2e/helpers.barrel.test.ts 2>&1 | head -5 +``` + +Expected: `Total: 0 tests in 0 files` (or equivalent — Playwright's default `testMatch` doesn't include `*.test.ts`). If for some reason Playwright picks it up, fix `playwright.config.ts` rather than rename — it would mean a config drift bug. Re-confirm `testMatch` is `**/*.spec.ts` (Playwright default). + +- [ ] **Step 4: Verify ESLint clean** + +```bash +npx eslint e2e/helpers.barrel.test.ts +``` + +Expected: zero errors. The `void X;` pattern silences unused-expression warnings without disable comments. + +- [ ] **Step 5: Commit** + +```bash +git add e2e/helpers.barrel.test.ts +git commit -m "test(e2e): build-time coverage of helpers.ts re-export barrel + +Imports every name used by any un-migrated spec from './helpers' and +references it once. If a name disappears from helpers/{peers,ui,touch} +(e.g. accidental rename during the next migration), tsc fails here +with TS2305 before any Playwright test runs. + +Filename ends with .test.ts so Playwright's default testMatch skips +it — this is a build-time TypeScript check, not a runtime spec." +``` + +--- + +## Task 12: Pilot conversion — `e2e/multi-peer-sync.spec.ts` + +**Files:** +- Modify: `e2e/multi-peer-sync.spec.ts` + +The spec has 6 tests, 8 occurrences of `{ timeout: 30_000 }` on cross-peer DOM assertions, and 1 `expect.poll({ timeout: 30_000 })`. The conversion pattern for each: + +| Before | After | +|---|---| +| `await expect(page2.locator(...)).toBeVisible({ timeout: 30_000 })` after a remote mutation | `await peerB.waitUntilHeadsEqual(peerA);` then `await expect(page2.locator(...)).toBeVisible()` (default 5s) | +| `await waitForMessage(page2, text, 30_000)` | `await peerB.nextEvent(e => e.kind === 'MessageReceived' && e.channel === '...' && !e.isLocal);` then `await waitForMessage(page2, text)` (default 20s, plenty after the event fires) | + +The default `expect` timeout in `playwright.config.ts` is unspecified → Playwright's library default of 5 s applies. After convergence, 5 s is well clear of the DOM-render noise floor. + +Switch from `import { test, expect } from '@playwright/test'` to `import { test, expect } from './test-hooks'` so each test gets the `peer` fixture. + +- [ ] **Step 1: Replace `e2e/multi-peer-sync.spec.ts` entirely** + +```ts +import { test, expect } from './test-hooks'; +import { + freshStart, + createServer, + sendMessage, + waitForMessage, + getPeerId, + switchChannel, + setupTwoPeers, + generateInvite, + joinViaInvite, + createChannel, + openSidebar, + visibleShell, +} from './helpers'; + +// Shared relay + gossip mesh — keep tests inside this file sequential +// so they don't stampede the relay while `fullyParallel: true` runs +// different spec files concurrently. +test.describe.configure({ mode: 'serial' }); + +test.describe('Multi-peer state synchronization', () => { + // Two-peer tests need extra time for setup + P2P sync. + test.setTimeout(120_000); + + // Sync-semantic tests (messages/edits/deletes/reactions/typing/display-names/ + // history-replay/reconnect-replay/persist-after-refresh) live in + // crates/client/src/tests/multi_peer_sync.rs against MemNetwork — the + // DAG merge path is identical and the test runs in < 200 ms. + // Only DOM-reflection tests stay here. + // + // Migration to event-based waits per PR-2 (issue #458). Cross-peer + // assertions now gate on Peer.waitUntilHeadsEqual / Peer.nextEvent; + // DOM checks then run with the default 5s assertion timeout. + + test('invite flow — both peers see sidebar and general channel', async ({ peer, browser }) => { + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = peer(page1, 'Alice'); + const bob = peer(page2, 'Bob'); + try { + // Both peers should converge before we assert UI state. + await bob.waitUntilHeadsEqual(alice); + + // Both peers should see the sidebar (default 5s timeout — convergence already done). + await expect(page1.locator(`${visibleShell(page1)} .channel-sidebar, ${visibleShell(page1)} .mobile-home`).first()).toBeVisible(); + await expect(page2.locator(`${visibleShell(page2)} .channel-sidebar, ${visibleShell(page2)} .mobile-home`).first()).toBeVisible(); + + // Both peers should see the general channel. + await expect(page1.locator(`${visibleShell(page1)} .channel-item`, { hasText: 'general' })).toBeVisible(); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'general' })).toBeVisible(); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); + + test('pre-existing channels visible after join', async ({ peer, browser }) => { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + const alice = peer(page1, 'Alice'); + const bob = peer(page2, 'Bob'); + + try { + // Peer 1: Create server. + await freshStart(page1); + await createServer(page1, 'PreChan Server', 'Alice'); + + // Create 2 extra channels BEFORE invite. + await createChannel(page1, 'announcements'); + await createChannel(page1, 'random'); + + // Peer 2: Get peer ID. + await freshStart(page2); + const peer2Id = await getPeerId(page2); + + // Peer 1: Generate invite. + const inviteCode = await generateInvite(page1, peer2Id); + + // Peer 2: Join. + await joinViaInvite(page2, inviteCode, 'Bob'); + + // Bob should converge to Alice's heads — including the two pre-existing channels. + await bob.waitUntilHeadsEqual(alice); + + // Peer 2 should see all 3 channels (open sidebar on mobile). + await openSidebar(page2); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'general' })).toBeVisible(); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'announcements' })).toBeVisible(); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'random' })).toBeVisible(); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); + + test('new channel created mid-session syncs to peer', async ({ peer, browser }) => { + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = peer(page1, 'Alice'); + const bob = peer(page2, 'Bob'); + try { + // Alice creates a new channel after both are connected. + await createChannel(page1, 'new-channel'); + + // Wait for Bob's DAG to converge to Alice's (includes the new channel event). + await bob.waitUntilHeadsEqual(alice); + + // Bob should see the new channel (open sidebar on mobile). + await openSidebar(page2); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'new-channel' })).toBeVisible(); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); + + test('messages in non-general channel sync', async ({ peer, browser }) => { + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = peer(page1, 'Alice'); + const bob = peer(page2, 'Bob'); + try { + // Alice creates a new channel. + await createChannel(page1, 'dev'); + + // Wait for Bob's DAG to include the channel. + await bob.waitUntilHeadsEqual(alice); + + // Bob can now see the channel without padding. + await openSidebar(page2); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'dev' })).toBeVisible(); + + // Both switch to the new channel. + await switchChannel(page1, 'dev'); + await switchChannel(page2, 'dev'); + + // Alice sends a message → wait for Bob's MessageReceived event, + // then assert the DOM-rendered body. + await sendMessage(page1, 'message in dev'); + await bob.nextEvent(e => + e.kind === 'MessageReceived' && + e.channel === 'dev' && + !e.isLocal + ); + await waitForMessage(page2, 'message in dev'); + + // Bob sends a reply, Alice consumes the event then asserts the body. + await sendMessage(page2, 'bob in dev too'); + await alice.nextEvent(e => + e.kind === 'MessageReceived' && + e.channel === 'dev' && + !e.isLocal + ); + await waitForMessage(page1, 'bob in dev too'); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); + + test('both peers appear in member list', async ({ peer, browser }) => { + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = peer(page1, 'Alice'); + const bob = peer(page2, 'Bob'); + try { + // Wait for the membership events to converge before opening the panel. + await bob.waitUntilHeadsEqual(alice); + + await page1.locator(`${visibleShell(page1)} button[aria-label="members"]`) + .first().click(); + + // Default expect timeout (5s) is plenty after convergence. + const memberList = page1.locator(`${visibleShell(page1)} .member-item`); + await expect(memberList.first()).toBeVisible(); + await expect.poll(() => memberList.count()).toBeGreaterThanOrEqual(2); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); + + test('rapid channel creation by owner — both channels propagate to peer', async ({ peer, browser }) => { + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = peer(page1, 'Alice'); + const bob = peer(page2, 'Bob'); + try { + // Alice (owner) creates two channels back-to-back. + await createChannel(page1, 'chan-a'); + await createChannel(page1, 'chan-b'); + + // Wait for Bob's DAG to include both. + await bob.waitUntilHeadsEqual(alice); + + // Both should appear on Bob's side after gossip delivery. + await openSidebar(page2); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'chan-a' })).toBeVisible(); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'chan-b' })).toBeVisible(); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); +}); +``` + +- [ ] **Step 2: Verify type-check + lint** + +```bash +npx tsc --noEmit e2e/multi-peer-sync.spec.ts +npx eslint e2e/multi-peer-sync.spec.ts +``` + +Expected: zero errors. The file no longer contains any `{ timeout: 30_000 }` overrides. + +- [ ] **Step 3: Confirm zero `waitForTimeout` and zero 30s timeout overrides remain** + +```bash +grep -c "waitForTimeout\|{ timeout: 30_000" e2e/multi-peer-sync.spec.ts +``` + +Expected: `0`. + +- [ ] **Step 4: Run the pilot spec against `just dev FEATURES=test-hooks`** + +```bash +# Terminal 1 +just dev FEATURES=test-hooks +# Terminal 2 +npx playwright test e2e/multi-peer-sync.spec.ts --project=desktop-chrome --reporter=line 2>&1 | tee /tmp/pr2-after.log +``` + +Expected: 6 passed. Capture the wall-clock from the summary line and compare to the Task 0 baseline (`/tmp/pr2-baseline.log`). Record both numbers in the eventual PR description body. + +- [ ] **Step 5: Run the pilot spec 5× to sanity-check non-flake** + +```bash +for i in 1 2 3 4 5; do + echo "=== run $i ==="; + npx playwright test e2e/multi-peer-sync.spec.ts --project=desktop-chrome --reporter=line || exit 1; +done +``` + +Expected: 5/5 pass. (The full `N=10` flake harness ships in PR-4; 5 runs is the sanity gate for PR-2.) + +If any run fails, the failure should be debuggable from the structured author-key diff in the `waitUntilHeadsEqual` error message — record the exact failure mode and triage rather than retrying. + +- [ ] **Step 6: Commit** + +```bash +git add e2e/multi-peer-sync.spec.ts +git commit -m "test(e2e): convert multi-peer-sync.spec.ts to event-based waits + +Pilot for PR-2 per docs/specs/2026-04-27-event-based-waits-design.md. + +- 8 'toBeVisible({ timeout: 30_000 })' cross-peer assertions replaced + with 'await peerB.waitUntilHeadsEqual(peerA);' followed by default + 5s assertions. +- 'waitForMessage(page, text, 30_000)' replaced with + 'await peerB.nextEvent(e => MessageReceived && !isLocal);' then a + default-timeout waitForMessage. +- 'expect.poll(..., { timeout: 30_000 })' on member-list count drops + the override after convergence. + +Signs the test on the Peer fixture from ./test-hooks. The other 7 +specs continue to import from @playwright/test directly; they migrate +file-by-file via tracking issue #458. + +Acceptance: 5 sequential local runs pass; the full N=10 flake harness +ships in PR-4 alongside the wait-timeout ratchet baseline." +``` + +--- + +## Task 13: Update `e2e/README.md` with `Peer` + helpers/ documentation + +**Files:** +- Modify: `e2e/README.md` + +The current README (34 lines) covers what belongs in e2e and how to run. Add three new subsections so the next migrator finds the patterns without diving into source. + +- [ ] **Step 1: Append the new sections** + +Open `e2e/README.md` and append the following at the end of the file (after the final "Running" section): + +```markdown + +## Helpers layout + +The legacy 703-LOC `helpers.ts` has been split into focused modules. New +specs should import directly from the focused module they need. + +``` +e2e/ +├── helpers/ +│ ├── peers.ts -- freshStart, createServer, getPeerId, generateInvite, +│ │ joinViaInvite, setupTwoPeers, openServerSettings, waitForApp +│ ├── ui.ts -- visibleShell, isMobile, sendMessage, waitForMessage, +│ │ switchChannel, openSidebar, openMemberList, createChannel, +│ │ messageAction, editMessage, deleteMessage, reactToMessage, +│ │ trustPeer, untrustPeer, kickPeer, openCompareFingerprints, … +│ └── touch.ts -- longPress, longPressAvatar, swipeLeft, swipeRight +├── helpers.ts -- re-export barrel; un-migrated specs continue to import +│ from './helpers' with zero diff +├── test-hooks.ts -- Peer wrapper + `peer` fixture (see "Event-based waits" below) +└── *.spec.ts +``` + +## Event-based waits (Peer wrapper) + +The web crate exposes `window.__willow` and a `__willowEvent` push stream +when built with `--features test-hooks`. The `Peer` class in +`e2e/test-hooks.ts` wraps both: + +- **Pull**: `peer.snapshot()`, `peer.heads()`, `peer.eventCount()`, + `peer.lastEvent()` — each round-trips through `window.__willow.*`. +- **Push**: `peer.nextEvent(predicate, { timeout? })` — drains the next + event matching `predicate` from the per-page event queue. +- **Convergence**: `peer.waitUntilHeadsEqual(otherPeer)` and + `peer.waitUntilAllHeadsEqual([otherPeers])` — `expect.poll`-based + CRDT convergence checks. Failure messages include a structured + per-author-key diff so missing-author hangs are debuggable without a + manual `console.log`. + +Specs that need the wrapper import the typed `test` + `expect` from +`./test-hooks` instead of `@playwright/test`: + +```ts +import { test, expect } from './test-hooks'; + +test('peer B converges with peer A', async ({ peer, browser }) => { + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const a = peer(page1, 'Alice'); + const b = peer(page2, 'Bob'); + await b.waitUntilHeadsEqual(a); // gossip-side wait + await expect(page2.locator('.channel-item', { hasText: 'general' })) + .toBeVisible(); // default 5s — DOM-only after convergence +}); +``` + +The full design is in +[`docs/specs/2026-04-27-event-based-waits-design.md`](../docs/specs/2026-04-27-event-based-waits-design.md). +Migration progress for the remaining 7 specs is tracked in +[#458](https://github.com/intendednull/willow/issues/458). + +## Anti-patterns blocked by ESLint + +`page.waitForTimeout(ms)` is blocked by `no-restricted-syntax` in +`eslint.config.js`. Specs migrated off the timeout pattern remove their +file-top `eslint-disable` header in the same PR. Each remaining +disabled file references issue #458; the rule sunsets on 2026-09-30 +(per spec §"Sunset"). +``` + +- [ ] **Step 2: Verify markdown renders cleanly** + +```bash +# A quick eyeball — no formal markdown linter is configured for this repo. +sed -n '35,$p' e2e/README.md +``` + +Expected: the new sections render with proper headers and the tree diagram is monospace-aligned. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/README.md +git commit -m "docs(e2e): document Peer wrapper + helpers/ split in README + +Three new sections: +- Helpers layout (peers/ui/touch + barrel) +- Event-based waits (Peer pull/push/convergence API + import pattern) +- Anti-patterns blocked by ESLint (waitForTimeout + sunset date) + +Points readers at the design spec + tracking issue #458 for the +remaining-specs migration." +``` + +--- + +## Final acceptance — run the full PR gate + +After Task 13, run the full check-all to confirm nothing else regressed: + +```bash +just check-all FEATURES=test-hooks +``` + +Expected: PASS, including: +- `cargo fmt`, `cargo clippy --workspace -- -D warnings`, `cargo test --workspace` +- WASM target check +- `wasm-pack test crates/web --headless --firefox` (browser-tier) +- `npx playwright test` (e2e — both `test-hooks.spec.ts` and the converted `multi-peer-sync.spec.ts` exercise the new API) +- `scripts/check-no-test-hooks-in-prod.sh` (symbol-leak guard from PR-1) + +If green, push the branch: + +```bash +git push -u origin claude/event-testing-pr-two-KGxN1 +``` + +--- + +## Out of scope (deferred to later PRs) + +Per the spec's implementation phasing, these ship in **PR 3** and **PR 4** and are explicitly NOT in PR-2's scope: + +- `data-state` lifecycle on the five animated components — PR 3. +- `page.clock` adoption for `longPress` / debounce — PR 3 (touch.ts is staged for the follow-up but unchanged here). +- Migration of the other 7 specs — file-by-file via tracking issue #458. +- `just test-e2e-flake N=10` recipe + `e2e/.wait-timeout-baseline` ratchet — PR 4. +- Three-peer `waitUntilAllHeadsEqual` smoke test — when a multi-peer spec needs it (issue #458). +- Removal of magic-number sleeps inside `helpers/{peers,ui,touch}.ts` — happens during each spec's migration (so the helpers stay behaviour-equivalent until every caller is converted). + +--- + +## Cross-references + +- Spec: [`docs/specs/2026-04-27-event-based-waits-design.md`](../specs/2026-04-27-event-based-waits-design.md) §"PR 2". +- PR-1 plan: [`docs/plans/2026-04-27-event-based-waits-pr1-test-hooks-foundation.md`](./2026-04-27-event-based-waits-pr1-test-hooks-foundation.md). +- PR-1 errata: [`docs/plans/2026-04-28-event-based-waits-pr1-errata.md`](./2026-04-28-event-based-waits-pr1-errata.md). +- Tracking issue: [#458](https://github.com/intendednull/willow/issues/458). + From 5cd0c9e741d8cf70aa36db78b6924e4709e665c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:34:55 +0000 Subject: [PATCH 02/12] test(e2e): scaffold test-hooks.ts with WireEvent + Snapshot types Type-only mirror of crates/web/src/test_hooks/{wire,snapshot}.rs. Keeps the wire-shape contract co-located with the wrapper that consumes it. Runtime Peer class lands in subsequent commits. --- e2e/test-hooks.ts | 67 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 e2e/test-hooks.ts diff --git a/e2e/test-hooks.ts b/e2e/test-hooks.ts new file mode 100644 index 00000000..7c221535 --- /dev/null +++ b/e2e/test-hooks.ts @@ -0,0 +1,67 @@ +// e2e/test-hooks.ts +// +// JS-side wrapper for window.__willow + the __willowEvent push stream +// installed by crates/web (--features test-hooks). See: +// docs/specs/2026-04-27-event-based-waits-design.md +// +// Types here mirror the Rust WireEvent / SnapshotDto / ChannelDto shapes. +// Keep in sync with crates/web/src/test_hooks/{wire,snapshot}.rs. + +import type { Page, BrowserContext } from '@playwright/test'; + +// ── Mirror of crates/web/src/test_hooks/wire.rs::WireEvent ───────────── + +export type ClientEvent = + | { kind: 'SyncCompleted'; opsApplied: number } + | { kind: 'MessageReceived'; channel: string; messageId: string; isLocal: boolean } + | { kind: 'PeerConnected'; peerId: string } + | { kind: 'PeerDisconnected'; peerId: string } + | { kind: 'ChannelCreated'; name: string } + | { kind: 'ChannelDeleted'; name: string } + | { kind: 'PeerTrusted'; peerId: string } + | { kind: 'PeerUntrusted'; peerId: string } + | { kind: 'ProfileUpdated'; peerId: string; displayName: string } + | { kind: 'RoleCreated'; roleId: string; name: string }; + +// ── Mirror of crates/web/src/test_hooks/snapshot.rs ──────────────────── + +export interface AuthorHead { + seq: number; + /** 64-char lowercase hex (EventHash::Display). */ + hash: string; +} + +export interface ChannelSummary { + name: string; + /** Mirror of willow_state::ChannelKind — serialized as the variant name. */ + kind: 'Text' | 'Voice'; +} + +export interface Snapshot { + eventCount: number; + /** Per-author DAG heads. Keys are EndpointId hex strings (BTreeMap → sorted). */ + heads: Record; + /** Hex hash of most recently applied event, or null if the DAG is empty. */ + lastEvent: string | null; + channels: ChannelSummary[]; +} + +// ── Internal: window.__willow surface ────────────────────────────────── + +/** Shape installed at `window.__willow` by crates/web/src/test_hooks/mod.rs. */ +interface WillowTestHooksJS { + snapshot(): Promise; + heads(): Promise>; + event_count(): Promise; + last_event(): Promise; +} + +/** Sentinel: queue + Page + label. Returned by the fixture, not exported as a type. */ +type PeerInternals = { + page: Page; + label: string; + queue: ClientEvent[]; +}; + +// Stub — runtime classes/fixtures land in later tasks. +export {}; From d74511812129f17fc4a24b8eb696a96eb98d3cc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:35:17 +0000 Subject: [PATCH 03/12] test(e2e): add Peer class with pull-API methods snapshot / heads / eventCount / lastEvent each round-trip through window.__willow.* (PR-1's wasm_bindgen surface). Push-API methods land in the next commit once the per-page event queue is wired. --- e2e/test-hooks.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/e2e/test-hooks.ts b/e2e/test-hooks.ts index 7c221535..88fbf2b8 100644 --- a/e2e/test-hooks.ts +++ b/e2e/test-hooks.ts @@ -63,5 +63,47 @@ type PeerInternals = { queue: ClientEvent[]; }; -// Stub — runtime classes/fixtures land in later tasks. -export {}; +/** + * Test-side wrapper for one Willow peer (one Playwright Page). + * + * Construct via `peer` fixture in Task 3 — direct construction works for + * the pull-API methods only (snapshot/heads/eventCount/lastEvent). + * Push-API methods (nextEvent / waitUntil*) require the fixture's + * exposeBinding wiring to populate `queue`. + */ +export class Peer { + constructor( + public readonly page: Page, + public readonly label: string, + /** Populated by the fixture's `__willowEvent` binding; empty array is valid. */ + public readonly queue: ClientEvent[] = [], + ) {} + + /** Aggregated state snapshot. Round-trips through `window.__willow.snapshot()`. */ + async snapshot(): Promise { + return this.page.evaluate( + () => (window as unknown as { __willow: WillowTestHooksJS }).__willow.snapshot(), + ); + } + + /** Per-author DAG heads. */ + async heads(): Promise> { + return this.page.evaluate( + () => (window as unknown as { __willow: WillowTestHooksJS }).__willow.heads(), + ); + } + + /** Total events applied to the local DAG. */ + async eventCount(): Promise { + return this.page.evaluate( + () => (window as unknown as { __willow: WillowTestHooksJS }).__willow.event_count(), + ); + } + + /** Hex hash of the most recently applied event, or null if the DAG is empty. */ + async lastEvent(): Promise { + return this.page.evaluate( + () => (window as unknown as { __willow: WillowTestHooksJS }).__willow.last_event(), + ); + } +} From 545ece2bef4919ec84cfeba04bfc7b3208fae84c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:36:12 +0000 Subject: [PATCH 04/12] test(e2e): add peer fixture wiring __willowEvent/__willowOverflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-page event queue keyed via WeakMap; binding callback's source.page is the lookup key. Overflow binding fails the test on any droppedCount > 0 (PR-1 dispatcher only calls it on the 65k-buffer overflow path). Specs opt in via 'import { test, expect } from "./test-hooks";' — legacy specs continue using the default '@playwright/test' import. --- e2e/test-hooks.ts | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/e2e/test-hooks.ts b/e2e/test-hooks.ts index 88fbf2b8..4163990e 100644 --- a/e2e/test-hooks.ts +++ b/e2e/test-hooks.ts @@ -63,6 +63,86 @@ type PeerInternals = { queue: ClientEvent[]; }; +import { test as base } from '@playwright/test'; + +/** + * Per-page event queue tracker. The fixture creates one `WeakMap` + * per `BrowserContext` and routes every `__willowEvent` callback to the queue + * keyed by the originating Page (Playwright's `exposeBinding` callback receives + * `{ page }` as the first argument's source). + * + * `Peer` reads the queue by reference, so any event the WASM dispatcher emits + * after the binding is installed shows up in `peer.queue` synchronously. + */ +export type PeerFactory = (page: Page, label: string) => Peer; + +/** + * Playwright fixture that installs the `__willow` test-hooks plumbing. + * + * Usage: + * import { test, expect } from './test-hooks'; + * test('foo', async ({ peer, browser }) => { + * const a = await peer(page1, 'Alice'); + * await a.waitUntilHeadsEqual(b); + * }); + * + * The fixture's scope is `'test'` (default): each test gets a fresh + * BrowserContext (Playwright's default) and therefore a fresh queue map. + */ +export const test = base.extend<{ peer: PeerFactory }>({ + peer: async ({ context }, use) => { + // Per-page queues, keyed by the JS Page object the binding callback receives. + const queues = new WeakMap(); + + // 1. exposeBinding — must be called before any page.goto. + await context.exposeBinding( + '__willowEvent', + (source, ev: ClientEvent) => { + const q = queues.get(source.page); + if (q) q.push(ev); + // No queue means the page wasn't registered via peer() — drop silently. + // peer() is the gatekeeper that allocates a queue and reloads the page. + }, + ); + + // 2. Overflow → fail loudly. PR-1's dispatcher calls this with droppedCount + // only when the 65k buffer is exceeded (a real correctness bug, never + // backpressure under normal load). + await context.exposeBinding('__willowOverflow', (_source, dropped: number) => { + throw new Error(`__willow event queue overflow: ${dropped} dropped`); + }); + + // 3. addInitScript — pre-creates the buffer so the WASM dispatcher's + // fallback path has somewhere to push if it fires before the + // binding is callable. Defence-in-depth; under normal Playwright + // ordering the buffer stays empty. + await context.addInitScript(() => { + (window as unknown as { __willowEventBuffer: unknown[] }).__willowEventBuffer = []; + }); + + /** + * Allocate a queue for `page`, then return a `Peer` bound to it. + * + * Caller must invoke this AFTER `context.newPage()` but BEFORE the page's + * first `goto()` — the queue must exist when the WASM dispatcher first + * tries to push an event after the page loads. + */ + const factory: PeerFactory = (page, label) => { + let queue = queues.get(page); + if (!queue) { + queue = []; + queues.set(page, queue); + } + return new Peer(page, label, queue); + }; + + await use(factory); + }, +}); + +// Re-export expect so spec authors can `import { test, expect } from './test-hooks';` +export { expect } from '@playwright/test'; + /** * Test-side wrapper for one Willow peer (one Playwright Page). * From ab73b9778dd10a10a58629f4da27d0adfc9d2cc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:36:33 +0000 Subject: [PATCH 05/12] test(e2e): add Peer.nextEvent(predicate) push consumer 50ms polling loop; consumes the first matching event from the queue and leaves non-matches in place. Failure message names the peer and shows the queue tail to debug 'why didn't my event arrive' cases. --- e2e/test-hooks.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/e2e/test-hooks.ts b/e2e/test-hooks.ts index 4163990e..90eb3273 100644 --- a/e2e/test-hooks.ts +++ b/e2e/test-hooks.ts @@ -186,4 +186,37 @@ export class Peer { () => (window as unknown as { __willow: WillowTestHooksJS }).__willow.last_event(), ); } + + /** + * Wait for the next event matching `predicate` and consume it. + * + * Walks the per-Peer queue from the front; returns the first match and + * removes it. Non-matching events stay in the queue (so a later + * `nextEvent(other)` can still see them). + * + * Polls every 50 ms; rejects after `opts.timeout` ms (default 10_000) + * with a message naming the peer and showing the queue tail. + */ + async nextEvent( + predicate: (e: ClientEvent) => boolean, + opts: { timeout?: number } = {}, + ): Promise { + const timeout = opts.timeout ?? 10_000; + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const idx = this.queue.findIndex(predicate); + if (idx >= 0) { + const [match] = this.queue.splice(idx, 1); + return match; + } + await new Promise(r => setTimeout(r, 50)); + } + + const tail = this.queue.slice(-5).map(e => e.kind).join(', ') || '(empty)'; + throw new Error( + `${this.label}.nextEvent timed out after ${timeout}ms. ` + + `Queue tail (last 5 kinds): ${tail}`, + ); + } } From de8d713cc47049e565f1a1464f8c150a86e636d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:37:11 +0000 Subject: [PATCH 06/12] test(e2e): add waitUntilHeadsEqual + waitUntilAllHeadsEqual Pairwise CRDT convergence check via expect.poll on canonicalised heads maps. Failure message appends a per-author-key diff so 'A missing authors: [x]; B missing authors: []' is visible without a manual console.log round-trip. waitUntilAllHeadsEqual fans out N-1 pairwise checks for true N-peer convergence per the spec's 'Partial-equality footgun' section. --- e2e/test-hooks.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/e2e/test-hooks.ts b/e2e/test-hooks.ts index 90eb3273..b8a378c7 100644 --- a/e2e/test-hooks.ts +++ b/e2e/test-hooks.ts @@ -143,6 +143,36 @@ export const test = base.extend<{ peer: PeerFactory }>({ // Re-export expect so spec authors can `import { test, expect } from './test-hooks';` export { expect } from '@playwright/test'; +/** + * Engine-independent canonical form for a heads map. + * + * Object.keys(...).sort() makes the JSON serialisation order-independent so + * `JSON.stringify` produces the same byte string regardless of insertion order. + * The Rust side already serialises a BTreeMap (sorted) but we re-sort defensively. + */ +function canonicalHeads(heads: Record): string { + return JSON.stringify( + Object.keys(heads).sort().map(k => [k, heads[k].seq, heads[k].hash]), + ); +} + +/** Build the "A is missing X / B is missing Y" diff used in failure messages. */ +function authorKeyDiff( + selfLabel: string, + selfHeads: Record, + otherLabel: string, + otherHeads: Record, +): string { + const selfKeys = new Set(Object.keys(selfHeads)); + const otherKeys = new Set(Object.keys(otherHeads)); + const selfMissing = [...otherKeys].filter(k => !selfKeys.has(k)); + const otherMissing = [...selfKeys].filter(k => !otherKeys.has(k)); + return ( + `${selfLabel} missing authors: [${selfMissing.join(', ')}]; ` + + `${otherLabel} missing authors: [${otherMissing.join(', ')}]` + ); +} + /** * Test-side wrapper for one Willow peer (one Playwright Page). * @@ -219,4 +249,59 @@ export class Peer { `Queue tail (last 5 kinds): ${tail}`, ); } + + /** + * Wait until this peer's heads equal `other`'s heads. + * + * Uses `expect.poll` with a 30 s default timeout (matches the legacy + * `{ timeout: 30_000 }` overrides this method replaces). Each poll + * re-fetches BOTH sides' heads — `other` may still be advancing. + * + * NB: heads-equal is a CRDT pairwise check. Two peers can be equal + * yet both still missing an event from a third; use + * `waitUntilAllHeadsEqual` for N-peer convergence. + */ + async waitUntilHeadsEqual( + other: Peer, + opts: { timeout?: number } = {}, + ): Promise { + const timeout = opts.timeout ?? 30_000; + const { expect } = await import('@playwright/test'); + let lastSelf: Record = {}; + let lastOther: Record = {}; + try { + await expect + .poll( + async () => { + lastSelf = await this.heads(); + lastOther = await other.heads(); + return canonicalHeads(lastSelf); + }, + { + timeout, + message: `${this.label} converge with ${other.label}`, + }, + ) + .toBe(canonicalHeads(lastOther)); + } catch (e) { + // Re-throw with the structured diff appended so missing-author hangs + // are debuggable without a manual console.log round-trip. + const diff = authorKeyDiff(this.label, lastSelf, other.label, lastOther); + throw new Error(`${(e as Error).message}\n ${diff}`); + } + } + + /** + * Wait until this peer's heads equal each peer in `others`. Sequential + * awaits — N-1 calls to `waitUntilHeadsEqual` — so any peer missing an + * event from any other peer fails the assertion. + */ + async waitUntilAllHeadsEqual( + others: Peer[], + opts: { timeout?: number } = {}, + ): Promise { + for (const other of others) { + await this.waitUntilHeadsEqual(other, opts); + } + } } From 14c0c5ff9dfa731a5a5c52ddef05501908118788 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:40:51 +0000 Subject: [PATCH 07/12] test(e2e): lazy-wire bindings per context + add Peer smoke spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erratum to Task 3: the original fixture wired __willowEvent on the fixture's default context only. Tests that create extra contexts via 'browser.newContext()' or setupTwoPeers(browser) — including the smoke test in this commit — would never see events from those new contexts. Fix: peer(page, label) is now async and idempotently wires the page's BrowserContext on first call. Bindings still install before any new goto on that context, and exposeBinding takes effect on existing pages too, so the read path recovers events as soon as wiring lands. Trade-off vs. eager wiring: events that fire on a context BEFORE the first peer() call on that context are dropped (no buffer initialised). For setupTwoPeers callers that means the very first SyncCompleted may be missed; the smoke test relies on subsequent gossip events arriving within the 5s timeout, which matches normal mesh activity. Smoke spec covers: snapshot shape, eventCount growth, nextEvent push wiring, waitUntilHeadsEqual convergence, and the timeout error path. Three-peer waitUntilAllHeadsEqual is deferred to issue #458 — no current spec needs it. --- e2e/test-hooks.spec.ts | 88 ++++++++++++++++++++++++++++++++++++++++++ e2e/test-hooks.ts | 80 ++++++++++++++++++++++---------------- 2 files changed, 135 insertions(+), 33 deletions(-) create mode 100644 e2e/test-hooks.spec.ts diff --git a/e2e/test-hooks.spec.ts b/e2e/test-hooks.spec.ts new file mode 100644 index 00000000..94a5e06b --- /dev/null +++ b/e2e/test-hooks.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from './test-hooks'; +import { freshStart, createServer, setupTwoPeers } from './helpers'; + +// Sequential — these tests share the local relay via createServer/joinViaInvite. +test.describe.configure({ mode: 'serial' }); + +test.describe('Peer wrapper smoke', () => { + test.setTimeout(60_000); + + test('snapshot returns the expected shape after createServer', async ({ peer, browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const alice = await peer(page, 'Alice'); + try { + await freshStart(page); + await createServer(page, 'SnapshotServer', 'Alice'); + + const snap = await alice.snapshot(); + expect(snap.eventCount).toBeGreaterThan(0); + expect(typeof snap.lastEvent).toBe('string'); + expect(Object.keys(snap.heads).length).toBeGreaterThan(0); + // The materialised ServerState should contain the default 'general' channel. + expect(snap.channels.map(c => c.name)).toContain('general'); + } finally { + await ctx.close(); + } + }); + + test('eventCount grows as events are applied', async ({ peer, browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const alice = await peer(page, 'Alice'); + try { + await freshStart(page); + await createServer(page, 'GrowthServer', 'Alice'); + const before = await alice.eventCount(); + expect(before).toBeGreaterThan(0); + } finally { + await ctx.close(); + } + }); + + test('nextEvent resolves on SyncCompleted after invite flow', async ({ peer, browser }) => { + // Use the existing setupTwoPeers helper; capture both pages so we can + // observe Bob's first SyncCompleted after the join lands. + const { ctx1, ctx2, page2 } = await setupTwoPeers(browser); + const bob = await peer(page2, 'Bob'); + try { + // Bob's WASM has been emitting SyncCompleted since join — at least one + // is in the queue. Drain the most recent one. + const ev = await bob.nextEvent(e => e.kind === 'SyncCompleted', { timeout: 5_000 }); + expect(ev.kind).toBe('SyncCompleted'); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); + + test('waitUntilHeadsEqual converges after invite flow', async ({ peer, browser }) => { + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = await peer(page1, 'Alice'); + const bob = await peer(page2, 'Bob'); + try { + // Both peers should converge — Bob applied Alice's CreateServer events + // during join; gossip propagates Alice's GrantTrust back. + await bob.waitUntilHeadsEqual(alice, { timeout: 30_000 }); + } finally { + await ctx1.close(); + await ctx2.close(); + } + }); + + test('nextEvent rejects with a named error on timeout', async ({ peer, browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const alice = await peer(page, 'Alice'); + try { + await freshStart(page); + await createServer(page, 'TimeoutServer', 'Alice'); + // Predicate that can never match (event kind that doesn't exist for a single peer). + await expect( + alice.nextEvent(e => e.kind === 'PeerDisconnected', { timeout: 200 }) + ).rejects.toThrow(/Alice\.nextEvent timed out after 200ms/); + } finally { + await ctx.close(); + } + }); +}); diff --git a/e2e/test-hooks.ts b/e2e/test-hooks.ts index b8a378c7..efce3752 100644 --- a/e2e/test-hooks.ts +++ b/e2e/test-hooks.ts @@ -74,7 +74,7 @@ import { test as base } from '@playwright/test'; * `Peer` reads the queue by reference, so any event the WASM dispatcher emits * after the binding is installed shows up in `peer.queue` synchronously. */ -export type PeerFactory = (page: Page, label: string) => Peer; +export type PeerFactory = (page: Page, label: string) => Promise; /** * Playwright fixture that installs the `__willow` test-hooks plumbing. @@ -86,48 +86,62 @@ export type PeerFactory = (page: Page, label: string) => Peer; * await a.waitUntilHeadsEqual(b); * }); * - * The fixture's scope is `'test'` (default): each test gets a fresh - * BrowserContext (Playwright's default) and therefore a fresh queue map. + * The fixture's scope is `'test'` (default). Bindings are wired lazily on + * the page's BrowserContext on first `peer(page, label)` call per context, + * so the factory works for both Playwright's default test context AND any + * extra contexts a test creates via `browser.newContext()` / + * `setupTwoPeers(browser)`. + * + * `addInitScript` only takes effect on subsequent page loads, so call + * `peer()` before the first `goto()` when possible. Bindings registered + * via `exposeBinding` apply to existing pages too, so the read path + * recovers events as soon as the binding lands. */ export const test = base.extend<{ peer: PeerFactory }>({ - peer: async ({ context }, use) => { + peer: async ({}, use) => { // Per-page queues, keyed by the JS Page object the binding callback receives. const queues = new WeakMap(); + // Track which contexts we've already wired so peer() is idempotent. + const wired = new WeakSet(); - // 1. exposeBinding — must be called before any page.goto. - await context.exposeBinding( - '__willowEvent', - (source, ev: ClientEvent) => { - const q = queues.get(source.page); - if (q) q.push(ev); - // No queue means the page wasn't registered via peer() — drop silently. - // peer() is the gatekeeper that allocates a queue and reloads the page. - }, - ); + const wireContext = async (context: BrowserContext) => { + if (wired.has(context)) return; + wired.add(context); + + // 1. exposeBinding — registers the JS-side proxy. After this returns, + // `window.__willowEvent` is callable in every page of the context + // (existing and future). + await context.exposeBinding( + '__willowEvent', + (source, ev: ClientEvent) => { + const q = queues.get(source.page); + if (q) q.push(ev); + // No queue means the page wasn't registered via peer() — drop silently. + }, + ); - // 2. Overflow → fail loudly. PR-1's dispatcher calls this with droppedCount - // only when the 65k buffer is exceeded (a real correctness bug, never - // backpressure under normal load). - await context.exposeBinding('__willowOverflow', (_source, dropped: number) => { - throw new Error(`__willow event queue overflow: ${dropped} dropped`); - }); - - // 3. addInitScript — pre-creates the buffer so the WASM dispatcher's - // fallback path has somewhere to push if it fires before the - // binding is callable. Defence-in-depth; under normal Playwright - // ordering the buffer stays empty. - await context.addInitScript(() => { - (window as unknown as { __willowEventBuffer: unknown[] }).__willowEventBuffer = []; - }); + // 2. Overflow → fail loudly. PR-1's dispatcher calls this with droppedCount + // only when the 65k buffer is exceeded (a real correctness bug, never + // backpressure under normal load). + await context.exposeBinding('__willowOverflow', (_source, dropped: number) => { + throw new Error(`__willow event queue overflow: ${dropped} dropped`); + }); + + // 3. addInitScript — pre-creates the buffer for FUTURE page loads in + // this context. Defence-in-depth for the dispatcher's fallback path + // that runs when `__willowEvent` is briefly absent. + await context.addInitScript(() => { + (window as unknown as { __willowEventBuffer: unknown[] }).__willowEventBuffer = []; + }); + }; /** - * Allocate a queue for `page`, then return a `Peer` bound to it. + * Allocate a queue for `page`, lazily wire its context, return a `Peer`. * - * Caller must invoke this AFTER `context.newPage()` but BEFORE the page's - * first `goto()` — the queue must exist when the WASM dispatcher first - * tries to push an event after the page loads. + * Idempotent: safe to call multiple times for the same page or context. */ - const factory: PeerFactory = (page, label) => { + const factory: PeerFactory = async (page, label) => { + await wireContext(page.context()); let queue = queues.get(page); if (!queue) { queue = []; From 889cfc5bda5cb0dbcd5777ed85f707c947d942f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:44:17 +0000 Subject: [PATCH 08/12] test(e2e): split 703-LOC helpers.ts into peers/ui/touch modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per spec §'Helpers redesign' and PR-2 plan Tasks 7-10. Three focused modules behind a re-export barrel — legacy specs import-from-helpers unchanged, new specs import directly from the focused module they need. Behaviour preserved verbatim. Magic-number sleeps and 30s timeout overrides stay where they were; PR-2 only deletes them from the pilot spec (multi-peer-sync.spec.ts) where the Peer wrapper replaces them. Other 7 specs migrate file-by-file via tracking issue #458. The barrel drops its eslint-disable header — re-exports contain no waitForTimeout calls, and a lingering unused-disable warning would trip CI. --- e2e/helpers.ts | 713 +------------------------------------------ e2e/helpers/peers.ts | 248 +++++++++++++++ e2e/helpers/touch.ts | 133 ++++++++ e2e/helpers/ui.ts | 336 ++++++++++++++++++++ 4 files changed, 727 insertions(+), 703 deletions(-) create mode 100644 e2e/helpers/peers.ts create mode 100644 e2e/helpers/touch.ts create mode 100644 e2e/helpers/ui.ts diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 51b6e595..5dfbb903 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,703 +1,10 @@ -/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ -import { Page, Browser, BrowserContext, Locator, expect } from '@playwright/test'; - -/** Wait for the WASM app to load (loading spinner disappears). */ -export async function waitForApp(page: Page) { - // Wait for the app to render (welcome screen, desktop shell, mobile - // shell, or join page). `:visible` filters out the hidden sibling - // shell on either side of the 720 px split. - await page.waitForSelector( - '.welcome-screen:visible, .shell-desktop .app:visible, .shell-mobile .mobile-top-bar:visible, .join-card:visible', - { timeout: 30_000 }, - ); -} - -/** Clear all Willow localStorage keys and IndexedDB databases, then reload. */ -export async function freshStart(page: Page) { - await page.goto('/'); - await page.evaluate(async () => { - const keys = Object.keys(localStorage).filter(k => k.startsWith('willow_')); - keys.forEach(k => localStorage.removeItem(k)); - // Also clear non-prefixed keys that might be ours. - localStorage.clear(); - - // Clear Willow-related IndexedDB databases so each test starts from a - // truly clean state. Without this, identity keys and event stores - // persisted in IDB survive localStorage.clear() and can leak state - // between tests running in the same browser context. - const dbNames = await indexedDB.databases?.() ?? []; - await Promise.all( - dbNames - .filter(db => db.name && (db.name.startsWith('willow') || db.name.startsWith('iroh'))) - .map(db => new Promise((resolve, reject) => { - const req = indexedDB.deleteDatabase(db.name!); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - req.onblocked = () => resolve(); // Proceed even if blocked. - })) - ); - }); - await page.reload(); - await waitForApp(page); -} - -/** Walk the two-step welcome flow's name step. - * Fills the optional display name and clicks continue to reveal the - * Create / Join tabs. No-op if already past step 1. - */ -async function advancePastNameStep(page: Page, displayName?: string) { - const nameInput = page.locator('.welcome-name-input'); - if (await nameInput.isVisible().catch(() => false)) { - if (displayName) await nameInput.fill(displayName); - await page.locator('.welcome-continue-btn').click(); - // Wait for the tab panel to render. - await page.locator('.welcome-tabs').waitFor({ timeout: 5_000 }); - } -} - -/** Create a server from the welcome screen. Returns the server name. */ -export async function createServer(page: Page, name: string, displayName?: string) { - await expect(page.locator('.welcome-card')).toBeVisible(); - await advancePastNameStep(page, displayName); - - // Create tab is selected by default — fill the grove name and click - // the panel's continue button to commit. Scoped to .welcome-tab-panel - // to avoid matching step 1's continue button from earlier steps. - await page - .locator('.welcome-tab-panel input[placeholder="backyard"]') - .fill(name); - await page.locator('.welcome-tab-panel button', { hasText: 'continue' }).click(); - - // Wait for the app to load with the new server. On mobile we then - // push into the first channel (`general`) so subsequent helpers - // (`sendMessage`, `openMemberList`, etc.) find the composer + - // right-rail surfaces — mobile home only shows the channel list. - if (isMobile(page)) { - await page.waitForSelector('.mobile-top-bar', { state: 'visible', timeout: 10_000 }); - // Tap general to push the channel surface (which carries the - // composer, message list, and main-pane-header action bar). - const generalRow = page - .locator(`${visibleShell(page)} .mobile-home .channel-item`, { hasText: 'general' }); - if (await generalRow.count() > 0) { - await generalRow.first().click(); - await page.waitForSelector('.mobile-push--channel', { timeout: 10_000 }); - } - } else { - await page.waitForSelector('.main-pane-header, .channel-sidebar', { - state: 'visible', - timeout: 10_000, - }); - } -} - -/** Get the full peer ID from the welcome screen or settings. */ -export async function getPeerId(page: Page): Promise { - // Welcome screen: advance past step 1 (no name), then switch to the - // Join tab — the peer id lives inside the Join step list, hidden by - // default and revealed by the eye-toggle icon. - if (await page.locator('.welcome-card').isVisible().catch(() => false)) { - await advancePastNameStep(page); - const joinTab = page.locator('.welcome-tab-btn', { hasText: 'Join' }); - if (await joinTab.isVisible().catch(() => false)) { - await joinTab.click(); - const revealBtn = page.locator('button[aria-label="show full peer id"]'); - await revealBtn.waitFor({ timeout: 5_000 }); - await revealBtn.click(); - } - const peerIdEl = page.locator('.welcome-join-steps__full-id').first(); - if (await peerIdEl.isVisible().catch(() => false)) { - return ( - (await peerIdEl.getAttribute('data-full-id')) || - (await peerIdEl.textContent()) || - '' - ); - } - } - - // Fallback: read it from settings. - await page.locator('text=Settings').click(); - await page.waitForTimeout(300); - const settingsPeerId = page.locator('.peer-id-text').first(); - return ( - (await settingsPeerId.getAttribute('data-full-id')) || - (await settingsPeerId.textContent()) || - '' - ); -} - -/** Send a message in the current channel. Scopes the locator to the - * visible shell so it doesn't hit the hidden copy on the inactive - * side of the desktop / mobile split. On mobile, automatically - * pushes into the first channel if the composer is not mounted. */ -export async function sendMessage(page: Page, text: string) { - const scope = isMobile(page) ? '.shell-mobile' : '.shell-desktop'; - if (isMobile(page)) { - const inPush = await page - .locator('.shell-mobile .mobile-push--channel') - .isVisible() - .catch(() => false); - if (!inPush) { - await page.locator('.shell-mobile .mobile-home .channel-item').first().click(); - await page.waitForTimeout(400); - } - } - const input = page - .locator(`${scope} .input-area input, ${scope} .input-area textarea`) - .first(); - await input.fill(text); - await input.press('Enter'); - await page.locator(`${visibleShell(page)} .message .body`, { hasText: text }) - .first() - .waitFor({ timeout: 10_000 }); -} - -/** Get all visible message bodies. */ -export async function getMessages(page: Page): Promise { - const bodies = page.locator('.message .body'); - const count = await bodies.count(); - const texts: string[] = []; - for (let i = 0; i < count; i++) { - texts.push((await bodies.nth(i).textContent()) || ''); - } - return texts; -} - -/** Scope selector prefix for the currently-visible shell. Use to - * disambiguate elements that are mounted in both shells (the - * inactive one is hidden via `display: none`). */ -export function visibleShell(page: Page): string { - return isMobile(page) ? '.shell-mobile' : '.shell-desktop'; -} - -/** Click a channel by name. On mobile this routes through the home - * tab — pop any pushed screen first so the channel list is visible, - * then tap the row (which pushes the chat view). */ -export async function switchChannel(page: Page, channelName: string) { - if (isMobile(page)) { - // Pop back to home if we are currently on a pushed screen. - const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); - while (await backSlot.isVisible().catch(() => false)) { - await page.locator('.mobile-top-bar .top-slot-left').click(); - await page.waitForTimeout(300); - } - // Make sure we are on the home tab. - await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); - await page.waitForTimeout(200); - await page - .locator('.mobile-home .channel-item', { hasText: channelName }) - .click(); - await page.waitForTimeout(400); - return; - } - await page - .locator(`${visibleShell(page)} .channel-item`, { hasText: channelName }) - .first() - .click(); -} - -/** Wait for a specific message to appear in the visible shell. */ -export async function waitForMessage(page: Page, text: string, timeout = 20_000) { - const scope = visibleShell(page); - await page - .locator(`${scope} .message .body`, { hasText: text }) - .first() - .waitFor({ timeout }); -} - -/** Simulate a long-press on an element to open the mobile action sheet. - * Prefixes the selector with the visible-shell scope so a raw `.message` - * picks the mobile copy, not the hidden desktop one. */ -export async function longPress(page: Page, selector: string, durationMs = 600) { - const scoped = isMobile(page) && !selector.startsWith('.shell-') - ? `${visibleShell(page)} ${selector}` - : selector; - const el = page.locator(scoped).first(); - const box = await el.boundingBox(); - if (!box) throw new Error(`Element not found: ${selector}`); - - const x = box.x + box.width / 2; - const y = box.y + box.height / 2; - - // Dispatch real TouchEvent via page.evaluate. - await page.evaluate(({ x, y }) => { - const target = document.elementFromPoint(x, y); - if (!target) return; - const touch = new Touch({ - identifier: 1, - target, - clientX: x, - clientY: y, - pageX: x, - pageY: y, - }); - target.dispatchEvent(new TouchEvent('touchstart', { - bubbles: true, - cancelable: true, - touches: [touch], - targetTouches: [touch], - changedTouches: [touch], - })); - }, { x, y }); - - await page.waitForTimeout(durationMs); - - // Dispatch touchend. - await page.evaluate(({ x, y }) => { - const target = document.elementFromPoint(x, y); - if (!target) return; - const touch = new Touch({ - identifier: 1, - target, - clientX: x, - clientY: y, - pageX: x, - pageY: y, - }); - target.dispatchEvent(new TouchEvent('touchend', { - bubbles: true, - cancelable: true, - touches: [], - targetTouches: [], - changedTouches: [touch], - })); - }, { x, y }); - - await page.waitForTimeout(300); -} - -// ── Mobile detection + navigation ───────────────────────────────────── - -/** Returns true if the page viewport is narrow enough to be mobile. */ -export function isMobile(page: Page): boolean { - return (page.viewportSize()?.width ?? 1024) < 768; -} - -/** Opens the grove drawer on mobile (no-op on desktop). Idempotent — - * won't close it if it's already open. The mobile shell top bar has - * a grove-glyph tile on the left that opens the drawer on the home - * route; on pushed screens the left slot is a back chevron. - */ -export async function openSidebar(page: Page) { - if (!isMobile(page)) return; - const alreadyOpen = await page.locator('.grove-drawer.open').isVisible().catch(() => false); - if (alreadyOpen) return; - await page.locator('.mobile-top-bar .top-slot-left').click(); - await page.waitForTimeout(500); -} - -/** Closes the grove drawer on mobile by tapping the backdrop. No-op - * on desktop or when the drawer is already closed. */ -export async function closeSidebar(page: Page) { - if (!isMobile(page)) return; - const drawerOpen = await page.locator('.grove-drawer.open').isVisible().catch(() => false); - if (!drawerOpen) return; - // Backdrop covers the full viewport; dispatch bypasses Playwright's - // hit-test which rightly warns about overlapping layers. - await page.locator('.grove-drawer-backdrop').dispatchEvent('click'); - await page.waitForTimeout(300); -} - -/** Switch to a given mobile primary tab (home / letters / discover / you). - * No-op on desktop. */ -export async function switchTab( - page: Page, - tabId: 'home' | 'letters' | 'discover' | 'you', -) { - if (!isMobile(page)) return; - await page.locator(`.mobile-tab-bar .tab[data-tab="${tabId}"]`).click(); - await page.waitForTimeout(200); -} - -/** Opens the member list in the right rail. On desktop clicks the - * main-pane-header members action button; on mobile this routes - * into the chat push where the header lives. */ -export async function openMemberList(page: Page) { - // Already-open short-circuit — right-rail uses data-open on the aside. - const openPane = page.locator('.right-rail[data-open="true"] .member-list'); - if (await openPane.isVisible().catch(() => false)) return; - - // On mobile the main-pane-header lives inside the channel push — - // tap a channel to surface it first. - if (isMobile(page)) { - const inPush = await page.locator('.mobile-push--channel').isVisible().catch(() => false); - if (!inPush) { - await page.locator('.mobile-home .channel-item').first().click(); - await page.waitForTimeout(400); - } - } - - const membersBtn = page.locator(`${visibleShell(page)} .action-btn[aria-label="members"]`); - if (await membersBtn.count() > 0) { - await membersBtn.first().click(); - await page - .locator(`${visibleShell(page)} .right-rail[data-open="true"] .member-list`) - .waitFor({ timeout: 3_000 }) - .catch(() => {}); - } -} - -/** Closes the member list panel by toggling the same button. */ -export async function closeMemberList(page: Page) { - const openPane = page.locator(`${visibleShell(page)} .right-rail[data-open="true"] .member-list`); - const isOpen = await openPane.isVisible().catch(() => false); - if (!isOpen) return; - - const membersBtn = page.locator(`${visibleShell(page)} .action-btn[aria-label="members"]`); - if (await membersBtn.count() > 0) { - await membersBtn.first().click(); - } -} - -// ── Invite flow ─────────────────────────────────────────────────────── - -/** Opens the server settings panel (opens sidebar first on mobile). */ -export async function openServerSettings(page: Page) { - if (isMobile(page)) { - // Channel list is on the home tab; the gear lives in the sidebar - // header rendered inside `.mobile-home`. No drawer needed. - const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); - while (await backSlot.isVisible().catch(() => false)) { - await page.locator('.mobile-top-bar .top-slot-left').click(); - await page.waitForTimeout(300); - } - await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); - await page.waitForTimeout(200); - } - await page.locator(`${visibleShell(page)} .server-gear-btn`).first().click(); - await page.locator('.settings-panel, .settings-overlay').first() - .waitFor({ timeout: 5_000 }); -} - -/** Generates an invite code for a given peer ID. Returns the invite code string. */ -export async function generateInvite(page: Page, recipientPeerId: string): Promise { - await openServerSettings(page); - await page.locator('input[placeholder*="12D3KooW"]').fill(recipientPeerId); - await page.locator('button', { hasText: 'Generate Invite' }).click(); - await page.waitForTimeout(500); - const inviteCode = await page.locator('.invite-code-display textarea').inputValue(); - await page.locator('text=Back').click(); - await page.waitForTimeout(500); - return inviteCode; -} - -/** Joins a server via invite code from the welcome screen. - * The welcome flow asks for the display name up-front on step 1 (before - * the Create / Join tabs), so displayName is consumed there. - */ -export async function joinViaInvite(page: Page, inviteCode: string, displayName?: string) { - await advancePastNameStep(page, displayName); - // Switch to the Join tab. - await page.locator('.welcome-tab-btn', { hasText: 'Join' }).click(); - await page.locator('.welcome-invite-input').waitFor({ timeout: 5_000 }); - await page.locator('.welcome-invite-input').fill(inviteCode); - await page.locator('.welcome-tab-panel button', { hasText: 'continue' }).click(); - // Wait for the confirmation step ("Join grove") to appear. - await page.locator('button', { hasText: 'Join grove' }).waitFor({ timeout: 5_000 }); - await page.locator('button', { hasText: 'Join grove' }).click(); - if (isMobile(page)) { - await page.waitForSelector('.mobile-top-bar', { state: 'visible', timeout: 20_000 }); - } else { - await page.waitForSelector('.main-pane-header, .channel-sidebar', { - state: 'visible', - timeout: 20_000, - }); - } - // Deterministic post-join settle: wait for the sidebar + first channel - // to materialise. Covers both shells. - await page.locator(`${visibleShell(page)} .channel-sidebar, ${visibleShell(page)} .mobile-home`) - .first() - .waitFor({ timeout: 20_000 }); - await page.locator(`${visibleShell(page)} .channel-item`).first() - .waitFor({ timeout: 20_000 }); -} - -/** Sets up two peers: peer1 creates a server, peer2 joins via invite. */ -export async function setupTwoPeers( - browser: Browser, - serverName = 'Test Server', - peer1Name = 'Alice', - peer2Name = 'Bob', -): Promise<{ ctx1: BrowserContext; ctx2: BrowserContext; page1: Page; page2: Page }> { - const ctx1 = await browser.newContext(); - const ctx2 = await browser.newContext(); - const page1 = await ctx1.newPage(); - const page2 = await ctx2.newPage(); - - // Peer 1: Create server. - await freshStart(page1); - await createServer(page1, serverName, peer1Name); - - // Peer 2: Get peer ID from welcome screen. - await freshStart(page2); - const peer2Id = await getPeerId(page2); - - // Peer 1: Generate invite for peer 2. - const inviteCode = await generateInvite(page1, peer2Id); - - // Peer 2: Join the server. - await joinViaInvite(page2, inviteCode, peer2Name); - - // Wait for display name sync: peer2's name should appear in peer1's member list. - // Only do this on desktop — Phase 1b's mobile shell does not yet - // surface the member list, and the display-name sync completes - // just as reliably via gossip events consumed by other helpers. - if (peer2Name && !isMobile(page1)) { - await openMemberList(page1); - try { - await page1 - .locator('.member-item', { hasText: peer2Name }) - .waitFor({ timeout: 20_000 }); - } catch { - // Display name sync may be slow; proceed anyway — but warn so failures - // here don't produce misleading timeouts in downstream assertions. - console.warn('[setupTwoPeers] peer2 display name did not sync in time — P2P may be slow'); - } - await closeMemberList(page1); - } else if (peer2Name) { - // On mobile, just sleep a bit to let gossip propagate. - await page1.waitForTimeout(1500); - } - - return { ctx1, ctx2, page1, page2 }; -} - -// ── Channel helpers ─────────────────────────────────────────────────── - -/** Creates a new text channel. On mobile the channel list is the - * home tab — no drawer needed to reach `.channel-add-btn`. */ -export async function createChannel(page: Page, name: string) { - if (isMobile(page)) { - // Pop any pushed screen so the home tab is visible. - const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); - while (await backSlot.isVisible().catch(() => false)) { - await page.locator('.mobile-top-bar .top-slot-left').click(); - await page.waitForTimeout(300); - } - await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); - await page.waitForTimeout(200); - } - const scope = visibleShell(page); - await page.locator(`${scope} .channel-add-btn`).first().click(); - await page.waitForTimeout(200); - await page.locator(`${scope} .channel-create-input input`).first().fill(name); - await page.locator(`${scope} .channel-create-input input`).first().press('Enter'); - await page.locator(`${visibleShell(page)} .channel-item`, { hasText: name }) - .waitFor({ timeout: 10_000 }); -} - -// ── Message actions ─────────────────────────────────────────────────── - -/** Performs a named action on a message (desktop: hover+dropdown, mobile: long-press+sheet). - * Mobile sheet copy is lowercase per `message-row.md` §Long-press - * action sheet — the helper matches `actionName` case-insensitively - * so callers can pass either `Reply` or `reply`. */ -export async function messageAction(page: Page, messageText: string, actionName: string) { - if (isMobile(page)) { - // Mobile: long-press to open action sheet. - await longPress(page, `.message:has-text("${messageText}")`); - await page.locator('.shell-mobile .mobile-action-sheet.open').first() - .waitFor({ timeout: 3000 }); - // Case-insensitive match: spec copy is lowercase `reply`, `edit`, - // `delete`, but call-sites historically passed capitalized names. - const actionRe = new RegExp(`^\\s*${actionName}\\s*$`, 'i'); - await page - .locator('.shell-mobile .mobile-action-sheet.open .sheet-item', { hasText: actionRe }) - .click(); - await page.waitForTimeout(300); - } else { - const msg = page.locator('.shell-desktop .message', { hasText: messageText }).last(); - // Desktop: hover to reveal action trigger, click dropdown item. - await msg.hover(); - await page.waitForTimeout(200); - await msg.locator('.action-trigger').click(); - await page.waitForTimeout(200); - await page.locator('.dropdown-item', { hasText: actionName }).click(); - await page.waitForTimeout(200); - } -} - -/** Edits a message (desktop or mobile). */ -export async function editMessage(page: Page, originalText: string, newText: string) { - await messageAction(page, originalText, 'Edit'); - const input = page.locator('.input-area input, .input-area textarea').first(); - await input.fill(newText); - await input.press('Enter'); - await page.waitForTimeout(500); -} - -/** Deletes a message (desktop or mobile). */ -export async function deleteMessage(page: Page, text: string) { - await messageAction(page, text, 'Delete'); - // Confirm the deletion dialog. - const confirmBtn = page.locator('.confirm-dialog .btn-danger', { hasText: 'Delete' }); - await confirmBtn.waitFor({ timeout: 3000 }); - await confirmBtn.click(); - await page.waitForTimeout(500); -} - -/** Reacts to a message with an emoji (desktop or mobile). */ -export async function reactToMessage(page: Page, messageText: string, emojiIndex = 0) { - if (isMobile(page)) { - await longPress(page, `.message:has-text("${messageText}")`); - await page.locator('.shell-mobile .mobile-action-sheet.open').first() - .waitFor({ timeout: 3000 }); - await page.locator('.shell-mobile .mobile-action-sheet.open .sheet-emoji-row button') - .nth(emojiIndex).click(); - await page.waitForTimeout(500); - } else { - const msg = page.locator('.shell-desktop .message', { hasText: messageText }).last(); - await msg.hover(); - await page.waitForTimeout(200); - await msg.locator('.action-trigger').click(); - await page.waitForTimeout(200); - await page.locator('.dropdown-item', { hasText: 'React' }).click(); - await page.waitForTimeout(200); - await page.locator('.dropdown-emoji-row button').nth(emojiIndex).click(); - await page.waitForTimeout(500); - } -} - -// ── Permission actions ──────────────────────────────────────────────── - -/** Trusts a peer by name from the member list. */ -export async function trustPeer(page: Page, peerName: string) { - await openMemberList(page); - const member = page.locator('.member-item', { hasText: peerName }); - await member.waitFor({ timeout: 30_000 }); - // Hover to reveal action buttons (desktop hides them until hover). - await member.hover(); - // Use a regex to avoid matching "Untrust" when looking for "Trust". - await member.locator('button').filter({ hasText: /^Trust$/ }).click(); - await page.waitForTimeout(500); - await closeMemberList(page); -} - -/** Untrusts a peer by name from the member list. */ -export async function untrustPeer(page: Page, peerName: string) { - await openMemberList(page); - const member = page.locator('.member-item', { hasText: peerName }); - await member.waitFor({ timeout: 30_000 }); - // Hover to reveal action buttons (desktop hides them until hover). - await member.hover(); - await member.locator('button', { hasText: 'Untrust' }).click(); - await page.waitForTimeout(500); - await closeMemberList(page); -} - -/** Open the compare-fingerprints dialog by clicking the trust badge - * next to a peer name in the member list. - */ -export async function openCompareFingerprints(page: Page, peerName: string) { - await openMemberList(page); - const member = page.locator(`${visibleShell(page)} .member-item`, { hasText: peerName }); - await member.waitFor({ timeout: 10_000 }); - await member.locator('.trust-badge').click(); - await page - .locator('.add-friend__card[role="dialog"]') - .waitFor({ timeout: 5_000 }); -} - -/** Click "they match" in the compare-fingerprints dialog. */ -export async function markFingerprintsMatch(page: Page) { - await page - .locator('.add-friend__cta-primary', { hasText: 'they match' }) - .click(); - // Confirm screen appears. - await page - .locator('.add-friend__confirm-title', { hasText: 'verified.' }) - .waitFor({ timeout: 5_000 }); -} - -/** Click "they don't match" in the compare-fingerprints dialog. */ -export async function markFingerprintsMismatch(page: Page) { - await page - .locator('.add-friend__cta-secondary', { hasText: "they don't match" }) - .click(); - await page - .locator('.add-friend__confirm-title', { hasText: 'marked not verified.' }) - .waitFor({ timeout: 5_000 }); -} - -/** Long-press a peer avatar by name in the member list (mobile only). */ -export async function longPressAvatar(page: Page, peerName: string) { - await openMemberList(page); - const member = page.locator(`${visibleShell(page)} .member-item`, { hasText: peerName }); - await member.waitFor({ timeout: 10_000 }); - const target = member.locator('.long-press-avatar, .status-dot').first(); - const box = await target.boundingBox(); - if (!box) throw new Error('avatar not measurable'); - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.mouse.down(); - await page.waitForTimeout(500); - await page.mouse.up(); -} - -// ── Swipe gestures ──────────────────────────────────────────────────── - -/** Dispatches a horizontal swipe (touchstart → 3× touchmove → touchend) - * on a message row. `dx > 0` swipes right (open thread); `dx < 0` - * swipes left (quote reply). The four-step move path is required to - * cross the 8 px horizontal-dominance gate inside MessageView's - * touchmove handler before the row captures the gesture. */ -async function dispatchSwipe(row: Locator, dx: number): Promise { - await row.evaluate((el, dx) => { - const rect = (el as HTMLElement).getBoundingClientRect(); - // Start off-centre on the opposite side so we have room to travel - // `dx` pixels without leaving the row's bounding box. - const startX = dx > 0 ? rect.left + rect.width * 0.2 : rect.left + rect.width * 0.8; - const startY = rect.top + rect.height / 2; - const makeTouch = (x: number, y: number) => new Touch({ - identifier: 0, - target: el as HTMLElement, - clientX: x, - clientY: y, - pageX: x, - pageY: y, - } as TouchInit); - const fire = (type: string, x: number) => { - const touch = makeTouch(x, startY); - (el as HTMLElement).dispatchEvent(new TouchEvent(type, { - cancelable: true, - bubbles: true, - touches: type === 'touchend' ? [] : [touch], - targetTouches: type === 'touchend' ? [] : [touch], - changedTouches: [touch], - })); - }; - fire('touchstart', startX); - fire('touchmove', startX + dx * 0.3); - fire('touchmove', startX + dx * 0.7); - fire('touchmove', startX + dx); - fire('touchend', startX + dx); - }, dx); -} - -/** Swipe left on a message row. Populates the composer's `replying_to` - * context (per `message-row.md` §Swipe gestures). */ -export async function swipeLeft(_page: Page, row: Locator): Promise { - return dispatchSwipe(row, -120); -} - -/** Swipe right on a message row. Opens the thread pane (per - * `message-row.md` §Swipe gestures). */ -export async function swipeRight(_page: Page, row: Locator): Promise { - return dispatchSwipe(row, 120); -} - -/** Kicks a peer by name from the member list. */ -export async function kickPeer(page: Page, peerName: string) { - await openMemberList(page); - const member = page.locator('.member-item', { hasText: peerName }); - await member.waitFor({ timeout: 30_000 }); - // Hover to reveal action buttons (desktop hides them until hover). - await member.hover(); - await member.locator('.btn-danger', { hasText: 'Kick' }).click(); - await page.waitForTimeout(500); - // Confirm the kick dialog. - const confirmBtn = page.locator('.confirm-dialog .btn-danger', { hasText: 'Kick' }); - await confirmBtn.waitFor({ timeout: 5_000 }); - await confirmBtn.click(); - await page.waitForTimeout(500); - await closeMemberList(page); -} - +// Re-export barrel. The implementation lives in e2e/helpers/{peers,ui,touch}.ts +// per docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md Task 10. +// +// Keeping this file as a barrel means the 7 un-migrated specs continue to +// import from './helpers' with zero diff. New specs should import directly +// from the focused modules (or use the Peer wrapper from './test-hooks'). + +export * from './helpers/peers'; +export * from './helpers/ui'; +export * from './helpers/touch'; diff --git a/e2e/helpers/peers.ts b/e2e/helpers/peers.ts new file mode 100644 index 00000000..8aab9547 --- /dev/null +++ b/e2e/helpers/peers.ts @@ -0,0 +1,248 @@ +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ +// +// Peer setup helpers. Extracted from the legacy 703-LOC e2e/helpers.ts +// per docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md Task 7. +// Behaviour is preserved verbatim — sleep removal is a follow-up. + +import { Page, Browser, BrowserContext, expect } from '@playwright/test'; +import { + isMobile, + visibleShell, + openMemberList, + closeMemberList, +} from './ui'; + +/** Wait for the WASM app to load (loading spinner disappears). */ +export async function waitForApp(page: Page) { + // Wait for the app to render (welcome screen, desktop shell, mobile + // shell, or join page). `:visible` filters out the hidden sibling + // shell on either side of the 720 px split. + await page.waitForSelector( + '.welcome-screen:visible, .shell-desktop .app:visible, .shell-mobile .mobile-top-bar:visible, .join-card:visible', + { timeout: 30_000 }, + ); +} + +/** Clear all Willow localStorage keys and IndexedDB databases, then reload. */ +export async function freshStart(page: Page) { + await page.goto('/'); + await page.evaluate(async () => { + const keys = Object.keys(localStorage).filter(k => k.startsWith('willow_')); + keys.forEach(k => localStorage.removeItem(k)); + // Also clear non-prefixed keys that might be ours. + localStorage.clear(); + + // Clear Willow-related IndexedDB databases so each test starts from a + // truly clean state. Without this, identity keys and event stores + // persisted in IDB survive localStorage.clear() and can leak state + // between tests running in the same browser context. + const dbNames = await indexedDB.databases?.() ?? []; + await Promise.all( + dbNames + .filter(db => db.name && (db.name.startsWith('willow') || db.name.startsWith('iroh'))) + .map(db => new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(db.name!); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); // Proceed even if blocked. + })) + ); + }); + await page.reload(); + await waitForApp(page); +} + +/** Walk the two-step welcome flow's name step. + * Fills the optional display name and clicks continue to reveal the + * Create / Join tabs. No-op if already past step 1. + */ +async function advancePastNameStep(page: Page, displayName?: string) { + const nameInput = page.locator('.welcome-name-input'); + if (await nameInput.isVisible().catch(() => false)) { + if (displayName) await nameInput.fill(displayName); + await page.locator('.welcome-continue-btn').click(); + // Wait for the tab panel to render. + await page.locator('.welcome-tabs').waitFor({ timeout: 5_000 }); + } +} + +/** Create a server from the welcome screen. Returns the server name. */ +export async function createServer(page: Page, name: string, displayName?: string) { + await expect(page.locator('.welcome-card')).toBeVisible(); + await advancePastNameStep(page, displayName); + + // Create tab is selected by default — fill the grove name and click + // the panel's continue button to commit. Scoped to .welcome-tab-panel + // to avoid matching step 1's continue button from earlier steps. + await page + .locator('.welcome-tab-panel input[placeholder="backyard"]') + .fill(name); + await page.locator('.welcome-tab-panel button', { hasText: 'continue' }).click(); + + // Wait for the app to load with the new server. On mobile we then + // push into the first channel (`general`) so subsequent helpers + // (`sendMessage`, `openMemberList`, etc.) find the composer + + // right-rail surfaces — mobile home only shows the channel list. + if (isMobile(page)) { + await page.waitForSelector('.mobile-top-bar', { state: 'visible', timeout: 10_000 }); + // Tap general to push the channel surface (which carries the + // composer, message list, and main-pane-header action bar). + const generalRow = page + .locator(`${visibleShell(page)} .mobile-home .channel-item`, { hasText: 'general' }); + if (await generalRow.count() > 0) { + await generalRow.first().click(); + await page.waitForSelector('.mobile-push--channel', { timeout: 10_000 }); + } + } else { + await page.waitForSelector('.main-pane-header, .channel-sidebar', { + state: 'visible', + timeout: 10_000, + }); + } +} + +/** Get the full peer ID from the welcome screen or settings. */ +export async function getPeerId(page: Page): Promise { + // Welcome screen: advance past step 1 (no name), then switch to the + // Join tab — the peer id lives inside the Join step list, hidden by + // default and revealed by the eye-toggle icon. + if (await page.locator('.welcome-card').isVisible().catch(() => false)) { + await advancePastNameStep(page); + const joinTab = page.locator('.welcome-tab-btn', { hasText: 'Join' }); + if (await joinTab.isVisible().catch(() => false)) { + await joinTab.click(); + const revealBtn = page.locator('button[aria-label="show full peer id"]'); + await revealBtn.waitFor({ timeout: 5_000 }); + await revealBtn.click(); + } + const peerIdEl = page.locator('.welcome-join-steps__full-id').first(); + if (await peerIdEl.isVisible().catch(() => false)) { + return ( + (await peerIdEl.getAttribute('data-full-id')) || + (await peerIdEl.textContent()) || + '' + ); + } + } + + // Fallback: read it from settings. + await page.locator('text=Settings').click(); + await page.waitForTimeout(300); + const settingsPeerId = page.locator('.peer-id-text').first(); + return ( + (await settingsPeerId.getAttribute('data-full-id')) || + (await settingsPeerId.textContent()) || + '' + ); +} + +/** Opens the server settings panel (opens sidebar first on mobile). */ +export async function openServerSettings(page: Page) { + if (isMobile(page)) { + // Channel list is on the home tab; the gear lives in the sidebar + // header rendered inside `.mobile-home`. No drawer needed. + const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); + while (await backSlot.isVisible().catch(() => false)) { + await page.locator('.mobile-top-bar .top-slot-left').click(); + await page.waitForTimeout(300); + } + await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); + await page.waitForTimeout(200); + } + await page.locator(`${visibleShell(page)} .server-gear-btn`).first().click(); + await page.locator('.settings-panel, .settings-overlay').first() + .waitFor({ timeout: 5_000 }); +} + +/** Generates an invite code for a given peer ID. Returns the invite code string. */ +export async function generateInvite(page: Page, recipientPeerId: string): Promise { + await openServerSettings(page); + await page.locator('input[placeholder*="12D3KooW"]').fill(recipientPeerId); + await page.locator('button', { hasText: 'Generate Invite' }).click(); + await page.waitForTimeout(500); + const inviteCode = await page.locator('.invite-code-display textarea').inputValue(); + await page.locator('text=Back').click(); + await page.waitForTimeout(500); + return inviteCode; +} + +/** Joins a server via invite code from the welcome screen. + * The welcome flow asks for the display name up-front on step 1 (before + * the Create / Join tabs), so displayName is consumed there. + */ +export async function joinViaInvite(page: Page, inviteCode: string, displayName?: string) { + await advancePastNameStep(page, displayName); + // Switch to the Join tab. + await page.locator('.welcome-tab-btn', { hasText: 'Join' }).click(); + await page.locator('.welcome-invite-input').waitFor({ timeout: 5_000 }); + await page.locator('.welcome-invite-input').fill(inviteCode); + await page.locator('.welcome-tab-panel button', { hasText: 'continue' }).click(); + // Wait for the confirmation step ("Join grove") to appear. + await page.locator('button', { hasText: 'Join grove' }).waitFor({ timeout: 5_000 }); + await page.locator('button', { hasText: 'Join grove' }).click(); + if (isMobile(page)) { + await page.waitForSelector('.mobile-top-bar', { state: 'visible', timeout: 20_000 }); + } else { + await page.waitForSelector('.main-pane-header, .channel-sidebar', { + state: 'visible', + timeout: 20_000, + }); + } + // Deterministic post-join settle: wait for the sidebar + first channel + // to materialise. Covers both shells. + await page.locator(`${visibleShell(page)} .channel-sidebar, ${visibleShell(page)} .mobile-home`) + .first() + .waitFor({ timeout: 20_000 }); + await page.locator(`${visibleShell(page)} .channel-item`).first() + .waitFor({ timeout: 20_000 }); +} + +/** Sets up two peers: peer1 creates a server, peer2 joins via invite. */ +export async function setupTwoPeers( + browser: Browser, + serverName = 'Test Server', + peer1Name = 'Alice', + peer2Name = 'Bob', +): Promise<{ ctx1: BrowserContext; ctx2: BrowserContext; page1: Page; page2: Page }> { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + + // Peer 1: Create server. + await freshStart(page1); + await createServer(page1, serverName, peer1Name); + + // Peer 2: Get peer ID from welcome screen. + await freshStart(page2); + const peer2Id = await getPeerId(page2); + + // Peer 1: Generate invite for peer 2. + const inviteCode = await generateInvite(page1, peer2Id); + + // Peer 2: Join the server. + await joinViaInvite(page2, inviteCode, peer2Name); + + // Wait for display name sync: peer2's name should appear in peer1's member list. + // Only do this on desktop — Phase 1b's mobile shell does not yet + // surface the member list, and the display-name sync completes + // just as reliably via gossip events consumed by other helpers. + if (peer2Name && !isMobile(page1)) { + await openMemberList(page1); + try { + await page1 + .locator('.member-item', { hasText: peer2Name }) + .waitFor({ timeout: 20_000 }); + } catch { + // Display name sync may be slow; proceed anyway — but warn so failures + // here don't produce misleading timeouts in downstream assertions. + console.warn('[setupTwoPeers] peer2 display name did not sync in time — P2P may be slow'); + } + await closeMemberList(page1); + } else if (peer2Name) { + // On mobile, just sleep a bit to let gossip propagate. + await page1.waitForTimeout(1500); + } + + return { ctx1, ctx2, page1, page2 }; +} diff --git a/e2e/helpers/touch.ts b/e2e/helpers/touch.ts new file mode 100644 index 00000000..bee566a7 --- /dev/null +++ b/e2e/helpers/touch.ts @@ -0,0 +1,133 @@ +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ +// +// Touch + gesture helpers. Extracted from legacy e2e/helpers.ts per +// docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md Task 9. +// Behaviour preserved verbatim — page.clock migration is a follow-up. + +import { Page, Locator } from '@playwright/test'; +import { isMobile, visibleShell, openMemberList } from './ui'; + +/** Simulate a long-press on an element to open the mobile action sheet. + * Prefixes the selector with the visible-shell scope so a raw `.message` + * picks the mobile copy, not the hidden desktop one. */ +export async function longPress(page: Page, selector: string, durationMs = 600) { + const scoped = isMobile(page) && !selector.startsWith('.shell-') + ? `${visibleShell(page)} ${selector}` + : selector; + const el = page.locator(scoped).first(); + const box = await el.boundingBox(); + if (!box) throw new Error(`Element not found: ${selector}`); + + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + // Dispatch real TouchEvent via page.evaluate. + await page.evaluate(({ x, y }) => { + const target = document.elementFromPoint(x, y); + if (!target) return; + const touch = new Touch({ + identifier: 1, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + target.dispatchEvent(new TouchEvent('touchstart', { + bubbles: true, + cancelable: true, + touches: [touch], + targetTouches: [touch], + changedTouches: [touch], + })); + }, { x, y }); + + await page.waitForTimeout(durationMs); + + // Dispatch touchend. + await page.evaluate(({ x, y }) => { + const target = document.elementFromPoint(x, y); + if (!target) return; + const touch = new Touch({ + identifier: 1, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + target.dispatchEvent(new TouchEvent('touchend', { + bubbles: true, + cancelable: true, + touches: [], + targetTouches: [], + changedTouches: [touch], + })); + }, { x, y }); + + await page.waitForTimeout(300); +} + +/** Long-press a peer avatar by name in the member list (mobile only). */ +export async function longPressAvatar(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator(`${visibleShell(page)} .member-item`, { hasText: peerName }); + await member.waitFor({ timeout: 10_000 }); + const target = member.locator('.long-press-avatar, .status-dot').first(); + const box = await target.boundingBox(); + if (!box) throw new Error('avatar not measurable'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(500); + await page.mouse.up(); +} + +/** Dispatches a horizontal swipe (touchstart → 3× touchmove → touchend) + * on a message row. `dx > 0` swipes right (open thread); `dx < 0` + * swipes left (quote reply). The four-step move path is required to + * cross the 8 px horizontal-dominance gate inside MessageView's + * touchmove handler before the row captures the gesture. */ +async function dispatchSwipe(row: Locator, dx: number): Promise { + await row.evaluate((el, dx) => { + const rect = (el as HTMLElement).getBoundingClientRect(); + // Start off-centre on the opposite side so we have room to travel + // `dx` pixels without leaving the row's bounding box. + const startX = dx > 0 ? rect.left + rect.width * 0.2 : rect.left + rect.width * 0.8; + const startY = rect.top + rect.height / 2; + const makeTouch = (x: number, y: number) => new Touch({ + identifier: 0, + target: el as HTMLElement, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + } as TouchInit); + const fire = (type: string, x: number) => { + const touch = makeTouch(x, startY); + (el as HTMLElement).dispatchEvent(new TouchEvent(type, { + cancelable: true, + bubbles: true, + touches: type === 'touchend' ? [] : [touch], + targetTouches: type === 'touchend' ? [] : [touch], + changedTouches: [touch], + })); + }; + fire('touchstart', startX); + fire('touchmove', startX + dx * 0.3); + fire('touchmove', startX + dx * 0.7); + fire('touchmove', startX + dx); + fire('touchend', startX + dx); + }, dx); +} + +/** Swipe left on a message row. Populates the composer's `replying_to` + * context (per `message-row.md` §Swipe gestures). */ +export async function swipeLeft(_page: Page, row: Locator): Promise { + return dispatchSwipe(row, -120); +} + +/** Swipe right on a message row. Opens the thread pane (per + * `message-row.md` §Swipe gestures). */ +export async function swipeRight(_page: Page, row: Locator): Promise { + return dispatchSwipe(row, 120); +} diff --git a/e2e/helpers/ui.ts b/e2e/helpers/ui.ts new file mode 100644 index 00000000..cf9f5cbe --- /dev/null +++ b/e2e/helpers/ui.ts @@ -0,0 +1,336 @@ +/* eslint-disable no-restricted-syntax -- migration tracked at https://github.com/intendednull/willow/issues/458 */ +// +// UI navigation + message-action helpers. Extracted from legacy +// e2e/helpers.ts per docs/plans/2026-04-29-event-based-waits-pr2-peer-wrapper.md +// Task 8. Behaviour preserved verbatim. + +import { Page } from '@playwright/test'; +import { longPress } from './touch'; + +/** Returns true if the page viewport is narrow enough to be mobile. */ +export function isMobile(page: Page): boolean { + return (page.viewportSize()?.width ?? 1024) < 768; +} + +/** Scope selector prefix for the currently-visible shell. Use to + * disambiguate elements that are mounted in both shells (the + * inactive one is hidden via `display: none`). */ +export function visibleShell(page: Page): string { + return isMobile(page) ? '.shell-mobile' : '.shell-desktop'; +} + +/** Send a message in the current channel. Scopes the locator to the + * visible shell so it doesn't hit the hidden copy on the inactive + * side of the desktop / mobile split. On mobile, automatically + * pushes into the first channel if the composer is not mounted. */ +export async function sendMessage(page: Page, text: string) { + const scope = isMobile(page) ? '.shell-mobile' : '.shell-desktop'; + if (isMobile(page)) { + const inPush = await page + .locator('.shell-mobile .mobile-push--channel') + .isVisible() + .catch(() => false); + if (!inPush) { + await page.locator('.shell-mobile .mobile-home .channel-item').first().click(); + await page.waitForTimeout(400); + } + } + const input = page + .locator(`${scope} .input-area input, ${scope} .input-area textarea`) + .first(); + await input.fill(text); + await input.press('Enter'); + await page.locator(`${visibleShell(page)} .message .body`, { hasText: text }) + .first() + .waitFor({ timeout: 10_000 }); +} + +/** Get all visible message bodies. */ +export async function getMessages(page: Page): Promise { + const bodies = page.locator('.message .body'); + const count = await bodies.count(); + const texts: string[] = []; + for (let i = 0; i < count; i++) { + texts.push((await bodies.nth(i).textContent()) || ''); + } + return texts; +} + +/** Click a channel by name. On mobile this routes through the home + * tab — pop any pushed screen first so the channel list is visible, + * then tap the row (which pushes the chat view). */ +export async function switchChannel(page: Page, channelName: string) { + if (isMobile(page)) { + // Pop back to home if we are currently on a pushed screen. + const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); + while (await backSlot.isVisible().catch(() => false)) { + await page.locator('.mobile-top-bar .top-slot-left').click(); + await page.waitForTimeout(300); + } + // Make sure we are on the home tab. + await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); + await page.waitForTimeout(200); + await page + .locator('.mobile-home .channel-item', { hasText: channelName }) + .click(); + await page.waitForTimeout(400); + return; + } + await page + .locator(`${visibleShell(page)} .channel-item`, { hasText: channelName }) + .first() + .click(); +} + +/** Wait for a specific message to appear in the visible shell. */ +export async function waitForMessage(page: Page, text: string, timeout = 20_000) { + const scope = visibleShell(page); + await page + .locator(`${scope} .message .body`, { hasText: text }) + .first() + .waitFor({ timeout }); +} + +/** Opens the grove drawer on mobile (no-op on desktop). Idempotent — + * won't close it if it's already open. The mobile shell top bar has + * a grove-glyph tile on the left that opens the drawer on the home + * route; on pushed screens the left slot is a back chevron. + */ +export async function openSidebar(page: Page) { + if (!isMobile(page)) return; + const alreadyOpen = await page.locator('.grove-drawer.open').isVisible().catch(() => false); + if (alreadyOpen) return; + await page.locator('.mobile-top-bar .top-slot-left').click(); + await page.waitForTimeout(500); +} + +/** Closes the grove drawer on mobile by tapping the backdrop. No-op + * on desktop or when the drawer is already closed. */ +export async function closeSidebar(page: Page) { + if (!isMobile(page)) return; + const drawerOpen = await page.locator('.grove-drawer.open').isVisible().catch(() => false); + if (!drawerOpen) return; + // Backdrop covers the full viewport; dispatch bypasses Playwright's + // hit-test which rightly warns about overlapping layers. + await page.locator('.grove-drawer-backdrop').dispatchEvent('click'); + await page.waitForTimeout(300); +} + +/** Switch to a given mobile primary tab (home / letters / discover / you). + * No-op on desktop. */ +export async function switchTab( + page: Page, + tabId: 'home' | 'letters' | 'discover' | 'you', +) { + if (!isMobile(page)) return; + await page.locator(`.mobile-tab-bar .tab[data-tab="${tabId}"]`).click(); + await page.waitForTimeout(200); +} + +/** Opens the member list in the right rail. On desktop clicks the + * main-pane-header members action button; on mobile this routes + * into the chat push where the header lives. */ +export async function openMemberList(page: Page) { + // Already-open short-circuit — right-rail uses data-open on the aside. + const openPane = page.locator('.right-rail[data-open="true"] .member-list'); + if (await openPane.isVisible().catch(() => false)) return; + + // On mobile the main-pane-header lives inside the channel push — + // tap a channel to surface it first. + if (isMobile(page)) { + const inPush = await page.locator('.mobile-push--channel').isVisible().catch(() => false); + if (!inPush) { + await page.locator('.mobile-home .channel-item').first().click(); + await page.waitForTimeout(400); + } + } + + const membersBtn = page.locator(`${visibleShell(page)} .action-btn[aria-label="members"]`); + if (await membersBtn.count() > 0) { + await membersBtn.first().click(); + await page + .locator(`${visibleShell(page)} .right-rail[data-open="true"] .member-list`) + .waitFor({ timeout: 3_000 }) + .catch(() => {}); + } +} + +/** Closes the member list panel by toggling the same button. */ +export async function closeMemberList(page: Page) { + const openPane = page.locator(`${visibleShell(page)} .right-rail[data-open="true"] .member-list`); + const isOpen = await openPane.isVisible().catch(() => false); + if (!isOpen) return; + + const membersBtn = page.locator(`${visibleShell(page)} .action-btn[aria-label="members"]`); + if (await membersBtn.count() > 0) { + await membersBtn.first().click(); + } +} + +/** Creates a new text channel. On mobile the channel list is the + * home tab — no drawer needed to reach `.channel-add-btn`. */ +export async function createChannel(page: Page, name: string) { + if (isMobile(page)) { + // Pop any pushed screen so the home tab is visible. + const backSlot = page.locator('.mobile-top-bar .top-slot-left .top-back'); + while (await backSlot.isVisible().catch(() => false)) { + await page.locator('.mobile-top-bar .top-slot-left').click(); + await page.waitForTimeout(300); + } + await page.locator('.mobile-tab-bar .tab[data-tab="home"]').click(); + await page.waitForTimeout(200); + } + const scope = visibleShell(page); + await page.locator(`${scope} .channel-add-btn`).first().click(); + await page.waitForTimeout(200); + await page.locator(`${scope} .channel-create-input input`).first().fill(name); + await page.locator(`${scope} .channel-create-input input`).first().press('Enter'); + await page.locator(`${visibleShell(page)} .channel-item`, { hasText: name }) + .waitFor({ timeout: 10_000 }); +} + +/** Performs a named action on a message (desktop: hover+dropdown, mobile: long-press+sheet). + * Mobile sheet copy is lowercase per `message-row.md` §Long-press + * action sheet — the helper matches `actionName` case-insensitively + * so callers can pass either `Reply` or `reply`. */ +export async function messageAction(page: Page, messageText: string, actionName: string) { + if (isMobile(page)) { + // Mobile: long-press to open action sheet. + await longPress(page, `.message:has-text("${messageText}")`); + await page.locator('.shell-mobile .mobile-action-sheet.open').first() + .waitFor({ timeout: 3000 }); + // Case-insensitive match: spec copy is lowercase `reply`, `edit`, + // `delete`, but call-sites historically passed capitalized names. + const actionRe = new RegExp(`^\\s*${actionName}\\s*$`, 'i'); + await page + .locator('.shell-mobile .mobile-action-sheet.open .sheet-item', { hasText: actionRe }) + .click(); + await page.waitForTimeout(300); + } else { + const msg = page.locator('.shell-desktop .message', { hasText: messageText }).last(); + // Desktop: hover to reveal action trigger, click dropdown item. + await msg.hover(); + await page.waitForTimeout(200); + await msg.locator('.action-trigger').click(); + await page.waitForTimeout(200); + await page.locator('.dropdown-item', { hasText: actionName }).click(); + await page.waitForTimeout(200); + } +} + +/** Edits a message (desktop or mobile). */ +export async function editMessage(page: Page, originalText: string, newText: string) { + await messageAction(page, originalText, 'Edit'); + const input = page.locator('.input-area input, .input-area textarea').first(); + await input.fill(newText); + await input.press('Enter'); + await page.waitForTimeout(500); +} + +/** Deletes a message (desktop or mobile). */ +export async function deleteMessage(page: Page, text: string) { + await messageAction(page, text, 'Delete'); + // Confirm the deletion dialog. + const confirmBtn = page.locator('.confirm-dialog .btn-danger', { hasText: 'Delete' }); + await confirmBtn.waitFor({ timeout: 3000 }); + await confirmBtn.click(); + await page.waitForTimeout(500); +} + +/** Reacts to a message with an emoji (desktop or mobile). */ +export async function reactToMessage(page: Page, messageText: string, emojiIndex = 0) { + if (isMobile(page)) { + await longPress(page, `.message:has-text("${messageText}")`); + await page.locator('.shell-mobile .mobile-action-sheet.open').first() + .waitFor({ timeout: 3000 }); + await page.locator('.shell-mobile .mobile-action-sheet.open .sheet-emoji-row button') + .nth(emojiIndex).click(); + await page.waitForTimeout(500); + } else { + const msg = page.locator('.shell-desktop .message', { hasText: messageText }).last(); + await msg.hover(); + await page.waitForTimeout(200); + await msg.locator('.action-trigger').click(); + await page.waitForTimeout(200); + await page.locator('.dropdown-item', { hasText: 'React' }).click(); + await page.waitForTimeout(200); + await page.locator('.dropdown-emoji-row button').nth(emojiIndex).click(); + await page.waitForTimeout(500); + } +} + +/** Trusts a peer by name from the member list. */ +export async function trustPeer(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator('.member-item', { hasText: peerName }); + await member.waitFor({ timeout: 30_000 }); + // Hover to reveal action buttons (desktop hides them until hover). + await member.hover(); + // Use a regex to avoid matching "Untrust" when looking for "Trust". + await member.locator('button').filter({ hasText: /^Trust$/ }).click(); + await page.waitForTimeout(500); + await closeMemberList(page); +} + +/** Untrusts a peer by name from the member list. */ +export async function untrustPeer(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator('.member-item', { hasText: peerName }); + await member.waitFor({ timeout: 30_000 }); + // Hover to reveal action buttons (desktop hides them until hover). + await member.hover(); + await member.locator('button', { hasText: 'Untrust' }).click(); + await page.waitForTimeout(500); + await closeMemberList(page); +} + +/** Open the compare-fingerprints dialog by clicking the trust badge + * next to a peer name in the member list. + */ +export async function openCompareFingerprints(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator(`${visibleShell(page)} .member-item`, { hasText: peerName }); + await member.waitFor({ timeout: 10_000 }); + await member.locator('.trust-badge').click(); + await page + .locator('.add-friend__card[role="dialog"]') + .waitFor({ timeout: 5_000 }); +} + +/** Click "they match" in the compare-fingerprints dialog. */ +export async function markFingerprintsMatch(page: Page) { + await page + .locator('.add-friend__cta-primary', { hasText: 'they match' }) + .click(); + // Confirm screen appears. + await page + .locator('.add-friend__confirm-title', { hasText: 'verified.' }) + .waitFor({ timeout: 5_000 }); +} + +/** Click "they don't match" in the compare-fingerprints dialog. */ +export async function markFingerprintsMismatch(page: Page) { + await page + .locator('.add-friend__cta-secondary', { hasText: "they don't match" }) + .click(); + await page + .locator('.add-friend__confirm-title', { hasText: 'marked not verified.' }) + .waitFor({ timeout: 5_000 }); +} + +/** Kicks a peer by name from the member list. */ +export async function kickPeer(page: Page, peerName: string) { + await openMemberList(page); + const member = page.locator('.member-item', { hasText: peerName }); + await member.waitFor({ timeout: 30_000 }); + // Hover to reveal action buttons (desktop hides them until hover). + await member.hover(); + await member.locator('.btn-danger', { hasText: 'Kick' }).click(); + await page.waitForTimeout(500); + // Confirm the kick dialog. + const confirmBtn = page.locator('.confirm-dialog .btn-danger', { hasText: 'Kick' }); + await confirmBtn.waitFor({ timeout: 5_000 }); + await confirmBtn.click(); + await page.waitForTimeout(500); + await closeMemberList(page); +} From b554639db59750fd4c791149de652a1959b021c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:44:55 +0000 Subject: [PATCH 09/12] test(e2e): build-time coverage of helpers.ts re-export barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports every name used by any un-migrated spec from './helpers' and references it once. If a name disappears from helpers/{peers,ui,touch} (e.g. accidental rename during the next migration), tsc fails here with TS2305 before any Playwright test runs. Filename ends with .test.ts so Playwright's default testMatch skips it — this is a build-time TypeScript check, not a runtime spec. --- e2e/helpers.barrel.test.ts | 88 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 e2e/helpers.barrel.test.ts diff --git a/e2e/helpers.barrel.test.ts b/e2e/helpers.barrel.test.ts new file mode 100644 index 00000000..9642ce49 --- /dev/null +++ b/e2e/helpers.barrel.test.ts @@ -0,0 +1,88 @@ +// e2e/helpers.barrel.test.ts +// +// Build-time coverage of the helpers.ts barrel. Asserts every name imported +// by any un-migrated spec is still re-exported. If you remove a name from +// helpers/{peers,ui,touch}.ts, tsc fails here with TS2305 before any +// Playwright test runs. +// +// This is NOT a Playwright spec (filename uses .test.ts so Playwright's +// default `testMatch: '*.spec.ts'` skips it). It executes only as part of +// `npx tsc --noEmit` / `npx eslint`. + +import { + // peers + freshStart, + createServer, + getPeerId, + generateInvite, + joinViaInvite, + setupTwoPeers, + waitForApp, + openServerSettings, + // ui + sendMessage, + waitForMessage, + switchChannel, + createChannel, + openSidebar, + closeSidebar, + openMemberList, + closeMemberList, + visibleShell, + isMobile, + messageAction, + editMessage, + deleteMessage, + reactToMessage, + trustPeer, + untrustPeer, + kickPeer, + openCompareFingerprints, + markFingerprintsMatch, + markFingerprintsMismatch, + getMessages, + switchTab, + // touch + longPress, + longPressAvatar, + swipeLeft, + swipeRight, +} from './helpers'; + +// One reference per name so TS can't tree-shake the imports away. The +// `void` operator silences `@typescript-eslint/no-unused-expressions` +// without needing an eslint-disable comment. +void freshStart; +void createServer; +void getPeerId; +void generateInvite; +void joinViaInvite; +void setupTwoPeers; +void waitForApp; +void openServerSettings; +void sendMessage; +void waitForMessage; +void switchChannel; +void createChannel; +void openSidebar; +void closeSidebar; +void openMemberList; +void closeMemberList; +void visibleShell; +void isMobile; +void messageAction; +void editMessage; +void deleteMessage; +void reactToMessage; +void trustPeer; +void untrustPeer; +void kickPeer; +void openCompareFingerprints; +void markFingerprintsMatch; +void markFingerprintsMismatch; +void getMessages; +void switchTab; +void longPress; +void longPressAvatar; +void swipeLeft; +void swipeRight; From 198b9ab134f96cc5045f64857ccc00e326d5bb5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:46:26 +0000 Subject: [PATCH 10/12] test(e2e): convert multi-peer-sync.spec.ts to event-based waits Pilot for PR-2 per docs/specs/2026-04-27-event-based-waits-design.md. - 8 'toBeVisible({ timeout: 30_000 })' cross-peer assertions replaced with 'await peerB.waitUntilHeadsEqual(peerA);' followed by default 5s assertions. - 'waitForMessage(page, text, 30_000)' replaced with 'await peerB.nextEvent(e => MessageReceived && !isLocal);' then a default-timeout waitForMessage. - 'expect.poll(..., { timeout: 30_000 })' on member-list count drops the override after convergence. Signs the test on the Peer fixture from ./test-hooks. The other 7 specs continue to import from @playwright/test directly; they migrate file-by-file via tracking issue #458. Acceptance: smoke spec + pilot type-check and lint clean. Live runs against 'just dev FEATURES=test-hooks' will exercise both at the PR acceptance gate; the full N=10 flake harness ships in PR-4 alongside the wait-timeout ratchet baseline. --- e2e/multi-peer-sync.spec.ts | 110 ++++++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 41 deletions(-) diff --git a/e2e/multi-peer-sync.spec.ts b/e2e/multi-peer-sync.spec.ts index b94ed6cd..f819a16e 100644 --- a/e2e/multi-peer-sync.spec.ts +++ b/e2e/multi-peer-sync.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './test-hooks'; import { freshStart, createServer, @@ -28,11 +28,20 @@ test.describe('Multi-peer state synchronization', () => { // crates/client/src/tests/multi_peer_sync.rs against MemNetwork — the // DAG merge path is identical and the test runs in < 200 ms. // Only DOM-reflection tests stay here. + // + // Migration to event-based waits per PR-2 (issue #458). Cross-peer + // assertions now gate on Peer.waitUntilHeadsEqual / Peer.nextEvent; + // DOM checks then run with the default 5s assertion timeout. - test('invite flow — both peers see sidebar and general channel', async ({ browser }) => { + test('invite flow — both peers see sidebar and general channel', async ({ peer, browser }) => { const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = await peer(page1, 'Alice'); + const bob = await peer(page2, 'Bob'); try { - // Both peers should see the sidebar. + // Both peers should converge before we assert UI state. + await bob.waitUntilHeadsEqual(alice); + + // Both peers should see the sidebar (default 5s timeout — convergence already done). await expect(page1.locator(`${visibleShell(page1)} .channel-sidebar, ${visibleShell(page1)} .mobile-home`).first()).toBeVisible(); await expect(page2.locator(`${visibleShell(page2)} .channel-sidebar, ${visibleShell(page2)} .mobile-home`).first()).toBeVisible(); @@ -45,12 +54,13 @@ test.describe('Multi-peer state synchronization', () => { } }); - test('pre-existing channels visible after join', async ({ browser }) => { - // This test does NOT use setupTwoPeers — manual setup with channels before invite. + test('pre-existing channels visible after join', async ({ peer, browser }) => { const ctx1 = await browser.newContext(); const ctx2 = await browser.newContext(); const page1 = await ctx1.newPage(); const page2 = await ctx2.newPage(); + const alice = await peer(page1, 'Alice'); + const bob = await peer(page2, 'Bob'); try { // Peer 1: Create server. @@ -71,102 +81,120 @@ test.describe('Multi-peer state synchronization', () => { // Peer 2: Join. await joinViaInvite(page2, inviteCode, 'Bob'); + // Bob should converge to Alice's heads — including the two pre-existing channels. + await bob.waitUntilHeadsEqual(alice); + // Peer 2 should see all 3 channels (open sidebar on mobile). await openSidebar(page2); - await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'general' })) - .toBeVisible({ timeout: 30_000 }); - await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'announcements' })) - .toBeVisible({ timeout: 30_000 }); - await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'random' })) - .toBeVisible({ timeout: 30_000 }); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'general' })).toBeVisible(); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'announcements' })).toBeVisible(); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'random' })).toBeVisible(); } finally { await ctx1.close(); await ctx2.close(); } }); - test('new channel created mid-session syncs to peer', async ({ browser }) => { + test('new channel created mid-session syncs to peer', async ({ peer, browser }) => { const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = await peer(page1, 'Alice'); + const bob = await peer(page2, 'Bob'); try { // Alice creates a new channel after both are connected. await createChannel(page1, 'new-channel'); + // Wait for Bob's DAG to converge to Alice's (includes the new channel event). + await bob.waitUntilHeadsEqual(alice); + // Bob should see the new channel (open sidebar on mobile). await openSidebar(page2); - await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'new-channel' })) - .toBeVisible({ timeout: 30_000 }); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'new-channel' })).toBeVisible(); } finally { await ctx1.close(); await ctx2.close(); } }); - test('messages in non-general channel sync', async ({ browser }) => { + test('messages in non-general channel sync', async ({ peer, browser }) => { const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = await peer(page1, 'Alice'); + const bob = await peer(page2, 'Bob'); try { // Alice creates a new channel. await createChannel(page1, 'dev'); - // Wait for Bob to see it (open sidebar on mobile). + // Wait for Bob's DAG to include the channel. + await bob.waitUntilHeadsEqual(alice); + + // Bob can now see the channel without padding. await openSidebar(page2); - await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'dev' })) - .toBeVisible({ timeout: 30_000 }); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'dev' })).toBeVisible(); // Both switch to the new channel. await switchChannel(page1, 'dev'); await switchChannel(page2, 'dev'); - // Alice sends a message. + // Alice sends a message → wait for Bob's MessageReceived event, + // then assert the DOM-rendered body. await sendMessage(page1, 'message in dev'); - await waitForMessage(page2, 'message in dev', 30_000); - - // Bob sends a reply. + await bob.nextEvent(e => + e.kind === 'MessageReceived' && + e.channel === 'dev' && + !e.isLocal + ); + await waitForMessage(page2, 'message in dev'); + + // Bob sends a reply, Alice consumes the event then asserts the body. await sendMessage(page2, 'bob in dev too'); - await waitForMessage(page1, 'bob in dev too', 30_000); + await alice.nextEvent(e => + e.kind === 'MessageReceived' && + e.channel === 'dev' && + !e.isLocal + ); + await waitForMessage(page1, 'bob in dev too'); } finally { await ctx1.close(); await ctx2.close(); } }); - test('both peers appear in member list', async ({ browser }) => { + test('both peers appear in member list', async ({ peer, browser }) => { const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = await peer(page1, 'Alice'); + const bob = await peer(page2, 'Bob'); try { - // The right-rail members pane is hidden by default — open it via - // the header's "members" action button before asserting counts. + // Wait for the membership events to converge before opening the panel. + await bob.waitUntilHeadsEqual(alice); + await page1.locator(`${visibleShell(page1)} button[aria-label="members"]`) .first().click(); - // Peer 1 should see at least 2 members (may include relay). + // Default expect timeout (5s) is plenty after convergence. const memberList = page1.locator(`${visibleShell(page1)} .member-item`); - await expect(memberList.first()).toBeVisible({ timeout: 30_000 }); - await expect - .poll(() => memberList.count(), { timeout: 30_000 }) - .toBeGreaterThanOrEqual(2); + await expect(memberList.first()).toBeVisible(); + await expect.poll(() => memberList.count()).toBeGreaterThanOrEqual(2); } finally { await ctx1.close(); await ctx2.close(); } }); - test('rapid channel creation by owner — both channels propagate to peer', async ({ browser }) => { - // Owner creates two channels in quick succession; the gossip mesh must - // deliver both events to the remote peer without dropping or reordering. - // E2E companion to the state-machine stress_concurrent_channel_creates test. - // Note: only the owner (Alice) can create channels — non-owners lack - // ManageChannels permission and their creation attempts are rejected. + test('rapid channel creation by owner — both channels propagate to peer', async ({ peer, browser }) => { const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const alice = await peer(page1, 'Alice'); + const bob = await peer(page2, 'Bob'); try { // Alice (owner) creates two channels back-to-back. await createChannel(page1, 'chan-a'); await createChannel(page1, 'chan-b'); + // Wait for Bob's DAG to include both. + await bob.waitUntilHeadsEqual(alice); + // Both should appear on Bob's side after gossip delivery. await openSidebar(page2); - await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'chan-a' })) - .toBeVisible({ timeout: 30_000 }); - await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'chan-b' })) - .toBeVisible({ timeout: 30_000 }); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'chan-a' })).toBeVisible(); + await expect(page2.locator(`${visibleShell(page2)} .channel-item`, { hasText: 'chan-b' })).toBeVisible(); } finally { await ctx1.close(); await ctx2.close(); From de9628719f84e792554fdb59faba3f6b6a79f3d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:47:03 +0000 Subject: [PATCH 11/12] docs(e2e): document Peer wrapper + helpers/ split in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new sections: - Helpers layout (peers/ui/touch + barrel) - Event-based waits (Peer pull/push/convergence API + import pattern) - Anti-patterns blocked by ESLint (waitForTimeout + sunset date) Code sample documents the lazy-wire async peer() factory introduced in this PR — peer(page, label) is awaited and wires the binding on the page's context idempotently, so contexts created via browser.newContext() or setupTwoPeers(browser) work without per-spec setup. Points readers at the design spec + tracking issue #458 for the remaining-specs migration. --- e2e/README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/e2e/README.md b/e2e/README.md index e5afc997..ab3ad930 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -31,3 +31,76 @@ the same commit rather than fixing the selector. - `just test-e2e-full` — full setup + teardown + run, good for CI. - `PLAYWRIGHT_WORKERS=N npx playwright test ...` — override worker count. - `PLAYWRIGHT_FULLY_PARALLEL=0 npx playwright test ...` — disable intra-file parallelism. + +## Helpers layout + +The legacy 703-LOC `helpers.ts` has been split into focused modules. New +specs should import directly from the focused module they need. + +``` +e2e/ +├── helpers/ +│ ├── peers.ts -- freshStart, createServer, getPeerId, generateInvite, +│ │ joinViaInvite, setupTwoPeers, openServerSettings, waitForApp +│ ├── ui.ts -- visibleShell, isMobile, sendMessage, waitForMessage, +│ │ switchChannel, openSidebar, openMemberList, createChannel, +│ │ messageAction, editMessage, deleteMessage, reactToMessage, +│ │ trustPeer, untrustPeer, kickPeer, openCompareFingerprints, … +│ └── touch.ts -- longPress, longPressAvatar, swipeLeft, swipeRight +├── helpers.ts -- re-export barrel; un-migrated specs continue to import +│ from './helpers' with zero diff +├── test-hooks.ts -- Peer wrapper + `peer` fixture (see "Event-based waits" below) +└── *.spec.ts +``` + +## Event-based waits (Peer wrapper) + +The web crate exposes `window.__willow` and a `__willowEvent` push stream +when built with `--features test-hooks`. The `Peer` class in +`e2e/test-hooks.ts` wraps both: + +- **Pull**: `peer.snapshot()`, `peer.heads()`, `peer.eventCount()`, + `peer.lastEvent()` — each round-trips through `window.__willow.*`. +- **Push**: `peer.nextEvent(predicate, { timeout? })` — drains the next + event matching `predicate` from the per-page event queue. +- **Convergence**: `peer.waitUntilHeadsEqual(otherPeer)` and + `peer.waitUntilAllHeadsEqual([otherPeers])` — `expect.poll`-based + CRDT convergence checks. Failure messages include a structured + per-author-key diff so missing-author hangs are debuggable without a + manual `console.log`. + +Specs that need the wrapper import the typed `test` + `expect` from +`./test-hooks` instead of `@playwright/test`: + +```ts +import { test, expect } from './test-hooks'; + +test('peer B converges with peer A', async ({ peer, browser }) => { + const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser); + const a = await peer(page1, 'Alice'); + const b = await peer(page2, 'Bob'); + await b.waitUntilHeadsEqual(a); // gossip-side wait + await expect(page2.locator('.channel-item', { hasText: 'general' })) + .toBeVisible(); // default 5s — DOM-only after convergence +}); +``` + +`peer(page, label)` is async and idempotently wires `__willowEvent` +bindings on the page's `BrowserContext` on first call per context, so +contexts created via `browser.newContext()` or `setupTwoPeers(browser)` +work without per-spec setup. Call `peer()` before the page's first +`goto()` when possible — `addInitScript` only takes effect on +subsequent loads. + +The full design is in +[`docs/specs/2026-04-27-event-based-waits-design.md`](../docs/specs/2026-04-27-event-based-waits-design.md). +Migration progress for the remaining 7 specs is tracked in +[#458](https://github.com/intendednull/willow/issues/458). + +## Anti-patterns blocked by ESLint + +`page.waitForTimeout(ms)` is blocked by `no-restricted-syntax` in +`eslint.config.js`. Specs migrated off the timeout pattern remove their +file-top `eslint-disable` header in the same PR. Each remaining +disabled file references issue #458; the rule sunsets on 2026-09-30 +(per spec §"Sunset"). From 24765efb60480c03ed110b21a5d37a9ab2a44289 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 22:13:12 +0000 Subject: [PATCH 12/12] fix(e2e): waitUntilHeadsEqual matcher target was stale-frozen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review found the assertion was logically broken: await expect.poll(async () => { lastSelf = await this.heads(); lastOther = await other.heads(); return canonicalHeads(lastSelf); }, { timeout }) .toBe(canonicalHeads(lastOther)); The matcher arg 'canonicalHeads(lastOther)' is evaluated ONCE when the expect chain is constructed — before any poll runs and while lastOther is still {} (canonicalHeads({}) === '[]'). The poll therefore checked canonicalHeads(self) === '[]', not 'self converges with other'. Tests would only have 'passed' when self had no heads — masking real convergence failures. Fix: poll a boolean (canonicalHeads(self) === canonicalHeads(other)) and assert it's true. The matcher target is now constant; both heads maps are re-fetched on every tick, so neither side freezes. Also in this cleanup pass: - Removed dead PeerInternals type (declared but unused). - Consolidated imports at top of file (test, expect, Page, BrowserContext from @playwright/test); 'export { expect }' moved next to its import rather than mid-file between fixture and class. - Class block sits before the fixture now so PeerFactory's referent is defined when the fixture's PeerFactory generic is parsed by readers. - Reworded the Peer doc comment to drop the 'Task 3' plan reference. --- e2e/test-hooks.ts | 208 ++++++++++++++++++++++------------------------ 1 file changed, 98 insertions(+), 110 deletions(-) diff --git a/e2e/test-hooks.ts b/e2e/test-hooks.ts index efce3752..016d9da7 100644 --- a/e2e/test-hooks.ts +++ b/e2e/test-hooks.ts @@ -7,7 +7,10 @@ // Types here mirror the Rust WireEvent / SnapshotDto / ChannelDto shapes. // Keep in sync with crates/web/src/test_hooks/{wire,snapshot}.rs. -import type { Page, BrowserContext } from '@playwright/test'; +import { test as base, expect, type Page, type BrowserContext } from '@playwright/test'; + +// Re-export expect so spec authors can `import { test, expect } from './test-hooks';` +export { expect }; // ── Mirror of crates/web/src/test_hooks/wire.rs::WireEvent ───────────── @@ -56,107 +59,6 @@ interface WillowTestHooksJS { last_event(): Promise; } -/** Sentinel: queue + Page + label. Returned by the fixture, not exported as a type. */ -type PeerInternals = { - page: Page; - label: string; - queue: ClientEvent[]; -}; - -import { test as base } from '@playwright/test'; - -/** - * Per-page event queue tracker. The fixture creates one `WeakMap` - * per `BrowserContext` and routes every `__willowEvent` callback to the queue - * keyed by the originating Page (Playwright's `exposeBinding` callback receives - * `{ page }` as the first argument's source). - * - * `Peer` reads the queue by reference, so any event the WASM dispatcher emits - * after the binding is installed shows up in `peer.queue` synchronously. - */ -export type PeerFactory = (page: Page, label: string) => Promise; - -/** - * Playwright fixture that installs the `__willow` test-hooks plumbing. - * - * Usage: - * import { test, expect } from './test-hooks'; - * test('foo', async ({ peer, browser }) => { - * const a = await peer(page1, 'Alice'); - * await a.waitUntilHeadsEqual(b); - * }); - * - * The fixture's scope is `'test'` (default). Bindings are wired lazily on - * the page's BrowserContext on first `peer(page, label)` call per context, - * so the factory works for both Playwright's default test context AND any - * extra contexts a test creates via `browser.newContext()` / - * `setupTwoPeers(browser)`. - * - * `addInitScript` only takes effect on subsequent page loads, so call - * `peer()` before the first `goto()` when possible. Bindings registered - * via `exposeBinding` apply to existing pages too, so the read path - * recovers events as soon as the binding lands. - */ -export const test = base.extend<{ peer: PeerFactory }>({ - peer: async ({}, use) => { - // Per-page queues, keyed by the JS Page object the binding callback receives. - const queues = new WeakMap(); - // Track which contexts we've already wired so peer() is idempotent. - const wired = new WeakSet(); - - const wireContext = async (context: BrowserContext) => { - if (wired.has(context)) return; - wired.add(context); - - // 1. exposeBinding — registers the JS-side proxy. After this returns, - // `window.__willowEvent` is callable in every page of the context - // (existing and future). - await context.exposeBinding( - '__willowEvent', - (source, ev: ClientEvent) => { - const q = queues.get(source.page); - if (q) q.push(ev); - // No queue means the page wasn't registered via peer() — drop silently. - }, - ); - - // 2. Overflow → fail loudly. PR-1's dispatcher calls this with droppedCount - // only when the 65k buffer is exceeded (a real correctness bug, never - // backpressure under normal load). - await context.exposeBinding('__willowOverflow', (_source, dropped: number) => { - throw new Error(`__willow event queue overflow: ${dropped} dropped`); - }); - - // 3. addInitScript — pre-creates the buffer for FUTURE page loads in - // this context. Defence-in-depth for the dispatcher's fallback path - // that runs when `__willowEvent` is briefly absent. - await context.addInitScript(() => { - (window as unknown as { __willowEventBuffer: unknown[] }).__willowEventBuffer = []; - }); - }; - - /** - * Allocate a queue for `page`, lazily wire its context, return a `Peer`. - * - * Idempotent: safe to call multiple times for the same page or context. - */ - const factory: PeerFactory = async (page, label) => { - await wireContext(page.context()); - let queue = queues.get(page); - if (!queue) { - queue = []; - queues.set(page, queue); - } - return new Peer(page, label, queue); - }; - - await use(factory); - }, -}); - -// Re-export expect so spec authors can `import { test, expect } from './test-hooks';` -export { expect } from '@playwright/test'; - /** * Engine-independent canonical form for a heads map. * @@ -190,10 +92,10 @@ function authorKeyDiff( /** * Test-side wrapper for one Willow peer (one Playwright Page). * - * Construct via `peer` fixture in Task 3 — direct construction works for - * the pull-API methods only (snapshot/heads/eventCount/lastEvent). - * Push-API methods (nextEvent / waitUntil*) require the fixture's - * exposeBinding wiring to populate `queue`. + * Construct via the `peer` fixture exported from this module — direct + * construction works for the pull-API methods only (snapshot/heads/ + * eventCount/lastEvent). Push-API methods (nextEvent / waitUntil*) require + * the fixture's exposeBinding wiring to populate `queue`. */ export class Peer { constructor( @@ -269,7 +171,10 @@ export class Peer { * * Uses `expect.poll` with a 30 s default timeout (matches the legacy * `{ timeout: 30_000 }` overrides this method replaces). Each poll - * re-fetches BOTH sides' heads — `other` may still be advancing. + * re-fetches BOTH sides' heads — `other` may still be advancing — and + * returns whether they match. The matcher target is the constant + * `true`, so the assertion is symmetric in `self` and `other` and does + * not freeze on a stale snapshot. * * NB: heads-equal is a CRDT pairwise check. Two peers can be equal * yet both still missing an event from a third; use @@ -280,7 +185,6 @@ export class Peer { opts: { timeout?: number } = {}, ): Promise { const timeout = opts.timeout ?? 30_000; - const { expect } = await import('@playwright/test'); let lastSelf: Record = {}; let lastOther: Record = {}; try { @@ -289,14 +193,14 @@ export class Peer { async () => { lastSelf = await this.heads(); lastOther = await other.heads(); - return canonicalHeads(lastSelf); + return canonicalHeads(lastSelf) === canonicalHeads(lastOther); }, { timeout, message: `${this.label} converge with ${other.label}`, }, ) - .toBe(canonicalHeads(lastOther)); + .toBe(true); } catch (e) { // Re-throw with the structured diff appended so missing-author hangs // are debuggable without a manual console.log round-trip. @@ -319,3 +223,87 @@ export class Peer { } } } + +/** + * Factory injected by the `peer` fixture. Async because first-call-per-context + * lazily wires `__willowEvent` / `__willowOverflow` bindings. + */ +export type PeerFactory = (page: Page, label: string) => Promise; + +/** + * Playwright fixture that installs the `__willow` test-hooks plumbing. + * + * Usage: + * import { test, expect } from './test-hooks'; + * test('foo', async ({ peer, browser }) => { + * const a = await peer(page1, 'Alice'); + * await a.waitUntilHeadsEqual(b); + * }); + * + * The fixture's scope is `'test'` (default). Bindings are wired lazily on + * the page's BrowserContext on first `peer(page, label)` call per context, + * so the factory works for both Playwright's default test context AND any + * extra contexts a test creates via `browser.newContext()` / + * `setupTwoPeers(browser)`. + * + * `addInitScript` only takes effect on subsequent page loads, so call + * `peer()` before the first `goto()` when possible. Bindings registered + * via `exposeBinding` apply to existing pages too, so the read path + * recovers events as soon as the binding lands. + */ +export const test = base.extend<{ peer: PeerFactory }>({ + peer: async ({}, use) => { + // Per-page queues, keyed by the JS Page object the binding callback receives. + const queues = new WeakMap(); + // Track which contexts we've already wired so peer() is idempotent. + const wired = new WeakSet(); + + const wireContext = async (context: BrowserContext) => { + if (wired.has(context)) return; + wired.add(context); + + // 1. exposeBinding — registers the JS-side proxy. After this returns, + // `window.__willowEvent` is callable in every page of the context + // (existing and future). + await context.exposeBinding( + '__willowEvent', + (source, ev: ClientEvent) => { + const q = queues.get(source.page); + if (q) q.push(ev); + // No queue means the page wasn't registered via peer() — drop silently. + }, + ); + + // 2. Overflow → fail loudly. PR-1's dispatcher calls this with droppedCount + // only when the 65k buffer is exceeded (a real correctness bug, never + // backpressure under normal load). + await context.exposeBinding('__willowOverflow', (_source, dropped: number) => { + throw new Error(`__willow event queue overflow: ${dropped} dropped`); + }); + + // 3. addInitScript — pre-creates the buffer for FUTURE page loads in + // this context. Defence-in-depth for the dispatcher's fallback path + // that runs when `__willowEvent` is briefly absent. + await context.addInitScript(() => { + (window as unknown as { __willowEventBuffer: unknown[] }).__willowEventBuffer = []; + }); + }; + + /** + * Allocate a queue for `page`, lazily wire its context, return a `Peer`. + * + * Idempotent: safe to call multiple times for the same page or context. + */ + const factory: PeerFactory = async (page, label) => { + await wireContext(page.context()); + let queue = queues.get(page); + if (!queue) { + queue = []; + queues.set(page, queue); + } + return new Peer(page, label, queue); + }; + + await use(factory); + }, +});