1
43import { test, expect } from "../fixtures";
44import { DEMO_EMAIL, DEMO_PASSWORD } from "../fixtures";
45import {
46 addMemberViaSeam,
47 register,
48 login,
49 createWorkspace,
50 createPage,
51 createBlock,
52 getBlocks,
53 seamServiceToken,
54} from "../utils/api";
55import type { APIRequestContext, Browser, Page } from "@playwright/test";
56import type { AuthState } from "../fixtures";
57
58const PRESENCE_BUDGET_MS = 1_000;
59const LEAVE_BUDGET_MS = 1_500;
60const DEMO_NAME = "Demo User";
61
62const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000";
63
64
66async function newUser(
67 browser: Browser,
68 name: string,
69): Promise<{
70 user: { email: string; password: string };
71 page: Page;
72 close: () => Promise<void>;
73 api: APIRequestContext;
74}> {
75 const email = `e2e-pres-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`;
76 const password = `e2e-${Date.now().toString(36)}`;
77 const plain = await browser.newContext();
78 const plainApi = plain.request;
79 await register(plainApi, email, password, name);
80 const { accessToken } = await login(plainApi, email, password);
81 const jar = await plain.cookies();
82 const state: AuthState = {
83 cookies: jar.map((c) => ({
84 name: c.name,
85 value: c.value,
86 domain: c.domain,
87 path: c.path,
88 expires: c.expires,
89 httpOnly: c.httpOnly,
90 secure: c.secure,
91 sameSite: c.sameSite,
92 })),
93 origins: accessToken
94 ? [{ origin: WEB_BASE, localStorage: [{ name: "factory.accessToken", value: accessToken }] }]
95 : [],
96 };
97 await plain.close();
98 const context = await browser.newContext({ storageState: state });
99 const page = await context.newPage();
100 return {
101 user: { email, password },
102 page,
103 api: context.request,
104 close: () => context.close(),
105 };
106}
107
108
110async function ownerTab(browser: Browser) {
111 const plain = await browser.newContext();
112 const plainApi = plain.request;
113 await login(plainApi, DEMO_EMAIL, DEMO_PASSWORD);
114 const jar = await plain.cookies();
115 const state: AuthState = {
116 cookies: jar.map((c) => ({
117 name: c.name,
118 value: c.value,
119 domain: c.domain,
120 path: c.path,
121 expires: c.expires,
122 httpOnly: c.httpOnly,
123 secure: c.secure,
124 sameSite: c.sameSite,
125 })),
126 origins: [],
127 };
128 await plain.close();
129 const context = await browser.newContext({ storageState: state });
130 const page = await context.newPage();
131 return { page, close: () => context.close() };
132}
133
134
135async function seed(browser: Browser, api: APIRequestContext) {
136 const ws = await createWorkspace(api, `Presence ${Date.now()}`);
137 const pg = await createPage(api, ws.id, { title: "Presence page" });
138 for (let i = 0; i < 3; i += 1) {
139 await createBlock(api, pg.id, {
140 type: "paragraph",
141 content: { text: ["alpha", "beta", "gamma"][i] },
142 position: `${i + 1}.0`,
143 });
144 }
145 const owner = await ownerTab(browser);
146 const bob = await newUser(browser, "Bob");
147 const carol = await newUser(browser, "Carol");
148
149
150
151 await addMemberViaSeam(seamServiceToken(), ws.id, bob.user.email, "MEMBER");
152 await addMemberViaSeam(seamServiceToken(), ws.id, carol.user.email, "MEMBER");
153 const url = `/w/${ws.id}/page/${pg.id}`;
154 return {
155 wsId: ws.id,
156 pageId: pg.id,
157 url,
158 owner,
159 bob,
160 carol,
161 closeAll: async () => {
162 await owner.close().catch(() => {});
163 await bob.close().catch(() => {});
164 await carol.close().catch(() => {});
165 },
166 };
167}
168
169
171function avatars(page: Page) {
172 return page.locator('[role="img"][aria-label$="is viewing this page"]');
173}
174
175
176function cursorLabel(page: Page, name: string) {
177 return page.locator(`div[aria-hidden="true"] span`, {
178 hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}$`),
179 });
180}
181
182
183
184test.describe("TASK-0093 — 3-client presence + live cursors", () => {
185 test("three clients see the other two's avatars within the budget; a leave updates the bars", async ({
186 browser,
187 api,
188 }) => {
189 const s = await seed(browser, api);
190 try {
191 await s.owner.page.goto(s.url);
192 await s.bob.page.goto(s.url);
193
194
195 const t0 = Date.now();
196 await s.carol.page.goto(s.url);
197 await expect
198 .poll(() => avatars(s.carol.page).count(), {
199 timeout: PRESENCE_BUDGET_MS * 2,
200 message: "the joiner sees the current peer set (re-broadcast)",
201 })
202 .toBe(2);
203 const delta = Date.now() - t0;
204 expect(
205 delta,
206 `join→avatar delta ${delta} ms (DoD metric is 500 ms; this is a 2× flake-superset)`,
207 ).toBeLessThanOrEqual(PRESENCE_BUDGET_MS);
208
209
210
211 for (const p of [s.owner.page, s.bob.page, s.carol.page]) {
212 await expect.poll(() => avatars(p).count(), { timeout: PRESENCE_BUDGET_MS * 2 }).toBe(2);
213 await expect
214 .poll(
215 async () =>
216 p
217 .locator('div[aria-label*="are viewing this page"]')
218 .first()
219 .getAttribute("aria-label"),
220 {
221 timeout: PRESENCE_BUDGET_MS * 2,
222 },
223 )
224 .toContain("2");
225 }
226
227
228
229 await s.carol.close();
230 for (const p of [s.owner.page, s.bob.page]) {
231 await expect
232 .poll(() => avatars(p).count(), {
233 timeout: LEAVE_BUDGET_MS,
234 message: "the leaver's avatar disappears",
235 })
236 .toBe(1);
237 }
238 } finally {
239 await s.closeAll();
240 }
241 });
242
243 test("cursors: A types → B and C see A's caret + name label; A hidden → cursor gone, avatar stays; A returns → cursor back", async ({
244 browser,
245 api,
246 }) => {
247 const s = await seed(browser, api);
248 try {
249 await s.owner.page.goto(s.url);
250 await s.bob.page.goto(s.url);
251 await s.carol.page.goto(s.url);
252 await expect
253 .poll(() => avatars(s.bob.page).count(), { timeout: PRESENCE_BUDGET_MS * 2 })
254 .toBe(2);
255
256
257
258
259 await s.owner.page
260 .locator("[data-block-id]")
261 .nth(1)
262 .locator('[contenteditable="true"]')
263 .first()
264 .click();
265 await s.owner.page.keyboard.press("End");
266 await s.owner.page.keyboard.type(" hi");
267
268 await expect
269 .poll(
270 async () => {
271 const label = cursorLabel(s.bob.page, DEMO_NAME).first();
272 if ((await label.count()) === 0) return false;
273 const caretBox = await label.boundingBox();
274 const rowBox = await s.bob.page.locator("[data-block-id]").nth(1).boundingBox();
275 if (!caretBox || !rowBox) return false;
276
277
278
279 return (
280 caretBox.x >= rowBox.x - 8 &&
281 caretBox.x <= rowBox.x + rowBox.width + 8 &&
282 caretBox.y >= rowBox.y - 40 &&
283 caretBox.y <= rowBox.y + rowBox.height + 40
284 );
285 },
286 { timeout: PRESENCE_BUDGET_MS * 2, message: "B sees A's caret in block 2" },
287 )
288 .toBeTruthy();
289 await expect
290 .poll(async () => (await cursorLabel(s.carol.page, DEMO_NAME).count()) > 0, {
291 timeout: PRESENCE_BUDGET_MS * 2,
292 message: "C sees A's caret",
293 })
294 .toBeTruthy();
295
296
297
298 await s.owner.page.evaluate(() => {
299 Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
300 document.dispatchEvent(new Event("visibilitychange"));
301 });
302 await expect
303 .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) === 0, {
304 timeout: LEAVE_BUDGET_MS,
305 message: "A's cursor hides when A's tab is hidden",
306 })
307 .toBeTruthy();
308 expect(await avatars(s.bob.page).count(), "A's avatar stays when A's tab is hidden").toBe(2);
309
310
311 await s.owner.page.evaluate(() => {
312 Object.defineProperty(document, "visibilityState", {
313 configurable: true,
314 value: "visible",
315 });
316 document.dispatchEvent(new Event("visibilitychange"));
317 });
318 await expect
319 .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) > 0, {
320 timeout: PRESENCE_BUDGET_MS * 2,
321 message: "A's cursor returns when A's tab is visible",
322 })
323 .toBeTruthy();
324 } finally {
325 await s.closeAll();
326 }
327 });
328
329 test("stale cursor: B deletes A's block → A's cursor is dropped on B (no orphan node)", async ({
330 browser,
331 api,
332 }) => {
333 const s = await seed(browser, api);
334 try {
335 await s.owner.page.goto(s.url);
336 await s.bob.page.goto(s.url);
337 await s.carol.page.goto(s.url);
338 await expect
339 .poll(() => avatars(s.bob.page).count(), { timeout: PRESENCE_BUDGET_MS * 2 })
340 .toBe(2);
341
342
343 await s.owner.page
344 .locator("[data-block-id]")
345 .nth(0)
346 .locator('[contenteditable="true"]')
347 .first()
348 .click();
349 await s.owner.page.keyboard.press("End");
350 await s.owner.page.keyboard.type("x");
351 await expect
352 .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) > 0, {
353 timeout: PRESENCE_BUDGET_MS * 2,
354 message: "B sees A's cursor in block 1",
355 })
356 .toBeTruthy();
357
358
359
360
361
362
363
364 const blocks = await getBlocks(api, s.pageId);
365 const first = blocks[0];
366 expect(first, "block 1 exists before the delete").toBeTruthy();
367 const del = await api.delete(`/api/v1/blocks/${first.id}`);
368 expect(del.status()).toBeLessThan(300);
369 await expect
370 .poll(async () => s.bob.page.locator("[data-block-id]").count(), { timeout: 3_000 })
371 .toBe(2);
372
373
374
375 await expect
376 .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) === 0, {
377 timeout: PRESENCE_BUDGET_MS * 2,
378 message: "A's stale cursor is dropped on B",
379 })
380 .toBeTruthy();
381 expect(await avatars(s.bob.page).count(), "A's avatar stays after the block delete").toBe(2);
382 } finally {
383 await s.closeAll();
384 }
385 });
386
387 test("a11y: the live region announces presence state; the cursor overlay is aria-hidden", async ({
388 browser,
389 api,
390 }) => {
391 const s = await seed(browser, api);
392 try {
393 await s.owner.page.goto(s.url);
394 await s.bob.page.goto(s.url);
395
396 const live = s.bob.page.locator('[role="status"]').first();
397 await expect(live).toBeAttached();
398
399
400
401 const before = await live.innerText().catch(() => "");
402 await s.carol.page.goto(s.url);
403 await expect
404 .poll(async () => (await live.innerText().catch(() => "")).replace(before, "").trim(), {
405 timeout: PRESENCE_BUDGET_MS * 2,
406 message: "the live region gains an announcement on the join",
407 })
408 .toMatch(/joined|editing|left/);
409
410
411
412
413 await s.owner.page
414 .locator("[data-block-id]")
415 .nth(0)
416 .locator('[contenteditable="true"]')
417 .first()
418 .click();
419 await s.owner.page.keyboard.type("a");
420 const overlay = s.bob.page
421 .locator('div[aria-hidden="true"]')
422 .filter({ has: s.bob.page.locator("span") })
423 .last();
424 await expect
425 .poll(async () => (await cursorLabel(s.bob.page, DEMO_NAME).count()) > 0, {
426 timeout: PRESENCE_BUDGET_MS * 2,
427 message: "the cursor renders (visually)",
428 })
429 .toBeTruthy();
430 await expect(overlay).toHaveAttribute("aria-hidden", "true");
431 } finally {
432 await s.closeAll();
433 }
434 });
435
436 test("reduced motion: the cursor caret has transition-duration 0s (instant)", async ({
437 browser,
438 api,
439 }) => {
440 const ws = await createWorkspace(api, `RM ${Date.now()}`);
441 const owner = await ownerTab(browser);
442 const bob = await newUser(browser, "Bob");
443
444 await addMemberViaSeam(seamServiceToken(), ws.id, bob.user.email, "MEMBER");
445 const pg = await createPage(api, ws.id, { title: "RM page" });
446 for (let i = 0; i < 2; i += 1) {
447 await createBlock(api, pg.id, {
448 type: "paragraph",
449 content: { text: ["a", "b"][i] },
450 position: `${i + 1}.0`,
451 });
452 }
453 const url = `/w/${ws.id}/page/${pg.id}`;
454
455 const rm = await browser.newContext({
456 storageState: await bob.page.context().storageState(),
457 reducedMotion: "reduce",
458 });
459 const rmPage = await rm.newPage();
460 try {
461 await owner.page.goto(url);
462 await rmPage.goto(url);
463 await expect.poll(() => avatars(rmPage).count(), { timeout: PRESENCE_BUDGET_MS * 2 }).toBe(1);
464 await owner.page
465 .locator("[data-block-id]")
466 .nth(0)
467 .locator('[contenteditable="true"]')
468 .first()
469 .click();
470 await owner.page.keyboard.type("x");
471 await expect
472 .poll(async () => (await cursorLabel(rmPage, DEMO_NAME).count()) > 0, {
473 timeout: PRESENCE_BUDGET_MS * 2,
474 message: "the cursor renders under reduced motion",
475 })
476 .toBeTruthy();
477
478
479 const dur = await rmPage
480 .locator('div[aria-hidden="true"] span')
481 .first()
482 .evaluate((el) => getComputedStyle(el).transitionDuration);
483 expect(dur, `caret transition-duration under reduced motion (got ${dur})`).toBe("0s");
484 } finally {
485 await owner.close().catch(() => {});
486 await rm.close().catch(() => {});
487 await bob.close().catch(() => {});
488 }
489 });
490});
491