Files
TX/artifacts/tx-os/tests/notes-thread-dialog.spec.mjs
T
riyadhafraa 7a2ae8434d Update project documentation and code comments to remove platform-specific references
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu
Replit-Helium-Checkpoint-Created: true
2026-05-14 06:23:49 +00:00

170 lines
5.9 KiB
JavaScript

// 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();
});