/** * TASK-0095 S13 — Phase-7 realtime E2E suite: shared fixtures + raw-WS probes. * * This module is the SHARED half of the 7-scenario suite (the scenarios live * in `tests/e2e/specs/realtime.spec.ts`). It provides: * * - seeded contexts: the demo OWNER tab + two freshly-registered ADMIN * users (added through the S9 internal seam — the D3 unblock). ADMIN * (not MEMBER) is deliberate: the shared permissions engine's role * baseline on descendant resources (pages/databases) is * `member → view` (packages/permissions/src/resolve.ts, * ROLE_BASE_ON_DESCENDANTS) — a bare seam-MEMBER could not edit the * page, so the concurrent-editing/offline/resume scenarios that need * three EDIT identities use the seam's ADMIN role (the seam grants no * OWNER; recorded). * - a RAW WebSocket probe (`rawClient`) for the protocol-level assertions * the browser cannot observe: the `hello.serverTime` RTT (S2), the * `lastSeq` update-log resume delta (S4), the no-error-frame assert * (S1), and the 4010 drain (S6). It speaks the TASK-0091/0092 wire * protocol (packages/shared frames): JSON control frames + binary * `0x01 update / 0x02 awareness` frames, the `/realtime` upgrade path, * and the `?token=&room=` handshake (the same contract the k6 1k * baseline + the integration rt-helpers drive). * - the deterministic network cut (S3 offline): route-abort of the API + * the realtime socket + CDP offline emulation (navigator.onLine=false — * the outbox's enqueue gate, apps/web/src/lib/api.ts `isOffline()`). * - the realtime-process restart (S6): SIGTERM the running `tsx watch * apps/realtime/src/index.ts` (the stack's dev process), read the drain * 4010, respawn it with the SAME environment (captured from * /proc//environ), wait for :4001. * * ── RUNNER GATE (Docker) — NOT RUNNABLE LOCALLY ───────────────────────── * Every scenario here needs the full stack (Postgres + Redis + API :4000 + * realtime :4001 + web :3000, the seeded demo user). A Docker daemon is * unavailable in the authoring environment (permission denied; recorded, * never faked). The suite is CI-ready under the same contract as every * other spec in this package (`pnpm docker:up && pnpm dev`, or the CI e2e * job) and is committed so the Phase-7 exit criteria are pinned for CI. */ import { expect, request as pwRequest } from "@playwright/test"; import * as Y from "yjs"; import WebSocket from "ws"; import * as fs from "node:fs"; import * as net from "node:net"; import { spawn, execFileSync } from "node:child_process"; import type { Browser, Page, BrowserContext, APIRequestContext } from "@playwright/test"; import type { AuthState, Workspace, PageDto } from "../fixtures"; import { DEMO_EMAIL, DEMO_PASSWORD } from "../fixtures"; import { addMemberViaSeam, createBlock, createDatabase, createPage, createWorkspace, register, login, seamServiceToken, getBlocks, } from "../utils/api"; const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000"; const WS_BASE = process.env.E2E_WS_URL ?? "ws://localhost:4001"; export const CONVERGE_MS = 3_000; // the exit-criterion window ("within 3 s") // --- auth contexts ------------------------------------------------------------------ type Cookie = { name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: "Strict" | "Lax" | "None"; }; function cookiesFromLogin(jar: Cookie[]): AuthState["cookies"] { return 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, })); } /** A browser context authenticated as the user (S10: cookie → silent-refresh). */ interface OwnerTab { context: BrowserContext; page: Page; close: () => Promise; } async function contextWithLogin( browser: Browser, email: string, password: string, ): Promise { const plain = await browser.newContext(); const res = await plain.request.post("/api/v1/auth/login", { data: { email, password }, }); if (res.status() !== 200) { throw new Error(`contextWithLogin(${email}): login failed (${res.status()})`); } const jar = await plain.cookies(); await plain.close(); const context = await browser.newContext({ storageState: { cookies: cookiesFromLogin(jar), origins: [] }, }); const page = await context.newPage(); return { context, page, close: () => context.close() }; } /** * A freshly-REGISTERED user (no session yet). The caller adds the workspace * membership (the S9 seam) and THEN calls `authedUser` to log in — the login * is what bakes the `workspaces[]` claim into the access token (auth.service.ts * captures `memberships.map(m => m.workspaceId)` at sign time), which the * realtime handshake requires (server.ts: `session.workspaces.includes(wsId)` * or 403). Registering before the seam add keeps the token honest. */ export interface NewUser { user: { email: string; password: string }; context: BrowserContext; page: Page; api: APIRequestContext; /** The user's access token (for rawClient); carries workspaces[]. */ token: string; close: () => Promise; } export async function newUser( browser: Browser, name: string, ): Promise<{ user: { email: string; password: string } }> { const email = `e2e-rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`; const password = `e2e-${Date.now().toString(36)}`; const plain = await browser.newContext(); await register(plain.request, email, password, name); await plain.close(); return { user: { email, password } }; } /** * Log in an existing user + open an authenticated browser context. The * `accessToken` (rotation-safe: a throwaway session — the web's silent-refresh * owns the cookie) is the raw-WS-probe credential (S2/S4/S6). MUST be called * AFTER the user's workspace membership exists, so the token carries it. */ export async function authedUser( browser: Browser, user: { email: string; password: string }, ): Promise { const plain = await browser.newContext(); const logged = await login(plain.request, user.email, user.password); const token = logged.accessToken ?? ""; const jar = await plain.cookies(); await plain.close(); const context = await browser.newContext({ storageState: { cookies: cookiesFromLogin(jar), origins: [] }, }); const page = await context.newPage(); return { user, context, page, api: context.request, token, close: () => context.close(), }; } /** The seeded demo owner (a fresh login — the S10 cookie + silent-refresh). */ export async function ownerTab(browser: Browser) { return contextWithLogin(browser, DEMO_EMAIL, DEMO_PASSWORD); } // --- the 3-user page fixture (S9 seam + ADMIN, see the header) ---------------------- export interface RtPageSeed { ws: Workspace; page: PageDto; url: string; owner: OwnerTab; a: NewUser; b: NewUser; closeAll: () => Promise; } /** * Seed: fresh workspace (demo owner) + a page with `blockCount` paragraph * blocks + the two ADMIN users (the S9 seam). The caller owns `api` (the * demo-owner API fixture) — it is only used for the seed calls. * * ORDER (the token-carry subtlety): register Alice/Bob, add them through the * seam, and THEN log them in. The access token bakes `workspaces[]` in at * login time (auth.service.ts) — the realtime handshake 403s a token whose * `workspaces[]` lacks the room's workspace — so the login MUST come after * the seam membership exists. (The browser contexts use the cookie session, * which is membership-agnostic at request time, but the raw probes use the * token — hence the ordering.) */ export async function seedPage( browser: Browser, api: APIRequestContext, blockCount = 3, ): Promise { const ws = await createWorkspace(api, `RT ${Date.now()}`); const page = await createPage(api, ws.id, { title: "Realtime page" }); const seeds = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]; for (let i = 0; i < blockCount; i += 1) { await createBlock(api, page.id, { type: "paragraph", content: { text: seeds[i] ?? `seed-${i}` }, position: `${i + 1}.0`, }); } const owner = await ownerTab(browser); const aReg = await newUser(browser, "Alice"); const bReg = await newUser(browser, "Bob"); // The seam (S9 — the D3 unblock). ADMIN → full_access on descendants // (the baseline table) — see the header for why not MEMBER. const token = seamServiceToken(); await addMemberViaSeam(token, ws.id, aReg.user.email, "ADMIN"); await addMemberViaSeam(token, ws.id, bReg.user.email, "ADMIN"); // Log in AFTER the seam add (the token now carries the workspace). const a = await authedUser(browser, aReg.user); const b = await authedUser(browser, bReg.user); const url = `/w/${ws.id}/page/${page.id}`; return { ws, page, url, owner, a, b, closeAll: async () => { await owner.close().catch(() => {}); await a.close().catch(() => {}); await b.close().catch(() => {}); }, }; } // --- block DOM helpers (the (b) overlay mechanics) ----------------------------------- /** The `nth` block's rendered text (0-based, position order). */ export function blockText(page: Page, nth: number): Promise { return page.locator('[data-testid^="block-"]').nth(nth).innerText().then((t) => t.trim()); } /** Click into the `nth` block, move to the end, type (one keystroke at a * time — the per-keystroke (b) flush path). */ export 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); } /** Wait until BOTH pages' blocks show the union (the (b) refetch convergence). */ export async function expectConverged( a: Page, b: Page, checks: { nth: number; contains: string }[], timeoutMs = CONVERGE_MS * 4, ): Promise { for (const p of [a, b]) { for (const c of checks) { await expect .poll(() => blockText(p, c.nth), { timeout: timeoutMs, message: `block ${c.nth} to contain ${c.contains}`, }) .toContain(c.contains); } } } /** The page title input (apps/web/src/components/editor/page-view.tsx). */ export function titleInput(page: Page) { return page.locator('input[aria-label="Page title"]'); } /** * The at-rest API truth for the page's blocks (the (b) "source of truth at * rest" — the flush's /internal/blocks funnel output). Asserts the text of * the `nth` block equals `text` exactly (position order). A paragraph block's * content is `{ text }` (the canonical block JSON — see the integration * convergence tests). */ export async function expectAtRestBlock( api: APIRequestContext, pageId: string, nth: number, text: string, ): Promise { const blocks = await getBlocks(api, pageId); const got = blocks[nth]?.content as { text?: unknown } | unknown[] | undefined; const gotText = got !== null && typeof got === "object" && !Array.isArray(got) && "text" in got ? String((got as { text?: unknown }).text ?? "") : Array.isArray(got) ? got.join("") : String(got ?? ""); expect( gotText, `at-rest block ${nth} (API truth) — expected "${text}", got "${gotText}"`, ).toBe(text); } // --- deterministic network cut (S3) --------------------------------------------------- export interface NetworkCut { restore: () => Promise; } /** * Simulate the browser going offline, the way the outbox + status UI expect: * `context.setOffline(true)` (Playwright) aborts every request AND sets * `navigator.onLine === false` — the outbox's enqueue gate (lib/api.ts * `isOffline()`) and the offline-banner condition (lib/offline-status.ts * `bannerVisible = !navigatorOnline || …`). `restore()` → `setOffline(false)` * restores the network + fires the window `online` event, which drains the * outbox (offlineQueue.ts `initOutbox` wires window `online` → `drain()`) and * clears the banner. * * A route-level abort (the collab-two-tab approach) CANNOT set * `navigator.onLine` — so the R1 outbox (the enqueue gate) and the offline * banner would never engage. `setOffline` is the faithful simulation; the * collab-two-tab spec's "flaky" note was about an older, page-level variant — * here it is the CONTEXT-level API, asserted type-safe against the pinned * 1.49.1 runtime. */ export async function cutNetwork(context: BrowserContext): Promise { await context.setOffline(true); return { restore: async () => { await context.setOffline(false); }, }; } // --- raw WebSocket probe (the TASK-0091/0092 protocol) -------------------------------- export interface RawClient { ws: WebSocket; /** Every received text frame (history view). */ frames: () => Record[]; /** Every received binary frame, kind byte stripped (history view). */ binaries: () => Uint8Array[]; nextFrame: (type: string, timeoutMs?: number) => Promise>; frameCount: (type: string) => number; errorFrames: () => Record[]; /** The last `ack {seq}` (the update-log resume fast path, R5). */ lastAckSeq: () => number | null; sendText: (obj: unknown) => void; /** Append `text` to a local Y.Doc's "t" type and send the delta as a * `0x01`-prefixed binary update frame (the TASK-0091 wire format). */ sendUpdate: (doc: Y.Doc, text: string) => void; close: (code?: number) => void; onClose: (timeoutMs?: number) => Promise<{ code: number; reason: string }>; closed: () => { code: number; reason: string } | null; } /** * Connect a raw WS client the way the k6 baseline + the integration * rt-helpers do: `/realtime?token=&room=`, JSON frames + binary * `0x01`-prefixed Yjs updates. The token must carry `workspaces: [wsId]` * (the handshake binds the room's workspace to the claim — note 13). */ export async function rawClient( token: string, room: string, wsBase: string = WS_BASE, ): Promise { const url = `${wsBase.replace(/\/+$/, "")}/realtime?token=${encodeURIComponent( token, )}&room=${encodeURIComponent(room)}`; const ws = new WebSocket(url); const textQueue: Record[] = []; const binQueue: Uint8Array[] = []; let closeInfo: { code: number; reason: string } | null = null; const closeWaiters: { resolve: (v: { code: number; reason: string }) => void; timer: ReturnType; }[] = []; ws.on("message", (data: Buffer, isBinary: boolean) => { if (isBinary) { // Strip the 0x01/0x02 kind byte (the server's binary frame prefix). binQueue.push(new Uint8Array(data.subarray(1))); return; } try { textQueue.push(JSON.parse(data.toString("utf8")) as Record); } catch { /* non-JSON text — ignore */ } }); ws.on("close", (code, reason) => { closeInfo = { code, reason: reason.toString() }; for (const w of closeWaiters.splice(0)) { clearTimeout(w.timer); w.resolve(closeInfo!); } }); await new Promise((resolve, reject) => { const t = setTimeout( () => reject(new Error(`rawClient connect timeout (${room})`)), 8_000, ); ws.once("open", () => { clearTimeout(t); resolve(); }); ws.once("error", (err) => { clearTimeout(t); reject(err instanceof Error ? err : new Error(String(err))); }); }); return { ws, frames: () => [...textQueue], binaries: () => [...binQueue], nextFrame: (type, timeoutMs = 8_000) => new Promise((resolve, reject) => { const i = textQueue.findIndex((f) => f.type === type); if (i !== -1) { resolve(textQueue.splice(i, 1)[0]!); return; } const timer = setTimeout( () => reject( new Error( `timeout waiting for "${type}" (got ${textQueue.length} frames)`, ), ), timeoutMs, ); const onMsg = (data: Buffer, isBinary: boolean) => { if (isBinary) return; try { const f = JSON.parse(data.toString("utf8")) as Record; if (f.type !== type) return; const j = textQueue.indexOf(f); if (j !== -1) textQueue.splice(j, 1); ws.off("message", onMsg); clearTimeout(timer); resolve(f); } catch { /* ignore */ } }; ws.on("message", onMsg); }), frameCount: (type) => textQueue.filter((f) => f.type === type).length, errorFrames: () => textQueue.filter((f) => f.type === "error"), lastAckSeq: () => { for (let i = textQueue.length - 1; i >= 0; i -= 1) { const f = textQueue[i]!; if (f.type === "ack" && typeof f.seq === "number") return f.seq; } return null; }, sendText: (obj) => ws.send(JSON.stringify(obj)), sendUpdate: (doc, text) => { const yText = doc.getText("t"); // Encode the delta against the CURRENT STATE VECTOR (the incremental // update since this client's last known state) — `encodeStateAsUpdate` // with a full-update argument returns an empty buffer, so it must be a // state vector (the TASK-0091 wire format the rt-helpers drive). const sv = Y.encodeStateVector(doc); yText.insert(0, text); const delta = Y.encodeStateAsUpdate(doc, sv); const out = new Uint8Array(delta.byteLength + 1); out[0] = 0x01; // BINARY_KIND.Update out.set(delta, 1); ws.send(out); }, close: (code = 1000) => ws.close(code), onClose: (timeoutMs = 8_000) => new Promise((resolve, reject) => { if (closeInfo !== null) { resolve(closeInfo); return; } const w = { resolve, timer: setTimeout( () => reject(new Error("timeout waiting for close (socket still open)")), timeoutMs, ), }; closeWaiters.push(w); }), closed: () => closeInfo, }; } // --- the realtime process restart (S6) ------------------------------------------------- /** * Restart the running realtime server the way CI runs it (the R6 recovery * path — the SIGTERM drain → 4010 to every socket → flush ≤ 10 s → respawn). * * The stack is `pnpm dev` → `turbo run dev` → a per-app child (apps/realtime * runs `tsx watch src/index.ts`). The process actually binding :4001 is a * descendant of turbo, NOT turbo itself — so we locate it by the LISTENING * PORT (the unambiguous signal) and kill ONLY its subtree (turbo + the API/ * web children must keep running). We then respawn the EXACT command we found * (argv + cwd + env, captured from /proc) so the restart is faithful to the * runner's real process rather than a re-guessed `tsx watch`. * * CI-gated: `ss -tlnp` + /proc are the Linux CI runner's primitives (Docker * gate — this suite never runs locally). */ export async function restartRealtimeServer(): Promise { const pid = pidListeningOn(4001); if (pid === null) { throw new Error("restartRealtimeServer: nothing is listening on :4001 (stack not up?)"); } const info = procInfo(pid); if (info === null) { throw new Error(`restartRealtimeServer: cannot read /proc/${pid} (non-Linux runner?)`); } // SIGTERM the whole subtree (tsx watch + its node child hold the port). for (const p of [...descendantsOf(pid), pid].sort((x, y) => y - x)) { try { process.kill(p, "SIGTERM"); } catch { /* already gone */ } } await waitForPortFree(4001, 30_000); // Respawn the exact command (argv[0] + rest) in its original cwd + env. const child = spawn(info.argv[0]!, info.argv.slice(1), { cwd: info.cwd, env: info.env, stdio: "ignore", detached: true, }); child.unref(); await waitForPort(4001, 90_000); await waitForPort(4002, 90_000); } /** The PID holding the LISTEN socket on `port` (`ss -tlnp`). */ function pidListeningOn(port: number): number | null { let out: string; try { out = execFileSync("ss", ["-tlnp"], { encoding: "utf8" }); } catch { return null; } for (const line of out.split("\n")) { // ...:4001 ... users:(("node",pid=1234,fd=20)) const portMatch = new RegExp(`:\\b${port}\\b`).test(line); if (!portMatch) continue; const pid = /pid=(\d+)/.exec(line)?.[1]; if (pid !== undefined) return Number(pid); } return null; } /** A process's direct + transitive descendants (the /proc ppid walk). */ function descendantsOf(pid: number): number[] { const children = new Map(); const pids = fs .readdirSync("/proc") .filter((n) => /^\d+$/.test(n)) .map(Number); for (const p of pids) { try { // /proc/

