// E2E test for: when a sender sends a note, the recipient (in // another browser context) sees the note pop up as a centered modal on // their screen — wherever they are in the app — within ~seconds. import { test, expect } from "@playwright/test"; import pg from "pg"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run this UI test"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdUserIds = []; const createdNoteIds = []; function uniq() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } async function createUser(prefix, displayEn) { const username = `${prefix}_${uniq()}`; const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn], ); const id = rows[0].id; createdUserIds.push(id); await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`, [id], ); return { id, username, displayEn }; } test.afterAll(async () => { if (createdNoteIds.length > 0) { await pool.query( `DELETE FROM note_replies WHERE note_id = ANY($1::int[])`, [createdNoteIds], ); await pool.query( `DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`, [createdNoteIds], ); await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [ createdNoteIds, ]); } if (createdUserIds.length > 0) { await pool.query(`DELETE FROM notes WHERE user_id = ANY($1::int[])`, [ createdUserIds, ]); await pool.query( `DELETE FROM notifications WHERE user_id = ANY($1::int[])`, [createdUserIds], ); await pool.query( `DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds], ); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ createdUserIds, ]); } await pool.end(); }); async function loginViaUi(page, username) { await page.goto("/login"); await page.locator("#username").fill(username); await page.locator("#password").fill(TEST_PASSWORD); await Promise.all([ page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000, }), page.locator('form button[type="submit"]').click(), ]); } test("recipient sees a popup modal when a note arrives in real time", async ({ browser, }) => { // Two-context flow with socket warmup + login UI + send confirmation // routinely runs ~25-35s; default 30s leaves no margin. test.setTimeout(90_000); const sender = await createUser("popup_sender", "Popup Sender"); const recipient = await createUser("popup_recipient", "Popup Recipient"); // Two isolated browser contexts (separate cookies → separate sessions). const senderCtx = await browser.newContext(); const recipientCtx = await browser.newContext(); const senderPage = await senderCtx.newPage(); const recipientPage = await recipientCtx.newPage(); try { // Recipient signs in and sits on the home page (NOT /notes), to // prove the popup surfaces wherever they are in the app. await loginViaUi(recipientPage, recipient.username); await recipientPage.goto("/"); // Reset the audio play counter exposed by NotificationPlayer so we can // assert exactly-one chime fires for this incoming note. await recipientPage.evaluate(() => { window.__txosNotifPlayCount = 0; window.__txosNotifLastSound = undefined; }); // Give the socket time to connect + warm up. await recipientPage.waitForTimeout(3500); // Sender composes a note and sends it to the recipient. await loginViaUi(senderPage, sender.username); await senderPage.goto("/notes"); await expect(senderPage.getByTestId("notes-page")).toBeVisible(); const noteContent = `Drop everything and read this. ${uniq()}`; await senderPage.getByTestId("notes-composer-open").click(); await senderPage.getByTestId("notes-composer-content").fill(noteContent); const createPromise = senderPage.waitForResponse( (r) => new URL(r.url()).pathname === "/api/notes" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await senderPage.getByTestId("notes-composer-save").click(); const createResp = await createPromise; const createdNote = await createResp.json(); createdNoteIds.push(createdNote.id); const card = senderPage.getByTestId(`note-card-${createdNote.id}`); await expect(card).toBeVisible(); await card.hover(); await senderPage.getByTestId(`note-card-send-${createdNote.id}`).click(); await expect(senderPage.getByTestId("send-note-dialog")).toBeVisible(); await senderPage .getByTestId("send-note-search") .fill(recipient.displayEn); await senderPage .getByTestId(`send-recipient-option-${recipient.id}`) .click(); const sendPromise = senderPage.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${createdNote.id}/send` && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await senderPage.getByTestId("send-note-submit").click(); await expect(senderPage.getByTestId("send-note-confirm")).toBeVisible(); await senderPage.getByTestId("send-note-confirm-submit").click(); await sendPromise; const sendCompletedAt = Date.now(); // ---- Recipient should now see the floating popup card ---- // Real-time SLA: socket-fanout popup must surface within ~3s of the // send returning. Anything slower defeats the "can't miss it" goal. const popup = recipientPage.getByTestId("incoming-note-popup"); await expect(popup).toBeVisible({ timeout: 3_000 }); expect(Date.now() - sendCompletedAt).toBeLessThan(3_000); await expect(popup).toContainText(noteContent); await expect(popup).toContainText(sender.displayEn); // Floating card — NOT a modal: must not render as an alertdialog // and must not block clicks behind it (no full-viewport overlay). await expect(popup).toHaveAttribute("data-popup-kind", "note"); await expect(recipientPage.locator('[role="alertdialog"]')).toHaveCount(0); // The drag handle is the only pointer-event surface that initiates // a drag; presence is enough to confirm the floating-card affordance. await expect( recipientPage.getByTestId("incoming-note-popup-drag-handle"), ).toBeVisible(); // Sender does NOT see their own popup. await expect(senderPage.getByTestId("incoming-note-popup")).toHaveCount(0); // Attention sound: the per-note chime should have fired exactly once // on the recipient (deduped by noteId, post-warmup) — and the sender // should NOT have heard anything for their own outgoing note. await expect .poll( async () => recipientPage.evaluate(() => window.__txosNotifPlayCount ?? 0), { timeout: 5_000 }, ) .toBeGreaterThanOrEqual(1); const lastSound = await recipientPage.evaluate( () => window.__txosNotifLastSound, ); expect(lastSound).toBe("knock"); const senderPlayCount = await senderPage.evaluate( () => window.__txosNotifPlayCount ?? 0, ); expect(senderPlayCount).toBe(0); // Persistent visual indicator: while the popup is queued, the Notes // app tile pulses (queue-length backed) so the user can't miss it // even after navigating away — covered by `app-icon-pulse-StickyNote` // (Notes' iconName). The pulse element is present on the home grid. // We don't strictly require Notes to be on the visible page (the // recipient sits on /), so we just check at least one pulse exists. await expect( recipientPage.locator('[data-testid^="app-icon-pulse-"]').first(), ).toBeVisible(); // Dismissing acknowledges (closes) the popup. await recipientPage .getByTestId("incoming-note-popup-close") .click(); await expect(popup).toBeHidden(); // After dismissal, queueLength drops to 0 → pulse goes away. await expect( recipientPage.locator('[data-testid^="app-icon-pulse-"]'), ).toHaveCount(0); } finally { await senderCtx.close(); await recipientCtx.close(); } }); // when the recipient replies on a note, the *original sender* // (note owner) should see the same floating-card popup variant — pre- // filled with the reply text + replier name — wherever they are in the // app, with the same chime/vibration profile as the new-note alert. test("sender sees a floating reply card when the recipient replies", async ({ browser, }) => { // Even longer than the new-note test: TWO send flows + reply submit + // socket warmups across two contexts. test.setTimeout(120_000); const sender = await createUser("reply_sender", "Reply Sender"); const recipient = await createUser("reply_recipient", "Reply Recipient"); const senderCtx = await browser.newContext(); const recipientCtx = await browser.newContext(); const senderPage = await senderCtx.newPage(); const recipientPage = await recipientCtx.newPage(); try { // Sender composes + sends the original note. await loginViaUi(senderPage, sender.username); await senderPage.goto("/notes"); await expect(senderPage.getByTestId("notes-page")).toBeVisible(); const noteContent = `Please reply with your thoughts. ${uniq()}`; await senderPage.getByTestId("notes-composer-open").click(); await senderPage.getByTestId("notes-composer-content").fill(noteContent); const createPromise = senderPage.waitForResponse( (r) => new URL(r.url()).pathname === "/api/notes" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await senderPage.getByTestId("notes-composer-save").click(); const createResp = await createPromise; const createdNote = await createResp.json(); createdNoteIds.push(createdNote.id); const card = senderPage.getByTestId(`note-card-${createdNote.id}`); await expect(card).toBeVisible(); await card.hover(); await senderPage.getByTestId(`note-card-send-${createdNote.id}`).click(); await expect(senderPage.getByTestId("send-note-dialog")).toBeVisible(); await senderPage .getByTestId("send-note-search") .fill(recipient.displayEn); await senderPage .getByTestId(`send-recipient-option-${recipient.id}`) .click(); const sendPromise = senderPage.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${createdNote.id}/send` && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await senderPage.getByTestId("send-note-submit").click(); await expect(senderPage.getByTestId("send-note-confirm")).toBeVisible(); await senderPage.getByTestId("send-note-confirm-submit").click(); await sendPromise; // Sender navigates AWAY from /notes to prove the reply popup // surfaces wherever they are — same guarantee as the new-note popup. await senderPage.goto("/"); await senderPage.evaluate(() => { window.__txosNotifPlayCount = 0; window.__txosNotifLastSound = undefined; }); await senderPage.waitForTimeout(3500); // socket warmup window // Recipient signs in, opens the inbox, replies on the thread. await loginViaUi(recipientPage, recipient.username); await recipientPage.goto(`/notes?thread=${createdNote.id}&reply=1`); const replyText = `Reply body ${uniq()}`; const replyInput = recipientPage.getByTestId("thread-reply-input"); await expect(replyInput).toBeVisible({ timeout: 10_000 }); await replyInput.fill(replyText); const replyPromise = recipientPage.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${createdNote.id}/reply` && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await recipientPage.getByTestId("thread-reply-submit").click(); await replyPromise; const replySentAt = Date.now(); // ---- Original sender sees the floating REPLY card ---- const popup = senderPage.getByTestId("incoming-note-popup"); await expect(popup).toBeVisible({ timeout: 5_000 }); expect(Date.now() - replySentAt).toBeLessThan(5_000); await expect(popup).toHaveAttribute("data-popup-kind", "reply"); await expect(popup).toContainText(replyText); await expect(popup).toContainText(recipient.displayEn); // Same floating-card guarantees: no modal backdrop, drag handle present. await expect(senderPage.locator('[role="alertdialog"]')).toHaveCount(0); await expect( senderPage.getByTestId("incoming-note-popup-drag-handle"), ).toBeVisible(); // Reply card now uses the same three-button action row as the // note variant — Done, Open note, Reply — so all three are // present. The header X is also a dismiss path. Done on the reply // variant only dismisses (markRead is a no-op for the note owner). await expect( senderPage.getByTestId("incoming-note-popup-mark-read"), ).toBeVisible(); await expect( senderPage.getByTestId("incoming-note-popup-open"), ).toBeVisible(); await expect( senderPage.getByTestId("incoming-note-popup-reply"), ).toBeVisible(); // Same chime profile as new-note alert: notes channel, "knock" sound, // exactly one play (deduped by replyId). await expect .poll( async () => senderPage.evaluate(() => window.__txosNotifPlayCount ?? 0), { timeout: 5_000 }, ) .toBeGreaterThanOrEqual(1); const lastSound = await senderPage.evaluate( () => window.__txosNotifLastSound, ); expect(lastSound).toBe("knock"); // The replier (recipient) does NOT see their own outgoing reply card. await expect( recipientPage.getByTestId("incoming-note-popup"), ).toHaveCount(0); // Header X closes the floating card. await senderPage.getByTestId("incoming-note-popup-close").click(); await expect(popup).toBeHidden(); } finally { await senderCtx.close(); await recipientCtx.close(); } }); // a checklist note must render the inner sticky-note "as a // real note" — i.e. the popup body shows a read-only checklist preview // (not the plain text body), proving `noteKind` + `items` are forwarded // from the backend through the socket payload into the popup. test("recipient popup renders checklist items for a checklist note", async ({ browser, }) => { test.setTimeout(90_000); const sender = await createUser("popup_cl_sender", "Popup CL Sender"); const recipient = await createUser("popup_cl_recipient", "Popup CL Recipient"); const senderCtx = await browser.newContext(); const recipientCtx = await browser.newContext(); const senderPage = await senderCtx.newPage(); const recipientPage = await recipientCtx.newPage(); try { await loginViaUi(recipientPage, recipient.username); await recipientPage.goto("/"); await recipientPage.waitForTimeout(3500); await loginViaUi(senderPage, sender.username); await senderPage.goto("/notes"); await expect(senderPage.getByTestId("notes-page")).toBeVisible(); await senderPage.getByTestId("notes-composer-open").click(); // Switch composer to checklist mode and add two items. await senderPage.getByTestId("notes-composer-kind-toggle").click(); const list = senderPage.getByTestId("notes-composer-checklist-list"); await expect(list).toBeVisible(); // List starts empty — click "Add item" to insert the first row, // fill it, press Enter to spawn a second row, fill that one too. await senderPage.getByRole("button", { name: "Add item" }).click(); // The ChecklistEditor uses shadcn `Input` (no explicit `type=text`), // so match any non-checkbox input inside the list container. const itemInputs = list.locator("input:not([type=checkbox])"); await expect(itemInputs.first()).toBeVisible(); await itemInputs.first().fill("Buy milk"); await itemInputs.first().press("Enter"); await expect(itemInputs.nth(1)).toBeVisible(); await itemInputs.nth(1).fill("Walk dog"); await itemInputs.nth(1).blur(); const createPromise = senderPage.waitForResponse( (r) => new URL(r.url()).pathname === "/api/notes" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await senderPage.getByTestId("notes-composer-save").click(); const createResp = await createPromise; const createdNote = await createResp.json(); createdNoteIds.push(createdNote.id); const card = senderPage.getByTestId(`note-card-${createdNote.id}`); await expect(card).toBeVisible(); await card.hover(); await senderPage.getByTestId(`note-card-send-${createdNote.id}`).click(); await expect(senderPage.getByTestId("send-note-dialog")).toBeVisible(); await senderPage .getByTestId("send-note-search") .fill(recipient.displayEn); await senderPage .getByTestId(`send-recipient-option-${recipient.id}`) .click(); const sendPromise = senderPage.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${createdNote.id}/send` && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await senderPage.getByTestId("send-note-submit").click(); await expect(senderPage.getByTestId("send-note-confirm")).toBeVisible(); await senderPage.getByTestId("send-note-confirm-submit").click(); await sendPromise; const popup = recipientPage.getByTestId("incoming-note-popup"); await expect(popup).toBeVisible({ timeout: 5_000 }); // Checklist branch: dedicated container is rendered, with each item // as a row, and the plain text content branch is NOT used. const checklist = recipientPage.getByTestId( "incoming-note-popup-checklist", ); await expect(checklist).toBeVisible(); await expect(checklist).toContainText("Buy milk"); await expect(checklist).toContainText("Walk dog"); await recipientPage.getByTestId("incoming-note-popup-close").click(); await expect(popup).toBeHidden(); } finally { await senderCtx.close(); await recipientCtx.close(); } });