diff --git a/tests/browser/github-body.spec.mjs b/tests/browser/github-body.spec.mjs index a2e43df25c..b2ded6561f 100644 --- a/tests/browser/github-body.spec.mjs +++ b/tests/browser/github-body.spec.mjs @@ -151,6 +151,24 @@ test("PR media plays under the packaged media policy in a narrow GitHub panel", } for (const source of [before, after]) await expect(panel.locator(`a[href="${source}"]`)).toHaveCount(0); + // WebKit can finish a seek issued just after playback pauses without moving: + // seeked fires and currentTime stays where playback paused. Keep the + // fullscreen video from autoplaying, so the seek below follows no pause, and + // seek only after its initial-position restore has landed. + await page.evaluate(() => { + window.fullscreenRestored = new Promise((resolve) => { + const observer = new MutationObserver(() => { + const video = document.querySelector( + '[role="dialog"][aria-label="Video attachment"] video', + ); + if (!video) return; + observer.disconnect(); + video.pause(); + video.addEventListener("seeked", resolve, { once: true }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); + }); await panel .getByRole("button", { name: "Open video fullscreen" }) .nth(1) @@ -175,9 +193,9 @@ test("PR media plays under the packaged media policy in a narrow GitHub panel", ), ) .toBe(true); + await page.evaluate(() => window.fullscreenRestored); // Native seek controls are browser-owned; observe the real media seek boundary. const seek = await fullscreen.evaluate(async (element) => { - element.pause(); const seconds = element.duration / 2; const completed = new Promise((resolve) => element.addEventListener("seeked", resolve, { once: true }), diff --git a/tests/browser/inbox-archive.spec.mjs b/tests/browser/inbox-archive.spec.mjs index 9be0bc1158..af04bae933 100644 --- a/tests/browser/inbox-archive.spec.mjs +++ b/tests/browser/inbox-archive.spec.mjs @@ -1,6 +1,7 @@ import { test, expect } from "./fixture.mjs"; import { openPage } from "./navigation.mjs"; import { readJournal } from "./reading.mjs"; +import { frames } from "./timeline.mjs"; const channel = "f12918e7-88d0-4ddd-aa6b-d4888ff6d3bd"; // The Archive shortcut acts on the last opened conversation. @@ -25,6 +26,23 @@ test.use({ screenshot: "off", }); +// Opening a conversation reveals its entry reply, the window's first, by +// focusing that row. In WebKit the focus clears the editor selection, so a +// fill racing the reveal types nothing. The row can be in view before the +// reveal runs, and list aria-busy does not cover reader positioning: wait for +// the focus and the reveal's next-frame acknowledgement before filling. +async function revealed(page, inbox, app) { + await expect( + inbox.getByRole("list", { name: "Inbox conversations" }), + ).toHaveAttribute("aria-busy", "false"); + await expect( + conversations(inbox).locator( + `[data-message-id="${app.inboxWindow.replies[0].id}"]`, + ), + ).toBeFocused(); + await frames(page); +} + async function choose(page, inbox, control, option) { await inbox .getByRole("button", { name: "Inbox filters", exact: true }) @@ -449,6 +467,7 @@ test("Inbox sending keeps the conversation open until manually archived", async await expect( inbox.getByRole("checkbox", { name: "Archive on send" }), ).toHaveCount(0); + await revealed(page, inbox, app); const detail = conversations(inbox); const editor = detail.getByRole("textbox"); await editor.fill("Keep this conversation"); @@ -540,6 +559,7 @@ test("selected row button advances and menu closes without losing the original d const next = rows.filter({ hasText: "Unread neighbor" }); await expect(rows).toHaveCount(2); await thread.getByRole("button", { name: /^Open / }).click(); + await revealed(page, inbox, app); const editor = conversations(inbox).getByRole("textbox"); await editor.fill("Keep the selected archive draft"); await editor.evaluate((el) => { @@ -607,18 +627,7 @@ test("unselected row Archive preserves the open editor and unread neighbor", asy const next = rows.filter({ hasText: "Unread neighbor" }); await expect(rows).toHaveCount(2); await thread.getByRole("button", { name: /^Open / }).click(); - const target = conversations(inbox).locator( - `[data-message-id="${app.inboxWindow.replies[0].id}"]`, - ); - await expect(target).toBeInViewport(); - // Finish entry reveal and its next-frame acknowledgement before taking the - // editor/row-focus baseline; list aria-busy does not cover reader positioning. - await page.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)), - ), - ); + await revealed(page, inbox, app); const editor = conversations(inbox).getByRole("textbox"); await editor.fill("Keep the unselected archive draft"); await editor.evaluate((el) => { @@ -671,6 +680,7 @@ test("reply and fresh mention reopen their filters without replacing composer dr await choose(page, inbox, "Show", "Archived"); await expect(rows).toHaveCount(1); await rows.getByRole("button", { name: /^Open / }).click(); + await revealed(page, inbox, app); const detail = conversations(inbox); const editor = detail.getByRole("textbox"); await editor.fill("Unsent mention retention draft"); diff --git a/tests/browser/navigation-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs index 6cbb4e180c..90312fe44b 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -884,13 +884,18 @@ test("Create new retains its draft when preferences fail before submission and r await page.getByText("Diagnostics", { exact: true }).click(); const held = gate(), started = gate(); + // Transient at first, so relay recovery owns the failure and shows its notice. + let status = 502; const failLegacy = async (route) => { started.resolve(); await held.promise; - app.report.sidebarPreferenceFailures ??= []; - app.report.sidebarPreferenceFailures.push(route.request().url()); + const failed = status; + const failures = + failed === 502 ? "sidebarPreferenceFailures" : "relayFailures"; + app.report[failures] ??= []; + app.report[failures].push(route.request().url()); await route.fulfill({ - status: 502, + status: failed, contentType: "application/json", body: JSON.stringify({ error: "Preferences unavailable" }), }); @@ -930,10 +935,19 @@ test("Create new retains its draft when preferences fail before submission and r await expect(dialog.getByRole("alert")).toContainText("Retry preferences"); expect(app.report.sidebarPublications ?? []).toHaveLength(0); // Another failed recovery must keep both the draft and recovery action. + // Recovery would retry a transient failure on its own seconds later, and + // after the unroute below that read could remove Retry preferences while + // the final click is still landing. A 4xx is not transient, so recovery + // leaves it alone and the dialog's Retry is the only way out. The test + // flips the status here, not the click, so a recovery read that lands + // first also gets a 403 and can satisfy the response wait. Reads + // serialize behind the disabled Retry, so toBeEnabled still waits for the + // manual read. + status = 403; const retryFailed = page.waitForResponse( (response) => new URL(response.url()).pathname.endsWith("/sidebar-preferences") && - response.status() === 502, + response.status() === 403, ); await dialog .getByRole("button", { name: "Retry preferences", exact: true }) diff --git a/tests/browser/sidebar-section-creation.spec.mjs b/tests/browser/sidebar-section-creation.spec.mjs index 7d96f83226..658add4bac 100644 --- a/tests/browser/sidebar-section-creation.spec.mjs +++ b/tests/browser/sidebar-section-creation.spec.mjs @@ -57,7 +57,9 @@ test("Channels offers empty-section creation with keyboard focus and confirmed p writerReads++; if (failWriterRead) { failWriterRead = false; - // A denial keeps manual recovery available while transient reads retry. + // Relay recovery retries a transient failure such as a 502 about a + // second later, which can remove Retry preferences while a slow + // click is still landing. A denial leaves the retry to the user. app.report.relayFailures ??= []; app.report.relayFailures.push(route.request().url()); await route.fulfill({ @@ -163,7 +165,7 @@ test("Channels offers empty-section creation with keyboard focus and confirmed p await expect( dialog.getByRole("button", { name: "Create", exact: true }), ).toBeDisabled(); - await expect(dialog.getByRole("alert")).not.toContainText("502"); + await expect(dialog.getByRole("alert")).not.toContainText("403"); // Recover preferences without replaying creation, then Cancel/reopen. await dialog.getByRole("button", { name: "Retry preferences" }).click(); await expect(field).toBeFocused(); diff --git a/tests/browser/voice-notes.spec.mjs b/tests/browser/voice-notes.spec.mjs index 8c3904fb23..b796f231b1 100644 --- a/tests/browser/voice-notes.spec.mjs +++ b/tests/browser/voice-notes.spec.mjs @@ -1,27 +1,48 @@ import { watchPageErrors } from "./page-errors.mjs"; import { test, expect } from "./playwright-test.mjs"; -import { createServer } from "./vite-server.mjs"; +import { build, preview } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; -let server, origin; +let server, directory, url; +// A dev server serves this fixture as about 800 modules. On a busy runner, +// WebKit's first navigation took longer than the navigation timeout to load +// them, so build the page once and serve the bundle. test.beforeAll(async () => { - server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), + const root = fileURLToPath(new URL("../../", import.meta.url)); + directory = await mkdtemp(join(tmpdir(), "buzz-voice-notes-")); + const config = { + root, configFile: false, envDir: false, + logLevel: "error", plugins: [react()], - server: { host: "127.0.0.1", port: 0 }, + // Development React, as the dev server served, keeps StrictMode's checks. + define: { + "import.meta.env.DEV": "true", + "process.env.NODE_ENV": '"development"', + }, + build: { + rollupOptions: { + input: join(root, "tests/fixtures/voice-notes.html"), + }, + outDir: join(directory, "dist"), + emptyOutDir: true, + target: "esnext", + }, + }; + await build(config); + server = await preview({ + ...config, + preview: { host: "127.0.0.1", port: 0, strictPort: true }, }); - await server.listen(); - origin = server.resolvedUrls.local[0]; - // A new server transforms the fixture's modules, Tailwind's globals.css - // among them, during its first navigation. On a slow runner that took longer - // than the navigation timeout, so transform them before any test navigates. - await server.warmupRequest("/tests/fixtures/voice-notes.tsx"); - await server.waitForRequestsIdle(); + url = `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/voice-notes.html`; }); test.afterAll(async () => { - await server?.close(); + if (server) await new Promise((resolve) => server.httpServer.close(resolve)); + if (directory) await rm(directory, { recursive: true, force: true }); }); test.beforeEach(async ({ page }) => { await page.route("**/api/relay/media?*", async (route) => { @@ -38,7 +59,7 @@ test.beforeEach(async ({ page }) => { body: Buffer.from(bytes), }); }); - await page.goto(`${origin}tests/fixtures/voice-notes.html`); + await page.goto(url); await page.evaluate(() => { const mic = { mode: "allow", @@ -376,18 +397,12 @@ test("rejects a full attachment draft before opening the microphone", async ({ await expect( page.getByRole("status").filter({ hasText: /file-\d+\.txt:.*Ready/ }), ).toHaveCount(10); - const uploaded = await page.evaluate(async () => { - const { attachmentDraft } = await import( - "/src/features/messages/attachment-draft.ts" - ); - return attachmentDraft( - window.voiceFixture.session, - "voice-fixture:draft:general", - "general", - ) + const uploaded = await page.evaluate(() => + window.voiceFixture + .attachmentDraft("voice-fixture:draft:general", "general") .snapshot() - .map((item) => item.uploaded); - }); + .map((item) => item.uploaded), + ); expect(uploaded.map((item) => item.type)).toEqual( Array(10).fill("text/plain"), ); diff --git a/tests/fixtures/voice-notes.tsx b/tests/fixtures/voice-notes.tsx index b203337583..ba1d0db829 100644 --- a/tests/fixtures/voice-notes.tsx +++ b/tests/fixtures/voice-notes.tsx @@ -22,6 +22,7 @@ import { type UploadedAttachment, } from "../../src/features/relay/attachments"; import { PublishRejected } from "../../src/features/relay/outbox"; +import { attachmentDraft } from "../../src/features/messages/attachment-draft"; import "../../src/shared/styles/globals.css"; const viewer = keypair(), @@ -161,7 +162,8 @@ Object.assign(window, { voiceFixture: { report, media: (url: string) => urls.get(url), - session: owner.session, + attachmentDraft: (key: string, channelId: string) => + attachmentDraft(owner.session, key, channelId), toggle: (enabled: boolean) => runtime.reconcile(enabled ? [plugin] : []), }, });