Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion tests/browser/github-body.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 }),
Expand Down
34 changes: 22 additions & 12 deletions tests/browser/inbox-archive.spec.mjs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 })
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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");
Expand Down
22 changes: 18 additions & 4 deletions tests/browser/navigation-groups.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
});
Expand Down Expand Up @@ -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 })
Expand Down
6 changes: 4 additions & 2 deletions tests/browser/sidebar-section-creation.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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();
Expand Down
65 changes: 40 additions & 25 deletions tests/browser/voice-notes.spec.mjs
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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",
Expand Down Expand Up @@ -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"),
);
Expand Down
4 changes: 3 additions & 1 deletion tests/fixtures/voice-notes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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] : []),
},
});
Expand Down
Loading