From 1c6220e710def75bc91fa6177ddac2178e809e46 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Tue, 21 Apr 2026 13:22:37 +0000 Subject: [PATCH] Add e2e test for home clock position and size persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #57: add an automated check that exercises the same flow that was previously only manually verified — drag the home-page clock tile to a new grid slot, long-press to flip it from compact to large, reload, and assert both the position and size survive. Implementation - New Playwright spec at artifacts/teaboy-os/tests/home-clock-persistence.spec.mjs, mirroring the conventions of the existing leave-group-successor.spec.mjs (DB-seeded user, UI login, cleanup in afterAll). - Drives a real pointer drag that satisfies dnd-kit's 8px activation threshold, then a separate long-press (>500ms, no movement) to trigger the size toggle in home.tsx without aborting the timer. - Asserts both per-user localStorage keys are written (teaboy:home-clock-position: and teaboy:home-clock-size:) and that, after page.reload(), the clock tile still has col-span-2 / row-span-2 classes and lives at the persisted index in the grid (not just in storage). Verification - Ran the new spec in isolation: 1 passed. - Ran the full validation workflow (api-server tests + teaboy-os test:e2e): 18 + 3 passed. No production code changes. --- .../tests/home-clock-persistence.spec.mjs | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 artifacts/teaboy-os/tests/home-clock-persistence.spec.mjs diff --git a/artifacts/teaboy-os/tests/home-clock-persistence.spec.mjs b/artifacts/teaboy-os/tests/home-clock-persistence.spec.mjs new file mode 100644 index 00000000..f0941023 --- /dev/null +++ b/artifacts/teaboy-os/tests/home-clock-persistence.spec.mjs @@ -0,0 +1,220 @@ +// 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 +// `teaboy:home-clock-position` / `teaboy: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/teaboy-os/src/pages/home.tsx (drag/long-press wiring) +// - artifacts/teaboy-os/src/components/clock.tsx (useHomeClock{Position,Size}) +// +// Run with: +// DATABASE_URL=... pnpm --filter @workspace/teaboy-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( + `teaboy:home-clock-position:${uid}`, + ), + size: window.localStorage.getItem(`teaboy: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); + }); +});