/stat: pid (comm) state ppid ... const stat = fs.readFileSync(`/proc/${p}/stat`, "utf8"); const close = stat.lastIndexOf(")"); const fields = stat.slice(close + 2).split(" "); const ppid = Number(fields[1]); // state(0) ppid(1) if (!Number.isFinite(ppid)) continue; const arr = children.get(ppid) ?? []; arr.push(p); children.set(ppid, arr); } catch { /* gone / no perm */ } } const out: number[] = []; const stack = [...(children.get(pid) ?? [])]; while (stack.length > 0) { const p = stack.pop()!; out.push(p); for (const c of children.get(p) ?? []) stack.push(c); } return out; } interface ProcInfo { argv: string[]; env: Record; cwd: string; } /** argv + env + cwd for `pid` (the respawn inputs). Null off-Linux. */ function procInfo(pid: number): ProcInfo | null { try { const argv = fs .readFileSync(`/proc/${pid}/cmdline`, "utf8") .split("\0") .filter((s) => s.length > 0); const cwd = fs.readlinkSync(`/proc/${pid}/cwd`); const raw = fs.readFileSync(`/proc/${pid}/environ`, "utf8"); const env: Record = {}; for (const entry of raw.split("\0")) { const eq = entry.indexOf("="); if (eq > 0) env[entry.slice(0, eq)] = entry.slice(eq + 1); } if (argv.length === 0) return null; return { argv, env, cwd }; } catch { return null; } } export async function waitForPort(port: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; // eslint-disable-next-line no-constant-condition while (true) { const ok = await new Promise((resolve) => { const s = net.connect(port, "127.0.0.1"); s.once("connect", () => { s.destroy(); resolve(true); }); s.once("error", () => resolve(false)); }); if (ok) return; if (Date.now() >= deadline) throw new Error(`port ${port} not accepting within ${timeoutMs}ms`); await new Promise((r) => setTimeout(r, 500)); } } async function waitForPortFree(port: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; // eslint-disable-next-line no-constant-condition while (true) { const ok = await new Promise((resolve) => { const s = net.connect(port, "127.0.0.1"); s.once("connect", () => { s.destroy(); resolve(true); }); s.once("error", () => resolve(false)); }); if (!ok) return; if (Date.now() >= deadline) throw new Error(`port ${port} still bound after ${timeoutMs}ms`); await new Promise((r) => setTimeout(r, 300)); } } // --- the API liveness guard (local vs CI) --------------------------------------------- /** The API is reachable (global-setup throws first; this is a belt). */ export async function apiReachable(): Promise { try { const req = await pwRequest.newContext({ baseURL: API_BASE, timeout: 5_000 }); const res = await req.get("/api/v1/health").catch(() => null); await req.dispose(); return res !== null; } catch { return false; } } /** Create a database (title + a text prop) for the record-outbox scenario. */ export async function seedDatabase( api: APIRequestContext, wsId: string, title = "RT Records", ) { return createDatabase(api, wsId, { title, pageId: null, properties: [{ name: "Name", type: "title" }, { name: "Note", type: "text" }], }); }