// on touch devices (iPad / phone), tapping a button on the // floating note popup MUST work — even when other floating UI is on // screen at the same time. The previous root cause was a full-viewport // `fixed inset-0` wrapper at z-[110] that intermittently swallowed // taps on iOS Safari. This test emulates a touch viewport, sends a // note across two contexts, and verifies a real `touchscreen.tap` on // the Reply button navigates the recipient to the thread. 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 = []; const createdMeetingIds = []; function uniq() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } function todayLocal() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function timePlusMinutes(deltaMins) { const d = new Date(Date.now() + deltaMins * 60_000); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:00`; } async function insertImminentMeeting(titleEn, deltaMins = 3) { const date = todayLocal(); const startTime = timePlusMinutes(deltaMins); const endTime = timePlusMinutes(deltaMins + 30); const { rows: dnRows } = await pool.query( `SELECT COALESCE(MAX(daily_number), 0) + 1 AS n FROM executive_meetings WHERE meeting_date = $1`, [date], ); const dn = dnRows[0].n; const { rows } = await pool.query( `INSERT INTO executive_meetings (daily_number, title_ar, title_en, meeting_date, start_time, end_time, status) VALUES ($1, $2, $3, $4, $5, $6, 'scheduled') RETURNING id`, [dn, `${titleEn} AR`, titleEn, date, startTime, endTime], ); const id = rows[0].id; createdMeetingIds.push(id); return id; } 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 (createdMeetingIds.length > 0) { await pool.query( `DELETE FROM executive_meeting_alert_state WHERE meeting_id = ANY($1::int[])`, [createdMeetingIds], ); await pool.query( `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`, [createdMeetingIds], ); await pool.query( `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [createdMeetingIds], ); await pool.query( `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [createdMeetingIds], ); } 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 on a touch device can tap Reply on the floating note popup", async ({ browser, }) => { test.setTimeout(120_000); const sender = await createUser("popup_touch_sender", "Touch Sender"); const recipient = await createUser("popup_touch_recipient", "Touch Recipient"); // Touch-emulating context for the recipient — chromium honours // hasTouch + isMobile so `(hover: none)` and `pointerType==='touch'` // both report correctly. We avoid devices["iPad …"] because that // pins to webkit, which isn't installed in this environment. const recipientCtx = await browser.newContext({ hasTouch: true, isMobile: true, viewport: { width: 820, height: 1180 }, }); const senderCtx = await browser.newContext(); const recipientPage = await recipientCtx.newPage(); const senderPage = await senderCtx.newPage(); try { await loginViaUi(recipientPage, recipient.username); await recipientPage.goto("/"); // Sanity: the touch-only CSS path is what we depend on. expect( await recipientPage.evaluate( () => window.matchMedia("(hover: none)").matches, ), ).toBe(true); // Wait out the socket warmup window so events fanout cleanly. await recipientPage.waitForTimeout(3500); await loginViaUi(senderPage, sender.username); await senderPage.goto("/notes"); await expect(senderPage.getByTestId("notes-page")).toBeVisible(); const noteContent = `Tap reply on this card. ${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 createdNote = await (await createPromise).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; // ---- Recipient sees the floating popup ---- const popup = recipientPage.getByTestId("incoming-note-popup"); await expect(popup).toBeVisible({ timeout: 5_000 }); // The popup must NOT be a full-viewport overlay. Before the fix // it was wrapped in a `fixed inset-0` div whose child captured // pointer events; after the fix the rendered popup root is // sized to the card itself. We verify the bounding box is well // smaller than the viewport in BOTH dimensions — the regression // would re-introduce a viewport-sized box. const box = await popup.boundingBox(); expect(box).not.toBeNull(); expect(box.width).toBeLessThan(500); // card is ~384 + ring/padding expect(box.height).toBeLessThan(800); // The redundant toast must NOT also surface — when the popup is // shown the toast is suppressed (#427) so we don't pile two // floating layers on top of each other on touch. await expect( recipientPage.locator('[data-state="open"][role="status"]'), ).toHaveCount(0); // ---- The actual regression: a TOUCH tap on Reply must work ---- const replyBtn = recipientPage.getByTestId("incoming-note-popup-reply"); await expect(replyBtn).toBeVisible(); // `locator.tap()` requires `hasTouch: true` and fires a real // touchstart/touchend → synthesized click sequence — the same path // a finger tap takes on iOS Safari, which is what the user // reported hung. await replyBtn.tap(); // Reply navigates to /notes and dismisses the popup. If the tap // were swallowed (the original hang) neither would happen. // (We don't assert the querystring here — wouter v3's `setLocation` // strips it; that's an unrelated pre-existing behaviour outside // the scope of #427.) await recipientPage.waitForURL( (url) => url.pathname === "/notes", { timeout: 5_000 }, ); await expect(popup).toBeHidden(); } finally { await senderCtx.close(); await recipientCtx.close(); } }); // Stacked-layer reproduction (#427): the user reported the hang // specifically when the floating note popup AND the 5-min upcoming // meeting alert are on screen at the same time. This test seeds an // imminent meeting, has admin (the "recipient") log in on a touch // viewport so the meeting alert appears, then triggers a real // cross-user note delivery so the floating popup stacks on top of // the alert. We then tap the popup's Reply button — the same gesture // the user reported as hung — and assert it works. test("popup + meeting alert simultaneously: tap on popup still works", async ({ browser, }) => { test.setTimeout(150_000); const sender = await createUser("popup_stack_sender", "Stack Sender"); const meetingTitle = `STACK_TEST ${uniq()}`; await insertImminentMeeting(meetingTitle, 3); // Admin user (seeded by migrations) sees the upcoming meeting alert // on every page since the alert is global. We use admin as the // touch recipient so we don't need to wire up permissions for a // throwaway user. const recipientCtx = await browser.newContext({ hasTouch: true, isMobile: true, viewport: { width: 820, height: 1180 }, }); const senderCtx = await browser.newContext(); const recipientPage = await recipientCtx.newPage(); const senderPage = await senderCtx.newPage(); try { // Recipient logs in via the existing admin account. await recipientPage.goto("/login"); await recipientPage.locator("#username").fill("admin"); await recipientPage.locator("#password").fill("admin123"); await Promise.all([ recipientPage.waitForURL( (url) => !url.pathname.endsWith("/login"), { timeout: 15_000 }, ), recipientPage.locator('form button[type="submit"]').click(), ]); await recipientPage.goto("/"); // Wait for the meeting alert to surface (poll runs every 30s but // the initial fetch fires on mount). const alert = recipientPage.getByTestId("upcoming-meeting-alert"); await expect(alert).toBeVisible({ timeout: 20_000 }); // Look up admin's userId so the sender can target them. const { rows: adminRows } = await pool.query( `SELECT id FROM users WHERE username = 'admin' LIMIT 1`, ); const adminId = adminRows[0].id; // Sender composes + sends a note to admin. await loginViaUi(senderPage, sender.username); await senderPage.goto("/notes"); await expect(senderPage.getByTestId("notes-page")).toBeVisible(); await senderPage.getByTestId("notes-composer-open").click(); await senderPage .getByTestId("notes-composer-content") .fill(`Tap reply with the meeting alert visible too. ${uniq()}`); 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 createdNote = await (await createPromise).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("admin"); await senderPage .getByTestId(`send-recipient-option-${adminId}`) .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; // ---- Both layers must be visible at the same time ---- const popup = recipientPage.getByTestId("incoming-note-popup"); await expect(popup).toBeVisible({ timeout: 10_000 }); await expect(alert).toBeVisible(); // The actual reproduction: tap Reply on the popup while the // meeting alert is also on screen. Pre-fix this would intermittently // hang on iOS Safari because the popup's `fixed inset-0` wrapper // sat above the alert and swallowed the tap. const replyBtn = recipientPage.getByTestId("incoming-note-popup-reply"); await expect(replyBtn).toBeVisible(); await replyBtn.tap(); await recipientPage.waitForURL( (url) => url.pathname === "/notes", { timeout: 5_000 }, ); await expect(popup).toBeHidden(); // The meeting alert should still be present after dismissing the // popup — they are independent floating layers. await expect(alert).toBeVisible(); } finally { await senderCtx.close(); await recipientCtx.close(); } });