d0e6912017
Update project name from teaboy-os to tx-os and remove obsolete services. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 3154f23a-748a-4118-aa41-fc01b7b1f04d Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/PVuelRZ Replit-Helium-Checkpoint-Created: true
221 lines
8.4 KiB
JavaScript
221 lines
8.4 KiB
JavaScript
// 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);
|
|
});
|
|
});
|