// UI test verifying that the home-page clock widget keeps its grid position // and compact/large size after a full page reload, and that the changes are // persisted to localStorage under the documented keys // `tx:home-clock-position` / `tx:home-clock-size` (or their per-user // variants once a user is logged in). // // This is the recurring automated counterpart to the manual e2e check // described in .local/tasks/task-57.md. It guards against silent regressions // in: // - artifacts/tx-os/src/pages/home.tsx (drag/long-press wiring) // - artifacts/tx-os/src/components/clock.tsx (useHomeClock{Position,Size}) // // Run with: // DATABASE_URL=... pnpm --filter @workspace/tx-os exec playwright test // // The test seeds its own user via the database and cleans up after itself, so // it can run against the live dev environment without polluting it. 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 the home-clock UI test"); } // Same convention as the other UI specs: precomputed bcrypt hash of the // literal password "TestPass123!". const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdUserIds = []; function uniqueSuffix() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } async function createUser(prefix) { const username = `${prefix}_${uniqueSuffix()}`; 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 (createdUserIds.length > 0) { await pool.query( `DELETE FROM user_app_orders 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, password) { await page.goto("/login"); await page.locator("#username").fill(username); await page.locator("#password").fill(password); await Promise.all([ page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000, }), page.locator('form button[type="submit"]').click(), ]); } // The clock tile has aria-label="Clock" on its inner icon; the sortable // wrapper is its parent flex container. We grab the wrapper because that is // the element that owns the dnd-kit pointer listeners. function clockTile(page) { return page.locator('[aria-label="Clock"]').first().locator(".."); } // Read the per-user localStorage values written by useHomeClock{Position,Size}. async function readClockStorage(page, userId) { return page.evaluate((uid) => { return { position: window.localStorage.getItem( `tx:home-clock-position:${uid}`, ), size: window.localStorage.getItem(`tx:home-clock-size:${uid}`), }; }, userId); } // Simulate a real pointer drag that satisfies dnd-kit's PointerSensor // activation distance (8px) before reaching the destination. We move in a few // steps so dnd-kit's collision detection has time to react. async function dragWithMouse(page, fromBox, toBox) { const fromX = fromBox.x + fromBox.width / 2; const fromY = fromBox.y + fromBox.height / 2; const toX = toBox.x + toBox.width / 2; const toY = toBox.y + toBox.height / 2; await page.mouse.move(fromX, fromY); await page.mouse.down(); // Pass the 8px activation threshold before sliding to the target. await page.mouse.move(fromX + 12, fromY + 12, { steps: 4 }); await page.mouse.move(toX, toY, { steps: 12 }); // Hover briefly so SortableContext registers the new index. await page.waitForTimeout(120); await page.mouse.up(); } test.describe("Home clock widget: position + size persistence", () => { test("position and size survive a full reload", async ({ page }) => { const user = await createUser("home_clock_persist"); await loginViaUi(page, user.username, TEST_PASSWORD); // Land on home and wait for the clock + at least one app icon to render. await page.goto("/"); await expect(clockTile(page)).toBeVisible({ timeout: 15_000 }); const appButtons = page.locator( 'button.flex.flex-col.items-center.gap-2.group', ); await expect(appButtons.first()).toBeVisible(); const appCount = await appButtons.count(); expect( appCount, "need at least 2 app tiles to have a distinct drop target for the clock", ).toBeGreaterThanOrEqual(2); // Sanity check: starting position defaults to 0 (no key written yet). const initial = await readClockStorage(page, user.id); expect(initial.position).toBeNull(); expect(initial.size).toBeNull(); // ---- Drag the clock to a later position in the grid ---------------- // Pick a target tile that is meaningfully far from the clock so dnd-kit // is forced to reorder. const targetIndex = Math.min(appCount - 1, 3); const sourceBox = await clockTile(page).boundingBox(); const targetBox = await appButtons.nth(targetIndex).boundingBox(); if (!sourceBox || !targetBox) { throw new Error("Could not measure clock or target tile bounding box"); } await dragWithMouse(page, sourceBox, targetBox); // Wait for the position write to land in localStorage. await expect .poll(async () => (await readClockStorage(page, user.id)).position, { timeout: 5_000, }) .not.toBeNull(); const afterDrag = await readClockStorage(page, user.id); const draggedPosition = Number(afterDrag.position); expect(Number.isFinite(draggedPosition)).toBe(true); expect(draggedPosition).toBeGreaterThan(0); // ---- Long-press the clock to flip it to the large size ------------- const clockBox = await clockTile(page).boundingBox(); if (!clockBox) throw new Error("Could not measure clock tile after drag"); const cx = clockBox.x + clockBox.width / 2; const cy = clockBox.y + clockBox.height / 2; await page.mouse.move(cx, cy); await page.mouse.down(); // The long-press timer fires at 500ms; hold a bit longer to be safe and // do NOT move (movement >8px aborts the long-press in home.tsx). await page.waitForTimeout(750); await page.mouse.up(); await expect .poll(async () => (await readClockStorage(page, user.id)).size, { timeout: 5_000, }) .toBe("large"); // The large clock tile spans 2 columns / 2 rows in the grid. await expect(clockTile(page)).toHaveClass(/col-span-2/); await expect(clockTile(page)).toHaveClass(/row-span-2/); const beforeReload = await readClockStorage(page, user.id); expect(beforeReload.position).toBe(String(draggedPosition)); expect(beforeReload.size).toBe("large"); // ---- Reload and assert both values stuck --------------------------- await page.reload(); await expect(clockTile(page)).toBeVisible({ timeout: 15_000 }); const afterReload = await readClockStorage(page, user.id); expect(afterReload.position).toBe(String(draggedPosition)); expect(afterReload.size).toBe("large"); // The visual large state must also be restored after reload. await expect(clockTile(page)).toHaveClass(/col-span-2/); await expect(clockTile(page)).toHaveClass(/row-span-2/); // The clock's restored index in the combined grid must match the // persisted position — proves the saved value is actually re-applied to // the layout, not just sitting in localStorage. const clockIndexInGrid = await page.evaluate(() => { const clockIcon = document.querySelector('[aria-label="Clock"]'); if (!clockIcon) return -1; const wrapper = clockIcon.parentElement; if (!wrapper || !wrapper.parentElement) return -1; const siblings = Array.from(wrapper.parentElement.children); return siblings.indexOf(wrapper); }); expect(clockIndexInGrid).toBe(draggedPosition); }); });