diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 373070e2..3b8c0837 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -16,6 +16,8 @@ overflow-y: auto; overflow-anchor: none; padding: 0 var(--space-panel-inset); + /* Keep fractional last rows reachable despite integer scroll extents. */ + padding-bottom: var(--space-half); scrollbar-width: thin; } .feed ol { @@ -217,7 +219,7 @@ } @media (max-width: 650px) { .feed { - padding: 0 var(--space-3); + padding-inline: var(--space-3); } .composer { margin: var(--space-2) var(--space-3) var(--space-3); diff --git a/src/features/relay/signed-boundary.test.ts b/src/features/relay/signed-boundary.test.ts index 5eb8633f..b50f923d 100644 --- a/src/features/relay/signed-boundary.test.ts +++ b/src/features/relay/signed-boundary.test.ts @@ -2,7 +2,7 @@ import { assert, afterEach, expect, it, vi } from "vitest"; import { connectSignedTransport } from "./transport"; -import { ApiCapacity } from "./http-admission"; +import { ApiCapacity, ApiPaused } from "./http-admission"; import { PublishRejected } from "./outbox"; import { signed, keypair } from "./testing"; function required(value: T | undefined): T { @@ -88,47 +88,51 @@ it("positive control: immediate signer keeps signed fetches 500ms apart", async required(starts[i]) - required(starts[i - 1]), ).toBeGreaterThanOrEqual(500); }); -it("a signer already waiting cannot bypass a newly learned shared cooldown", async () => { - vi.useFakeTimers(); - const key = keypair(); - const pending: Array<() => void> = []; - const starts: number[] = []; - const identity = { - getPublicKey: async () => key.pubkey, - signEvent: (t: Parameters[1]) => - new Promise>((resolve) => - pending.push(() => resolve(signed(key, t))), - ), - }; - vi.stubGlobal("fetch", async () => { - starts.push(performance.now()); - return starts.length === 1 - ? Response.json( - { error: "rate-limited: quota exceeded; retry in 2s" }, - { status: 429 }, - ) - : Response.json([]); - }); - const t = await connectSignedTransport( - identity, - "https://pause-during-sign.test", - "relay", - ); - const one = t.query([{ kinds: [0], limit: 1 }]).catch((e) => e); - const two = t.query([{ kinds: [0], limit: 2 }]).catch((e) => e); - await vi.advanceTimersByTimeAsync(600); - await vi.waitFor(() => expect(pending).toHaveLength(2)); - required(pending[0])(); - await tick(); - await vi.advanceTimersByTimeAsync(1); - await one; - required(pending[1])(); - await tick(); - await vi.advanceTimersByTimeAsync(1); - expect(starts).toHaveLength(1); - await vi.advanceTimersByTimeAsync(3500); - await two; -}); +it.each([0, 1])( + "a signer already waiting cannot bypass a newly learned shared cooldown (signer %i completes first)", + async (first) => { + vi.useFakeTimers(); + const key = keypair(); + const pending: Array<() => void> = []; + const starts: number[] = []; + const identity = { + getPublicKey: async () => key.pubkey, + signEvent: (t: Parameters[1]) => + new Promise>((resolve) => + pending.push(() => resolve(signed(key, t))), + ), + }; + vi.stubGlobal("fetch", async () => { + starts.push(performance.now()); + return starts.length === 1 + ? Response.json( + { error: "rate-limited: quota exceeded; retry in 2s" }, + { status: 429 }, + ) + : Response.json([]); + }); + const t = await connectSignedTransport( + identity, + "https://pause-during-sign.test", + "relay", + ); + const one = t.query([{ kinds: [0], limit: 1 }]).catch((e) => e); + const two = t.query([{ kinds: [0], limit: 2 }]).catch((e) => e); + await vi.advanceTimersByTimeAsync(600); + await vi.waitFor(() => expect(pending).toHaveLength(2)); + required(pending[first])(); + await tick(); + await vi.advanceTimersByTimeAsync(1); + await Promise.race([one, two]); + required(pending[1 - first])(); + await tick(); + await vi.advanceTimersByTimeAsync(1); + expect(starts).toHaveLength(1); + await vi.advanceTimersByTimeAsync(3500); + expect(await Promise.all([one, two])).toContainEqual(expect.any(ApiPaused)); + expect(starts).toHaveLength(1); + }, +); function deferredSigner() { const key = keypair(); diff --git a/tests/browser/agents.spec.mjs b/tests/browser/agents.spec.mjs index 7c69a256..5ecaf836 100644 --- a/tests/browser/agents.spec.mjs +++ b/tests/browser/agents.spec.mjs @@ -1,240 +1,194 @@ -import { test, expect } from "@playwright/test"; -import { createServer } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; +import { test, expect } from "./source-fixture.mjs"; test("My agents reads the existing library with exact linked keys and session-safe retries", async ({ page, }) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0, strictPort: false }, - }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - try { - await server.listen(); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/agents.html`, - ); - const agents = page.getByRole("region", { - name: "My agents", - exact: true, - }); - await expect( - agents.getByRole("heading", { name: "A Brain", exact: true }), - ).toHaveCount(2); - const keys = await page.evaluate(() => window.agentFixture.agents); - await expect - .poll(() => agents.locator("img").evaluate((image) => image.naturalWidth)) - .toBeGreaterThan(0); - await expect(agents.locator("img")).toHaveCSS("opacity", "1"); - await expect( - agents.getByRole("img", { name: "A Brain", exact: true }), - ).toHaveCount(2); + await page.goto("/tests/fixtures/agents.html"); + const agents = page.getByRole("region", { + name: "My agents", + exact: true, + }); + await expect( + agents.getByRole("heading", { name: "A Brain", exact: true }), + ).toHaveCount(2); + const keys = await page.evaluate(() => window.agentFixture.agents); + await expect + .poll(() => agents.locator("img").evaluate((image) => image.naturalWidth)) + .toBeGreaterThan(0); + await expect(agents.locator("img")).toHaveCSS("opacity", "1"); + await expect( + agents.getByRole("img", { name: "A Brain", exact: true }), + ).toHaveCount(2); - for (const key of keys) - await expect(agents.getByText(key, { exact: true })).toBeHidden(); - await agents - .getByRole("button", { name: "A Brain: 2 identities", exact: true }) - .click(); - for (const key of keys) - await expect(agents.getByText(key, { exact: true })).toBeVisible(); - await expect( - page.getByText(/current Buzz library, read-only/), - ).toBeVisible(); - const surface = page.getByRole("region", { name: "Agents", exact: true }); - for (const mode of ["light", "dark"]) { - await page.evaluate((mode) => { - document.documentElement.dataset.colorMode = mode; - }, mode); - for (const width of [390, 800, 1600]) { - await page.setViewportSize({ width, height: 400 }); - const frame = await page.locator("main").boundingBox(); - const bounds = await surface.boundingBox(); - expect(frame).not.toBeNull(); - expect(bounds).not.toBeNull(); - expect(Math.abs(frame.width - bounds.width)).toBeLessThan(2); - expect(Math.abs(frame.height - bounds.height)).toBeLessThan(2); - await expect(surface).toHaveCSS("overflow", "hidden"); - expect( - await surface.evaluate((el) => { - const style = getComputedStyle(el); - const probe = document.createElement("div"); - probe.style.cssText = - "background:var(--bg-panel);border-radius:var(--radius-panel);border:1px solid var(--border-primary);box-shadow:var(--shadow-xs)"; - el.append(probe); - const reference = getComputedStyle(probe); - const matches = [ - "backgroundColor", - "borderRadius", - "borderTopColor", - "boxShadow", - ].every((key) => style[key] === reference[key]); - probe.remove(); - return matches; - }), - ).toBe(true); - const scroller = surface.locator(":scope > div"); - const documentTop = await page.evaluate( - () => document.scrollingElement.scrollTop, - ); - await scroller.evaluate((el) => { - el.scrollTop = 0; - }); - expect( - await scroller.evaluate((el) => el.scrollHeight > el.clientHeight), - ).toBe(true); - await scroller.evaluate((el) => { - el.scrollTop = el.scrollHeight; - }); - expect(await scroller.evaluate((el) => el.scrollTop)).toBeGreaterThan( - 0, - ); - await expect( - surface.getByText(/current Buzz library, read-only/), - ).toBeInViewport(); - expect(await surface.evaluate((el) => el.scrollTop)).toBe(0); - expect( - await page.evaluate(() => document.scrollingElement.scrollTop), - ).toBe(documentTop); - expect( - await page.evaluate(() => document.documentElement.scrollWidth), - ).toBe(width); - } - } - await page.setViewportSize({ width: 1440, height: 950 }); - await page.screenshot({ - path: test.info().outputPath("my-agents.png"), - }); - await page.evaluate(() => { - document.documentElement.dataset.colorMode = "dark"; - }); - expect( - await page - .getByRole("region", { name: "Agents", exact: true }) - .evaluate((el) => { + for (const key of keys) + await expect(agents.getByText(key, { exact: true })).toBeHidden(); + await agents + .getByRole("button", { name: "A Brain: 2 identities", exact: true }) + .click(); + for (const key of keys) + await expect(agents.getByText(key, { exact: true })).toBeVisible(); + await expect(page.getByText(/current Buzz library, read-only/)).toBeVisible(); + const surface = page.getByRole("region", { name: "Agents", exact: true }); + for (const mode of ["light", "dark"]) { + await page.evaluate((mode) => { + document.documentElement.dataset.colorMode = mode; + }, mode); + for (const width of [390, 800, 1600]) { + await page.setViewportSize({ width, height: 400 }); + const frame = await page.locator("main").boundingBox(); + const bounds = await surface.boundingBox(); + expect(frame).not.toBeNull(); + expect(bounds).not.toBeNull(); + expect(Math.abs(frame.width - bounds.width)).toBeLessThan(2); + expect(Math.abs(frame.height - bounds.height)).toBeLessThan(2); + await expect(surface).toHaveCSS("overflow", "hidden"); + expect( + await surface.evaluate((el) => { + const style = getComputedStyle(el); const probe = document.createElement("div"); - probe.style.backgroundColor = "var(--bg-panel)"; + probe.style.cssText = + "background:var(--bg-panel);border-radius:var(--radius-panel);border:1px solid var(--border-primary);box-shadow:var(--shadow-xs)"; el.append(probe); - const expected = getComputedStyle(probe).backgroundColor; + const reference = getComputedStyle(probe); + const matches = [ + "backgroundColor", + "borderRadius", + "borderTopColor", + "boxShadow", + ].every((key) => style[key] === reference[key]); probe.remove(); - return getComputedStyle(el).backgroundColor === expected; + return matches; }), - ).toBe(true); - await page.screenshot({ - path: test.info().outputPath("my-agents-dark.png"), - }); - await page - .getByRole("button", { name: "Toggle empty", exact: true }) - .click(); - await page - .getByRole("button", { name: "Refresh agents", exact: true }) - .click(); - await expect( - page.getByText("No selected agents in your Buzz library."), - ).toBeVisible(); - await expect(agents.getByRole("article")).toHaveCount(0); - await page - .getByRole("button", { name: "Toggle empty", exact: true }) - .click(); - await page - .getByRole("button", { name: "Toggle error", exact: true }) - .click(); - await page - .getByRole("button", { name: "Refresh agents", exact: true }) - .click(); - await expect(page.getByRole("alert").first()).toContainText( - "Could not read", - ); - await page - .getByRole("button", { name: "Toggle error", exact: true }) - .click(); - await page.getByRole("button", { name: "Retry", exact: true }).click(); - await expect(agents.getByRole("article")).toHaveCount(2); - await page - .getByRole("button", { name: "Toggle archive", exact: true }) - .click(); - await page - .getByRole("button", { name: "Refresh agents", exact: true }) - .click(); - await expect(agents.getByRole("article")).toHaveCount(2); - await expect(agents.getByText(keys[0], { exact: true })).toHaveCount(0); - await page - .getByRole("button", { name: "Toggle archive", exact: true }) - .click(); - await page - .getByRole("button", { name: "Toggle missing archive", exact: true }) - .click(); - await page - .getByRole("button", { name: "Refresh agents", exact: true }) - .click(); - await expect(agents.getByRole("article")).toHaveCount(2); - await agents - .getByRole("button", { name: "A Brain: 2 identities", exact: true }) - .click(); - for (const key of keys) - await expect(agents.getByText(key, { exact: true })).toBeVisible(); - await expect(page.getByText(/Archive visibility is unknown/)).toBeVisible(); - await page - .getByRole("button", { name: "Toggle missing archive", exact: true }) - .click(); - await page - .getByRole("button", { name: "Refresh agents", exact: true }) - .click(); - await expect(page.getByText(/Archive visibility is unknown/)).toHaveCount( - 0, - ); - const reads = await page.evaluate(() => window.agentFixture.reads()); - await page - .getByRole("button", { name: "Toggle page", exact: true }) - .click(); - await expect( - page.getByRole("region", { name: "Agents", exact: true }), - ).toHaveCount(0); - await page - .getByRole("button", { name: "Toggle page", exact: true }) - .click(); - await expect(agents.getByRole("article")).toHaveCount(2); - expect(await page.evaluate(() => window.agentFixture.reads())).toBe( - reads + 2, - ); - await page - .getByRole("button", { name: "Toggle hold", exact: true }) - .click(); - await page - .getByRole("button", { name: "Refresh agents", exact: true }) - .click(); - await expect(page.getByRole("status")).toHaveText( - "Reading your Buzz library…", - ); - await page - .getByRole("button", { name: "Community B", exact: true }) - .click(); - await page - .getByRole("button", { name: "Release reads", exact: true }) - .click(); - await expect( - agents.getByRole("heading", { name: "B Brain", exact: true }), - ).toHaveCount(2); - await expect(page.getByText("A Brain", { exact: true })).toHaveCount(0); - await page - .getByRole("button", { name: "Community A", exact: true }) - .click(); - await expect( - agents.getByRole("heading", { name: "A Brain", exact: true }), - ).toHaveCount(2); - await page - .getByRole("button", { name: "Clear cache", exact: true }) - .click(); - await expect(page.getByRole("status")).toContainText("Library cleared"); - await expect(page.getByRole("article")).toHaveCount(0); - expect(errors).toEqual([]); - } finally { - await server.close(); + ).toBe(true); + const scroller = surface.locator(":scope > div"); + const documentTop = await page.evaluate( + () => document.scrollingElement.scrollTop, + ); + await scroller.evaluate((el) => { + el.scrollTop = 0; + }); + expect( + await scroller.evaluate((el) => el.scrollHeight > el.clientHeight), + ).toBe(true); + await scroller.evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + expect(await scroller.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + await expect( + surface.getByText(/current Buzz library, read-only/), + ).toBeInViewport(); + expect(await surface.evaluate((el) => el.scrollTop)).toBe(0); + expect( + await page.evaluate(() => document.scrollingElement.scrollTop), + ).toBe(documentTop); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(width); + } } + await page.setViewportSize({ width: 1440, height: 950 }); + await page.screenshot({ + path: test.info().outputPath("my-agents.png"), + }); + await page.evaluate(() => { + document.documentElement.dataset.colorMode = "dark"; + }); + expect( + await page + .getByRole("region", { name: "Agents", exact: true }) + .evaluate((el) => { + const probe = document.createElement("div"); + probe.style.backgroundColor = "var(--bg-panel)"; + el.append(probe); + const expected = getComputedStyle(probe).backgroundColor; + probe.remove(); + return getComputedStyle(el).backgroundColor === expected; + }), + ).toBe(true); + await page.screenshot({ + path: test.info().outputPath("my-agents-dark.png"), + }); + await page.getByRole("button", { name: "Toggle empty", exact: true }).click(); + await page + .getByRole("button", { name: "Refresh agents", exact: true }) + .click(); + await expect( + page.getByText("No selected agents in your Buzz library."), + ).toBeVisible(); + await expect(agents.getByRole("article")).toHaveCount(0); + await page.getByRole("button", { name: "Toggle empty", exact: true }).click(); + await page.getByRole("button", { name: "Toggle error", exact: true }).click(); + await page + .getByRole("button", { name: "Refresh agents", exact: true }) + .click(); + await expect(page.getByRole("alert").first()).toContainText("Could not read"); + await page.getByRole("button", { name: "Toggle error", exact: true }).click(); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(agents.getByRole("article")).toHaveCount(2); + await page + .getByRole("button", { name: "Toggle archive", exact: true }) + .click(); + await page + .getByRole("button", { name: "Refresh agents", exact: true }) + .click(); + await expect(agents.getByRole("article")).toHaveCount(2); + await expect(agents.getByText(keys[0], { exact: true })).toHaveCount(0); + await page + .getByRole("button", { name: "Toggle archive", exact: true }) + .click(); + await page + .getByRole("button", { name: "Toggle missing archive", exact: true }) + .click(); + await page + .getByRole("button", { name: "Refresh agents", exact: true }) + .click(); + await expect(agents.getByRole("article")).toHaveCount(2); + await agents + .getByRole("button", { name: "A Brain: 2 identities", exact: true }) + .click(); + for (const key of keys) + await expect(agents.getByText(key, { exact: true })).toBeVisible(); + await expect(page.getByText(/Archive visibility is unknown/)).toBeVisible(); + await page + .getByRole("button", { name: "Toggle missing archive", exact: true }) + .click(); + await page + .getByRole("button", { name: "Refresh agents", exact: true }) + .click(); + await expect(page.getByText(/Archive visibility is unknown/)).toHaveCount(0); + const reads = await page.evaluate(() => window.agentFixture.reads()); + await page.getByRole("button", { name: "Toggle page", exact: true }).click(); + await expect( + page.getByRole("region", { name: "Agents", exact: true }), + ).toHaveCount(0); + await page.getByRole("button", { name: "Toggle page", exact: true }).click(); + await expect(agents.getByRole("article")).toHaveCount(2); + expect(await page.evaluate(() => window.agentFixture.reads())).toBe( + reads + 2, + ); + await page.getByRole("button", { name: "Toggle hold", exact: true }).click(); + await page + .getByRole("button", { name: "Refresh agents", exact: true }) + .click(); + await expect(page.getByRole("status")).toHaveText( + "Reading your Buzz library…", + ); + await page.getByRole("button", { name: "Community B", exact: true }).click(); + await page + .getByRole("button", { name: "Release reads", exact: true }) + .click(); + await expect( + agents.getByRole("heading", { name: "B Brain", exact: true }), + ).toHaveCount(2); + await expect(page.getByText("A Brain", { exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Community A", exact: true }).click(); + await expect( + agents.getByRole("heading", { name: "A Brain", exact: true }), + ).toHaveCount(2); + await page.getByRole("button", { name: "Clear cache", exact: true }).click(); + await expect(page.getByRole("status")).toContainText("Library cleared"); + await expect(page.getByRole("article")).toHaveCount(0); + expect(errors).toEqual([]); }); diff --git a/tests/browser/avatar-loading.spec.mjs b/tests/browser/avatar-loading.spec.mjs index b9aac32b..c5cb53ac 100644 --- a/tests/browser/avatar-loading.spec.mjs +++ b/tests/browser/avatar-loading.spec.mjs @@ -1,98 +1,79 @@ -import { expect, test } from "@playwright/test"; -import { createServer } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; +import { expect, test } from "./source-fixture.mjs"; import { fileURLToPath } from "node:url"; test("shared avatars defer offscreen artwork, omit the referrer and recover from failure", async ({ page, }) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0, strictPort: false }, - }); - try { - await server.listen(); - const requests = []; - await page.route("https://images.example/avatar.png", async (route) => { - requests.push(route.request().headers()); - await route.fulfill({ - path: fileURLToPath( - new URL( - "../fixtures/design-system/assets/avatar.png", - import.meta.url, - ), - ), - contentType: "image/png", - }); + const requests = []; + await page.route("https://images.example/avatar.png", async (route) => { + requests.push(route.request().headers()); + await route.fulfill({ + path: fileURLToPath( + new URL("../fixtures/design-system/assets/avatar.png", import.meta.url), + ), + contentType: "image/png", }); - const port = server.httpServer.address().port; - await page.goto( - `http://127.0.0.1:${port}/tests/fixtures/agents.html?external-avatar&offscreen-avatar`, - ); - await expect( - page.getByRole("region", { name: "Agents", exact: true }), - ).not.toBeInViewport(); - await page.waitForTimeout(300); - expect(requests).toHaveLength(0); - await page.evaluate(() => - window.scrollTo(0, document.documentElement.scrollHeight), - ); - await expect( - page.getByRole("region", { name: "Agents", exact: true }), - ).toBeInViewport(); - await expect.poll(() => requests.length).toBe(1); - expect(requests[0].referer).toBeUndefined(); + }); + await page.goto( + "/tests/fixtures/agents.html?external-avatar&offscreen-avatar", + ); + await expect( + page.getByRole("region", { name: "Agents", exact: true }), + ).not.toBeInViewport(); + await page.waitForTimeout(300); + expect(requests).toHaveLength(0); + await page.evaluate(() => + window.scrollTo(0, document.documentElement.scrollHeight), + ); + await expect( + page.getByRole("region", { name: "Agents", exact: true }), + ).toBeInViewport(); + await expect.poll(() => requests.length).toBe(1); + expect(requests[0].referer).toBeUndefined(); - const avatar = page - .getByRole("article") - .filter({ - has: page.getByRole("button", { name: "A Brain: 2 identities" }), - }) - .getByRole("img", { name: "A Brain", exact: true }); - const image = avatar.locator("img"); - await expect - .poll(() => image.evaluate((el) => el.naturalWidth)) - .toBeGreaterThan(0); - await expect(image).toHaveCSS("opacity", "1"); - await expect(avatar).toHaveText(""); - const original = await avatar.boundingBox(); + const avatar = page + .getByRole("article") + .filter({ + has: page.getByRole("button", { name: "A Brain: 2 identities" }), + }) + .getByRole("img", { name: "A Brain", exact: true }); + const image = avatar.locator("img"); + await expect + .poll(() => image.evaluate((el) => el.naturalWidth)) + .toBeGreaterThan(0); + await expect(image).toHaveCSS("opacity", "1"); + await expect(avatar).toHaveText(""); + const original = await avatar.boundingBox(); - let release; - const held = new Promise((resolve) => { - release = resolve; - }); - await page.route("https://images.example/failure.png", async (route) => { - await held; - await route.abort(); - }); - await page.evaluate(() => - window.agentFixture.setArtwork("https://images.example/failure.png"), - ); - await page - .getByRole("button", { name: "Refresh agents", exact: true }) - .click(); - await expect(avatar).toContainText("A"); - await expect(image).toHaveCSS("opacity", "0"); - expect(await avatar.boundingBox()).toEqual(original); - release(); - await expect(avatar).toContainText("A"); - await expect(avatar.locator("img")).toHaveCount(0); - await page.evaluate(() => - window.agentFixture.setArtwork("https://images.example/avatar.png"), - ); - await page - .getByRole("button", { name: "Refresh agents", exact: true }) - .click(); - await expect - .poll(() => image.evaluate((el) => el.naturalWidth)) - .toBeGreaterThan(0); - await expect(image).toHaveCSS("opacity", "1"); - await expect(avatar).toHaveText(""); - } finally { - await server.close(); - } + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route("https://images.example/failure.png", async (route) => { + await held; + await route.abort(); + }); + await page.evaluate(() => + window.agentFixture.setArtwork("https://images.example/failure.png"), + ); + await page + .getByRole("button", { name: "Refresh agents", exact: true }) + .click(); + await expect(avatar).toContainText("A"); + await expect(image).toHaveCSS("opacity", "0"); + expect(await avatar.boundingBox()).toEqual(original); + release(); + await expect(avatar).toContainText("A"); + await expect(avatar.locator("img")).toHaveCount(0); + await page.evaluate(() => + window.agentFixture.setArtwork("https://images.example/avatar.png"), + ); + await page + .getByRole("button", { name: "Refresh agents", exact: true }) + .click(); + await expect + .poll(() => image.evaluate((el) => el.naturalWidth)) + .toBeGreaterThan(0); + await expect(image).toHaveCSS("opacity", "1"); + await expect(avatar).toHaveText(""); }); diff --git a/tests/browser/emoji.spec.mjs b/tests/browser/emoji.spec.mjs index 773abc57..15d8284a 100644 --- a/tests/browser/emoji.spec.mjs +++ b/tests/browser/emoji.spec.mjs @@ -1,1013 +1,967 @@ -import { test, expect } from "@playwright/test"; -import { createServer } from "vite"; -import react from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { test, expect } from "./source-fixture.mjs"; test("community picker uses keyboard, proxy thumbnails, event-local history and scoped send/reply tags", async ({ browserName, page, }) => { - // Parallel fixtures must not invalidate each other’s optimized lazy imports. - const cacheDir = await mkdtemp(join(tmpdir(), "buzz-emoji-vite-")); - let server; - try { - server = await createServer({ - cacheDir, - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0, strictPort: false }, - }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); - await page.route("**/emoji-media/**", async (route) => { - if (route.request().url().includes("broken.png")) - return route.fulfill({ status: 404, body: "missing" }); - return route.fulfill({ - contentType: "image/svg+xml", - body: '', - }); - }); - await server.listen(); - // The host selection, not the operating system, chooses the widget mode. - await page.emulateMedia({ colorScheme: "dark" }); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/emoji.html`, - ); - const draft = () => - page.getByRole("textbox", { name: /Message #general|Reply to thread/ }); - const picker = page.getByRole("button", { - name: "Insert emoji", - exact: true, + const errors = []; + page.on("pageerror", (error) => errors.push(String(error))); + await page.route("**/emoji-media/**", async (route) => { + if (route.request().url().includes("broken.png")) + return route.fulfill({ status: 404, body: "missing" }); + return route.fulfill({ + contentType: "image/svg+xml", + body: '', }); - await expect( - page.getByText("Broken :missing:", { exact: true }), - ).toBeVisible(); - await expect( - page.getByText("Unloadable :broken:", { exact: true }), - ).toBeVisible(); - const sentSingleEmoji = page.locator("p[data-single-emoji]"); - await expect(sentSingleEmoji).toHaveCSS("font-size", "42px"); - await expect(sentSingleEmoji).toHaveCSS("margin-top", "4px"); - await expect(sentSingleEmoji.locator('img[alt=":party:"]')).toHaveCSS( - "width", - "42px", - ); - await expect(sentSingleEmoji.locator('img[alt=":party:"]')).toHaveCSS( - "height", - "42px", - ); - expect( - await sentSingleEmoji.evaluate((message) => { - const byline = message.previousElementSibling; - const emoji = message.querySelector("img"); - return ( - emoji.getBoundingClientRect().top - - byline.getBoundingClientRect().bottom - ); - }), - ).toBeCloseTo(4, 1); - await expect( - page.getByRole("link", { name: "https://example.test/:party" }), - ).toHaveAttribute("href", "https://example.test/:party"); - const historic = page.locator('img[src*="original.png"]'); - const originalSrc = await historic.getAttribute("src"); - expect(originalSrc).toContain("/emoji-media/a/"); - await expect(page.locator('img[src*="reaction.png"]')).toHaveCount(1); - // Preserve real pointer selection, then observe the app's native copy event - // payload directly. Linux WebKit does not paste a script-installed DOM range - // from its platform clipboard, even though the handler populated the event. - const emojiImage = sentSingleEmoji.locator("img"); - const copyBounds = await emojiImage.boundingBox(); - await page.mouse.move( - copyBounds.x - 2, - copyBounds.y + copyBounds.height / 2, - ); - await page.mouse.down(); - await page.mouse.move( - copyBounds.x + copyBounds.width + 2, - copyBounds.y + copyBounds.height / 2, - { steps: 8 }, - ); - await page.mouse.up(); - expect( - await emojiImage.evaluate((image) => { - const selection = window.getSelection(); - return ( - !!selection && - !selection.isCollapsed && - selection.containsNode(image, true) - ); - }), - ).toBe(true); - const observeCopyPayload = () => - page.evaluate(() => { - window.__emojiCopyPayload = undefined; - document.addEventListener( - "copy", - (event) => { - window.__emojiCopyPayload = { - prevented: event.defaultPrevented, - text: event.clipboardData?.getData("text/plain"), - trusted: event.isTrusted, - }; - }, - { once: true }, - ); - }); - const copyPayload = () => page.evaluate(() => window.__emojiCopyPayload); - const copySelection = async () => { - if (browserName === "webkit") { - // Headless WebKit does not dispatch Copy for selected non-editable content - // from Playwright keyboard input on Linux. Invoke its browser copy command; - // this uses the real selection and document listener, not a synthetic event. - expect(await page.evaluate(() => document.execCommand("copy"))).toBe( - true, - ); - return; - } - await page.keyboard.press("ControlOrMeta+c"); - }; + }); + // The host selection, not the operating system, chooses the widget mode. + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto("/tests/fixtures/emoji.html"); + const draft = () => + page.getByRole("textbox", { name: /Message #general|Reply to thread/ }); + const picker = page.getByRole("button", { + name: "Insert emoji", + exact: true, + }); + await expect( + page.getByText("Broken :missing:", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Unloadable :broken:", { exact: true }), + ).toBeVisible(); + const sentSingleEmoji = page.locator("p[data-single-emoji]"); + await expect(sentSingleEmoji).toHaveCSS("font-size", "42px"); + await expect(sentSingleEmoji).toHaveCSS("margin-top", "4px"); + await expect(sentSingleEmoji.locator('img[alt=":party:"]')).toHaveCSS( + "width", + "42px", + ); + await expect(sentSingleEmoji.locator('img[alt=":party:"]')).toHaveCSS( + "height", + "42px", + ); + expect( + await sentSingleEmoji.evaluate((message) => { + const byline = message.previousElementSibling; + const emoji = message.querySelector("img"); + return ( + emoji.getBoundingClientRect().top - + byline.getBoundingClientRect().bottom + ); + }), + ).toBeCloseTo(4, 1); + await expect( + page.getByRole("link", { name: "https://example.test/:party" }), + ).toHaveAttribute("href", "https://example.test/:party"); + const historic = page.locator('img[src*="original.png"]'); + const originalSrc = await historic.getAttribute("src"); + expect(originalSrc).toContain("/emoji-media/a/"); + await expect(page.locator('img[src*="reaction.png"]')).toHaveCount(1); + // Preserve real pointer selection, then observe the app's native copy event + // payload directly. Linux WebKit does not paste a script-installed DOM range + // from its platform clipboard, even though the handler populated the event. + const emojiImage = sentSingleEmoji.locator("img"); + const copyBounds = await emojiImage.boundingBox(); + await page.mouse.move(copyBounds.x - 2, copyBounds.y + copyBounds.height / 2); + await page.mouse.down(); + await page.mouse.move( + copyBounds.x + copyBounds.width + 2, + copyBounds.y + copyBounds.height / 2, + { steps: 8 }, + ); + await page.mouse.up(); + expect( await emojiImage.evaluate((image) => { - const range = document.createRange(); - range.selectNode(image); const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - await observeCopyPayload(); - await copySelection(); - await expect - .poll(copyPayload) - .toEqual({ prevented: true, text: ":party:", trusted: true }); - await historic.evaluate((image) => { - const range = document.createRange(); - range.selectNodeContents(image.closest("p")); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - await observeCopyPayload(); - await copySelection(); - await expect.poll(copyPayload).toEqual({ - prevented: true, - text: "Historic :unknown:party: and https://example.test/:party:", - trusted: true, - }); - const table = page.locator("table"); - await expect(table.locator('img[alt=":party:"]')).toHaveCount(1); - await table.evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - await observeCopyPayload(); - await copySelection(); - await expect.poll(copyPayload).toEqual({ - prevented: true, - text: "State\tOwner\tCount\tTail\n:party:\t\t12\t\n\tlead\t\tend", - trusted: true, - }); - await table - .locator("tbody tr") - .first() - .evaluate((row) => { - const range = document.createRange(); - range.selectNodeContents(row); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - await observeCopyPayload(); - await copySelection(); - await expect.poll(copyPayload).toEqual({ - prevented: true, - text: ":party:\t\t12\t", - trusted: true, + return ( + !!selection && + !selection.isCollapsed && + selection.containsNode(image, true) + ); + }), + ).toBe(true); + const observeCopyPayload = () => + page.evaluate(() => { + window.__emojiCopyPayload = undefined; + document.addEventListener( + "copy", + (event) => { + window.__emojiCopyPayload = { + prevented: event.defaultPrevented, + text: event.clipboardData?.getData("text/plain"), + trusted: event.isTrusted, + }; + }, + { once: true }, + ); }); - const blockquote = page.locator("blockquote"); - const preformatted = blockquote.locator("xpath=following-sibling::pre[1]"); - await expect(blockquote.locator('img[alt=":party:"]')).toHaveCount(1); - await blockquote.evaluate((element) => { - const start = element.querySelector("p")?.firstChild; - const end = element.nextElementSibling?.querySelector("code")?.lastChild; - if (!start || !end) throw new Error("Missing quote/code text boundaries"); + const copyPayload = () => page.evaluate(() => window.__emojiCopyPayload); + const copySelection = async () => { + if (browserName === "webkit") { + // Headless WebKit does not dispatch Copy for selected non-editable content + // from Playwright keyboard input on Linux. Invoke its browser copy command; + // this uses the real selection and document listener, not a synthetic event. + expect(await page.evaluate(() => document.execCommand("copy"))).toBe( + true, + ); + return; + } + await page.keyboard.press("ControlOrMeta+c"); + }; + await emojiImage.evaluate((image) => { + const range = document.createRange(); + range.selectNode(image); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + await observeCopyPayload(); + await copySelection(); + await expect + .poll(copyPayload) + .toEqual({ prevented: true, text: ":party:", trusted: true }); + await historic.evaluate((image) => { + const range = document.createRange(); + range.selectNodeContents(image.closest("p")); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + await observeCopyPayload(); + await copySelection(); + await expect.poll(copyPayload).toEqual({ + prevented: true, + text: "Historic :unknown:party: and https://example.test/:party:", + trusted: true, + }); + const table = page.locator("table"); + await expect(table.locator('img[alt=":party:"]')).toHaveCount(1); + await table.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + await observeCopyPayload(); + await copySelection(); + await expect.poll(copyPayload).toEqual({ + prevented: true, + text: "State\tOwner\tCount\tTail\n:party:\t\t12\t\n\tlead\t\tend", + trusted: true, + }); + await table + .locator("tbody tr") + .first() + .evaluate((row) => { const range = document.createRange(); - range.setStart(start, 0); - range.setEnd(end, end.textContent?.length ?? 0); + range.selectNodeContents(row); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }); - await expect(preformatted).toContainText("code"); - await observeCopyPayload(); - await copySelection(); - await expect.poll(copyPayload).toEqual({ - prevented: true, - text: "Quote :party:\n\ncode\n", - trusted: true, - }); - // Independently prove this browser's real clipboard transport with the exact - // handler payload; the editable source path intentionally uses native copy. - await draft().fill(":party:"); - await draft().press("ControlOrMeta+a"); - await page.keyboard.press("ControlOrMeta+c"); - await draft().fill(""); - await page.keyboard.press("ControlOrMeta+v"); - await expect(draft()).toHaveJSProperty("value", ":party:"); - // One Shift+Left selects one rendered custom emoji, not its trailing colon. - await draft().press("Shift+ArrowLeft"); - expect( - await draft().evaluate((element) => - element.value.slice(element.selectionStart, element.selectionEnd), - ), - ).toBe(":party:"); - await page.keyboard.press("ControlOrMeta+c"); - await draft().fill(""); - await page.keyboard.press("ControlOrMeta+v"); - await expect(draft()).toHaveJSProperty("value", ":party:"); - await draft().fill(":party::party:"); - const selectedDraftText = () => - draft().evaluate((element) => - element.value.slice(element.selectionStart, element.selectionEnd), - ); - for (const [key, selected] of [ - ["Shift+ArrowLeft", ":party:"], - ["Shift+ArrowLeft", ":party::party:"], - ["Shift+ArrowRight", ":party:"], - ["Shift+ArrowRight", ""], - ]) { - await draft().press(key); - expect(await selectedDraftText()).toBe(selected); - } - await draft().evaluate((element) => element.setSelectionRange(0, 0)); - for (const [key, selected] of [ - ["Shift+ArrowRight", ":party:"], - ["Shift+ArrowRight", ":party::party:"], - ["Shift+ArrowLeft", ":party:"], - ["Shift+ArrowLeft", ""], - ]) { - await draft().press(key); - expect(await selectedDraftText()).toBe(selected); - } - await draft().fill(":party:hello"); - await draft().evaluate((element) => element.setSelectionRange(7, 7)); + await observeCopyPayload(); + await copySelection(); + await expect.poll(copyPayload).toEqual({ + prevented: true, + text: ":party:\t\t12\t", + trusted: true, + }); + const blockquote = page.locator("blockquote"); + const preformatted = blockquote.locator("xpath=following-sibling::pre[1]"); + await expect(blockquote.locator('img[alt=":party:"]')).toHaveCount(1); + await blockquote.evaluate((element) => { + const start = element.querySelector("p")?.firstChild; + const end = element.nextElementSibling?.querySelector("code")?.lastChild; + if (!start || !end) throw new Error("Missing quote/code text boundaries"); + const range = document.createRange(); + range.setStart(start, 0); + range.setEnd(end, end.textContent?.length ?? 0); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + await expect(preformatted).toContainText("code"); + await observeCopyPayload(); + await copySelection(); + await expect.poll(copyPayload).toEqual({ + prevented: true, + text: "Quote :party:\n\ncode\n", + trusted: true, + }); + // Independently prove this browser's real clipboard transport with the exact + // handler payload; the editable source path intentionally uses native copy. + await draft().fill(":party:"); + await draft().press("ControlOrMeta+a"); + await page.keyboard.press("ControlOrMeta+c"); + await draft().fill(""); + await page.keyboard.press("ControlOrMeta+v"); + await expect(draft()).toHaveJSProperty("value", ":party:"); + // One Shift+Left selects one rendered custom emoji, not its trailing colon. + await draft().press("Shift+ArrowLeft"); + expect( + await draft().evaluate((element) => + element.value.slice(element.selectionStart, element.selectionEnd), + ), + ).toBe(":party:"); + await page.keyboard.press("ControlOrMeta+c"); + await draft().fill(""); + await page.keyboard.press("ControlOrMeta+v"); + await expect(draft()).toHaveJSProperty("value", ":party:"); + await draft().fill(":party::party:"); + const selectedDraftText = () => + draft().evaluate((element) => + element.value.slice(element.selectionStart, element.selectionEnd), + ); + for (const [key, selected] of [ + ["Shift+ArrowLeft", ":party:"], + ["Shift+ArrowLeft", ":party::party:"], + ["Shift+ArrowRight", ":party:"], + ["Shift+ArrowRight", ""], + ]) { + await draft().press(key); + expect(await selectedDraftText()).toBe(selected); + } + await draft().evaluate((element) => element.setSelectionRange(0, 0)); + for (const [key, selected] of [ + ["Shift+ArrowRight", ":party:"], + ["Shift+ArrowRight", ":party::party:"], + ["Shift+ArrowLeft", ":party:"], + ["Shift+ArrowLeft", ""], + ]) { + await draft().press(key); + expect(await selectedDraftText()).toBe(selected); + } + await draft().fill(":party:hello"); + await draft().evaluate((element) => element.setSelectionRange(7, 7)); + await draft().press("Shift+ArrowLeft"); + expect(await selectedDraftText()).toBe(":party:"); + await draft().press("Backspace"); + await expect(draft()).toHaveJSProperty("value", "hello"); + // Visible source text and unavailable emoji retain ordinary character selection. + for (const literal of [":unknown:", ":nosource:"]) { + await draft().fill(literal); await draft().press("Shift+ArrowLeft"); - expect(await selectedDraftText()).toBe(":party:"); - await draft().press("Backspace"); - await expect(draft()).toHaveJSProperty("value", "hello"); - // Visible source text and unavailable emoji retain ordinary character selection. - for (const literal of [":unknown:", ":nosource:"]) { - await draft().fill(literal); - await draft().press("Shift+ArrowLeft"); - expect(await selectedDraftText()).toBe(":"); - } - await page.locator("main").evaluate((main) => { - main.style.width = "300px"; - }); - await draft().fill(Array(24).fill(":party:").join(" ")); - const largeCustom = draft(); - await expect(largeCustom.locator("img")).toHaveCount(24); - await expect(largeCustom.locator("img").last()).toHaveCSS("width", "42px"); - expect( - await largeCustom.evaluate( - (group) => group.scrollWidth <= group.clientWidth, - ), - ).toBe(true); - expect( - await largeCustom - .locator("img") - .last() - .evaluate((image) => image.offsetTop), - ).toBeGreaterThan(0); - await page.screenshot({ - path: test.info().outputPath("large-custom-emoji-draft.png"), - }); - const lastCustomBounds = await largeCustom + expect(await selectedDraftText()).toBe(":"); + } + await page.locator("main").evaluate((main) => { + main.style.width = "300px"; + }); + await draft().fill(Array(24).fill(":party:").join(" ")); + const largeCustom = draft(); + await expect(largeCustom.locator("img")).toHaveCount(24); + await expect(largeCustom.locator("img").last()).toHaveCSS("width", "42px"); + expect( + await largeCustom.evaluate( + (group) => group.scrollWidth <= group.clientWidth, + ), + ).toBe(true); + expect( + await largeCustom .locator("img") .last() - .boundingBox(); - const inputBounds = await draft().boundingBox(); - expect(lastCustomBounds.y + lastCustomBounds.height).toBeLessThanOrEqual( - inputBounds.y + inputBounds.height, - ); - await page.locator("main").evaluate((main) => { - main.style.width = "800px"; - }); - await draft().fill(""); - // Opening/reading does not load the Unicode dataset or Mart's global state. - expect( - await page.evaluate(() => - performance - .getEntriesByType("resource") - .some((entry) => entry.name.includes("emoji-mart")), + .evaluate((image) => image.offsetTop), + ).toBeGreaterThan(0); + await page.screenshot({ + path: test.info().outputPath("large-custom-emoji-draft.png"), + }); + const lastCustomBounds = await largeCustom + .locator("img") + .last() + .boundingBox(); + const inputBounds = await draft().boundingBox(); + expect(lastCustomBounds.y + lastCustomBounds.height).toBeLessThanOrEqual( + inputBounds.y + inputBounds.height, + ); + await page.locator("main").evaluate((main) => { + main.style.width = "800px"; + }); + await draft().fill(""); + // Opening/reading does not load the Unicode dataset or Mart's global state. + expect( + await page.evaluate(() => + performance + .getEntriesByType("resource") + .some((entry) => entry.name.includes("emoji-mart")), + ), + ).toBe(false); + await picker.focus(); + await picker.press("Enter"); + const search = page.getByRole("searchbox", { + name: "Search emoji", + }); + await expect(search).toBeFocused(); + await expect(search).toHaveAttribute("placeholder", "Search emoji"); + const categoryNavigation = page.locator("em-emoji-picker #nav"); + const skinTone = categoryNavigation.locator(".buzz-skin-tone-nav-button"); + await expect(skinTone).toHaveAttribute("aria-label", /skin tone/i); + await expect(categoryNavigation.locator("button").last()).toHaveClass( + /buzz-skin-tone-nav-button/, + ); + await expect( + page.locator("em-emoji-picker .search .skin-tone-button"), + ).toHaveCount(0); + await skinTone.hover(); + await expect + .poll(() => + skinTone.evaluate( + (button) => getComputedStyle(button, "::before").backgroundColor, ), - ).toBe(false); - await picker.focus(); - await picker.press("Enter"); - const search = page.getByRole("searchbox", { - name: "Search emoji", - }); - await expect(search).toBeFocused(); - await expect(search).toHaveAttribute("placeholder", "Search emoji"); - const categoryNavigation = page.locator("em-emoji-picker #nav"); - const skinTone = categoryNavigation.locator(".buzz-skin-tone-nav-button"); - await expect(skinTone).toHaveAttribute("aria-label", /skin tone/i); - await expect(categoryNavigation.locator("button").last()).toHaveClass( - /buzz-skin-tone-nav-button/, - ); - await expect( - page.locator("em-emoji-picker .search .skin-tone-button"), - ).toHaveCount(0); - await skinTone.hover(); - await expect - .poll(() => - skinTone.evaluate( - (button) => getComputedStyle(button, "::before").backgroundColor, - ), - ) - .toBe("rgb(232, 232, 232)"); - await skinTone.click(); - await expect(skinTone).toHaveAttribute("aria-selected", ""); - const toneMenu = page.locator("em-emoji-picker #root > .menu"); - await expect(toneMenu).toBeVisible(); - await expect(toneMenu).toHaveCSS("z-index", "100"); - // Mart's opening transform temporarily lifts the menu above its final edge. - // Measure the settled menu: its 42px bottom offset meets the 42px nav flush, - // which is non-overlapping but does not leave a strictly positive gap. - await expect(toneMenu).toHaveCSS("transform", "none"); - await expect(toneMenu).toHaveCSS("opacity", "1"); - const toneMenuBox = await toneMenu.boundingBox(); - const categoryNavigationBox = await categoryNavigation.boundingBox(); - expect(toneMenuBox.y + toneMenuBox.height).toBeLessThanOrEqual( - categoryNavigationBox.y, - ); - expect( - await toneMenu.evaluate((menu) => { - const bounds = menu.getBoundingClientRect(); - const top = menu - .getRootNode() - .elementFromPoint( - bounds.left + bounds.width / 2, - bounds.top + bounds.height / 2, - ); - return !!top && menu.contains(top); - }), - ).toBe(true); - await toneMenu.locator(".option").nth(1).click(); - await expect(toneMenu).toHaveCount(0); - await expect(skinTone).toBeFocused(); - await search.focus(); - const searchIcon = page.locator( - '[aria-label="Emoji picker"] > svg.lucide-search', - ); - await expect(searchIcon).toHaveAttribute("viewBox", "0 0 24 24"); - await expect(searchIcon).toHaveAttribute("stroke-width", "2"); - await expect(searchIcon.locator("path")).toHaveAttribute( - "d", - "m21 21-4.34-4.34", - ); - await expect(searchIcon.locator("circle")).toHaveAttribute("r", "8"); - await expect( - page.locator('[aria-label="Emoji picker"] > svg.lucide-search:visible'), - ).toHaveCount(1); - await expect(page.locator("em-emoji-picker .search .loupe")).toHaveCSS( - "visibility", - "hidden", + ) + .toBe("rgb(232, 232, 232)"); + await skinTone.click(); + await expect(skinTone).toHaveAttribute("aria-selected", ""); + const toneMenu = page.locator("em-emoji-picker #root > .menu"); + await expect(toneMenu).toBeVisible(); + await expect(toneMenu).toHaveCSS("z-index", "100"); + // Mart's opening transform temporarily lifts the menu above its final edge. + // Measure the settled menu: its 42px bottom offset meets the 42px nav flush, + // which is non-overlapping but does not leave a strictly positive gap. + await expect(toneMenu).toHaveCSS("transform", "none"); + await expect(toneMenu).toHaveCSS("opacity", "1"); + const toneMenuBox = await toneMenu.boundingBox(); + const categoryNavigationBox = await categoryNavigation.boundingBox(); + expect(toneMenuBox.y + toneMenuBox.height).toBeLessThanOrEqual( + categoryNavigationBox.y, + ); + expect( + await toneMenu.evaluate((menu) => { + const bounds = menu.getBoundingClientRect(); + const top = menu + .getRootNode() + .elementFromPoint( + bounds.left + bounds.width / 2, + bounds.top + bounds.height / 2, + ); + return !!top && menu.contains(top); + }), + ).toBe(true); + await toneMenu.locator(".option").nth(1).click(); + await expect(toneMenu).toHaveCount(0); + await expect(skinTone).toBeFocused(); + await search.focus(); + const searchIcon = page.locator( + '[aria-label="Emoji picker"] > svg.lucide-search', + ); + await expect(searchIcon).toHaveAttribute("viewBox", "0 0 24 24"); + await expect(searchIcon).toHaveAttribute("stroke-width", "2"); + await expect(searchIcon.locator("path")).toHaveAttribute( + "d", + "m21 21-4.34-4.34", + ); + await expect(searchIcon.locator("circle")).toHaveAttribute("r", "8"); + await expect( + page.locator('[aria-label="Emoji picker"] > svg.lucide-search:visible'), + ).toHaveCount(1); + await expect(page.locator("em-emoji-picker .search .loupe")).toHaveCSS( + "visibility", + "hidden", + ); + await expect(search).toHaveCSS("height", "28px"); + await expect(search).toHaveCSS("margin-left", "2px"); + await expect(search).toHaveCSS("margin-right", "2px"); + await expect(search).toHaveCSS("border-top-width", "0px"); + await expect(search).toHaveCSS("border-radius", "14px"); + await expect(search).toHaveCSS("background-color", "rgb(240, 240, 240)"); + await expect(search).toHaveCSS("color", "rgb(0, 0, 0)"); + await expect(search).toHaveCSS("outline-style", "none"); + await expect(search).toHaveCSS("box-shadow", "rgb(0, 0, 0) 0px 0px 0px 2px"); + const surface = page.locator("em-emoji-picker #root"); + const region = page.getByRole("region", { name: "Emoji picker" }); + await expect(surface).toHaveAttribute("data-theme", "light"); + await expect(region).toHaveCSS("background-color", "rgb(255, 255, 255)"); + await expect(region).toHaveCSS("border-radius", "24px"); + await expect(region).toHaveCSS("border-top-width", "1px"); + await expect(region).not.toHaveCSS("box-shadow", "none"); + await expect(page.getByRole("button", { name: "Refresh emoji" })).toHaveCount( + 0, + ); + await expect(surface).toHaveCSS("width", "360px"); + const initialRegion = await region.boundingBox(); + const initialSurface = await surface.boundingBox(); + const searchGutters = await search.evaluate((input) => { + const searchBounds = input.getBoundingClientRect(); + const rootBounds = input + .getRootNode() + .querySelector("#root") + .getBoundingClientRect(); + return { + left: searchBounds.left - rootBounds.left, + right: rootBounds.right - searchBounds.right, + }; + }); + expect(searchGutters.left).toBeCloseTo(searchGutters.right, 1); + expect(initialSurface.height).toBeCloseTo( + Math.min(348, page.viewportSize().height * 0.4), + 1, + ); + expect(initialRegion.width).toBe(initialSurface.width + 2); + expect(initialRegion.height).toBe(initialSurface.height + 2); + expect(initialRegion.x + 1).toBe(initialSurface.x); + expect(initialRegion.y + 1).toBe(initialSurface.y); + await page.screenshot({ + path: test.info().outputPath("emoji-picker-dark-os.png"), + }); + await search.fill("face"); + const searchResults = page.locator( + "em-emoji-picker .scroll .category button", + ); + await expect(searchResults.first()).toBeVisible(); + expect(await searchResults.count()).toBeGreaterThanOrEqual(6); + const searchRowPositions = await searchResults.evaluateAll((buttons) => + buttons.slice(0, 6).map((button) => { + const bounds = button.getBoundingClientRect(); + return { x: bounds.x, y: bounds.y }; + }), + ); + expect( + searchRowPositions.every(({ y }) => y === searchRowPositions[0].y), + ).toBe(true); + await search.fill("party"); + const emojiClear = page.locator("em-emoji-picker .search .delete"); + await expect(emojiClear).toHaveCSS("right", "10px"); + await expect(emojiClear.locator("svg")).toHaveAttribute( + "viewBox", + "0 0 24 24", + ); + await expect(emojiClear.locator("svg")).toHaveClass(/lucide-circle-x/); + await expect(emojiClear.locator("svg")).toHaveCSS("width", "16px"); + await expect(emojiClear.locator("svg")).toHaveCSS("height", "16px"); + await expect(emojiClear).toHaveCSS("color", "rgb(82, 82, 82)"); + await expect(emojiClear.locator("circle")).toHaveCSS( + "fill", + "rgb(82, 82, 82)", + ); + await expect(emojiClear.locator("circle")).toHaveCSS("stroke", "none"); + await expect(emojiClear.locator("path").first()).toHaveCSS( + "stroke", + "rgb(240, 240, 240)", + ); + await expect( + page.locator('[aria-label="Emoji picker"] > svg.lucide-search:visible'), + ).toHaveCount(1); + const insert = page.getByRole("button", { + name: ":party:", + exact: true, + }); + await expect(insert).toBeVisible(); + await expect(insert).toHaveCSS("width", "48px"); + await expect(insert).toHaveCSS("height", "48px"); + await expect(insert).toHaveCSS("font-size", "36px"); + await expect(insert.locator("img")).toHaveCSS("max-width", "32px"); + await expect(insert.locator("img")).toHaveCSS("max-height", "32px"); + const searchNode = await search.elementHandle(); + // Exercise the actual widget boundary without recreating the picker/search. + for (const mode of ["dark", "light"]) { + await page.evaluate((mode) => { + document.documentElement.dataset.colorMode = mode; + }, mode); + await expect(surface).toHaveAttribute("data-theme", mode); + await expect(search).toHaveCSS( + "background-color", + mode === "dark" ? "rgb(16, 16, 16)" : "rgb(240, 240, 240)", ); - await expect(search).toHaveCSS("height", "28px"); - await expect(search).toHaveCSS("margin-left", "2px"); - await expect(search).toHaveCSS("margin-right", "2px"); - await expect(search).toHaveCSS("border-top-width", "0px"); - await expect(search).toHaveCSS("border-radius", "14px"); - await expect(search).toHaveCSS("background-color", "rgb(240, 240, 240)"); - await expect(search).toHaveCSS("color", "rgb(0, 0, 0)"); - await expect(search).toHaveCSS("outline-style", "none"); await expect(search).toHaveCSS( "box-shadow", - "rgb(0, 0, 0) 0px 0px 0px 2px", - ); - const surface = page.locator("em-emoji-picker #root"); - const region = page.getByRole("region", { name: "Emoji picker" }); - await expect(surface).toHaveAttribute("data-theme", "light"); - await expect(region).toHaveCSS("background-color", "rgb(255, 255, 255)"); - await expect(region).toHaveCSS("border-radius", "24px"); - await expect(region).toHaveCSS("border-top-width", "1px"); - await expect(region).not.toHaveCSS("box-shadow", "none"); - await expect( - page.getByRole("button", { name: "Refresh emoji" }), - ).toHaveCount(0); - await expect(surface).toHaveCSS("width", "360px"); - const initialRegion = await region.boundingBox(); - const initialSurface = await surface.boundingBox(); - const searchGutters = await search.evaluate((input) => { - const searchBounds = input.getBoundingClientRect(); - const rootBounds = input - .getRootNode() - .querySelector("#root") - .getBoundingClientRect(); - return { - left: searchBounds.left - rootBounds.left, - right: rootBounds.right - searchBounds.right, - }; - }); - expect(searchGutters.left).toBeCloseTo(searchGutters.right, 1); - expect(initialSurface.height).toBeCloseTo( - Math.min(348, page.viewportSize().height * 0.4), - 1, - ); - expect(initialRegion.width).toBe(initialSurface.width + 2); - expect(initialRegion.height).toBe(initialSurface.height + 2); - expect(initialRegion.x + 1).toBe(initialSurface.x); - expect(initialRegion.y + 1).toBe(initialSurface.y); - await page.screenshot({ - path: test.info().outputPath("emoji-picker-dark-os.png"), - }); - await search.fill("face"); - const searchResults = page.locator( - "em-emoji-picker .scroll .category button", - ); - await expect(searchResults.first()).toBeVisible(); - expect(await searchResults.count()).toBeGreaterThanOrEqual(6); - const searchRowPositions = await searchResults.evaluateAll((buttons) => - buttons.slice(0, 6).map((button) => { - const bounds = button.getBoundingClientRect(); - return { x: bounds.x, y: bounds.y }; - }), + mode === "dark" + ? "rgb(255, 255, 255) 0px 0px 0px 2px" + : "rgb(0, 0, 0) 0px 0px 0px 2px", ); - expect( - searchRowPositions.every(({ y }) => y === searchRowPositions[0].y), - ).toBe(true); - await search.fill("party"); - const emojiClear = page.locator("em-emoji-picker .search .delete"); - await expect(emojiClear).toHaveCSS("right", "10px"); - await expect(emojiClear.locator("svg")).toHaveAttribute( - "viewBox", - "0 0 24 24", - ); - await expect(emojiClear.locator("svg")).toHaveClass(/lucide-circle-x/); - await expect(emojiClear.locator("svg")).toHaveCSS("width", "16px"); - await expect(emojiClear.locator("svg")).toHaveCSS("height", "16px"); - await expect(emojiClear).toHaveCSS("color", "rgb(82, 82, 82)"); - await expect(emojiClear.locator("circle")).toHaveCSS( - "fill", - "rgb(82, 82, 82)", - ); - await expect(emojiClear.locator("circle")).toHaveCSS("stroke", "none"); - await expect(emojiClear.locator("path").first()).toHaveCSS( - "stroke", - "rgb(240, 240, 240)", - ); - await expect( - page.locator('[aria-label="Emoji picker"] > svg.lucide-search:visible'), - ).toHaveCount(1); - const insert = page.getByRole("button", { - name: ":party:", - exact: true, - }); - await expect(insert).toBeVisible(); - await expect(insert).toHaveCSS("width", "48px"); - await expect(insert).toHaveCSS("height", "48px"); - await expect(insert).toHaveCSS("font-size", "36px"); - await expect(insert.locator("img")).toHaveCSS("max-width", "32px"); - await expect(insert.locator("img")).toHaveCSS("max-height", "32px"); - const searchNode = await search.elementHandle(); - // Exercise the actual widget boundary without recreating the picker/search. - for (const mode of ["dark", "light"]) { - await page.evaluate((mode) => { - document.documentElement.dataset.colorMode = mode; - }, mode); - await expect(surface).toHaveAttribute("data-theme", mode); - await expect(search).toHaveCSS( - "background-color", - mode === "dark" ? "rgb(16, 16, 16)" : "rgb(240, 240, 240)", - ); - await expect(search).toHaveCSS( - "box-shadow", - mode === "dark" - ? "rgb(255, 255, 255) 0px 0px 0px 2px" - : "rgb(0, 0, 0) 0px 0px 0px 2px", - ); - await expect(search).toHaveCSS("font-family", /Inter Variable/); + await expect(search).toHaveCSS("font-family", /Inter Variable/); - await expect(search).toHaveValue("party"); - await expect(search).toBeFocused(); - expect(await searchNode.evaluate((node) => node.isConnected)).toBe(true); - } - // Exercise the shared composer's containing-block sizing in a clipped 300px pane. - await page.locator("main").evaluate((el) => { - el.style.width = "300px"; - }); - await expect(page.locator("em-emoji-picker #root")).toHaveCSS( - "width", - "240px", - ); await expect(search).toHaveValue("party"); - await expect(page.locator("em-emoji-picker nav")).toHaveCount(0); - const narrowRegion = await region.boundingBox(); - const narrowSurface = await surface.boundingBox(); - expect(narrowRegion.width).toBe(narrowSurface.width + 2); - expect(narrowRegion.height).toBe(narrowSurface.height + 2); - expect(narrowRegion.x + 1).toBe(narrowSurface.x); - expect(narrowRegion.y + 1).toBe(narrowSurface.y); - const pane = await page.locator("main").boundingBox(); - const popover = await page - .getByRole("region", { name: "Emoji picker" }) - .boundingBox(); - expect(popover.x).toBeGreaterThanOrEqual(pane.x); - expect(popover.x + popover.width).toBeLessThanOrEqual(pane.x + pane.width); + await expect(search).toBeFocused(); + expect(await searchNode.evaluate((node) => node.isConnected)).toBe(true); + } + // Exercise the shared composer's containing-block sizing in a clipped 300px pane. + await page.locator("main").evaluate((el) => { + el.style.width = "300px"; + }); + await expect(page.locator("em-emoji-picker #root")).toHaveCSS( + "width", + "240px", + ); + await expect(search).toHaveValue("party"); + await expect(page.locator("em-emoji-picker nav")).toHaveCount(0); + const narrowRegion = await region.boundingBox(); + const narrowSurface = await surface.boundingBox(); + expect(narrowRegion.width).toBe(narrowSurface.width + 2); + expect(narrowRegion.height).toBe(narrowSurface.height + 2); + expect(narrowRegion.x + 1).toBe(narrowSurface.x); + expect(narrowRegion.y + 1).toBe(narrowSurface.y); + const pane = await page.locator("main").boundingBox(); + const popover = await page + .getByRole("region", { name: "Emoji picker" }) + .boundingBox(); + expect(popover.x).toBeGreaterThanOrEqual(pane.x); + expect(popover.x + popover.width).toBeLessThanOrEqual(pane.x + pane.width); + expect( + await insert.evaluate((el) => { + const r = el.getBoundingClientRect(); + return el.contains( + el.getRootNode().elementFromPoint(r.right - 2, r.top + r.height / 2), + ); + }), + ).toBe(true); + await page.screenshot({ + path: test.info().outputPath("community-emoji-picker.png"), + }); + // Every result, including the last column, fits and is hit-testable in shadow DOM. + const results = page.locator("em-emoji-picker .category button"); + expect(await results.count()).toBeGreaterThan(5); + for (const button of await results.all()) { + if (!(await button.isVisible())) continue; expect( - await insert.evaluate((el) => { + await button.evaluate((el) => { const r = el.getBoundingClientRect(); return el.contains( el.getRootNode().elementFromPoint(r.right - 2, r.top + r.height / 2), ); }), ).toBe(true); - await page.screenshot({ - path: test.info().outputPath("community-emoji-picker.png"), - }); - // Every result, including the last column, fits and is hit-testable in shadow DOM. - const results = page.locator("em-emoji-picker .category button"); - expect(await results.count()).toBeGreaterThan(5); - for (const button of await results.all()) { - if (!(await button.isVisible())) continue; - expect( - await button.evaluate((el) => { - const r = el.getBoundingClientRect(); - return el.contains( - el - .getRootNode() - .elementFromPoint(r.right - 2, r.top + r.height / 2), - ); - }), - ).toBe(true); - } - await page.locator("main").evaluate((el) => { - el.style.width = "800px"; - }); - await expect(page.locator("em-emoji-picker #root")).toHaveCSS( - "width", - "360px", - ); - await search.fill(""); - const frequent = page.locator( - 'em-emoji-picker [data-id="frequent"] button', - ); - await expect(frequent.first()).toBeVisible(); - const firstRow = await frequent.evaluateAll((buttons) => - buttons.slice(0, 7).map((button) => { - const bounds = button.getBoundingClientRect(); - return { - height: bounds.height, - width: bounds.width, - x: bounds.x, - y: bounds.y, - }; - }), - ); - expect(firstRow).toHaveLength(6); - expect(firstRow.every(({ y }) => y === firstRow[0].y)).toBe(true); - expect(firstRow[0]).toMatchObject({ height: 48, width: 48 }); - const rowGaps = firstRow - .slice(1) - .map((item, index) => item.x - firstRow[index].x - firstRow[index].width); - expect(rowGaps[0]).toBeCloseTo(9.6, 1); - expect(rowGaps.every((gap) => Math.abs(gap - rowGaps[0]) < 0.1)).toBe(true); - const emojiGridGutters = await surface.evaluate((root) => { - const buttons = root.querySelectorAll('[data-id="frequent"] button'); - const first = buttons[0].getBoundingClientRect(); - const last = buttons[5].getBoundingClientRect(); - const rootBounds = root.getBoundingClientRect(); + } + await page.locator("main").evaluate((el) => { + el.style.width = "800px"; + }); + await expect(page.locator("em-emoji-picker #root")).toHaveCSS( + "width", + "360px", + ); + await search.fill(""); + const frequent = page.locator('em-emoji-picker [data-id="frequent"] button'); + await expect(frequent.first()).toBeVisible(); + const firstRow = await frequent.evaluateAll((buttons) => + buttons.slice(0, 7).map((button) => { + const bounds = button.getBoundingClientRect(); return { - left: first.left - rootBounds.left, - right: rootBounds.right - last.right, + height: bounds.height, + width: bounds.width, + x: bounds.x, + y: bounds.y, }; - }); - expect(emojiGridGutters.left).toBeCloseTo(emojiGridGutters.right, 1); - expect(emojiGridGutters.left).toBeCloseTo(12, 1); - const scrollbar = page.locator("em-emoji-picker .buzz-scrollbar-track"); - const scrollbarThumb = scrollbar.locator(".buzz-scrollbar-thumb"); - await expect(scrollbar).toBeVisible(); - await expect(scrollbar).toHaveCSS("right", "4px"); - await expect(scrollbar).toHaveCSS("opacity", "0.6"); - await expect(scrollbarThumb).toHaveCSS( - "background-color", - "rgb(149, 149, 149)", - ); - for (const [index, result] of searchRowPositions.entries()) - expect(result.x).toBeCloseTo(firstRow[index].x, 1); - const navigation = page.locator("em-emoji-picker nav"); - await expect(navigation).toBeVisible(); - const navigationButtonWidths = await navigation - .locator("button") - .evaluateAll((buttons) => - buttons.map((button) => button.getBoundingClientRect().width), - ); - expect(navigationButtonWidths).toHaveLength(11); - expect( - navigationButtonWidths.every( - (width) => Math.abs(width - navigationButtonWidths[0]) < 0.1, - ), - ).toBe(true); - const navigationGutters = await navigation.evaluate((nav) => { - const rootBounds = nav - .getRootNode() - .querySelector("#root") - .getBoundingClientRect(); - const buttons = nav.querySelectorAll("button"); - const first = buttons[0].getBoundingClientRect(); - const last = buttons[buttons.length - 1].getBoundingClientRect(); + }), + ); + expect(firstRow).toHaveLength(6); + expect(firstRow.every(({ y }) => y === firstRow[0].y)).toBe(true); + expect(firstRow[0]).toMatchObject({ height: 48, width: 48 }); + const rowGaps = firstRow + .slice(1) + .map((item, index) => item.x - firstRow[index].x - firstRow[index].width); + expect(rowGaps[0]).toBeCloseTo(9.6, 1); + expect(rowGaps.every((gap) => Math.abs(gap - rowGaps[0]) < 0.1)).toBe(true); + const emojiGridGutters = await surface.evaluate((root) => { + const buttons = root.querySelectorAll('[data-id="frequent"] button'); + const first = buttons[0].getBoundingClientRect(); + const last = buttons[5].getBoundingClientRect(); + const rootBounds = root.getBoundingClientRect(); + return { + left: first.left - rootBounds.left, + right: rootBounds.right - last.right, + }; + }); + expect(emojiGridGutters.left).toBeCloseTo(emojiGridGutters.right, 1); + expect(emojiGridGutters.left).toBeCloseTo(12, 1); + const scrollbar = page.locator("em-emoji-picker .buzz-scrollbar-track"); + const scrollbarThumb = scrollbar.locator(".buzz-scrollbar-thumb"); + await expect(scrollbar).toBeVisible(); + await expect(scrollbar).toHaveCSS("right", "4px"); + await expect(scrollbar).toHaveCSS("opacity", "0.6"); + await expect(scrollbarThumb).toHaveCSS( + "background-color", + "rgb(149, 149, 149)", + ); + for (const [index, result] of searchRowPositions.entries()) + expect(result.x).toBeCloseTo(firstRow[index].x, 1); + const navigation = page.locator("em-emoji-picker nav"); + await expect(navigation).toBeVisible(); + const navigationButtonWidths = await navigation + .locator("button") + .evaluateAll((buttons) => + buttons.map((button) => button.getBoundingClientRect().width), + ); + expect(navigationButtonWidths).toHaveLength(11); + expect( + navigationButtonWidths.every( + (width) => Math.abs(width - navigationButtonWidths[0]) < 0.1, + ), + ).toBe(true); + const navigationGutters = await navigation.evaluate((nav) => { + const rootBounds = nav + .getRootNode() + .querySelector("#root") + .getBoundingClientRect(); + const buttons = nav.querySelectorAll("button"); + const first = buttons[0].getBoundingClientRect(); + const last = buttons[buttons.length - 1].getBoundingClientRect(); + return { + left: first.left - rootBounds.left, + right: rootBounds.right - last.right, + }; + }); + expect( + Math.abs(navigationGutters.left - navigationGutters.right), + ).toBeLessThan(0.1); + expect(navigationGutters.left).toBeCloseTo(8, 1); + for (const [category, icon] of Object.entries({ + "Frequently used": "clock", + "Smileys & People": "face-slightly-smiling", + "Animals & Nature": "paw-print", + "Food & Drink": "apple", + Activity: "dumbbell", + "Travel & Places": "car-front", + Objects: "lightbulb", + Symbols: "shapes", + Flags: "flag", + Custom: "asterisk", + })) { + const categoryIcon = navigation + .getByRole("button", { name: category, exact: true }) + .locator(`svg.lucide-${icon}`); + await expect(categoryIcon).toHaveCount(1); + await expect(categoryIcon).toHaveCSS("width", "18px"); + await expect(categoryIcon).toHaveCSS("height", "18px"); + await expect(categoryIcon).toHaveCSS("fill", "none"); + await expect(categoryIcon).toHaveCSS("stroke-width", "2px"); + } + const recentIcon = navigation + .getByRole("button", { name: "Frequently used" }) + .locator("svg.lucide-clock"); + await expect(recentIcon).toHaveAttribute("viewBox", "0 0 24 24"); + await expect(recentIcon).toHaveCSS("fill", "none"); + await expect(recentIcon).toHaveCSS("stroke-width", "2px"); + await expect(recentIcon.locator("circle")).toHaveAttribute("r", "10"); + await expect(recentIcon.locator("path")).toHaveAttribute("d", "M12 6v6l4 2"); + const indicator = navigation.locator(".bar"); + await expect(indicator).toHaveCSS("display", "none"); + const selectedCategory = navigation.locator("button[aria-selected]"); + await expect(selectedCategory).toHaveCSS("color", "rgb(0, 0, 0)"); + const selectedBackground = () => + selectedCategory.evaluate((element) => { + const style = getComputedStyle(element, "::before"); + const bounds = element.getBoundingClientRect(); return { - left: first.left - rootBounds.left, - right: rootBounds.right - last.right, + background: style.backgroundColor, + buttonHeight: bounds.height, + buttonWidth: bounds.width, + duration: style.transitionDuration, + height: style.height, + left: style.left, + top: style.top, + width: style.width, }; }); - expect( - Math.abs(navigationGutters.left - navigationGutters.right), - ).toBeLessThan(0.1); - expect(navigationGutters.left).toBeCloseTo(8, 1); - for (const [category, icon] of Object.entries({ - "Frequently used": "clock", - "Smileys & People": "face-slightly-smiling", - "Animals & Nature": "paw-print", - "Food & Drink": "apple", - Activity: "dumbbell", - "Travel & Places": "car-front", - Objects: "lightbulb", - Symbols: "shapes", - Flags: "flag", - Custom: "asterisk", - })) { - const categoryIcon = navigation - .getByRole("button", { name: category, exact: true }) - .locator(`svg.lucide-${icon}`); - await expect(categoryIcon).toHaveCount(1); - await expect(categoryIcon).toHaveCSS("width", "18px"); - await expect(categoryIcon).toHaveCSS("height", "18px"); - await expect(categoryIcon).toHaveCSS("fill", "none"); - await expect(categoryIcon).toHaveCSS("stroke-width", "2px"); - } - const recentIcon = navigation - .getByRole("button", { name: "Frequently used" }) - .locator("svg.lucide-clock"); - await expect(recentIcon).toHaveAttribute("viewBox", "0 0 24 24"); - await expect(recentIcon).toHaveCSS("fill", "none"); - await expect(recentIcon).toHaveCSS("stroke-width", "2px"); - await expect(recentIcon.locator("circle")).toHaveAttribute("r", "10"); - await expect(recentIcon.locator("path")).toHaveAttribute( - "d", - "M12 6v6l4 2", - ); - const indicator = navigation.locator(".bar"); - await expect(indicator).toHaveCSS("display", "none"); - const selectedCategory = navigation.locator("button[aria-selected]"); - await expect(selectedCategory).toHaveCSS("color", "rgb(0, 0, 0)"); - const selectedBackground = () => - selectedCategory.evaluate((element) => { - const style = getComputedStyle(element, "::before"); - const bounds = element.getBoundingClientRect(); - return { - background: style.backgroundColor, - buttonHeight: bounds.height, - buttonWidth: bounds.width, - duration: style.transitionDuration, - height: style.height, - left: style.left, - top: style.top, - width: style.width, - }; - }); - expect(await selectedBackground()).toMatchObject({ - background: "rgb(232, 232, 232)", - duration: "0.12s", - height: "28px", - width: "28px", - }); - const initialBackground = await selectedBackground(); - expect(parseFloat(initialBackground.left)).toBeCloseTo( - initialBackground.buttonWidth / 2, - 1, - ); - expect(parseFloat(initialBackground.top)).toBeCloseTo( - initialBackground.buttonHeight / 2, - 1, - ); - const categoryPositions = await navigation + expect(await selectedBackground()).toMatchObject({ + background: "rgb(232, 232, 232)", + duration: "0.12s", + height: "28px", + width: "28px", + }); + const initialBackground = await selectedBackground(); + expect(parseFloat(initialBackground.left)).toBeCloseTo( + initialBackground.buttonWidth / 2, + 1, + ); + expect(parseFloat(initialBackground.top)).toBeCloseTo( + initialBackground.buttonHeight / 2, + 1, + ); + const categoryPositions = await navigation + .locator("button") + .evaluateAll((buttons) => + buttons.map((button) => button.getBoundingClientRect().x), + ); + await navigation.getByRole("button", { name: "Smileys & People" }).click(); + expect( + await navigation .locator("button") .evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().x), - ); - await navigation.getByRole("button", { name: "Smileys & People" }).click(); - expect( - await navigation - .locator("button") - .evaluateAll((buttons) => - buttons.map((button) => button.getBoundingClientRect().x), - ), - ).toEqual(categoryPositions); - await expect - .poll(() => selectedBackground().then(({ background }) => background)) - .toBe("rgb(232, 232, 232)"); - await page.emulateMedia({ reducedMotion: "reduce" }); - expect((await selectedBackground()).duration).toBe("0s"); - await page.emulateMedia({ reducedMotion: "no-preference" }); - const categoryHeading = page - .locator("em-emoji-picker .category .sticky") - .first(); - await expect(categoryHeading).toHaveCSS("color", "rgb(82, 82, 82)"); - await expect(categoryHeading).toHaveCSS("font-size", "12px"); - await expect(categoryHeading).toHaveCSS("font-weight", "400"); - await expect(page.getByText("Pick an emoji", { exact: true })).toHaveCount( - 0, - ); - const rootBox = await surface.boundingBox(); - const navBox = await navigation.boundingBox(); - expect(navBox.y).toBeGreaterThan(rootBox.y + rootBox.height / 2); - await search.fill("party"); - await expect(insert.locator("img")).toHaveAttribute( - "src", - /emoji-media\/a\/.*1.png/, - ); - const retiredPicker = await page.locator("em-emoji-picker").elementHandle(); - const retiredTheme = await retiredPicker.getAttribute("theme"); - await search.press("Escape"); - await expect(picker).toBeFocused(); - await page.evaluate(async () => { - document.documentElement.dataset.colorMode = "dark"; - await new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)), - ); - }); - expect(await retiredPicker.evaluate((el) => el.isConnected)).toBe(false); - // Closing owns the mode observer too; a retired widget must not update. - expect(await retiredPicker.getAttribute("theme")).toBe(retiredTheme); - await picker.click(); - // Newly opened widgets must start in the selected mode without a later toggle. - await expect(surface).toHaveAttribute("data-theme", "dark"); - for (const query of [ - "party-parrot", - "party parrot", - "parrot", - ":party-parrot:", - ]) { - await search.fill(query); - await expect( - page.getByRole("button", { name: ":party-parrot:", exact: true }), - ).toBeVisible(); - } - for (const query of [ - "party-parrot-wave", - ":party-parrot-wave:", - "party parrot wave", - ]) { - await search.fill(query); - await expect( - page.getByRole("button", { name: ":party-parrot-wave:", exact: true }), - ).toBeVisible(); - } - await search.fill("aonly"); - await search.press("Enter"); - await expect(draft()).toHaveJSProperty("value", ":aonly:"); - await picker.click(); - await search.fill(""); - await expect( - page.locator( - 'em-emoji-picker [data-id="frequent"] img[src*="aonly.png"]', ), - ).toHaveCount(1); - await search.fill("grinning"); + ).toEqual(categoryPositions); + await expect + .poll(() => selectedBackground().then(({ background }) => background)) + .toBe("rgb(232, 232, 232)"); + await page.emulateMedia({ reducedMotion: "reduce" }); + expect((await selectedBackground()).duration).toBe("0s"); + await page.emulateMedia({ reducedMotion: "no-preference" }); + const categoryHeading = page + .locator("em-emoji-picker .category .sticky") + .first(); + await expect(categoryHeading).toHaveCSS("color", "rgb(82, 82, 82)"); + await expect(categoryHeading).toHaveCSS("font-size", "12px"); + await expect(categoryHeading).toHaveCSS("font-weight", "400"); + await expect(page.getByText("Pick an emoji", { exact: true })).toHaveCount(0); + const rootBox = await surface.boundingBox(); + const navBox = await navigation.boundingBox(); + expect(navBox.y).toBeGreaterThan(rootBox.y + rootBox.height / 2); + await search.fill("party"); + await expect(insert.locator("img")).toHaveAttribute( + "src", + /emoji-media\/a\/.*1.png/, + ); + const retiredPicker = await page.locator("em-emoji-picker").elementHandle(); + const retiredTheme = await retiredPicker.getAttribute("theme"); + await search.press("Escape"); + await expect(picker).toBeFocused(); + await page.evaluate(async () => { + document.documentElement.dataset.colorMode = "dark"; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + }); + expect(await retiredPicker.evaluate((el) => el.isConnected)).toBe(false); + // Closing owns the mode observer too; a retired widget must not update. + expect(await retiredPicker.getAttribute("theme")).toBe(retiredTheme); + await picker.click(); + // Newly opened widgets must start in the selected mode without a later toggle. + await expect(surface).toHaveAttribute("data-theme", "dark"); + for (const query of [ + "party-parrot", + "party parrot", + "parrot", + ":party-parrot:", + ]) { + await search.fill(query); await expect( - page.getByRole("button", { name: ":grinning:", exact: true }), + page.getByRole("button", { name: ":party-parrot:", exact: true }), ).toBeVisible(); - await page.getByRole("button", { name: "😀", exact: true }).click(); - await expect(draft()).toHaveJSProperty("value", ":aonly:😀"); - await draft().fill("before after"); - await draft().evaluate((el) => el.setSelectionRange(7, 7)); - await picker.press("Enter"); - await search.focus(); - await search.fill("party"); - await search.press("Enter"); - await expect(draft()).toHaveJSProperty("value", "before :party:after"); - await expect(draft()).toBeFocused(); - await draft().press("Enter"); - await expect - .poll(() => - page.evaluate(() => window.emojiFixture.report.publications.length), - ) - .toBe(1); - const first = await page.evaluate( - () => window.emojiFixture.report.publications[0], - ); - expect(first.community).toBe("a"); - expect(first.event.tags).toContainEqual([ + } + for (const query of [ + "party-parrot-wave", + ":party-parrot-wave:", + "party parrot wave", + ]) { + await search.fill(query); + await expect( + page.getByRole("button", { name: ":party-parrot-wave:", exact: true }), + ).toBeVisible(); + } + await search.fill("aonly"); + await search.press("Enter"); + await expect(draft()).toHaveJSProperty("value", ":aonly:"); + await picker.click(); + await search.fill(""); + await expect( + page.locator('em-emoji-picker [data-id="frequent"] img[src*="aonly.png"]'), + ).toHaveCount(1); + await search.fill("grinning"); + await expect( + page.getByRole("button", { name: ":grinning:", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "😀", exact: true }).click(); + await expect(draft()).toHaveJSProperty("value", ":aonly:😀"); + await draft().fill("before after"); + await draft().evaluate((el) => el.setSelectionRange(7, 7)); + await picker.press("Enter"); + await search.focus(); + await search.fill("party"); + await search.press("Enter"); + await expect(draft()).toHaveJSProperty("value", "before :party:after"); + await expect(draft()).toBeFocused(); + await draft().press("Enter"); + await expect + .poll(() => + page.evaluate(() => window.emojiFixture.report.publications.length), + ) + .toBe(1); + const first = await page.evaluate( + () => window.emojiFixture.report.publications[0], + ); + expect(first.community).toBe("a"); + expect(first.event.tags).toContainEqual([ + "emoji", + "party", + "https://a.test/media/1.png", + ]); + await expect(historic).toHaveAttribute("src", originalSrc); + await picker.click(); + await search.fill("party"); + await page.evaluate(() => window.emojiFixture.replace()); + await expect(search).toHaveValue("party"); + await expect(insert.locator("img")).toHaveAttribute("src", /2.png/); + await search.press("Escape"); + await draft().fill("A draft"); + await page.getByRole("button", { name: "Switch community" }).click(); + await expect(draft()).toHaveJSProperty("value", ""); + await expect + .poll(() => page.evaluate(() => window.emojiFixture.status("b"))) + .toBe("ready"); + await picker.click(); + await search.fill("aonly"); + await expect( + page.getByRole("button", { name: ":aonly:", exact: true }), + ).toHaveCount(0); + await expect( + page.locator('em-emoji-picker img[src*="emoji-media/a/"]'), + ).toHaveCount(0); + await search.fill(""); + await expect( + page.locator('em-emoji-picker img[src*="aonly.png"]'), + ).toHaveCount(0); + await search.fill("party"); + await expect(insert.locator("img")).toHaveAttribute( + "src", + /emoji-media\/b\/.*1.png/, + ); + await insert.click(); + await expect(draft()).toHaveJSProperty("value", ":party:"); + await expect(draft()).toBeFocused(); + await draft().press("Enter"); + await expect + .poll(() => + page.evaluate(() => window.emojiFixture.report.publications.length), + ) + .toBe(2); + await page.getByRole("button", { name: "Toggle thread" }).click(); + await picker.click(); + await search.fill("party"); + await insert.click(); + await expect(draft()).toHaveJSProperty("value", ":party:"); + await expect(draft()).toBeFocused(); + await draft().press("Enter"); + await expect + .poll(() => + page.evaluate(() => window.emojiFixture.report.publications.length), + ) + .toBe(3); + const replies = await page.evaluate(() => + window.emojiFixture.report.publications.slice(1), + ); + for (const { community, event } of replies) { + expect(community).toBe("b"); + expect(event.tags).toContainEqual([ "emoji", "party", - "https://a.test/media/1.png", + "https://b.test/media/1.png", ]); - await expect(historic).toHaveAttribute("src", originalSrc); - await picker.click(); - await search.fill("party"); - await page.evaluate(() => window.emojiFixture.replace()); - await expect(search).toHaveValue("party"); - await expect(insert.locator("img")).toHaveAttribute("src", /2.png/); - await search.press("Escape"); - await draft().fill("A draft"); - await page.getByRole("button", { name: "Switch community" }).click(); - await expect(draft()).toHaveJSProperty("value", ""); - await expect - .poll(() => page.evaluate(() => window.emojiFixture.status("b"))) - .toBe("ready"); - await picker.click(); - await search.fill("aonly"); - await expect( - page.getByRole("button", { name: ":aonly:", exact: true }), - ).toHaveCount(0); - await expect( - page.locator('em-emoji-picker img[src*="emoji-media/a/"]'), - ).toHaveCount(0); - await search.fill(""); - await expect( - page.locator('em-emoji-picker img[src*="aonly.png"]'), - ).toHaveCount(0); - await search.fill("party"); - await expect(insert.locator("img")).toHaveAttribute( - "src", - /emoji-media\/b\/.*1.png/, - ); - await insert.click(); - await expect(draft()).toHaveJSProperty("value", ":party:"); - await expect(draft()).toBeFocused(); - await draft().press("Enter"); - await expect - .poll(() => - page.evaluate(() => window.emojiFixture.report.publications.length), - ) - .toBe(2); - await page.getByRole("button", { name: "Toggle thread" }).click(); - await picker.click(); - await search.fill("party"); - await insert.click(); - await expect(draft()).toHaveJSProperty("value", ":party:"); - await expect(draft()).toBeFocused(); - await draft().press("Enter"); - await expect - .poll(() => - page.evaluate(() => window.emojiFixture.report.publications.length), - ) - .toBe(3); - const replies = await page.evaluate(() => - window.emojiFixture.report.publications.slice(1), - ); - for (const { community, event } of replies) { - expect(community).toBe("b"); - expect(event.tags).toContainEqual([ - "emoji", - "party", - "https://b.test/media/1.png", - ]); - } - expect( - replies[1].event.tags.some((tag) => tag[0] === "e" && tag[3] === "reply"), - ).toBe(true); - await page.getByRole("button", { name: "Toggle thread" }).click(); - await page.getByRole("button", { name: "Switch community" }).click(); - await expect(draft()).toHaveJSProperty("value", "A draft"); - await picker.click(); - await page.evaluate(async () => { - window.emojiFixture.fail(true); - await window.emojiFixture.refresh(); - }); - await expect(page.getByRole("alert")).toContainText( - "Fixture catalog offline", - ); - await search.fill("grinning"); - await page.getByRole("button", { name: "😀", exact: true }).click(); - await expect(draft()).toHaveJSProperty("value", "😀A draft"); - await draft().fill(":party:"); - await draft().press("Enter"); - await expect(draft()).toHaveJSProperty("value", ":party:"); - await expect(page.getByRole("alert")).toContainText( - "Community emoji unavailable", - ); - await picker.click(); - await expect(region.getByRole("alert")).toContainText( - "Fixture catalog offline", - ); - await page.evaluate(() => { - window.emojiFixture.fail(false); - window.emojiFixture.holdCatalog(); - }); - try { - await page - .getByRole("button", { name: "Retry emoji", exact: true }) - .click(); - await expect - .poll(() => page.evaluate(() => window.emojiFixture.status("a"))) - .toBe("loading"); - // A usable Unicode-only picker during loading is not the recovered mount. - await expect(search).toHaveAttribute("data-buzz-search-ready", "true"); - await expect( - page.locator('em-emoji-picker [data-id="buzz-custom"]'), - ).toHaveCount(0); - } finally { - await page.evaluate(() => window.emojiFixture.releaseCatalog()); - } + } + expect( + replies[1].event.tags.some((tag) => tag[0] === "e" && tag[3] === "reply"), + ).toBe(true); + await page.getByRole("button", { name: "Toggle thread" }).click(); + await page.getByRole("button", { name: "Switch community" }).click(); + await expect(draft()).toHaveJSProperty("value", "A draft"); + await picker.click(); + await page.evaluate(async () => { + window.emojiFixture.fail(true); + await window.emojiFixture.refresh(); + }); + await expect(page.getByRole("alert")).toContainText( + "Fixture catalog offline", + ); + await search.fill("grinning"); + await page.getByRole("button", { name: "😀", exact: true }).click(); + await expect(draft()).toHaveJSProperty("value", "😀A draft"); + await draft().fill(":party:"); + await draft().press("Enter"); + await expect(draft()).toHaveJSProperty("value", ":party:"); + await expect(page.getByRole("alert")).toContainText( + "Community emoji unavailable", + ); + await picker.click(); + await expect(region.getByRole("alert")).toContainText( + "Fixture catalog offline", + ); + await page.evaluate(() => { + window.emojiFixture.fail(false); + window.emojiFixture.holdCatalog(); + }); + try { + await page + .getByRole("button", { name: "Retry emoji", exact: true }) + .click(); await expect .poll(() => page.evaluate(() => window.emojiFixture.status("a"))) - .toBe("ready"); - await expect( - page.locator('em-emoji-picker [data-id="buzz-custom"]'), - ).toHaveCount(1); + .toBe("loading"); + // A usable Unicode-only picker during loading is not the recovered mount. await expect(search).toHaveAttribute("data-buzz-search-ready", "true"); - await search.fill("party"); - await expect(insert).toBeVisible(); - await draft().fill(":broken: readable"); - await expect(draft()).toContainText(":broken: readable"); - await draft().fill(":broken: :nosource:"); - await expect(draft()).toContainText(":broken: :nosource:"); - await expect(draft()).not.toHaveCSS("color", "rgba(0, 0, 0, 0)"); - const longDraft = `:party: ${"long text ".repeat(160)}`; - await draft().fill(longDraft); - await expect(draft()).toHaveJSProperty("value", longDraft); - await expect(draft()).toContainText("long text long text"); - await expect(draft().locator("img")).toHaveCount(1); - await expect - .poll(() => - draft().evaluate( - (element) => element.scrollHeight - element.clientHeight, - ), - ) - .toBeGreaterThan(0); - await draft().evaluate((element) => { - element.scrollTop = element.scrollHeight; - }); - await expect - .poll(() => draft().evaluate((element) => element.scrollTop)) - .toBeGreaterThan(0); - await draft().fill(""); - await page.evaluate(() => window.emojiFixture.remove()); - await expect(search).toHaveValue("party"); - await expect(insert).toHaveCount(0); await expect( page.locator('em-emoji-picker [data-id="buzz-custom"]'), ).toHaveCount(0); - await expect(historic).toHaveAttribute("src", originalSrc); - await search.fill(""); - await expect( - page.locator('em-emoji-picker img[src*="emoji-media"]'), - ).toHaveCount(0); - await search.fill("aonly"); - await expect( - page.getByRole("button", { name: ":aonly:", exact: true }), - ).toHaveCount(0); - await search.press("Escape"); - await picker.click(); - await search.fill("party"); - await expect(insert).toHaveCount(0); - await search.press("Escape"); - await draft().fill(""); - await picker.click(); - await search.fill("grinning"); - await page.getByRole("button", { name: "😀", exact: true }).click(); - await expect(draft()).toHaveAttribute("data-single-emoji", "true"); - await expect(draft()).toHaveCSS("font-size", "42px"); - await draft().fill("😀 🙏 👏"); - await expect(draft()).toHaveAttribute("data-single-emoji", "true"); - await expect(draft()).toHaveCSS("font-size", "42px"); - await draft().fill("😀 🙏 👏 😄"); - await expect(draft()).toHaveAttribute("data-single-emoji", "true"); - await expect(draft()).toHaveCSS("font-size", "42px"); - await draft().fill("😀 🙏 👏 hello"); - await expect(draft()).not.toHaveAttribute("data-single-emoji", "true"); - await expect(draft()).toHaveCSS("font-size", "14px"); - const publicationCount = await page.evaluate( - () => window.emojiFixture.report.publications.length, - ); - await draft().press("Enter"); - await expect - .poll(() => - page.evaluate(() => window.emojiFixture.report.publications.length), - ) - .toBe(publicationCount + 1); - expect( - await page.evaluate( - () => window.emojiFixture.report.publications.at(-1).event.content, - ), - ).toBe("😀 🙏 👏 hello"); - expect(errors).toEqual([]); } finally { - try { - await server?.close(); - } finally { - await rm(cacheDir, { recursive: true, force: true }); - } + await page.evaluate(() => window.emojiFixture.releaseCatalog()); } + await expect + .poll(() => page.evaluate(() => window.emojiFixture.status("a"))) + .toBe("ready"); + await expect( + page.locator('em-emoji-picker [data-id="buzz-custom"]'), + ).toHaveCount(1); + await expect(search).toHaveAttribute("data-buzz-search-ready", "true"); + await search.fill("party"); + await expect(insert).toBeVisible(); + await draft().fill(":broken: readable"); + await expect(draft()).toContainText(":broken: readable"); + await draft().fill(":broken: :nosource:"); + await expect(draft()).toContainText(":broken: :nosource:"); + await expect(draft()).not.toHaveCSS("color", "rgba(0, 0, 0, 0)"); + const longDraft = `:party: ${"long text ".repeat(160)}`; + await draft().fill(longDraft); + await expect(draft()).toHaveJSProperty("value", longDraft); + await expect(draft()).toContainText("long text long text"); + await expect(draft().locator("img")).toHaveCount(1); + await expect + .poll(() => + draft().evaluate( + (element) => element.scrollHeight - element.clientHeight, + ), + ) + .toBeGreaterThan(0); + await draft().evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect + .poll(() => draft().evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + await draft().fill(""); + await page.evaluate(() => window.emojiFixture.remove()); + await expect(search).toHaveValue("party"); + await expect(insert).toHaveCount(0); + await expect( + page.locator('em-emoji-picker [data-id="buzz-custom"]'), + ).toHaveCount(0); + await expect(historic).toHaveAttribute("src", originalSrc); + await search.fill(""); + await expect( + page.locator('em-emoji-picker img[src*="emoji-media"]'), + ).toHaveCount(0); + await search.fill("aonly"); + await expect( + page.getByRole("button", { name: ":aonly:", exact: true }), + ).toHaveCount(0); + await search.press("Escape"); + await picker.click(); + await search.fill("party"); + await expect(insert).toHaveCount(0); + await search.press("Escape"); + await draft().fill(""); + await picker.click(); + await search.fill("grinning"); + await page.getByRole("button", { name: "😀", exact: true }).click(); + await expect(draft()).toHaveAttribute("data-single-emoji", "true"); + await expect(draft()).toHaveCSS("font-size", "42px"); + await draft().fill("😀 🙏 👏"); + await expect(draft()).toHaveAttribute("data-single-emoji", "true"); + await expect(draft()).toHaveCSS("font-size", "42px"); + await draft().fill("😀 🙏 👏 😄"); + await expect(draft()).toHaveAttribute("data-single-emoji", "true"); + await expect(draft()).toHaveCSS("font-size", "42px"); + await draft().fill("😀 🙏 👏 hello"); + await expect(draft()).not.toHaveAttribute("data-single-emoji", "true"); + await expect(draft()).toHaveCSS("font-size", "14px"); + const publicationCount = await page.evaluate( + () => window.emojiFixture.report.publications.length, + ); + await draft().press("Enter"); + await expect + .poll(() => + page.evaluate(() => window.emojiFixture.report.publications.length), + ) + .toBe(publicationCount + 1); + expect( + await page.evaluate( + () => window.emojiFixture.report.publications.at(-1).event.content, + ), + ).toBe("😀 🙏 👏 hello"); + expect(errors).toEqual([]); }); diff --git a/tests/browser/external-links.spec.mjs b/tests/browser/external-links.spec.mjs index 8ff97b8c..fc653267 100644 --- a/tests/browser/external-links.spec.mjs +++ b/tests/browser/external-links.spec.mjs @@ -1,4 +1,5 @@ import { test, expect } from "./fixture.mjs"; +import { end } from "./timeline.mjs"; const github = "https://github.com/block/buzz/pull/1"; const ordinary = "https://example.test/external-link"; @@ -48,6 +49,8 @@ test("unhandled links open externally and disabling GitHub restores the fallback await page.goto(app.origin); await openMessages(page); app.append("primary", "alpha", `${github} ${ordinary} ${unsupported}`); + await expect(link(page, github)).toBeAttached(); + await end(page); await link(page, github).click(); const panel = page.getByRole("complementary", { name: "GitHub", diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs index 3d966219..7a759fb4 100644 --- a/tests/browser/mentions.spec.mjs +++ b/tests/browser/mentions.spec.mjs @@ -1,183 +1,155 @@ -import { test, expect } from "@playwright/test"; -import { createServer } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; +import { test, expect } from "./source-fixture.mjs"; test("actual composer selects namesakes by exact key, publishes channel/reply tags, and blocks removed members", async ({ page, }) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0 }, - }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - try { - await server.listen(); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html`, - ); - const keys = await page.evaluate(() => ({ - first: window.mentionFixture.first, - second: window.mentionFixture.second, - })); - const choose = async (key) => { - await page - .getByRole("button", { name: "Mention a member", exact: true }) - .click(); - const picker = page.getByRole("region", { - name: "Mention a channel member", - }); - await expect( - picker.getByRole("button", { name: `Honey ${key}`, exact: true }), - ).toBeVisible(); - await picker - .getByRole("button", { name: `Honey ${key}`, exact: true }) - .click(); - }; - const order = () => - page - .getByRole("button", { name: /^(Mention a member|Insert emoji)$/ }) - .evaluateAll((buttons) => - buttons.map((button) => button.getAttribute("aria-label")), - ); - await expect.poll(order).toEqual(["Mention a member", "Insert emoji"]); - await choose(keys.first); - await choose(keys.second); - // The merged toolbar must preserve exact recipients while the new picker - // inserts Unicode and follows the host mode without recreating the draft. - const input = page.getByRole("textbox", { name: "Message #General" }); - await page.evaluate(() => { - document.documentElement.dataset.colorMode = "dark"; - }); + await page.goto("/tests/fixtures/mentions.html"); + const keys = await page.evaluate(() => ({ + first: window.mentionFixture.first, + second: window.mentionFixture.second, + })); + const choose = async (key) => { await page - .getByRole("button", { name: "Insert emoji", exact: true }) + .getByRole("button", { name: "Mention a member", exact: true }) .click(); - const search = page.getByRole("searchbox", { name: "Search" }); - await expect(page.locator("em-emoji-picker #root")).toHaveAttribute( - "data-theme", - "dark", - ); - await search.fill("grinning"); - await page.getByRole("button", { name: "😀", exact: true }).click(); - await expect(input).toHaveJSProperty("value", "@Honey @Honey 😀"); - await expect( - page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button"), - ).toHaveCount(2); - const chip = page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button") - .first(); - await expect(chip).toHaveCSS("background-color", "rgb(26, 26, 26)"); - await expect(chip).toHaveCSS("color", "rgb(255, 255, 255)"); - await page.screenshot({ - path: test.info().outputPath("mention-recipients.png"), + const picker = page.getByRole("region", { + name: "Mention a channel member", }); - await page - .getByRole("button", { name: "Send message", exact: true }) - .click(); - await expect - .poll(() => - page.evaluate( - () => - window.mentionFixture.publications.length || - window.mentionFixture - .outbox() - .map((item) => ({ state: item.delivery, error: item.error })), - ), - ) - .toBe(1); - const first = await page.evaluate( - () => window.mentionFixture.publications[0], - ); - expect(first.content).toBe("@Honey @Honey 😀"); - expect(first.tags.filter(([tag]) => tag === "p")).toEqual([ - ["p", keys.first], - ["p", keys.second], - ]); - expect(first.tags.filter(([tag]) => tag === "h")).toEqual([["h", "c"]]); - await page.getByRole("button", { name: "Toggle thread" }).click(); - await choose(keys.second); - await page.evaluate(() => - window.mentionFixture.change("disable", "buzz.mentions"), - ); - await expect( - page.getByRole("button", { name: "Mention a member", exact: true }), - ).toHaveCount(0); - await expect( - page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button"), - ).toHaveCount(1); - await page - .getByRole("button", { name: "Send message", exact: true }) - .click(); - await expect - .poll(() => - page.evaluate(() => window.mentionFixture.publications.length), - ) - .toBe(2); - const reply = await page.evaluate( - () => window.mentionFixture.publications[1], - ); - expect(reply.tags).toContainEqual(["e", "a".repeat(64), "", "reply"]); - expect(reply.tags.filter(([tag]) => tag === "p")).toEqual([ - ["p", keys.second], - ]); - await page.evaluate(() => - window.mentionFixture.change("enable", "buzz.mentions"), - ); - await expect.poll(order).toEqual(["Mention a member", "Insert emoji"]); - await choose(keys.first); - await page - .getByRole("button", { name: "Remove first Honey", exact: true }) - .click(); - await page - .getByRole("button", { name: "Send message", exact: true }) - .click(); - await expect(page.getByRole("alert")).toContainText( - "no longer a channel member", - ); - await expect( - page.getByRole("textbox", { name: "Reply to thread" }), - ).toHaveJSProperty("value", "@Honey "); - expect( - await page.evaluate(() => window.mentionFixture.publications.length), - ).toBe(2); - // A child layout effect sees disabled DOM before parent command props refresh. - // Both commands must fail, even with the previous render's enabled closures. - await page - .getByRole("button", { name: "Toggle disabled", exact: true }) - .click(); await expect( - page.getByRole("textbox", { name: "Reply to thread" }), - ).toBeDisabled(); - expect( - await page.evaluate(() => window.mentionFixture.disabledCalls), - ).toEqual([{ inputDisabled: true, text: false, mention: false }]); - await expect( - page.getByRole("textbox", { name: "Reply to thread" }), - ).toHaveJSProperty("value", "@Honey "); - await expect( - page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button"), - ).toHaveCount(1); - await page - .getByRole("button", { name: "Toggle disabled", exact: true }) + picker.getByRole("button", { name: `Honey ${key}`, exact: true }), + ).toBeVisible(); + await picker + .getByRole("button", { name: `Honey ${key}`, exact: true }) .click(); - await choose(keys.second); - await expect( - page.getByRole("textbox", { name: "Reply to thread" }), - ).toHaveJSProperty("value", "@Honey @Honey "); - expect(errors).toEqual([]); - } finally { - await server.close(); - } + }; + const order = () => + page + .getByRole("button", { name: /^(Mention a member|Insert emoji)$/ }) + .evaluateAll((buttons) => + buttons.map((button) => button.getAttribute("aria-label")), + ); + await expect.poll(order).toEqual(["Mention a member", "Insert emoji"]); + await choose(keys.first); + await choose(keys.second); + // The merged toolbar must preserve exact recipients while the new picker + // inserts Unicode and follows the host mode without recreating the draft. + const input = page.getByRole("textbox", { name: "Message #General" }); + await page.evaluate(() => { + document.documentElement.dataset.colorMode = "dark"; + }); + await page.getByRole("button", { name: "Insert emoji", exact: true }).click(); + const search = page.getByRole("searchbox", { name: "Search" }); + await expect(page.locator("em-emoji-picker #root")).toHaveAttribute( + "data-theme", + "dark", + ); + await search.fill("grinning"); + await page.getByRole("button", { name: "😀", exact: true }).click(); + await expect(input).toHaveJSProperty("value", "@Honey @Honey 😀"); + await expect( + page + .getByRole("region", { name: "Notification recipients" }) + .getByRole("button"), + ).toHaveCount(2); + const chip = page + .getByRole("region", { name: "Notification recipients" }) + .getByRole("button") + .first(); + await expect(chip).toHaveCSS("background-color", "rgb(26, 26, 26)"); + await expect(chip).toHaveCSS("color", "rgb(255, 255, 255)"); + await page.screenshot({ + path: test.info().outputPath("mention-recipients.png"), + }); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect + .poll(() => + page.evaluate( + () => + window.mentionFixture.publications.length || + window.mentionFixture + .outbox() + .map((item) => ({ state: item.delivery, error: item.error })), + ), + ) + .toBe(1); + const first = await page.evaluate( + () => window.mentionFixture.publications[0], + ); + expect(first.content).toBe("@Honey @Honey 😀"); + expect(first.tags.filter(([tag]) => tag === "p")).toEqual([ + ["p", keys.first], + ["p", keys.second], + ]); + expect(first.tags.filter(([tag]) => tag === "h")).toEqual([["h", "c"]]); + await page.getByRole("button", { name: "Toggle thread" }).click(); + await choose(keys.second); + await page.evaluate(() => + window.mentionFixture.change("disable", "buzz.mentions"), + ); + await expect( + page.getByRole("button", { name: "Mention a member", exact: true }), + ).toHaveCount(0); + await expect( + page + .getByRole("region", { name: "Notification recipients" }) + .getByRole("button"), + ).toHaveCount(1); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect + .poll(() => page.evaluate(() => window.mentionFixture.publications.length)) + .toBe(2); + const reply = await page.evaluate( + () => window.mentionFixture.publications[1], + ); + expect(reply.tags).toContainEqual(["e", "a".repeat(64), "", "reply"]); + expect(reply.tags.filter(([tag]) => tag === "p")).toEqual([ + ["p", keys.second], + ]); + await page.evaluate(() => + window.mentionFixture.change("enable", "buzz.mentions"), + ); + await expect.poll(order).toEqual(["Mention a member", "Insert emoji"]); + await choose(keys.first); + await page + .getByRole("button", { name: "Remove first Honey", exact: true }) + .click(); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText( + "no longer a channel member", + ); + await expect( + page.getByRole("textbox", { name: "Reply to thread" }), + ).toHaveJSProperty("value", "@Honey "); + expect( + await page.evaluate(() => window.mentionFixture.publications.length), + ).toBe(2); + // A child layout effect sees disabled DOM before parent command props refresh. + // Both commands must fail, even with the previous render's enabled closures. + await page + .getByRole("button", { name: "Toggle disabled", exact: true }) + .click(); + await expect( + page.getByRole("textbox", { name: "Reply to thread" }), + ).toBeDisabled(); + expect( + await page.evaluate(() => window.mentionFixture.disabledCalls), + ).toEqual([{ inputDisabled: true, text: false, mention: false }]); + await expect( + page.getByRole("textbox", { name: "Reply to thread" }), + ).toHaveJSProperty("value", "@Honey "); + await expect( + page + .getByRole("region", { name: "Notification recipients" }) + .getByRole("button"), + ).toHaveCount(1); + await page + .getByRole("button", { name: "Toggle disabled", exact: true }) + .click(); + await choose(keys.second); + await expect( + page.getByRole("textbox", { name: "Reply to thread" }), + ).toHaveJSProperty("value", "@Honey @Honey "); + expect(errors).toEqual([]); }); diff --git a/tests/browser/messages.spec.mjs b/tests/browser/messages.spec.mjs index ce55d5cd..ed37a88e 100644 --- a/tests/browser/messages.spec.mjs +++ b/tests/browser/messages.spec.mjs @@ -1,274 +1,245 @@ -import { test, expect } from "@playwright/test"; -import { createServer } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; +import { test, expect } from "./source-fixture.mjs"; // Independent source consumer proves safe ordinary-prop reuse, with real React, // thread reader and durable outbox. No developer env, broker, credentials or relay. test("shared thread UI auto-loads, follows live replies, retries and isolates retargeted drafts", async ({ page, }) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0, strictPort: false }, - }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - try { - await server.listen(); - const address = server.httpServer.address(); - await page.goto( - `http://127.0.0.1:${address.port}/tests/fixtures/messages.html`, - ); - await page.evaluate(() => window.messagesFixture.activate()); - await expect - .poll(() => - page.evaluate(() => window.messagesFixture.extensionsActive()), - ) - .toContain("custom"); - const feed = page.getByRole("region", { name: "Channel message history" }); - await expect( - feed.getByRole("heading", { name: "Channel Markdown", level: 2 }), - ).toBeVisible(); - await expect( - feed.getByText("Virtualized channel row", { exact: true }), - ).toHaveCSS("font-weight", /^(650|700)$/); - const feedIndentation = await feed.evaluate(() => { - const nested = [...document.querySelectorAll("li")].find( - (item) => item.textContent?.trim() === "channel nested", - ); - const outer = nested?.parentElement?.parentElement; - if (!(outer instanceof HTMLLIElement) || !nested) - throw new Error("Missing channel nested ordered list"); - return { - outer: outer.getBoundingClientRect().left, - nested: nested.getBoundingClientRect().left, - }; - }); - expect(feedIndentation.nested).toBeGreaterThan(feedIndentation.outer + 8); - const panel = page.getByRole("complementary", { - name: "Thread", - exact: true, - }); - const history = panel.getByRole("region", { name: "Thread messages" }); - const draft = panel.getByRole("textbox", { - name: "Reply to thread", - exact: true, - }); - const choose = (name) => - page.getByRole("button", { name, exact: true }).click(); - const gap = () => - history.evaluate( - (el) => el.scrollHeight - el.clientHeight - el.scrollTop, - ); - await expect( - panel.getByText("61 replies shown", { exact: true }), - ).toBeVisible(); - await expect(panel.getByRole("status")).toHaveCount(0); - await expect( - page.getByRole("button", { name: "Load more replies", exact: true }), - ).toHaveCount(0); - await expect.poll(gap).toBeLessThan(2); - await expect( - history.getByRole("heading", { name: "Markdown reply", level: 2 }), - ).toBeVisible(); - await expect(history.getByText("Bold", { exact: true })).toHaveCSS( - "font-weight", - /^(650|700)$/, + await page.goto("/tests/fixtures/messages.html"); + await page.evaluate(() => window.messagesFixture.activate()); + await expect + .poll(() => page.evaluate(() => window.messagesFixture.extensionsActive())) + .toContain("custom"); + const feed = page.getByRole("region", { name: "Channel message history" }); + await expect( + feed.getByRole("heading", { name: "Channel Markdown", level: 2 }), + ).toBeVisible(); + await expect( + feed.getByText("Virtualized channel row", { exact: true }), + ).toHaveCSS("font-weight", /^(650|700)$/); + const feedIndentation = await feed.evaluate(() => { + const nested = [...document.querySelectorAll("li")].find( + (item) => item.textContent?.trim() === "channel nested", ); - await expect(history.getByText("italic", { exact: true })).toHaveCSS( - "font-style", - "italic", + const outer = nested?.parentElement?.parentElement; + if (!(outer instanceof HTMLLIElement) || !nested) + throw new Error("Missing channel nested ordered list"); + return { + outer: outer.getBoundingClientRect().left, + nested: nested.getBoundingClientRect().left, + }; + }); + expect(feedIndentation.nested).toBeGreaterThan(feedIndentation.outer + 8); + const panel = page.getByRole("complementary", { + name: "Thread", + exact: true, + }); + const history = panel.getByRole("region", { name: "Thread messages" }); + const draft = panel.getByRole("textbox", { + name: "Reply to thread", + exact: true, + }); + const choose = (name) => + page.getByRole("button", { name, exact: true }).click(); + const gap = () => + history.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop); + await expect( + panel.getByText("61 replies shown", { exact: true }), + ).toBeVisible(); + await expect(panel.getByRole("status")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Load more replies", exact: true }), + ).toHaveCount(0); + await expect.poll(gap).toBeLessThan(2); + await expect( + history.getByRole("heading", { name: "Markdown reply", level: 2 }), + ).toBeVisible(); + await expect(history.getByText("Bold", { exact: true })).toHaveCSS( + "font-weight", + /^(650|700)$/, + ); + await expect(history.getByText("italic", { exact: true })).toHaveCSS( + "font-style", + "italic", + ); + await expect(history.locator("del")).toHaveText("done"); + await expect( + history + .getByText("unordered one", { exact: true }) + .locator("xpath=ancestor::ul[1]"), + ).toHaveCSS("list-style-type", "disc"); + const indentation = await history.evaluate(() => { + const nested = [...document.querySelectorAll("li")].find( + (item) => item.textContent?.trim() === "nested", ); - await expect(history.locator("del")).toHaveText("done"); - await expect( - history - .getByText("unordered one", { exact: true }) - .locator("xpath=ancestor::ul[1]"), - ).toHaveCSS("list-style-type", "disc"); - const indentation = await history.evaluate(() => { - const nested = [...document.querySelectorAll("li")].find( - (item) => item.textContent?.trim() === "nested", - ); - const outer = nested?.parentElement?.parentElement; - if (!(outer instanceof HTMLLIElement) || !nested) - throw new Error("Missing nested ordered list"); - if (getComputedStyle(nested.parentElement).listStyleType !== "decimal") - throw new Error("Nested ordered list lost its marker style"); - return { - outer: outer.getBoundingClientRect().left, - nested: nested.getBoundingClientRect().left, - }; - }); - expect(indentation.nested).toBeGreaterThan(indentation.outer + 8); - await expect(history.getByAltText(":_lead:")).toBeVisible(); - await expect(history.getByAltText(":trail_:")).toBeVisible(); - await expect( - history.getByRole("heading", { name: "Agent Markdown", level: 3 }), - ).toBeVisible(); - await expect( - history.getByText("Rendered from an agent envelope", { exact: true }), - ).toHaveCSS("font-weight", /^(650|700)$/); - await expect( - history.locator("code").filter({ hasText: "agent-code" }), - ).toBeVisible(); - // A
count misses a second line box caused by inherited pre-wrap. - // Measure the actual first/second text baselines in both shared surfaces. - for (const surface of [feed, history]) { - const paragraph = surface - .locator("p") - .filter({ hasText: /^first\s+second$/ }); - await expect(paragraph.locator("br")).toHaveCount(1); - const geometry = await paragraph.evaluate((element) => { - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); - const tops = []; - for (let node = walker.nextNode(); node; node = walker.nextNode()) { - for (const word of ["first", "second"]) { - const start = node.textContent.indexOf(word); - if (start < 0) continue; - const range = document.createRange(); - range.setStart(node, start); - range.setEnd(node, start + word.length); - tops.push(range.getBoundingClientRect().top); - } + const outer = nested?.parentElement?.parentElement; + if (!(outer instanceof HTMLLIElement) || !nested) + throw new Error("Missing nested ordered list"); + if (getComputedStyle(nested.parentElement).listStyleType !== "decimal") + throw new Error("Nested ordered list lost its marker style"); + return { + outer: outer.getBoundingClientRect().left, + nested: nested.getBoundingClientRect().left, + }; + }); + expect(indentation.nested).toBeGreaterThan(indentation.outer + 8); + await expect(history.getByAltText(":_lead:")).toBeVisible(); + await expect(history.getByAltText(":trail_:")).toBeVisible(); + await expect( + history.getByRole("heading", { name: "Agent Markdown", level: 3 }), + ).toBeVisible(); + await expect( + history.getByText("Rendered from an agent envelope", { exact: true }), + ).toHaveCSS("font-weight", /^(650|700)$/); + await expect( + history.locator("code").filter({ hasText: "agent-code" }), + ).toBeVisible(); + // A
count misses a second line box caused by inherited pre-wrap. + // Measure the actual first/second text baselines in both shared surfaces. + for (const surface of [feed, history]) { + const paragraph = surface + .locator("p") + .filter({ hasText: /^first\s+second$/ }); + await expect(paragraph.locator("br")).toHaveCount(1); + const geometry = await paragraph.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const tops = []; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + for (const word of ["first", "second"]) { + const start = node.textContent.indexOf(word); + if (start < 0) continue; + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + word.length); + tops.push(range.getBoundingClientRect().top); } - return { - tops, - height: element.getBoundingClientRect().height, - lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), - }; - }); - expect(geometry.tops).toHaveLength(2); - expect( - Math.abs(geometry.tops[1] - geometry.tops[0] - geometry.lineHeight), - ).toBeLessThan(1); - expect(Math.abs(geometry.height - 2 * geometry.lineHeight)).toBeLessThan( - 1, - ); - } - await expect(history.locator("pre code")).toHaveCSS("white-space", "pre"); - await expect(history.locator("table")).toContainText("wide-column-one-"); - await expect(history.locator("pre code")).toContainText("wide-content-"); - const safeLink = history.getByRole("link", { name: "Safe link" }); - await expect(safeLink).toHaveAttribute("href", "https://example.com/path"); - await expect(safeLink).toHaveAttribute("rel", "noopener noreferrer"); - const pagesBefore = page.context().pages().length; - await safeLink.click(); - await expect - .poll(() => - page.evaluate(() => window.messagesFixture.report.links.at(-1)), - ) - .toBe("https://example.com/path"); - expect(page.context().pages()).toHaveLength(pagesBefore); - const unhandled = history.getByRole("link", { name: "Unhandled link" }); - const popup = page.waitForEvent("popup"); - await unhandled.click(); - const external = await popup; - await external.waitForLoadState("domcontentloaded"); - expect(external.url()).toBe("https://example.com/unhandled"); - await external.close(); - await safeLink.click({ modifiers: ["ControlOrMeta"] }); - await expect - .poll(() => - page.evaluate(() => window.messagesFixture.report.links.length), - ) - .toBe(2); - const modified = page - .context() - .pages() - .find((candidate) => candidate !== page); - await modified?.close(); - await history.evaluate((element) => { - for (const selector of ["pre", "table"]) { - const item = element.querySelector(selector); - if (!(item instanceof HTMLElement)) - throw new Error(`Missing ${selector}`); - if ( - item.getBoundingClientRect().right > - element.getBoundingClientRect().right + 1 - ) - throw new Error(`${selector} overflows the thread`); - if (item.scrollWidth <= item.clientWidth) - throw new Error( - `${selector} does not provide local horizontal scrolling`, - ); } + return { + tops, + height: element.getBoundingClientRect().height, + lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), + }; }); - await history.evaluate((el) => { - el.scrollTop = 100; - el.dispatchEvent(new Event("scroll")); - }); - await page.evaluate(() => window.messagesFixture.live()); - await expect( - panel.getByText("62 replies shown", { exact: true }), - ).toBeVisible(); - await expect.poll(() => history.evaluate((el) => el.scrollTop)).toBe(100); - await draft.fill("keep first draft"); - await choose("Second root"); - await expect(draft).toHaveJSProperty("value", ""); - await expect( - panel.getByText("60 replies shown", { exact: true }), - ).toBeVisible(); - await expect.poll(gap).toBeLessThan(2); - await draft.fill("reject second reply"); - await draft.press("Enter"); - await expect(draft).toHaveJSProperty("value", ""); - await expect( - panel.getByText("Couldn’t send this message.", { exact: true }), - ).toBeVisible({ timeout: 15_000 }); - await panel.getByRole("button", { name: "Retry", exact: true }).click(); - await expect( - panel.getByRole("button", { name: "Retry", exact: true }), - ).toHaveCount(0); - await expect( - panel.getByText("reject second reply", { exact: true }), - ).toHaveCount(1); - // Retry removes its control while queued; wait for the actual second publish. - await expect - .poll(() => - page.evaluate(() => window.messagesFixture.report.publications.length), + expect(geometry.tops).toHaveLength(2); + expect( + Math.abs(geometry.tops[1] - geometry.tops[0] - geometry.lineHeight), + ).toBeLessThan(1); + expect(Math.abs(geometry.height - 2 * geometry.lineHeight)).toBeLessThan(1); + } + await expect(history.locator("pre code")).toHaveCSS("white-space", "pre"); + await expect(history.locator("table")).toContainText("wide-column-one-"); + await expect(history.locator("pre code")).toContainText("wide-content-"); + const safeLink = history.getByRole("link", { name: "Safe link" }); + await expect(safeLink).toHaveAttribute("href", "https://example.com/path"); + await expect(safeLink).toHaveAttribute("rel", "noopener noreferrer"); + const pagesBefore = page.context().pages().length; + await safeLink.click(); + await expect + .poll(() => page.evaluate(() => window.messagesFixture.report.links.at(-1))) + .toBe("https://example.com/path"); + expect(page.context().pages()).toHaveLength(pagesBefore); + const unhandled = history.getByRole("link", { name: "Unhandled link" }); + const popup = page.waitForEvent("popup"); + await unhandled.click(); + const external = await popup; + await external.waitForLoadState("domcontentloaded"); + expect(external.url()).toBe("https://example.com/unhandled"); + await external.close(); + await safeLink.click({ modifiers: ["ControlOrMeta"] }); + await expect + .poll(() => page.evaluate(() => window.messagesFixture.report.links.length)) + .toBe(2); + const modified = page + .context() + .pages() + .find((candidate) => candidate !== page); + await modified?.close(); + await history.evaluate((element) => { + for (const selector of ["pre", "table"]) { + const item = element.querySelector(selector); + if (!(item instanceof HTMLElement)) + throw new Error(`Missing ${selector}`); + if ( + item.getBoundingClientRect().right > + element.getBoundingClientRect().right + 1 ) - .toBe(2); - const delivery = await page.evaluate(() => window.messagesFixture.report); - expect(delivery.signings).toHaveLength(1); - expect(delivery.publications).toHaveLength(2); - expect(delivery.publications[0]).toEqual(delivery.publications[1]); - await choose("First root"); - await expect(draft).toHaveJSProperty("value", "keep first draft"); - await page - .getByRole("textbox", { name: "Message #one", exact: true }) - .fill("keep channel draft"); - await choose("Other channel root"); - await expect(draft).toHaveJSProperty("value", ""); - await expect( - page.getByRole("textbox", { name: "Message #two", exact: true }), - ).toHaveJSProperty("value", ""); - await choose("First root"); - await expect(draft).toHaveJSProperty("value", "keep first draft"); - await expect( - page.getByRole("textbox", { name: "Message #one", exact: true }), - ).toHaveJSProperty("value", "keep channel draft"); - await choose("Switch scope"); - await expect(draft).toHaveJSProperty("value", ""); - await choose("Switch scope"); - await expect(draft).toHaveJSProperty("value", "keep first draft"); - for (const [index, kind] of [9, 40002].entries()) { - await page.evaluate((value) => window.messagesFixture.deep(value), kind); - await expect( - panel.getByText(`${63 + index} replies shown`, { exact: true }), - ).toBeVisible(); - const literal = history - .getByText("literal deep message", { exact: false }) - .last(); - await expect(literal).toBeVisible(); - await expect(literal).toHaveCSS("white-space", "pre-wrap"); + throw new Error(`${selector} overflows the thread`); + if (item.scrollWidth <= item.clientWidth) + throw new Error( + `${selector} does not provide local horizontal scrolling`, + ); } - expect(errors).toEqual([]); - } finally { - await server.close(); + }); + await history.evaluate((el) => { + el.scrollTop = 100; + el.dispatchEvent(new Event("scroll")); + }); + await page.evaluate(() => window.messagesFixture.live()); + await expect( + panel.getByText("62 replies shown", { exact: true }), + ).toBeVisible(); + await expect.poll(() => history.evaluate((el) => el.scrollTop)).toBe(100); + await draft.fill("keep first draft"); + await choose("Second root"); + await expect(draft).toHaveJSProperty("value", ""); + await expect( + panel.getByText("60 replies shown", { exact: true }), + ).toBeVisible(); + await expect.poll(gap).toBeLessThan(2); + await draft.fill("reject second reply"); + await draft.press("Enter"); + await expect(draft).toHaveJSProperty("value", ""); + await expect( + panel.getByText("Couldn’t send this message.", { exact: true }), + ).toBeVisible({ timeout: 15_000 }); + await panel.getByRole("button", { name: "Retry", exact: true }).click(); + await expect( + panel.getByRole("button", { name: "Retry", exact: true }), + ).toHaveCount(0); + await expect( + panel.getByText("reject second reply", { exact: true }), + ).toHaveCount(1); + // Retry removes its control while queued; wait for the actual second publish. + await expect + .poll(() => + page.evaluate(() => window.messagesFixture.report.publications.length), + ) + .toBe(2); + const delivery = await page.evaluate(() => window.messagesFixture.report); + expect(delivery.signings).toHaveLength(1); + expect(delivery.publications).toHaveLength(2); + expect(delivery.publications[0]).toEqual(delivery.publications[1]); + await choose("First root"); + await expect(draft).toHaveJSProperty("value", "keep first draft"); + await page + .getByRole("textbox", { name: "Message #one", exact: true }) + .fill("keep channel draft"); + await choose("Other channel root"); + await expect(draft).toHaveJSProperty("value", ""); + await expect( + page.getByRole("textbox", { name: "Message #two", exact: true }), + ).toHaveJSProperty("value", ""); + await choose("First root"); + await expect(draft).toHaveJSProperty("value", "keep first draft"); + await expect( + page.getByRole("textbox", { name: "Message #one", exact: true }), + ).toHaveJSProperty("value", "keep channel draft"); + await choose("Switch scope"); + await expect(draft).toHaveJSProperty("value", ""); + await choose("Switch scope"); + await expect(draft).toHaveJSProperty("value", "keep first draft"); + for (const [index, kind] of [9, 40002].entries()) { + await page.evaluate((value) => window.messagesFixture.deep(value), kind); + await expect( + panel.getByText(`${63 + index} replies shown`, { exact: true }), + ).toBeVisible(); + const literal = history + .getByText("literal deep message", { exact: false }) + .last(); + await expect(literal).toBeVisible(); + await expect(literal).toHaveCSS("white-space", "pre-wrap"); } + expect(errors).toEqual([]); }); diff --git a/tests/browser/notifications.spec.mjs b/tests/browser/notifications.spec.mjs index 323f83f8..02f3e476 100644 --- a/tests/browser/notifications.spec.mjs +++ b/tests/browser/notifications.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "./fixture.mjs"; -import { open, settle } from "./timeline.mjs"; +import { end, open, settle } from "./timeline.mjs"; import { finalizeEvent, generateSecretKey } from "nostr-tools"; test.use({ @@ -255,6 +255,12 @@ for (const kind of ["mention", "thread reply"]) { page, app, }) => { + // Only this geometry scenario needs an overflowing Beta history. + if (kind === "mention") { + for (let i = 0; i < 20; i++) { + app.append("primary", "beta", `Earlier message ${i}`, false, false); + } + } // Model a real prior contribution in relay history, not a client-side // participation/readiness override. The incoming reply itself has no p tag. const root = @@ -343,6 +349,24 @@ for (const kind of ["mention", "thread reply"]) { ), ) .toBe(false); + if (!root) { + // Fractional reflow must not leave the last row clipped at maximum scroll. + await row.evaluate((element) => { + element.style.paddingBottom = "0.125px"; + }); + for (const width of [1440, 640]) { + await page.setViewportSize({ width, height: 950 }); + await expect + .poll(() => + surface.evaluate( + (element) => element.scrollHeight > element.clientHeight, + ), + ) + .toBe(true); + await end(page); + await expect(row).toBeInViewport({ ratio: 1 }); + } + } }); } diff --git a/tests/browser/reactions.spec.mjs b/tests/browser/reactions.spec.mjs index 9ff8054a..62a4cb02 100644 --- a/tests/browser/reactions.spec.mjs +++ b/tests/browser/reactions.spec.mjs @@ -1,139 +1,117 @@ -import { test, expect } from "@playwright/test"; -import { createServer } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; +import { test, expect } from "./source-fixture.mjs"; test("reaction plus opens a visible emoji-only picker, restores focus and publishes custom emoji", async ({ page, }) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0 }, + const errors = []; + page.on("pageerror", (error) => errors.push(String(error))); + await page.route("**/emoji-media/**", (route) => + route.fulfill({ + contentType: "image/svg+xml", + body: '', + }), + ); + await page.goto("/tests/fixtures/emoji.html?reactions"); + const plus = page.getByRole("button", { + name: "Add reaction", + exact: true, }); - try { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); - await page.route("**/emoji-media/**", (route) => - route.fulfill({ - contentType: "image/svg+xml", - body: '', - }), - ); - await server.listen(); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/emoji.html?reactions`, - ); - const plus = page.getByRole("button", { - name: "Add reaction", - exact: true, - }); - // Only one fixture message has reactions; the others must have no action. - await expect(plus).toHaveCount(1); - await plus.click(); - const search = page.locator('em-emoji-picker input[type="search"]'); - await expect(search).toBeVisible(); - const padding = await search.evaluate((input) => { - const field = input.getBoundingClientRect(); - const picker = input - .getRootNode() - .querySelector("#root") - .getBoundingClientRect(); - return { - top: field.top - picker.top, - left: field.left - picker.left, - right: picker.right - field.right, - }; - }); - expect(padding.top).toBeCloseTo(padding.left, 1); - expect(padding.top).toBeCloseTo(padding.right, 1); - await expect(search).toHaveCSS("border-radius", "14px"); - await expect( - page.getByRole("tab", { name: "GIF", exact: true }), - ).toHaveCount(0); - await search.press("Escape"); - await expect(search).toHaveCount(0); - await expect(plus).toBeFocused(); - await plus.click(); - await search.fill("party"); - const custom = page - .locator("em-emoji-picker button") - .filter({ has: page.locator('img[src*="1.png"]') }) - .first(); - await expect(custom).toBeVisible(); - await page.screenshot({ - path: test.info().outputPath("reaction-picker.png"), - }); - await custom.click(); - await expect(search).toHaveCount(0); - await expect - .poll(() => - page.evaluate(() => window.emojiFixture.report.publications.length), - ) - .toBe(1); - const event = await page.evaluate( - () => window.emojiFixture.report.publications[0].event, - ); - expect(event.kind).toBe(7); - expect(event.content).toBe(":party:"); - expect(event.tags).toContainEqual([ - "emoji", - "party", - "https://a.test/media/1.png", - ]); - expect(event.tags.filter(([name]) => name === "e")).toHaveLength(1); - for (const length of [62, 63, 64]) { - await plus.click(); - const boundarySearch = page.locator( - 'em-emoji-picker input[type="search"]', - ); - const shortcode = "a".repeat(length); - await boundarySearch.fill(shortcode); - await page - .getByRole("button", { name: `:${shortcode}:`, exact: true }) - .click(); - } - await expect - .poll(() => - page.evaluate(() => window.emojiFixture.report.publications.length), - ) - .toBe(4); - await page.evaluate(() => window.emojiFixture.archive(true)); - await expect(plus).toHaveCount(0); - await page.evaluate(() => window.emojiFixture.archive(false)); - await expect(plus).toHaveCount(1); - const boundaryEvents = await page.evaluate(() => - window.emojiFixture.report.publications - .slice(1) - .map(({ event }) => event), - ); - expect(boundaryEvents.map(({ content }) => content)).toEqual( - [62, 63, 64].map((length) => `:${"a".repeat(length)}:`), - ); - expect(boundaryEvents.every(({ kind }) => kind === 7)).toBe(true); - await page.evaluate(() => window.emojiFixture.rejectReaction()); + // Only one fixture message has reactions; the others must have no action. + await expect(plus).toHaveCount(1); + await plus.click(); + const search = page.locator('em-emoji-picker input[type="search"]'); + await expect(search).toBeVisible(); + const padding = await search.evaluate((input) => { + const field = input.getBoundingClientRect(); + const picker = input + .getRootNode() + .querySelector("#root") + .getBoundingClientRect(); + return { + top: field.top - picker.top, + left: field.left - picker.left, + right: picker.right - field.right, + }; + }); + expect(padding.top).toBeCloseTo(padding.left, 1); + expect(padding.top).toBeCloseTo(padding.right, 1); + await expect(search).toHaveCSS("border-radius", "14px"); + await expect(page.getByRole("tab", { name: "GIF", exact: true })).toHaveCount( + 0, + ); + await search.press("Escape"); + await expect(search).toHaveCount(0); + await expect(plus).toBeFocused(); + await plus.click(); + await search.fill("party"); + const custom = page + .locator("em-emoji-picker button") + .filter({ has: page.locator('img[src*="1.png"]') }) + .first(); + await expect(custom).toBeVisible(); + await page.screenshot({ + path: test.info().outputPath("reaction-picker.png"), + }); + await custom.click(); + await expect(search).toHaveCount(0); + await expect + .poll(() => + page.evaluate(() => window.emojiFixture.report.publications.length), + ) + .toBe(1); + const event = await page.evaluate( + () => window.emojiFixture.report.publications[0].event, + ); + expect(event.kind).toBe(7); + expect(event.content).toBe(":party:"); + expect(event.tags).toContainEqual([ + "emoji", + "party", + "https://a.test/media/1.png", + ]); + expect(event.tags.filter(([name]) => name === "e")).toHaveLength(1); + for (const length of [62, 63, 64]) { await plus.click(); + const boundarySearch = page.locator('em-emoji-picker input[type="search"]'); + const shortcode = "a".repeat(length); + await boundarySearch.fill(shortcode); await page - .locator("em-emoji-picker button") - .filter({ has: page.locator('img[src*="1.png"]') }) - .first() + .getByRole("button", { name: `:${shortcode}:`, exact: true }) .click(); - const retry = page.getByRole("button", { name: "Retry reaction" }); - await expect(retry).toBeVisible(); - await page.evaluate(() => window.emojiFixture.remount()); - await expect(retry).toBeVisible(); - await retry.click(); - await expect(retry).toHaveCount(0); - await expect - .poll(() => - page.evaluate(() => window.emojiFixture.report.publications.length), - ) - .toBe(5); - expect(errors).toEqual([]); - } finally { - await server.close(); } + await expect + .poll(() => + page.evaluate(() => window.emojiFixture.report.publications.length), + ) + .toBe(4); + await page.evaluate(() => window.emojiFixture.archive(true)); + await expect(plus).toHaveCount(0); + await page.evaluate(() => window.emojiFixture.archive(false)); + await expect(plus).toHaveCount(1); + const boundaryEvents = await page.evaluate(() => + window.emojiFixture.report.publications.slice(1).map(({ event }) => event), + ); + expect(boundaryEvents.map(({ content }) => content)).toEqual( + [62, 63, 64].map((length) => `:${"a".repeat(length)}:`), + ); + expect(boundaryEvents.every(({ kind }) => kind === 7)).toBe(true); + await page.evaluate(() => window.emojiFixture.rejectReaction()); + await plus.click(); + await page + .locator("em-emoji-picker button") + .filter({ has: page.locator('img[src*="1.png"]') }) + .first() + .click(); + const retry = page.getByRole("button", { name: "Retry reaction" }); + await expect(retry).toBeVisible(); + await page.evaluate(() => window.emojiFixture.remount()); + await expect(retry).toBeVisible(); + await retry.click(); + await expect(retry).toHaveCount(0); + await expect + .poll(() => + page.evaluate(() => window.emojiFixture.report.publications.length), + ) + .toBe(5); + expect(errors).toEqual([]); }); diff --git a/tests/browser/terminal-renderer.spec.mjs b/tests/browser/terminal-renderer.spec.mjs index 3eadc2c9..6e9746b9 100644 --- a/tests/browser/terminal-renderer.spec.mjs +++ b/tests/browser/terminal-renderer.spec.mjs @@ -1,555 +1,503 @@ -import { test, expect } from "@playwright/test"; -import { createServer } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; +import { test, expect } from "./source-fixture.mjs"; test("real xterm retains output across detach, handles input and resize, and releases app shortcut", async ({ page, }) => { - const root = fileURLToPath(new URL("../../", import.meta.url)); - const server = await createServer({ - root, - configFile: false, - envFile: false, - plugins: [react()], - server: { host: "127.0.0.1", port: 0 }, - }); const errors = []; page.on("pageerror", (e) => errors.push(String(e))); - try { - await server.listen(); - await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/terminal.html`, - ); - const button = (name) => page.getByRole("button", { name, exact: true }); - const input = page.getByLabel("Input", { exact: true }); - const splash = page.locator("[data-terminal-splash]"); - await expect(splash).toHaveCount(0); // Wait for actual shell output. - await page.clock.pauseAt(new Date("2026-01-01T01:00:00Z")); - await button("Paint terminal").click(); - await page.clock.runFor(50); // Let xterm parse/paint, then hold the welcome open. - await expect(splash).toBeVisible(); - await expect(splash.locator('[data-layer="head"]')).not.toHaveCount(0); - await page.evaluate(() => document.fonts.ready); - const rightEdge = await splash.evaluate((element) => { - const xs = []; - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); - while (walker.nextNode()) { - const text = walker.currentNode; - for (let i = 0; i < text.length; i++) { - if (!"▜▐▟".includes(text.textContent[i])) continue; - const range = document.createRange(); - range.setStart(text, i); - range.setEnd(text, i + 1); - xs.push(range.getBoundingClientRect().x); - } + await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); + await page.goto("/tests/fixtures/terminal.html"); + const button = (name) => page.getByRole("button", { name, exact: true }); + const input = page.getByLabel("Input", { exact: true }); + const splash = page.locator("[data-terminal-splash]"); + await expect(splash).toHaveCount(0); // Wait for actual shell output. + await page.clock.pauseAt(new Date("2026-01-01T01:00:00Z")); + await button("Paint terminal").click(); + await page.clock.runFor(50); // Let xterm parse/paint, then hold the welcome open. + await expect(splash).toBeVisible(); + await expect(splash.locator('[data-layer="head"]')).not.toHaveCount(0); + await page.evaluate(() => document.fonts.ready); + const rightEdge = await splash.evaluate((element) => { + const xs = []; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const text = walker.currentNode; + for (let i = 0; i < text.length; i++) { + if (!"▜▐▟".includes(text.textContent[i])) continue; + const range = document.createRange(); + range.setStart(text, i); + range.setEnd(text, i + 1); + xs.push(range.getBoundingClientRect().x); } - return xs; - }); - expect(rightEdge.length).toBeGreaterThan(5); - expect(Math.max(...rightEdge) - Math.min(...rightEdge)).toBeLessThan(1); + } + return xs; + }); + expect(rightEdge.length).toBeGreaterThan(5); + expect(Math.max(...rightEdge) - Math.min(...rightEdge)).toBeLessThan(1); - const colors = await splash - .locator('[data-layer="head"]') - .evaluateAll((nodes) => - nodes.map((node) => getComputedStyle(node).color), + const colors = await splash + .locator('[data-layer="head"]') + .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).color)); + expect(new Set(colors).size).toBeGreaterThan(8); + await expect(splash).toHaveCSS("--splash-lightness", "72%"); + await expect(splash).toHaveCSS("--splash-chroma", "0.12"); + await page.screenshot({ + path: test.info().outputPath("buzzterm-light.png"), + }); + const xterm = page.locator(".xterm-viewport"); + const assertAnsiContrast = async () => { + for (const text of [ + "ANSI_WHITE_ON_BLACK", + "ANSI_WHITE_ON_GRAY", + "ANSI_WHITE_ON_DEFAULT", + "ANSI_BLACK_ON_DEFAULT", + ]) { + const ratio = await page + .getByText(text, { exact: true }) + .evaluate((el) => { + const css = getComputedStyle(el); + const luminance = (color) => { + const rgb = color + .match(/[\d.]+/g) + .slice(0, 3) + .map(Number) + .map((v) => { + const s = v / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722; + }; + const fg = luminance(css.color); + const background = + css.backgroundColor === "rgba(0, 0, 0, 0)" + ? getComputedStyle(document.querySelector(".xterm-viewport")) + .backgroundColor + : css.backgroundColor; + const bg = luminance(background); + return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); + }); + expect(ratio, `${text} foreground/background contrast`).toBeGreaterThan( + 4.5, ); - expect(new Set(colors).size).toBeGreaterThan(8); - await expect(splash).toHaveCSS("--splash-lightness", "72%"); - await expect(splash).toHaveCSS("--splash-chroma", "0.12"); - await page.screenshot({ - path: test.info().outputPath("buzzterm-light.png"), - }); - const xterm = page.locator(".xterm-viewport"); - const assertAnsiContrast = async () => { - for (const text of [ - "ANSI_WHITE_ON_BLACK", - "ANSI_WHITE_ON_GRAY", - "ANSI_WHITE_ON_DEFAULT", - "ANSI_BLACK_ON_DEFAULT", - ]) { - const ratio = await page - .getByText(text, { exact: true }) - .evaluate((el) => { - const css = getComputedStyle(el); - const luminance = (color) => { - const rgb = color - .match(/[\d.]+/g) - .slice(0, 3) - .map(Number) - .map((v) => { - const s = v / 255; - return s <= 0.04045 - ? s / 12.92 - : ((s + 0.055) / 1.055) ** 2.4; - }); - return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722; - }; - const fg = luminance(css.color); - const background = - css.backgroundColor === "rgba(0, 0, 0, 0)" - ? getComputedStyle(document.querySelector(".xterm-viewport")) - .backgroundColor - : css.backgroundColor; - const bg = luminance(background); - return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); - }); - expect(ratio, `${text} foreground/background contrast`).toBeGreaterThan( - 4.5, - ); - } - }; - await assertAnsiContrast(); - const assertSystemAppearance = async () => { - const actual = await xterm.evaluate((viewport) => { - const element = viewport.closest("[data-buzz-ui]"); - const css = getComputedStyle(element); - const probe = document.createElement("span"); - probe.style.backgroundColor = "var(--bg-panel)"; - element.append(probe); - const background = getComputedStyle(probe).backgroundColor; - probe.remove(); - return { - background: getComputedStyle(viewport).backgroundColor, - expectedBackground: background, - font: css.fontFamily, - size: css.fontSize, - renderedSize: getComputedStyle(document.querySelector(".xterm-rows")) - .fontSize, - }; - }); - expect(actual.background).toBe(actual.expectedBackground); - expect(actual.font).toContain("JetBrains Mono"); - expect(actual.renderedSize).toBe(actual.size); - return Number.parseFloat(actual.size); - }; - expect(await assertSystemAppearance()).toBe(13); - const lightBackground = await xterm.evaluate( - (el) => getComputedStyle(el).backgroundColor, - ); - await page.evaluate(() => { - window.retainedTerminal = document.querySelector(".xterm"); - }); - await button("Toggle theme").click(); - await page.clock.runFor(50); // Flush xterm's theme repaint under the held clock. - await expect - .poll(() => xterm.evaluate((el) => getComputedStyle(el).backgroundColor)) - .not.toBe(lightBackground); - await expect(splash).toHaveCSS("--splash-lightness", "80%"); - await expect(splash).toHaveCSS("--splash-chroma", "0.16"); - await assertAnsiContrast(); - expect(await assertSystemAppearance()).toBe(13); - await page.screenshot({ - path: test.info().outputPath("buzzterm-dark.png"), + } + }; + await assertAnsiContrast(); + const assertSystemAppearance = async () => { + const actual = await xterm.evaluate((viewport) => { + const element = viewport.closest("[data-buzz-ui]"); + const css = getComputedStyle(element); + const probe = document.createElement("span"); + probe.style.backgroundColor = "var(--bg-panel)"; + element.append(probe); + const background = getComputedStyle(probe).backgroundColor; + probe.remove(); + return { + background: getComputedStyle(viewport).backgroundColor, + expectedBackground: background, + font: css.fontFamily, + size: css.fontSize, + renderedSize: getComputedStyle(document.querySelector(".xterm-rows")) + .fontSize, + }; }); - expect( - await page.evaluate( - () => window.retainedTerminal === document.querySelector(".xterm"), - ), - ).toBe(true); + expect(actual.background).toBe(actual.expectedBackground); + expect(actual.font).toContain("JetBrains Mono"); + expect(actual.renderedSize).toBe(actual.size); + return Number.parseFloat(actual.size); + }; + expect(await assertSystemAppearance()).toBe(13); + const lightBackground = await xterm.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ); + await page.evaluate(() => { + window.retainedTerminal = document.querySelector(".xterm"); + }); + await button("Toggle theme").click(); + await page.clock.runFor(50); // Flush xterm's theme repaint under the held clock. + await expect + .poll(() => xterm.evaluate((el) => getComputedStyle(el).backgroundColor)) + .not.toBe(lightBackground); + await expect(splash).toHaveCSS("--splash-lightness", "80%"); + await expect(splash).toHaveCSS("--splash-chroma", "0.16"); + await assertAnsiContrast(); + expect(await assertSystemAppearance()).toBe(13); + await page.screenshot({ + path: test.info().outputPath("buzzterm-dark.png"), + }); + expect( + await page.evaluate( + () => window.retainedTerminal === document.querySelector(".xterm"), + ), + ).toBe(true); - await expect(page.locator(".xterm-rows")).toContainText( - "BUZZ_RENDERER_READY", - ); - await page.locator(".xterm-helper-textarea").focus(); - await page.keyboard.type("hello"); - await expect(splash).toHaveCount(0); - await page.clock.resume(); + await expect(page.locator(".xterm-rows")).toContainText( + "BUZZ_RENDERER_READY", + ); + await page.locator(".xterm-helper-textarea").focus(); + await page.keyboard.type("hello"); + await expect(splash).toHaveCount(0); + await page.clock.resume(); - await page.keyboard.press("Control+c"); - await expect(input).toHaveText('"hello\\u0003"'); - await button("Alternate screen").click(); - await expect(page.locator(".xterm-rows")).toContainText( - "BUZZ_RENDERER_READY", - ); - const fontSize = await page - .locator(".xterm-rows") - .evaluate((el) => getComputedStyle(el).fontSize); - await button("Enlarge text").click(); - await expect - .poll(() => - page - .locator(".xterm-rows") - .evaluate((el) => getComputedStyle(el).fontSize), - ) - .not.toBe(fontSize); - expect(await assertSystemAppearance()).toBe(19.5); - const dimensions = await page.getByLabel("Dimensions").textContent(); - await page.setViewportSize({ width: 800, height: 600 }); - await expect(page.getByLabel("Dimensions")).not.toHaveText(dimensions); - await button("Toggle mount").click(); - await expect(page.locator(".xterm")).toHaveCount(0); - await button("Paint terminal").click(); // Parse output while detached. - await button("Toggle theme").click(); // Reopen must pick up hidden appearance changes. - await button("Toggle mount").click(); - await expect(page.locator(".xterm-rows")).toContainText( - "BUZZ_RENDERER_READY", - ); - await expect(splash).toHaveCount(0); - await expect - .poll(() => xterm.evaluate((el) => getComputedStyle(el).backgroundColor)) - .toBe(lightBackground); - await page.locator(".xterm-helper-textarea").focus(); - const before = await input.textContent(); - const modifier = (await page.evaluate(() => - /Mac|iPhone|iPad/.test(navigator.platform), - )) - ? "Meta" - : "Control"; - await page.evaluate(() => { - window.terminalChord = false; - window.addEventListener("keydown", (e) => { - if (e.key === "j") window.terminalChord = !e.defaultPrevented; - }); + await page.keyboard.press("Control+c"); + await expect(input).toHaveText('"hello\\u0003"'); + await button("Alternate screen").click(); + await expect(page.locator(".xterm-rows")).toContainText( + "BUZZ_RENDERER_READY", + ); + const fontSize = await page + .locator(".xterm-rows") + .evaluate((el) => getComputedStyle(el).fontSize); + await button("Enlarge text").click(); + await expect + .poll(() => + page + .locator(".xterm-rows") + .evaluate((el) => getComputedStyle(el).fontSize), + ) + .not.toBe(fontSize); + expect(await assertSystemAppearance()).toBe(19.5); + const dimensions = await page.getByLabel("Dimensions").textContent(); + await page.setViewportSize({ width: 800, height: 600 }); + await expect(page.getByLabel("Dimensions")).not.toHaveText(dimensions); + await button("Toggle mount").click(); + await expect(page.locator(".xterm")).toHaveCount(0); + await button("Paint terminal").click(); // Parse output while detached. + await button("Toggle theme").click(); // Reopen must pick up hidden appearance changes. + await button("Toggle mount").click(); + await expect(page.locator(".xterm-rows")).toContainText( + "BUZZ_RENDERER_READY", + ); + await expect(splash).toHaveCount(0); + await expect + .poll(() => xterm.evaluate((el) => getComputedStyle(el).backgroundColor)) + .toBe(lightBackground); + await page.locator(".xterm-helper-textarea").focus(); + const before = await input.textContent(); + const modifier = (await page.evaluate(() => + /Mac|iPhone|iPad/.test(navigator.platform), + )) + ? "Meta" + : "Control"; + await page.evaluate(() => { + window.terminalChord = false; + window.addEventListener("keydown", (e) => { + if (e.key === "j") window.terminalChord = !e.defaultPrevented; }); - await page.keyboard.press(`${modifier}+j`); - expect(await page.evaluate(() => window.terminalChord)).toBe(true); - await expect(input).toHaveText(before); - // Fresh dark startup, bounded static splash (including reduced motion). - await page.emulateMedia({ reducedMotion: "reduce" }); - await page.reload(); - await page.clock.pauseAt(new Date("2026-01-01T02:00:00Z")); - await button("Toggle theme").click(); - await button("Paint terminal").click(); - await page.clock.runFor(50); - await expect(splash).toBeVisible(); - await expect(splash).toHaveCSS("animation-name", "none"); - await page.clock.runFor(2000); - await expect(splash).toBeVisible(); - await page.clock.runFor(1000); - await expect(splash).toHaveCount(0); // 3s, not the old 4.5s. - await page.clock.resume(); - await expect(page.locator(".xterm-rows")).toContainText( - "BUZZ_RENDERER_READY", - ); - // Small terminals retain readable branding instead of clipped block art. - await page.setViewportSize({ width: 400, height: 600 }); - await page.reload(); - await page.clock.pauseAt(new Date("2026-01-01T03:00:00Z")); - await button("Paint terminal").click(); - await page.clock.runFor(50); - await expect(splash).toHaveText("buzz term"); - await button("Toggle mount").click(); - await button("Toggle theme").click(); - await button("Toggle mount").click(); - await expect(splash).toHaveCount(0); - await page.clock.resume(); - expect(errors).toEqual([]); - } finally { - await server.close(); - } + }); + await page.keyboard.press(`${modifier}+j`); + expect(await page.evaluate(() => window.terminalChord)).toBe(true); + await expect(input).toHaveText(before); + // Fresh dark startup, bounded static splash (including reduced motion). + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.reload(); + await page.clock.pauseAt(new Date("2026-01-01T02:00:00Z")); + await button("Toggle theme").click(); + await button("Paint terminal").click(); + await page.clock.runFor(50); + await expect(splash).toBeVisible(); + await expect(splash).toHaveCSS("animation-name", "none"); + await page.clock.runFor(2000); + await expect(splash).toBeVisible(); + await page.clock.runFor(1000); + await expect(splash).toHaveCount(0); // 3s, not the old 4.5s. + await page.clock.resume(); + await expect(page.locator(".xterm-rows")).toContainText( + "BUZZ_RENDERER_READY", + ); + // Small terminals retain readable branding instead of clipped block art. + await page.setViewportSize({ width: 400, height: 600 }); + await page.reload(); + await page.clock.pauseAt(new Date("2026-01-01T03:00:00Z")); + await button("Paint terminal").click(); + await page.clock.runFor(50); + await expect(splash).toHaveText("buzz term"); + await button("Toggle mount").click(); + await button("Toggle theme").click(); + await button("Toggle mount").click(); + await expect(splash).toHaveCount(0); + await page.clock.resume(); + expect(errors).toEqual([]); }); test("terminal shared controls keep focus, recovery and layout in both modes", async ({ page, }) => { - const root = fileURLToPath(new URL("../../", import.meta.url)); - const server = await createServer({ - root, - configFile: false, - envDir: false, - plugins: [react()], - server: { host: "127.0.0.1", port: 0 }, - }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - try { - await server.listen(); - await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/terminal-panel.html`, - ); - const button = (name) => page.getByRole("button", { name, exact: true }); - const launcher = button("Toggle channel terminal"); - await expect(launcher).toHaveAttribute("data-buzz-ui", ""); - await expect(launcher).toHaveCSS("padding-left", "0px"); - await expect(launcher).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); - await launcher.hover(); - await expect(launcher).toHaveCSS("background-color", "rgb(218, 218, 218)"); - await launcher.click(); - const drawer = page.getByRole("region", { name: "Terminal drawer" }); - await expect(drawer.locator(".xterm-rows")).toContainText( - "FIXTURE_SHELL_READY", + await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); + await page.goto("/tests/fixtures/terminal-panel.html"); + const button = (name) => page.getByRole("button", { name, exact: true }); + const launcher = button("Toggle channel terminal"); + await expect(launcher).toHaveAttribute("data-buzz-ui", ""); + await expect(launcher).toHaveCSS("padding-left", "0px"); + await expect(launcher).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await launcher.hover(); + await expect(launcher).toHaveCSS("background-color", "rgb(218, 218, 218)"); + await launcher.click(); + const drawer = page.getByRole("region", { name: "Terminal drawer" }); + await expect(drawer.locator(".xterm-rows")).toContainText( + "FIXTURE_SHELL_READY", + ); + await page.evaluate(() => { + window.retainedTerminal = document.querySelector(".xterm"); + }); + await expect(launcher).toHaveAttribute("aria-pressed", "true"); + await expect(launcher).toHaveAttribute("data-icon-variant", "tint"); + await page.mouse.move(0, 0); + const expectToken = async (node, property, token) => { + const value = await node.evaluate( + (el, { property, token }) => { + const probe = document.createElement("span"); + probe.style.setProperty(property, `var(${token})`); + el.append(probe); + const value = getComputedStyle(probe).getPropertyValue(property); + probe.remove(); + return value; + }, + { property, token }, ); - await page.evaluate(() => { - window.retainedTerminal = document.querySelector(".xterm"); - }); - await expect(launcher).toHaveAttribute("aria-pressed", "true"); - await expect(launcher).toHaveAttribute("data-icon-variant", "tint"); - await page.mouse.move(0, 0); - const expectToken = async (node, property, token) => { - const value = await node.evaluate( - (el, { property, token }) => { - const probe = document.createElement("span"); - probe.style.setProperty(property, `var(${token})`); - el.append(probe); - const value = getComputedStyle(probe).getPropertyValue(property); - probe.remove(); - return value; - }, - { property, token }, + await expect(node).toHaveCSS(property, value); + }; + await expectToken(launcher, "color", "--purple-12"); + await expectToken(launcher, "background-color", "--purple-3"); + const restart = button("Restart"); + await expect(restart).toHaveClass("buzz-button"); + await expect(restart).toHaveCSS("border-top-width", "0px"); + const hide = button("Hide terminal"); + // Pointer focus is quiet; keyboard navigation paints the actual control. + await button("Enlarge text").click(); + await expect(restart).toHaveCSS("font-size", "24px"); + // Keep the operation pending while its disabled color finishes animating. + // A short fixture delay can expire between Playwright's assertion samples. + await page.evaluate(() => window.terminalPanel.holdClose()); + try { + await restart.click(); + await expect + .poll(() => page.evaluate(() => window.terminalPanel.closePending())) + .toBe(true); + await expect(restart).toBeDisabled(); + await expectToken(restart, "color", "--text-disabled"); + await expect(restart).toHaveCSS("outline-style", "none"); + } finally { + await page.evaluate(() => window.terminalPanel.releaseClose()); + } + await expect(restart).toBeEnabled(); + await expect(drawer.locator(".xterm-rows")).toContainText( + "FIXTURE_SHELL_READY", + ); + await button("End session").focus(); + await page.keyboard.press("Tab"); + await expect(hide).toBeFocused(); + await expect(hide).toHaveCSS("outline-style", "solid"); + await expect(hide).toHaveCSS("outline-width", "2px"); + for (const mode of ["light", "dark"]) { + if (mode === "dark") await button("Toggle theme").click(); + for (const width of [1280, 800, 390]) { + await page.setViewportSize({ width, height: 844 }); + await expect(hide).toBeInViewport(); + await expectToken( + drawer.locator("[data-terminal-version]"), + "background-color", + "--bg-panel", ); - await expect(node).toHaveCSS(property, value); - }; - await expectToken(launcher, "color", "--purple-12"); - await expectToken(launcher, "background-color", "--purple-3"); - const restart = button("Restart"); - await expect(restart).toHaveClass("buzz-button"); - await expect(restart).toHaveCSS("border-top-width", "0px"); - const hide = button("Hide terminal"); - // Pointer focus is quiet; keyboard navigation paints the actual control. - await button("Enlarge text").click(); - await expect(restart).toHaveCSS("font-size", "24px"); - // Keep the operation pending while its disabled color finishes animating. - // A short fixture delay can expire between Playwright's assertion samples. - await page.evaluate(() => window.terminalPanel.holdClose()); - try { - await restart.click(); - await expect - .poll(() => page.evaluate(() => window.terminalPanel.closePending())) - .toBe(true); - await expect(restart).toBeDisabled(); - await expectToken(restart, "color", "--text-disabled"); - await expect(restart).toHaveCSS("outline-style", "none"); - } finally { - await page.evaluate(() => window.terminalPanel.releaseClose()); - } - await expect(restart).toBeEnabled(); - await expect(drawer.locator(".xterm-rows")).toContainText( - "FIXTURE_SHELL_READY", - ); - await button("End session").focus(); - await page.keyboard.press("Tab"); - await expect(hide).toBeFocused(); - await expect(hide).toHaveCSS("outline-style", "solid"); - await expect(hide).toHaveCSS("outline-width", "2px"); - for (const mode of ["light", "dark"]) { - if (mode === "dark") await button("Toggle theme").click(); - for (const width of [1280, 800, 390]) { - await page.setViewportSize({ width, height: 844 }); - await expect(hide).toBeInViewport(); - await expectToken( - drawer.locator("[data-terminal-version]"), - "background-color", - "--bg-panel", - ); - if (width === 1280) { - const splash = drawer.locator("[data-terminal-splash]"); - await page.evaluate(() => window.terminalPanel.holdClose()); - try { - await restart.click(); - await expect - .poll(() => - page.evaluate(() => window.terminalPanel.closePending()), - ) - .toBe(true); - await page.clock.pauseAt( - new Date(`2026-01-01T0${mode === "light" ? 1 : 2}:00:00Z`), - ); - } finally { - await page.evaluate(() => window.terminalPanel.releaseClose()); - } - await page.clock.runFor(50); - await expect(splash.locator('[data-layer="head"]')).toHaveCount(151); - const bounds = await splash.evaluate((el) => { - const frame = el.getBoundingClientRect(); - const art = el.querySelector("pre").getBoundingClientRect(); - return ( - art.top >= frame.top - 1 && - art.bottom <= frame.bottom + 1 && - art.left >= frame.left - 1 && - art.right <= frame.right + 1 - ); - }); - expect(bounds).toBe(true); - await page.clock.resume(); + if (width === 1280) { + const splash = drawer.locator("[data-terminal-splash]"); + await page.evaluate(() => window.terminalPanel.holdClose()); + try { + await restart.click(); + await expect + .poll(() => + page.evaluate(() => window.terminalPanel.closePending()), + ) + .toBe(true); + await page.clock.pauseAt( + new Date(`2026-01-01T0${mode === "light" ? 1 : 2}:00:00Z`), + ); + } finally { + await page.evaluate(() => window.terminalPanel.releaseClose()); } - await expect(restart).toBeInViewport(); - expect( - await drawer.evaluate((el) => el.scrollWidth), - ).toBeLessThanOrEqual(width); - const header = drawer.locator(".panel-header"); - expect( - await header.evaluate((el) => el.scrollWidth), - ).toBeLessThanOrEqual(width); - await page.screenshot({ - path: test.info().outputPath(`terminal-panel-${mode}-${width}.png`), + await page.clock.runFor(50); + await expect(splash.locator('[data-layer="head"]')).toHaveCount(151); + const bounds = await splash.evaluate((el) => { + const frame = el.getBoundingClientRect(); + const art = el.querySelector("pre").getBoundingClientRect(); + return ( + art.top >= frame.top - 1 && + art.bottom <= frame.bottom + 1 && + art.left >= frame.left - 1 && + art.right <= frame.right + 1 + ); }); + expect(bounds).toBe(true); + await page.clock.resume(); } + await expect(restart).toBeInViewport(); + expect(await drawer.evaluate((el) => el.scrollWidth)).toBeLessThanOrEqual( + width, + ); + const header = drawer.locator(".panel-header"); + expect(await header.evaluate((el) => el.scrollWidth)).toBeLessThanOrEqual( + width, + ); + await page.screenshot({ + path: test.info().outputPath(`terminal-panel-${mode}-${width}.png`), + }); } - await page.evaluate(() => { - window.retainedTerminal = document.querySelector(".xterm"); - }); - await hide.click(); - await expect(drawer).toHaveCount(0); - await expect(launcher).toHaveAttribute("aria-pressed", "false"); - await launcher.click(); - expect( - await page.evaluate( - () => window.retainedTerminal === document.querySelector(".xterm"), - ), - ).toBe(true); - await button("Toggle close failure").click(); - await button("End session").click(); - await expect(page.getByRole("alert")).toContainText("Fixture close failed"); - await button("Toggle close failure").click(); - await button("End session").focus(); - await page.keyboard.press("Enter"); - await expect(button("Start session")).toBeVisible(); - await expect(hide).toBeFocused(); - await button("Start session").click(); - await expect(drawer.locator(".xterm-rows")).toContainText( - "FIXTURE_SHELL_READY", - ); - await page.evaluate(() => window.terminalPanel.holdClose()); - await button("End session").focus(); - await page.keyboard.press("Enter"); - await expect - .poll(() => page.evaluate(() => window.terminalPanel.closePending())) - .toBe(true); - await button("Toggle theme").focus(); // The user leaves while close is pending. - await page.evaluate(() => window.terminalPanel.releaseClose()); - await expect(button("Start session")).toBeVisible(); - await expect(button("Toggle theme")).toBeFocused(); - await button("Start session").click(); - await expect(drawer.locator(".xterm-rows")).toContainText( - "FIXTURE_SHELL_READY", - ); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/terminal-panel.html?short`, - ); - await page.evaluate(() => - document.documentElement.style.setProperty("--buzz-text-scale", "2"), - ); - await launcher.click(); - await expect(drawer.locator(".xterm-rows")).toContainText("FIXTURE"); - await expect(hide).toBeInViewport(); - await expect(restart).toBeInViewport(); - await expect(button("End session")).toBeInViewport(); - expect( - await drawer.locator(".xterm-viewport").evaluate((el) => el.clientHeight), - ).toBeGreaterThanOrEqual(78); // two 26px mono lines at 1.5 leading - expect( - await drawer.locator(".panel-header").evaluate((el) => el.scrollWidth), - ).toBeLessThanOrEqual(390); - await page.screenshot({ - path: test.info().outputPath("terminal-short-200.png"), - }); - expect(errors).toEqual([]); - } finally { - await server.close(); } + await page.evaluate(() => { + window.retainedTerminal = document.querySelector(".xterm"); + }); + await hide.click(); + await expect(drawer).toHaveCount(0); + await expect(launcher).toHaveAttribute("aria-pressed", "false"); + await launcher.click(); + expect( + await page.evaluate( + () => window.retainedTerminal === document.querySelector(".xterm"), + ), + ).toBe(true); + await button("Toggle close failure").click(); + await button("End session").click(); + await expect(page.getByRole("alert")).toContainText("Fixture close failed"); + await button("Toggle close failure").click(); + await button("End session").focus(); + await page.keyboard.press("Enter"); + await expect(button("Start session")).toBeVisible(); + await expect(hide).toBeFocused(); + await button("Start session").click(); + await expect(drawer.locator(".xterm-rows")).toContainText( + "FIXTURE_SHELL_READY", + ); + await page.evaluate(() => window.terminalPanel.holdClose()); + await button("End session").focus(); + await page.keyboard.press("Enter"); + await expect + .poll(() => page.evaluate(() => window.terminalPanel.closePending())) + .toBe(true); + await button("Toggle theme").focus(); // The user leaves while close is pending. + await page.evaluate(() => window.terminalPanel.releaseClose()); + await expect(button("Start session")).toBeVisible(); + await expect(button("Toggle theme")).toBeFocused(); + await button("Start session").click(); + await expect(drawer.locator(".xterm-rows")).toContainText( + "FIXTURE_SHELL_READY", + ); + await page.goto("/tests/fixtures/terminal-panel.html?short"); + await page.evaluate(() => + document.documentElement.style.setProperty("--buzz-text-scale", "2"), + ); + await launcher.click(); + await expect(drawer.locator(".xterm-rows")).toContainText("FIXTURE"); + await expect(hide).toBeInViewport(); + await expect(restart).toBeInViewport(); + await expect(button("End session")).toBeInViewport(); + expect( + await drawer.locator(".xterm-viewport").evaluate((el) => el.clientHeight), + ).toBeGreaterThanOrEqual(78); // two 26px mono lines at 1.5 leading + expect( + await drawer.locator(".panel-header").evaluate((el) => el.scrollWidth), + ).toBeLessThanOrEqual(390); + await page.screenshot({ + path: test.info().outputPath("terminal-short-200.png"), + }); + expect(errors).toEqual([]); }); test("real xterm replies survive scope switches while stale input and retired writes are fenced", async ({ page, }) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envDir: false, - plugins: [react()], - server: { host: "127.0.0.1", port: 0 }, - }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - try { - await server.listen(); - const url = `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/terminal-session.html`; - const open = async () => { - await page.goto(url); - await expect - .poll(() => page.evaluate(() => window.terminalSession?.ready())) - .toBe(true); - await page.evaluate(() => window.terminalSession.mount()); - }; - const generated = () => - page.evaluate(() => window.terminalSession.generated); - const writes = () => page.evaluate(() => window.terminalSession.writes); - const reply = (data) => ({ owner: "retained-owner", id: "pty-1", data }); - const output = async (data) => { - await expect - .poll(() => page.evaluate(() => window.terminalSession.reading())) - .toBe(true); - await page.evaluate((data) => window.terminalSession.output(data), data); - }; - await open(); - await page.evaluate(() => window.terminalSession.holdWrites()); - await page.keyboard.type("a"); - await expect.poll(writes).toEqual([reply("a")]); - await page.keyboard.type("b"); // Queued user input must be rechecked at dispatch. - await output("\x1b[6n"); + const url = "/tests/fixtures/terminal-session.html"; + const open = async () => { + await page.goto(url); await expect - .poll(generated) - .toContainEqual({ data: "\x1b[1;1R", source: "reply" }); - await page.evaluate(() => { - window.terminalSession.switchScope(); - window.terminalSession.releaseWrites(); - }); - await expect.poll(writes).toEqual([reply("a"), reply("\x1b[1;1R")]); - // The old view can still receive events before React unmounts it. - await page.keyboard.type("x"); - await page.evaluate(() => window.terminalSession.staleInput()); - await expect - .poll(generated) - .toContainEqual({ data: "STALE_PASTE", source: "user" }); - await page.evaluate(() => window.terminalSession.detach()); - await output("\x1b[5n\x1b[6n"); + .poll(() => page.evaluate(() => window.terminalSession?.ready())) + .toBe(true); + await page.evaluate(() => window.terminalSession.mount()); + }; + const generated = () => page.evaluate(() => window.terminalSession.generated); + const writes = () => page.evaluate(() => window.terminalSession.writes); + const reply = (data) => ({ owner: "retained-owner", id: "pty-1", data }); + const output = async (data) => { await expect - .poll(writes) - .toEqual([ - reply("a"), - reply("\x1b[1;1R"), - reply("\x1b[0n"), - reply("\x1b[1;1R"), - ]); - const before = await generated(); - await page.evaluate(() => window.terminalSession.staleInput()); - expect(await generated()).toEqual(before); // Detached keyboard/paste never enters the session. - await page.evaluate(() => window.terminalSession.dispose()); + .poll(() => page.evaluate(() => window.terminalSession.reading())) + .toBe(true); + await page.evaluate((data) => window.terminalSession.output(data), data); + }; + await open(); + await page.evaluate(() => window.terminalSession.holdWrites()); + await page.keyboard.type("a"); + await expect.poll(writes).toEqual([reply("a")]); + await page.keyboard.type("b"); // Queued user input must be rechecked at dispatch. + await output("\x1b[6n"); + await expect + .poll(generated) + .toContainEqual({ data: "\x1b[1;1R", source: "reply" }); + await page.evaluate(() => { + window.terminalSession.switchScope(); + window.terminalSession.releaseWrites(); + }); + await expect.poll(writes).toEqual([reply("a"), reply("\x1b[1;1R")]); + // The old view can still receive events before React unmounts it. + await page.keyboard.type("x"); + await page.evaluate(() => window.terminalSession.staleInput()); + await expect + .poll(generated) + .toContainEqual({ data: "STALE_PASTE", source: "user" }); + await page.evaluate(() => window.terminalSession.detach()); + await output("\x1b[5n\x1b[6n"); + await expect + .poll(writes) + .toEqual([ + reply("a"), + reply("\x1b[1;1R"), + reply("\x1b[0n"), + reply("\x1b[1;1R"), + ]); + const before = await generated(); + await page.evaluate(() => window.terminalSession.staleInput()); + expect(await generated()).toEqual(before); // Detached keyboard/paste never enters the session. + await page.evaluate(() => window.terminalSession.dispose()); + + // Receipt and dispatch are distinct fences: returning to A must not revive + // input received in B. A real parser reply behind it proves the queue drained. + await open(); + await page.evaluate(() => window.terminalSession.holdWrites()); + await page.keyboard.type("a"); + await expect.poll(writes).toEqual([reply("a")]); + await page.evaluate(() => window.terminalSession.switchScope()); + await page.keyboard.type("x"); + await page.evaluate(() => window.terminalSession.staleInput()); + await expect + .poll(generated) + .toContainEqual({ data: "STALE_PASTE", source: "user" }); + await page.evaluate(() => window.terminalSession.restoreScope()); + await output("\x1b[5n"); + await expect + .poll(generated) + .toContainEqual({ data: "\x1b[0n", source: "reply" }); + await page.evaluate(() => window.terminalSession.releaseWrites()); + await expect.poll(writes).toEqual([reply("a"), reply("\x1b[0n")]); + await page.evaluate(() => window.terminalSession.dispose()); - // Receipt and dispatch are distinct fences: returning to A must not revive - // input received in B. A real parser reply behind it proves the queue drained. + for (const operation of ["end", "dispose"]) { await open(); await page.evaluate(() => window.terminalSession.holdWrites()); await page.keyboard.type("a"); await expect.poll(writes).toEqual([reply("a")]); - await page.evaluate(() => window.terminalSession.switchScope()); - await page.keyboard.type("x"); - await page.evaluate(() => window.terminalSession.staleInput()); - await expect - .poll(generated) - .toContainEqual({ data: "STALE_PASTE", source: "user" }); - await page.evaluate(() => window.terminalSession.restoreScope()); - await output("\x1b[5n"); + await output("\x1b[6n"); await expect .poll(generated) - .toContainEqual({ data: "\x1b[0n", source: "reply" }); - await page.evaluate(() => window.terminalSession.releaseWrites()); - await expect.poll(writes).toEqual([reply("a"), reply("\x1b[0n")]); - await page.evaluate(() => window.terminalSession.dispose()); - - for (const operation of ["end", "dispose"]) { - await open(); - await page.evaluate(() => window.terminalSession.holdWrites()); - await page.keyboard.type("a"); - await expect.poll(writes).toEqual([reply("a")]); - await output("\x1b[6n"); - await expect - .poll(generated) - .toContainEqual({ data: "\x1b[1;1R", source: "reply" }); - await page.evaluate(async (operation) => { - const ending = window.terminalSession[operation](); - window.terminalSession.releaseWrites(); - await ending; - }, operation); - expect(await writes()).toEqual([reply("a")]); - if (operation === "end") - await page.evaluate(() => window.terminalSession.dispose()); - } - expect(errors).toEqual([]); - } finally { - await server.close(); + .toContainEqual({ data: "\x1b[1;1R", source: "reply" }); + await page.evaluate(async (operation) => { + const ending = window.terminalSession[operation](); + window.terminalSession.releaseWrites(); + await ending; + }, operation); + expect(await writes()).toEqual([reply("a")]); + if (operation === "end") + await page.evaluate(() => window.terminalSession.dispose()); } + expect(errors).toEqual([]); }); diff --git a/tests/integration/vite-fixture.test.mjs b/tests/integration/vite-fixture.test.mjs index 0dac1ce0..6db5dd82 100644 --- a/tests/integration/vite-fixture.test.mjs +++ b/tests/integration/vite-fixture.test.mjs @@ -64,7 +64,7 @@ test("browser fixture call sites cannot fall back to Vite's shared default cache const source = await readFile(join(browser, name), "utf8"); if (!source.includes('import { createServer } from "vite"')) continue; // These existing tests own a temporary directory through their full lifecycle. - assert.ok(["emoji.spec.mjs", "conversation.spec.mjs"].includes(name), name); + assert.equal(name, "conversation.spec.mjs"); assert.match( source, /cacheDir[,:]/,