// E2E test: the note thread dialog must cap its height instead of growing // to fill the entire viewport when the conversation is long. Regression // guard for (user reported the dialog covering the whole page). 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) { 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, prefix], ); 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 }; } 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 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("thread dialog does not exceed viewport height when conversation is long", async ({ page, }) => { const VIEWPORT_HEIGHT = 720; await page.setViewportSize({ width: 1280, height: VIEWPORT_HEIGHT }); const owner = await createUser("thread_dialog_owner"); const r1 = await createUser("thread_dialog_recip1"); const r2 = await createUser("thread_dialog_recip2"); await loginViaUi(page, owner.username); // Create the note as the owner. const noteResp = await page.request.post("/api/notes", { data: { title: `Long Thread ${uniq()}`, content: "kickoff message" }, }); expect(noteResp.ok()).toBeTruthy(); const note = await noteResp.json(); createdNoteIds.push(note.id); // Snapshot-deliver to two recipients via SQL (mirrors what /send does). for (const rcp of [r1, r2]) { await pool.query( `INSERT INTO note_recipients (note_id, sender_user_id, recipient_user_id, title, content, color, status) VALUES ($1, $2, $3, $4, $5, 'default', 'replied')`, [note.id, owner.id, rcp.id, note.title, note.content], ); } // Insert a long conversation alternating between owner and each recipient // (~15 replies per recipient → 30 total) so the OWNER thread view renders // many GroupedReplies bubbles. for (const rcp of [r1, r2]) { for (let i = 0; i < 15; i++) { const fromOwner = i % 2 === 0; const sender = fromOwner ? owner.id : rcp.id; const recipient = fromOwner ? rcp.id : owner.id; await pool.query( `INSERT INTO note_replies (note_id, sender_user_id, recipient_user_id, content) VALUES ($1, $2, $3, $4)`, [note.id, sender, recipient, `Reply #${i + 1} in this conversation`], ); } } // Deep-link opens the thread dialog regardless of which tab is active. await page.goto(`/notes?thread=${note.id}`); const dialog = page.getByTestId("note-thread-dialog"); await expect(dialog).toBeVisible(); // Wait for the thread payload to land so the GroupedReplies block renders. await expect(page.getByTestId("note-thread-scroll")).toBeVisible(); await expect( dialog.getByTestId(`thread-conversation-${r1.id}`), ).toBeVisible(); // The dialog must stay capped at ~85vh and never exceed 90vh, regardless // of how many replies it contains. The composer + header should still be // reachable (composer stays pinned outside the scroll region). const box = await dialog.boundingBox(); expect(box).not.toBeNull(); expect(box.height).toBeLessThanOrEqual(VIEWPORT_HEIGHT * 0.9); // The internal scroll container should actually overflow (proving content // is being scrolled, not truncated). const scroller = page.getByTestId("note-thread-scroll"); const scrollInfo = await scroller.evaluate((el) => ({ scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, })); expect(scrollInfo.scrollHeight).toBeGreaterThan(scrollInfo.clientHeight); // Recipient chips and composer must stay pinned while replies scroll — // both should remain in the viewport even after we scroll the replies // region all the way down. const r1Chip = dialog.getByTestId(`thread-recipient-${r1.id}`); const r2Chip = dialog.getByTestId(`thread-recipient-${r2.id}`); const composer = page.getByTestId("thread-reply-input"); await expect(r1Chip).toBeInViewport(); await expect(r2Chip).toBeInViewport(); await expect(composer).toBeInViewport(); await scroller.evaluate((el) => { el.scrollTop = el.scrollHeight; }); await expect(r1Chip).toBeInViewport(); await expect(r2Chip).toBeInViewport(); await expect(composer).toBeInViewport(); });