1
125import { test, expect, request as pwRequest } from "@playwright/test";
126import type { APIRequestContext, Browser, Page } from "@playwright/test";
127import {
128 addMemberViaSeam,
129 createPage,
130 createWorkspace,
131 seamServiceToken,
132} from "../utils/api";
133
134const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000";
135const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000";
136
137const COMMENT_VISIBLE_BUDGET_MS = 1000;
138const BELL_PUSH_BUDGET_MS = 2000;
139const API_SETTLE_MS = 5000;
140
141
142const mentionToken = (kind: string, id: string) => `@${kind}:${id}`;
143
144
145const uniqueEmail = (tag: string) =>
146 `e2e-0094-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`;
147
148
149
150
151
152interface ApiUser {
153 email: string;
154 password: string;
155
156 token: string;
157
158 id: string;
159 name: string;
160
161 api: APIRequestContext;
162 close: () => Promise<void>;
163}
164
165
170async function bearerCtx(token: string): Promise<APIRequestContext> {
171 return pwRequest.newContext({
172 baseURL: API_BASE,
173 timeout: 30_000,
174 extraHTTPHeaders: { Authorization: `Bearer ${token}` },
175 });
176}
177
178async function registerAndLogin(
179 name: string,
180): Promise<{ token: string; id: string; email: string; password: string }> {
181 const email = uniqueEmail(name.toLowerCase());
182 const password = `e2e-${Date.now().toString(36)}`;
183
184 const plain = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 });
185 try {
186 const reg = await plain.post("/api/v1/auth/register", { data: { email, password, name } });
187 expect(reg.status(), `register ${email}: ${await reg.text()}`).toBe(201);
188 const lg = await plain.post("/api/v1/auth/login", { data: { email, password } });
189 expect(lg.status(), `login ${email}: ${await lg.text()}`).toBe(200);
190 const body = (await lg.json()) as { accessToken?: string };
191 expect(body.accessToken, `login body for ${email} carries accessToken`).toBeTruthy();
192 const token = body.accessToken!;
193
194
195 const me = await bearerCtx(token);
196 const meRes = await me.get("/api/v1/auth/me");
197 await me.dispose();
198 expect(meRes.status(), `GET /me ${email}: ${await meRes.text()}`).toBe(200);
199 const meBody = (await meRes.json()) as { user: { id: string } };
200 return { token, id: meBody.user.id, email, password };
201 } finally {
202 await plain.dispose().catch(() => {});
203 }
204}
205
206async function newApiUser(name: string): Promise<ApiUser> {
207 const { token, id, email, password } = await registerAndLogin(name);
208 const api = await bearerCtx(token);
209 return {
210 email,
211 password,
212 token,
213 id,
214 name,
215 api,
216 close: () => api.dispose().catch(() => {}),
217 };
218}
219
220
227async function apiSeed() {
228 const owner = await newApiUser("Owner");
229 const alice = await newApiUser("Alice");
230 try {
231 const wsRes = await owner.api.post("/api/v1/workspaces", {
232 data: { name: `Comments ${Date.now()}` },
233 });
234 expect(wsRes.status(), `create workspace: ${await wsRes.text()}`).toBeLessThan(300);
235 const ws = (await wsRes.json()) as { id: string };
236
237 const pgRes = await owner.api.post(`/api/v1/workspaces/${ws.id}/pages`, {
238 data: { title: "Comments page" },
239 });
240 expect(pgRes.status(), `create page: ${await pgRes.text()}`).toBeLessThan(300);
241 const pg = (await pgRes.json()) as { id: string };
242
243 return {
244 owner,
245 alice,
246 wsId: ws.id,
247 pageId: pg.id,
248 closeAll: async () => {
249 await owner.close().catch(() => {});
250 await alice.close().catch(() => {});
251 },
252 };
253 } catch (err) {
254 await owner.close().catch(() => {});
255 await alice.close().catch(() => {});
256 throw err;
257 }
258}
259
260
261async function apiUnreadCount(api: APIRequestContext): Promise<number> {
262 const r = await api.get("/api/v1/notifications/unread-count");
263 expect(r.status(), `unread-count: ${await r.text()}`).toBe(200);
264 return ((await r.json()) as { count: number }).count;
265}
266
267
268async function apiNotificationRows(
269 api: APIRequestContext,
270): Promise<{ type: string; readAt: string | null }[]> {
271 const r = await api.get("/api/v1/notifications");
272 expect(r.status(), `notifications list: ${await r.text()}`).toBe(200);
273 return ((await r.json()) as { data: { type: string; readAt: string | null }[] }).data;
274}
275
276
277
278
279
280
289async function browserTab(
290 browser: Browser,
291 name: string,
292): Promise<{
293 email: string;
294 password: string;
295
296 id: string;
297
298 page: Page;
299
301 api: APIRequestContext;
302 close: () => Promise<void>;
303}> {
304 const email = uniqueEmail(name.toLowerCase());
305 const password = `e2e-${Date.now().toString(36)}`;
306 const plain = await browser.newContext();
307 let token = "";
308 try {
309 const reg = await plain.request.post("/api/v1/auth/register", {
310 data: { email, password, name },
311 });
312 expect(reg.status(), `register ${email}: ${await reg.text()}`).toBe(201);
313 const lg = await plain.request.post("/api/v1/auth/login", {
314 data: { email, password },
315 });
316 expect(lg.status(), `login ${email}: ${await lg.text()}`).toBe(200);
317 token = ((await lg.json()) as { accessToken?: string }).accessToken ?? "";
318 expect(token, `login ${email} carried accessToken`).toBeTruthy();
319 const jar = await plain.cookies();
320 const state = {
321 cookies: jar.map((c) => ({
322 name: c.name,
323 value: c.value,
324 domain: c.domain,
325 path: c.path,
326 expires: c.expires,
327 httpOnly: c.httpOnly,
328 secure: c.secure,
329 sameSite: c.sameSite,
330 })),
331 origins: [],
332 };
333 await plain.close();
334 const context = await browser.newContext({ storageState: state });
335 const page = await context.newPage();
336
337
338 const api = await pwRequest.newContext({
339 baseURL: API_BASE,
340 timeout: 30_000,
341 extraHTTPHeaders: { Authorization: `Bearer ${token}` },
342 });
343 const meRes = await api.get("/api/v1/auth/me");
344 expect(meRes.status(), `GET /me ${email}: ${await meRes.text()}`).toBe(200);
345 const id = ((await meRes.json()) as { user: { id: string } }).user.id;
346 return {
347 email,
348 password,
349 id,
350 page,
351 api,
352 close: async () => {
353 await context.close();
354 await api.dispose().catch(() => {});
355 },
356 };
357 } catch (err) {
358 await plain.close().catch(() => {});
359 throw err;
360 }
361}
362
363
370async function partBSeed(browser: Browser) {
371
372
373
374 const owner = await browserTab(browser, "Owner");
375 const alice = await browserTab(browser, "Alice");
376 const bob = await browserTab(browser, "Bob");
377 try {
378 const ws = await createWorkspace(owner.api, `Comments UI ${Date.now()}`);
379 const pg = await createPage(owner.api, ws.id, { title: "Comments UI page" });
380
381 await addMemberViaSeam(seamServiceToken(), ws.id, alice.email);
382 await addMemberViaSeam(seamServiceToken(), ws.id, bob.email);
383 const pageUrl = `/w/${ws.id}/page/${pg.id}`;
384 const inboxUrl = `/w/${ws.id}/notifications`;
385 return {
386 owner,
387 alice,
388 bob,
389 wsId: ws.id,
390 pageId: pg.id,
391 pageUrl,
392 inboxUrl,
393 closeAll: async () => {
394 await owner.close().catch(() => {});
395 await alice.close().catch(() => {});
396 await bob.close().catch(() => {});
397 },
398 };
399 } catch (err) {
400 await owner.close().catch(() => {});
401 await alice.close().catch(() => {});
402 await bob.close().catch(() => {});
403 throw err;
404 }
405}
406
407
408
409
410
411test.describe("TASK-0094 — comments + notifications (API-only)", () => {
412 test("A0 — the guard is Bearer-only: a header-less request 401s (D1)", async () => {
413
414
415
416
417
418 const plain = await pwRequest.newContext({ baseURL: API_BASE, timeout: 15_000 });
419 try {
420 const res = await plain.get("/api/v1/notifications/unread-count");
421 expect(res.status(), `expected 401 (no bearer), got ${await res.text()}`).toBe(401);
422 } finally {
423 await plain.dispose().catch(() => {});
424 }
425 });
426
427 test("A1 — an @user mention creates EXACTLY ONE unread MENTION row for the recipient", async () => {
428 const s = await apiSeed();
429 try {
430
431
432
433 const res = await s.owner.api.post("/api/v1/comments", {
434 data: {
435 targetType: "page",
436 targetId: s.pageId,
437 body: `${mentionToken("user", s.alice.id)} — ship it`,
438 },
439 });
440 expect(res.status(), `comment create: ${await res.text()}`).toBeLessThan(300);
441
442
443
444
445
446
447 await expect
448 .poll(
449 async () =>
450 (await apiNotificationRows(s.alice.api)).filter(
451 (n) => n.type === "MENTION" && n.readAt === null,
452 ).length,
453 { timeout: API_SETTLE_MS, message: "Alice has exactly one unread MENTION row" },
454 )
455 .toBe(1);
456
457
458 await expect
459 .poll(() => apiUnreadCount(s.alice.api), {
460 timeout: API_SETTLE_MS,
461 message: "Alice's unread count is 1",
462 })
463 .toBe(1);
464 } finally {
465 await s.closeAll();
466 }
467 });
468
469 test("A2 — a user mentioned directly AND via a team gets EXACTLY ONE row (dedup)", async () => {
470
471
472
473
474
475 const owner = await newApiUser("Owner");
476 const alice = await newApiUser("Alice");
477 const bob = await newApiUser("Bob");
478 try {
479 const wsRes = await owner.api.post("/api/v1/workspaces", {
480 data: { name: `Comments A2 ${Date.now()}` },
481 });
482 expect(wsRes.status(), `create workspace: ${await wsRes.text()}`).toBeLessThan(300);
483 const ws = (await wsRes.json()) as { id: string };
484
485
486
487
488 await addMemberViaSeam(seamServiceToken(), ws.id, alice.email);
489 await addMemberViaSeam(seamServiceToken(), ws.id, bob.email);
490
491
492
493
494 const teamRes = await owner.api.post(`/api/v1/workspaces/${ws.id}/teams`, {
495 data: { name: "Engineering" },
496 });
497 expect(teamRes.status(), `create team: ${await teamRes.text()}`).toBeLessThan(300);
498 const team = (await teamRes.json()) as { id: string };
499 for (const member of [alice, bob]) {
500 const addRes = await owner.api.post(`/api/v1/workspaces/${ws.id}/teams/${team.id}/members`, {
501 data: { userId: member.id },
502 });
503 expect(addRes.status(), `team add ${member.name}: ${await addRes.text()}`).toBeLessThan(300);
504 }
505
506 const pgRes = await owner.api.post(`/api/v1/workspaces/${ws.id}/pages`, {
507 data: { title: "Comments A2 page" },
508 });
509 expect(pgRes.status(), `create page: ${await pgRes.text()}`).toBeLessThan(300);
510 const pg = (await pgRes.json()) as { id: string };
511
512
513
514
515
516
517 const res = await owner.api.post("/api/v1/comments", {
518 data: {
519 targetType: "page",
520 targetId: pg.id,
521 body: `${mentionToken("user", alice.id)} ${mentionToken("team", team.id)} — review`,
522 },
523 });
524 expect(res.status(), `comment create: ${await res.text()}`).toBeLessThan(300);
525
526
527 await expect
528 .poll(
529 async () =>
530 (await apiNotificationRows(alice.api)).filter(
531 (n) => n.type === "MENTION" && n.readAt === null,
532 ).length,
533 { timeout: API_SETTLE_MS, message: "Alice has exactly one unread MENTION row (dedup)" },
534 )
535 .toBe(1);
536
537
538 await expect
539 .poll(
540 async () =>
541 (await apiNotificationRows(bob.api)).filter(
542 (n) => n.type === "MENTION" && n.readAt === null,
543 ).length,
544 { timeout: API_SETTLE_MS, message: "Bob has exactly one unread MENTION row (team)" },
545 )
546 .toBe(1);
547 } finally {
548 await owner.close().catch(() => {});
549 await alice.close().catch(() => {});
550 await bob.close().catch(() => {});
551 }
552 });
553});
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574test.describe("TASK-0094 — comments + notifications (browser UI)", () => {
575
576
577 async function openCommentsPanel(page: Page) {
578 await expect(page.getByTestId("sidebar"), "the shell should authenticate + render").toBeVisible({
579 timeout: 20_000,
580 });
581 await page.getByTestId("comment-button").click();
582 await expect(page.getByTestId("comment-panel")).toBeVisible();
583 }
584
585 test("B1 — B's comment appears in A's open panel within the budget (300 ms)", async ({
586 browser,
587 }) => {
588
589
590
591
592
593
594 const s = await partBSeed(browser);
595 try {
596 await s.owner.page.goto(s.pageUrl);
597 await s.alice.page.goto(s.pageUrl);
598 await openCommentsPanel(s.owner.page);
599 await openCommentsPanel(s.alice.page);
600
601
602 await expect(s.owner.page.getByTestId("comment-panel")).toContainText("No comments yet");
603
604
605
606 await s.alice.page.getByTestId("comment-composer").click();
607 await s.alice.page.keyboard.type("hello from alice");
608
609
610
611
612 const t0 = Date.now();
613 await s.alice.page.getByTestId("comment-submit").click();
614 await expect
615 .poll(
616 () => s.owner.page.getByTestId("comment-panel").innerText(),
617 { timeout: COMMENT_VISIBLE_BUDGET_MS, message: "owner's panel shows Alice's comment" },
618 )
619 .toContain("hello from alice");
620 const delta = Date.now() - t0;
621 expect(
622 delta,
623 `comment fan-out delta ${delta} ms (DoD metric is 300 ms; this is a 2× flake-superset)`,
624 ).toBeLessThanOrEqual(COMMENT_VISIBLE_BUDGET_MS);
625 } finally {
626 await s.closeAll();
627 }
628 });
629
630 test("B2 — an @user mention bumps the recipient's bell < 1 s + fires the toast", async ({
631 browser,
632 }) => {
633
634
635
636
637
638 const s = await partBSeed(browser);
639 try {
640 await s.owner.page.goto(s.pageUrl);
641 await s.alice.page.goto(s.pageUrl);
642 await openCommentsPanel(s.owner.page);
643
644 await expect(s.alice.page.getByTestId("notification-bell")).toHaveAttribute(
645 "aria-label",
646 "Notifications",
647 { timeout: 15_000 },
648 );
649
650 const t0 = Date.now();
651
652
653 await s.owner.page.getByTestId("comment-composer").click();
654 await s.owner.page.keyboard.type(`@user:${s.alice.id} — review this`);
655 await s.owner.page.getByTestId("comment-submit").click();
656
657
658 await expect
659 .poll(
660 () => s.alice.page.getByTestId("notification-bell").getAttribute("aria-label"),
661 { timeout: BELL_PUSH_BUDGET_MS, message: "Alice's bell shows 1 unread" },
662 )
663 .toBe("1 unread notification");
664 const delta = Date.now() - t0;
665 expect(
666 delta,
667 `bell push delta ${delta} ms (DoD metric is 1 s; this is a 2× flake-superset)`,
668 ).toBeLessThanOrEqual(BELL_PUSH_BUDGET_MS);
669
670
671
672 await expect(
673 s.alice.page.getByRole("status").filter({ hasText: /mentioned in a comment/i }).first(),
674 ).toBeVisible({ timeout: BELL_PUSH_BUDGET_MS });
675
676
677 await expect
678 .poll(
679 async () =>
680 (await apiNotificationRows(s.alice.api)).filter(
681 (n) => n.type === "MENTION" && n.readAt === null,
682 ).length,
683 { timeout: API_SETTLE_MS, message: "API agrees: one unread MENTION row" },
684 )
685 .toBe(1);
686 } finally {
687 await s.closeAll();
688 }
689 });
690
691 test("B3 — bell count tracks the API; row open navigates + marks read; mark-all → 0", async ({
692 browser,
693 }) => {
694
695
696
697
698 const s = await partBSeed(browser);
699 try {
700
701 for (let i = 0; i < 3; i += 1) {
702 const res = await s.owner.api.post("/api/v1/comments", {
703 data: {
704 targetType: "page",
705 targetId: s.pageId,
706 body: `${mentionToken("user", s.alice.id)} — item ${i + 1}`,
707 },
708 });
709 expect(res.status(), `comment ${i + 1}: ${await res.text()}`).toBeLessThan(300);
710 }
711 await expect
712 .poll(
713 async () =>
714 (await apiNotificationRows(s.alice.api)).filter(
715 (n) => n.type === "MENTION" && n.readAt === null,
716 ).length,
717 { timeout: API_SETTLE_MS, message: "API: three unread MENTION rows" },
718 )
719 .toBe(3);
720
721
722 await s.alice.page.goto(s.inboxUrl);
723 await expect(s.alice.page.getByTestId("notification-bell"), "shell authenticated").toBeVisible({
724 timeout: 20_000,
725 });
726 await expect(s.alice.page.getByTestId("notification-bell")).toHaveAttribute(
727 "aria-label",
728 "3 unread notifications",
729 { timeout: API_SETTLE_MS },
730 );
731 await expect(s.alice.page.getByText("Unread (3)")).toBeVisible();
732
733
734
735 const firstRow = s.alice.page.getByTestId(/^notification-row-/).first();
736 await firstRow.getByRole("button", { name: "Open" }).click();
737 await expect(s.alice.page).toHaveURL(/\/w\/[^/]+\/page\
738 await expect
739 .poll(() => apiUnreadCount(s.alice.api), {
740 timeout: API_SETTLE_MS,
741 message: "API: unread 3 → 2 after opening a row",
742 })
743 .toBe(2);
744
745
746 await s.alice.page.goto(s.inboxUrl);
747 await expect(s.alice.page.getByTestId("mark-all-read")).toBeVisible({ timeout: 15_000 });
748 await s.alice.page.getByTestId("mark-all-read").click();
749 await expect.poll(() => apiUnreadCount(s.alice.api), {
750 timeout: API_SETTLE_MS,
751 message: "API: unread 0 after mark-all-read",
752 }).toBe(0);
753 await expect(s.alice.page.getByTestId("notification-bell")).toHaveAttribute(
754 "aria-label",
755 "Notifications",
756 { timeout: API_SETTLE_MS },
757 );
758 } finally {
759 await s.closeAll();
760 }
761 });
762
763 test("B4 — a11y: the mention popup is a listbox with singular option names", async ({
764 browser,
765 }) => {
766
767
768
769
770
771
772 const s = await partBSeed(browser);
773 try {
774 await s.owner.page.goto(s.pageUrl);
775 await openCommentsPanel(s.owner.page);
776
777
778 await s.owner.page.getByTestId("comment-composer").click();
779 await s.owner.page.keyboard.type("@");
780 const listbox = s.owner.page.getByTestId("mention-listbox");
781 await expect(listbox).toBeVisible({ timeout: 15_000 });
782 await expect(listbox).toHaveAttribute("role", "listbox");
783
784
785
786 const peopleGroup = listbox.getByRole("group", { name: /people/i }).first();
787 await expect(peopleGroup).toBeVisible();
788 const firstMemberOption = peopleGroup.getByRole("option").first();
789 await expect(firstMemberOption).toBeVisible();
790 await expect(firstMemberOption).toHaveAttribute("aria-label", /\(Member\)$/);
791
792 await expect(
793 peopleGroup.locator('[data-testid^="mention-option-user-"]'),
794 ).not.toHaveCount(0);
795
796
797
798 expect(await peopleGroup.getByRole("option").count(), "People group lists members").toBeGreaterThanOrEqual(2);
799
800
801 await s.owner.page.keyboard.press("ArrowDown");
802 const selectedCount = await listbox.locator('[aria-selected="true"]').count();
803 expect(selectedCount, "exactly one option highlighted after ArrowDown").toBe(1);
804
805
806 await s.owner.page.keyboard.press("Escape");
807 await expect(listbox).toHaveCount(0);
808 } finally {
809 await s.closeAll();
810 }
811 });
812});
813