// E2E test for the user-defined Notes Folders feature: // - Create a folder from the rail // - Drag a note from "Unfiled" into the new folder // - Filter by selected folder shows only notes in that folder // - Drag the note from one folder into another // - Rename + delete a folder; deleting moves its notes back to Unfiled // - Verifies bilingual rendering: re-runs the rail title check in Arabic // so the RTL label is exercised end-to-end. 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, 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 note_folders 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(), ]); } // Drive a real pointer drag so @dnd-kit's PointerSensor activates. The // sensor's activation constraint is `distance: 8`, so we first nudge the // pointer past the threshold from the source, then move to the target in // steps (so over-targets are detected on the way), then release. async function dragNoteToFolderTarget(page, noteId, dropTargetTestId) { const src = page.getByTestId(`note-card-${noteId}`).first(); const dst = page.getByTestId(dropTargetTestId).first(); // Scroll the destination first; source typically remains in view (notes // grid sits beside or below the folder area). Then measure BOTH boxes // after scrolling, otherwise the source coordinates can be stale. await dst.scrollIntoViewIfNeeded(); await src.scrollIntoViewIfNeeded(); await dst.scrollIntoViewIfNeeded(); const sBox = await src.boundingBox(); const dBox = await dst.boundingBox(); if (!sBox) throw new Error(`source note-card-${noteId} has no bounding box`); if (!dBox) throw new Error(`target ${dropTargetTestId} has no bounding box`); const sx = sBox.x + sBox.width / 2; const sy = sBox.y + sBox.height / 2; const dx = dBox.x + dBox.width / 2; const dy = dBox.y + dBox.height / 2; await page.mouse.move(sx, sy); await page.mouse.down(); // First nudge — must exceed PointerSensor's 8px activation distance. await page.mouse.move(sx + 12, sy + 12, { steps: 5 }); // Then walk to the destination so dnd-kit tracks the over-target. await page.mouse.move(dx, dy, { steps: 10 }); // Hold briefly so dnd-kit's collision detection settles before drop. await page.waitForTimeout(50); await page.mouse.up(); } async function createNoteViaApi(page, title) { const resp = await page.request.post("/api/notes", { data: { title, content: "body of " + title }, }); expect(resp.ok()).toBeTruthy(); const note = await resp.json(); createdNoteIds.push(note.id); return note; } test("create folder, drag note in, filter, move between folders, rename, delete", async ({ page, }) => { const user = await createUser("notes_folders_e2e", "Folder Tester"); await loginViaUi(page, user.username); // Seed two notes via the API so the test focuses on folder UX. const note1 = await createNoteViaApi(page, `Folder Note 1 ${uniq()}`); const note2 = await createNoteViaApi(page, `Folder Note 2 ${uniq()}`); await page.goto("/notes"); await expect(page.getByTestId("notes-page")).toBeVisible(); // Folders rail is visible on the My Notes tab. const rail = page.getByTestId("notes-folders-rail"); await expect(rail).toBeVisible(); await expect(page.getByTestId("folder-drop-all")).toBeVisible(); await expect(page.getByTestId("folder-drop-unfiled")).toBeVisible(); // Both new notes should be unfiled. await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^2$/); // Create folder "Projects". await page.getByTestId("folder-create-open").click(); await page.getByTestId("folder-create-input").fill("Projects"); const createProjectsResp = page.waitForResponse( (r) => new URL(r.url()).pathname === "/api/note-folders" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, ); await page.getByTestId("folder-create-submit").click(); const projectsResp = await createProjectsResp; const projects = await projectsResp.json(); // Create folder "Personal". await page.getByTestId("folder-create-open").click(); await page.getByTestId("folder-create-input").fill("Personal"); const createPersonalResp = page.waitForResponse( (r) => new URL(r.url()).pathname === "/api/note-folders" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, ); await page.getByTestId("folder-create-submit").click(); const personalResp = await createPersonalResp; const personal = await personalResp.json(); // Both folders show up with count 0. await expect(page.getByTestId(`folder-select-${projects.id}`)).toBeVisible(); await expect(page.getByTestId(`folder-select-${personal.id}`)).toBeVisible(); await expect( page.getByTestId(`folder-drop-${projects.id}-count`), ).toHaveText(/^0$/); // Newly created folder is auto-selected — Personal was the most recent // create, so its row should be the active selection (button has the // selected styling: bg-foreground text-background). await expect( page.getByTestId(`folder-drop-${personal.id}`), ).toHaveClass(/bg-foreground/); // Reset selection to "All" so the rest of the flow filters correctly. await page.getByTestId("folder-drop-all").click(); // ---- Drag note1 into Projects ---- const movePromise1 = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${note1.id}` && r.request().method() === "PATCH" && r.status() >= 200 && r.status() < 300, ); await dragNoteToFolderTarget(page, note1.id, `folder-drop-${projects.id}`); await movePromise1; // Counts update. await expect( page.getByTestId(`folder-drop-${projects.id}-count`), ).toHaveText(/^1$/); await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/); // ---- Persistence after a full page reload ---- await page.reload(); await expect(page.getByTestId("notes-page")).toBeVisible(); await expect( page.getByTestId(`folder-drop-${projects.id}-count`), ).toHaveText(/^1$/); await page.getByTestId(`folder-select-${projects.id}`).click(); await expect(page.getByTestId(`note-card-${note1.id}`)).toBeVisible(); await expect(page.getByTestId(`note-card-${note2.id}`)).toHaveCount(0); await page.getByTestId("folder-drop-all").click(); // ---- Filter by Projects: only note1 visible ---- await page.getByTestId(`folder-select-${projects.id}`).click(); await expect(page.getByTestId(`note-card-${note1.id}`)).toBeVisible(); await expect(page.getByTestId(`note-card-${note2.id}`)).toHaveCount(0); // ---- Filter Unfiled: only note2 visible ---- await page.getByTestId("folder-drop-unfiled").click(); await expect(page.getByTestId(`note-card-${note2.id}`)).toBeVisible(); await expect(page.getByTestId(`note-card-${note1.id}`)).toHaveCount(0); // ---- Drag note2 into Personal, then move it from Personal to Projects ---- await page.getByTestId("folder-drop-all").click(); const movePromise2 = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${note2.id}` && r.request().method() === "PATCH" && r.status() >= 200 && r.status() < 300, ); await dragNoteToFolderTarget(page, note2.id, `folder-drop-${personal.id}`); await movePromise2; await expect( page.getByTestId(`folder-drop-${personal.id}-count`), ).toHaveText(/^1$/); const movePromise3 = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${note2.id}` && r.request().method() === "PATCH" && r.status() >= 200 && r.status() < 300, ); await dragNoteToFolderTarget(page, note2.id, `folder-drop-${projects.id}`); await movePromise3; await expect( page.getByTestId(`folder-drop-${projects.id}-count`), ).toHaveText(/^2$/); await expect( page.getByTestId(`folder-drop-${personal.id}-count`), ).toHaveText(/^0$/); // ---- Rename Projects -> "Work" ---- await page.getByTestId(`folder-menu-${projects.id}`).click(); await page.getByTestId(`folder-rename-${projects.id}`).click(); const renameInput = page.getByTestId(`folder-rename-input-${projects.id}`); await renameInput.fill("Work"); const renamePromise = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/note-folders/${projects.id}` && r.request().method() === "PATCH" && r.status() >= 200 && r.status() < 300, ); await page.getByTestId(`folder-rename-save-${projects.id}`).click(); await renamePromise; await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText( "Work", ); // ---- Move note1 INTO Personal so the folder is non-empty, then delete it // and verify the note returns to Unfiled (FK ON DELETE SET NULL). ---- const movePromiseToPersonal = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${note1.id}` && r.request().method() === "PATCH" && r.status() >= 200 && r.status() < 300, ); await dragNoteToFolderTarget(page, note1.id, `folder-drop-${personal.id}`); await movePromiseToPersonal; await expect( page.getByTestId(`folder-drop-${personal.id}-count`), ).toHaveText(/^1$/); const deletePromise = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/note-folders/${personal.id}` && r.request().method() === "DELETE" && r.status() >= 200 && r.status() < 300, ); await page.getByTestId(`folder-menu-${personal.id}`).click(); await page.getByTestId(`folder-delete-${personal.id}`).click(); // In-app AlertDialog (replacing native window.confirm) — click confirm. await page.getByTestId(`folder-delete-confirm-${personal.id}`).click(); await deletePromise; await expect( page.getByTestId(`folder-delete-dialog-${personal.id}`), ).toHaveCount(0); await expect(page.getByTestId(`folder-select-${personal.id}`)).toHaveCount(0); // The orphaned note should have folderId reset to null via FK SET NULL, // so it should now appear under "Unfiled". const allNotes = await (await page.request.get(`/api/notes`)).json(); const note1AfterDelete = allNotes.find((n) => n.id === note1.id); expect(note1AfterDelete).toBeDefined(); expect(note1AfterDelete.folderId).toBeNull(); await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/); // ---- Cross-user safety: another user's folder cannot be assigned. ---- const otherUser = await createUser("notes_folders_other", "Other User"); const { rows: otherFolderRows } = await pool.query( `INSERT INTO note_folders (user_id, name) VALUES ($1, $2) RETURNING id`, [otherUser.id, `OtherFolder_${uniq()}`], ); const otherFolderId = otherFolderRows[0].id; const crossResp = await page.request.patch(`/api/notes/${note2.id}`, { data: { folderId: otherFolderId }, }); expect(crossResp.status()).toBe(400); await pool.query(`DELETE FROM note_folders WHERE id = $1`, [otherFolderId]); await page.getByTestId("folder-drop-all").click(); // ---- Bilingual smoke check: flip the user to Arabic, reload, and verify // the rail renders RTL with the Arabic copy. ---- await pool.query( `UPDATE users SET preferred_language = 'ar' WHERE id = $1`, [user.id], ); await page.evaluate(() => { localStorage.setItem("tx-lang", "ar"); }); await page.reload(); await expect(page.getByTestId("notes-page")).toHaveAttribute("dir", "rtl"); await expect(page.getByTestId("notes-folders-rail")).toBeVisible(); await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText( "Work", ); await expect(page.getByTestId("folder-drop-unfiled")).toContainText( "بدون مجلد", ); }); // Mobile / touch fallback: long-press on a note card then drag the finger to // a folder row should move the note via the touch drop pipeline (no HTML5 // drag events). Also verifies the rail renders as a horizontal chip row at a // narrow viewport. test("touch long-press drags a note into a folder on small viewport", async ({ browser, }) => { // Narrow viewport with touch capability. We deliberately leave `isMobile` // false so Playwright's `page.mouse` API still fires pointer events that // @dnd-kit's PointerSensor can pick up — the same code path an iPad would // hit (PointerSensor handles touch-derived pointer events identically to // mouse-derived ones; the TouchSensor is just a long-press fallback). const context = await browser.newContext({ viewport: { width: 414, height: 800 }, hasTouch: true, }); const page = await context.newPage(); const user = await createUser("notes_folders_touch", "Touch Tester"); await loginViaUi(page, user.username); const note = await createNoteViaApi(page, `Touch Note ${uniq()}`); await page.goto("/notes"); await expect(page.getByTestId("notes-page")).toBeVisible(); const rail = page.getByTestId("notes-folders-rail"); await expect(rail).toBeVisible(); // Folder rail must be horizontally scrollable on mobile (flex layout, not a // fixed-width sidebar). const railLayout = await rail.evaluate((el) => { const cs = getComputedStyle(el); return { display: cs.display, overflowX: cs.overflowX }; }); expect(railLayout.display).toBe("flex"); expect(["auto", "scroll"]).toContain(railLayout.overflowX); // Create a folder via API to keep the test focused on the touch drop path. const fResp = await page.request.post("/api/note-folders", { data: { name: `TouchFolder_${uniq()}` }, }); expect(fResp.ok()).toBeTruthy(); const folder = await fResp.json(); await page.reload(); await expect(page.getByTestId(`folder-drop-${folder.id}`)).toBeVisible(); const movePromise = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${note.id}` && r.request().method() === "PATCH" && r.status() >= 200 && r.status() < 300, ); await dragNoteToFolderTarget(page, note.id, `folder-drop-${folder.id}`); await movePromise; await expect( page.getByTestId(`folder-drop-${folder.id}-count`), ).toHaveText(/^1$/); await context.close(); });