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..9191b3f7ab7 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; @@ -8771,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++, @@ -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__ = () => { @@ -15380,21 +15390,78 @@ 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__: { - listen?: ( - event: string, - callback: () => void, - ) => Promise<() => Promise>; - }; + // 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 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; + }; + 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); } - ).__TAURI_INTERNALS__; + }; + mockIPC(handleMockIpc); + window.__BUZZ_E2E_EVENT_LISTENER_COUNT__ = (event) => + 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) => { - const unlisten = await listen(event, () => callback()); + tauriInternals().listen = async (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. */