Repository navigation
fix(desktop): fix the product races behind four E2E flakes - #8680
Conversation
🔐 Codex Security Review
Review SummaryOverall Risk: MEDIUM
Findings[MEDIUM]
|
jedwards27
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Reviewed: eda26b9423328a06960bb0d5fd4c1af54600c419..66e4ca3b0da7379c888e67cbac9f7c621aa093b9 (exact head 66e4ca3b0da7379c888e67cbac9f7c621aa093b9)
Risk: high — this changes Desktop timeline ordering/buffering behavior and the regressions are specifically async/UI race conditions.
Behavior/contracts traced: optimistic send acknowledgement identity (localKey), authoritative channel-window head refetch/reconciliation, timeline renderKey projection, unread-tail freeze buffering, relay/bridge/Tauri channel-window ordering, focused Desktop E2E/product-race evidence.
Findings
Blocking:
-
desktop/src/features/messages/lib/channelWindowReconciliation.ts:13-19,:43-60anddesktop/src/features/messages/ui/useBufferedTimelineMessages.ts:30-49can drop an already-visible acknowledged send from the frozen unread-tail view after an authoritative head refetch.Send success preserves the optimistic id as
localKeyon the accepted event (desktop/src/features/messages/hooks.ts:759-762), and timeline rows useevent.localKey ?? event.idas the stablerenderKey(desktop/src/features/messages/lib/formatTimelineMessages.ts:478-481). But a head refetch replaces the page and removes the matching live overlay row by event id (desktop/src/features/messages/lib/channelWindowStore.ts:104-109). The reconciliation path only retains pending messages and non-broadcast thread replies from the prior cache, so an acknowledged top-level message with alocalKeyis not retained. The frozen-tail buffer then looks up frozen rows only byrenderKey; when the authoritative row comes back with only its relay id, the previously frozenlocalKeyno longer matches and the row is treated as gone.User impact: after sending a message, getting the ack, scrolling up, then hitting a subscribe/reconnect/head refetch, that already-visible message can disappear from the frozen reading view and be miscounted as a new hidden tail row until the reader jumps back to bottom. That is directly in the behavior this PR is trying to stabilize.
A temporary exact-head regression row reproduced the failure shape:
selectBufferedTimelineMessages({ frozenRowKeys: ["a", "pending-b", "c"], isAtBottom: false, messages: ["a", "b", "c", "d"], // b is the same acked row after refetch, but no renderKey }) actual: ["a", "c"] expected: ["a", "b", "c"]Author action: preserve acknowledged local identity through authoritative window reconciliation (for example, reapply the prior
localKeyby event id when projecting page rows), or make the frozen snapshot store a stable alias set containing bothrenderKeyand event id. Add a regression covering: optimistic send → ack withlocalKey→ freeze while scrolled up → authoritative refetch returns the same event withoutlocalKey→ frozen view still includes the row and the pending count only counts truly newer arrivals.Verification owner: author owns the fix and regression; reviewer/tooling should re-run the focused buffering/reconciliation tests after the patch.
Non-blocking confidence gap:
- Current non-required Flake Check rollup still has two red Playwright-smoke repeats in
desktop/tests/e2e/video-attachment.spec.ts:856/ line922(inline video hover reveals a timeline without a second play control, controlsydelta1vs<0.5). The changed voice-note test passed immediately before that failure in both jobs, and I am not classifying this as the blocker above. Still, because this PR claims flake cleanup, the refreshed round should either rerun/refresh that evidence or explicitly document why the video hover failures are unrelated/pre-existing. Verification owner: author/CI flake-check evidence.
Validation
Evidence reviewed at exact head 66e4ca3b0da7379c888e67cbac9f7c621aa093b9:
- PASS:
just desktop-typecheck - PASS:
just desktop-check(three existing warnings outside this diff) - PASS: targeted node tests covering
messageQueryKeys,projectChannelWindow,useBufferedTimelineMessages,useThreadReplies, andrelayClientShared(42/42) - PASS: targeted jsdom autocomplete tests (4/4)
- PASS: targeted smoke E2E for the new fast-Enter, autocomplete-cleared-edit, same-second live-order, and delete-while-reading flows (7/7)
- PASS:
pnpm install --frozen-lockfile - PASS:
cd desktop && pnpm test(169 tests) - PASS:
cd desktop && pnpm check(exit 0, warnings noted above) - PASS:
cd desktop && pnpm exec node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/ui/useBufferedTimelineMessages.test.mjs src/features/messages/lib/projectChannelWindow.test.mjs(21 tests) - FAIL as expected with a temporary mutation test for the blocking
localKey/refetch/frozen-tail case described above. - Required checks observed green/skipped for the path selection: Desktop, Desktop Build (macOS), Desktop E2E Integration, Desktop E2E Relay, Desktop Release Candidate, and DCO Check succeeded; Rust/Web/Mobile/Security/relay-only required contexts were skipped by selection.
Manual/native evidence: focused browser/mock-bridge E2E evidence exists for the four changed Desktop journeys; no independent native Desktop app/manual in-app pass was completed in this review. That remains a confidence gap, not the author-actionable blocker.
Residual risk: browser/mock-bridge evidence does not prove native WebView/OS timing. After the code fix, the exact real-app journeys should still be spot-checked before release readiness.
Cleanup: disposable review worktrees/artifacts remain to be swept after the follow-up round/verdict; no source changes from this review are being submitted.
) ## Summary `video-attachment.spec.ts:856` (inline video hover reveals a timeline without a second play control) fails in about 1 of 15 runs. This change fixes the test only. The test reads the inline controls' page `y` at rest and again after hovering, then asserts the two differ by less than 0.5px, so the controls don't slide in. In failing runs the whole message row moves 1px in the timeline between the two reads. The controls stay in the same place relative to their player: ``` pass {"restControls":529,"hoverControls":529,"restPlayer":392,"hoverPlayer":392} fail {"restControls":529,"hoverControls":528,"restPlayer":392,"hoverPlayer":391} ``` Now the test measures the controls' offset from their `video-player` in a single `evaluate`, so it reads both values in the same frame. Moving the row no longer matters, and a real slide-in still fails. ### Why it showed up now On [buzz #8680](#8680), Flake Check failed 6 of 14 Playwright shard runs on this test. That PR edits shared helpers in the same spec file, so `scripts/flake-check.mjs` selects the whole file and repeats every test in it 10 times. The test hadn't changed on main since September. It just had never been repeated before. ## Testing - Reproduced on clean `main` with `playwright test --project=smoke video-attachment -g "inline video hover reveals" --repeat-each 15`: 1 failed, 14 passed. - With this change: `--repeat-each 40` gave 40 passed. The full `video-attachment` spec also passed, 13 of 13. - To confirm the test can still fail, I temporarily added `translate-y-1 group-hover/video:translate-y-0` to the inline controls. The test failed with `Received: 4`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… by keyboard (#8682) ## Summary Test-side fixes for the two largest desktop E2E flake clusters in the 21–28 Oct 2026 CI audit: - **Composer `fill()` races the app's deferred focus-to-end** (§1). Text was appended to the old draft, or a clear did nothing. - **Clicks on the hover-only message action bar while the timeline re-pins** (§3/§8/§18). Reply and more-actions clicks timed out or were intercepted. Two shared helpers replace the per-spec workarounds, and §9 rides along because its spec now imports them. The PR changes test code only. It is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found. #8559, #8622 and #8613 touch these specs or helpers, but had no coverage for these clusters. Draft #8661 edits `empty-edit-delete.spec.ts`'s `submitEmptyEdit` and `beforeEach`, the same lines as this PR. ### New helpers - `tests/helpers/composer.ts`: - `clearComposer` waits for the app's focus, then repeats select-all + Delete through the editor keymap until the editor is empty (250 ms per try, 5 s overall). - `replaceComposerText` clears, then fills. On an empty document, a late collapse to the end is a no-op. - `tests/helpers/channelHeadReady.ts`: - `GENERAL_CHANNEL_ID`. - `openGeneral` clicks `#general`, then calls `waitForMockChannelHeadReady`. - `pressRowAction(button)` does focus + Enter. The action bar also shows on focus-within, and more-actions is a Radix trigger that opens on Enter. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | §1 `inbox-edit.spec.ts:525` | 39641311877, 39821769576, 40333705511, 40348069929, 40388246283 | **Append:** entering edit mode, or closing the non-member mention dialog, focuses the end of the draft in deferred steps: RAF, a nested RAF, then ProseMirror's 20 ms selection resync. `fill()` selects and inserts in separate protocol steps, so a step that lands in between collapses the selection and the text is appended. **Clear does nothing:** `fill("")` presses Delete before ProseMirror handles `selectionchange`, so the editor blocks it. | `clearComposer` / `replaceComposerText` | | §1 `inbox-edit.spec.ts:560` | 39538411386, 39821769576 | Same as above. | Same | | §1 `mention-recipients.spec.ts:591` (both variants) | 39799090750, 40374286094, 39641311877 | Same | Same | | §1 `mention-recipients.spec.ts:691` (both variants) | 40348069929, 40556019361, 39601763602 | Same | Same | | §1 `messaging.spec.ts:3799` | 39827234118, 40445481983 | Same, plus a bare `#general` click and a `force: true` Reply | Same, plus `openGeneral` and `pressRowAction` | | §1 `messaging.spec.ts:4022` | 40389392495 | Same | Same | | §1 `messaging.spec.ts:4289` | 39822883395, 40557808800, 40566841699 | Same | Same | | §1 `messaging.spec.ts:4368` | 40574058918 | Same | Same | | §1 `remote-owned-mentions.spec.ts:463` (both variants) | 39618692657, 40374286094, 40382726903, 40389392495, 40582387515 | Same, after the Invite dialog closes | Same | | §3 `messaging.spec.ts` :3531, :3647, :4122, :4208, :4450 (root and reply) and :4633 (keyboard and button) | :3531 40583830861, 40595483966. :3647 40566841699, 40597484657. :4122 39641311877, 40368315739, 40368363357. :4208 39525092486, 39796569342, 40367495044. :4450 39796569342, 40348069929, 40373404267. :4633 39530486096, 40348069929, 40368315739, 40582387515, 40368363357 | The action bar is `pointer-events-none` until its row is hovered. Opening `#general` refreshes the head after the live subscription lands, and the timeline re-pins, so the row moves out from under the pointer. `force: true` only hid this. | `openGeneral`, then `pressRowAction` on Reply or more-actions. Six more messaging tests that used hover + `click({ force: true })` get the same change, and each first waits for its last row to contain the sent root. | | §18 `thread-follow.spec.ts:350` | 39529963092, 40348069929, 40583830861 (all 3), 40595483966; PR 39541263507 (all 3) | Same; the composer overlay intercepts the more-actions click. | Same | | §8 `thread-head-stale-edit.spec.ts:55` | 39525092486, 39618692657, 39796569342, 39822883395, 40011714848, 40281448810, 40368363357, 40389392495, 40445481983, 40574058918; PR 40031299575 (all 3) | Same (used `force: true`) | Same | | `file-attachment.spec.ts:165` | 1–2 CI failures (audit long tail) | A more-actions click on a moving timeline | Same | | `empty-edit-delete.spec.ts` (`openMoreActionsMenu`, `submitEmptyEdit`, the non-empty edit, `beforeEach`) | Covered under §11 in the desktop-races sibling | The same hover and `fill` races | The head-ready wait before emitting, `pressRowAction`, `clearComposer` and `replaceComposerText`. The autocomplete table rows keep their back-to-back keys, which is what they test. | | §9 `file-attachment.spec.ts:419` "upload progress floats above the dock and lifts Jump to latest" | 10 of 50 main jobs, including 40583830861, 40597484657, 40608383847 | A programmatic `scrollTop` keeps the bottom intent armed. The upload card resizes the content, the timeline re-pins, and the pill unmounts under `boundingBox()`. The fixed 250 ms wait read the card mid-slide. | `scrollTimelineUp()` leaves the bottom with a real wheel, and one `expect.poll` checks all three layout relations. | Local before/after counts are from the original `flakey-tests` commits (e286a6ae0, 816c7c35e, c66f411f3, 67185df6a), running `main`'s copy and the changed copy side by side under forcings: - **§1 at 4× CPU throttle:** 130/130 after; `main`'s copies ranged from 5/10 to 10/10. - **§1 with `selectionchange` delayed, a stretched resync, or a gap inside `fill()`:** `main`'s copies 0/5 to 4/5 and 0/20; after 65/65 and 20/20. - **§3 at 4× throttle:** `main` had 3 Reply-click timeouts at :4208 and 3 at :4633 (keyboard); after 120/120. - **`file-attachment:165`:** 0/3 before, 3/3 after. - **§9 unforced:** 4/5 before, 5/5 after. These were measured with the helper's first version, which waited two frames plus the 20 ms resync. The final state-based helper ran unforced only: 157/157 and then 196/196 over the touched specs. Its retry was not checked under a forced late collapse. :3531, :3647, :4122, :4450 root, :4633 button, `thread-head-stale-edit:55` and the six force-click tests were not force-reproduced; they get the fix that matches their CI signatures and reproduced siblings. Dropped: - `mention-recipients.spec.ts:933` (40608383847) fails on `flavors.handled`, which is unrelated. - `persistent-agent-audience.spec.ts:828` (`@Mor@Morg`) did not reproduce in 79 forced runs. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs typecheck and the unit suites. #8613's flake-check lane will also repeat the changed specs. - Every file except `empty-edit-delete.spec.ts` is byte-identical to its final version on `flakey-tests` (67185df6a). That file carries only this PR's hunks; see "Merge order". - Checked for this PR: `git diff --check`, biome on the partial spec (no changes, the same as on `main`), and a conflict-free merge with all six siblings onto `main`. ### Readiness Test-only change. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order No required order. `desktop/tests/e2e/empty-edit-delete.spec.ts` is also edited by **flakey-tests-desktop-races**, which adds the autocomplete table after line 158. This PR changes imports, helpers, `beforeEach` and line 143. The hunks don't touch, so the two merge cleanly in either order, as checked with `git merge-tree`. Draft #8661 edits the same `submitEmptyEdit`/`beforeEach` lines; whichever lands second will need a rebase. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8681 (desktop E2E state gates). 🤖 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>
## Summary Test-side fixes for ten desktop E2E flake clusters and six long-tail rows from the 21–28 Oct 2026 CI audit. Each spec now waits for the state it assumes (a rendered row, a mounted listener, a finished animation, a held boot read), not a fixed delay or a one-shot probe. Two mock-bridge seams support this. The PR changes test code and mock-bridge seams only, apart from one product constant extracted with its value unchanged. It is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found. - #8622, #8244 and #8386 touch some of these spec files, but not the tests fixed here. - §7 mode A (`navigation:472`) is left to open PR #6852, which already sets the width and measures in one polled evaluate. - Draft #8279 queues cold-launch nostr-bind links in the product. Its mock returns null, so the §12 listener seam is still needed. ### Mock-bridge seams - `__BUZZ_E2E_EVENT_LISTENER_COUNT__(event)` counts live **app** listeners per Tauri event. It wraps the mocked `plugin:event|listen`/`unlisten`. The spec-facing `__TAURI_INTERNALS__.listen` passes a marker target that the wrapper skips, so a spec's own subscription can't satisfy the wait. Tauri 2.11.1's mock ignores targets, so delivery is unchanged. - `deferFirstChannelsRead` arms the existing channels-read hold at boot. `__BUZZ_E2E_RELEASE_CHANNELS_READ__` releases it. - `CREATE_PROJECT_REFOCUS_DELAY_MS` moves to `createProjectFormFocus.ts` with its value unchanged (50 ms). Both `CreateProjectFormContent` and `project-commit-detail.spec` import it. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | §2 `overscroll-boundary.spec.ts:34` (and its sibling) | 39601763602, 40367495044, 40583830861, 40597484657, 40373404267 | The `message-timeline` test id first lands on the loading skeleton, which doesn't overflow, so the scroll lock rightly cancels the wheel. | Wait for a rendered row, and content taller than the viewport, before dispatching wheels. | | §4 `project-conversation-load-failure.spec.ts:73` | 40367495044 (all 3 attempts), 40608383847, 40583830861; PRs 39541263507, 40509460168 | The seeded root and reply were dated `now`. The panel's live REQ (`since = floor(now)`) still replayed the reply, so the error card never appeared. | Date both events a minute in the past, and correct the comment. | | §6 `workflows.spec.ts` `createWorkflow` cluster (:222, :365, :297, :525, :471, :406, :614, :997) | 14 of 50 main jobs; :222 ×4, e.g. 40353619362 | `AnimatePresence mode="wait"` keeps the trigger body mounted through its exit, so "Message text" could fill the trigger's field (:222 and the callers). For :297, `isVisible()` read "closed" one frame before the dialog opened its own channel list. | Wait for the step body's "Step details" control before filling. Create dialogs wait for the list; only the duplicate dialog clicks it. | | §12 `nostr-bind.spec.ts` (:156, :198, :228, :247, :260, :335, :417, :447) | 8 main jobs, each test once | The spec emitted `deep-link-nostr-bind` before the app subscribed, and the mock drops events that have no listener. | Wait for `__BUZZ_E2E_EVENT_LISTENER_COUNT__` to report the app listener before emitting. | | §15 `profile.spec.ts:1128` (integration) | 7 of 50 main jobs | The 320 ms motion-sampling window started before the click. | Start the window when `activeTab` reads "runtime". | | §17 `agent-availability.spec.ts:602`, both shutdown-failure variants (integration) | 6 jobs (4 disconnected-online, 2 failed-online) | The "shutdown refused" toast sits over the Delete row and pauses while the pointer is on it, so the retry click is intercepted. | Assert the toast, then retry with focus + Enter. The toaster has no close button. | | §19 `virtualization.spec.ts:247` "08 — cascading older pages…" | 4 of 50 main jobs; PR 40605736093 (all 3 attempts) | The 120 ms and 400 ms windows were timed from before the wheel bursts, and CDP can deliver wheels late. | Count the wheels the page receives. Each window opens at the first wheel and closes only after all have landed, then waits for still frames. **A second, product-side mode remains** (see below). | | §20 `message-copy-link.spec.ts:160` | 4 of 50 main jobs (5 failed attempts), e.g. 40595483966 | The huddle row reused `"d".repeat(64)`, which is a seeded ordinary `#general` message. | Use ids that no seed uses, and emit only after `waitForMockChannelHeadReady`. | | §21 `navigation.spec.ts:687` | 4 of 50 main jobs | The pending chip's `boundingBox()` was read once, and can be null while the row mounts. | Poll until the chip has a width. | | §23 `sidebar-snapshot.spec.ts:358`, `:388`, `:438` | PR 40427822003 (all 3 attempts); never on main | The 500 ms loading-state budget counted app boot, so a slow runner finished the channels read first. | Hold the first channels read with `deferFirstChannelsRead`, assert the loading state, then release it. | | `project-commit-detail.spec.ts:259` | 1–2 CI failures (audit long tail) | A 50 ms refocus timer can land mid-fill and type the description into the name field. | Once the name field is focused, wait out a page timer of `CREATE_PROJECT_REFOCUS_DELAY_MS`; equal-delay timers fire in order. | | `project-pr-review.spec.ts:3520` | 1–2 CI failures | The same timer pattern in `CreateProjectWorkItemDialog`. | Wait for `toBeFocused()` on the title, which only that timer can satisfy. | | `thread-load-failure.spec.ts:79` | 1–2 CI failures | `openWelcomeThread` branched on a one-shot `count()` and fell back to a hover-only Reply click. | Wait for the summary row, then click it. | | `virtualization.spec.ts:201` | 1–2 CI failures | The drop point sat 2 px above the target's bottom edge, after two fixed frames. | Drop at the centre, after dnd-kit's live region names the target. | | `channel-composer-overflow.spec.ts:217` | 1–2 CI failures | Same-second emits sort by id, and the clearance was checked once. | `createdAt: base + i` and `expect.poll`. | | `scroll-history.spec.ts:1289` | 1–2 CI failures | A fixed 250 ms wait before idle sampling. | Poll for coverage first. | Local before/after counts are from the original `flakey-tests` commits (e286a6ae0, 816c7c35e, 67185df6a). Each used a forcing that made `main`'s copy fail with CI's signature: | Test | Before | After | |---|---|---| | §2 | 3/15 | 15/15 | | §4 | 0/10 | 10/10 | | §6 :222 | 0/3 | 3/3 | | §6 :297 | 0/3 | 3/3 | | §12 | 0/3 | 3/3 | | §15 | 0/3 | 3/3 | | §17 | 1/4 | 4/4 | | §19 late-wheel modes | 0/3 | 3/3 and 2/3 | | §23 | 2/6 | 6/6 | | project-commit-detail | 0/3 | 3/3 | | project-pr-review | 0/3 | 3/3 | §20, §21, `thread-load-failure:79`, `virtualization:201`, `channel-composer-overflow:217` and `scroll-history:1289` were not reproduced; they get the fix that matches their CI signature. Not fixed (follow-up): `virtualization:247` also has a product-side mode. In one frame after a prepend, `scrollHeight` can drop 458–639 px while `scrollTop` moves only 50–114 px, so the anchor row jumps 382–457 px. That is lost anchor compensation, and this test can still fail on it. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs typecheck and the unit suites. #8613's flake-check lane will also repeat the changed specs. - Every file except `virtualization.spec.ts` is byte-identical to its final version on `flakey-tests` (67185df6a). That file carries only this PR's hunks; see "Merge order". - Checked for this PR: `git diff --check`, biome on the partial spec (no changes, the same as on `main`), and a conflict-free merge with all six siblings onto `main`. ### Readiness Test and mock-bridge change; the one product edit is a constant extraction with its value unchanged. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order No required order. `desktop/tests/e2e/virtualization.spec.ts` is also edited by **flakey-tests-desktop-races**, which adds one test after line 929; this PR's hunks are all above line 551. They don't touch, so the two merge cleanly in either order, as checked with `git merge-tree`. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8682 (desktop E2E composer + row actions). 🤖 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> Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
…cap (#8679) ## Summary Fixes the two CI workflow defects that turned `main` red most often in the 21–28 Oct 2026 audit: - **Desktop Tauri:** the lane now caches its apt `.deb`s. It no longer re-downloads 62 MB from a slow Ubuntu mirror on every run. - **Windows Rust:** the lane drops a duplicate `cargo check` and gets a 60 min cap, so a cold run can save its cache. It also updates #8613's `scripts/flake-check.test.mjs`, which breaks once the Tauri package list moves into an env var. There are no product code or test-behaviour changes. This is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found. #8613 touched both workflows after the audit, but not these steps. No open PR changes how the Tauri packages are fetched, the Windows timeout, or its Check (workspace) step: - #8422, #8321 and #8373 only add packages to the list, and would now add them to `TAURI_APT_PACKAGES`. - #8414 adds two mesh-llm Tauri steps to the Windows job. If it lands, they must fit the cold budget below. ### CI failures fixed | Lane / test | Failing runs | Cause | Change | |---|---|---|---| | Desktop Tauri, "Install Tauri dependencies (Linux)" (`_ci-desktop.yml`) | 62 jobs in 51 run attempts, 22–27 Oct. On main: 39821769576, 39822883395, 39827234118, 40333705511, 40342468627, 40348069929, 40353619362, 40368315739, 40373404267, 40374519539, 40556742929 | All 3 `ci-apt-retry.sh` attempts hit their 420 s limit while `azure.archive.ubuntu.com` served the 62 MB at 20–65 KB/s. The 25.5 MB `libwebkit2gtk-4.1-0` alone takes a whole attempt, and each attempt loses its partial download. | The package list moves to a job-level `TAURI_APT_PACKAGES`. A new "Resolve Tauri apt archives" step builds the cache key from `apt-get install --print-uris -o Acquire::ForceHash=SHA256` (name, size and SHA256 per file). `actions/cache/restore` uses that key. The install step copies only the restored files whose SHA256 matches the signed index, because apt 2.8.3 reuses any same-size cached `.deb` without hashing it. A best-effort rebuild keeps exactly the listed files. `actions/cache/save` runs only on core-lane pushes after a successful rebuild; PR runs never save. | | Windows Rust (`_ci-rust.yml`) | All on main: 39796569342 (job 119885795086), 39821769576 (119975358772), 39827234118 (119994286695), 39865872891 (120128351939), 40402556064 (121852634685) | Every test passed. Cold steps take 41.3–43.5 min, so the 45 min cap cancelled the `Swatinem/rust-cache` post-run save 1.6–3.7 min in. No cache was written, and the next push was cold again. | Delete "Check (workspace)". The clippy step before it covers the same `--workspace --all-targets` set with `-D warnings`, which saves 2.0–2.8 min. Raise `timeout-minutes` from 45 to 60, with the measured breakdown in a comment: 39–41 min of steps, a ~4.5 min save and ~15 min of margin. | | `scripts/flake-check.test.mjs` "repeat invocations mirror the lanes they stand in for" | None in CI yet: it fails only with this change applied | #8613 added this test after `flakey-tests` branched. It checks that the advisory `flake-tauri` lane installs the same packages as the Desktop Tauri core lane, by regex-matching the inline `apt-get install -y --no-install-recommends \` list in `_ci-desktop.yml`. That list now lives in `TAURI_APT_PACKAGES`, so the regex matched nothing and the test failed with "Desktop Tauri apt packages". | Read `TAURI_APT_PACKAGES` from `_ci-desktop.yml` and compare package names (12 on each side) with `_ci-flake.yml`'s list. Renaming one package in `_ci-flake.yml` still fails the test. Giving the `flake-tauri` lane the same read-only `.deb` restore is a follow-up. | Local evidence is from the original `flakey-tests` commit (9aa768a3b): - The apt steps ran verbatim in Ubuntu 24.04 containers with apt 2.8.3, with the cache restore and the save step's `if:` emulated. - On a miss, the install fetches everything ("Need to get 254 MB"). On an exact hit with the network down, it fetches nothing ("Need to get 0 B/254 MB"). - On a fallback with one `.deb` missing and one corrupted at the same size, the hash check rejects the corrupt file, and apt re-fetches only the corrupt and missing files. - The key was identical in all four simulated jobs. - The Windows numbers come from the jobs API for the five runs above. On this branch, the split session ran the `scripts/` node tests: `ci-selection`, `ci-required-gates`, `ci-apt-retry` and `flake-check` on macOS gave 84 pass, 0 fail and 8 skipped (Linux-only). Before the `flake-check.test.mjs` fix, that test failed 1 case. The same session ran actionlint 1.7.12 with shellcheck, which was clean, and the rust-cache contract scripts, which passed. ### Verification This branch was pushed without further local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify` over SSH. The pre-push hook runs tests, and the HTTPS token lacks the `workflow` scope. Only hosted CI can show: - The runner's real package list (about 62 MB) and the same key in all three Tauri lanes. - The `actions/cache` round trip. The first PR run misses, because `main` has no key yet. After merge, the first push to `main` saves it. - A cold Windows run saving its cache within the new cap. The next toolchain bump or cache eviction will show it. Remaining risk: after Ubuntu updates a large package, runs fetch it from the mirror until a push to `main` saves a new key. ### Readiness CI-only change. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order Independent of the other six. It merges cleanly onto `main`, and with every sibling, in any order. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8680 (desktop product races), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions). 🤖 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>
…#8677) ## Summary Fixes two CI flakes from the 21–28 Oct 2026 audit, both in test code with no product change: - The relay E2E helper `collect_until_eose` now drops repeat copies of an event id. - Two Windows shell tests drop a 5 s cap that a cold Git Bash start can exceed. This is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found for these two. The buzz-terminal `passwd_shell()` `getpwuid` race (`env_fence_tests::resolve_shell_falls_through_to_passwd_not_the_default`, run 38590095289) is left to open PR #7687, which already replaces `getpwuid`. #8636 and #7976 edit `buzz-test-client/src/lib.rs`, and #7607 and #7915 edit `buzz-dev-mcp/src/shell.rs`, but none of them changes `collect_until_eose` or these timeouts. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | Relay E2E `e2e_project::test_project_tombstone_deletes_coordinate_and_spares_members` (e2e_project.rs:338, "left: 2 right: 1") | 40408678412#1, job 121879630394 (PR #8620, schema-only) | The EVENT handler sends OK, then fans out in a spawned task. `handle_req` registers the live subscription before its history query, and `req.rs`'s `seen_ids` dedups history rows only. If fan-out runs after the REQ is registered, the event arrives once live and once from history, both before EOSE, and `collect_until_eose` kept every frame. | `collect_until_eose` keeps the first copy of each event id, as production clients do. Its doc comment says a test of the relay's own dedup must read frames with `recv_event`. A new unit test, `collect_until_eose_keeps_one_copy_of_each_event_id`, uses a fake relay that sends the duplicate. | | Windows `buzz-dev-mcp` `shell::tests::basic_echo` (shell.rs:1026, exit 124) | 39675698081#1, job 119485363399 (green on re-run) | 124 is the shell tool's own timeout exit code. `basic_echo` capped the shell at 5 s, and it is one of the first tests in the process to start Git Bash, which can take longer than 5 s on a cold runner. | `timeout_ms: None` (the production 120 s default) in `basic_echo` and in `workdir_is_honored`, which has the same exposure. `timeout_fires` still covers timeouts. | Pre-check: no e2e test relies on the relay's own history dedup. Every exact-count assertion uses a single filter. The NIP-33 "only newest remains" assertions would see a failed replacement as two different ids, and this dedup keeps both. Local evidence is from the original `flakey-tests` commit (fc4518d39): - The new unit test: FAIL before, PASS after. - 13 of the 14 e2e files that call the helper, plus `e2e_thread_roots`, against an isolated local relay. All pass, except `e2e_relay` `test_unarchive_emits_member_added_notification`. That test fails by itself 3/5 with the original helper too, and CI's Relay E2E lane doesn't select it. - The Windows failure can't be reproduced on macOS. The Windows Rust lane is the check. Follow-up (out of scope): make the relay deliver each event id at most once per subscription, keeping the live/history overlap. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs clippy and unit tests. - Both files are byte-identical to their final versions on `flakey-tests` (67185df6a). - Checked for this PR: `git diff --check`, `rustfmt --check`, and a conflict-free merge with all six siblings onto `main`. ### Readiness Test-only change. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order Independent of the other six. It merges cleanly onto `main`, and with every sibling, in any order. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions). 🤖 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>
#8676) ## Summary When the user-deletion worker dropped a lease heartbeat, its pooled connection could be left inside an open transaction. The worker's final lease `release` then reported success but never committed, and a rerun saw the request as "leased by another worker" for up to 60 s. The heartbeat is now one autocommit `UPDATE`. The PR also fixes six PostgreSQL-lane test flakes from the 21–28 Oct 2026 CI audit. It moves one relay test off the 100 ms-acquire test helper before that test starts flaking the same way. This is one of seven PRs split out of the `flakey-tests` branch (siblings below). It touches only `buzz-db`, `buzz-deletion` and `buzz-relay`. Every relay file except `connection.rs` and `boot_lifecycle.rs` changes only for the helper rename in the last row. ### Related issue None found. Nothing on `main` (eda26b9) or in an open PR covers these. G, a single hermit `pgschema` 504 that needs a workflow change, is out of scope. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | `buzz-deletion` `user::worker::postgres_tests::dummy_executor_failure_retries_same_community_stage` (:47) and `next_step_advances_one_handler_at_a_time_through_completion` (:131) | 40439041234 (main), 40399635380#3, 40413732817#1 | **Product bug.** `run_stage_with_heartbeat` drops the renewal future when the stage handler wins `select!`. The heartbeat opened a transaction. sqlx 0.9 bumps `transaction_depth` only after `BEGIN` returns, so a `begin()` dropped mid-`BEGIN` queues no `ROLLBACK`, and the connection goes back to the pool inside an open transaction. The worker's autocommit `release` then ran inside it and never committed. | `EntityDeletionStore::heartbeat` is one autocommit `UPDATE`, guarded by `lock_owned`'s ownership/status/expiry predicate. A superseded lease is never renewed. A new regression test, `dropped_heartbeat_leaves_no_open_transaction_for_the_release`, drops the real heartbeat mid-`BEGIN` through a pausing proxy. | | `buzz-db` `session_statement_timeouts_bound_writer_and_reader_sessions` (runtime/tests.rs:3138, `PoolTimedOut`) | 40128463987, 40314950864#1, 40374286094 (main), 40394821009#1, 40413732817#1 | Each config's lazy reader pool dialled cold inside the 150 ms `READER_ACQUIRE_TIMEOUT`. `Db::new` never runs the boot ping. | Warm the reader the way the relay boots it (`read_pool_boot_ping_for_tests` + `await_idle_floor`). | | `buzz-relay` `startup_owns_the_dependency_sampler` (boot_lifecycle.rs) | Torn scrape: 39865872891 (main), 39624074244#2, 40385799750#1, 40413293801#1. Early exit: 39704588623 | metrics-exporter-prometheus 0.18.3 snapshots counters, then gauges. The scrape that first shows the completion gauge can therefore predate the `checks_total` counters. | Assert on a second scrape, taken after the gauge is seen. On an early relay exit, the helper now panics with the exit status, stdout and stderr. The early exit itself was not reproduced. | | `buzz-db` `sample_writer_waits_for_shared_floor_lock_before_sampling_time` (replica_fence.rs:1086) and `replica_floor_probe_waits_for_shared_writer_and_records_after_release` (runtime/tests.rs:3452) | 39601763602 (main), 40391445284#1, 39825846222, 40301667418 | Both scan cluster-wide `pg_stat_activity` but ran outside the serialized `postgres-cluster-global` group. Connection churn from other tests masked activity (`MaskedActivity { masked: 1 }`). | Rename both with a `cluster_global_` prefix. `nextest show-config test-groups --profile postgres-ci` lists both in the group. | | `buzz-db` `stalled_reader_sessions_release_their_slots_for_new_connections` (reader_pool_postgres_tests.rs:354) | 40399635380#2 | The floor warms one connection, so the second warm-up acquire dials cold inside 150 ms. | Retry only that warm-up acquire on `PoolTimedOut`, for up to 5 s (#8472's precedent). The stall under test keeps the production budget. | | `buzz-db` `readiness_check_cancellation_balances_waiter_and_inflight_connection` (runtime/tests.rs:391, `Elapsed`) | 39785370353 | The test aborted `pg_sleep(5)` after a fixed 25 ms. Recovery lands at server start + 5 s, but the bound is abort + 5 s, a ~22 ms margin locally. | Poll `pg_stat_activity` until the query is active, then abort. Use `pg_sleep(1)` within the unchanged 5 s bound. | | `buzz-relay` `upgrade_would_deny_line_is_written_at_auth_while_open` (connection.rs) | None yet (preventive, added by #8634) | It used the 100 ms-acquire `test_state_with_database_url` against the real test database, the same shape as the `PoolTimedOut` flakes above. | Use `test_state_with_database_url_and_pool_options` with sqlx's default timeout. The 100 ms helper is renamed `test_state_with_unreachable_database_url`; all its remaining callers use closed ports. | Local before/after counts are from the original `flakey-tests` commit (1f0451915), on macOS with OrbStack Postgres: - Regression test: 3/3 FAIL before, 3/3 PASS after. - Worker stress: 3 failures in 288 iterations before; 2,100 passing runs after. - Statement timeouts: 44/50 FAIL before, 50/50 PASS after. - Sampler: 2/200 FAIL before, 200/200 PASS after. - `MaskedActivity` under `pgbench -C`: 2/50 iterations FAIL before. After, the lane passed 3× (613/613) but was not re-run under pgbench. - Stalled reader: 10/30 FAIL before, 30/30 PASS after. - Forced late-`Execute` cancellation: 10/10 FAIL before, 10/10 PASS after. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs clippy and unit tests. - Every file on this branch is byte-identical to its final version on `flakey-tests` (67185df6a), where the counts above were measured. - Not re-run for this PR: clippy, nextest and the Postgres lanes. - Checked for this PR: `git diff --check`, `rustfmt --check` on the changed files, and a conflict-free merge with all six siblings onto `main`. ### Readiness This PR has a product fix (the `buzz-db` lease heartbeat). Under AGENTS.md "Before opening a PR", it needs an agent review and a **human check against a local relay** before `buzz-review-completed` is added. Suggested check: 1. With local Postgres and a relay, submit and approve a user deletion with `buzz-admin user-deletions submit` / `approve`. 2. Run `buzz-admin user-deletions run` until it completes, then run it again at once. 3. Working looks like this: both runs complete, neither reports the request as leased by another worker, and `buzz-admin user-deletions inspect <id>` shows no leftover lease. The original race is rare and is not expected to reproduce by hand; this check confirms releases still commit. ### Merge order Independent of the other six. It merges cleanly onto `main`, and with every sibling, in any order. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions). 🤖 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>
Product changes behind four desktop E2E flakes from the 21-28 Oct 2026 CI audit, each with a regression test. Before editing I checked origin/main and the open and merged PRs. None fixed these; draft #8559 does not fix the voice-note flake. Since then, draft #8661 (Refs #6733) closes the #channel list synchronously when an edit is cleared. §11 below covers that case and the @mention and :emoji lists too, so the two overlap in useChannelLinks.ts and empty-edit-delete.spec.ts. §5 Channel-browser Enter acts on the deferred list - The list renders from useDeferredValue(query). An Enter that landed before the deferred render read the old list, so it opened a stale match or skipped the create row. - While the deferred query lags, Enter now filters the live query with the same selectBrowserChannels the list renders from. - channel-browser.spec: two forced variants dispatch the input and Enter in one task, one creating a new name and one opening the typed match. Before 0/20, after 20/20 (10 repeats each). §11 A stale autocomplete swallows Enter (empty-edit-delete, #6733) - Clearing the draft went through the 120 ms query debounce. The #channel list stayed open, so Enter re-inserted the suggestion instead of submitting the empty edit. - useChannelLinks, useEmojiAutocomplete and useMentions (typed @ only) now close as soon as the trigger is gone. Typing a query still debounces. - empty-edit-delete.spec: one table over #channel, @mention and :emoji clears an open autocomplete and presses Enter at once. Before 0/30, after 30/30 (10 repeats per row). - autocompleteTriggerClose.jsdom-test: the channel and emoji hooks close synchronously and leave Enter unhandled. Before 0/2, after 2/2. - ChannelAutocomplete gains data-testid="channel-autocomplete" so the test can wait for it. §10 One chronological order for same-second events - The live cache merge (sortMessages, ascending id within a second) and the channel-window projection (descending id) disagreed. Two same-second rows therefore swapped on screen as each writer landed. - shared/lib/eventOrder.ts now holds compareRelayOrder and its exact reverse, compareChronologicalOrder. sortMessages and the window and reconciliation sorts all use compareChronologicalOrder. - The window's descending id is kept, so an older page fetched from a mid-second cursor still lands contiguously above the rows shown. The window's order is unchanged; only sortMessages moved. - sortEvents keeps ascending id. It resolves raw history REQs, timeline consumers re-sort its output, and project issues replay same-second assignment operations in its order. - projectChannelWindow.test: in both arrival orders, the cache merge and the window projection agree on same-second order. Fails before. - channel-dense-second-reach.spec: a RAF sampler sees a single order for two same-second live rows. Before 0/10 (swapped on every run), after 10/10. - Thread replies: loadThreadReplies stores sortMessages([...replies, ...receivedInFlight]) and appendMessage merges a live reply through mergeMessages, which also re-sorts with sortMessages, so the two writers already agree. A useThreadReplies.test row pins it: two same-second replies fetched in ascending id order, then one later live reply; the fetched pair keeps its order. Storing the pages unsorted makes it fail. - Paging still holds: channel-dense-second-reach, channel-window-mock-paging, scroll-history and virtualization (including "08 - cascading older pages") all pass. - Test fixes with the same cause: video-attachment.spec:1469 gives its three messages distinct seconds (5/5). For voice-note.spec:906, its two messages get distinct seconds (10/10), and it finds each card through its own message row rather than by index. §13 Unread pill reads "Jump to latest" instead of "1 new message" - Instrumenting stream.spec:477 against a local relay found frozen rows coming back reordered in 7 of 12 runs (5 of the 12 failed). A freeze-lag hypothesis did not reproduce (forced mock variants passed 5/5 before), so there is no data-tail-frozen gate and stream.spec is unchanged. - The selector ended older history at the snapshot's first id, so rows that had moved ahead of it were prepended and also kept in the frozen tail. The duplicates filled out the row count, pendingCount fell to 0, and the label fell back to "Jump to latest". §10's comparator alone stops the reordering here. - Deleting a frozen row also dropped the whole freeze (forced variant: delete a frozen row, then emit; before 0/5). - useBufferedTimelineMessages now keys rows by renderKey, which is stable across an optimistic send and its ack (the ref is frozenRowKeys, since it holds renderKey ?? id, not event ids). It drops only deleted frozen rows, and it ends older history at the first frozen row of any key. The last change matters even with §10: an ack changes the row's id, so it can still move among same-second rows. - useBufferedTimelineMessages.test: deletion, reorder and ack rows. Before 4/7, after 7/7. - virtualization.spec: deleting a row while reading keeps the next arrival counted. Before 0/10, after 10/10. - Integration lane (isolated relay on :3017, own databases and redis db): stream:477 before 25/30, all 5 failures "Jump to latest"; with all fixes 30/30 before the reorder change, then 20/20 and 10/10 after it. The full stream.spec passed 10/10 on a fresh relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Closing the moment an update carried no trigger broke selecting a suggestion with Enter. With the menu open, ProseMirror splits the paragraph before the composer's keydown handler runs. That update has no trigger, so the menu closed and the handler never selected the suggestion, which left "@bo" or "Ask @fifor a hand" in about 25 smoke specs. Close immediately only when the draft is cleared, the stale-Enter case this fix targets, and keep the debounce for every other update. Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
An acknowledged send keeps its optimistic id as localKey, and its row renders under that key. A head refetch (replaceNewestChannelWindow) or a live echo after the ack (mergeLiveChannelWindowEvent) brings the same event back without one, and reconcileChannelWindowMessages kept only pending sends and thread replies from the cache. The row's renderKey then flipped to the relay id. Its React key changed, so the row remounted, and a frozen unread tail treated it as deleted: it hid the row and counted it as a new arrival. reconcileChannelWindowMessages now carries a cached localKey over to the window event with the same id. Every timeline projection goes through it, so the key holds for a head refetch, a live echo, an older page and a persisted-head seed alike. Matching the frozen tail on both keys instead would fix only the pill and leave the remount. useBufferedTimelineMessages.jsdom-test: send, ack with localKey, freeze while scrolled up, then the event returns without localKey, once per path (head refetch, live echo), then a newer arrival. The acked row stays in the frozen view and pendingCount is 1. Before 0/2, after 2/2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
66e4ca3 to
eb88a98
Compare
Flake Check run 40973558941 failed "08 — cascading older pages never snap the viewport toward newest" at `wheelTrace.received`: expected 4, received 1. This branch does not cause it. The deep-history mock dates every row in its own second, so the same-second ordering change cannot reorder this channel, and a pure prepend buffers the same before and after the frozen-tail change. The assertion came in with #8681 about 30 minutes before the run. Main had run it 4 times since; this branch repeats the test only because it adds one to the file. Cause: setting scrollTop to 180 crosses the start threshold, so the older page is fetched before any wheel is sent, and the 300 ms mock delay runs out during the burst. The settle gate admits a held page after 100 ms with no wheel or scroll, so a runner that pauses that long between wheels lets the page commit mid-burst. The trace stopped at the commit, and its count of 4 was a race against the timing. Chromium does not coalesce these wheels. The timeline's wheel listeners are non-passive and Playwright awaits each ack, so a standalone page with the same listeners saw all four as separate events even under 150 ms and 300 ms main-thread stalls. Fix: a mock-bridge hold, `__BUZZ_E2E_HOLD_CHANNEL_WINDOW_PAGES__`, on the `__BUZZ_E2E_HOLD_USERS_BATCH__` pattern. Test 08 holds each page before the crossing and drops the 300 ms delay and the early break. The motion trace releases the page once it is sampling, and asserts that exactly one page was held. The anchor baseline therefore always comes before the commit, where before it could come after an early commit. Forced variant, a 400 ms pause after the first wheel of page 0: before, fails with CI's signature (expected 4, received 1); after, passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Flake Check run 40978030438 failed two tests this PR changes or adds.
video-attachment "right-click menus expose distinct selectors": the
off-relay right-click opened no menu. A live row reaches the timeline
floor in two scroll steps. The first, to the virtualizer's estimated
floor, leaves the new video row visible but hanging under the composer
overlay (row 506-686, composer from 587). The second, to the measured
floor, settles it at 392. A force right-click taken between the two
computed its point under the composer. The contextmenu hit the composer
form and the pointer stayed over it, matching the CI artifacts: no menu
in any frame, no hover controls on either video. force also skipped the
hit-target check that would have caught this. Both video right-clicks
now wait for the player to clear the composer, then go through
Playwright's actionability checks. The wait matters too: after a failed
hit check Playwright scrolls the row itself, and that trailing scroll
can dismiss the menu it just opened.
Forced variant, ResizeObserver delivery delayed 400 ms for the
off-relay arrival: before, 2/2 fail with CI's signature (contextmenu at
547,596 on FORM.message-composer); after, 3/3 pass, clicking at the
settled centre with no scroll after it.
channel-dense-second-reach "same-second live messages": the sampler
read [] while the DOM held both rows in the right order. It sampled on
animation frames, but the rows commit in a task, so a read that beat
the next frame saw nothing. A MutationObserver now records the pair's
order after every commit, before any later task can read it. That also
catches a swap that reverts within one frame. Forced variant, reading
in the commit's own task: the old sampler returns [] 3/3, the observer
the expected order 3/3. Reverting sortMessages to ascending id within a
second still fails the test 3/3.
Audit of this PR's other added or changed E2E tests:
- virtualization "deleting a row while reading": it emitted its
deletion and live arrival without waiting for live admission, which
starts only after the first window renders. A probe found no live
subscription right after the click, and no post-admission head
refresh yet when the test scrolled up. It now opens #general through
openGeneral, like its siblings. With a deleted frozen row dropping
the whole freeze again, it still fails 2/2 ("Jump to latest").
- channel-browser forced-Enter variants, the empty-edit-delete
autocomplete table and voice-note:906: no timing hazard. The first
is ordered by React's microtask commit; the others wait on visible
state or use non-force clicks.
Ran: pnpm build:e2e, tsc, biome on the three specs, and the fixed tests
on the real build (right-click 5/5, same-second 8/8, deletion 4/4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…#8678) ## Summary Fixes three mobile flakes from the 21–28 Oct 2026 CI runs, in test code only: - In two iOS `RunnerTests` classes, late bridge callbacks no longer fail whichever test is running when they fire. - The waits on a busy test-host main thread now cap at 10 s. - Two Flutter avatar tests poll for the ready frame instead of sleeping a fixed 50 ms. This is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | `BuzzPushSnapshotEnrichmentTests` (RunnerTests) | 40362671989 (job 121712747802, main), 39681273328#1 (job 119504560518), 40658588384 (job 122715950541, PR #8640) | The 1 s waits ran out while the test host's Flutter app held the main thread, and the test returned and deleted its temp directory. The bridge callback's assertions then ran later and were blamed on whichever test was running (`ProfileSetupRecoveryTests`, `IdentityFilePluginTests`). | Callbacks only record the value and fulfill. Every assertion, including the `isRestricted` checks, runs after the wait, so a late callback writes into a dead local. The seven waits cap at 10 s (`callbackTimeout`). | | `ProfileSetupRecoveryTests` `testStartedEditorWithoutReadyRestoresProfileAndAllowsRetry` | 40585805357 (job 122477274956, PR #8568) | Engine release and editor dismissal must both happen within 2 s, on the main thread the host app keeps busy. | Both predicate waits cap at 10 s. | | Flutter `channel_detail_page_test.dart` "profile sheet shows the poster before autoplay and restores it on tap" and `settings_profile_header_test.dart` "shows the poster until the animated avatar is ready" | 40368315739 (job 121732711851, main) | After the image response is released, both tests slept a fixed 50 ms of real time before `pumpAndSettle`. A slower real decode left no frame scheduled, so the ready key was missing. | A new `WidgetHelpers.pumpUntilFound` pumps in 10 ms real-time steps until the ready key appears, capped at about 5 s. | Local evidence is from the original `flakey-tests` commit (53aaec97c), Xcode 27.1 on the iOS 26.2 simulator: - Enrichment, with `Self.complete` delayed 1.5 s and 4 s: 6 failures before, 107/107 after. With a 12 s delay, every failure lands in its owning test. A mutated error code is still caught. - Profile setup, with the engine release held 4 s: 2 failures before, 4/4 after. A leaked engine still fails. - Avatar tests: under load, 94/96 before and 96/96 after. With a 200 ms image delay, 0/5 before and 5/5 after. A widget that never signals ready still fails. Not included: - The planned `AppDelegate` / `Runner.xcscheme` change that keeps Flutter out of the RunnerTests host. It did not work locally: restored scene sessions and the `UIMainStoryboardFile` fallback, and a normal launch came up black afterwards. - `IdentityFilePluginTests` `testSyntheticDocumentsReadInPlaceAndRejectInvalidOrOversize` (40441255307#1). It is left unchanged and stays open. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs the Flutter tests. - Every file is byte-identical to its final version on `flakey-tests` (67185df6a). - Checked for this PR: `git diff --check`, and a conflict-free merge with all six siblings onto `main`. - #8613's flake-check lane will repeat the two changed Dart test files. ### Readiness Test-only change. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order Independent of the other six. It merges cleanly onto `main`, and with every sibling, in any order. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions). 🤖 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> Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
jedwards27
left a comment
There was a problem hiding this comment.
🤖 :bot: Jude's code review agent — APPROVED at head a3f579cd1ce9766219ba884ba6ed9a18694bb426.
I reviewed the fresh-head delta from 0c73e9eefbb93bdf0877f319f1960edab3118180 to a3f579cd1ce9766219ba884ba6ed9a18694bb426 and reconciled it with the prior full review. The delta is limited to the three E2E spec files:
desktop/tests/e2e/channel-dense-second-reach.spec.tsdesktop/tests/e2e/video-attachment.spec.tsdesktop/tests/e2e/virtualization.spec.ts
No production desktop, Tauri/native, relay, persistence, cache, or schema code changed in this delta. I found no author-actionable defects.
Key checks:
- Same-second ordering test still asserts stable visible order and now observes committed DOM order via
MutationObserver, which strengthens rather than weakens the race check. - Video context-menu test now waits for the actual video hit target and right-clicks without
force, while preserving relay/off-relay menu assertions. - Delete-while-reading test now opens
#generalthrough the live-admission/head-refresh helper before exercising the reading-state delete/live-arrival behavior. git diff --check 0c73e9eefbb93bdf0877f319f1960edab3118180..HEAD -- desktop/tests/e2e/channel-dense-second-reach.spec.ts desktop/tests/e2e/video-attachment.spec.ts desktop/tests/e2e/virtualization.spec.tspassed locally.- Live PR state immediately before this review: head
a3f579cd1ce9766219ba884ba6ed9a18694bb426, baseef4130da4bfeea9fe37477d621166675e0ab3ef7; CI rollup is 51 SUCCESS / 31 SKIPPED with no failing/pending checks. Required desktop/DCO gates are green; unrelated path-selected gates are skipped. - Authenticated reviewer is
jedwards27; PR author ismatt2e.
Residual verification gap: I did not perform native/manual in-app hardware testing for this test-only delta. That is not author rework and should be owned by the usual release/readiness verifier if a final app-readiness claim is needed.
Verdict: APPROVE.
…vent-guards * origin/main: (29 commits) Restyle community invitations (#8728) feat(relay): add incremental artifact reads (#8523) fix(mobile): show agent DM typing and balance reply bubble padding (#8731) perf(relay): route author- and #p-scoped global subscriptions by selector (#8666) fix(desktop): fix the product races behind four E2E flakes (#8680) test(scripts): stop file-size fixtures leaking git repos and fsmonitor daemons (#8727) test(mobile): keep late callbacks and fixed sleeps from failing tests (#8678) fix(mobile): resolve iOS pushes on a fresh connection and retry a stale one (#8722) ci(loadgen): publish buzz-loadgen as ghcr.io/block/buzz-loadgen (#8717) test(relay): fix main's build after the test-state helper rename (#8718) fix(db): keep lease releases durable and deflake PostgreSQL lane tests (#8676) test: dedupe collect_until_eose by event id and uncap two shell tests (#8677) ci: cache the Tauri apt .debs and fit a cold Windows Rust run in its cap (#8679) test(desktop): gate flaky E2E specs on the state they assume (#8681) test(desktop): settle composer edits and press hover-only row actions by keyboard (#8682) test(desktop): measure inline video controls against their player (#8683) relay: open the search pool lazily so a dead reader can't block boot (#8662) fix(mobile): address #8650 second-pass review (#8691) Revert "fix(mobile): push every message in your own Solo chats (#8615)" (#8690) fix: don't notify workflow owners through their attribution tag (#8694) ... Signed-off-by: Ravneet Arora <rarora@squareup.com>
Units 6-9 of the 23-30 Oct desktop Playwright flake audit. All are test-only. origin/main (1eb67bb, now including #8680) hasn't touched these specs since fae3f03. No open PR touches the tests changed here: #6134 (conflicting since 10-19) only swaps page.goto calls in three of these files, #8187's hunk starts just after workflow-local-controls:151, #5778 (draft, conflicting) creates projects-v3-screenshots from an older base, and #8622, #7452, #7812, #8244 and #3342 edit other tests in the same files. Unit 6: measuring before an animation finishes onboarding.spec.ts:1487, "hosted community address line stays within the card for a long name" Run: #8715 (Integration shard 1), which doesn't touch onboarding. Signature: the centring check at :1545 expects <= 2 and receives 5.13. Cause: "Connect and continue" ends in showPage("owned") (WelcomeSetup.tsx:323), which mounts an OnboardingSlideTransition. Its content slides in from translateX(48px) over 650 ms (components.css:521, :676). fill() doesn't wait for stability, and the surface, input and suffix boxes come from three boundingBox round trips, so they can be read at different points of the slide. Change: waitForAnimations(page) after the fill, before measuring. onboarding.spec.ts:2289, "connected first-community profile keeps navigation inside the card and balances the avatar editor" Runs: main 40746677244 and 40925172215, and #8666 (Integration shard 1). Signature: the spacing check at :2485 expects <= 4 and receives 4.255. Cause: the avatar dialog opens with MODAL_CONTENT_MOTION_CLASS, a 200 ms zoom-in-95 (modalMotion.ts:5), and has a 250 ms height transition. dialogBox is read in one round trip and the preview and editor boxes in a later one, so a scale change between them skews the gap comparison. Change: waitForAnimations(page) once the dialog is visible. projects-v3-screenshots.spec.ts:280, "projects v3 workspace screenshot states", failing in the expectInsetSection call at :893 Runs: main 39643531998, 40759209995 and 40969909906 (Smoke shard 8), each passing on retry. Signature: the header's right inset at :326 expects 16 and receives 8. Four more runs fail the menu edge check at :328 with fractional values (expected 984, received 975.6875): 39622053330, 39641311877, 40281448810 and 40856574572. Cause: switching the context rail back from chat to repository narrows it from the thread panel's 380 px to the repository's 280 px (useProjectPanelWidths.ts:5, :17), animated by transition-[width] duration-200 (ProjectContextRail.tsx:31). The workspace widens with it, and expectInsetSection reads the workspace, header and menu boxes in three separate round trips. The fractional widths are mid-transition values. Change: waitForAnimations(page) before that call, the only one that follows a rail width change. Unit 7: fill() racing a caret move the app makes a frame later mention-recipients.spec.ts:376, "editing to a longer typed member drops the original shorter reference", and :97, "ambiguous added mention blocks editing before clearing the draft" Runs: main 40969965981 (Smoke shard 5, a run with every earlier fix) for :376. 40969858676 for :97, before the fixes. Signature: the edit_message poll at :428 receives null. The error context shows the edited message as "@scout hello @scout Jones hello": fill() appended. Cause: entering edit mode sets the content, then focuses its end in a requestAnimationFrame (MessageComposer.tsx:413). toHaveText passes as soon as the content lands, before that frame. If the frame lands between fill()'s select-all and its typing, the selection collapses and the text is appended. #8682's replaceComposerText handles this, but these two call sites (:115 and :403) were missed. Change: replaceComposerText at both. workflow-local-controls.spec.ts:151, "inserts template variables with keyboard control and restores the caret" Runs: main 40926393897 and 40957616146 (before the fixes), and #8239, which changes no desktop files. Signature: :194 expects "Keep {{auth" and receives "Hello {{trigger.text}}Keep {{auth". Cause: selectVariable restores the caret in a requestAnimationFrame (WorkflowTemplateTextarea.tsx:66). Setting the controlled value has already put the caret at the end, so the selectionStart poll passes before that frame runs. The frame then collapses fill()'s select-all. Change: after the poll, wait two animation frames, then fill. This is a frame barrier, not a timer. The app queued its frame before the evaluate, so it runs first. Unit 8: clicks that hit the wrong element profile.spec.ts:1128, "renders agent profile ingress subviews from the Playwright mock bridge", failing at :1543 Run: #8523, which changes no desktop files. Its base (ef4130d) already includes #8681. Signature: archiveAgentRow.click() times out because three stacked Sonner success toasts (data-expanded="true") intercept the pointer. Cause: the test's Stop and Start steps raise success toasts (useAgentLifecycleActions.ts:51, :61). Sonner stacks them bottom right, over the profile panel's action rows, and pauses their dismissal while the pointer is over them. #8681 fixed this test's motion window, and fixed the toast problem only in agent-availability:602. Change: press the five action rows (:1528-:1547) through a local pressActionRow helper: toBeEnabled, focus, Enter, as agent-availability.spec.ts:744-745 does. toBeEnabled keeps the wait click() gave, since these rows are disabled while a delete or archive is pending. config-bridge-screenshots.spec.ts:314, "07 — profile side panel — Configuration section" Runs: main 40967147375 (before the fixes), and #8235, which changes no desktop files. Signature: "Model" isn't visible within 10 s at :352. The error context shows nadia's panel ("Not managed on this device"), not Charlie's. Cause: the spec clicked .last() of the rows with a [data-testid^="message-avatar-"] descendant. That matches only UserAvatar's -image and -fallback children. Radix renders the image only once it loads, and each fallback only after its own 200 ms delayMs timer (UserAvatar.tsx:67, :119). Neither avatar in #agents has a URL, so the matching set fills in row by row as the timers fire. Resolved in between, .last() is nadia's row (seeded at -85 s), just above Charlie's "reporting in." row (-80 s). The note blamed a misplaced click; the code points at locator resolution instead. Change: pick Charlie's row by its text, as profile.spec.ts does for Charlie's other #agents message. Then check the panel's name row reads charlie before opening Runtime, so a wrong panel fails there and says so. Unit 9: the createWorkflow copies (preventive) navigation.spec.ts:153, "direct workflow detail links close back to workflows" Runs: 7 failures on main, the last at 29 Oct 16:12Z, none after #8681. Signature: the Edit workflow dialog has no "Trigger: Message Posted" at :176. Runs: 14 first-attempt failures in Smoke shard 7, all passing on retry; on main 39827234118, 40718018673, 40789486699, 40802051064, 40840949170, 40891643205 and 40909291143. Cause: the same race #8681 fixed in workflows.spec's createWorkflow. Picking a channel opens the trigger pane (WorkflowDialog.tsx:702), and for Message Posted the conditions editor expands "Message text" by default (WorkflowTriggerConditions.tsx:157), rendering an input with that label. After "Send Message", AnimatePresence mode="wait" (WorkflowFormBuilder.tsx:907) keeps the trigger body mounted through its exit and holds the step body back. An unscoped getByLabel("Message text") can fill the trigger's filter. The step then has no text, which yamlToFormState rejects (workflowFormTypes.ts:348), so the Edit dialog opens in YAML mode (WorkflowDialog.tsx:88). That is the signature the earlier audit recorded for navigation:153. Change: wait for the step inspector's "Action" header and "Step details" control, then fill inside the inspector, as workflows.spec does. workflows.spec.ts:716 ("captures the built editor at desktop and narrow widths", fill at :726) and :820 ("pane routes use stable IDs and Form/YAML changes stay synchronized", fill at :831) pick a channel and then add a Send Message step the same way, with no failures in the window. So the wait moves into one addSendMessageStep helper in tests/helpers/workflows.ts, rather than four copies. Both specs' createWorkflow and those two tests call it. A change to the inspector's structure then needs one fix. Left out: workflow-reaction-picker.spec.ts:203-204. The note listed it, but its trigger is Reaction Added, whose condition fields are "Reaction emoji", "Author" and "Message" (workflowConditionExpression.ts:40). No trigger control there is labelled "Message text", so the unscoped fill can only reach the step's textarea. Verification Not run locally: Playwright bogs this machine down, and the Flake Check lane will repeat the touched specs. pnpm typecheck and pnpm check pass. biome's 3 warnings are in untouched files. An ad hoc tsc ran over the eight touched specs, using the desktop tsconfig with only those files, and over HEAD's copies of them. Both gave the same errors: onboarding 16, navigation 7, profile 7, workflow-local-controls 7, projects-v3-screenshots 2, and none in mention-recipients, config-bridge-screenshots or workflows. The messages are identical once line numbers are stripped, and all are pre-existing (no Node types, import.meta.env, MockBridgeOptions drift and the like). Once the helper moved to tests/helpers/workflows.ts, a rerun over navigation, workflows and the helper gave navigation's same 7 and none in the other two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Units 2-5 of the 23-30 Oct desktop Playwright flake audit. All four are test-only. origin/main (1eb67bb, now including #8680) hasn't touched these specs since fae3f03. Of the open PRs, #8622 adds an unrelated test at the end of navigation.spec, and #7812 (stale since 10-06) adds tests elsewhere in three of them. #8680 didn't touch them, but it carried navigation:472 mode A's product half (eb88a98), which is not part of this commit. The code confirms each cause, so none is dropped. navigation.spec.ts:472, "composer Buzz chip labels wrap without orphaning their icons" Runs: 11 jobs in the window, 6 after the earlier fixes, and 4 of the 7 main runs that carry every fix. Run 40970053997 (Smoke shard 7) failed all three attempts. Mode A: jobs 122971493553, 123066839967, 123572467962, 123819180597 and retry 2 of 123784825042. Signature: fragmentStep is null at :634. Cause: rows key on renderKey = localKey ?? id. On main, reconcileChannelWindowMessages keeps only pending sends and thread replies from the cache, so a head refetch or live echo after the ack drops the localKey. The key flips and the row remounts, losing the 220 px width the test set in an earlier evaluate. Change: re-land the test half of closed PR #6852, which never merged. Set the width and measure in one expect.poll, and set the width again before each fragment measurement. Mode B: jobs 123573056202, 123784199542, 123784825042 (twice), 123804428893, 123818728963, 123819543461, and 122924164125 (the Infinity variant). Signature: the |tooltipCenter - cursorX| < 1 poll at :667 receives 81.35 or Infinity. In failing attempt 40970053997 the post-send settle (scrollTop 200, 296, 260, 302) took its last step 21 ms or less before focus, and the tooltip read closed 12 ms after it. In the passing retry 40980513346, scrolling had stopped at least 15 ms before focus. Cause: Radix Tooltip 1.2.16 closes when an ancestor of its trigger scrolls (a capture-phase window listener). Reopening by pointer needs a pointermove while hasPointerMoveOpenedRef is false, then the chip provider's 500 ms delay. Any close clears that timer, and only pointerleave resets the flag. useInlineTooltipPosition moves the content only on pointermove and placement. So a scroll after focus can leave the tooltip closed, or open without the fragment offset, and the spec's single mouse.move never recovers it. Change: before focusing the chip, poll until the timeline sits at its floor (scrollHeight - clientHeight - scrollTop <= 1) at the start of one poll, is still there two animation frames later, and its scrollTop hasn't moved. Waiting only for scrollTop to hold for two frames isn't enough: a timeline whose settle hasn't started yet reads as still, and the failing run's last step landed under two frames before focus. The settle's end state is the floor. useVirtualizedBottomSettle scrolls only when Virtua's ResizeObserver reports a layout change, and after the send and the 220 px wrap the timeline sits short of the floor until the settle catches up. Focusing the chip and moving the pointer over it don't change layout. The send's ack does. It removes the row's "Sending…" status, and the confirmed row can lose its header, since a pending row is never shown as a continuation (MessageRow.tsx:167). The send's bottom intent is still armed, so that re-pins the timeline. So before the floor poll, wait for the sent row's message-send-status to reach count 0. If the ack has already landed, that wait is a no-op. The standalone toBeVisible after focus is gone. positionOverFragment asserts data-state matches /open/ instead, because a closing tooltip stays visible through its exit animation. No retry. Wrapping the measure, move and check in toPass, leaving and re-entering the chip on each attempt, would also pass, but it retries around the race instead of removing it. The ack and floor waits close the scroll window. The only other way to scroll under the tooltip was a remount that drops the row's 220 px width (mode A), and #8680's retainAcknowledgedLocalKeys, on this branch's base (1eb67bb), keeps the row's key. So positionOverFragment is a single pass, as on main, plus mode A's width re-set: set the width, measure, move, assert open, then poll the centre with the default timeouts. huddle-transcription.spec.ts:637, "animates the responding agent with the shared speaker ring" Runs: 9 jobs, always Smoke shard 4 attempt 1. Main: 39896948198, 40718743531, 40732061485, 40840949170, 40906253761, 40969965981 and 40983029157. PRs: #8715, #8666. Signature: toBeFocused() at :697 reads inactive for all 14 polls over 5 s. At +3.0 s in the failure video (40983029157, job 123827818084) the caret is in "Message #huddle". Cause: useComposerAutofocus re-runs when `focus` changes. That's focusEnd, which depends on the editor, so it runs again once the editor mounts. It backs off only for input, textarea, select and contentEditable focus, so it takes focus from the focused trigger. Change: wait for message-input to be focused before focusing the trigger. The same spec already does this at :855. scroll-history.spec.ts:1289, "fast middle-page scroll settles with continuous mounted coverage" Runs: 9 jobs in the window. After the fixes: main 40980404335 (Smoke shard 9, job 123819180518), two attempts on #8720, and the brain/native-account-setup branch. Signature: the viewportCoverage poll expects < 100 and receives 200. The failure screenshot shows the timeline at the floor. The 200 px is the bottom spacer (--composer-overlay-height) under the last row. Cause: TimelineMessageList arms useVirtualizedBottomSettle when the channel first positions. Its ResizeObserver then pins to the floor until listenForReaderScroll input or an accepted target cancels it. handleScroll deliberately doesn't cancel, and a prepend delta doesn't settle, so the intent survives the prepend and the spec's scrollTop writes. Virtua's re-measure after the jump pulls the list back down. Change: before the fast scroll, hover the timeline and wheel up 120 px. Then poll until it is more than 32 px from the floor (the timeline's at-bottom band) and scrollTop holds for two frames, so the writes stay the last scroll. custom-emoji.spec.ts:370, "reacting with a custom emoji renders via the loopback media proxy" Runs: main 40787609859 (10-28), main 40983029157 (Smoke shard 3, a run with every fix), and #8241 (agent and auth files only). Signature: the poll at :453 expects the inline "Add reaction" opacity "0" and receives "1". The error context marks button "Open reactions" [active]. Cause: the non-modal Radix Popover returns focus to its trigger when the picker closes. FocusScope does that in a setTimeout(0) after the content unmounts, and the content unmounts after its 100 ms exit animation. That can land after the spec's blur. The action bar is hidden by opacity only, so the trigger takes focus, and group-focus-within/message keeps the button visible. Change: wait for the "Open reactions" trigger to be focused, then blur. The note suggested waiting for em-emoji-picker to reach count 0. That still leaves the setTimeout window open. Focus reaches the trigger only after unmount, so this wait covers both. Verification Not run locally: the Flake Check lane will repeat the touched specs. pnpm typecheck and pnpm check pass. biome's 3 warnings are in untouched files. An ad hoc tsc ran over the four specs, using the desktop tsconfig with only those files, and over HEAD's copies of them. Both gave the same error counts: navigation 7, huddle 17, scroll-history 0, custom-emoji 2. The messages are identical once line numbers are stripped, and all are pre-existing: no Node types, findLast against the ES2022 lib, and the leadingBounds narrowing at navigation:576. With the floor wait in place and the retry dropped, navigation still gives the same 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
message-target-highlight.spec.ts:79 is the most frequent unfixed desktop flake. It failed in all 7 main runs whose SHA carries every earlier flake fix (40969965981, 40970005965, 40970053997, 40980258899, 40980404335, 40980513346, 40983029157), and in 26 jobs since those fixes landed, on main and on PRs #8235, #8239, #8241, #8666, #8680, attempts failed. Only the two cases that pick mounted[2] fail: "same channel, row loaded off screen" (15 jobs) and "other channel, row further up" (11 jobs). The target scrolls in and flashes, then the timeline lands on the newest message, so `stillOnScreen` at :179 reads false (twice it was `flashedOnScreen` at :153). Cause The triage note blamed the thread panel opening. The failure videos from run 40983029157 (Smoke shard 6) show that is a precondition, not the trigger: - Other channel: the panel opens at +0.36 s, the target flashes at +0.7 s, the older page prepends by +1.04 s, the target is still on screen at +1.47 s, and the timeline sits at the exact floor by +1.97 s. - Same channel: the panel and the flash land in the same frame. The snap follows about 0.5 s later, with the load-older spinner still showing. The panel opening never moved the target. A later floor report did: TimelineMessageList's onScroll reporting within 32 px of the bottom, during or after the older-page load that only a mounted[2] target sets off. onVirtualizerAtBottomStateChange(true) reset the anchor to at-bottom and set virtualizerAtBottomRef. With the panel open, the split-panel settle, the viewport-resize settle or the next append settle then pinned the timeline to the floor. The exact-floor landing points at our own settleAtBottom rather than restored scroll. Which Virtua layout pass emits the floor report is still unidentified. Change Accepting a virtual route target now sets targetHoldRef in useAnchoredScroll. Only the two targetMessageId paths set it, on first mount and in the later target effect, by passing holdTarget to scrollToMessage. The public scrollToMessage, which MessageTimeline's oldest-unread pill and find-in-channel stepping call, doesn't hold. It also replaces an earlier hold, so a find jump after a deep link follows the floor as on main. While the hold is set: - shouldSettleForSplitPanel and shouldSettleVirtualizedViewportResize return false; - a floor report still updates isAtBottom and the new-message count, but keeps the message anchor, so live appends don't settle either; - a commit that changes the newest message sets isAtBottom false, clears virtualizerAtBottomRef and adds the rows after the previous newest one to the new-message count. Virtua reports bottom state only from scroll events, and an append below the viewport emits none. Without this, a floor report left isAtBottom true, so MessageTimeline hid the Jump to latest / N new pill while rows piled up out of sight (Review-Proven Rule 6). A realistic trigger is a mention or search hit on one of the last few messages in a busy channel: scrollToIndex clamps at the floor and emits that report. Clearing virtualizerAtBottomRef means a later release doesn't snap past the new rows either. The hold only acts on an arrival, so a reader at the floor with nothing new sees no pill. Counting rows rather than the length change matters: a cold target is merged in older than the head window, and the history page that fills the gap keeps both ends of the list. classifyTimelineMessageDelta calls that an append, but nothing arrived below, so it raises no pill. Neither does deleting the newest message; - viewport reflow re-centres the target through virtualScrollToMessage, from the viewport-resize observer and when the split panel opens or closes. Rows rewrap around the target without a scroll event, so the target can drift, and a floor report from before the reflow can keep isAtBottom true while the newest rows drop below the fold. The re-centring scroll refreshes the bottom state. Virtua's scrollToIndex cancels any earlier one still in flight, so a burst of resizes only restarts its measure loop. The split-panel call fires only on a transition, not on mount or when the virtualizer API registers. The hold is released by reader scroll input (a wheel, a touch drag, a pointerdown on the scroller itself, a scroll key aimed at it), Jump to latest, the reader's own send, and a channel change. The input listeners are the ones that already cancel bottom intent in useVirtualizedBottomSettle, now shared as listenForReaderScroll. A release at the floor restores the at-bottom anchor, so the timeline follows live messages again. onTargetReached does not release it. The artifacts never show the panel pushing the target off screen, so skipping the settle is the fix aimed at the CI failure. The re-centring keeps the target's place and the pill right after a reflow. Known limits: - Programmatic scrolls (focus moves, scrollTop writes) don't release the hold. A find or unread-pill jump replaces it. A resize after such a scroll (Tab focus into a lower row, say) re-centres the target, away from the focused row. - ChannelPane's composer-height settle still calls scrollToBottom within 32 px of the floor, which releases the hold. - Live messages that arrive while the hold is set near the floor are not followed until reader input, Jump to latest or the reader's own send. The pill shows them, with their count. - Under the hold, only rows after the previous newest message count. A live message that sorts just ahead of it (a same-second timestamp) raises no pill until a later row lands. Unit before/after useAnchoredScroll.lifecycle.test.mjs replays the CI sequence: accept a route target, clear it, open the split panel, report the floor, resize the viewport and append a live message. It asserts no bottom settle, and that the panel opening and the resize each re-centre the target. Then it asserts the append shows the pill (isAtBottom false, one new message). A scrollbar pointerdown after that releases the hold, and a resize neither settles nor re-centres. One case per release path asserts the resize settle resumes and the re-centring stops. One of them is a find jump: a public scrollToMessage on a held timeline, then a floor report. A descendant pointerdown (row interaction) keeps the hold, and a resize still re-centres. Reader input at the floor resumes following a live append. Under a hold, a history page filling the gap below the target, and then deleting the newest message, leave isAtBottom true and the count at 0. A commit that adds more history and one live row counts 1. The split-panel and viewport-resize policy tables gain a held row. Mutations over the lifecycle file and both policy tables (29 tests, all pass intact): - hold never set on accept (the old behaviour): 12 fail, including the CI-shaped hold case - split-panel settle ignores the hold: 13 fail - viewport-resize settle ignores the hold: 13 fail - floor report resets a held anchor: 12 fail (the reset anchor is never re-centred) - reader input does not release: 6 fail - Jump to latest, own send, channel change don't release: 1 each - release skips the at-bottom resync: 1 fails - a held append doesn't raise the pill: 2 fail - a held append leaves virtualizerAtBottomRef set: 1 fails - every scrollToMessage holds (this commit's first draft): 1 fails - a public jump keeps an earlier hold: 1 fails - the held-append branch as first drafted (any non-prepend growth, counted by length): 1 fails, the gap-fill case - a newest-id gate but a length-delta count: 1 fails - a missing previous newest counts every row: 1 fails - no rowsBelow > 0 guard: 1 fails - rows counted from the previous newest, off by one: 2 fail - no split-panel re-centre: 12 fail - no viewport-resize re-centre: 12 fail - re-centre ignores the hold: 1 fails pnpm typecheck and pnpm check pass. All 46 tests in the files that import the touched modules pass. Only hosted CI can show Playwright was not run locally. Whether the spec now holds in Smoke shard 6 and in Flake Check's repeats is unverified, and so is the unidentified Virtua floor report. scroll-history.spec.ts and the other timeline specs need the full desktop smoke run to show the hold doesn't regress bottom-follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
## Summary A product fix for the most frequent unfixed desktop E2E flake, `message-target-highlight.spec.ts:79`. A deep-linked message now holds its place against bottom settles until the reader scrolls. This is one of six PRs split from the reviewed `flakey-tests` branch (siblings below). - **Runs:** it failed in all 7 main runs whose SHA carries every earlier flake fix (40969965981, 40970005965, 40970053997, 40980258899, 40980404335, 40980513346, 40983029157). It also failed in 26 jobs since those fixes landed, on main and on PRs #8235, #8239, #8241, #8666 and #8680. Only the two cases that pick `mounted[2]` fail: "same channel, row loaded off screen" (15 jobs) and "other channel, row further up" (11 jobs). The target scrolls in and flashes, then the timeline lands on the newest message. `stillOnScreen` at :179 reads false (twice it was `flashedOnScreen` at :153). ### Cause The failure videos (run 40983029157, Smoke shard 6) show that the thread panel opening is a precondition, not the trigger. A later floor report moved the target: `TimelineMessageList`'s `onScroll` reported within 32 px of the bottom, during or after the older-page load that only a `mounted[2]` target sets off. `onVirtualizerAtBottomStateChange(true)` reset the anchor to at-bottom. Then the split-panel settle, the viewport-resize settle or the next append settle pinned the timeline to the floor. Which Virtua layout pass emits the floor report is still unidentified. ### Change Accepting a virtual route target sets `targetHoldRef` in `useAnchoredScroll`. Only the two `targetMessageId` paths set it, via `holdTarget`. The public `scrollToMessage`, which the oldest-unread pill and find-in-channel use, doesn't hold, and it replaces any earlier hold. While the hold is set: - The split-panel and viewport-resize settles are skipped. - A floor report still updates `isAtBottom` and the new-message count, but keeps the message anchor, so live appends don't settle either. - A commit that changes the newest message sets `isAtBottom` false and clears `virtualizerAtBottomRef`. It adds the rows after the previous newest one to the new-message count. So the "Jump to latest" / "N new messages" pill still appears for rows below the fold (Review-Proven Rule 6). A history page that fills the gap below a cold target raises no pill, and neither does deleting the newest message. - Viewport reflow re-centres the target through `virtualScrollToMessage`. It runs from the resize observer and when the split panel opens or closes. The hold is released by reader scroll input: a wheel, a touch drag, a pointerdown on the scroller itself, or a scroll key. Those listeners already cancel bottom intent in `useVirtualizedBottomSettle`, and this PR shares them as `listenForReaderScroll`. Jump to latest, the reader's own send and a channel change also release it. A release at the floor restores the at-bottom anchor, so the timeline follows live messages again. `onTargetReached` does not release the hold. ### Known limits - Programmatic scrolls (focus moves, `scrollTop` writes) don't release the hold, but a find or unread-pill jump replaces it. A resize after such a scroll re-centres the target. - ChannelPane's composer-height settle still calls `scrollToBottom` within 32 px of the floor, which releases the hold. - Live messages that arrive under a hold near the floor aren't followed until reader input, Jump to latest or the reader's own send. The pill shows them, with their count. - Under the hold, only rows after the previous newest message count. A same-second row that sorts just ahead of it raises no pill until a later row lands. - Agent review raised two optional gaps, and scrolling recovers from both: - If the previous newest message is deleted and a new row arrives in the same deferred commit, 0 rows are counted. - A thread reply sent with "also send to channel" doesn't arm the bottom intent, so it shows as "1 new message" instead of being followed. ### Related issue None found. ### Testing `useAnchoredScroll.lifecycle.test.mjs` replays the CI sequence: accept a route target, clear it, open the split panel, report the floor, resize and append a live message. It asserts no bottom settle, a re-centre on the panel opening and on the resize, and the pill on the append. Further cases cover: - each release path; - a find jump on a held timeline; - a descendant pointerdown keeping the hold; - a gap-fill page and deleting the newest message, neither of which raises the pill. The split-panel and viewport-resize policy tables each gain a held row. Mutations were run over the lifecycle file and both policy tables (29 tests, all pass intact). Each of the 20 mutations fails at least one test: | Mutation | Tests that fail | |---|---| | Hold never set (the old behaviour) | 12 | | Split-panel settle ignores the hold | 13 | | Viewport-resize settle ignores the hold | 13 | | Floor report resets a held anchor | 12 | | No split-panel re-centre | 12 | | No viewport-resize re-centre | 12 | | Reader input doesn't release the hold | 6 | | Held append raises no pill | 2 | The commit message lists the other 12, each failing 1 or 2 tests. `pnpm typecheck` and `pnpm check` pass. All 46 tests in the files that import the touched modules pass. Only hosted CI can show whether the spec now holds in Smoke shard 6 and in Flake Check's repeats, because Playwright was not run locally. It also has to show that `scroll-history.spec.ts` and the other timeline specs keep bottom-follow working. Pushed with `git push --no-verify`, because the pre-push suites fail when the laptop is under load. ### Readiness and human check An agent review of the combined `flakey-tests` branch (b432453af) found no blockers. This changes desktop app behaviour, so it needs a human in-app check before `buzz-review-completed` is added. Suggested check, in `just dev` against a channel with a few pages of history: 1. Open a message link (a mention or search hit) to a message several screens up, once in the same channel and once from another channel. The message should flash and stay on screen after older history loads, including when the thread panel opens or closes, or the window is resized. 2. With the target still held, have someone post in the channel. The timeline stays put, and the pill reads "1 new message". 3. Wheel-scroll to the bottom, or click Jump to latest. The next message that arrives is followed. ### Siblings - #8739 ci: Hermit fetch deadline and sccache fallback - #8740 test(relay): thread_window aux-page hold and `api::media` Postgres tests - #8741 test(mobile): RunnerTests title fade and alert wait - #8743 test(desktop): four specs racing scroll settles and focus - #8744 test(desktop): five settle classes and remote-owned-mentions 🤖 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>
…sh loadgen to the fork Three follow-ups the merge's CI found: - desktop/src-tauri/Cargo.lock: regenerating from upstream's lock rolled h2, negentropy, rtrb and ppmd-rust back below the versions our #105 pinned for RUSTSEC-2026-0258, -0365 and -0274. Restore them with `cargo update --precise`. All three cargo-deny policies pass. - ForumComposer.tsx: still passed isChannelAutocompleteOpen, which the merge removed with the rest of #27 in favor of upstream's block#8680. Take upstream's file. `tsc --noEmit` passes. - loadgen-image.yml (new upstream): publish to the running repository's GHCR namespace, overridable with GHCR_LOADGEN_IMAGE, as #24 did for the other image workflows. The fork cannot write ghcr.io/block. Signed-off-by: Tee8z <tee8z@protonmail.com>
Summary
Fixes the four desktop product races behind recurring E2E flakes in the 21–28 Oct 2026 CI audit. Each fix has a regression test:
Two E2E specs that failed for the same ordering reason also get test-side fixes.
This is one of seven PRs split out of the
flakey-testsbranch (siblings below).Related issue
None found for these. Draft #8559 does not fix the voice-note flake. Draft #8661 (Refs #6733) closes the
#channellist synchronously when an edit is cleared. §11 below also covers that case, plus the@mentionand:emojilists, so the two PRs overlap inuseChannelLinks.tsandempty-edit-delete.spec.ts.Flaky tests fixed
channel-browser.spec.ts:402"Enter with no matches jumps to create"useDeferredValue(query). An Enter that landed before the deferred render read the old list, so it opened a stale match or skipped the create row.selectBrowserChannelsthe list renders from.empty-edit-delete.spec.ts:88,:111,:132#channellist stayed open, so Enter re-inserted the suggestion instead of submitting the empty edit.useChannelLinks,useEmojiAutocompleteanduseMentions(typed@only) close as soon as the trigger is gone. Typing a query still debounces.ChannelAutocompletegainsdata-testid="channel-autocomplete".video-attachment.spec.ts:1469"right-click menus expose distinct selectors…"sortMessages, ascending id within a second) and the channel-window projection (descending id) disagreed, so two same-second rows swapped on screen as each writer landed.shared/lib/eventOrder.tsholdscompareRelayOrderand its exact reverse,compareChronologicalOrder.sortMessagesand the window and reconciliation sorts all use the window's order, so older-page paging is unchanged.sortEventskeeps ascending id. The spec gives its three messages distinct seconds.voice-note.spec.ts:906"starting a duplicate voice-note player pauses the other instance"stream.spec.ts:477"keeps scroll position when new messages arrive above the fold" (integration)pendingCountfell to 0, and the pill read "Jump to latest". Deleting a frozen row also dropped the whole freeze.useBufferedTimelineMessageskeys rows byrenderKey, which is stable across an optimistic send and its ack (frozenRowKeys). It drops only deleted frozen rows, and ends older history at the first frozen row of any key.stream.specis unchanged.New regression tests:
channel-browser.spec: two forced Enter variants.empty-edit-delete.spec: a#/@/:autocomplete table.autocompleteTriggerClose.jsdom-test.projectChannelWindow.test: the cache merge and the window agree in both arrival orders.channel-dense-second-reach.spec: a RAF sampler sees one order.useBufferedTimelineMessages.test: deletion, reorder and ack rows.virtualization.spec: deleting a row while reading keeps the next arrival counted.useThreadReplies.testrow: the thread fetch and a live append agree on same-second order.Local before/after counts are from the original
flakey-testscommits (238c08c7c, c66f411f3):stream:477against an isolated relayVerification
This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. Hosted CI is the check. It was pushed with
git push --no-verify, because the pre-push hook runs typecheck and the unit suites. #8613's flake-check lane will also repeat the changed specs and unit tests.empty-edit-delete.spec.tsandvirtualization.spec.tsis byte-identical to its final version onflakey-tests(67185df6a). Those two carry only this PR's hunks; see "Merge order".git diff --check, biome on both partial specs (no changes, the same as onmain), and a conflict-free merge with all six siblings ontomain.Readiness
These are product fixes in the desktop app. Under AGENTS.md "Before opening a PR", this needs an agent review, an agent run of the affected flows, and a human in-app check before
buzz-review-completedis added. Suggested check, injust devor a local build against any community:#genso the channel list opens. Select all, delete, and press Enter at once. The empty edit is submitted (the delete-by-clearing flow) and no suggestion is re-inserted. Repeat with@(mention list) and:sm(emoji list).Merge order
No required order, but two files are shared:
desktop/tests/e2e/empty-edit-delete.spec.tsis also edited by flakey-tests-desktop-e2e-actions. This PR adds the autocomplete table after line 158. The sibling changes imports, helpers,beforeEachand line 143.desktop/tests/e2e/virtualization.spec.tsis also edited by flakey-tests-desktop-e2e-gates. This PR adds one test after line 929. The sibling's hunks are all above line 551.The hunks don't touch, so each pair merges cleanly in either order, as checked with
git merge-tree. Draft #8661 overlaps this PR inuseChannelLinks.tsandempty-edit-delete.spec.ts; whichever lands second will need a rebase.Sibling PRs
Split from
flakey-tests(one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions).🤖 Generated with Claude Code