From e23cf4d4d2de02c3eb5e80326b2baca0977ef206 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 16:31:10 +1100 Subject: [PATCH 1/2] test(desktop): gate flaky E2E specs on the state they assume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-side fixes for §2, §4, §6, §12, §15, §17, §19, §20, §21 and §23 of the 21-28 Oct 2026 desktop E2E audit, plus six long-tail rows. Test code and mock-bridge seams, plus one product constant extracted with its value unchanged. Line numbers are as on main and in the cited runs. Pre-check: origin/main (eda26b942) has no desktop test or e2eBridge changes that cover these. #8622, #8244 and #8386 touch some of these spec files, but not the tests fixed here. Draft #8279 queues cold-launch nostr-bind links in the product, but its mock returns null, so the §12 listener seam is still needed. §7 mode A (navigation:472) is left to open PR #6852, which already sets the width and measures in one polled evaluate. Before/after counts come from running main's copy and the changed copy of each spec under the same forcing (pnpm build:e2e, --retries=0 --trace=off, Playwright outside the sandbox). The forcing hooks (CDP CPU throttle, held timers, delayed listeners, pinned toasts, late wheels, stretched Web Animations, a seed timed into a wall-clock second) lived in a local bridge.ts patch and generated spec copies, and are not committed. Where no forcing made main's copy fail, the entry says so. Mock-bridge seams (src/testing/e2eBridge.ts, tests/helpers/bridge.ts) - `__BUZZ_E2E_EVENT_LISTENER_COUNT__(event)`: counts live app listeners per Tauri event by wrapping the mocked `plugin:event|listen` and `unlisten` commands (§12). The spec-facing `__TAURI_INTERNALS__.listen` passes a marker target object that the wrapper skips, so a spec's own subscription (persona-sync.spec.ts:104 subscribes that way) cannot satisfy the wait. Tauri 2.11.1's mock ignores targets, so delivery is unchanged. A throwaway diag showed a spec-surface listen leaves the count at 0 and still receives the emit, an app-style listen counts 1 and drops to 0 on unlisten, and with the target check removed the spec listen counts 1. - `deferFirstChannelsRead`: arms the existing channels-read hold at boot, released with `__BUZZ_E2E_RELEASE_CHANNELS_READ__` (§23). §2 overscroll-boundary.spec.ts:34 (and its sibling) Runs: 39601763602, 40367495044, 40583830861, 40597484657, 40373404267 Cause: the `message-timeline` test id first lands on the loading skeleton, which does not overflow, so the scroll lock rightly cancels the wheel and the "not prevented" expectation at :66 fails. Change: wait for a rendered row and content taller than the viewport before dispatching wheels. Counts (6x CPU throttle, 15 repeats): before 3/15 pass (all failures at :66), after 15/15. §4 project-conversation-load-failure.spec.ts:73 Runs: 40367495044 (all 3 attempts), 40608383847, 40583830861, and PR runs 39541263507, 40509460168 Cause: the seeded root and reply were dated `now` and `now + 1`. The panel's live REQ uses `since = floor(now)` and the mock replays stored events with `created_at >= since`, so the reply still arrived live, the panel rendered the list, and "Couldn't load replies" never appeared. Change: date both events a minute in the past and correct the comment that said seeding before the subscription was enough. Counts (seed forced into the first 30 ms of a wall-clock second, 10 repeats): before 0/10 (CI's error-card signature), after 10/10. §6 workflows.spec.ts createWorkflow cluster (:222, :365, :297, :525, :471, :406, :614, :997) Runs: 14 of 50 main jobs; :222 x4, e.g. 40353619362. Cause (:222 and the createWorkflow callers): the "Action" header switches at once, but AnimatePresence mode="wait" keeps the trigger body mounted through its 0.15 s exit, so getByLabel("Message text") can fill the trigger's condition field. The action saves without text and the activation dialog never opens. Change: also wait for the step body's own "Step details" control before filling. Counts (:222, every Web Animation 10x longer, 3 repeats): before 0/3 (CI's wait for the "Turn on" button), after 3/3. Cause (:297): selectWorkflowChannel probed isVisible() and then clicked. A create dialog opens its channel list itself one frame after mount, so a probe taken before that frame reads "closed" and the click lands on the open list. Change: create dialogs wait for the list; only the duplicate dialog, which never opens it itself, clicks. Counts (probe forced to read "closed", 3 repeats): before 0/3 (strict mode violation), after 3/3. §12 nostr-bind.spec.ts (:156, :198, :228, :247, :260, :335, :417, :447) Runs: 8 main jobs, each test once. Cause: the spec emitted deep-link-nostr-bind once the mock bridge existed, but the app subscribes only after boot and the mocked emit drops events with no listener. Change: wait for __BUZZ_E2E_EVENT_LISTENER_COUNT__ to report the listener before emitting. Counts (listener registration delayed 1 s, :156, 3 repeats): before 0/3 (CI's wait for nostr-bind-page), after 3/3. §15 profile.spec.ts:1128 "renders agent profile ingress subviews from the Playwright mock bridge" (integration) Runs: 7 of 50 main jobs. Cause: the 320 ms motion-sampling window started before the click, so a slow click or frame spent it before the tab moved. Change: start the window when activeTab reads "runtime"; the window length is unchanged. Counts (click 400 ms after sampling starts, 3 repeats): before 0/3, after 3/3. §17 agent-availability.spec.ts:602 profile deletion (integration, both shutdown-failure variants) Runs: 6 jobs (4 disconnected-online, 2 failed-online). Cause: the "shutdown refused" toast can sit over the Delete row. The pointer left there by the first click expands it and pauses its dismissal, so the retry click waits on an intercepting toast. Change: assert the toast, then retry with focus + Enter (the toaster has no close button). Counts (toast pinned over the row with the pointer on it, 4 tests): before 1/4, after 4/4. §19 virtualization.spec.ts:247 "08 - cascading older pages never snap the viewport toward newest" Runs: 4 of 50 main jobs; PR 40605736093 failed all three attempts. Cause: the 120 ms and 400 ms windows were timed from before the wheel bursts, and CDP can deliver wheels late. A late last upward delta was read as anchor drift (exactly 15, the -15 delta), and late exit wheels closed the 400 ms window before any travel. Change: count the wheels the page receives. Each window starts at the first wheel and closes only after all of them have landed; the exit window also waits for two still frames. Assert every wheel arrived. The drift baseline waits for three still frames. Counts (3 repeats each): last upward delta 150 ms late: before 0/3 (maxDrift 15), after 3/3. exit burst 450 ms late: before 0/3 (maxForwardTravel 0), after 2/3; the failure is the product mode below, not the exit window. Not fixed: a second, product-side mode. Unforced, main's copy failed 3/5 and 4/8; this copy 1/5 and 2/8. A per-frame trace shows that in one frame after a prepend, scrollHeight drops 458-639 px but scrollTop moves only 50-114 px, so the anchor row jumps 382-457 px and stays there (CI also logged 428). That is lost anchor compensation in the product, and this test can still fail on it. §20 message-copy-link.spec.ts:160 "pending and huddle rows omit both copy-link surfaces" Runs: 4 of 50 main jobs (5 failed attempts), e.g. 40595483966. Cause: the huddle row reused id "d".repeat(64), the seeded REACTION_TARGET_EVENT_ID: an ordinary kind-9 #general message with a copy-link button, matching CI's "Expected 0, Received 1" on that id. Change: ids no seed uses ("2e" and "1d" x32), and emit only after waitForMockChannelHeadReady. Counts: not reproduced; main's copy passed 3/3 under three forcings. §21 navigation.spec.ts:687 "message links to visible root messages open the thread panel" Runs: 4 of 50 main jobs. Cause: the pending chip's boundingBox() was read once and can be null while the sent row is still mounting. Change: poll until the chip has a width, and compare against that. Counts: not reproduced; main's copy passed 3/3 with the chip hidden for 600 ms after mount. §23 sidebar-snapshot.spec.ts:358, :388, :438 Runs: PR 40427822003, all three attempts; never on main. Cause: the 500 ms loading-state budget counts from goto and includes app boot, so on a slow runner the channels read finishes first. Change: hold the first channels read with deferFirstChannelsRead, assert the loading state, then release it. Counts (800 ms after goto, 3 tests x 2): before 2/6, after 6/6. Long tail (one or two CI failures each) project-commit-detail:259: the dialog autofocuses the name field on open and focuses it again from a 50 ms timer, which can land mid-fill and type the description into the name. Change: once the name field is focused, wait out a page timer of the same delay; equal-delay timers fire in order. The delay is now CREATE_PROJECT_REFOCUS_DELAY_MS in createProjectFormFocus.ts, imported by both CreateProjectFormContent and the spec, so changing the delay changes the wait (its own module, so the Playwright runner does not load the component's import graph). Counts (50 ms timers held until the description is focused, 3 repeats): before 0/3, after 3/3. A plain toBeFocused() wait also failed 0/3. project-pr-review:3520: the same timer pattern in CreateProjectWorkItemDialog, but the title field is not autofocused (the category select precedes it), so toBeFocused() can only be satisfied by the timer and holds for any delay. Counts (same forcing): before 0/3, after 3/3. thread-load-failure:79: openWelcomeThread branched on a one-shot count() and fell back to a hover-only Reply click, which is what failed in CI. Change: wait for the summary row, then click it. Not reproduced (3/3 unthrottled and at 6x). virtualization:201: the drop point sat 2 px above the target's bottom edge and the drop followed two fixed frames. Change: drop at the centre, after dnd-kit's live region names the target. Not reproduced (3/3 both at 4x). channel-composer-overflow:217: same-second emits sort by id, so the last emitted is not always the bottom row, and the clearance was checked once. Change: createdAt base + i, and expect.poll. Not reproduced (3/3 both at 4x). scroll-history:1289: a fixed 250 ms wait before idle sampling. Change: poll for coverage first; the idle samples still catch a stale range. Not reproduced (3/3 both at 4x). Follow-ups, not in this change: - §19 product mode (virtualization:247): anchor compensation is lost after a prepend; this test can still fail on it. - §7 mode B (navigation:472 positionOverFragment 81.35) and §14 video-attachment:844 (1 px shift) need a failing trace. - §12 product question: whether a real cold-start deep link can arrive before listen(); draft #8279 queues cold-launch links. - Drop both create-project refocus timers in favour of Radix onOpenAutoFocus targeting the field, which removes both waits. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .../projects/ui/CreateProjectFormContent.tsx | 3 +- .../projects/ui/createProjectFormFocus.ts | 5 + desktop/src/testing/e2eBridge.ts | 51 ++++- desktop/tests/e2e/agent-availability.spec.ts | 15 +- .../e2e/channel-composer-overflow.spec.ts | 62 +++--- desktop/tests/e2e/message-copy-link.spec.ts | 17 +- desktop/tests/e2e/navigation.spec.ts | 14 +- desktop/tests/e2e/nostr-bind.spec.ts | 9 +- desktop/tests/e2e/overscroll-boundary.spec.ts | 21 +- desktop/tests/e2e/profile.spec.ts | 12 +- .../tests/e2e/project-commit-detail.spec.ts | 10 + .../project-conversation-load-failure.spec.ts | 19 +- desktop/tests/e2e/project-pr-review.spec.ts | 5 + desktop/tests/e2e/scroll-history.spec.ts | 6 +- desktop/tests/e2e/sidebar-snapshot.spec.ts | 32 ++-- desktop/tests/e2e/thread-load-failure.spec.ts | 19 +- desktop/tests/e2e/virtualization.spec.ts | 181 +++++++++++++----- desktop/tests/e2e/workflows.spec.ts | 16 +- desktop/tests/helpers/bridge.ts | 5 + 19 files changed, 352 insertions(+), 150 deletions(-) create mode 100644 desktop/src/features/projects/ui/createProjectFormFocus.ts diff --git a/desktop/src/features/projects/ui/CreateProjectFormContent.tsx b/desktop/src/features/projects/ui/CreateProjectFormContent.tsx index 16ec9a52193..7d1c887bdd8 100644 --- a/desktop/src/features/projects/ui/CreateProjectFormContent.tsx +++ b/desktop/src/features/projects/ui/CreateProjectFormContent.tsx @@ -2,6 +2,7 @@ import { ArrowLeft } from "lucide-react"; import * as React from "react"; import type { CreateProjectInput } from "@/features/projects/useCreateProject"; +import { CREATE_PROJECT_REFOCUS_DELAY_MS } from "@/features/projects/ui/createProjectFormFocus"; import { CreateProjectFormSettings } from "@/features/projects/ui/CreateProjectFormSettings"; import { useCreateProjectFormSettings } from "@/features/projects/ui/useCreateProjectFormSettings"; import { cn } from "@/shared/lib/cn"; @@ -46,7 +47,7 @@ export function CreateProjectFormContent({ const timerId = globalThis.setTimeout(() => { nameInputRef.current?.focus(); - }, 50); + }, CREATE_PROJECT_REFOCUS_DELAY_MS); return () => globalThis.clearTimeout(timerId); }, [active, initialName]); diff --git a/desktop/src/features/projects/ui/createProjectFormFocus.ts b/desktop/src/features/projects/ui/createProjectFormFocus.ts new file mode 100644 index 00000000000..507ed8dbecc --- /dev/null +++ b/desktop/src/features/projects/ui/createProjectFormFocus.ts @@ -0,0 +1,5 @@ +/** + * Delay before the create-project form focuses its name field again after it + * opens. E2E specs import it to wait out that refocus before filling the form. + */ +export const CREATE_PROJECT_REFOCUS_DELAY_MS = 50; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e03c85d40d4..e12ab3be505 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -377,6 +377,11 @@ type E2eConfig = { /** Reject successive mock `join_channel` calls, then resume. */ joinChannelErrors?: string[]; channelsReadDelayMs?: number; + /** + * Hold the first `get_channels` read until + * `__BUZZ_E2E_RELEASE_CHANNELS_READ__` is called. + */ + deferFirstChannelsRead?: boolean; /** Return not-modified for this many reads before resuming full payloads. */ channelsNotModifiedResponses?: number; /** When true, a matching knownHash returns a not-modified channel payload. */ @@ -1352,6 +1357,11 @@ declare global { event: string, payload: unknown, ) => Promise; + /** + * Number of live app listeners for a mocked Tauri event. Spec + * subscriptions made through `__TAURI_INTERNALS__.listen` are not counted. + */ + __BUZZ_E2E_EVENT_LISTENER_COUNT__?: (event: string) => number; __BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__?: (input: { members: MockHuddleMemberSeed[]; transcriptionEnabled: boolean; @@ -11824,7 +11834,7 @@ export function maybeInstallE2eTauriMocks() { for (const release of held) release(); return held.length; }; - deferNextChannelsRead = false; + deferNextChannelsRead = config.mock?.deferFirstChannelsRead === true; deferredChannelsReadResolve = null; window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0; window.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__ = () => { @@ -15384,6 +15394,11 @@ export function maybeInstallE2eTauriMocks() { const tauriInternals = ( window as typeof window & { __TAURI_INTERNALS__: { + invoke: ( + command: string, + args?: unknown, + options?: unknown, + ) => Promise; listen?: ( event: string, callback: () => void, @@ -15391,10 +15406,42 @@ export function maybeInstallE2eTauriMocks() { }; } ).__TAURI_INTERNALS__; + // The mocked emit drops events that have no listener yet. Track live app + // listeners per event so specs can wait for the app to subscribe first. + // Spec subscriptions pass this target object, which listen() forwards as is + // and the mock ignores, so a spec that subscribes first cannot satisfy that + // wait for the app. + const specListenTarget = { kind: "Any" } as const; + const eventListenerIds = new Map>(); + const mockedInvoke = tauriInternals.invoke; + tauriInternals.invoke = async (command, args, options) => { + const result = await mockedInvoke(command, args, options); + const { event, eventId, target } = (args ?? {}) as { + event?: string; + eventId?: unknown; + target?: unknown; + }; + if ( + event && + command === "plugin:event|listen" && + target !== specListenTarget + ) { + const ids = eventListenerIds.get(event) ?? new Set(); + ids.add(result); + eventListenerIds.set(event, ids); + } else if (event && command === "plugin:event|unlisten") { + eventListenerIds.get(event)?.delete(eventId); + } + return result; + }; + window.__BUZZ_E2E_EVENT_LISTENER_COUNT__ = (event) => + eventListenerIds.get(event)?.size ?? 0; // Page-evaluated E2E specs use this surface; delegate to Tauri's mocked channel // so their listeners observe the same events emitted by application test seams. tauriInternals.listen = async (event, callback) => { - const unlisten = await listen(event, () => callback()); + const unlisten = await listen(event, () => callback(), { + target: specListenTarget, + }); return async () => unlisten(); }; diff --git a/desktop/tests/e2e/agent-availability.spec.ts b/desktop/tests/e2e/agent-availability.spec.ts index ad59e96e2a0..947b6300fbf 100644 --- a/desktop/tests/e2e/agent-availability.spec.ts +++ b/desktop/tests/e2e/agent-availability.spec.ts @@ -725,6 +725,9 @@ for (const scenario of [ } else { await expect.poll(effects).toEqual(["shutdown"]); await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await expect(page.locator("[data-sonner-toast]")).toContainText( + "shutdown refused", + ); // Retry the same user action without healing presence. A successful // request permits the explicitly confirmed local deletion, not a claim // of remote termination. No membership write precedes it. @@ -733,8 +736,16 @@ for (const scenario of [ window as typeof window & { __DELETE_FAIL_SHUTDOWN__?: boolean } ).__DELETE_FAIL_SHUTDOWN__ = false; }); - await page.getByTestId("user-profile-delete-agent-row").click(); - await page.getByTestId("agent-delete-confirm-action").click(); + // The failure toast can cover the action rows, and a pointer over it + // pauses its dismissal. The toaster has no close button, so retry with + // the keyboard, which the toast cannot intercept. + const deleteRow = page.getByTestId("user-profile-delete-agent-row"); + await expect(deleteRow).toBeEnabled(); + await deleteRow.focus(); + await deleteRow.press("Enter"); + const confirmDelete = page.getByTestId("agent-delete-confirm-action"); + await confirmDelete.focus(); + await confirmDelete.press("Enter"); await expect .poll(effects) .toEqual(["shutdown", "shutdown", "delete", "remove-member"]); diff --git a/desktop/tests/e2e/channel-composer-overflow.spec.ts b/desktop/tests/e2e/channel-composer-overflow.spec.ts index 6ba13532a9d..2acfc801a3b 100644 --- a/desktop/tests/e2e/channel-composer-overflow.spec.ts +++ b/desktop/tests/e2e/channel-composer-overflow.spec.ts @@ -35,15 +35,17 @@ async function emit( page: import("@playwright/test").Page, content: string, parentEventId: string | null = null, + createdAt?: number, ) { const event = await page.evaluate( (payload) => window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: payload.channel, content: payload.content, + createdAt: payload.createdAt, parentEventId: payload.parentEventId, }), - { channel: CHANNEL, content, parentEventId }, + { channel: CHANNEL, content, createdAt, parentEventId }, ); if (!event) throw new Error("mock message emitter is not installed"); return event as { created_at: number; id: string }; @@ -221,9 +223,14 @@ test.describe("composer overlays mask scrolled content", () => { await expect(page.getByTestId("message-timeline")).toBeVisible(); await waitForMockLiveSubscription(page, CHANNEL); + // Give each message its own second. Same-second messages order by id, so + // the last one emitted is not necessarily the bottom row. + const base = Math.floor(Date.now() / 1000); let newestMessageId = ""; for (let i = 0; i < 20; i++) { - newestMessageId = (await emit(page, `Bottom-clearance message ${i}`)).id; + newestMessageId = ( + await emit(page, `Bottom-clearance message ${i}`, null, base + i) + ).id; } await page.waitForTimeout(300); await page.evaluate(() => { @@ -241,40 +248,27 @@ test.describe("composer overlays mask scrolled content", () => { ); await waitForAnimations(page); - const clearsComposer = await page.evaluate((messageId) => { - const composer = document.querySelector( - '[data-testid="channel-composer-overlay"] [data-testid="message-composer"]', - ); - const newestRow = document.querySelector( - `[data-message-id="${CSS.escape(messageId)}"]`, - ); - if (!composer || !newestRow) return false; - return ( - newestRow.getBoundingClientRect().bottom <= - composer.getBoundingClientRect().top - ); - }, newestMessageId); - expect(clearsComposer).toBe(true); + // The timeline re-pins to the bottom on the composer's resize, which can + // land after the animations settle, so poll rather than measure once. + const newestClearsComposer = () => + page.evaluate((messageId) => { + const composer = document.querySelector( + '[data-testid="channel-composer-overlay"] [data-testid="message-composer"]', + ); + const newestRow = document.querySelector( + `[data-message-id="${CSS.escape(messageId)}"]`, + ); + return Boolean( + composer && + newestRow && + newestRow.getBoundingClientRect().bottom <= + composer.getBoundingClientRect().top, + ); + }, newestMessageId); + await expect.poll(newestClearsComposer).toBe(true); await page.setViewportSize({ width: 1280, height: 620 }); - await expect - .poll(() => - page.evaluate((messageId) => { - const composer = document.querySelector( - '[data-testid="channel-composer-overlay"] [data-testid="message-composer"]', - ); - const newestRow = document.querySelector( - `[data-message-id="${CSS.escape(messageId)}"]`, - ); - return Boolean( - composer && - newestRow && - newestRow.getBoundingClientRect().bottom <= - composer.getBoundingClientRect().top, - ); - }, newestMessageId), - ) - .toBe(true); + await expect.poll(newestClearsComposer).toBe(true); }); test("thread panel replies fade out behind the reply composer", async ({ diff --git a/desktop/tests/e2e/message-copy-link.spec.ts b/desktop/tests/e2e/message-copy-link.spec.ts index b2e1b0f5a23..95f7a8a8f75 100644 --- a/desktop/tests/e2e/message-copy-link.spec.ts +++ b/desktop/tests/e2e/message-copy-link.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test"; import { KIND_HUDDLE_STARTED } from "../../src/shared/constants/kinds"; import { installMockBridge } from "../helpers/bridge"; +import { waitForMockChannelHeadReady } from "../helpers/channelHeadReady"; const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; @@ -163,21 +164,19 @@ test("pending and huddle rows omit both copy-link surfaces", async ({ await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - await expect - .poll(() => - page.evaluate( - () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", - ), - ) - .toBe(true); + // Emit only after history and its head refresh settle, so neither can + // replace the rows under test. + await waitForMockChannelHeadReady(page, "general", GENERAL_CHANNEL_ID); + // The ids must not match a seeded #general message. "d".repeat(64) is the + // seeded reaction target, which has a copy-link button of its own. const { huddleId, pendingId } = await page.evaluate((huddleKind) => { const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; if (!emit) throw new Error("Mock message emitter is unavailable."); const pending = emit({ channelName: "general", content: "Pending copy-link regression", - id: "c".repeat(64), + id: "2e".repeat(32), pending: true, }); const huddle = emit({ @@ -185,7 +184,7 @@ test("pending and huddle rows omit both copy-link surfaces", async ({ content: JSON.stringify({ ephemeral_channel_id: "10000000-0000-4000-8000-000000000001", }), - id: "d".repeat(64), + id: "1d".repeat(32), kind: huddleKind, }); return { huddleId: huddle.id, pendingId: pending.id }; diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 90ae8cc64f9..45f32fcd9b6 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -733,7 +733,15 @@ test("message links to visible root messages open the thread panel", async ({ name: "Open message in channel general", }); await expect(rootThreadLink).toHaveText("general"); - const pendingChipBox = await rootThreadLink.boundingBox(); + // The sent row can still be mounting here, and boundingBox() then reads + // null. Poll until the chip has laid out and keep that pending width. + let pendingChipWidth = 0; + await expect + .poll(async () => { + pendingChipWidth = (await rootThreadLink.boundingBox())?.width ?? 0; + return pendingChipWidth; + }) + .toBeGreaterThan(0); await expect .poll(() => page.evaluate( @@ -776,9 +784,7 @@ test("message links to visible root messages open the thread panel", async ({ // channel label at the exact width it had while the fetch was in flight, and // never the fetched snippet or the truncated event hash. await expect(rootThreadLink).toHaveText("general"); - expect((await rootThreadLink.boundingBox())?.width).toBe( - pendingChipBox?.width, - ); + expect((await rootThreadLink.boundingBox())?.width).toBe(pendingChipWidth); await expect( messageTooltip.locator('[data-buzz-tooltip-metadata-content=""]'), ).toHaveClass(/line-clamp-3/); diff --git a/desktop/tests/e2e/nostr-bind.spec.ts b/desktop/tests/e2e/nostr-bind.spec.ts index 17677ea46e1..8ea3cfb1e9c 100644 --- a/desktop/tests/e2e/nostr-bind.spec.ts +++ b/desktop/tests/e2e/nostr-bind.spec.ts @@ -57,13 +57,12 @@ async function openNostrBind( ) { await installMockBridge(page, mock); await page.goto("/"); + // The mocked emit drops events with no listener, and the app subscribes to + // deep links only after boot. Wait for that listener before emitting. await page.waitForFunction( () => - typeof ( - window as Window & { - __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; - } - ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function", + (window.__BUZZ_E2E_EVENT_LISTENER_COUNT__?.("deep-link-nostr-bind") ?? + 0) > 0, ); await emitNostrBind(page, payload); await expect(page.getByTestId("nostr-bind-page")).toBeVisible(); diff --git a/desktop/tests/e2e/overscroll-boundary.spec.ts b/desktop/tests/e2e/overscroll-boundary.spec.ts index e7e561c1252..d7b23f468f6 100644 --- a/desktop/tests/e2e/overscroll-boundary.spec.ts +++ b/desktop/tests/e2e/overscroll-boundary.spec.ts @@ -27,6 +27,23 @@ async function dispatchWheelPrevented( ); } +// The `message-timeline` test id first lands on the loading skeleton, which +// does not overflow, so the lock rightly cancels a wheel there. Wait for the +// real conversation scroller: a rendered row and content taller than the +// viewport. +async function waitForConversationScrollable( + timeline: import("@playwright/test").Locator, +) { + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await expect + .poll(() => + timeline.evaluate( + (element) => element.scrollHeight - element.clientHeight, + ), + ) + .toBeGreaterThan(1); +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); @@ -36,7 +53,7 @@ test("locks viewport rubber-band outside conversation scrollers", async ({ }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("message-timeline")).toBeVisible(); + await waitForConversationScrollable(page.getByTestId("message-timeline")); await expect( dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', { @@ -84,7 +101,7 @@ test("locks viewport rubber-band outside conversation scrollers", async ({ test("locks horizontal viewport pan everywhere", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("message-timeline")).toBeVisible(); + await waitForConversationScrollable(page.getByTestId("message-timeline")); for (const deltaX of [-120, 120]) { await expect( diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 128152fbecf..82c44f71be5 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1591,8 +1591,16 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a activeTab: string | undefined; x: number[]; }> = []; - const startedAt = performance.now(); + // Time the window from the tab switch, not from before the click: + // a slow click or frame would otherwise spend it before the motion. + let startedAt: number | null = null; const sample = () => { + if ( + startedAt === null && + container.dataset.activeTab === "runtime" + ) { + startedAt = performance.now(); + } samples.push({ activeTab: container.dataset.activeTab, x: Array.from(container.children).map((child) => { @@ -1602,7 +1610,7 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a : new DOMMatrixReadOnly(transform).m41; }), }); - if (performance.now() - startedAt < 320) { + if (startedAt === null || performance.now() - startedAt < 320) { requestAnimationFrame(sample); } else { resolve(samples); diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 2ee454aefcc..dcf5badeecd 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; +import { CREATE_PROJECT_REFOCUS_DELAY_MS } from "../../src/features/projects/ui/createProjectFormFocus"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -262,6 +263,15 @@ test("creating a project opens its channel conversation", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await openCreateProjectDialog(page); + // The dialog autofocuses the name field on open and focuses it again from a + // timer, which can land mid-fill and type the description into the name. + // Once the field is focused, that timer is set; a page timer of the same + // delay set now fires after it (equal-delay timers run in order). + await expect(page.getByTestId("create-project-name")).toBeFocused(); + await page.evaluate( + (delayMs) => new Promise((resolve) => window.setTimeout(resolve, delayMs)), + CREATE_PROJECT_REFOCUS_DELAY_MS, + ); await page.getByTestId("create-project-name").fill("multi-repo-demo"); await page .getByTestId("create-project-description") diff --git a/desktop/tests/e2e/project-conversation-load-failure.spec.ts b/desktop/tests/e2e/project-conversation-load-failure.spec.ts index 7ed1a88256b..4fa2b7e524c 100644 --- a/desktop/tests/e2e/project-conversation-load-failure.spec.ts +++ b/desktop/tests/e2e/project-conversation-load-failure.spec.ts @@ -77,12 +77,15 @@ test.describe("project conversation load failure", () => { await installMockBridge(page); await page.goto("/", { waitUntil: "domcontentloaded" }); - // Seed the conversation BEFORE opening general — i.e. before its live - // subscription exists — so the reply lands in the mock store (searchable, - // and returnable by a successful get_thread_replies) but is never live - // pushed into the thread-replies cache. If general were open first, the - // live handler would seed that cache and the panel would render the list - // branch, masking the error card. The root carries the repository discovery + // Seed the conversation into the mock store (searchable, and returnable by + // a successful get_thread_replies) without it ever being live-pushed into + // the thread-replies cache: a live reply would make the panel render the + // list branch and mask the error card. Seeding before any subscription + // exists is not enough on its own — the panel's live REQ uses + // `since = floor(now)` and the mock replays stored events with + // `created_at >= since`, so a reply dated in the current or next second + // still arrives live. Date both events a minute in the past so neither can + // fall inside that window. The root carries the repository discovery // token so it surfaces as the channel's latest discussion hit (what the // Channels-tab row opens); its reply omits the token so it never competes // to be the opened hit. @@ -119,14 +122,14 @@ test.describe("project conversation load failure", () => { channelName: "general", content: rootContent, pubkey: author, - createdAt: now, + createdAt: now - 60, }); emit({ channelName: "general", content: replyContent, parentEventId: root.id, pubkey: author, - createdAt: now + 1, + createdAt: now - 59, }); return root.id; }, diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts index 2524a2b30ed..37452d2eac0 100644 --- a/desktop/tests/e2e/project-pr-review.spec.ts +++ b/desktop/tests/e2e/project-pr-review.spec.ts @@ -3529,6 +3529,11 @@ test("project task can be created with a category from the tasks header", async .getByTestId("project-section-header") .getByRole("button", { name: "Create task" }) .click(); + // The dialog focuses the title field from a timer after opening. Filling + // before then lets that focus move land mid-fill and type the body into the + // title. The category select comes first, so the dialog's own autofocus + // never lands on the title and this waits for the timer, whatever its delay. + await expect(page.getByTestId("create-issue-title")).toBeFocused(); await page .getByTestId("create-issue-category") .selectOption("change-request"); diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 5313bdaf727..19332522fdd 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -1353,7 +1353,6 @@ test("fast middle-page scroll settles with continuous mounted coverage", async ( element.dispatchEvent(new Event("scroll", { bubbles: true })); } }); - await page.waitForTimeout(250); const viewportCoverage = () => timeline.evaluate((element) => { @@ -1377,6 +1376,11 @@ test("fast middle-page scroll settles with continuous mounted coverage", async ( return Math.max(largestGap, viewport.bottom - cursor); }); + // Virtua remounts the new range a few frames after the last scroll; under + // load that can take longer than any fixed wait. Wait for coverage first, + // then sample it while idle: a stale range stays blank, so it still fails. + await expect.poll(viewportCoverage).toBeLessThan(100); + // A day heading can produce a small legitimate gap between message rows; // the stale-range failure leaves a viewport-scale hole. Check repeatedly // while idle so a transient good frame cannot mask a stuck blank range. diff --git a/desktop/tests/e2e/sidebar-snapshot.spec.ts b/desktop/tests/e2e/sidebar-snapshot.spec.ts index bff5af61209..8395d3659ea 100644 --- a/desktop/tests/e2e/sidebar-snapshot.spec.ts +++ b/desktop/tests/e2e/sidebar-snapshot.spec.ts @@ -35,6 +35,17 @@ function makeSnapshotChannel(index: number, prefix = "snapshot") { }; } +// Cold-boot tests hold the first channels read (deferFirstChannelsRead) so the +// loading state is on screen however long boot takes, then release it here. +async function releaseFirstChannelsRead(page: Page) { + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_CHANNELS_READ_PENDING__)) + .toBe(1); + expect( + await page.evaluate(() => window.__BUZZ_E2E_RELEASE_CHANNELS_READ__?.()), + ).toBe(1); +} + const FULL_SNAPSHOT = Array.from({ length: 14 }, (_, index) => makeSnapshotChannel(index), ); @@ -358,13 +369,12 @@ test("matching not-modified preserves display mutations without persisting them" test("first-ever boot without a snapshot sends null and shows loading", async ({ page, }) => { - await installMockBridge(page, { channelsReadDelayMs: READ_DELAY_MS }); + await installMockBridge(page, { deferFirstChannelsRead: true }); await page.goto("/"); - await expect(page.getByTestId("sidebar-loading")).toBeVisible({ - timeout: 500, - }); + await expect(page.getByTestId("sidebar-loading")).toBeVisible(); await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(0); + await releaseFirstChannelsRead(page); await expect .poll(() => getChannelsPayloads(page)) .toEqual([{ knownHash: null }]); @@ -391,13 +401,12 @@ test("a different identity's snapshot is ignored", async ({ page }) => { keyOwnerPubkey: OWNER_PUBKEY, ownerPubkey: STALE_COMMUNITY_PUBKEY, }); - await installMockBridge(page, { channelsReadDelayMs: READ_DELAY_MS }); + await installMockBridge(page, { deferFirstChannelsRead: true }); await page.goto("/"); - await expect(page.getByTestId("sidebar-loading")).toBeVisible({ - timeout: 500, - }); + await expect(page.getByTestId("sidebar-loading")).toBeVisible(); await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(0); + await releaseFirstChannelsRead(page); await expect .poll(() => getChannelsPayloads(page)) .toEqual([{ knownHash: null }]); @@ -447,15 +456,14 @@ test("partial hash/list write fails toward a full fetch", async ({ page }) => { ); }, snapshotKey(RELAY_URL)); await installMockBridge(page, { - channelsReadDelayMs: READ_DELAY_MS, + deferFirstChannelsRead: true, honorChannelsKnownHash: true, }); await page.goto("/"); - await expect(page.getByTestId("sidebar-loading")).toBeVisible({ - timeout: 500, - }); + await expect(page.getByTestId("sidebar-loading")).toBeVisible(); await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(0); + await releaseFirstChannelsRead(page); await expect .poll(() => getChannelsPayloads(page)) .toEqual([{ knownHash: null }]); diff --git a/desktop/tests/e2e/thread-load-failure.spec.ts b/desktop/tests/e2e/thread-load-failure.spec.ts index 291600e736b..5195b442b66 100644 --- a/desktop/tests/e2e/thread-load-failure.spec.ts +++ b/desktop/tests/e2e/thread-load-failure.spec.ts @@ -54,24 +54,15 @@ async function setThreadRepliesFailing(page: Page, failing: boolean) { }, failing); } -// Open the welcome thread deterministically: prefer its summary row, but fall -// back to hovering the root message and clicking Reply. The summary row depends -// on the channel-window query having materialized the seeded reply, which can -// lag; the root Reply affordance opens the same thread panel without that race, -// so the panel is reliably open before the error-card assertions run. +// Open the welcome thread from its "1 reply" summary row. The row appears only +// once the channel window has materialized the seeded reply, which can lag, so +// wait for it rather than probing it once. async function openWelcomeThread(page: Page) { const summary = page.locator( '[data-testid="message-thread-summary"][data-thread-head-id="mock-general-welcome"]', ); - if (await summary.count()) { - await summary.first().click(); - } else { - const root = page.locator( - '[data-testid="message-row"][data-message-id="mock-general-welcome"]', - ); - await root.hover(); - await root.getByRole("button", { name: "Reply" }).click(); - } + await expect(summary).toBeVisible(); + await summary.click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); } diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index 463096ce7bb..7a858d8ebe9 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -31,7 +31,12 @@ async function seedChannelSections(page: Page) { // 6px distance constraint, so a single move never starts a drag. This walks the // pointer down, past the activation threshold, onto the target, then releases — // the sequence dnd-kit needs to fire onDragEnd and commit the reorder. -async function dragOver(page: Page, source: Locator, target: Locator) { +async function dragOver( + page: Page, + source: Locator, + target: Locator, + targetSectionId: string, +) { const from = await source.boundingBox(); if (!from) throw new Error("drag source not laid out"); const pointer = { @@ -64,11 +69,13 @@ async function dragOver(page: Page, source: Locator, target: Locator) { const to = await target.boundingBox(); if (!to) throw new Error("drag target not laid out"); + // Aim at the target's centre, well inside its droppable rect, not 2 px + // above its bottom edge. const destination = { x: to.x + to.width / 2, - y: to.y + to.height - 2, + y: to.y + to.height / 2, }; - await page.evaluate(async ({ x, y }) => { + await page.evaluate(({ x, y }) => { document.dispatchEvent( new PointerEvent("pointermove", { bubbles: true, @@ -80,9 +87,18 @@ async function dragOver(page: Page, source: Locator, target: Locator) { pointerType: "mouse", }), ); - await new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }); + }, destination); + // dnd-kit announces each new drop target in its live region. Release only + // after it names the target (its sortable row or its body), rather than + // after a fixed number of frames, so the drop cannot race the collision. + await expect( + page.locator('[id^="DndLiveRegion-"]').filter({ + hasText: new RegExp( + `moved over droppable area (section-drop:)?${targetSectionId}\\.`, + ), + }), + ).toHaveCount(1); + await page.evaluate(({ x, y }) => { document.dispatchEvent( new PointerEvent("pointerup", { bubbles: true, @@ -235,7 +251,7 @@ test.describe("list virtualization", () => { // Drag "Priority" past "Archive" — onDragEnd commits arrayMove and persists // the new order. The drop must land for the order to flip. - await dragOver(page, topHeaderButton, bottomHeader); + await dragOver(page, topHeaderButton, bottomHeader, SECTION_BOTTOM.id); // The drop landed: order flipped. A no-op drag would leave it unchanged. await expect.poll(sectionOrder).toEqual(["Archive", "Priority"]); @@ -334,46 +350,75 @@ test.describe("list virtualization", () => { }), ) : Promise.resolve(false); - const wheelTracePromise = timeline.evaluate(async (scroller) => { - const s = scroller as HTMLElement; - let previousScrollTop = s.scrollTop; - let maxBoundaryRollback = 0; - let minScrollTop = s.scrollTop; - const deadline = performance.now() + 120; - while (performance.now() < deadline) { - maxBoundaryRollback = Math.max( - maxBoundaryRollback, - s.scrollTop - previousScrollTop, - ); - previousScrollTop = s.scrollTop; - minScrollTop = Math.min(minScrollTop, s.scrollTop); - await new Promise((resolve) => requestAnimationFrame(resolve)); - } - return { maxBoundaryRollback, minScrollTop }; - }); + // CDP can deliver a wheel burst late (Linux Chromium more than macOS), + // so a window timed from before the burst can close before the input + // lands. Time the 120 ms window from the first wheel the page receives + // and keep it open until every wheel has arrived. Stop early at the + // prepend commit: its anchor correction is not a boundary rollback. + const upDeltas = [-60, -30, -20, -15]; + const wheelTracePromise = timeline.evaluate( + async (scroller, wheelCount) => { + const s = scroller as HTMLElement; + let received = 0; + const countWheel = (event: WheelEvent) => { + if (!event.ctrlKey) received += 1; + }; + s.addEventListener("wheel", countWheel, { passive: true }); + const startHeight = s.scrollHeight; + let previousScrollTop = s.scrollTop; + let maxBoundaryRollback = 0; + let minScrollTop = s.scrollTop; + let firstWheelAt: number | null = null; + const giveUpAt = performance.now() + 5_000; + while (performance.now() < giveUpAt) { + if (s.scrollHeight > startHeight + 800) break; + if (firstWheelAt === null && received > 0) { + firstWheelAt = performance.now(); + } + maxBoundaryRollback = Math.max( + maxBoundaryRollback, + s.scrollTop - previousScrollTop, + ); + previousScrollTop = s.scrollTop; + minScrollTop = Math.min(minScrollTop, s.scrollTop); + if ( + received >= wheelCount && + firstWheelAt !== null && + performance.now() - firstWheelAt >= 120 + ) { + break; + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + s.removeEventListener("wheel", countWheel); + return { maxBoundaryRollback, minScrollTop, received }; + }, + upDeltas.length, + ); const box = await timeline.boundingBox(); if (!box) throw new Error("timeline has no bounding box"); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - for (const deltaY of [-60, -30, -20, -15]) { + for (const deltaY of upDeltas) { await page.mouse.wheel(0, deltaY); await page.waitForTimeout(12); } const wheelTrace = await wheelTracePromise; + expect(wheelTrace.received).toBe(upDeltas.length); expect(wheelTrace.minScrollTop).toBeLessThanOrEqual(350); expect(wheelTrace.maxBoundaryRollback).toBeLessThan(5); - // Linux Chromium delivers CDP wheel input with more latency than macOS, - // so the burst's final delta can land AFTER the anchor baseline sample - // below. maxDrift then reports the reader's own last wheel event as - // anchor drift (measured exactly 15 = the -15 delta; changing the delta - // to -17 made the failure read 17). Gate the baseline on input settle: - // two consecutive frames with identical scrollTop. This tightens the - // assertion rather than diluting it — the baseline becomes honest and - // genuine post-prepend drift still reads as drift. + // A late final delta lands AFTER an anchor baseline taken during a + // pause in the burst, and maxDrift then reports the reader's own wheel + // as anchor drift (it once read exactly 15, the -15 delta). wheelTrace + // returned only after every wheel arrived; now wait for the scroll they + // caused to hold still for three frames. This tightens the assertion + // rather than diluting it — the baseline becomes honest and genuine + // post-prepend drift still reads as drift. await timeline.evaluate(async (element) => { let prior = element.scrollTop; - for (let frame = 0; frame < 30; frame += 1) { + let stillFrames = 0; + for (let frame = 0; frame < 60 && stillFrames < 3; frame += 1) { await new Promise((resolve) => requestAnimationFrame(resolve)); - if (element.scrollTop === prior) break; + stillFrames = element.scrollTop === prior ? stillFrames + 1 : 0; prior = element.scrollTop; } }); @@ -444,33 +489,65 @@ test.describe("list virtualization", () => { // That reader intent retires Virtua's active prepend reconciliation, so // later row measurements must not pull the viewport back toward the // completed prepend before the next upward load. - const exitTracePromise = timeline.evaluate(async (scroller) => { - const s = scroller as HTMLElement; - const startScrollTop = s.scrollTop; - let previousScrollTop = startScrollTop; - let maxForwardTravel = 0; - let maxRollback = 0; - const deadline = performance.now() + 400; - while (performance.now() < deadline) { - const travel = s.scrollTop - startScrollTop; - maxForwardTravel = Math.max(maxForwardTravel, travel); - maxRollback = Math.max(maxRollback, previousScrollTop - s.scrollTop); - previousScrollTop = s.scrollTop; - await new Promise((resolve) => requestAnimationFrame(resolve)); - } - return { maxForwardTravel, maxRollback }; - }); + // Same late-delivery guard as the upward burst: time the 400 ms window + // from the first wheel received, and close it only once every wheel has + // arrived and the scroll has held still for two frames. + const downDeltas = [120, 100, 80]; + const exitTracePromise = timeline.evaluate( + async (scroller, wheelCount) => { + const s = scroller as HTMLElement; + let received = 0; + const countWheel = (event: WheelEvent) => { + if (!event.ctrlKey) received += 1; + }; + s.addEventListener("wheel", countWheel, { passive: true }); + const startScrollTop = s.scrollTop; + let previousScrollTop = startScrollTop; + let maxForwardTravel = 0; + let maxRollback = 0; + let firstWheelAt: number | null = null; + let stillFrames = 0; + const giveUpAt = performance.now() + 5_000; + while (performance.now() < giveUpAt) { + if (firstWheelAt === null && received > 0) { + firstWheelAt = performance.now(); + } + const travel = s.scrollTop - startScrollTop; + maxForwardTravel = Math.max(maxForwardTravel, travel); + maxRollback = Math.max( + maxRollback, + previousScrollTop - s.scrollTop, + ); + stillFrames = + s.scrollTop === previousScrollTop ? stillFrames + 1 : 0; + previousScrollTop = s.scrollTop; + if ( + received >= wheelCount && + firstWheelAt !== null && + performance.now() - firstWheelAt >= 400 && + stillFrames >= 2 + ) { + break; + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + s.removeEventListener("wheel", countWheel); + return { maxForwardTravel, maxRollback, received }; + }, + downDeltas.length, + ); const exitBox = await timeline.boundingBox(); if (!exitBox) throw new Error("timeline has no bounding box"); await page.mouse.move( exitBox.x + exitBox.width / 2, exitBox.y + exitBox.height / 2, ); - for (const deltaY of [120, 100, 80]) { + for (const deltaY of downDeltas) { await page.mouse.wheel(0, deltaY); await page.waitForTimeout(12); } const exitTrace = await exitTracePromise; + expect(exitTrace.received).toBe(downDeltas.length); expect(exitTrace.maxForwardTravel).toBeGreaterThan(200); expect(exitTrace.maxRollback).toBeLessThan(5); diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index d9548589392..51856be1fba 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -15,12 +15,18 @@ async function navigateToWorkflows(page: import("@playwright/test").Page) { await expect(page.getByTestId("workflows-view")).toBeVisible(); } +// A new workflow's channel picker opens itself one animation frame after the +// dialog mounts. A visibility probe taken before that frame reads "closed", +// and the click that follows can land after it and toggle the list shut, so +// create dialogs wait for the list instead. Dialogs that never open it +// themselves (duplicate) open it with a click. async function selectWorkflowChannel( page: import("@playwright/test").Page, dialog: import("@playwright/test").Locator, + { opensItself = true }: { opensItself?: boolean } = {}, ) { const channelList = page.getByTestId("channel-combobox-list"); - if (!(await channelList.isVisible())) { + if (!opensItself) { await dialog.getByRole("combobox", { name: "Channel" }).click(); } await expect(channelList).toBeVisible(); @@ -98,10 +104,16 @@ async function createWorkflow( await page.getByRole("menuitem", { name: "Send Message" }).click(); // The inspector swaps from the trigger to the new step after the click; an // unscoped fill can land in the trigger's message-text condition instead. + // The "Action" header switches at once, but the trigger body stays mounted + // through its exit animation (AnimatePresence mode="wait"), so also wait + // for a control only the step body renders. const stepInspector = dialog.getByTestId("workflow-node-inspector"); await expect( stepInspector.getByRole("button", { name: "Action", exact: true }), ).toBeVisible(); + await expect( + stepInspector.getByRole("button", { name: "Step details" }), + ).toBeVisible(); await stepInspector.getByLabel("Message text").fill("Workflow notification"); if (options?.stepName) { await dialog.getByRole("button", { name: "Step details" }).click(); @@ -574,7 +586,7 @@ test("duplicates a workflow", async ({ page }) => { // Submit the duplicate after deliberately choosing its destination channel. const dialog = page.getByRole("dialog", { name: "Duplicate workflow" }); - await selectWorkflowChannel(page, dialog); + await selectWorkflowChannel(page, dialog, { opensItself: false }); await dialog.getByRole("button", { name: "Create copy" }).click(); const activationConfirmation = page.getByRole("alertdialog", { name: "This workflow may run often", diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c074cc419f8..3eaf58a5e4b 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -285,6 +285,11 @@ type MockBridgeOptions = { /** Reject successive mock `join_channel` calls, then resume. */ joinChannelErrors?: string[]; channelsReadDelayMs?: number; + /** + * Hold the first `get_channels` read until + * `__BUZZ_E2E_RELEASE_CHANNELS_READ__` is called. + */ + deferFirstChannelsRead?: boolean; /** Return not-modified for this many reads before resuming full payloads. */ channelsNotModifiedResponses?: number; /** When true, a matching knownHash returns a not-modified channel payload. */ From c6f493f2336b6d7647c7f344f480bd146c3eefa7 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 20:48:05 +1100 Subject: [PATCH 2/2] test(desktop): count app event listeners without rewrapping invoke Wrapping `__TAURI_INTERNALS__.invoke` after mockIPC broke specs that trap mockIPC's assignment of it: terminal-wheel recursed through its own trap and the app never booted, and agent-control-regressions applied its observer hold twice, so the second hold was never released. Mock the event plugin inside the IPC callback instead, as `shouldMockEvents` does, and count live app listeners there. Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- desktop/src/testing/e2eBridge.ts | 98 +++++++++++++++++++------------- 1 file changed, 59 insertions(+), 39 deletions(-) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e12ab3be505..9191b3f7ab7 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8781,7 +8781,7 @@ async function replayInstallOutput( ); } // `emit` reaches listeners registered through the real `listen` API, which - // is what the UI hook uses; mockIPC's shouldMockEvents wires the two. + // is what the UI hook uses; the bridge's mocked event plugin wires the two. await emit("acp-install-output", { runtime_id: runtimeId, seq: installOutputSeq++, @@ -15390,55 +15390,75 @@ export function maybeInstallE2eTauriMocks() { handleMockCommand(command, payload ?? null); window.__BUZZ_E2E_EMIT_TAURI_EVENT__ = (event, payload) => emit(event, payload); - mockIPC(handleMockCommand, { shouldMockEvents: true }); - const tauriInternals = ( - window as typeof window & { - __TAURI_INTERNALS__: { - invoke: ( - command: string, - args?: unknown, - options?: unknown, - ) => Promise; - listen?: ( - event: string, - callback: () => void, - ) => Promise<() => Promise>; - }; - } - ).__TAURI_INTERNALS__; - // The mocked emit drops events that have no listener yet. Track live app - // listeners per event so specs can wait for the app to subscribe first. + // The mocked emit drops events that have no listener yet. The event plugin is + // mocked here, as mockIPC's `shouldMockEvents` would, so live app listeners + // can be counted per event and specs can wait for the app to subscribe first. + // Wrapping `__TAURI_INTERNALS__.invoke` instead would break specs that trap + // mockIPC's assignment of it. // Spec subscriptions pass this target object, which listen() forwards as is // and the mock ignores, so a spec that subscribes first cannot satisfy that // wait for the app. + const tauriInternals = () => + ( + window as typeof window & { + __TAURI_INTERNALS__: { + runCallback: (id: number, data: unknown) => void; + listen?: ( + event: string, + callback: () => void, + ) => Promise<() => Promise>; + }; + } + ).__TAURI_INTERNALS__; const specListenTarget = { kind: "Any" } as const; - const eventListenerIds = new Map>(); - const mockedInvoke = tauriInternals.invoke; - tauriInternals.invoke = async (command, args, options) => { - const result = await mockedInvoke(command, args, options); - const { event, eventId, target } = (args ?? {}) as { - event?: string; - eventId?: unknown; + const eventHandlers = new Map(); + const appEventHandlers = new Map>(); + const handleMockIpc = (command: string, payload: unknown) => { + const args = (payload ?? {}) as { + event: string; + handler?: number; + eventId?: number; target?: unknown; }; - if ( - event && - command === "plugin:event|listen" && - target !== specListenTarget - ) { - const ids = eventListenerIds.get(event) ?? new Set(); - ids.add(result); - eventListenerIds.set(event, ids); - } else if (event && command === "plugin:event|unlisten") { - eventListenerIds.get(event)?.delete(eventId); + switch (command) { + case "plugin:event|listen": { + const handler = args.handler as number; + eventHandlers.set(args.event, [ + ...(eventHandlers.get(args.event) ?? []), + handler, + ]); + if (args.target !== specListenTarget) { + const handlers = + appEventHandlers.get(args.event) ?? new Set(); + handlers.add(handler); + appEventHandlers.set(args.event, handlers); + } + return Promise.resolve(handler); + } + case "plugin:event|emit": + for (const handler of eventHandlers.get(args.event) ?? []) { + tauriInternals().runCallback(handler, args); + } + return Promise.resolve(null); + case "plugin:event|unlisten": + eventHandlers.set( + args.event, + (eventHandlers.get(args.event) ?? []).filter( + (handler) => handler !== args.eventId, + ), + ); + appEventHandlers.get(args.event)?.delete(args.eventId as number); + return Promise.resolve(null); + default: + return handleMockCommand(command, payload); } - return result; }; + mockIPC(handleMockIpc); window.__BUZZ_E2E_EVENT_LISTENER_COUNT__ = (event) => - eventListenerIds.get(event)?.size ?? 0; + appEventHandlers.get(event)?.size ?? 0; // Page-evaluated E2E specs use this surface; delegate to Tauri's mocked channel // so their listeners observe the same events emitted by application test seams. - tauriInternals.listen = async (event, callback) => { + tauriInternals().listen = async (event, callback) => { const unlisten = await listen(event, () => callback(), { target: specListenTarget, });