Files
TX/artifacts/tx-os/tests/notes-popup-on-receive.spec.mjs
T
Riyadh 953c516b1f Task #410: floating draggable note popup + reply alert at sender
Convert the incoming-note popup from a centered AlertDialog modal into
a floating, draggable, animated card (no backdrop) and surface the
same card variant at the original sender when the recipient replies.

UI / popup:
- Rewrite IncomingNotePopup as a fixed-positioned card with custom
  pointer-event drag handler, viewport clamping, sessionStorage-
  persisted position keyed per user, RTL-aware default anchor, ESC
  dismissal, scale-in/fade enter animation, and click-through layer
  (pointer-events-none wrapper). Initial framer-motion impl crashed
  in vite (useRef-of-null / Invalid hook call); rewrote without
  framer-motion using plain CSS transitions for stability.
- isDragging tracked in state so the transform transition is reliably
  disabled during drag (per architect review).

Reply variant:
- Generalize incoming-note-queue with PopupPayload discriminated union
  (note | reply); reply dedupe by replyId, note dedupe by noteId;
  applyDismiss accepts number | {replyId}.
- Floating card switches heading + actions for reply variant: shows
  replier name, reply text, and openThread / replyBack actions; the
  recipient-only mark-read action is suppressed (owner can't mark own
  outbound note read).

Sound + socket:
- New note_replied client handler enqueues the reply payload and plays
  notificationSoundNote + vibrationEnabledNote (gated by
  notificationsMuted + notifyNotesEnabled — no new prefs), deduped
  via playedReplyIdsRef.
- Server emit at /notes/:id/reply enriched with replyContent (≤280
  chars), replier (UserSummary), noteTitle, color so the client can
  render without an extra round-trip.

i18n + tests:
- Add en/ar keys: replyHeading, replyHeadingNoSender, replyBack,
  openThread, dragHint.
- Extend notes-popup-on-receive.spec.mjs: first test asserts
  no [role=alertdialog], drag-handle visible, data-popup-kind="note";
  new reply test asserts data-popup-kind="reply", reply text +
  replier name visible, mark-read action absent, chime fires once
  for sender on reply.

Drift from plan:
- Used custom pointer-event drag instead of framer-motion drag — the
  framer-motion impl crashed in vite (React-instance null in dev).
  Same UX (drag from header only, click-through behind card).
- E2E: api-server unit test failures (executive-meetings, pre-existing
  and unrelated) abort the test workflow before playwright runs;
  could not get a clean green run during this session. Tx-os
  typecheck passes; browser console clean; popup interface (testIds,
  data attrs, text) is identical to the previously-working
  framer-motion run, so no functional regression expected.
2026-05-05 21:32:37 +00:00

378 lines
14 KiB
JavaScript

// E2E test for Task #406: 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 noteTitle = `Popup Note ${uniq()}`;
const noteContent = "Drop everything and read this.";
await senderPage.getByTestId("notes-composer-open").click();
await senderPage.getByTestId("notes-composer-title").fill(noteTitle);
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(noteTitle);
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-dismiss")
.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();
}
});
// Task #410: 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 noteTitle = `Reply Note ${uniq()}`;
const noteContent = "Please reply with your thoughts.";
await senderPage.getByTestId("notes-composer-open").click();
await senderPage.getByTestId("notes-composer-title").fill(noteTitle);
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}/replies` &&
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 uses the "open thread" + "reply back" affordances —
// the recipient-only "mark read" action MUST be absent.
await expect(
senderPage.getByTestId("incoming-note-popup-mark-read"),
).toHaveCount(0);
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);
// Dismiss closes the floating card.
await senderPage.getByTestId("incoming-note-popup-dismiss").click();
await expect(popup).toBeHidden();
} finally {
await senderCtx.close();
await recipientCtx.close();
}
});