/** * TASK-0092 (b) — 2-tab concurrent-edit + offline-converge E2E (slice 6c). * * The (b) decision (R6 conflict, phase-07-collaboration.md): the web keeps * the Phase-4 per-block editor + the remote event overlay; a remote tab's * flush publishes `page.updated` → the realtime server fans a `block:changed` * REFETCH hint over the control socket → each tab's overlay re-reads the * source of truth at rest. Convergence is EVENTUALLY CONSISTENT via the * refetch hint (not per-keystroke CRDT merge — that is the un-migrated R6 * provider path, a follow-up task). This spec proves the (b) mechanic in * TWO BROWSER TABS (the acceptance criterion, re-scoped to (b)): two tabs * editing the same page converge to the union of both tabs' edits. * * ── RUNNER GATE (Docker) — NOT RUNNABLE LOCALLY ──────────────────────── * This spec requires 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). It is * committed so the (b) acceptance mechanic is pinned for CI; it is not a * local-green test here. * * Two tabs = two browser contexts, each with its OWN demo-user session * (S10: fresh login per tab — the refresh cookie ROTATES on every * /auth/refresh, so two tabs sharing one cookie would trip reuse-detection * and revoke the session family mid-test). Both tabs belong to the demo * user; the control fan-out is per-page, so one user's two tabs is the * exact production scenario the overlay is built for. * * (b) offline mechanic (the honest one, verified against the code): the * offline tab's edits land in the LOCAL save queue (never blocked — * the local-first invariant). The queue retries a failed flush on the NEXT * change or on unmount (batch-queue.ts) — NOT automatically on reconnect — * so the test recovers the network and then makes one more change to * trigger the flush that carries the offline edits. Convergence is * asserted within the acceptance window. */ import { test, expect, DEMO_EMAIL, DEMO_PASSWORD } from "../fixtures"; import type { APIRequestContext, Browser, Page } from "@playwright/test"; const CONVERGE_MS = 3_000; // the acceptance window ("within 3 s") // --- fixtures the spec needs (a second authed tab, page + blocks) ---------- /** A demo-user tab with its OWN session (fresh login — see the S10 note in * the header; a shared refresh cookie would rotate + trip reuse-detection). * The cookie jar authenticates the web client, which silent-refreshes into * a memory access token on load. */ async function authedTab(browser: Browser): Promise<{ page: Page; close: () => Promise }> { const plain = await browser.newContext(); const res = await plain.request.post("/api/v1/auth/login", { data: { email: DEMO_EMAIL, password: DEMO_PASSWORD }, }); if (res.status() !== 200) { throw new Error(`authedTab: login failed (${res.status()}): ${await res.text()}`); } const jar = await plain.cookies(); await plain.close(); const context = await browser.newContext({ storageState: { 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: [], }, }); const page = await context.newPage(); return { page, close: () => context.close() }; } /** Create a workspace + page with 3 seeded paragraph blocks (positions 1/2/3). */ async function seedPage(api: APIRequestContext) { const wsRes = await api.post("/api/v1/workspaces", { data: { name: `Collab 2tab ${Date.now()}` } }); expect(wsRes.status()).toBe(201); const { workspace } = (await wsRes.json()) as { workspace: { id: string } }; const pgRes = await api.post(`/api/v1/workspaces/${workspace.id}/pages`, { data: { title: "Two-tab convergence" }, }); expect(pgRes.status()).toBe(201); const { page: pg } = (await pgRes.json()) as { page: { id: string } }; for (const [i, text] of ["alpha", "beta", "gamma"].entries()) { const r = await api.post("/api/v1/blocks", { data: { pageId: pg.id, type: "paragraph", content: { text }, position: `${i + 1}.0` }, }); expect([201, 202]).toContain(r.status()); } return { wsId: workspace.id, pageId: pg.id }; } // --- helpers ------------------------------------------------------------------ /** The rendered text of the `nth` block (0-based, position-ordered). */ async function blockText(page: Page, nth: number): Promise { return (await page.locator('[data-testid^="block-"]').nth(nth).innerText()).trim(); } /** Click into the `nth` block, move to the end, and type. */ async function typeInto(page: Page, nth: number, text: string): Promise { const block = page.locator('[data-testid^="block-"]').nth(nth); await block.click(); await page.keyboard.press("End"); await page.keyboard.type(text); } /** Deterministic "network off": abort the realtime socket + block writes + * block reads. `restore()` returns network. (Route-level — precise, unlike * the flaky `setOffline`.) */ async function cutNetwork(page: Page) { await page.context().route(/\/realtime|\/api\/v1\/blocks/, (route) => route.abort()); return () => page.context().unroute(/\/realtime|\/api\/v1\/blocks/); } // --- the mechanic ------------------------------------------------------------- test.describe("TASK-0092 (b) — two-tab convergence (the refetch-hint overlay)", () => { test("edits in BOTH tabs converge to the union", async ({ browser, api }) => { const { wsId, pageId } = await seedPage(api); const url = `/w/${wsId}/page/${pageId}`; const A = await authedTab(browser); const B = await authedTab(browser); try { await A.page.goto(url); await B.page.goto(url); await expect(A.page.locator('[data-testid^="block-"]').first()).toContainText("alpha"); await expect(B.page.locator('[data-testid^="block-"]').first()).toContainText("alpha"); // Interleaved edits: A → block 1, B → block 2 (two tabs, same page). await typeInto(A.page, 0, " AA"); await typeInto(B.page, 1, " BB"); // (b) convergence: each tab's overlay receives the other tab's flush's // block:changed hint and refetches — both tabs end up showing BOTH // edits (the union, eventually consistent). await expect.poll(() => blockText(A.page, 0), { timeout: CONVERGE_MS * 4 }).toContain("AA"); await expect.poll(() => blockText(A.page, 1), { timeout: CONVERGE_MS * 4 }).toContain("BB"); await expect.poll(() => blockText(B.page, 0), { timeout: CONVERGE_MS * 4 }).toContain("AA"); await expect.poll(() => blockText(B.page, 1), { timeout: CONVERGE_MS * 4 }).toContain("BB"); } finally { await A.close(); await B.close(); } }); test("offline tab: the local edit survives the outage + converges after the network returns", async ({ browser, api }) => { const { wsId, pageId } = await seedPage(api); const url = `/w/${wsId}/page/${pageId}`; const A = await authedTab(browser); const B = await authedTab(browser); try { await A.page.goto(url); await B.page.goto(url); await expect(B.page.locator('[data-testid^="block-"]').first()).toContainText("alpha"); const restore = await cutNetwork(B.page); // B types while OFFLINE: the edit lands in the LOCAL editor + save // queue (never blocked — local-first). A types ONLINE (persists). await typeInto(B.page, 0, " OFF"); await typeInto(A.page, 0, " ON"); // A's online edit is visible to A immediately (local editor + save). await expect.poll(() => blockText(A.page, 0), { timeout: CONVERGE_MS }).toContain("ON"); // Network returns. The queue does NOT auto-flush on reconnect // (batch-queue.ts: a failed flush retries on the next change / unmount) // — so one more change triggers the flush that carries the offline // " OFF" edit to the API. await restore(); await typeInto(B.page, 0, "X"); // the flush trigger (carries " OFF" + "X") // Both tabs converge to the union of block 1 within the window. await expect.poll(() => blockText(A.page, 0), { timeout: CONVERGE_MS }).toContain("OFF"); await expect.poll(() => blockText(A.page, 0), { timeout: CONVERGE_MS }).toContain("ON"); await expect.poll(() => blockText(B.page, 0), { timeout: CONVERGE_MS }).toContain("OFF"); await expect.poll(() => blockText(B.page, 0), { timeout: CONVERGE_MS }).toContain("ON"); } finally { await A.close(); await B.close(); } }); });