7a2ae8434d
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
170 lines
5.9 KiB
JavaScript
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();
|
|
});
|