Repository navigation
test(browser): fix six journey sync flakes from the 2026-11-04 sweep - #2566
Merged
Merged
Conversation
This was referenced Nov 5, 2026
matt2e
added a commit
that referenced
this pull request
Nov 5, 2026
…ollapsing Work (#2574) ## Summary `navigation-groups.spec.mjs` "dismissing a failed move restores keyboard focus to its placement or a surviving row" clicked Work's header before the rolled-back row had left Starred. This adds the missing barrier to the journey's `failMove` helper. The change is test-only: no assertions are loosened, no retries, sleeps or timeouts change, and no product code changes. ## Problem WebKit's `navigation-groups.spec.mjs:650` failed once, at `await expect(beta).toBeHidden()` after the click on Work's summary, on `workflow-ui-issues` (#2362), a branch that doesn't touch the sidebar. - run 41946923047: https://github.com/block/buzz-app/actions/runs/41946923047/job/126981730084 In a scan of about 311 failed browser-journey jobs over about 25 hours, this journey failed only this once. ## Cause The trace (artifact `browser-journeys-webkit-11`) shows the following: - The 502 lands and the alert appears with the rollback render. The optimistic Starred copy of the row is then an `AnimatePresence` exit: it stays in the DOM and in layout (`inert`, `aria-hidden`, fading), and the row re-enters Work at once. - The journey's barrier, Work's beta row being visible, passes as soon as that row mounts at about 5% opacity (snapshot opacity 0.046), while the Starred copy is still there. - The click on Work's summary measured its box with the Starred copy still laid out (y 393–425). Removing the copy moved the header up by a row, to y ≈ 379 in the final screenshot. - Playwright's hit-target interceptor decides on the first event of a click and doesn't recheck later events. The press landed on the header, and the release landed on the Beta row that slid under it. The browser fired no click on the header, so `toggle("group:work", false)` never ran. - The trace is consistent with this. Work's `<details>` stays `open` and its content never gets `hidden` or `inert` in any snapshot after the click, and the call log shows no retry. The final screenshot has the pointer resting on the Beta row, which shows its hover highlight, and Alpha is still the current channel. This is the same press-and-release mismatch that #2566 describes for Show more in `channel-members-focus`. Here the layout shift is an exit animation completing rather than a search result landing. ## Fix After the alert, `failMove` waits until the sidebar holds exactly one `beta` row. Only the rolled-back placement in Work remains, so the Starred exit has finished before the journey acts on the layout. The helper runs for both of the journey's failed moves. ## Validation - Adversarial ordering, with `requestAnimationFrame` held from the 502 until the header press and a 300 ms press-to-release delay on the header click: - without the fix: 20/20 failed (10 Chromium, 10 WebKit) with the CI signature. The press landed on the Work summary with two beta rows in the DOM, the release landed on the Beta row, and the click fired on a common ancestor. - with the fix: 20/20 passed. - Whole `navigation-groups.spec.mjs`, `--workers=4 --retries=0 --repeat-each=10`: WebKit 100/100 passed (3.8 min) and Chromium 100/100 passed (3.9 min). The machine was heavily loaded (load average about 70), and the WebKit here is Playwright's macOS build, not the Linux build CI uses. - `tsc --noEmit`, `biome check --error-on-warnings` on the changed file and `git diff --check` are clean. `node --test tests/integration/flake-check.test.mjs` passes 10/10. This changes the same file as #2566 (a different journey, so the hunks don't overlap). A green run doesn't prove the flake is gone, because it fired once in about 311 jobs. Hosted CI covers broad validation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…e Retry ## Problem WebKit's `sidebar-section-creation.spec.mjs:9` "Channels offers empty-section creation with keyboard focus and confirmed persistence" failed at line 167 when clicking "Retry preferences". The click timed out after 10 s. Its call log says "element is not enabled", then "element was detached from the DOM". - clay/bestie-empty-state: run 41547288213, https://github.com/block/buzz-app/actions/runs/41547288213/job/125626193138 - kylez_NEXUS-4082_channel_unread_boundary: run 41553877476 attempt 1, https://github.com/block/buzz-app/actions/runs/41553877476/job/125648251816 (attempt 2 passed) ## Cause The journey's second creation fails the strong channel-sections writer read with an injected 502. `isTransientFailure` counts a 502 as transient, so the preference store publishes the error with `transient`, and relay recovery reads again after `RECOVERY_DELAYS_MS[0]` (1000 ms). That read disables Retry preferences (`disabled={refreshing}`), and its success removes the button. This is intended: recovery owns transient background reads. The trace from run 41547288213 shows the race. The 502 was fulfilled at 153289 ms and the click started at 153482. WebKit's first "visible, enabled and stable" check did not return until 154493. Recovery's read reached the route at 154393, so that check found the button disabled. The button detached at 155188, and the click timed out. Any click that takes more than about a second to land loses the Retry to recovery. ## Fix Inject a 403 instead. A denial is not transient, so `loadState` reports it as settled and recovery leaves it alone. The manual Retry is then the only way out, which is the path this journey exists to exercise. The fixture already uses this reasoning for activity reads ("Activity reads retry a 502 quietly, so their journeys inject a 403"). The failure is recorded under `app.report.relayFailures`, the fixture's allowance for injected relay 403s, rather than `sidebarPreferenceFailures`, which only matches 502s. The check that the alert doesn't show the status now looks for "403". A comment explains why a transient status would race recovery. The existing `writerReads` of 3 after the click now also confirms that no background read ran first. Only the test changes (+7/-4). No other assertions change, and no product code changes or browser cases are added or removed. Open PR #2150 ("Run native Pi agents through plugin execution targets") makes the same 403 and `relayFailures` change in this hunk as part of a feature change. It leaves the alert check on "502". When #2150 rebases, it can take this side of the hunk. ## Validation Local Linux workstation, Playwright 1.63.0, `--no-deps --workers=1 --retries=0`: - Adversarial ordering, using temporary, uncommitted changes: the Retry click was delayed 1.2 s, modelling the slow actionability check, and the first writer read after the injected failure was held for 600 ms. Without the fix: 10/10 failed (5 WebKit, 5 Chromium), all at the Retry click with "element is not enabled" followed by "element was detached from the DOM", the CI signature. Holding the read without delaying the click: 6/6 passed. With the fix: 10/10 passed, and 6/6 passed with a 3.5 s delay, past recovery's first two retries. - Natural contention: WebKit pinned to 4 CPUs shared with 4 busy loops, 2 workers, 24 repeats. Without the fix: 24/24 passed, so load alone did not open the window locally. With the fix: 24/24 passed. - `bin/pnpm test:browser tests/browser/sidebar-section-creation.spec.mjs --project webkit --project chromium --no-deps --workers=1 --grep "empty-section creation" --repeat-each=20`: 40/40 passed. - Full `sidebar-section-creation.spec.mjs`, both engines: 6/6 passed. - `biome check` and `git diff --check` are clean. A green run doesn't prove the flake is gone. Hosted CI covers broad validation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: mtoohey <mtoohey@squareup.com> (cherry picked from commit 69eae17b94b702a9c74c154e37ea147441c7ab11)
…rsation ## Problem WebKit's `inbox-archive.spec.mjs:658` "reply and fresh mention reopen their filters without replacing composer draft" failed on attempt 1 on branch peon/people-relationships-cache, and the same SHA passed on rerun: - run 41752428902 (webkit 14/14), https://github.com/block/buzz-app/actions/runs/41752428902/job/126320899490 Error: expect(locator).toHaveText(expected) failed Expected: "Unsent mention retention draft" Received: "" ## Cause Opening a conversation reveals its entry reply: `useMessageReveal` scrolls to the row, sets `tabindex=-1` and focuses it once the thread target is ready, two frames after the commit. The journey filled the composer as soon as it appeared, so on a slow runner the reveal landed during the fill. Playwright fills a contenteditable in two steps: it focuses the editor and selects its contents, then inserts the text at the selection. In WebKit, focusing the row clears the editor's selection, so the insert had nowhere to go. A temporary probe that focused the row between the two steps reproduced it: WebKit inserted nothing, while Chromium kept the selection and inserted the text. The trace shows the same thing. Between the fill's before and after snapshots, the only DOM change is the entry row gaining `tabindex=-1`. The editor's paragraph and trailing break are the same unchanged nodes, so the text never landed. A reset that cleared it would have replaced them. A user typing as the thread finishes loading doesn't lose text to the seed reset. That reset only applies to an untouched draft, and `valueRef` updates synchronously on input. Probes logged no editor reset while text was typed. Characters typed before the reveal stay in the composer. The reveal does take focus from the composer, though. Its first focus for a request doesn't check where focus is now, so keys typed after it go to the row. That focus policy is shared by every reveal, so this change doesn't touch it. Three more journeys in this file open a conversation and fill it, and they share the race. The "unselected row Archive" journey already waited for the entry row to be in viewport plus two frames, but that isn't a barrier: the row is in view before the reveal runs. ## Fix Add a `revealed()` helper. It waits for the conversation list's aria-busy to be false, for the entry row to be focused, and for two frames via the shared `frames()` helper. A focused row is the reveal's own observable effect, so filling after it can't overlap the reveal. All four journeys that fill after Open now call it, replacing the inline in-view wait. Only the test changes (+22/-12). No assertions are loosened, and no product code changes or browser cases are added or removed. ## Validation Local Linux workstation, Playwright 1.63.0, `--no-deps --workers=1 --retries=0`: - Adversarial ordering, using a temporary, uncommitted model in `useMessageReveal`: the entry reveal was held for 800 ms. If a fill placed its selection in a textbox meanwhile, the model ran the reveal at that moment and cleared the selection when the row took focus, as WebKit did in CI. The four journeys, both engines, `--repeat-each=3`: without the fix 17/24 failed, with the empty editor (or a disabled Send) in every journey, the existing in-view wait included. With the fix: 24/24 passed. - The four journeys, both engines, `--repeat-each=10`: 80/80 passed. - The same four journeys with four busy loops pinned to the workstation's CPUs, `--repeat-each=4`: 32/32 passed. - Full `inbox-archive.spec.mjs`, both engines, `--repeat-each=2`: 40/40 passed. - `biome check` and `git diff --check` are clean. A green run doesn't prove the flake is gone. Hosted CI covers broad validation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: mtoohey <mtoohey@squareup.com> (cherry picked from commit c715b9eb7603aeca65fb85629fc210189df576c3)
…d fits ## Problem The "WebKit recording (macOS)" lane failed in the first case of `voice-notes.spec.mjs`, "record, preview, send and play the chat card with speed and seeking" (:138). Both failures were in the shared beforeEach: `page.goto(.../tests/fixtures/voice-notes.html)` exceeded the 15 s navigation timeout waiting for load. The other three cases in the job passed. - renovate/all-minor-patch: run 41755043277, https://github.com/block/buzz-app/actions/runs/41755043277/job/126329063633 - peon/dnd-stable-transform: run 41757728323 attempt 1, https://github.com/block/buzz-app/actions/runs/41757728323/job/126337712196 (attempt 2 passed) ## Cause The fixture runs the real PluginRuntime, ConversationService, relay session and Voice Notes plugin, and a Vite dev server serves that graph module by module. Load waits until every module has run. Both traces show the client, not Vite, setting the pace: - Run 41755043277 made 818 requests: 314 .js, 263 .ts, 123 .tsx, 82 inline .png and 29 .css. Vite's median wait was 48 ms, but the median receive took 1.14 s and the slowest 2.6 s, for responses of 1-3 KB. The last response ended 13.3 s after the first request started. For over 3 s in the middle, no new request started. - Run 41757728323 made 816 requests: median wait 53 ms, median receive 1.0 s, and 14.5 s from first request to last response. Its `@vite/client` request also waited 6.1 s. Each module costs WebKit a round trip through its network and web processes. On a 3-core macOS runner with a cold browser, the 800 round trips by themselves nearly fill the budget. Later cases reuse the warm browser and pass. #1971 warmed Vite's transforms in beforeAll, which fixed only the server's side. ## Fix Build the fixture once in beforeAll and serve the bundle with `build` and `preview` from `vite-server.mjs`, as `agent-share-card`, `channel-members-focus`, `conversation-name` and `goose-model-row` already do. The page now loads in 4 requests instead of 838, and the warmup goes away because a build has nothing left to transform. The dev server served development React. The build defines `import.meta.env.DEV` and `process.env.NODE_ENV` as `build.mjs` does for `developmentReact`, so StrictMode's double effects and React's development warnings still apply. The first case asserts no unexplained page errors, and the microphone cases count releases. The bundle contains React's development warnings. The last case read the composer's draft by importing `/src/features/messages/attachment-draft.ts` from the dev server inside `page.evaluate`. A bundle has no such URL. The fixture now exposes `attachmentDraft` bound to its session instead. Drafts are cached per session in that module, so this reads the composer's own draft. The manual dev-server preview described in `docs/plugin-architecture.md` still works. No assertions, timeouts or retries change, and no browser cases are added or removed. ## Validation Local Linux workstation, Playwright 1.63.0, WebKit (WPE), `--no-deps --workers=1 --retries=0`. Each run started a new browser, so each run's first case was cold. Times are `page.goto` until load, with the run and busy loops pinned by `taskset`: | CPUs, busy loops | dev server (838 requests) | build (4 requests) | | ---------------- | ------------------------- | ------------------ | | 3, none | 1.15-1.23 s (5) | 0.53-0.65 s (5) | | 3, 3 | 2.0-2.85 s (20) | 0.44-1.21 s (20) | | 2, 8 | 3.7-6.8 s (10) | 1.1-2.55 s (10) | | 1, 3 | 5.1-6.1 s (10) | 2.0-2.3 s (10) | The first case alone, cold, with 3 CPUs and 3 loops: 2.3-2.8 s before, 0.7-1.1 s after. Linux WebKit spends much less per request than the macOS runner did (a median receive of about 1 s there), so these local gains are 2-3x. In CI, where the per-request cost dominated, the gain should be larger. The build itself takes 2.5 s on 3 CPUs and 3.9 s on 1 CPU, inside beforeAll's 120 s. - `bin/pnpm test:browser --config tests/browser/playwright.ci.config.mjs tests/browser/voice-notes.spec.mjs --project chromium --project webkit --no-deps --retries=0 --repeat-each=10` (Chromium runs all five cases, Linux WebKit the untagged one): 60/60 passed. The same command pinned to 3 CPUs shared with 3 busy loops, `--repeat-each=5`: 30/30 passed. - The macOS lane's selection (`--project webkit --grep @media-recorder`) can't pass on Linux, because Playwright's Linux WebKit has no MediaRecorder. Before and after the change, all four cases failed at the same step: Stop recording never appears. Hosted macOS CI runs them. - `biome check --error-on-warnings`, `tsc --noEmit` and `git diff --check` are clean. The flake-check listing test in `tests/integration/flake-check.test.mjs` that reads this spec passes. A green run doesn't prove the flake is gone. Hosted CI covers broad validation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: mtoohey <mtoohey@squareup.com> (cherry picked from commit eedec5a9b58fb97afef64f378f1cccdca928a573)
…same tick ## Problem WebKit's `github-body.spec.mjs:35` "PR media plays under the packaged media policy in a narrow GitHub panel" failed at :189, `expect(seek.actual).toBeCloseTo(seek.expected, 1)`. The fullscreen video's `seeked` fired, but `currentTime` was still near half a second instead of 15 (half the duration). - peon/federated-session-bundle: run 41567557720 attempt 1 (Received 0.533400327), https://github.com/block/buzz-app/actions/runs/41567557720/job/125692945945 (the rerun passed) - renovate/all-minor-patch: run 41683322444 (Received 0.48949321), https://github.com/block/buzz-app/actions/runs/41683322444/job/126083327417 ## Cause The fullscreen `VideoPlayer` autoplays, and its `onLoadedMetadata` handler seeks to the inline video's position (`initialTime`). The journey waited for `readyState` and `seekable`. Then, in one evaluate, it called `pause()` on the playing video and immediately set `currentTime` to half the duration. There were two possible explanations: (a) The restore lands after the journey's seek and overwrites it, so the `seeked` the journey awaits is the restore's. (b) WebKit completes a seek issued in the same tick as `pause()` on a playing video without updating `currentTime`. `seeked` fires, but `currentTime` stays where playback paused. This change fixes (b), the only one the evidence supports. Local probes (WebKit WPE, Playwright 1.63.0) repeated the journey's pause-then-seek many times per test. Some also logged every media event, every `currentTime` write and the journey's own seek: - Reloading the fullscreen video before each sample, so it autoplays and restores again, left about 0.2% of samples unmoved: `seeked` fired, and `currentTime` still equalled its value before the seek 300 ms later. That was 3 of 1,500 in the A/B below and 5 of 1,810 in earlier runs. - In all three unmoved samples whose event log was kept, `loadedmetadata` and the restore's write came before the journey's seek. Across 1,800 samples that reopened the viewer, `loadedmetadata` never landed after the journey's seek. Nothing showed (a) happening. - In the two unmoved samples that checked, `requestVideoFrameCallback` reported a presented frame at 15.2 s while `currentTime` still read 0.062 and 0.157. The pipeline had moved, but the position WebKit reported had not. The CI values sit near the inline video's paused position. By themselves they would fit either explanation. The local evidence decides between them. ## Fix Only the journey changes. Before it opens the viewer, a MutationObserver pauses the fullscreen video as soon as it mounts. Pausing clears the autoplay flag, so the video never plays. The observer also resolves `window.fullscreenRestored` on the video's first `seeked`, which is the restore's. The journey awaits that before its own seek and no longer calls `pause()` inside the seek evaluate. The seek now runs on a video that has never played, after the restore has finished. That avoids (b). It also rules out (a), because the restore has already landed when the journey seeks. The assertion stays exact (`toBeCloseTo(…, 1)`). The two inline videos still cover playback: each must reach 0.2 s and decode a frame. The fullscreen video's autoplay was never asserted. There is no product change and no `initialTime` guard in `VideoPlayer`, because nothing showed the restore landing late. No retries, sleeps or timeouts change, and no browser cases are added or removed. ## Validation Local Linux workstation, Playwright 1.63.0, `--no-deps --retries=0`: - Reload A/B, using a temporary, uncommitted spec: the journey up to the fullscreen viewer, then 50 samples per test. Each sample calls `load()` on the fullscreen video, waits for `readyState` and `seekable` as the journey does, and seeks to half the duration. WebKit, 30 repeats, 3 workers per mode, each mode pinned to 4 CPUs shared with 2 busy loops: - old (autoplay, then `pause()` and the seek in one tick): 3/1,500 unmoved (`currentTime` 0.091-0.112 against 15), all with the CI signature, and 1,500/1,500 playing before the seek. - new (`pause()` right after `load()`, then seek after the restore's `seeked`): 0/1,500 unmoved, 0/1,500 playing before the seek. Three against zero fits (b) but isn't statistically conclusive (one-sided p ≈ 0.12). - Reopen A/B, same setup: the viewer was closed and reopened before each of 30 samples per test. The new mode used this change's observer. Old: 0/900 unmoved. New: 0/900 unmoved, 0/900 playing before the seek. Neither mode reproduced the problem, so this run only shows the observer holds the video paused and the restore never landed late (0/1,800). - `bin/pnpm test:browser tests/browser/github-body.spec.mjs --project webkit --project chromium --no-deps --workers=1 --repeat-each=10 --grep "PR media plays"`: 20/20 passed. - The same journey, WebKit, pinned to 4 CPUs shared with 4 busy loops, `--workers=2 --repeat-each=16`: 16/16 passed. - Full `github-body.spec.mjs`, both engines: 2/2 passed. - `biome check` and `git diff --check` are clean. A green run doesn't prove the flake is gone. Hosted CI covers broad validation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: mtoohey <mtoohey@squareup.com> (cherry picked from commit c03a0a08deddd973dd29fe6728f9c56f93643f84)
…ce the final Retry
## Problem
Code review follow-up to "test(channels): deny the failed preference
read so recovery can't race Retry" (suggestion,
`tests/browser/navigation-groups.spec.mjs` "Create new retains its draft
when preferences fail before submission and recovers in place"). The
journey injects 502s on `sidebar-preferences`, which relay recovery
treats as transient. It clicks "Retry preferences" twice, and the second
click comes right after `page.unroute`. Recovery's next background read
can then succeed and remove the alert before that click lands. That is
the failure mode the earlier commit fixed in
`sidebar-section-creation.spec.mjs`.
## Cause
The timing, from `src/features/relay/recovery.ts` and the journey:
- The first 502 starts recovery. It retries after 1 s and again about
2 s later (`RECOVERY_DELAYS_MS`), and the still-installed route fails
both.
- The inline "Reconnecting to relay…" notice the journey waits for
renders only once `shown` is set, `RECOVERY_NOTICE_DELAY_MS` (3 s)
after the first failure. So the first manual Retry comes after one or
two background retries.
- The dialog's Retry calls `preferences.reload`, not
`recovery.retryNow`. Its read cancels recovery's pending timer but
keeps `attempt`. When it fails with a 502, recovery schedules its next
read at `RECOVERY_DELAYS_MS[attempt]`, 2 s or 4 s later.
- The journey then asserts, unroutes and clicks Retry again. If that
click hasn't landed when recovery's read runs, the read succeeds
because the route is gone. The alert unmounts with its button, and
the click times out waiting for it.
The window is 2-4 s rather than the about 1 s of the earlier case, so
this needs a slower click, but nothing in the journey orders it.
## Fix
Keep the first failure a 502, so recovery still owns it and shows the
notice the journey asserts. From the first manual Retry on, the route
answers 403. A denial is not transient: `loadState` reports it as
settled, recovery cancels its timer and stays idle, and the dialog's
Retry is the only read left. After the unroute, the final click is the
only thing that can recover preferences.
The journey still exercises a failed retry that keeps the draft and
Retry preferences. A 403 leaves the dialog unwritable the same way.
The 403s are recorded under `app.report.relayFailures`, the fixture's
allowance for injected relay 403s, and the 502 stays under
`sidebarPreferenceFailures`. The manual retry's response wait now
matches 403. Only the test changes (+14/-4). No assertions are
loosened, and no product code changes or browser cases are added or
removed.
## Validation
Local Linux workstation, Playwright 1.63.0, `--no-deps --retries=0`:
- Adversarial ordering, using a temporary, uncommitted copy of the spec
that waited between `page.unroute` and the final Retry click to model
a slow click. Both engines, `--workers=2`:
- Without the fix, with 4.5 s: 4/4 failed (2 WebKit, 2 Chromium),
all at the final click with "locator.click: Timeout 10000ms
exceeded ... waiting for ... Retry preferences".
- Without the fix, with 1.5 s: 6/6 passed, consistent with the 2-4 s
window.
- With the fix, with 4.5 s: 6/6 passed.
- `bin/pnpm test:browser tests/browser/navigation-groups.spec.mjs
--project webkit --project chromium --workers=4 --repeat-each=10
--grep "Create new retains"`: 20/20 passed.
- Full `navigation-groups.spec.mjs`, both engines: 20/20 passed.
- `biome check --error-on-warnings` and `git diff --check` are clean.
No CI failure of this journey was looked up. The race was found by
reading the code and confirmed with the forced delay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: mtoohey <mtoohey@squareup.com>
(cherry picked from commit b1eb5a07ec427209e45cfbc51528ce643cd3adfb)
matt2e
force-pushed
the
mtoohey/flaky-journey-sync
branch
from
November 5, 2026 05:09
d5fa4ef to
63c808b
Compare
kazemek21
approved these changes
Nov 5, 2026
kazemek21
left a comment
Contributor
There was a problem hiding this comment.
Kyle reviewed this PR and the Nexus review-tracker notes and approved with no comments.
Posted by Codex (gpt-6.1-sol) on behalf of kylez
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Six browser journeys each acted before a boundary they depended on: a debounced search landing, a background recovery read (in two journeys), a message reveal taking focus, a cold page load that fetched the fixture module by module, and a pause-then-seek in the same tick. Each commit adds the missing barrier, or removes the race at its source. All six changes are test-only: no assertions are loosened, no retries, sleeps or timeouts change, no browser cases are added or removed, and no product code changes.
test(channels): let the member search land before the final pointer expansion
Problem
WebKit's
channel-members-focus.spec.mjs:826"final invitation expansion continues keyboard focus at the first new row" failed 5 times between 2026-11-02 23:00Z and 2026-11-04 11:00Z, on 4 unrelated branches including main. Every failure was in the pointer iteration at :853: "Your other agents" held 8 "Add Helper" rows, never 10.Cause
Typing "Helper" shows the local agent rows at once. The relay search runs after
SEARCH_DEBOUNCE_MS(300 ms) and adds a "People not in this channel" group above "Your other agents". In all five traces, the pointer click started 185–260 ms after the fill, while the list was stillaria-busy. The click passed Playwright's hit-target check inside Show more's box (y 523–547). The People group then landed and moved Show more to y 543–567, so the press hit Show more and the release hit the row below it, and no click reached the button.Fix
After filling the search, wait for the Member list's
data-refreshing="false", as this file's large-search test already does. The search keepsloadingtrue from the moment the text changes until that text's results arrive, so the barrier can't pass early.Validation
pointerdownon Show more and the debounce lowered to 100 ms: without the fix 20/20 failed at :853 with "Received: 8", and with the fix 20/20 passed.--repeat-each=30: 60/60 passed. The full file, both engines: 36/36 passed.test(channels): deny the failed preference read so recovery can't race Retry
Problem
WebKit's
sidebar-section-creation.spec.mjs:9"Channels offers empty-section creation with keyboard focus and confirmed persistence" failed at :167 clicking "Retry preferences". The call log said "element is not enabled", then "element was detached from the DOM".Cause
The journey failed the channel-sections writer read with a 502. A 502 counts as transient, so relay recovery reads again after 1 s. That read disables Retry preferences, and its success removes the button. In the trace, WebKit's first actionability check for the Retry click returned 100 ms after recovery's read reached the route. Any click that takes about a second to land loses the Retry to recovery.
Fix
Inject a 403 instead. A denial isn't transient, so recovery leaves it alone, and the manual Retry is the only way out, which is the path this journey exists to exercise. The failure is recorded under
app.report.relayFailures. The check that the alert doesn't show the status now looks for "403".Note on main's #2150
#2150 merged to main after this commit was written (as
21f6b97a99). It makes the same 403 andrelayFailureschange in this hunk, with its own one-line comment, so the cherry-pick conflicted there. The conflict was resolved by keeping this commit's side. On this branch, the commit therefore only replaces that comment with the longer explanation and changes the alert check from "502" to "403". On main that check passes no matter what, because the injected status is already 403. The commit body still refers to #2150 as an open PR. No other adjustment was needed.Validation
--repeat-each=20: 40/40 passed. The full file, both engines: 6/6 passed.test(inbox): wait for the entry reveal before filling an opened conversation
Problem
WebKit's
inbox-archive.spec.mjs:658"reply and fresh mention reopen their filters without replacing composer draft" failed on attempt 1, and the same SHA passed on rerun. The composer was empty:Expected: "Unsent mention retention draft",Received: "".Cause
Opening a conversation reveals its entry reply.
useMessageRevealfocuses the row two frames after the thread target is ready. The journey filled the composer as soon as it appeared, so on a slow runner the reveal landed during the fill. Playwright focuses the editor and selects its contents, and then inserts text at the selection. In WebKit, the row taking focus clears that selection, so nothing is inserted. A probe that focused the row between the two steps reproduced it in WebKit but not in Chromium. The trace shows the only DOM change during the fill was the entry row gainingtabindex=-1. Three more journeys in the file share the race. The existing in-view wait in one of them isn't a barrier, because the row is in view before the reveal runs.Fix
Add a
revealed()helper. It waits for the conversation list'saria-busyto be false, for the entry row to be focused, and then two frames. All four journeys that fill after Open now call it. The product's focus policy is addressed separately in #2563.Validation
--repeat-each=10: 80/80 passed. The full file, both engines,--repeat-each=2: 40/40 passed.test(voice-notes): serve the fixture as a build so WebKit's first load fits
Problem
The "WebKit recording (macOS)" lane failed in the first case of
voice-notes.spec.mjs(:138), in the shared beforeEach.page.goto(.../tests/fixtures/voice-notes.html)exceeded the 15 s navigation timeout.Cause
The fixture runs the real PluginRuntime, ConversationService, relay session and Voice Notes plugin, and the Vite dev server served that graph module by module. The traces show about 816–818 requests. Vite's median wait was about 50 ms, but the median receive was about 1.0–1.14 s, and the first request to the last response took 13.3–14.5 s. On a cold 3-core macOS runner, WebKit's per-module round trips alone nearly fill the budget. #1971 only warmed the server's transforms.
Fix
beforeAlland serve it withbuildandpreviewfromvite-server.mjs, as four other specs already do. The page now loads in 4 requests instead of 838.import.meta.env.DEVandprocess.env.NODE_ENVare defined asbuild.mjsdoes), so StrictMode's double effects still apply./src/features/messages/attachment-draft.tsfrom the dev server. The fixture now exposesattachmentDraftbound to its session instead.Validation
Cold
page.gototo load, WebKit (WPE), one new browser per run:--repeat-each=10: 60/60 passed. Pinned to 3 CPUs with 3 busy loops,--repeat-each=5: 30/30 passed.--project webkit --grep @media-recorder) can't pass on Linux, because Playwright's Linux WebKit has no MediaRecorder. Before and after this change, those cases fail at the same step locally. Hosted macOS CI is the first real check of the fix.tsc --noEmitis clean, and the flake-check listing test intests/integration/flake-check.test.mjsthat reads this spec passes.test(github): seek the fullscreen PR video without pausing it in the same tick
Problem
WebKit's
github-body.spec.mjs:35"PR media plays under the packaged media policy in a narrow GitHub panel" failed at :189. The fullscreen video'sseekedfired, butcurrentTimewas about 0.5 instead of 15.Cause
The fullscreen
VideoPlayerautoplays and, onloadedmetadata, seeks to the inline video's position. The journey then paused the playing video and setcurrentTimein the same evaluate. There were two candidate mechanisms:pause()on a playing video without updatingcurrentTimeLocal probes support (b). About 0.2% of reloaded samples were unmoved:
seekedfired, butcurrentTimedidn't change. In those samples the restore always came before the journey's seek, andrequestVideoFrameCallbackshowed a frame at 15.2 s whilecurrentTimestill read under 0.2. Across 1,800 reopen samples,loadedmetadatanever landed after the journey's seek.Fix
Before the journey opens the viewer, a MutationObserver pauses the fullscreen video as soon as it mounts, so it never plays. The observer also resolves on the video's first
seeked, which is the restore's. The journey waits for that before its own seek, and no longer callspause()in the seek evaluate. This covers both candidate mechanisms: the video has never played (b), and the restore has already landed (a). The assertion stays exact (toBeCloseTo(…, 1)), and the two inline videos still cover playback.Validation
Reload A/B, WebKit, 30 repeats × 50 samples per mode, 4 CPUs shared with 2 busy loops:
That fits (b), but it isn't statistically conclusive (one-sided p ≈ 0.12).
Reopen A/B: 0/900 unmoved in both modes, and the restore never landed late (0/1,800).
"PR media plays", both engines,
--repeat-each=10: 20/20 passed. WebKit pinned to 4 CPUs with 4 busy loops,--repeat-each=16: 16/16 passed.test(channels): deny the retried preference read so recovery can't race the final Retry
Problem
Found in code review, following up the sidebar-section-creation commit above.
navigation-groups.spec.mjs"Create new retains its draft when preferences fail before submission and recovers in place" injects 502s onsidebar-preferencesand clicks "Retry preferences" twice, the second time right afterpage.unroute. Recovery's next background read can succeed and remove the alert before that click lands. No CI failure of this journey was looked up. The race was found by reading the code and confirmed with a forced delay.Cause
preferences.reload. That cancels recovery's timer but keeps its attempt count, so the next background read is scheduled 2 s or 4 s after the manual Retry fails.Fix
Keep the first failure a 502, so recovery still owns it and shows the "Reconnecting to relay…" notice the journey asserts. From the first manual Retry on, the route answers 403, so recovery stays idle and the final click is the only thing that can recover preferences. The 403s go under
app.report.relayFailures, and the 502 stays undersidebarPreferenceFailures.Validation
page.unrouteand the final click, both engines:--repeat-each=10: 20/20 passed. The full file, both engines: 20/20 passed.f4805605f5:tsc --noEmit,biome check --error-on-warningson the seven changed files andgit diff --checkare clean. Biome's one info-leveluseLiteralKeysnote is on an untouched line that is the same on main.node --test tests/integration/flake-check.test.mjspasses 10/10. In WebKit and Chromium,--workers=4 --retries=0,channel-members-focus,sidebar-section-creation,inbox-archive,github-bodyandnavigation-groupsin full passed 84/84.voice-notes.spec.mjsunderplaywright.ci.config.mjs, Chromium and Linux WebKit: 6/6 passed. The macOS recording lane was not run, as noted above.A green run doesn't prove the flakes are gone. Hosted CI covers broad validation.
Coordination
data-refreshingwait inchannel-members-focus.spec.mjsand conflicts textually with this branch. It duplicates the first commit, so only one of the two should land.c1dcf443) deletesinbox-archive.spec.mjsand moves its draft journeys into a newinbox-mark-read.spec.mjs. There it works around the same reveal-vs-fill race with atypeDrafthelper that refills until the text lands (expect(...).toPass()). Git reports a modify/delete conflict with this branch. A resolver who just takes the delete drops therevealed()fix. Porting the helper intoinbox-mark-read.spec.mjswould replace the retry loop with a real barrier.sidebar-section-creation.spec.mjswas resolved as described in the second commit's section.🤖 Generated with Claude Code