// Regression for the per-card action row visibility on touch devices. // On phones / iPad there is no hover, so the row that holds Send / // archive / delete must be visible without any gesture. This test runs // in a touch-emulating context, creates a note, and asserts the Send // button is visible immediately — no hover required. 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(), ]); } // Emulate a touch device on the default (chromium) browser. We avoid // devices["iPad …"] because that pins the browser to webkit, which // isn't installed in this environment. Chromium honours hasTouch + // no mouse so `(hover: none)` matches. test.use({ hasTouch: true, isMobile: true, viewport: { width: 820, height: 1180 }, }); test.describe("note card actions on touch devices", () => { test("Send button is visible without hovering on touch devices", async ({ page, }) => { const user = await createUser("notes_touch_actions", "Touch Tester"); await loginViaUi(page, user.username); await page.goto("/notes"); await expect(page.getByTestId("notes-page")).toBeVisible(); // Sanity: the touch CSS variant is what we depend on. If the test // browser doesn't actually report `(hover: none)`, the assertion // below would still hold for the wrong reason (no hover applied), // so guard it. const noHover = await page.evaluate( () => window.matchMedia("(hover: none)").matches, ); expect(noHover).toBe(true); const content = `touch-test ${uniq()}`; 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, ); // Tap outside the composer to trigger auto-save. await page.touchscreen.tap(10, 10); const note = await (await savePromise).json(); const card = page.getByTestId(`note-card-${note.id}`); await expect(card).toBeVisible(); // The Send button must be visible without any hover gesture. const sendBtn = page.getByTestId(`note-card-send-${note.id}`); await expect(sendBtn).toBeVisible(); // And it must actually be clickable (not occluded / opacity-0). const opacity = await sendBtn.evaluate( (el) => getComputedStyle(el.parentElement).opacity, ); expect(Number(opacity)).toBe(1); }); });