/** * TASK-0093 — 3-client presence + live cursors E2E (the presence half of the * Phase-7 exit criterion + the cursor + a11y suite). * * ── RUNNER GATE (Docker) — NOT RUNNABLE LOCALLY ──────────────────────── * This spec needs the full stack (Postgres + Redis + API :4000 + * realtime :4001 + web :3000, the seeded demo user) — a Docker daemon, * which is unavailable in this environment (permission denied; recorded, * never faked). It is CI-ready under the same contract as every other spec * in this package (`pnpm docker:up && pnpm dev`, then the suite). Committed * so the TASK-0093 acceptance mechanic is pinned for CI; it is not a * local-green test here. * * ── WHY THREE DISTINCT USERS ─────────────────────────────────────────── * Presence is keyed by userId server-side (the server stamps `user` from the * handshake token + the display-name resolver) and a tab never renders its * OWN peer (the bar shows OTHERS). Three tabs of the SAME user collapse to * one presence entry and are self-excluded → 0 avatars. To assert the DoD's * "3 contexts on one page → all three see the other two's avatars", this * spec uses THREE DISTINCT USERS in one workspace (the seeded demo owner + * two invited members — the exact production scenario). * * ── ROUTE + SELECTORS (verified against the app source) ──────────────── * Page route: `/w/:workspace/page/:id` (the [workspace] segment accepts id * or slug; the sidebar links `/w/${slug}/page/${id}`). There is NO `/p/` * route and no middleware rewrite — the older specs' `/w/${id}/p/${id}` URL * would 404 (a pre-existing latent bug in specs that were committed * CI-ready but never run locally; recorded, not repeated here). Blocks are * rows carrying `data-block-id={id}`; the cursor overlay is the * `div[aria-hidden]` inside the block list; the a11y live region is * `role="status"`. * * ── THE < 500 ms BUDGET (honest) ─────────────────────────────────────── * The DoD metric is "the other two's avatars < 500 ms of each's join". E2E * measures the join→avatar delta on a 2× flake-superset (1000 ms) and logs * the actual, so a real regression (several seconds) fails while CI jitter * (100–200 ms) does not. The "caret within 4 px of coordsAtPos" sub-assert * is a UNIT concern (lib/presence-cursor.test.ts resolves the geometry * deterministically, including multi-block selections) — the E2E asserts * the observable: the caret + name label render, in the RIGHT BLOCK's * vicinity (a 4 px pixel-diff is not robust against CI font metrics). */ import { test, expect } from "../fixtures"; import { DEMO_EMAIL, DEMO_PASSWORD } from "../fixtures"; import { addMemberViaSeam, register, login, createWorkspace, createPage, createBlock, getBlocks, seamServiceToken, } from "../utils/api"; import type { APIRequestContext, Browser, Page } from "@playwright/test"; import type { AuthState } from "../fixtures"; const PRESENCE_BUDGET_MS = 1_000; // 2× the 500 ms DoD metric (flake-superset) const LEAVE_BUDGET_MS = 1_500; // the < 500 ms leave metric, flake-superset const DEMO_NAME = "Demo User"; // prisma/seed.ts: upsertUser(DEMO, "Demo User") const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000"; /** A freshly-registered user with its own authenticated browser context * (mirrors the `secondUser` fixture's cookie + token storage state). */ async function newUser( browser: Browser, name: string, ): Promise<{ user: { email: string; password: string }; page: Page; close: () => Promise; api: APIRequestContext; }> { const email = `e2e-pres-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`; const password = `e2e-${Date.now().toString(36)}`; const plain = await browser.newContext(); const plainApi = plain.request; await register(plainApi, email, password, name); const { accessToken } = await login(plainApi, email, password); const jar = await plain.cookies(); const state: AuthState = { cookies: jar.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, expires: c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, })), origins: accessToken ? [{ origin: WEB_BASE, localStorage: [{ name: "factory.accessToken", value: accessToken }] }] : [], }; await plain.close(); const context = await browser.newContext({ storageState: state }); const page = await context.newPage(); return { user: { email, password }, page, api: context.request, close: () => context.close(), }; } /** The seeded demo owner, in its own context (a fresh login — the cookie * + token storage state). */ async function ownerTab(browser: Browser) { const plain = await browser.newContext(); const plainApi = plain.request; await login(plainApi, DEMO_EMAIL, DEMO_PASSWORD); const jar = await plain.cookies(); const state: AuthState = { cookies: jar.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, expires: c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, })), origins: [], }; await plain.close(); const context = await browser.newContext({ storageState: state }); const page = await context.newPage(); return { page, close: () => context.close() }; } /** Seed: workspace + page + 3 paragraph blocks, then 3 distinct users in it. */ async function seed(browser: Browser, api: APIRequestContext) { const ws = await createWorkspace(api, `Presence ${Date.now()}`); const pg = await createPage(api, ws.id, { title: "Presence page" }); for (let i = 0; i < 3; i += 1) { await createBlock(api, pg.id, { type: "paragraph", content: { text: ["alpha", "beta", "gamma"][i] }, position: `${i + 1}.0`, }); } const owner = await ownerTab(browser); const bob = await newUser(browser, "Bob"); const carol = await newUser(browser, "Carol"); // Add each new user via the internal test seam (TASK-0095 S9 — the D3 // unblock; the public invite flow is email-gated and its token is never in // the response). The seam is service-token guarded and e2e-only. await addMemberViaSeam(seamServiceToken(), ws.id, bob.user.email, "MEMBER"); await addMemberViaSeam(seamServiceToken(), ws.id, carol.user.email, "MEMBER"); const url = `/w/${ws.id}/page/${pg.id}`; return { wsId: ws.id, pageId: pg.id, url, owner, bob, carol, closeAll: async () => { await owner.close().catch(() => {}); await bob.close().catch(() => {}); await carol.close().catch(() => {}); }, }; } /** The OTHERS' avatars in the PresenceBar (role=img; accessible name is * "{name} is viewing this page" — color is never the sole identifier). */ function avatars(page: Page) { return page.locator('[role="img"][aria-label$="is viewing this page"]'); } /** A peer's cursor name label (the visible pill in the overlay). */ function cursorLabel(page: Page, name: string) { return page.locator(`div[aria-hidden="true"] span`, { hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}$`), }); } // --- the mechanic ------------------------------------------------------------- test.describe("TASK-0093 — 3-client presence + live cursors", () => { test("three clients see the other two's avatars within the budget; a leave updates the bars", async ({ browser, api, }) => { const s = await seed(browser, api); try { await s.owner.page.goto(s.url); await s.bob.page.goto(s.url); // The 3rd client joins last; its join→avatar delta measures the // re-broadcast catch-up (the server re-sends the peer set on join). const t0 = Date.now(); await s.carol.page.goto(s.url); await expect .poll(() => avatars(s.carol.page).count(), { timeout: PRESENCE_BUDGET_MS * 2, message: "the joiner sees the current peer set (re-broadcast)", }) .toBe(2); const delta = Date.now() - t0; expect( delta, `join→avatar delta ${delta} ms (DoD metric is 500 ms; this is a 2× flake-superset)`, ).toBeLessThanOrEqual(PRESENCE_BUDGET_MS); // Each client's bar shows the other TWO, with the "2 people are // viewing this page" accessible count. for (const p of [s.owner.page, s.bob.page, s.carol.page]) { await expect.poll(() => avatars(p).count(), { timeout: PRESENCE_BUDGET_MS * 2 }).toBe(2); await expect .poll( async () => p .locator('div[aria-label*="are viewing this page"]') .first() .getAttribute("aria-label"), { timeout: PRESENCE_BUDGET_MS * 2, }, ) .toContain("2"); } // One leaves: Carol's socket closes → the server announces the // leave → owner + bob see Carol's avatar vanish (3 peers → 2 others). await s.carol.close(); for (const p of [s.owner.page, s.bob.page]) { await expect .poll(() => avatars(p).count(), { timeout: LEAVE_BUDGET_MS, message: "the leaver's avatar disappears", }) .toBe(1); } } finally { await s.closeAll(); } }); test("cursors: A types → B and C see A's caret + name label; A hidden → cursor gone, avatar stays; A returns → cursor back", async ({ browser, api, }) => { const s = await seed(browser, api); try { await s.owner.page.goto(s.url); await s.bob.page.goto(s.url); await s.carol.page.goto(s.url); await expect .poll(() => avatars(s.bob.page).count(), { timeout: PRESENCE_BUDGET_MS * 2 }) .toBe(2); // A (owner) types in block 2 → the caret moves → B and C see A's // cursor: the name label in the overlay, in the right block's // vicinity (the exact 4 px geometry is unit-tested). await s.owner.page .locator("[data-block-id]") .nth(1) .locator('[contenteditable="true"]') .first() .click(); await s.owner.page.keyboard.press("End"); await s.owner.page.keyboard.type(" hi"); await expect .poll( async () => { const label = cursorLabel(s.bob.page, DEMO_NAME).first(); if ((await label.count()) === 0) return false; const caretBox = await label.boundingBox(); const rowBox = await s.bob.page.locator("[data-block-id]").nth(1).boundingBox(); if (!caretBox || !rowBox) return false; // In block 2's vicinity: horizontally within the row's band, // vertically within one row-height above/below (the label sits // just below the caret). return ( caretBox.x >= rowBox.x - 8 && caretBox.x <= rowBox.x + rowBox.width + 8 && caretBox.y >= rowBox.y - 40 && caretBox.y <= rowBox.y + rowBox.height + 40 ); }, { timeout: PRESENCE_BUDGET_MS * 2, message: "B sees A's caret in block 2" }, ) .toBeTruthy(); await expect .poll(async () => (await cursorLabel(s.carol.page, DEMO_NAME).count()) > 0, { timeout: PRESENCE_BUDGET_MS * 2, message: "C sees A's caret", }) .toBeTruthy(); // A's tab goes hidden → viewing:false → A's cursor hides on B/C // within the budget, but A's AVATAR stays (A is still "here"). await s.owner.page.evaluate(() => { Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); document.dispatchEvent(new Event("visibilitychange")); }); await expect .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) === 0, { timeout: LEAVE_BUDGET_MS, message: "A's cursor hides when A's tab is hidden", }) .toBeTruthy(); expect(await avatars(s.bob.page).count(), "A's avatar stays when A's tab is hidden").toBe(2); // A returns → the cursor reappears. await s.owner.page.evaluate(() => { Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible", }); document.dispatchEvent(new Event("visibilitychange")); }); await expect .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) > 0, { timeout: PRESENCE_BUDGET_MS * 2, message: "A's cursor returns when A's tab is visible", }) .toBeTruthy(); } finally { await s.closeAll(); } }); test("stale cursor: B deletes A's block → A's cursor is dropped on B (no orphan node)", async ({ browser, api, }) => { const s = await seed(browser, api); try { await s.owner.page.goto(s.url); await s.bob.page.goto(s.url); await s.carol.page.goto(s.url); await expect .poll(() => avatars(s.bob.page).count(), { timeout: PRESENCE_BUDGET_MS * 2 }) .toBe(2); // A parks a cursor in block 1 (types once). await s.owner.page .locator("[data-block-id]") .nth(0) .locator('[contenteditable="true"]') .first() .click(); await s.owner.page.keyboard.press("End"); await s.owner.page.keyboard.type("x"); await expect .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) > 0, { timeout: PRESENCE_BUDGET_MS * 2, message: "B sees A's cursor in block 1", }) .toBeTruthy(); // B deletes block 1 via the API (authoritative + deterministic). The // delete publishes `page.updated` → the realtime fan-out sends the // `block:changed` refetch hint → B's overlay refetches (the (b) // mechanic, TASK-0092) → block 1 is gone from B's DOM. A's cursor // still references the deleted blockId → the resolver returns null → // the cursor is DROPPED silently (no orphan node). const blocks = await getBlocks(api, s.pageId); const first = blocks[0]; expect(first, "block 1 exists before the delete").toBeTruthy(); const del = await api.delete(`/api/v1/blocks/${first.id}`); expect(del.status()).toBeLessThan(300); await expect .poll(async () => s.bob.page.locator("[data-block-id]").count(), { timeout: 3_000 }) .toBe(2); // 3 blocks → 2 after the delete // A's cursor label is gone from B's overlay (the stale blockId // dropped); A's avatar STAYS (presence is independent of blocks). await expect .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) === 0, { timeout: PRESENCE_BUDGET_MS * 2, message: "A's stale cursor is dropped on B", }) .toBeTruthy(); expect(await avatars(s.bob.page).count(), "A's avatar stays after the block delete").toBe(2); } finally { await s.closeAll(); } }); test("a11y: the live region announces presence state; the cursor overlay is aria-hidden", async ({ browser, api, }) => { const s = await seed(browser, api); try { await s.owner.page.goto(s.url); await s.bob.page.goto(s.url); // The polite live region (role=status) is attached + visually hidden. const live = s.bob.page.locator('[role="status"]').first(); await expect(live).toBeAttached(); // A join is a state change → announced (≤ 1/s; the first change // within the window lands immediately). const before = await live.innerText().catch(() => ""); await s.carol.page.goto(s.url); // a 3rd client joins await expect .poll(async () => (await live.innerText().catch(() => "")).replace(before, "").trim(), { timeout: PRESENCE_BUDGET_MS * 2, message: "the live region gains an announcement on the join", }) .toMatch(/joined|editing|left/); // The cursor overlay is decorative (aria-hidden): the caret + name // label are NOT in the accessibility tree — the announcement is the // SR channel, the cursor is the visual one (spec R5). await s.owner.page .locator("[data-block-id]") .nth(0) .locator('[contenteditable="true"]') .first() .click(); await s.owner.page.keyboard.type("a"); const overlay = s.bob.page .locator('div[aria-hidden="true"]') .filter({ has: s.bob.page.locator("span") }) .last(); await expect .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) > 0, { timeout: PRESENCE_BUDGET_MS * 2, message: "the cursor renders (visually)", }) .toBeTruthy(); await expect(overlay).toHaveAttribute("aria-hidden", "true"); } finally { await s.closeAll(); } }); test("reduced motion: the cursor caret has transition-duration 0s (instant)", async ({ browser, api, }) => { const ws = await createWorkspace(api, `RM ${Date.now()}`); const owner = await ownerTab(browser); const bob = await newUser(browser, "Bob"); // Seam add (TASK-0095 S9 — D3 unblock; see the seed() above). await addMemberViaSeam(seamServiceToken(), ws.id, bob.user.email, "MEMBER"); const pg = await createPage(api, ws.id, { title: "RM page" }); for (let i = 0; i < 2; i += 1) { await createBlock(api, pg.id, { type: "paragraph", content: { text: ["a", "b"][i] }, position: `${i + 1}.0`, }); } const url = `/w/${ws.id}/page/${pg.id}`; // Bob in a reduced-motion context (the Phase-4 flag). const rm = await browser.newContext({ storageState: await bob.page.context().storageState(), reducedMotion: "reduce", }); const rmPage = await rm.newPage(); try { await owner.page.goto(url); await rmPage.goto(url); await expect.poll(() => avatars(rmPage).count(), { timeout: PRESENCE_BUDGET_MS * 2 }).toBe(1); await owner.page .locator("[data-block-id]") .nth(0) .locator('[contenteditable="true"]') .first() .click(); await owner.page.keyboard.type("x"); await expect .poll(async () => (await cursorLabel(rmPage, DEMO_NAME).count()) > 0, { timeout: PRESENCE_BUDGET_MS * 2, message: "the cursor renders under reduced motion", }) .toBeTruthy(); // The caret spans have no CSS transition (the global // prefers-reduced-motion rule + the layer sets none) — instant. const dur = await rmPage .locator('div[aria-hidden="true"] span') .first() .evaluate((el) => getComputedStyle(el).transitionDuration); expect(dur, `caret transition-duration under reduced motion (got ${dur})`).toBe("0s"); } finally { await owner.close().catch(() => {}); await rm.close().catch(() => {}); await bob.close().catch(() => {}); } }); });