// E2E regression for the styled in-app delete confirmation on notes. // Previously the trash icon called the browser's native confirm() which // shows the raw Repl hostname and clashes with the rest of the app's // design. This test guards the styled AlertDialog: cancelling keeps the // note, confirming deletes it. We deliberately do NOT register a // `dialog` event handler — if a native confirm fires the test will hang // (and fail), proving the native dialog is gone. 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 = []; 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 }; } test.afterAll(async () => { if (createdUserIds.length > 0) { await pool.query(`DELETE FROM notes 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(), ]); } async function createNoteViaComposer(page, content) { await page.getByTestId("notes-composer").click(); await page.getByTestId("notes-composer-content").fill(content); const savePromise = page.waitForResponse( (r) => new URL(r.url()).pathname === "/api/notes" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, ); await page.mouse.click(10, 10); const resp = await savePromise; return resp.json(); } test("delete note uses styled confirmation dialog (cancel keeps it, confirm deletes)", async ({ page, }) => { const user = await createUser("notes_delete_confirm", "Delete Tester"); await loginViaUi(page, user.username); await page.goto("/notes"); await expect(page.getByTestId("notes-page")).toBeVisible(); const title = `delete-test ${uniq()}`; const note = await createNoteViaComposer(page, title); const card = page.getByTestId(`note-card-${note.id}`); await expect(card).toBeVisible(); // Open the styled confirmation. Hover first so the action row appears. await card.hover(); await page.getByTestId(`note-card-delete-${note.id}`).click(); // Styled dialog appears (no native confirm). const dialog = page.getByTestId(`note-delete-dialog-${note.id}`); await expect(dialog).toBeVisible(); // Cancel — note must still be there. await dialog.getByRole("button", { name: "Cancel" }).click(); await expect(dialog).toBeHidden(); await expect(card).toBeVisible(); // Re-open and confirm. await card.hover(); await page.getByTestId(`note-card-delete-${note.id}`).click(); await expect(page.getByTestId(`note-delete-dialog-${note.id}`)).toBeVisible(); const deletePromise = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${note.id}` && r.request().method() === "DELETE" && r.status() >= 200 && r.status() < 300, ); await page.getByTestId(`note-delete-confirm-${note.id}`).click(); await deletePromise; await expect(card).toHaveCount(0); }); test("delete label uses styled confirmation dialog (cancel keeps it, confirm deletes)", async ({ page, }) => { const user = await createUser("notes_label_delete", "Label Tester"); await loginViaUi(page, user.username); await page.goto("/notes"); await expect(page.getByTestId("notes-page")).toBeVisible(); // Open the manage-labels dialog and create a label through the UI so // React Query has the row in its cache by the time we go to delete it. await page.getByTestId("notes-manage-labels-open").click(); const labelName = `lbl-${uniq()}`; const createPromise = page.waitForResponse( (r) => new URL(r.url()).pathname === "/api/note-labels" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, ); await page.getByPlaceholder(/new label|تصنيف جديد/i).fill(labelName); await page.getByPlaceholder(/new label|تصنيف جديد/i).press("Enter"); const label = await (await createPromise).json(); // The trash button uses opacity-0 + group-hover; force the click since // hovering a per-row group on portaled dialogs is flaky in headless. const labelDeleteBtn = page.getByTestId(`label-delete-${label.id}`); await labelDeleteBtn.click({ force: true }); const dialog = page.getByTestId(`label-delete-dialog-${label.id}`); await expect(dialog).toBeVisible(); const deletePromise = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/note-labels/${label.id}` && r.request().method() === "DELETE" && r.status() >= 200 && r.status() < 300, ); await page.getByTestId(`label-delete-confirm-${label.id}`).click(); await deletePromise; await expect(labelDeleteBtn).toHaveCount(0); });