1
55import { test, expect, DEMO_EMAIL } from "../fixtures";
56import * as Y from "yjs";
57import {
58 CONVERGE_MS,
59 apiReachable,
60 blockText,
61 cutNetwork,
62 expectAtRestBlock,
63 rawClient,
64 restartRealtimeServer,
65 seedPage,
66 typeInto,
67} from "../fixtures/realtime";
68import { me } from "../utils/api";
69import type { Page as PWPage } from "@playwright/test";
70
71
72
73const STACK_UP = await apiReachable();
74
75
76
77test.describe.configure({ mode: "serial" });
78
79const PRESENCE_BUDGET_MS = 1_000;
80const RECONNECT_BUDGET_MS = 3_000;
81const COMMENT_BUDGET_MS = 300;
82
83function avatars(page: PWPage) {
84 return page.locator('[role="img"][aria-label$="is viewing this page"]');
85}
86
87
88function chipAria(page: PWPage): Promise<string | null> {
89 return page.getByTestId("connection-chip").getAttribute("aria-label");
90}
91
92async function scenarioSkipIfNoStack(): Promise<void> {
93 if (!STACK_UP) {
94 test.skip(true, "no running full stack (Docker-gated — CI only)");
95 }
96 if (test.info().project.name !== "chromium-desktop") {
97 test.skip(true, "desktop-only (the connect-rate budget is per-runner-IP)");
98 }
99}
100
101test.describe("TASK-0095 S13 — Phase-7 realtime E2E (@phase7)", () => {
102 test("1. concurrent-editing: 20 interleaved ops (A×10 + B×10) → zero loss + Postgres at-rest", async ({
103 browser,
104 api,
105 }) => {
106 await scenarioSkipIfNoStack();
107 const s = await seedPage(browser, api, 2);
108 try {
109 await s.a.page.goto(s.url, { waitUntil: "domcontentloaded" });
110 await s.b.page.goto(s.url, { waitUntil: "domcontentloaded" });
111
112 await expect(s.a.page.locator('[data-testid^="block-"]').first(), "A's block").toBeVisible({ timeout: 20_000 });
113 await expect(s.b.page.locator('[data-testid^="block-"]').first(), "B's block").toBeVisible({ timeout: 20_000 });
114
115
116
117
118
119
120
121 for (let i = 0; i < 10; i += 1) {
122 await typeInto(s.a.page, 0, `A${i}`);
123 await expect
124 .poll(() => blockText(s.b.page, 0), {
125 timeout: CONVERGE_MS,
126 message: `B sees A's op ${i} (the (b) refetch)`,
127 })
128 .toContain(`A${i}`);
129 await typeInto(s.b.page, 1, `B${i}`);
130 await expect
131 .poll(() => blockText(s.a.page, 1), {
132 timeout: CONVERGE_MS,
133 message: `A sees B's op ${i} (the (b) refetch)`,
134 })
135 .toContain(`B${i}`);
136 }
137
138
139 const aUnion = "alpha" + Array.from({ length: 10 }, (_, i) => `A${i}`).join("");
140 const bUnion = "beta" + Array.from({ length: 10 }, (_, i) => `B${i}`).join("");
141 await expect.poll(() => blockText(s.a.page, 0), { timeout: CONVERGE_MS * 2 }).toBe(aUnion);
142 await expect.poll(() => blockText(s.a.page, 1), { timeout: CONVERGE_MS * 2 }).toBe(bUnion);
143 await expect.poll(() => blockText(s.b.page, 0), { timeout: CONVERGE_MS * 2 }).toBe(aUnion);
144 await expect.poll(() => blockText(s.b.page, 1), { timeout: CONVERGE_MS * 2 }).toBe(bUnion);
145
146
147 await expectAtRestBlock(api, s.page.id, 0, aUnion);
148 await expectAtRestBlock(api, s.page.id, 1, bUnion);
149 } finally {
150 await s.closeAll();
151 }
152 });
153
154 test("2. presence-3client: the joiner sees the peer set < 500 ms (median) + the serverTime RTT", async ({
155 browser,
156 api,
157 }) => {
158 await scenarioSkipIfNoStack();
159 const s = await seedPage(browser, api, 1);
160 try {
161 await s.owner.page.goto(s.url, { waitUntil: "domcontentloaded" });
162 await s.a.page.goto(s.url, { waitUntil: "domcontentloaded" });
163 await expect
164 .poll(() => avatars(s.a.page).count(), { timeout: PRESENCE_BUDGET_MS * 4 })
165 .toBe(1);
166
167
168
169
170 const t0 = Date.now();
171 await s.b.page.goto(s.url, { waitUntil: "domcontentloaded" });
172 await expect
173 .poll(() => avatars(s.b.page).count(), {
174 timeout: PRESENCE_BUDGET_MS * 4,
175 message: "the joiner sees the current peer set (re-broadcast)",
176 })
177 .toBe(2);
178 const delta = Date.now() - t0;
179 expect(
180 delta,
181 `join→avatar delta ${delta} ms (DoD 500 ms; 2× superset ${PRESENCE_BUDGET_MS} ms)`,
182 ).toBeLessThanOrEqual(PRESENCE_BUDGET_MS);
183
184
185 for (const p of [s.owner.page, s.a.page, s.b.page]) {
186 await expect.poll(() => avatars(p).count(), { timeout: PRESENCE_BUDGET_MS * 4 }).toBe(2);
187 }
188
189
190
191
192
193
194 const probe = await rawClient(s.a.token, `page:${s.page.id}`);
195 const hello = await probe.nextFrame("hello", 5_000);
196 expect(typeof hello.serverTime, "hello carries serverTime (the RTT basis)").toBe("number");
197 const rtts: number[] = [];
198 for (let i = 0; i < 5; i += 1) {
199 const ts = Date.now();
200 probe.sendText({ type: "ping", ts });
201 await probe.nextFrame("ping-pong", 5_000);
202 rtts.push(Date.now() - ts);
203 }
204 rtts.sort((x, y) => x - y);
205 const median = rtts[Math.floor(rtts.length / 2)]!;
206 console.log(`[S13-2] raw-WS RTT samples [${rtts.join(", ")}] ms; median ${median} ms (DoD < 200 ms)`);
207 expect(
208 median,
209 `median RTT ${median} ms (DoD 200 ms; 2× superset; the k6 1k baseline owns the hard p95)`,
210 ).toBeLessThan(400);
211 probe.close(1000);
212 } finally {
213 await s.closeAll();
214 }
215 });
216
217 test("3. offline-reconnect-sync: the outbox drain (setOffline) + the offline banner + reconnect < 3 s", async ({
218 browser,
219 api,
220 }) => {
221 await scenarioSkipIfNoStack();
222 const s = await seedPage(browser, api, 1);
223 try {
224
225
226 await s.a.page.goto(s.url, { waitUntil: "domcontentloaded" });
227 await expect(s.a.page.locator('[data-testid^="block-"]').first(), "A's block").toBeVisible({ timeout: 20_000 });
228
229
230
231
232
233
234 const cut = await cutNetwork(s.a.context);
235
236
237 const banner = s.a.page.getByTestId("offline-banner");
238 await expect(banner, "the offline banner shows while offline").toBeVisible({ timeout: 10_000 });
239
240
241
242 await typeInto(s.a.page, 0, " OFF-EDIT");
243
244 await s.a.page.waitForTimeout(800);
245
246
247
248
249
250 const t0 = Date.now();
251 await cut.restore();
252 await expect
253 .poll(() => s.a.page.getByTestId("offline-banner").count(), { timeout: 10_000 })
254 .toBe(0);
255 await expect
256 .poll(() => chipAria(s.a.page), {
257 timeout: RECONNECT_BUDGET_MS * 4,
258 message: "the chip returns to Live (the socket reconnected < 3 s)",
259 })
260 .toMatch(/^Connection: Live/);
261 const dt = Date.now() - t0;
262 console.log(`[S13-3] offline→restore→drain ${dt} ms (reconnect budget ${RECONNECT_BUDGET_MS} ms)`);
263
264
265
266 await expectAtRestBlock(api, s.page.id, 0, "alpha OFF-EDIT");
267 } finally {
268 await s.closeAll();
269 }
270 });
271
272 test("4. reconnect-resume: the warm update-log path (sync.request {lastSeq} → the missing delta + ack)", async ({
273 browser,
274 api,
275 }) => {
276 await scenarioSkipIfNoStack();
277 const s = await seedPage(browser, api, 1);
278 try {
279
280
281
282 const probe = await rawClient(s.a.token, `page:${s.page.id}`);
283 await probe.nextFrame("hello", 5_000);
284 const doc = new Y.Doc();
285 probe.sendUpdate(doc, "rt-1");
286 const ack1 = await probe.nextFrame("ack", 5_000);
287 const seq1 = ack1.seq as number;
288 expect(seq1, "a real (non-zero) log seq for update 1").toBeGreaterThan(0);
289 probe.sendUpdate(doc, "rt-2");
290 const ack2 = await probe.nextFrame("ack", 5_000);
291 const seq2 = ack2.seq as number;
292 expect(seq2 > seq1, "update 2 advanced the log seq").toBe(true);
293 probe.close(1000);
294 await probe.onClose(5_000);
295
296
297
298
299
300
301 const probe2 = await rawClient(s.a.token, `page:${s.page.id}`);
302 const hello2 = await probe2.nextFrame("hello", 5_000);
303 expect(hello2.connId, "a fresh connection id (reconnect)").toBeTruthy();
304 probe2.sendText({ type: "sync.request", lastSeq: seq1 });
305 const ack3 = await probe2.nextFrame("ack", 5_000);
306 expect(
307 ack3.seq,
308 `the resume acks the last replayed seq (expected ${seq2})`,
309 ).toBe(seq2);
310
311
312 await expect
313 .poll(() => probe2.binaries().length, {
314 timeout: 5_000,
315 message: "the log replayed update 2 as a binary (the warm path)",
316 })
317 .toBeGreaterThanOrEqual(1);
318 probe2.close(1000);
319 } finally {
320 await s.closeAll();
321 }
322 });
323
324 test("5. comments-300ms: the 3-user seam fan-out (a mention comment → the owner's bell)", async ({
325 browser,
326 api,
327 }) => {
328 await scenarioSkipIfNoStack();
329 const s = await seedPage(browser, api, 1);
330 try {
331
332
333
334 await s.owner.page.goto(s.url, { waitUntil: "domcontentloaded" });
335 await expect(s.owner.page.getByTestId("notification-bell"), "the owner's bell").toBeVisible({ timeout: 20_000 });
336
337
338
339
340
341
342
343 const ownerUser = await me(api);
344 const mention = `@user:${ownerUser.id} — ping (S13)`;
345 const t0 = Date.now();
346 const res = await s.a.api.post(`/api/v1/comments`, {
347 data: { targetType: "page", targetId: s.page.id, body: mention },
348 });
349 expect(res.status(), `mention comment: ${await res.text()}`).toBeLessThan(300);
350
351
352 await expect
353 .poll(
354 async () => (await s.owner.page.getByTestId("notification-bell").getAttribute("aria-label")) ?? "",
355 { timeout: COMMENT_BUDGET_MS * 4, message: "the owner's bell increments on the mention" },
356 )
357 .toMatch(/1 unread/);
358 const dt = Date.now() - t0;
359 console.log(`[S13-5] mention→bell ${dt} ms (budget ${COMMENT_BUDGET_MS} ms; 4× superset for CI jitter)`);
360 expect(dt, `mention→bell ${dt} ms (the < 300 ms fan-out, 4× superset)`).toBeLessThan(COMMENT_BUDGET_MS * 4);
361 } finally {
362 await s.closeAll();
363 }
364 });
365
366 test("6. server-restart-recovery: the SIGTERM 4010 drain + the clients auto-reconnect + re-converge", async ({
367 browser,
368 api,
369 }) => {
370 await scenarioSkipIfNoStack();
371 const s = await seedPage(browser, api, 1);
372 try {
373 await s.a.page.goto(s.url, { waitUntil: "domcontentloaded" });
374 await expect(s.a.page.locator('[data-testid^="block-"]').first(), "A's block").toBeVisible({ timeout: 20_000 });
375
376
377 const probe = await rawClient(s.a.token, `page:${s.page.id}`);
378 await probe.nextFrame("hello", 5_000);
379
380
381
382
383 const closePromise = probe.onClose(25_000);
384 await restartRealtimeServer();
385 const closeInfo = await closePromise;
386 expect(
387 closeInfo.code,
388 `the drain close code (R6: 4010 ServerShutdown), got ${closeInfo.code} (${closeInfo.reason})`,
389 ).toBe(4010);
390
391
392
393
394 await s.b.page.goto(s.url, { waitUntil: "domcontentloaded" });
395 await expect(s.b.page.locator('[data-testid^="block-"]').first(), "B's block").toBeVisible({ timeout: 20_000 });
396 await typeInto(s.a.page, 0, "AFTER-RESTART");
397 await expect
398 .poll(() => blockText(s.b.page, 0), { timeout: CONVERGE_MS * 6, message: "B sees A's post-restart edit" })
399 .toContain("AFTER-RESTART");
400 await expectAtRestBlock(api, s.page.id, 0, "alphaAFTER-RESTART");
401 } finally {
402 await s.closeAll();
403 }
404 });
405
406 test("7. a11y-offline: the R3.7 contract (the offline chip + banner; restore → Live)", async ({
407 browser,
408 api,
409 }) => {
410 await scenarioSkipIfNoStack();
411 const s = await seedPage(browser, api, 1);
412 try {
413
414 await s.a.page.goto(s.url, { waitUntil: "domcontentloaded" });
415 await expect(s.a.page.locator('[data-testid^="block-"]').first(), "A's block").toBeVisible({ timeout: 20_000 });
416 const cut = await cutNetwork(s.a.context);
417
418
419
420
421 await expect
422 .poll(() => chipAria(s.a.page), { timeout: 10_000 })
423 .toMatch(/^Connection: Offline, \d+ changes pending$/);
424
425
426
427 const banner = s.a.page.getByTestId("offline-banner");
428 await expect(banner).toBeVisible({ timeout: 10_000 });
429 expect(await banner.getAttribute("role")).toBe("status");
430 expect(await banner.getAttribute("aria-live")).toBe("polite");
431 await expect(banner).toContainText("You're offline — changes are saved on this device");
432
433
434 await cut.restore();
435 await expect
436 .poll(() => s.a.page.getByTestId("offline-banner").count(), { timeout: 10_000 })
437 .toBe(0);
438 await expect
439 .poll(() => chipAria(s.a.page), { timeout: 10_000 })
440 .toMatch(/^Connection: Live/);
441 } finally {
442 await s.closeAll();
443 }
444 });
445});
446