From 85faee1b48bad5b7ebff7a35c8c0b7c413746e28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 13:37:46 +0000 Subject: [PATCH 01/11] Add Playwright e2e tests for the demo UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install @playwright/test in demo package - playwright.config.ts: loads .env, starts Vite via webServer, wires globalSetup/globalTeardown - e2e/global-setup.ts: starts Postgres (docker compose -d), waits for readiness, seeds deterministic test data, clears the zero-cache replica, then spawns zero-cache-dev and waits for port 4848 - e2e/global-teardown.ts: kills the zero-cache process on exit - e2e/seed-test.ts: 10 fixed items (Alpha–Kappa) with inverted created/modified timestamps so all four sort orderings are distinct - e2e/tests/app.spec.ts: heading, item count, row rendering, default sort - e2e/tests/sort.spec.ts: sort field and direction toggle assertions - e2e/tests/item-detail.spec.ts: panel open/close, URL hash permalink, aria-selected, description and ID display Run with: cd demo && pnpm test:e2e https://claude.ai/code/session_01H3p3JUgo2y2389e3qKBo7N --- demo/e2e/global-setup.ts | 169 +++++++++++++++++++++++++++++ demo/e2e/global-teardown.ts | 17 +++ demo/e2e/seed-test.ts | 137 +++++++++++++++++++++++ demo/e2e/tests/app.spec.ts | 36 ++++++ demo/e2e/tests/item-detail.spec.ts | 94 ++++++++++++++++ demo/e2e/tests/sort.spec.ts | 92 ++++++++++++++++ demo/package.json | 4 +- demo/playwright.config.ts | 57 ++++++++++ pnpm-lock.yaml | 16 ++- 9 files changed, 618 insertions(+), 4 deletions(-) create mode 100644 demo/e2e/global-setup.ts create mode 100644 demo/e2e/global-teardown.ts create mode 100644 demo/e2e/seed-test.ts create mode 100644 demo/e2e/tests/app.spec.ts create mode 100644 demo/e2e/tests/item-detail.spec.ts create mode 100644 demo/e2e/tests/sort.spec.ts create mode 100644 demo/playwright.config.ts diff --git a/demo/e2e/global-setup.ts b/demo/e2e/global-setup.ts new file mode 100644 index 0000000..ae84192 --- /dev/null +++ b/demo/e2e/global-setup.ts @@ -0,0 +1,169 @@ +import {spawn} from 'child_process'; +import {existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'fs'; +import * as net from 'net'; +import {join} from 'path'; +import pg from 'pg'; +import {fileURLToPath} from 'url'; +import {seedTestDb} from './seed-test.ts'; + +const DEMO_DIR = fileURLToPath(new URL('..', import.meta.url)); + +// Replica dir is wiped on each run so zero-cache starts with clean data. +const REPLICA_DIR = '/tmp/zero-playwright-replica'; +export const REPLICA_FILE = join(REPLICA_DIR, 'replica'); + +// PID file lets globalTeardown kill the zero-cache process. +export const PID_FILE = '/tmp/zero-playwright.pid'; + +export default async function globalSetup(): Promise { + console.log('\n[setup] Starting postgres...'); + await startPostgres(); + + console.log('[setup] Waiting for postgres...'); + await waitForPort(5430); + await waitForPostgres(); + + console.log('[setup] Seeding test data...'); + await seedTestDb(process.env['ZERO_UPSTREAM_DB']!); + + console.log('[setup] Clearing zero-cache replica...'); + killExistingZeroCache(); + if (existsSync(REPLICA_DIR)) { + rmSync(REPLICA_DIR, {recursive: true, force: true}); + } + mkdirSync(REPLICA_DIR, {recursive: true}); + + console.log('[setup] Starting zero-cache...'); + const zeroCacheProc = spawnZeroCache(); + writeFileSync(PID_FILE, String(zeroCacheProc.pid)); + + console.log('[setup] Waiting for zero-cache on port 4848...'); + await waitForPort(4848, 60_000); + console.log('[setup] Ready.\n'); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function startPostgres(): Promise { + await new Promise((resolve, reject) => { + const proc = spawn( + 'docker', + [ + 'compose', + '--env-file', + '.env', + '-f', + './docker/docker-compose.yml', + 'up', + '-d', + ], + {cwd: DEMO_DIR, stdio: 'inherit'}, + ); + proc.on('exit', code => { + if (code === 0) resolve(); + else reject(new Error(`docker compose up exited with code ${code}`)); + }); + proc.on('error', reject); + }); +} + +async function waitForPostgres(timeoutMs = 30_000): Promise { + const connStr = process.env['ZERO_UPSTREAM_DB']!; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const pool = new pg.Pool({connectionString: connStr, max: 1}); + const client = await pool.connect(); + client.release(); + await pool.end(); + return; + } catch { + await sleep(500); + } + } + throw new Error('Postgres not ready within timeout'); +} + +function waitForPort(port: number, timeoutMs = 30_000): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + + function tryConnect() { + const socket = new net.Socket(); + socket.setTimeout(1_000); + + socket.on('connect', () => { + socket.destroy(); + resolve(); + }); + + const retry = () => { + socket.destroy(); + if (Date.now() >= deadline) { + reject(new Error(`Port ${port} not available after ${timeoutMs}ms`)); + return; + } + setTimeout(tryConnect, 500); + }; + + socket.on('timeout', retry); + socket.on('error', retry); + socket.connect(port, '127.0.0.1'); + } + + tryConnect(); + }); +} + +function spawnZeroCache() { + const env: NodeJS.ProcessEnv = { + ...process.env, + ZERO_REPLICA_FILE: REPLICA_FILE, + }; + + // Prefer the local bin so we use the exact version pinned in demo/package.json. + const bin = join(DEMO_DIR, 'node_modules', '.bin', 'zero-cache-dev'); + const command = existsSync(bin) ? bin : 'zero-cache-dev'; + + const proc = spawn(command, [], { + cwd: DEMO_DIR, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + + proc.stdout?.on('data', (d: Buffer) => + process.stdout.write(`[zero-cache] ${d}`), + ); + proc.stderr?.on('data', (d: Buffer) => + process.stderr.write(`[zero-cache] ${d}`), + ); + + proc.on('exit', code => { + if (code !== null && code !== 0) { + console.error(`[zero-cache] exited with code ${code}`); + } + }); + + proc.unref(); + return proc; +} + +function killExistingZeroCache(): void { + if (!existsSync(PID_FILE)) return; + const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10); + if (!isNaN(pid)) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Process may already be gone — that's fine. + } + } + rmSync(PID_FILE, {force: true}); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/demo/e2e/global-teardown.ts b/demo/e2e/global-teardown.ts new file mode 100644 index 0000000..e9a70bc --- /dev/null +++ b/demo/e2e/global-teardown.ts @@ -0,0 +1,17 @@ +import {existsSync, readFileSync, rmSync} from 'fs'; +import {PID_FILE} from './global-setup.ts'; + +export default async function globalTeardown(): Promise { + if (!existsSync(PID_FILE)) return; + + const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10); + if (!isNaN(pid)) { + try { + process.kill(pid, 'SIGTERM'); + console.log(`[teardown] Stopped zero-cache (pid ${pid})`); + } catch { + // Already gone — that's fine. + } + } + rmSync(PID_FILE, {force: true}); +} diff --git a/demo/e2e/seed-test.ts b/demo/e2e/seed-test.ts new file mode 100644 index 0000000..9c1757d --- /dev/null +++ b/demo/e2e/seed-test.ts @@ -0,0 +1,137 @@ +import pg from 'pg'; + +// Fixed base timestamp: 2023-11-14T22:13:20.000Z +const BASE = 1_700_000_000_000; +const H = 3_600_000; // 1 hour in ms + +export type TestItem = { + id: string; + title: string; + description: string; + created: number; + modified: number; +}; + +// Items are ordered such that created and modified are inverses of each other: +// +// modified DESC (default): Alpha, Beta, Gamma, ..., Kappa +// modified ASC: Kappa, Iota, Theta, ..., Alpha +// created DESC: Kappa, Iota, Theta, ..., Alpha +// created ASC: Alpha, Beta, Gamma, ..., Kappa +// +// This gives 4 distinct, predictable orderings to test sorting. +export const TEST_ITEMS: TestItem[] = [ + { + id: 'tstitem001', + title: 'Alpha Item', + description: 'Alpha test item description.', + created: BASE + 1 * H, + modified: BASE + 10 * H, + }, + { + id: 'tstitem002', + title: 'Beta Item', + description: 'Beta test item description.', + created: BASE + 2 * H, + modified: BASE + 9 * H, + }, + { + id: 'tstitem003', + title: 'Gamma Item', + description: 'Gamma test item description.', + created: BASE + 3 * H, + modified: BASE + 8 * H, + }, + { + id: 'tstitem004', + title: 'Delta Item', + description: 'Delta test item description.', + created: BASE + 4 * H, + modified: BASE + 7 * H, + }, + { + id: 'tstitem005', + title: 'Epsilon Item', + description: 'Epsilon test item description.', + created: BASE + 5 * H, + modified: BASE + 6 * H, + }, + { + id: 'tstitem006', + title: 'Zeta Item', + description: 'Zeta test item description.', + created: BASE + 6 * H, + modified: BASE + 5 * H, + }, + { + id: 'tstitem007', + title: 'Eta Item', + description: 'Eta test item description.', + created: BASE + 7 * H, + modified: BASE + 4 * H, + }, + { + id: 'tstitem008', + title: 'Theta Item', + description: 'Theta test item description.', + created: BASE + 8 * H, + modified: BASE + 3 * H, + }, + { + id: 'tstitem009', + title: 'Iota Item', + description: 'Iota test item description.', + created: BASE + 9 * H, + modified: BASE + 2 * H, + }, + { + id: 'tstitem010', + title: 'Kappa Item', + description: 'Kappa test item description.', + created: BASE + 10 * H, + modified: BASE + 1 * H, + }, +]; + +export async function seedTestDb(connectionString: string): Promise { + const pool = new pg.Pool({connectionString}); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Drop any stale logical replication slots so zero-cache can start fresh. + await client.query(` + SELECT pg_drop_replication_slot(slot_name) + FROM pg_replication_slots + WHERE slot_type = 'logical' + `); + + await client.query('DROP TABLE IF EXISTS item CASCADE'); + await client.query(` + CREATE TABLE item ( + id VARCHAR PRIMARY KEY, + title VARCHAR NOT NULL, + description VARCHAR NOT NULL, + created FLOAT8 NOT NULL, + modified FLOAT8 NOT NULL + ) + `); + + for (const item of TEST_ITEMS) { + await client.query( + `INSERT INTO item (id, title, description, created, modified) + VALUES ($1, $2, $3, $4, $5)`, + [item.id, item.title, item.description, item.created, item.modified], + ); + } + + await client.query('COMMIT'); + console.log(`Seeded ${TEST_ITEMS.length} test items`); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + await pool.end(); + } +} diff --git a/demo/e2e/tests/app.spec.ts b/demo/e2e/tests/app.spec.ts new file mode 100644 index 0000000..4e6f067 --- /dev/null +++ b/demo/e2e/tests/app.spec.ts @@ -0,0 +1,36 @@ +import {expect, test} from '@playwright/test'; +import {TEST_ITEMS} from '../seed-test.ts'; + +test.describe('App', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + }); + + test('shows the page heading', async ({page}) => { + await expect(page.getByRole('heading', {level: 1})).toContainText( + 'Zero Virtual Demo', + ); + }); + + test('shows the correct item count', async ({page}) => { + // The count is shown as "(N)" once all items have loaded. + await expect(page.getByText(`(${TEST_ITEMS.length})`)).toBeVisible({ + timeout: 15_000, + }); + }); + + test('renders list rows', async ({page}) => { + // Wait for the first real row (an element, not a placeholder
). + await expect(page.locator('a[data-index="0"]')).toBeVisible({ + timeout: 15_000, + }); + }); + + test('default sort is modified descending — Alpha Item is first', async ({ + page, + }) => { + // Alpha Item has the highest modified timestamp so it should be at index 0. + const firstRow = page.locator('a[data-index="0"]'); + await expect(firstRow).toContainText('Alpha Item', {timeout: 15_000}); + }); +}); diff --git a/demo/e2e/tests/item-detail.spec.ts b/demo/e2e/tests/item-detail.spec.ts new file mode 100644 index 0000000..13a1b53 --- /dev/null +++ b/demo/e2e/tests/item-detail.spec.ts @@ -0,0 +1,94 @@ +import {expect, test} from '@playwright/test'; +import {TEST_ITEMS} from '../seed-test.ts'; + +const TIMEOUT = 15_000; + +// In the default sort (modified desc) Alpha Item is at index 0. +const ALPHA = TEST_ITEMS.find(i => i.title === 'Alpha Item')!; + +test.describe('Item detail panel', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + // Wait for the list to have real rows loaded. + await expect(page.locator('a[data-index="0"]')).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('clicking a row opens the detail panel', async ({page}) => { + await page.locator('a[data-index="0"]').click(); + + // The panel should appear and show the item title in an

. + await expect(page.getByRole('heading', {level: 2})).toContainText( + 'Alpha Item', + {timeout: TIMEOUT}, + ); + }); + + test('detail panel shows the item description', async ({page}) => { + await page.locator('a[data-index="0"]').click(); + + await expect(page.getByText(ALPHA.description)).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('detail panel shows the item ID', async ({page}) => { + await page.locator('a[data-index="0"]').click(); + + await expect(page.getByText(ALPHA.id)).toBeVisible({timeout: TIMEOUT}); + }); + + test('clicking a row sets the URL hash to the item ID', async ({page}) => { + await page.locator('a[data-index="0"]').click(); + + await expect(page).toHaveURL(`/#${ALPHA.id}`, {timeout: TIMEOUT}); + }); + + test('the selected row gets aria-selected="true"', async ({page}) => { + await page.locator('a[data-index="0"]').click(); + + // After clicking, the row should carry aria-selected. + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toHaveAttribute( + 'aria-selected', + 'true', + {timeout: TIMEOUT}, + ); + }); + + test('close button hides the detail panel', async ({page}) => { + await page.locator('a[data-index="0"]').click(); + + // Confirm panel opened. + await expect(page.getByRole('heading', {level: 2})).toBeVisible({ + timeout: TIMEOUT, + }); + + // Click the close button (aria-label="Close"). + await page.getByRole('button', {name: 'Close'}).click(); + + // Panel should no longer be visible. + await expect(page.getByRole('heading', {level: 2})).not.toBeVisible(); + }); + + test('closing the panel clears the URL hash', async ({page}) => { + await page.locator('a[data-index="0"]').click(); + await expect(page).toHaveURL(`/#${ALPHA.id}`, {timeout: TIMEOUT}); + + await page.getByRole('button', {name: 'Close'}).click(); + + // Hash should be cleared (URL ends with just /). + await expect(page).toHaveURL(/\/#?$/, {timeout: TIMEOUT}); + }); + + test('navigating directly to a permalink shows the detail panel', async ({ + page, + }) => { + await page.goto(`/#${ALPHA.id}`); + + await expect(page.getByRole('heading', {level: 2})).toContainText( + 'Alpha Item', + {timeout: TIMEOUT}, + ); + }); +}); diff --git a/demo/e2e/tests/sort.spec.ts b/demo/e2e/tests/sort.spec.ts new file mode 100644 index 0000000..19cd6f5 --- /dev/null +++ b/demo/e2e/tests/sort.spec.ts @@ -0,0 +1,92 @@ +import {expect, test} from '@playwright/test'; + +// Seed data ordering summary (see seed-test.ts): +// +// modified DESC (default): Alpha first, Kappa last +// modified ASC: Kappa first, Alpha last +// created DESC: Kappa first, Alpha last +// created ASC: Alpha first, Kappa last + +const TIMEOUT = 15_000; + +test.describe('Sort controls', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + // Wait until the list has loaded real rows. + await expect(page.locator('a[data-index="0"]')).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('default state: sort field button reads "Modified"', async ({page}) => { + await expect( + page.getByRole('button', {name: 'Modified'}), + ).toBeVisible(); + }); + + test('default state: sort direction button title is "Descending"', async ({ + page, + }) => { + await expect( + page.getByRole('button', {name: 'Descending'}), + ).toBeVisible(); + }); + + test('default (modified desc): Alpha Item is first', async ({page}) => { + await expect(page.locator('a[data-index="0"]')).toContainText('Alpha Item'); + }); + + test('toggle sort field to created → Kappa Item is first (created desc)', async ({ + page, + }) => { + // Click the sort-field button (shows current field, toggles to the other). + await page.getByRole('button', {name: 'Modified'}).click(); + + // Button should now read "Created". + await expect(page.getByRole('button', {name: 'Created'})).toBeVisible(); + + // Kappa Item has the highest created timestamp. + await expect(page.locator('a[data-index="0"]')).toContainText('Kappa Item', { + timeout: TIMEOUT, + }); + }); + + test('toggle sort direction to asc while on created → Alpha Item is first (created asc)', async ({ + page, + }) => { + // Switch to created field first. + await page.getByRole('button', {name: 'Modified'}).click(); + await expect(page.locator('a[data-index="0"]')).toContainText('Kappa Item', { + timeout: TIMEOUT, + }); + + // Now flip direction: button title is "Descending" → becomes "Ascending". + await page.getByRole('button', {name: 'Descending'}).click(); + await expect(page.getByRole('button', {name: 'Ascending'})).toBeVisible(); + + // Alpha Item has the lowest created timestamp. + await expect(page.locator('a[data-index="0"]')).toContainText('Alpha Item', { + timeout: TIMEOUT, + }); + }); + + test('returning to modified asc after created asc → Kappa Item is first', async ({ + page, + }) => { + // Start: modified desc → switch to created desc → flip to created asc. + await page.getByRole('button', {name: 'Modified'}).click(); + await page.getByRole('button', {name: 'Descending'}).click(); + await expect(page.locator('a[data-index="0"]')).toContainText('Alpha Item', { + timeout: TIMEOUT, + }); + + // Switch field back to modified (direction stays asc → modified asc). + await page.getByRole('button', {name: 'Created'}).click(); + await expect(page.getByRole('button', {name: 'Modified'})).toBeVisible(); + + // Kappa Item has the lowest modified timestamp. + await expect(page.locator('a[data-index="0"]')).toContainText('Kappa Item', { + timeout: TIMEOUT, + }); + }); +}); diff --git a/demo/package.json b/demo/package.json index 0cda8af..338e96b 100644 --- a/demo/package.json +++ b/demo/package.json @@ -9,7 +9,8 @@ "dev:db-up": "docker compose --env-file .env -f ./docker/docker-compose.yml up", "dev:db-down": "docker compose --env-file .env -f ./docker/docker-compose.yml down", "dev:clean": "source .env && docker volume rm -f docker_zstart_pgdata && rm -rf \"${ZERO_REPLICA_FILE}\"*", - "seed": "node --env-file=.env seed.ts" + "seed": "node --env-file=.env seed.ts", + "test:e2e": "node --env-file=.env ./node_modules/.bin/playwright test" }, "dependencies": { "@hono/node-server": "^1.19.11", @@ -20,6 +21,7 @@ }, "devDependencies": { "@faker-js/faker": "^10.4.0", + "@playwright/test": "^1.50.0", "@tanstack/react-virtual": "^3.13.23", "@types/node": "^25.5.0", "@types/pg": "^8.20.0", diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts new file mode 100644 index 0000000..462736a --- /dev/null +++ b/demo/playwright.config.ts @@ -0,0 +1,57 @@ +import {defineConfig} from '@playwright/test'; +import {readFileSync} from 'fs'; +import {join} from 'path'; +import {fileURLToPath} from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +// Load .env into process.env so globalSetup and webServer inherit the vars. +// Existing env vars are not overwritten (allows CI overrides). +function loadDotEnv(): void { + let content: string; + try { + content = readFileSync(join(__dirname, '.env'), 'utf-8'); + } catch { + return; + } + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let val = trimmed.slice(eqIdx + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + process.env[key] ??= val; + } +} + +loadDotEnv(); + +export default defineConfig({ + testDir: './e2e/tests', + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + // Vite dev server — started after globalSetup, stopped after globalTeardown. + webServer: { + command: 'pnpm dev:ui', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c7e59b..e3696ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: '@faker-js/faker': specifier: ^10.4.0 version: 10.4.0 + '@playwright/test': + specifier: ^1.50.0 + version: 1.58.2 '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1199,6 +1202,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -4014,6 +4022,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@polka/url@1.0.0-next.29': optional: true @@ -5233,15 +5245,13 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 - playwright-core@1.58.2: - optional: true + playwright-core@1.58.2: {} playwright@1.58.2: dependencies: playwright-core: 1.58.2 optionalDependencies: fsevents: 2.3.2 - optional: true pngjs@7.0.0: optional: true From d2089fb0bf42cac636c7f5c2e2cde3a80ea5a0cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 09:55:32 +0000 Subject: [PATCH 02/11] Address review feedback on Playwright test setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use node: protocol prefix for all Node.js built-in module imports - Expand seed data from 10 to 200 items so the virtualizer exercises paging (min page size is 100); extra items have inverted created/ modified so all four sort orderings still produce distinct first rows: modified DESC → Alpha Item, modified ASC → Test Item 200, created DESC → Kappa Item, created ASC → Test Item 011 - Remove manual loadDotEnv() helper from playwright.config.ts; env vars are now loaded exclusively via `node --env-file=.env` in test:e2e - Update sort.spec.ts assertions to match the new 200-item dataset - Add scroll.spec.ts: scrolls the virtualizer to the bottom and verifies the last row (index 199) becomes visible (tests paging) - Add .github/workflows/e2e.yml CI workflow that installs Playwright, runs `pnpm test:e2e` (globalSetup handles postgres + zero-cache), and uploads the playwright-report artifact on failure https://claude.ai/code/session_01H3p3JUgo2y2389e3qKBo7N --- .github/workflows/e2e.yml | 39 ++++++++++++++++++++++ demo/e2e/global-setup.ts | 10 +++--- demo/e2e/global-teardown.ts | 2 +- demo/e2e/seed-test.ts | 60 +++++++++++++++++++++++---------- demo/e2e/tests/scroll.spec.ts | 62 +++++++++++++++++++++++++++++++++++ demo/e2e/tests/sort.spec.ts | 53 +++++++++++++++--------------- demo/playwright.config.ts | 35 ++------------------ 7 files changed, 177 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 demo/e2e/tests/scroll.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..17a8fac --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,39 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: demo + + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'pnpm' + + - run: pnpm install + working-directory: . + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: demo/playwright-report/ + retention-days: 30 diff --git a/demo/e2e/global-setup.ts b/demo/e2e/global-setup.ts index ae84192..e3787e0 100644 --- a/demo/e2e/global-setup.ts +++ b/demo/e2e/global-setup.ts @@ -1,9 +1,9 @@ -import {spawn} from 'child_process'; -import {existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'fs'; -import * as net from 'net'; -import {join} from 'path'; +import {spawn} from 'node:child_process'; +import {existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import * as net from 'node:net'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; import pg from 'pg'; -import {fileURLToPath} from 'url'; import {seedTestDb} from './seed-test.ts'; const DEMO_DIR = fileURLToPath(new URL('..', import.meta.url)); diff --git a/demo/e2e/global-teardown.ts b/demo/e2e/global-teardown.ts index e9a70bc..c363b95 100644 --- a/demo/e2e/global-teardown.ts +++ b/demo/e2e/global-teardown.ts @@ -1,4 +1,4 @@ -import {existsSync, readFileSync, rmSync} from 'fs'; +import {existsSync, readFileSync, rmSync} from 'node:fs'; import {PID_FILE} from './global-setup.ts'; export default async function globalTeardown(): Promise { diff --git a/demo/e2e/seed-test.ts b/demo/e2e/seed-test.ts index 9c1757d..10892a3 100644 --- a/demo/e2e/seed-test.ts +++ b/demo/e2e/seed-test.ts @@ -12,94 +12,118 @@ export type TestItem = { modified: number; }; -// Items are ordered such that created and modified are inverses of each other: +// Named items (1–10): Alpha through Kappa. // -// modified DESC (default): Alpha, Beta, Gamma, ..., Kappa -// modified ASC: Kappa, Iota, Theta, ..., Alpha -// created DESC: Kappa, Iota, Theta, ..., Alpha -// created ASC: Alpha, Beta, Gamma, ..., Kappa +// Within this group created and modified are inverses of each other. +// Within the extra group they are also inverses. +// This means all four sort orderings produce a distinct first row: // -// This gives 4 distinct, predictable orderings to test sorting. -export const TEST_ITEMS: TestItem[] = [ +// modified DESC (default) → Alpha Item (modified = BASE+10H, highest) +// modified ASC → Test Item 200 (modified = BASE−190H, lowest) +// created DESC → Kappa Item (created = BASE+10H, highest) +// created ASC → Test Item 011 (created = BASE−190H, lowest) +// +// The 200 items also give the virtualizer enough rows to exercise paging +// (the min page size in the demo is 100). +const NAMED: TestItem[] = [ { id: 'tstitem001', title: 'Alpha Item', - description: 'Alpha test item description.', + description: 'Alpha test item.', created: BASE + 1 * H, modified: BASE + 10 * H, }, { id: 'tstitem002', title: 'Beta Item', - description: 'Beta test item description.', + description: 'Beta test item.', created: BASE + 2 * H, modified: BASE + 9 * H, }, { id: 'tstitem003', title: 'Gamma Item', - description: 'Gamma test item description.', + description: 'Gamma test item.', created: BASE + 3 * H, modified: BASE + 8 * H, }, { id: 'tstitem004', title: 'Delta Item', - description: 'Delta test item description.', + description: 'Delta test item.', created: BASE + 4 * H, modified: BASE + 7 * H, }, { id: 'tstitem005', title: 'Epsilon Item', - description: 'Epsilon test item description.', + description: 'Epsilon test item.', created: BASE + 5 * H, modified: BASE + 6 * H, }, { id: 'tstitem006', title: 'Zeta Item', - description: 'Zeta test item description.', + description: 'Zeta test item.', created: BASE + 6 * H, modified: BASE + 5 * H, }, { id: 'tstitem007', title: 'Eta Item', - description: 'Eta test item description.', + description: 'Eta test item.', created: BASE + 7 * H, modified: BASE + 4 * H, }, { id: 'tstitem008', title: 'Theta Item', - description: 'Theta test item description.', + description: 'Theta test item.', created: BASE + 8 * H, modified: BASE + 3 * H, }, { id: 'tstitem009', title: 'Iota Item', - description: 'Iota test item description.', + description: 'Iota test item.', created: BASE + 9 * H, modified: BASE + 2 * H, }, { id: 'tstitem010', title: 'Kappa Item', - description: 'Kappa test item description.', + description: 'Kappa test item.', created: BASE + 10 * H, modified: BASE + 1 * H, }, ]; +// Extra items (11–200): programmatically generated with inverted +// created/modified so the extremes are Test Item 011 and Test Item 200. +// +// i=11: created = BASE−190H (lowest created), modified = BASE−1H +// i=200: created = BASE−1H, modified = BASE−190H (lowest modified) +const EXTRA: TestItem[] = Array.from({length: 190}, (_, k) => { + const i = k + 11; // 11..200 + const n = String(i).padStart(3, '0'); + return { + id: `tstitem${n}`, + title: `Test Item ${n}`, + description: `Test item ${n} description.`, + created: BASE - (201 - i) * H, + modified: BASE - (i - 10) * H, + }; +}); + +export const TEST_ITEMS: TestItem[] = [...NAMED, ...EXTRA]; + export async function seedTestDb(connectionString: string): Promise { const pool = new pg.Pool({connectionString}); const client = await pool.connect(); try { await client.query('BEGIN'); - // Drop any stale logical replication slots so zero-cache can start fresh. + // Drop stale logical replication slots so zero-cache can start fresh. await client.query(` SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots diff --git a/demo/e2e/tests/scroll.spec.ts b/demo/e2e/tests/scroll.spec.ts new file mode 100644 index 0000000..d558909 --- /dev/null +++ b/demo/e2e/tests/scroll.spec.ts @@ -0,0 +1,62 @@ +import {expect, test} from '@playwright/test'; +import {TEST_ITEMS} from '../seed-test.ts'; + +const TIMEOUT = 20_000; + +// The virtual list renders only the visible rows. Scrolling causes new pages +// to be fetched and new rows to be inserted into the DOM. With 200 items and a +// min page size of 100, there are at least 2 pages to load. + +test.describe('Scroll / paging', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + await expect(page.locator('a[data-index="0"]')).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('initial render shows the correct item count', async ({page}) => { + await expect( + page.getByText(`(${TEST_ITEMS.length})`), + ).toBeVisible({timeout: TIMEOUT}); + }); + + test('scrolling to the bottom loads items from the second page', async ({ + page, + }) => { + // The scrollable viewport is 2 DOM levels above [data-index="0"]: + //
← overflow:auto + //
← total-height spacer + // ← first row + const viewport = page.locator('[data-index="0"]').locator('xpath=../..'); + + // Scroll to the very bottom of the virtualised list. + await viewport.evaluate(el => { + el.scrollTop = el.scrollHeight; + }); + + // After scrolling, the virtualizer should render rows near the end of the + // list. At 200 items × 48 px/row the last row index is 199. + await expect( + page.locator(`a[data-index="${TEST_ITEMS.length - 1}"]`), + ).toBeVisible({timeout: TIMEOUT}); + }); + + test('scrolling down and back up restores the first item', async ({page}) => { + const viewport = page.locator('[data-index="0"]').locator('xpath=../..'); + + await viewport.evaluate(el => { + el.scrollTop = el.scrollHeight; + }); + + // Scroll back to the top. + await viewport.evaluate(el => { + el.scrollTop = 0; + }); + + await expect(page.locator('a[data-index="0"]')).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.locator('a[data-index="0"]')).toContainText('Alpha Item'); + }); +}); diff --git a/demo/e2e/tests/sort.spec.ts b/demo/e2e/tests/sort.spec.ts index 19cd6f5..f3d660b 100644 --- a/demo/e2e/tests/sort.spec.ts +++ b/demo/e2e/tests/sort.spec.ts @@ -1,11 +1,11 @@ import {expect, test} from '@playwright/test'; -// Seed data ordering summary (see seed-test.ts): +// Seed data ordering summary (see seed-test.ts for details): // -// modified DESC (default): Alpha first, Kappa last -// modified ASC: Kappa first, Alpha last -// created DESC: Kappa first, Alpha last -// created ASC: Alpha first, Kappa last +// modified DESC (default) → Alpha Item first (modified = BASE+10H) +// modified ASC → Test Item 200 first (modified = BASE−190H) +// created DESC → Kappa Item first (created = BASE+10H) +// created ASC → Test Item 011 first (created = BASE−190H) const TIMEOUT = 15_000; @@ -19,9 +19,7 @@ test.describe('Sort controls', () => { }); test('default state: sort field button reads "Modified"', async ({page}) => { - await expect( - page.getByRole('button', {name: 'Modified'}), - ).toBeVisible(); + await expect(page.getByRole('button', {name: 'Modified'})).toBeVisible(); }); test('default state: sort direction button title is "Descending"', async ({ @@ -39,54 +37,55 @@ test.describe('Sort controls', () => { test('toggle sort field to created → Kappa Item is first (created desc)', async ({ page, }) => { - // Click the sort-field button (shows current field, toggles to the other). + // Click the sort-field button — it shows the current field and toggles. await page.getByRole('button', {name: 'Modified'}).click(); - // Button should now read "Created". await expect(page.getByRole('button', {name: 'Created'})).toBeVisible(); - // Kappa Item has the highest created timestamp. + // Kappa Item has the highest created timestamp (BASE+10H). await expect(page.locator('a[data-index="0"]')).toContainText('Kappa Item', { timeout: TIMEOUT, }); }); - test('toggle sort direction to asc while on created → Alpha Item is first (created asc)', async ({ + test('toggle direction to asc while on created → Test Item 011 is first (created asc)', async ({ page, }) => { - // Switch to created field first. await page.getByRole('button', {name: 'Modified'}).click(); await expect(page.locator('a[data-index="0"]')).toContainText('Kappa Item', { timeout: TIMEOUT, }); - // Now flip direction: button title is "Descending" → becomes "Ascending". + // Flip direction: "Descending" → "Ascending". await page.getByRole('button', {name: 'Descending'}).click(); await expect(page.getByRole('button', {name: 'Ascending'})).toBeVisible(); - // Alpha Item has the lowest created timestamp. - await expect(page.locator('a[data-index="0"]')).toContainText('Alpha Item', { - timeout: TIMEOUT, - }); + // Test Item 011 has the lowest created timestamp (BASE−190H). + await expect(page.locator('a[data-index="0"]')).toContainText( + 'Test Item 011', + {timeout: TIMEOUT}, + ); }); - test('returning to modified asc after created asc → Kappa Item is first', async ({ + test('toggle field back to modified while on created asc → Test Item 200 is first (modified asc)', async ({ page, }) => { - // Start: modified desc → switch to created desc → flip to created asc. + // Navigate to created asc. await page.getByRole('button', {name: 'Modified'}).click(); await page.getByRole('button', {name: 'Descending'}).click(); - await expect(page.locator('a[data-index="0"]')).toContainText('Alpha Item', { - timeout: TIMEOUT, - }); + await expect(page.locator('a[data-index="0"]')).toContainText( + 'Test Item 011', + {timeout: TIMEOUT}, + ); // Switch field back to modified (direction stays asc → modified asc). await page.getByRole('button', {name: 'Created'}).click(); await expect(page.getByRole('button', {name: 'Modified'})).toBeVisible(); - // Kappa Item has the lowest modified timestamp. - await expect(page.locator('a[data-index="0"]')).toContainText('Kappa Item', { - timeout: TIMEOUT, - }); + // Test Item 200 has the lowest modified timestamp (BASE−190H). + await expect(page.locator('a[data-index="0"]')).toContainText( + 'Test Item 200', + {timeout: TIMEOUT}, + ); }); }); diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts index 462736a..72414c4 100644 --- a/demo/playwright.config.ts +++ b/demo/playwright.config.ts @@ -1,38 +1,7 @@ import {defineConfig} from '@playwright/test'; -import {readFileSync} from 'fs'; -import {join} from 'path'; -import {fileURLToPath} from 'url'; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); - -// Load .env into process.env so globalSetup and webServer inherit the vars. -// Existing env vars are not overwritten (allows CI overrides). -function loadDotEnv(): void { - let content: string; - try { - content = readFileSync(join(__dirname, '.env'), 'utf-8'); - } catch { - return; - } - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eqIdx = trimmed.indexOf('='); - if (eqIdx === -1) continue; - const key = trimmed.slice(0, eqIdx).trim(); - let val = trimmed.slice(eqIdx + 1).trim(); - if ( - (val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'")) - ) { - val = val.slice(1, -1); - } - process.env[key] ??= val; - } -} - -loadDotEnv(); +// Environment variables are loaded via `node --env-file=.env` in the +// test:e2e script, so no manual .env parsing is needed here. export default defineConfig({ testDir: './e2e/tests', globalSetup: './e2e/global-setup.ts', From 7a38b60373bc082c18e7c581a5ff6a88a4ad90a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 12:46:30 +0000 Subject: [PATCH 03/11] Fix test:e2e: invoke playwright cli.js directly, not the pnpm shell shim `node --env-file=.env ./node_modules/.bin/playwright` fails because pnpm generates a bash wrapper in .bin/, not a JS file. Node.js can't execute bash and throws a SyntaxError before playwright even starts. The package.json bin field for @playwright/test points to cli.js, so use that path directly: node --env-file=.env ./node_modules/@playwright/test/cli.js test https://claude.ai/code/session_01H3p3JUgo2y2389e3qKBo7N --- demo/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/package.json b/demo/package.json index 338e96b..7c3e9d9 100644 --- a/demo/package.json +++ b/demo/package.json @@ -10,7 +10,7 @@ "dev:db-down": "docker compose --env-file .env -f ./docker/docker-compose.yml down", "dev:clean": "source .env && docker volume rm -f docker_zstart_pgdata && rm -rf \"${ZERO_REPLICA_FILE}\"*", "seed": "node --env-file=.env seed.ts", - "test:e2e": "node --env-file=.env ./node_modules/.bin/playwright test" + "test:e2e": "node --env-file=.env ./node_modules/@playwright/test/cli.js test" }, "dependencies": { "@hono/node-server": "^1.19.11", From c3da0daa06836a0119bd6ee2dd19245831cdf3b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 13:03:23 +0000 Subject: [PATCH 04/11] Fix check-types: exclude playwright.config.ts from tsconfig.app.json tsconfig.app.json's "./*.ts" glob picked up playwright.config.ts and tsgo failed with TS2614 because @playwright/test is a CJS package whose named exports conflict with the project's verbatimModuleSyntax + NodeNext settings. Fix: - Exclude ./playwright.config.ts from demo/tsconfig.app.json - Add demo/e2e/tsconfig.json that extends tsconfig-shared.json and explicitly adds "types": ["@playwright/test"], covering both playwright.config.ts and all e2e/**/*.ts files - Add demo/e2e/tsconfig.json to the root check-types script so the playwright files are still type-checked https://claude.ai/code/session_01H3p3JUgo2y2389e3qKBo7N --- demo/e2e/tsconfig.json | 7 +++++++ demo/tsconfig.app.json | 3 ++- package.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 demo/e2e/tsconfig.json diff --git a/demo/e2e/tsconfig.json b/demo/e2e/tsconfig.json new file mode 100644 index 0000000..0423514 --- /dev/null +++ b/demo/e2e/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig-shared.json", + "compilerOptions": { + "types": ["@playwright/test"] + }, + "include": ["./**/*.ts", "../playwright.config.ts"] +} diff --git a/demo/tsconfig.app.json b/demo/tsconfig.app.json index 144304f..8aa7448 100644 --- a/demo/tsconfig.app.json +++ b/demo/tsconfig.app.json @@ -3,5 +3,6 @@ "compilerOptions": { "jsx": "react-jsx" }, - "include": ["./*.tsx", "./*.ts"] + "include": ["./*.tsx", "./*.ts"], + "exclude": ["./playwright.config.ts"] } diff --git a/package.json b/package.json index a5efca6..5ea464c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "scripts": { "dev": "pnpm --filter demo dev", - "check-types": "tsgo -p src/tsconfig.json && tsgo -p demo/tsconfig.app.json && tsgo -p demo/tsconfig.node.json", + "check-types": "tsgo -p src/tsconfig.json && tsgo -p demo/tsconfig.app.json && tsgo -p demo/tsconfig.node.json && tsgo -p demo/e2e/tsconfig.json", "lint": "oxlint --type-aware", "check": "oxlint --type-aware --type-check", "format": "oxfmt", From 2a283e6408f187afebbdf3cb3a8186b38c65f822 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 11:55:34 +0000 Subject: [PATCH 05/11] Fix e2e timeout failures in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes: 1. Stale locator after scroll (scroll.spec.ts) The virtualizer unmounts row 0 when it scrolls off-screen, so locator('[data-index="0"]').locator('../..') can't be re-evaluated after scrolling to the bottom — Playwright waits forever for the unmounted element and the test exceeds the 30 s budget. Fix: capture an ElementHandle *before* any scrolling; the handle holds a direct DOM reference that remains valid even after row 0 is unmounted. 2. Timeout budget too tight for CI (playwright.config.ts + app.spec.ts) Each test's beforeEach waits up to 20 s for zero-cache to replicate data; stacked with per-assertion timeouts this easily exceeds the default 30 s test timeout in CI. Fix: set timeout to 60 s in CI. Also accept the estimated count "(~200)" in the item-count test rather than requiring the exact "(200)", which only appears once every page has been fetched. https://claude.ai/code/session_01H3p3JUgo2y2389e3qKBo7N --- demo/e2e/tests/app.spec.ts | 8 ++++---- demo/e2e/tests/scroll.spec.ts | 28 +++++++++++++++++++++------- demo/playwright.config.ts | 2 ++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/demo/e2e/tests/app.spec.ts b/demo/e2e/tests/app.spec.ts index 4e6f067..3ac1297 100644 --- a/demo/e2e/tests/app.spec.ts +++ b/demo/e2e/tests/app.spec.ts @@ -13,10 +13,10 @@ test.describe('App', () => { }); test('shows the correct item count', async ({page}) => { - // The count is shown as "(N)" once all items have loaded. - await expect(page.getByText(`(${TEST_ITEMS.length})`)).toBeVisible({ - timeout: 15_000, - }); + // Accept both "(200)" (exact, all pages loaded) and "(~200)" (estimated). + await expect( + page.getByText(new RegExp(`\\(~?${TEST_ITEMS.length}\\)`)), + ).toBeVisible({timeout: 15_000}); }); test('renders list rows', async ({page}) => { diff --git a/demo/e2e/tests/scroll.spec.ts b/demo/e2e/tests/scroll.spec.ts index d558909..038a588 100644 --- a/demo/e2e/tests/scroll.spec.ts +++ b/demo/e2e/tests/scroll.spec.ts @@ -16,8 +16,10 @@ test.describe('Scroll / paging', () => { }); test('initial render shows the correct item count', async ({page}) => { + // Accept both the exact count "(200)" and the estimated "(~200)" — the + // virtualizer shows an estimate until all pages have been counted. await expect( - page.getByText(`(${TEST_ITEMS.length})`), + page.getByText(new RegExp(`\\(~?${TEST_ITEMS.length}\\)`)), ).toBeVisible({timeout: TIMEOUT}); }); @@ -25,13 +27,21 @@ test.describe('Scroll / paging', () => { page, }) => { // The scrollable viewport is 2 DOM levels above [data-index="0"]: - //
← overflow:auto + //
← overflow:auto //
← total-height spacer // ← first row - const viewport = page.locator('[data-index="0"]').locator('xpath=../..'); + // + // IMPORTANT: capture an ElementHandle *before* scrolling. The virtualizer + // unmounts row 0 once it leaves the viewport, which would make the + // locator chain ('[data-index="0"]/../..') stale if we re-evaluated it + // after scrolling. + const viewportEl = await page + .locator('[data-index="0"]') + .locator('xpath=../..') + .elementHandle(); // Scroll to the very bottom of the virtualised list. - await viewport.evaluate(el => { + await viewportEl!.evaluate(el => { el.scrollTop = el.scrollHeight; }); @@ -43,14 +53,18 @@ test.describe('Scroll / paging', () => { }); test('scrolling down and back up restores the first item', async ({page}) => { - const viewport = page.locator('[data-index="0"]').locator('xpath=../..'); + // Capture element handle while row 0 is still mounted (see above). + const viewportEl = await page + .locator('[data-index="0"]') + .locator('xpath=../..') + .elementHandle(); - await viewport.evaluate(el => { + await viewportEl!.evaluate(el => { el.scrollTop = el.scrollHeight; }); // Scroll back to the top. - await viewport.evaluate(el => { + await viewportEl!.evaluate(el => { el.scrollTop = 0; }); diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts index 72414c4..18899db 100644 --- a/demo/playwright.config.ts +++ b/demo/playwright.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, + // Give CI more time — zero-cache cold-start and replication are slower there. + timeout: process.env.CI ? 60_000 : 30_000, reporter: 'list', use: { baseURL: 'http://localhost:5173', From 2ae12f7529ae01208d6c58188c55cddc2f088d86 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Wed, 1 Apr 2026 14:49:27 +0200 Subject: [PATCH 06/11] feat: add VITE_PUBLIC_CACHE_PORT to configuration and update related components - Added VITE_PUBLIC_CACHE_PORT to .env and updated main.tsx to use this variable for cache URL. - Modified global-setup.ts to spawn zero-cache on the specified port. - Updated tests to reflect changes in item selection and visibility based on new caching behavior. - Enhanced scroll and sort tests to ensure proper functionality with the new cache settings. - Updated dependencies to use Playwright 1.59.0 and adjusted test configurations accordingly. - Improved virtualizer behavior to handle scroll adjustments more effectively. --- .gitignore | 1 + demo/.env | 2 + demo/e2e/global-setup.ts | 23 +- demo/e2e/seed-test.ts | 6 + demo/e2e/tests/app.spec.ts | 19 +- demo/e2e/tests/item-detail.spec.ts | 35 ++- demo/e2e/tests/scroll.spec.ts | 367 ++++++++++++++++++++++++++--- demo/e2e/tests/sort.spec.ts | 64 +++-- demo/e2e/tsconfig.json | 4 +- demo/main.tsx | 3 +- demo/package.json | 4 +- demo/vite-env.d.ts | 1 + pnpm-lock.yaml | 38 +-- src/react/use-zero-virtualizer.ts | 56 ++++- vitest.config.ts | 1 + 15 files changed, 512 insertions(+), 112 deletions(-) diff --git a/.gitignore b/.gitignore index de648b4..430c94d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ *.db-wal2 rocicorp-zero-virtual-*.tgz docs +.last-run.json diff --git a/demo/.env b/demo/.env index c7ed767..b9e6165 100644 --- a/demo/.env +++ b/demo/.env @@ -2,3 +2,5 @@ AUTH_SECRET="abcd" ZERO_UPSTREAM_DB="postgresql://user:password@127.0.0.1:5430/postgres" ZERO_QUERY_URL="http://localhost:*/api/zero/query" ZERO_MUTATE_URL="http://localhost:*/api/zero/mutate" +VITE_PUBLIC_CACHE_PORT=5858 + diff --git a/demo/e2e/global-setup.ts b/demo/e2e/global-setup.ts index e3787e0..b0d22a0 100644 --- a/demo/e2e/global-setup.ts +++ b/demo/e2e/global-setup.ts @@ -1,5 +1,11 @@ import {spawn} from 'node:child_process'; -import {existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import * as net from 'node:net'; import {join} from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -33,12 +39,13 @@ export default async function globalSetup(): Promise { } mkdirSync(REPLICA_DIR, {recursive: true}); + const port = Number(process.env['VITE_PUBLIC_CACHE_PORT'] ?? 5858); console.log('[setup] Starting zero-cache...'); - const zeroCacheProc = spawnZeroCache(); + const zeroCacheProc = spawnZeroCache(port); writeFileSync(PID_FILE, String(zeroCacheProc.pid)); - console.log('[setup] Waiting for zero-cache on port 4848...'); - await waitForPort(4848, 60_000); + console.log(`[setup] Waiting for zero-cache on port ${port}...`); + await waitForPort(port, 60_000); console.log('[setup] Ready.\n'); } @@ -117,17 +124,21 @@ function waitForPort(port: number, timeoutMs = 30_000): Promise { }); } -function spawnZeroCache() { +function spawnZeroCache(port: number) { + const binDir = join(DEMO_DIR, 'node_modules', '.bin'); const env: NodeJS.ProcessEnv = { ...process.env, + // Ensure node_modules/.bin is on PATH so zero-cache-dev can find zero-cache. + PATH: `${binDir}:${process.env['PATH'] ?? ''}`, ZERO_REPLICA_FILE: REPLICA_FILE, + ZERO_LOG_LEVEL: 'error', }; // Prefer the local bin so we use the exact version pinned in demo/package.json. const bin = join(DEMO_DIR, 'node_modules', '.bin', 'zero-cache-dev'); const command = existsSync(bin) ? bin : 'zero-cache-dev'; - const proc = spawn(command, [], { + const proc = spawn(command, ['--port', String(port)], { cwd: DEMO_DIR, env, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/demo/e2e/seed-test.ts b/demo/e2e/seed-test.ts index 10892a3..f010b9f 100644 --- a/demo/e2e/seed-test.ts +++ b/demo/e2e/seed-test.ts @@ -124,6 +124,12 @@ export async function seedTestDb(connectionString: string): Promise { await client.query('BEGIN'); // Drop stale logical replication slots so zero-cache can start fresh. + // Terminate any backends using the slots first, then drop them. + await client.query(` + SELECT pg_terminate_backend(active_pid) + FROM pg_replication_slots + WHERE slot_type = 'logical' AND active + `); await client.query(` SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots diff --git a/demo/e2e/tests/app.spec.ts b/demo/e2e/tests/app.spec.ts index 3ac1297..603ae44 100644 --- a/demo/e2e/tests/app.spec.ts +++ b/demo/e2e/tests/app.spec.ts @@ -1,5 +1,4 @@ import {expect, test} from '@playwright/test'; -import {TEST_ITEMS} from '../seed-test.ts'; test.describe('App', () => { test.beforeEach(async ({page}) => { @@ -13,15 +12,17 @@ test.describe('App', () => { }); test('shows the correct item count', async ({page}) => { - // Accept both "(200)" (exact, all pages loaded) and "(~200)" (estimated). - await expect( - page.getByText(new RegExp(`\\(~?${TEST_ITEMS.length}\\)`)), - ).toBeVisible({timeout: 15_000}); + // The virtualizer lazy-loads pages, so the initial count may be an + // estimate of the first page only (e.g. "(~100)"). Just verify that + // some item count is displayed in the heading. + await expect(page.getByText(/\(~?\d+\)/)).toBeVisible({timeout: 15_000}); }); test('renders list rows', async ({page}) => { // Wait for the first real row (an element, not a placeholder
). - await expect(page.locator('a[data-index="0"]')).toBeVisible({ + await expect( + page.locator('[class*="viewport"] a[href^="#"]').first(), + ).toBeVisible({ timeout: 15_000, }); }); @@ -29,8 +30,8 @@ test.describe('App', () => { test('default sort is modified descending — Alpha Item is first', async ({ page, }) => { - // Alpha Item has the highest modified timestamp so it should be at index 0. - const firstRow = page.locator('a[data-index="0"]'); - await expect(firstRow).toContainText('Alpha Item', {timeout: 15_000}); + // Alpha Item has the highest modified timestamp so it should be first. + const firstRow = page.getByRole('link', {name: 'Alpha Item'}); + await expect(firstRow).toBeVisible({timeout: 15_000}); }); }); diff --git a/demo/e2e/tests/item-detail.spec.ts b/demo/e2e/tests/item-detail.spec.ts index 13a1b53..9a87920 100644 --- a/demo/e2e/tests/item-detail.spec.ts +++ b/demo/e2e/tests/item-detail.spec.ts @@ -10,13 +10,13 @@ test.describe('Item detail panel', () => { test.beforeEach(async ({page}) => { await page.goto('/'); // Wait for the list to have real rows loaded. - await expect(page.locator('a[data-index="0"]')).toBeVisible({ + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ timeout: TIMEOUT, }); }); test('clicking a row opens the detail panel', async ({page}) => { - await page.locator('a[data-index="0"]').click(); + await page.locator(`a[href="#${ALPHA.id}"]`).click(); // The panel should appear and show the item title in an

. await expect(page.getByRole('heading', {level: 2})).toContainText( @@ -26,7 +26,7 @@ test.describe('Item detail panel', () => { }); test('detail panel shows the item description', async ({page}) => { - await page.locator('a[data-index="0"]').click(); + await page.locator(`a[href="#${ALPHA.id}"]`).click(); await expect(page.getByText(ALPHA.description)).toBeVisible({ timeout: TIMEOUT, @@ -34,19 +34,19 @@ test.describe('Item detail panel', () => { }); test('detail panel shows the item ID', async ({page}) => { - await page.locator('a[data-index="0"]').click(); + await page.locator(`a[href="#${ALPHA.id}"]`).click(); await expect(page.getByText(ALPHA.id)).toBeVisible({timeout: TIMEOUT}); }); test('clicking a row sets the URL hash to the item ID', async ({page}) => { - await page.locator('a[data-index="0"]').click(); + await page.locator(`a[href="#${ALPHA.id}"]`).click(); await expect(page).toHaveURL(`/#${ALPHA.id}`, {timeout: TIMEOUT}); }); test('the selected row gets aria-selected="true"', async ({page}) => { - await page.locator('a[data-index="0"]').click(); + await page.locator(`a[href="#${ALPHA.id}"]`).click(); // After clicking, the row should carry aria-selected. await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toHaveAttribute( @@ -57,7 +57,7 @@ test.describe('Item detail panel', () => { }); test('close button hides the detail panel', async ({page}) => { - await page.locator('a[data-index="0"]').click(); + await page.locator(`a[href="#${ALPHA.id}"]`).click(); // Confirm panel opened. await expect(page.getByRole('heading', {level: 2})).toBeVisible({ @@ -72,7 +72,7 @@ test.describe('Item detail panel', () => { }); test('closing the panel clears the URL hash', async ({page}) => { - await page.locator('a[data-index="0"]').click(); + await page.locator(`a[href="#${ALPHA.id}"]`).click(); await expect(page).toHaveURL(`/#${ALPHA.id}`, {timeout: TIMEOUT}); await page.getByRole('button', {name: 'Close'}).click(); @@ -91,4 +91,23 @@ test.describe('Item detail panel', () => { {timeout: TIMEOUT}, ); }); + + test('permalink to an item far down the list shows loading then resolves', async ({ + page, + }) => { + // Test Item 150 is near index 149 (second page) and is not loaded + // initially. The detail panel should show "Loading…" while the data + // is fetched, then resolve to the item. + const farItem = TEST_ITEMS.find(i => i.title === 'Test Item 150')!; + await page.goto(`/#${farItem.id}`); + + // Initially the detail panel shows a loading indicator. + await expect(page.getByText('Loading…')).toBeVisible({timeout: TIMEOUT}); + + // Eventually the item title appears in the panel heading. + await expect(page.getByRole('heading', {level: 2})).toContainText( + 'Test Item 150', + {timeout: TIMEOUT}, + ); + }); }); diff --git a/demo/e2e/tests/scroll.spec.ts b/demo/e2e/tests/scroll.spec.ts index 038a588..ba5936c 100644 --- a/demo/e2e/tests/scroll.spec.ts +++ b/demo/e2e/tests/scroll.spec.ts @@ -1,8 +1,31 @@ -import {expect, test} from '@playwright/test'; -import {TEST_ITEMS} from '../seed-test.ts'; +import { expect, test, type Page } from '@playwright/test'; +import { TEST_ITEMS } from '../seed-test.ts'; const TIMEOUT = 20_000; +// In default sort (modified DESC), Alpha Item is first. +const ALPHA = TEST_ITEMS.find(i => i.title === 'Alpha Item')!; + +/** + * Wait for the virtualizer's scroll state to be persisted into the + * Navigation API's current entry state. The virtualizer debounces + * `onScrollStateChange` at 100ms, so after the initial data render + * the state is not immediately available. In-page hash navigations + * (navigation.navigate) only trigger re-anchoring when the persisted + * scroll state changes between the old and new history entries, so we + * must wait for it before navigating away. + */ +async function waitForScrollStatePersisted(page: Page) { + await expect(async () => { + const persisted = await page.evaluate( + () => + (navigation.currentEntry?.getState() as Record) + ?.scrollState != null, + ); + expect(persisted).toBe(true); + }).toPass({timeout: TIMEOUT}); +} + // The virtual list renders only the visible rows. Scrolling causes new pages // to be fetched and new rows to be inserted into the DOM. With 200 items and a // min page size of 100, there are at least 2 pages to load. @@ -10,53 +33,40 @@ const TIMEOUT = 20_000; test.describe('Scroll / paging', () => { test.beforeEach(async ({page}) => { await page.goto('/'); - await expect(page.locator('a[data-index="0"]')).toBeVisible({ + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ timeout: TIMEOUT, }); }); test('initial render shows the correct item count', async ({page}) => { - // Accept both the exact count "(200)" and the estimated "(~200)" — the - // virtualizer shows an estimate until all pages have been counted. - await expect( - page.getByText(new RegExp(`\\(~?${TEST_ITEMS.length}\\)`)), - ).toBeVisible({timeout: TIMEOUT}); + // The virtualizer lazy-loads pages, so the initial count is an estimate + // based on the first page only (e.g. "(~100)"). Verify a count appears. + await expect(page.getByText(/\(~?\d+\)/)).toBeVisible({timeout: TIMEOUT}); }); test('scrolling to the bottom loads items from the second page', async ({ page, }) => { - // The scrollable viewport is 2 DOM levels above [data-index="0"]: - //
← overflow:auto - //
← total-height spacer - // ← first row - // - // IMPORTANT: capture an ElementHandle *before* scrolling. The virtualizer - // unmounts row 0 once it leaves the viewport, which would make the - // locator chain ('[data-index="0"]/../..') stale if we re-evaluated it - // after scrolling. const viewportEl = await page - .locator('[data-index="0"]') - .locator('xpath=../..') + .locator('[class*="viewport"]') .elementHandle(); - // Scroll to the very bottom of the virtualised list. - await viewportEl!.evaluate(el => { - el.scrollTop = el.scrollHeight; - }); - - // After scrolling, the virtualizer should render rows near the end of the - // list. At 200 items × 48 px/row the last row index is 199. - await expect( - page.locator(`a[data-index="${TEST_ITEMS.length - 1}"]`), - ).toBeVisible({timeout: TIMEOUT}); + // The virtualizer lazy-loads pages, so we need to scroll to the bottom + // repeatedly — each scroll triggers loading the next page, which extends + // the scrollable area. + await expect(async () => { + await viewportEl!.evaluate(el => { + el.scrollTop = el.scrollHeight; + }); + await expect( + page.locator(`a[href="#${TEST_ITEMS[TEST_ITEMS.length - 1].id}"]`), + ).toBeVisible(); + }).toPass({timeout: TIMEOUT}); }); test('scrolling down and back up restores the first item', async ({page}) => { - // Capture element handle while row 0 is still mounted (see above). const viewportEl = await page - .locator('[data-index="0"]') - .locator('xpath=../..') + .locator('[class*="viewport"]') .elementHandle(); await viewportEl!.evaluate(el => { @@ -68,9 +78,298 @@ test.describe('Scroll / paging', () => { el.scrollTop = 0; }); - await expect(page.locator('a[data-index="0"]')).toBeVisible({ + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toContainText( + 'Alpha Item', + ); + }); +}); + +/** + * Wait for a permalink row to become visible and selected. The virtualizer + * scrolls to the target automatically but the initial data fetch and + * pagination adjustments are async. + * + * Strategy: + * 1. Wait for any list rows to appear (data loaded). + * 2. Wait for the scroll position to stabilize (no change for 500 ms). + * 3. If the target row isn't visible yet, scroll the viewport down in + * viewport-sized steps until it appears. This mirrors what a user does + * when the virtualizer's auto-scroll undershoots. + * 4. Assert the row is visible and selected. + */ +async function waitForPermalinkRow(page: Page, id: string) { + const row = page.locator(`a[href="#${id}"]`); + + // 1. Wait for any rows to be rendered. + await expect( + page.locator('[class*="viewport"] a[href^="#"]').first(), + ).toBeVisible({timeout: 10_000}); + + // 2. Wait for scroll to settle. + await page.evaluate( + () => + new Promise(resolve => { + const vp = document.querySelector('[class*="viewport"]'); + if (!vp) { + resolve(); + return; + } + let last = vp.scrollTop; + const check = () => { + if (vp.scrollTop === last) { + resolve(); + } else { + last = vp.scrollTop; + setTimeout(check, 50); + } + }; + setTimeout(check, 50); + }), + ); + + // 3. If the row isn't visible, scroll down in steps until it appears. + await expect(async () => { + // const visible = await row.isVisible().catch(() => false); + // if (!visible) { + // await page.evaluate(() => { + // const vp = document.querySelector('[class*="viewport"]'); + // if (vp) vp.scrollTop += vp.clientHeight; + // }); + // } + await expect(row).toBeVisible({timeout: 1_000}); + }).toPass({timeout: 20_000}); + + // 4. Assert selected. + await expect(row).toHaveAttribute('aria-selected', 'true'); + return row; +} + +// --------------------------------------------------------------------------- +// Direct permalink navigation: load the app with a hash already in the URL +// (no prior `/` load). The app must scroll the target row into view and +// select it on first render. +// --------------------------------------------------------------------------- + +test.describe('Direct permalink navigation', () => { + test('first page item — Beta Item', async ({page}) => { + const beta = TEST_ITEMS.find(i => i.title === 'Beta Item')!; + await page.goto(`/#${beta.id}`); + + const row = page.locator(`a[href="#${beta.id}"]`); + await expect(row).toBeVisible({timeout: TIMEOUT}); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Beta Item'); + }); + + test('page-boundary item — Test Item 100', async ({page}) => { + const mid = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.goto(`/#${mid.id}`); + + const row = await waitForPermalinkRow(page, mid.id); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Test Item 100'); + }); + + test('last item — Test Item 200', async ({page}) => { + const last = TEST_ITEMS.find(i => i.title === 'Test Item 200')!; + await page.goto(`/#${last.id}`); + + const row = await waitForPermalinkRow(page, last.id); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Test Item 200'); + }); +}); + +// --------------------------------------------------------------------------- +// In-page hash navigation: load `/` first, wait for the list, then set +// location.hash. The app should scroll the target row into view and select it. +// --------------------------------------------------------------------------- + +test.describe('In-page hash navigation', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + await waitForScrollStatePersisted(page); + }); + + test('first page item — scrolls and selects', async ({page}) => { + const beta = TEST_ITEMS.find(i => i.title === 'Beta Item')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, beta.id); + await page.waitForURL(`/#${beta.id}`); + + const row = page.locator(`a[href="#${beta.id}"]`); + await expect(row).toBeVisible({timeout: TIMEOUT}); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Beta Item'); + }); + + test('page-boundary item — scrolls and selects', async ({page}) => { + const mid = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, mid.id); + await page.waitForURL(`/#${mid.id}`); + + const row = await waitForPermalinkRow(page, mid.id); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Test Item 100'); + }); + + test('last item — scrolls and selects', async ({page}) => { + const last = TEST_ITEMS.find(i => i.title === 'Test Item 200')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, last.id); + await page.waitForURL(`/#${last.id}`); + + const row = await waitForPermalinkRow(page, last.id); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Test Item 200'); + }); +}); + +// --------------------------------------------------------------------------- +// Back / forward navigation and scroll restore: the virtualizer persists +// scroll state in history.state via the Navigation API. Navigating back +// or forward should restore the scroll position and visible rows. +// --------------------------------------------------------------------------- + +test.describe('Back / forward and scroll restore', () => { + test('back after hash nav restores scroll position at the top', async ({ + page, + }) => { + await page.goto('/'); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toContainText( + 'Alpha Item', + ); + + // Wait for scroll state to be saved before navigating away. + await waitForScrollStatePersisted(page); + + // Navigate to a far item (pushes a new history entry). + const far = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, far.id); + await page.waitForURL(`/#${far.id}`); + await expect(page.locator(`a[href="#${far.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + // Go back — should restore to the top with Alpha Item visible. + await page.goBack(); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toContainText( + 'Alpha Item', + ); + }); + + test('forward after back restores the permalink position', async ({page}) => { + await page.goto('/'); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + // Wait for scroll state to be saved before navigating away. + await waitForScrollStatePersisted(page); + + // Navigate to a far item. + const far = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, far.id); + await page.waitForURL(`/#${far.id}`); + const farRow = page.locator(`a[href="#${far.id}"]`); + await expect(farRow).toBeVisible({timeout: TIMEOUT}); + + // Go back, then forward. + await page.goBack(); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + await page.goForward(); + await page.waitForURL(`/#${far.id}`); + await expect(async () => { + await expect(farRow).toBeVisible(); + await expect(farRow).toHaveAttribute('aria-selected', 'true'); + }).toPass({timeout: TIMEOUT}); + }); + + test('back restores a mid-list scroll position', async ({page}) => { + await page.goto('/'); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + // Grab the viewport element for scrolling. + const viewportEl = await page + .locator('[class*="viewport"]') + .elementHandle(); + + // Scroll partway down — enough to see row ~20 but not the very top. + await viewportEl!.evaluate(el => { + el.scrollTop = 800; + }); + + // Wait for a row around that scroll offset to appear. + await expect( + page + .locator( + 'a[href="#tstitem016"], a[href="#tstitem021"], a[href="#tstitem026"]', + ) + .first(), + ).toBeVisible({timeout: TIMEOUT}); + + // Record which row is visible at the top of the viewport. + const visibleRowHref = await page.evaluate(() => { + const viewport = document.querySelector('[class*="viewport"]'); + if (!viewport) return null; + const rect = viewport.getBoundingClientRect(); + const rows = [...document.querySelectorAll('a[href^="#"]')]; + let best: {href: string | null; top: number} | null = null; + for (const row of rows) { + const rowRect = row.getBoundingClientRect(); + if (rowRect.top >= rect.top - 5) { + if (!best || rowRect.top < best.top) { + best = {href: row.getAttribute('href'), top: rowRect.top}; + } + } + } + return best?.href ?? null; + }); + + // Wait for scroll state to be persisted (debounced at 100ms). + await waitForScrollStatePersisted(page); + + // Navigate to a permalink (pushes new history entry). + const far = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, far.id); + await page.waitForURL(`/#${far.id}`); + await expect(page.locator(`a[href="#${far.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + // Go back — should restore the mid-list scroll position. + await page.goBack(); + + // The previously visible row should reappear near the same position. + await expect(page.locator(`a[href="${visibleRowHref}"]`)).toBeVisible({ timeout: TIMEOUT, }); - await expect(page.locator('a[data-index="0"]')).toContainText('Alpha Item'); }); }); diff --git a/demo/e2e/tests/sort.spec.ts b/demo/e2e/tests/sort.spec.ts index f3d660b..082510e 100644 --- a/demo/e2e/tests/sort.spec.ts +++ b/demo/e2e/tests/sort.spec.ts @@ -1,4 +1,4 @@ -import {expect, test} from '@playwright/test'; +import {expect, test, type Page} from '@playwright/test'; // Seed data ordering summary (see seed-test.ts for details): // @@ -9,11 +9,42 @@ import {expect, test} from '@playwright/test'; const TIMEOUT = 15_000; +/** + * Assert that the row containing `text` is the first visible item in + * the scrollable viewport (i.e. closest to the top edge). Uses a retry + * loop because sort changes are async. + */ +async function expectFirstVisibleRow(page: Page, text: string) { + await expect( + async () => { + const isFirst = await page.evaluate((txt: string) => { + const viewport = document.querySelector('[class*="viewport"]'); + if (!viewport) return false; + const vpTop = viewport.getBoundingClientRect().top; + const rows = [...viewport.querySelectorAll('a[href^="#"]')]; + if (rows.length === 0) return false; + // Find the row closest to the viewport top. + let best: {el: Element; dist: number} | null = null; + for (const row of rows) { + const dist = Math.abs(row.getBoundingClientRect().top - vpTop); + if (!best || dist < best.dist) { + best = {el: row, dist}; + } + } + return best?.el.textContent?.includes(txt) ?? false; + }, text); + expect(isFirst).toBe(true); + }, + ).toPass({timeout: TIMEOUT}); +} + test.describe('Sort controls', () => { test.beforeEach(async ({page}) => { await page.goto('/'); // Wait until the list has loaded real rows. - await expect(page.locator('a[data-index="0"]')).toBeVisible({ + await expect( + page.locator('[class*="viewport"] a[href^="#"]').first(), + ).toBeVisible({ timeout: TIMEOUT, }); }); @@ -25,13 +56,11 @@ test.describe('Sort controls', () => { test('default state: sort direction button title is "Descending"', async ({ page, }) => { - await expect( - page.getByRole('button', {name: 'Descending'}), - ).toBeVisible(); + await expect(page.getByRole('button', {name: 'Descending'})).toBeVisible(); }); test('default (modified desc): Alpha Item is first', async ({page}) => { - await expect(page.locator('a[data-index="0"]')).toContainText('Alpha Item'); + await expectFirstVisibleRow(page, 'Alpha Item'); }); test('toggle sort field to created → Kappa Item is first (created desc)', async ({ @@ -43,28 +72,21 @@ test.describe('Sort controls', () => { await expect(page.getByRole('button', {name: 'Created'})).toBeVisible(); // Kappa Item has the highest created timestamp (BASE+10H). - await expect(page.locator('a[data-index="0"]')).toContainText('Kappa Item', { - timeout: TIMEOUT, - }); + await expectFirstVisibleRow(page, 'Kappa Item'); }); test('toggle direction to asc while on created → Test Item 011 is first (created asc)', async ({ page, }) => { await page.getByRole('button', {name: 'Modified'}).click(); - await expect(page.locator('a[data-index="0"]')).toContainText('Kappa Item', { - timeout: TIMEOUT, - }); + await expectFirstVisibleRow(page, 'Kappa Item'); // Flip direction: "Descending" → "Ascending". await page.getByRole('button', {name: 'Descending'}).click(); await expect(page.getByRole('button', {name: 'Ascending'})).toBeVisible(); // Test Item 011 has the lowest created timestamp (BASE−190H). - await expect(page.locator('a[data-index="0"]')).toContainText( - 'Test Item 011', - {timeout: TIMEOUT}, - ); + await expectFirstVisibleRow(page, 'Test Item 011'); }); test('toggle field back to modified while on created asc → Test Item 200 is first (modified asc)', async ({ @@ -73,19 +95,13 @@ test.describe('Sort controls', () => { // Navigate to created asc. await page.getByRole('button', {name: 'Modified'}).click(); await page.getByRole('button', {name: 'Descending'}).click(); - await expect(page.locator('a[data-index="0"]')).toContainText( - 'Test Item 011', - {timeout: TIMEOUT}, - ); + await expectFirstVisibleRow(page, 'Test Item 011'); // Switch field back to modified (direction stays asc → modified asc). await page.getByRole('button', {name: 'Created'}).click(); await expect(page.getByRole('button', {name: 'Modified'})).toBeVisible(); // Test Item 200 has the lowest modified timestamp (BASE−190H). - await expect(page.locator('a[data-index="0"]')).toContainText( - 'Test Item 200', - {timeout: TIMEOUT}, - ); + await expectFirstVisibleRow(page, 'Test Item 200'); }); }); diff --git a/demo/e2e/tsconfig.json b/demo/e2e/tsconfig.json index 0423514..5de5553 100644 --- a/demo/e2e/tsconfig.json +++ b/demo/e2e/tsconfig.json @@ -1,7 +1,9 @@ { "extends": "../../tsconfig-shared.json", "compilerOptions": { - "types": ["@playwright/test"] + "types": ["@playwright/test"], + "module": "ESNext", + "moduleResolution": "Bundler" }, "include": ["./**/*.ts", "../playwright.config.ts"] } diff --git a/demo/main.tsx b/demo/main.tsx index 4705fce..887a6ea 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -5,6 +5,7 @@ import './index.css'; import {schema} from './schema.ts'; const userID = import.meta.env.VITE_PUBLIC_USER_ID ?? 'anon'; +const cachePort = import.meta.env.VITE_PUBLIC_CACHE_PORT ?? '5858'; const url = new URL(window.location.href); const apiBase = `${url.origin}/api/zero`; @@ -12,7 +13,7 @@ createRoot(document.getElementById('root')!).render( diff --git a/demo/package.json b/demo/package.json index 7c3e9d9..043a7c5 100644 --- a/demo/package.json +++ b/demo/package.json @@ -21,10 +21,12 @@ }, "devDependencies": { "@faker-js/faker": "^10.4.0", - "@playwright/test": "^1.50.0", + "@playwright/test": "^1.59.0", "@tanstack/react-virtual": "^3.13.23", "@types/node": "^25.5.0", "@types/pg": "^8.20.0", + "playwright": "^1.59.0", + "playwright-core": "^1.59.0", "react": "^19.2.4", "react-dom": "^19.2.4", "vite": "^8.0.3" diff --git a/demo/vite-env.d.ts b/demo/vite-env.d.ts index b8415a0..7fd3a4a 100644 --- a/demo/vite-env.d.ts +++ b/demo/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_PUBLIC_USER_ID: string; + readonly VITE_PUBLIC_CACHE_PORT: string; } interface ImportMeta { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3696ba..704b362 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,8 +80,8 @@ importers: specifier: ^10.4.0 version: 10.4.0 '@playwright/test': - specifier: ^1.50.0 - version: 1.58.2 + specifier: ^1.59.0 + version: 1.59.0 '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -91,6 +91,12 @@ importers: '@types/pg': specifier: ^8.20.0 version: 8.20.0 + playwright: + specifier: ^1.59.0 + version: 1.59.0 + playwright-core: + specifier: ^1.59.0 + version: 1.59.0 react: specifier: ^19.2.4 version: 19.2.4 @@ -1202,8 +1208,8 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + '@playwright/test@1.59.0': + resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==} engines: {node: '>=18'} hasBin: true @@ -2377,13 +2383,13 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + playwright-core@1.59.0: + resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==} engines: {node: '>=18'} hasBin: true - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + playwright@1.59.0: + resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==} engines: {node: '>=18'} hasBin: true @@ -4022,9 +4028,9 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@playwright/test@1.58.2': + '@playwright/test@1.59.0': dependencies: - playwright: 1.58.2 + playwright: 1.59.0 '@polka/url@1.0.0-next.29': optional: true @@ -4320,11 +4326,11 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260327.2 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260327.2 - '@vitest/browser-playwright@4.1.2(playwright@1.58.2)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2)': + '@vitest/browser-playwright@4.1.2(playwright@1.59.0)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2)': dependencies: '@vitest/browser': 4.1.2(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2) '@vitest/mocker': 4.1.2(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0)) - playwright: 1.58.2 + playwright: 1.59.0 tinyrainbow: 3.1.0 vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.2)(happy-dom@20.8.9)(jsdom@29.0.1(@noble/hashes@1.8.0))(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0)) transitivePeerDependencies: @@ -5245,11 +5251,11 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 - playwright-core@1.58.2: {} + playwright-core@1.59.0: {} - playwright@1.58.2: + playwright@1.59.0: dependencies: - playwright-core: 1.58.2 + playwright-core: 1.59.0 optionalDependencies: fsevents: 2.3.2 @@ -5648,7 +5654,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 25.5.0 - '@vitest/browser-playwright': 4.1.2(playwright@1.58.2)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2) + '@vitest/browser-playwright': 4.1.2(playwright@1.59.0)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2) happy-dom: 20.8.9 jsdom: 29.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: diff --git a/src/react/use-zero-virtualizer.ts b/src/react/use-zero-virtualizer.ts index c43fe12..52850bb 100644 --- a/src/react/use-zero-virtualizer.ts +++ b/src/react/use-zero-virtualizer.ts @@ -258,6 +258,11 @@ export function useZeroVirtualizer< // Settled state: starts unsettled, flips to true after settleTime ms of // no scroll activity. Resets on scroll or listContextParams change. const [settled, setSettled] = useState(false); + // Tracks that a programmatic scroll adjustment (scrollToOffset) has been + // issued but the browser scroll event has not yet been processed by the + // virtualizer. While true, virtual items and scrollOffset are stale and + // must not be used for paging decisions. + const awaitingScrollSettleRef = useRef(false); const scrollOffsetRef = useRef(undefined); const resetSettleTimer = useCallback(() => { @@ -389,11 +394,29 @@ export function useZeroVirtualizer< offset !== scrollOffsetRef.current; scrollOffsetRef.current = offset ?? undefined; if (didScroll) { + awaitingScrollSettleRef.current = false; return resetSettleTimer(); } return undefined; }, [virtualizer.scrollOffset, resetSettleTimer]); + // Wrappers that mark a programmatic scroll as pending so paging effects + // skip stale virtual items until the browser fires the real scroll event. + const scrollToOffset = (targetOffset: number) => { + const currentOffset = virtualizer.scrollOffset ?? 0; + virtualizer.scrollToOffset(targetOffset); + if (targetOffset !== currentOffset) { + awaitingScrollSettleRef.current = true; + } + }; + + const scrollToIndex = ( + ...args: Parameters + ) => { + virtualizer.scrollToIndex(...args); + awaitingScrollSettleRef.current = true; + }; + useEffect(() => { // Make sure page size is enough to fill the scroll element at least // 3 times. Don't shrink page size. @@ -466,12 +489,12 @@ export function useZeroVirtualizer< // Apply scroll adjustments synchronously with layout to prevent visual jumps useLayoutEffect(() => { if (pendingScrollAdjustment !== 0) { - virtualizer.scrollToOffset( + const targetOffset = (virtualizer.scrollOffset ?? 0) + - pendingScrollAdjustment * - // TODO: Support dynamic item sizes - estimateSize(0), - ); + pendingScrollAdjustment * + // TODO: Support dynamic item sizes + estimateSize(0); + scrollToOffset(targetOffset); dispatch({type: 'SCROLL_ADJUSTED'}); } @@ -533,7 +556,7 @@ export function useZeroVirtualizer< if (!isListContextCurrent || scrollStateChanged) { if (effectiveScrollState) { - virtualizer.scrollToOffset(effectiveScrollState.scrollTop); + scrollToOffset(effectiveScrollState.scrollTop); dispatch({ type: 'RESET_STATE', estimatedTotal: effectiveScrollState.estimatedTotal, @@ -553,17 +576,17 @@ export function useZeroVirtualizer< : undefined; if (permalinkVirtualItem) { - virtualizer.scrollToIndex(permalinkVirtualItem.index, { + scrollToIndex(permalinkVirtualItem.index, { align: 'auto', }); } else { // TODO(arv): Figure out if we should scroll to top or bottom. - virtualizer.scrollToOffset( + const targetOffset = NUM_ROWS_FOR_LOADING_SKELETON * - // TODO: Support dynamic item sizes - estimateSize(0), - ); + // TODO: Support dynamic item sizes + estimateSize(0); + scrollToOffset(targetOffset); dispatch({ type: 'RESET_STATE', estimatedTotal: NUM_ROWS_FOR_LOADING_SKELETON, @@ -574,7 +597,7 @@ export function useZeroVirtualizer< }); } } else { - virtualizer.scrollToOffset(0); + scrollToOffset(0); dispatch({ type: 'RESET_STATE', estimatedTotal: 0, @@ -612,6 +635,15 @@ export function useZeroVirtualizer< return; } + // After a scroll adjustment (scrollToOffset), the browser fires the scroll + // event asynchronously. Until then the virtualizer's virtual items and + // scrollOffset are stale — they still reflect the *previous* scroll + // position. Acting on stale items would cause spurious anchor updates + // and cascading shifts. + if (awaitingScrollSettleRef.current) { + return; + } + if (atStart) { if (firstRowIndex !== 0) { dispatch({type: 'UPDATE_ANCHOR', anchor: TOP_ANCHOR}); diff --git a/vitest.config.ts b/vitest.config.ts index ee1edf9..625c9f6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,6 @@ import {defineConfig} from 'vitest/config'; export default defineConfig({ test: { environment: 'happy-dom', + include: ['src/**/*.test.{ts,tsx}'], }, }); From 753ddbafaba10f610d14a8282eeb112de4178680 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Wed, 1 Apr 2026 16:22:20 +0200 Subject: [PATCH 07/11] feat: Add Playwright e2e tests for demo and fix scroll settle race - Add Playwright test suite covering app rendering, item detail panel, sort controls, scroll/paging, permalink navigation, and back/forward scroll restore - Add global setup/teardown that starts Postgres, seeds deterministic test data, and launches zero-cache - Add GitHub Actions workflow to run e2e tests on push/PR - Make zero-cache port configurable via VITE_PUBLIC_CACHE_PORT env var - Fix race in useZeroVirtualizer where paging effects acted on stale virtual items after programmatic scrollToOffset/scrollToIndex before the browser scroll event fired (awaitingScrollSettleRef) - Restrict vitest to src/**/*.test.ts to avoid picking up Playwright tests --- .github/workflows/e2e.yml | 39 +++ .gitignore | 1 + demo/.env | 2 + demo/e2e/global-setup.ts | 180 ++++++++++++++ demo/e2e/global-teardown.ts | 17 ++ demo/e2e/seed-test.ts | 167 +++++++++++++ demo/e2e/tests/app.spec.ts | 37 +++ demo/e2e/tests/item-detail.spec.ts | 113 +++++++++ demo/e2e/tests/scroll.spec.ts | 375 +++++++++++++++++++++++++++++ demo/e2e/tests/sort.spec.ts | 107 ++++++++ demo/e2e/tsconfig.json | 9 + demo/main.tsx | 3 +- demo/package.json | 6 +- demo/playwright.config.ts | 28 +++ demo/tsconfig.app.json | 3 +- demo/vite-env.d.ts | 1 + package.json | 2 +- pnpm-lock.yaml | 40 ++- src/react/use-zero-virtualizer.ts | 56 ++++- vitest.config.ts | 1 + 20 files changed, 1159 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 demo/e2e/global-setup.ts create mode 100644 demo/e2e/global-teardown.ts create mode 100644 demo/e2e/seed-test.ts create mode 100644 demo/e2e/tests/app.spec.ts create mode 100644 demo/e2e/tests/item-detail.spec.ts create mode 100644 demo/e2e/tests/scroll.spec.ts create mode 100644 demo/e2e/tests/sort.spec.ts create mode 100644 demo/e2e/tsconfig.json create mode 100644 demo/playwright.config.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..17a8fac --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,39 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: demo + + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'pnpm' + + - run: pnpm install + working-directory: . + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: demo/playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index de648b4..430c94d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ *.db-wal2 rocicorp-zero-virtual-*.tgz docs +.last-run.json diff --git a/demo/.env b/demo/.env index c7ed767..b9e6165 100644 --- a/demo/.env +++ b/demo/.env @@ -2,3 +2,5 @@ AUTH_SECRET="abcd" ZERO_UPSTREAM_DB="postgresql://user:password@127.0.0.1:5430/postgres" ZERO_QUERY_URL="http://localhost:*/api/zero/query" ZERO_MUTATE_URL="http://localhost:*/api/zero/mutate" +VITE_PUBLIC_CACHE_PORT=5858 + diff --git a/demo/e2e/global-setup.ts b/demo/e2e/global-setup.ts new file mode 100644 index 0000000..b0d22a0 --- /dev/null +++ b/demo/e2e/global-setup.ts @@ -0,0 +1,180 @@ +import {spawn} from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import * as net from 'node:net'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import pg from 'pg'; +import {seedTestDb} from './seed-test.ts'; + +const DEMO_DIR = fileURLToPath(new URL('..', import.meta.url)); + +// Replica dir is wiped on each run so zero-cache starts with clean data. +const REPLICA_DIR = '/tmp/zero-playwright-replica'; +export const REPLICA_FILE = join(REPLICA_DIR, 'replica'); + +// PID file lets globalTeardown kill the zero-cache process. +export const PID_FILE = '/tmp/zero-playwright.pid'; + +export default async function globalSetup(): Promise { + console.log('\n[setup] Starting postgres...'); + await startPostgres(); + + console.log('[setup] Waiting for postgres...'); + await waitForPort(5430); + await waitForPostgres(); + + console.log('[setup] Seeding test data...'); + await seedTestDb(process.env['ZERO_UPSTREAM_DB']!); + + console.log('[setup] Clearing zero-cache replica...'); + killExistingZeroCache(); + if (existsSync(REPLICA_DIR)) { + rmSync(REPLICA_DIR, {recursive: true, force: true}); + } + mkdirSync(REPLICA_DIR, {recursive: true}); + + const port = Number(process.env['VITE_PUBLIC_CACHE_PORT'] ?? 5858); + console.log('[setup] Starting zero-cache...'); + const zeroCacheProc = spawnZeroCache(port); + writeFileSync(PID_FILE, String(zeroCacheProc.pid)); + + console.log(`[setup] Waiting for zero-cache on port ${port}...`); + await waitForPort(port, 60_000); + console.log('[setup] Ready.\n'); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function startPostgres(): Promise { + await new Promise((resolve, reject) => { + const proc = spawn( + 'docker', + [ + 'compose', + '--env-file', + '.env', + '-f', + './docker/docker-compose.yml', + 'up', + '-d', + ], + {cwd: DEMO_DIR, stdio: 'inherit'}, + ); + proc.on('exit', code => { + if (code === 0) resolve(); + else reject(new Error(`docker compose up exited with code ${code}`)); + }); + proc.on('error', reject); + }); +} + +async function waitForPostgres(timeoutMs = 30_000): Promise { + const connStr = process.env['ZERO_UPSTREAM_DB']!; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const pool = new pg.Pool({connectionString: connStr, max: 1}); + const client = await pool.connect(); + client.release(); + await pool.end(); + return; + } catch { + await sleep(500); + } + } + throw new Error('Postgres not ready within timeout'); +} + +function waitForPort(port: number, timeoutMs = 30_000): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + + function tryConnect() { + const socket = new net.Socket(); + socket.setTimeout(1_000); + + socket.on('connect', () => { + socket.destroy(); + resolve(); + }); + + const retry = () => { + socket.destroy(); + if (Date.now() >= deadline) { + reject(new Error(`Port ${port} not available after ${timeoutMs}ms`)); + return; + } + setTimeout(tryConnect, 500); + }; + + socket.on('timeout', retry); + socket.on('error', retry); + socket.connect(port, '127.0.0.1'); + } + + tryConnect(); + }); +} + +function spawnZeroCache(port: number) { + const binDir = join(DEMO_DIR, 'node_modules', '.bin'); + const env: NodeJS.ProcessEnv = { + ...process.env, + // Ensure node_modules/.bin is on PATH so zero-cache-dev can find zero-cache. + PATH: `${binDir}:${process.env['PATH'] ?? ''}`, + ZERO_REPLICA_FILE: REPLICA_FILE, + ZERO_LOG_LEVEL: 'error', + }; + + // Prefer the local bin so we use the exact version pinned in demo/package.json. + const bin = join(DEMO_DIR, 'node_modules', '.bin', 'zero-cache-dev'); + const command = existsSync(bin) ? bin : 'zero-cache-dev'; + + const proc = spawn(command, ['--port', String(port)], { + cwd: DEMO_DIR, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + + proc.stdout?.on('data', (d: Buffer) => + process.stdout.write(`[zero-cache] ${d}`), + ); + proc.stderr?.on('data', (d: Buffer) => + process.stderr.write(`[zero-cache] ${d}`), + ); + + proc.on('exit', code => { + if (code !== null && code !== 0) { + console.error(`[zero-cache] exited with code ${code}`); + } + }); + + proc.unref(); + return proc; +} + +function killExistingZeroCache(): void { + if (!existsSync(PID_FILE)) return; + const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10); + if (!isNaN(pid)) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Process may already be gone — that's fine. + } + } + rmSync(PID_FILE, {force: true}); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/demo/e2e/global-teardown.ts b/demo/e2e/global-teardown.ts new file mode 100644 index 0000000..c363b95 --- /dev/null +++ b/demo/e2e/global-teardown.ts @@ -0,0 +1,17 @@ +import {existsSync, readFileSync, rmSync} from 'node:fs'; +import {PID_FILE} from './global-setup.ts'; + +export default async function globalTeardown(): Promise { + if (!existsSync(PID_FILE)) return; + + const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10); + if (!isNaN(pid)) { + try { + process.kill(pid, 'SIGTERM'); + console.log(`[teardown] Stopped zero-cache (pid ${pid})`); + } catch { + // Already gone — that's fine. + } + } + rmSync(PID_FILE, {force: true}); +} diff --git a/demo/e2e/seed-test.ts b/demo/e2e/seed-test.ts new file mode 100644 index 0000000..f010b9f --- /dev/null +++ b/demo/e2e/seed-test.ts @@ -0,0 +1,167 @@ +import pg from 'pg'; + +// Fixed base timestamp: 2023-11-14T22:13:20.000Z +const BASE = 1_700_000_000_000; +const H = 3_600_000; // 1 hour in ms + +export type TestItem = { + id: string; + title: string; + description: string; + created: number; + modified: number; +}; + +// Named items (1–10): Alpha through Kappa. +// +// Within this group created and modified are inverses of each other. +// Within the extra group they are also inverses. +// This means all four sort orderings produce a distinct first row: +// +// modified DESC (default) → Alpha Item (modified = BASE+10H, highest) +// modified ASC → Test Item 200 (modified = BASE−190H, lowest) +// created DESC → Kappa Item (created = BASE+10H, highest) +// created ASC → Test Item 011 (created = BASE−190H, lowest) +// +// The 200 items also give the virtualizer enough rows to exercise paging +// (the min page size in the demo is 100). +const NAMED: TestItem[] = [ + { + id: 'tstitem001', + title: 'Alpha Item', + description: 'Alpha test item.', + created: BASE + 1 * H, + modified: BASE + 10 * H, + }, + { + id: 'tstitem002', + title: 'Beta Item', + description: 'Beta test item.', + created: BASE + 2 * H, + modified: BASE + 9 * H, + }, + { + id: 'tstitem003', + title: 'Gamma Item', + description: 'Gamma test item.', + created: BASE + 3 * H, + modified: BASE + 8 * H, + }, + { + id: 'tstitem004', + title: 'Delta Item', + description: 'Delta test item.', + created: BASE + 4 * H, + modified: BASE + 7 * H, + }, + { + id: 'tstitem005', + title: 'Epsilon Item', + description: 'Epsilon test item.', + created: BASE + 5 * H, + modified: BASE + 6 * H, + }, + { + id: 'tstitem006', + title: 'Zeta Item', + description: 'Zeta test item.', + created: BASE + 6 * H, + modified: BASE + 5 * H, + }, + { + id: 'tstitem007', + title: 'Eta Item', + description: 'Eta test item.', + created: BASE + 7 * H, + modified: BASE + 4 * H, + }, + { + id: 'tstitem008', + title: 'Theta Item', + description: 'Theta test item.', + created: BASE + 8 * H, + modified: BASE + 3 * H, + }, + { + id: 'tstitem009', + title: 'Iota Item', + description: 'Iota test item.', + created: BASE + 9 * H, + modified: BASE + 2 * H, + }, + { + id: 'tstitem010', + title: 'Kappa Item', + description: 'Kappa test item.', + created: BASE + 10 * H, + modified: BASE + 1 * H, + }, +]; + +// Extra items (11–200): programmatically generated with inverted +// created/modified so the extremes are Test Item 011 and Test Item 200. +// +// i=11: created = BASE−190H (lowest created), modified = BASE−1H +// i=200: created = BASE−1H, modified = BASE−190H (lowest modified) +const EXTRA: TestItem[] = Array.from({length: 190}, (_, k) => { + const i = k + 11; // 11..200 + const n = String(i).padStart(3, '0'); + return { + id: `tstitem${n}`, + title: `Test Item ${n}`, + description: `Test item ${n} description.`, + created: BASE - (201 - i) * H, + modified: BASE - (i - 10) * H, + }; +}); + +export const TEST_ITEMS: TestItem[] = [...NAMED, ...EXTRA]; + +export async function seedTestDb(connectionString: string): Promise { + const pool = new pg.Pool({connectionString}); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Drop stale logical replication slots so zero-cache can start fresh. + // Terminate any backends using the slots first, then drop them. + await client.query(` + SELECT pg_terminate_backend(active_pid) + FROM pg_replication_slots + WHERE slot_type = 'logical' AND active + `); + await client.query(` + SELECT pg_drop_replication_slot(slot_name) + FROM pg_replication_slots + WHERE slot_type = 'logical' + `); + + await client.query('DROP TABLE IF EXISTS item CASCADE'); + await client.query(` + CREATE TABLE item ( + id VARCHAR PRIMARY KEY, + title VARCHAR NOT NULL, + description VARCHAR NOT NULL, + created FLOAT8 NOT NULL, + modified FLOAT8 NOT NULL + ) + `); + + for (const item of TEST_ITEMS) { + await client.query( + `INSERT INTO item (id, title, description, created, modified) + VALUES ($1, $2, $3, $4, $5)`, + [item.id, item.title, item.description, item.created, item.modified], + ); + } + + await client.query('COMMIT'); + console.log(`Seeded ${TEST_ITEMS.length} test items`); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + await pool.end(); + } +} diff --git a/demo/e2e/tests/app.spec.ts b/demo/e2e/tests/app.spec.ts new file mode 100644 index 0000000..603ae44 --- /dev/null +++ b/demo/e2e/tests/app.spec.ts @@ -0,0 +1,37 @@ +import {expect, test} from '@playwright/test'; + +test.describe('App', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + }); + + test('shows the page heading', async ({page}) => { + await expect(page.getByRole('heading', {level: 1})).toContainText( + 'Zero Virtual Demo', + ); + }); + + test('shows the correct item count', async ({page}) => { + // The virtualizer lazy-loads pages, so the initial count may be an + // estimate of the first page only (e.g. "(~100)"). Just verify that + // some item count is displayed in the heading. + await expect(page.getByText(/\(~?\d+\)/)).toBeVisible({timeout: 15_000}); + }); + + test('renders list rows', async ({page}) => { + // Wait for the first real row (an element, not a placeholder
). + await expect( + page.locator('[class*="viewport"] a[href^="#"]').first(), + ).toBeVisible({ + timeout: 15_000, + }); + }); + + test('default sort is modified descending — Alpha Item is first', async ({ + page, + }) => { + // Alpha Item has the highest modified timestamp so it should be first. + const firstRow = page.getByRole('link', {name: 'Alpha Item'}); + await expect(firstRow).toBeVisible({timeout: 15_000}); + }); +}); diff --git a/demo/e2e/tests/item-detail.spec.ts b/demo/e2e/tests/item-detail.spec.ts new file mode 100644 index 0000000..9a87920 --- /dev/null +++ b/demo/e2e/tests/item-detail.spec.ts @@ -0,0 +1,113 @@ +import {expect, test} from '@playwright/test'; +import {TEST_ITEMS} from '../seed-test.ts'; + +const TIMEOUT = 15_000; + +// In the default sort (modified desc) Alpha Item is at index 0. +const ALPHA = TEST_ITEMS.find(i => i.title === 'Alpha Item')!; + +test.describe('Item detail panel', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + // Wait for the list to have real rows loaded. + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('clicking a row opens the detail panel', async ({page}) => { + await page.locator(`a[href="#${ALPHA.id}"]`).click(); + + // The panel should appear and show the item title in an

. + await expect(page.getByRole('heading', {level: 2})).toContainText( + 'Alpha Item', + {timeout: TIMEOUT}, + ); + }); + + test('detail panel shows the item description', async ({page}) => { + await page.locator(`a[href="#${ALPHA.id}"]`).click(); + + await expect(page.getByText(ALPHA.description)).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('detail panel shows the item ID', async ({page}) => { + await page.locator(`a[href="#${ALPHA.id}"]`).click(); + + await expect(page.getByText(ALPHA.id)).toBeVisible({timeout: TIMEOUT}); + }); + + test('clicking a row sets the URL hash to the item ID', async ({page}) => { + await page.locator(`a[href="#${ALPHA.id}"]`).click(); + + await expect(page).toHaveURL(`/#${ALPHA.id}`, {timeout: TIMEOUT}); + }); + + test('the selected row gets aria-selected="true"', async ({page}) => { + await page.locator(`a[href="#${ALPHA.id}"]`).click(); + + // After clicking, the row should carry aria-selected. + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toHaveAttribute( + 'aria-selected', + 'true', + {timeout: TIMEOUT}, + ); + }); + + test('close button hides the detail panel', async ({page}) => { + await page.locator(`a[href="#${ALPHA.id}"]`).click(); + + // Confirm panel opened. + await expect(page.getByRole('heading', {level: 2})).toBeVisible({ + timeout: TIMEOUT, + }); + + // Click the close button (aria-label="Close"). + await page.getByRole('button', {name: 'Close'}).click(); + + // Panel should no longer be visible. + await expect(page.getByRole('heading', {level: 2})).not.toBeVisible(); + }); + + test('closing the panel clears the URL hash', async ({page}) => { + await page.locator(`a[href="#${ALPHA.id}"]`).click(); + await expect(page).toHaveURL(`/#${ALPHA.id}`, {timeout: TIMEOUT}); + + await page.getByRole('button', {name: 'Close'}).click(); + + // Hash should be cleared (URL ends with just /). + await expect(page).toHaveURL(/\/#?$/, {timeout: TIMEOUT}); + }); + + test('navigating directly to a permalink shows the detail panel', async ({ + page, + }) => { + await page.goto(`/#${ALPHA.id}`); + + await expect(page.getByRole('heading', {level: 2})).toContainText( + 'Alpha Item', + {timeout: TIMEOUT}, + ); + }); + + test('permalink to an item far down the list shows loading then resolves', async ({ + page, + }) => { + // Test Item 150 is near index 149 (second page) and is not loaded + // initially. The detail panel should show "Loading…" while the data + // is fetched, then resolve to the item. + const farItem = TEST_ITEMS.find(i => i.title === 'Test Item 150')!; + await page.goto(`/#${farItem.id}`); + + // Initially the detail panel shows a loading indicator. + await expect(page.getByText('Loading…')).toBeVisible({timeout: TIMEOUT}); + + // Eventually the item title appears in the panel heading. + await expect(page.getByRole('heading', {level: 2})).toContainText( + 'Test Item 150', + {timeout: TIMEOUT}, + ); + }); +}); diff --git a/demo/e2e/tests/scroll.spec.ts b/demo/e2e/tests/scroll.spec.ts new file mode 100644 index 0000000..ba5936c --- /dev/null +++ b/demo/e2e/tests/scroll.spec.ts @@ -0,0 +1,375 @@ +import { expect, test, type Page } from '@playwright/test'; +import { TEST_ITEMS } from '../seed-test.ts'; + +const TIMEOUT = 20_000; + +// In default sort (modified DESC), Alpha Item is first. +const ALPHA = TEST_ITEMS.find(i => i.title === 'Alpha Item')!; + +/** + * Wait for the virtualizer's scroll state to be persisted into the + * Navigation API's current entry state. The virtualizer debounces + * `onScrollStateChange` at 100ms, so after the initial data render + * the state is not immediately available. In-page hash navigations + * (navigation.navigate) only trigger re-anchoring when the persisted + * scroll state changes between the old and new history entries, so we + * must wait for it before navigating away. + */ +async function waitForScrollStatePersisted(page: Page) { + await expect(async () => { + const persisted = await page.evaluate( + () => + (navigation.currentEntry?.getState() as Record) + ?.scrollState != null, + ); + expect(persisted).toBe(true); + }).toPass({timeout: TIMEOUT}); +} + +// The virtual list renders only the visible rows. Scrolling causes new pages +// to be fetched and new rows to be inserted into the DOM. With 200 items and a +// min page size of 100, there are at least 2 pages to load. + +test.describe('Scroll / paging', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('initial render shows the correct item count', async ({page}) => { + // The virtualizer lazy-loads pages, so the initial count is an estimate + // based on the first page only (e.g. "(~100)"). Verify a count appears. + await expect(page.getByText(/\(~?\d+\)/)).toBeVisible({timeout: TIMEOUT}); + }); + + test('scrolling to the bottom loads items from the second page', async ({ + page, + }) => { + const viewportEl = await page + .locator('[class*="viewport"]') + .elementHandle(); + + // The virtualizer lazy-loads pages, so we need to scroll to the bottom + // repeatedly — each scroll triggers loading the next page, which extends + // the scrollable area. + await expect(async () => { + await viewportEl!.evaluate(el => { + el.scrollTop = el.scrollHeight; + }); + await expect( + page.locator(`a[href="#${TEST_ITEMS[TEST_ITEMS.length - 1].id}"]`), + ).toBeVisible(); + }).toPass({timeout: TIMEOUT}); + }); + + test('scrolling down and back up restores the first item', async ({page}) => { + const viewportEl = await page + .locator('[class*="viewport"]') + .elementHandle(); + + await viewportEl!.evaluate(el => { + el.scrollTop = el.scrollHeight; + }); + + // Scroll back to the top. + await viewportEl!.evaluate(el => { + el.scrollTop = 0; + }); + + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toContainText( + 'Alpha Item', + ); + }); +}); + +/** + * Wait for a permalink row to become visible and selected. The virtualizer + * scrolls to the target automatically but the initial data fetch and + * pagination adjustments are async. + * + * Strategy: + * 1. Wait for any list rows to appear (data loaded). + * 2. Wait for the scroll position to stabilize (no change for 500 ms). + * 3. If the target row isn't visible yet, scroll the viewport down in + * viewport-sized steps until it appears. This mirrors what a user does + * when the virtualizer's auto-scroll undershoots. + * 4. Assert the row is visible and selected. + */ +async function waitForPermalinkRow(page: Page, id: string) { + const row = page.locator(`a[href="#${id}"]`); + + // 1. Wait for any rows to be rendered. + await expect( + page.locator('[class*="viewport"] a[href^="#"]').first(), + ).toBeVisible({timeout: 10_000}); + + // 2. Wait for scroll to settle. + await page.evaluate( + () => + new Promise(resolve => { + const vp = document.querySelector('[class*="viewport"]'); + if (!vp) { + resolve(); + return; + } + let last = vp.scrollTop; + const check = () => { + if (vp.scrollTop === last) { + resolve(); + } else { + last = vp.scrollTop; + setTimeout(check, 50); + } + }; + setTimeout(check, 50); + }), + ); + + // 3. If the row isn't visible, scroll down in steps until it appears. + await expect(async () => { + // const visible = await row.isVisible().catch(() => false); + // if (!visible) { + // await page.evaluate(() => { + // const vp = document.querySelector('[class*="viewport"]'); + // if (vp) vp.scrollTop += vp.clientHeight; + // }); + // } + await expect(row).toBeVisible({timeout: 1_000}); + }).toPass({timeout: 20_000}); + + // 4. Assert selected. + await expect(row).toHaveAttribute('aria-selected', 'true'); + return row; +} + +// --------------------------------------------------------------------------- +// Direct permalink navigation: load the app with a hash already in the URL +// (no prior `/` load). The app must scroll the target row into view and +// select it on first render. +// --------------------------------------------------------------------------- + +test.describe('Direct permalink navigation', () => { + test('first page item — Beta Item', async ({page}) => { + const beta = TEST_ITEMS.find(i => i.title === 'Beta Item')!; + await page.goto(`/#${beta.id}`); + + const row = page.locator(`a[href="#${beta.id}"]`); + await expect(row).toBeVisible({timeout: TIMEOUT}); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Beta Item'); + }); + + test('page-boundary item — Test Item 100', async ({page}) => { + const mid = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.goto(`/#${mid.id}`); + + const row = await waitForPermalinkRow(page, mid.id); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Test Item 100'); + }); + + test('last item — Test Item 200', async ({page}) => { + const last = TEST_ITEMS.find(i => i.title === 'Test Item 200')!; + await page.goto(`/#${last.id}`); + + const row = await waitForPermalinkRow(page, last.id); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Test Item 200'); + }); +}); + +// --------------------------------------------------------------------------- +// In-page hash navigation: load `/` first, wait for the list, then set +// location.hash. The app should scroll the target row into view and select it. +// --------------------------------------------------------------------------- + +test.describe('In-page hash navigation', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + await waitForScrollStatePersisted(page); + }); + + test('first page item — scrolls and selects', async ({page}) => { + const beta = TEST_ITEMS.find(i => i.title === 'Beta Item')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, beta.id); + await page.waitForURL(`/#${beta.id}`); + + const row = page.locator(`a[href="#${beta.id}"]`); + await expect(row).toBeVisible({timeout: TIMEOUT}); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Beta Item'); + }); + + test('page-boundary item — scrolls and selects', async ({page}) => { + const mid = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, mid.id); + await page.waitForURL(`/#${mid.id}`); + + const row = await waitForPermalinkRow(page, mid.id); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Test Item 100'); + }); + + test('last item — scrolls and selects', async ({page}) => { + const last = TEST_ITEMS.find(i => i.title === 'Test Item 200')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, last.id); + await page.waitForURL(`/#${last.id}`); + + const row = await waitForPermalinkRow(page, last.id); + await expect(row).toHaveAttribute('aria-selected', 'true'); + await expect(row).toContainText('Test Item 200'); + }); +}); + +// --------------------------------------------------------------------------- +// Back / forward navigation and scroll restore: the virtualizer persists +// scroll state in history.state via the Navigation API. Navigating back +// or forward should restore the scroll position and visible rows. +// --------------------------------------------------------------------------- + +test.describe('Back / forward and scroll restore', () => { + test('back after hash nav restores scroll position at the top', async ({ + page, + }) => { + await page.goto('/'); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toContainText( + 'Alpha Item', + ); + + // Wait for scroll state to be saved before navigating away. + await waitForScrollStatePersisted(page); + + // Navigate to a far item (pushes a new history entry). + const far = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, far.id); + await page.waitForURL(`/#${far.id}`); + await expect(page.locator(`a[href="#${far.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + // Go back — should restore to the top with Alpha Item visible. + await page.goBack(); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toContainText( + 'Alpha Item', + ); + }); + + test('forward after back restores the permalink position', async ({page}) => { + await page.goto('/'); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + // Wait for scroll state to be saved before navigating away. + await waitForScrollStatePersisted(page); + + // Navigate to a far item. + const far = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, far.id); + await page.waitForURL(`/#${far.id}`); + const farRow = page.locator(`a[href="#${far.id}"]`); + await expect(farRow).toBeVisible({timeout: TIMEOUT}); + + // Go back, then forward. + await page.goBack(); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + await page.goForward(); + await page.waitForURL(`/#${far.id}`); + await expect(async () => { + await expect(farRow).toBeVisible(); + await expect(farRow).toHaveAttribute('aria-selected', 'true'); + }).toPass({timeout: TIMEOUT}); + }); + + test('back restores a mid-list scroll position', async ({page}) => { + await page.goto('/'); + await expect(page.locator(`a[href="#${ALPHA.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + // Grab the viewport element for scrolling. + const viewportEl = await page + .locator('[class*="viewport"]') + .elementHandle(); + + // Scroll partway down — enough to see row ~20 but not the very top. + await viewportEl!.evaluate(el => { + el.scrollTop = 800; + }); + + // Wait for a row around that scroll offset to appear. + await expect( + page + .locator( + 'a[href="#tstitem016"], a[href="#tstitem021"], a[href="#tstitem026"]', + ) + .first(), + ).toBeVisible({timeout: TIMEOUT}); + + // Record which row is visible at the top of the viewport. + const visibleRowHref = await page.evaluate(() => { + const viewport = document.querySelector('[class*="viewport"]'); + if (!viewport) return null; + const rect = viewport.getBoundingClientRect(); + const rows = [...document.querySelectorAll('a[href^="#"]')]; + let best: {href: string | null; top: number} | null = null; + for (const row of rows) { + const rowRect = row.getBoundingClientRect(); + if (rowRect.top >= rect.top - 5) { + if (!best || rowRect.top < best.top) { + best = {href: row.getAttribute('href'), top: rowRect.top}; + } + } + } + return best?.href ?? null; + }); + + // Wait for scroll state to be persisted (debounced at 100ms). + await waitForScrollStatePersisted(page); + + // Navigate to a permalink (pushes new history entry). + const far = TEST_ITEMS.find(i => i.title === 'Test Item 100')!; + await page.evaluate(id => { + navigation.navigate(`#${id}`); + }, far.id); + await page.waitForURL(`/#${far.id}`); + await expect(page.locator(`a[href="#${far.id}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + + // Go back — should restore the mid-list scroll position. + await page.goBack(); + + // The previously visible row should reappear near the same position. + await expect(page.locator(`a[href="${visibleRowHref}"]`)).toBeVisible({ + timeout: TIMEOUT, + }); + }); +}); diff --git a/demo/e2e/tests/sort.spec.ts b/demo/e2e/tests/sort.spec.ts new file mode 100644 index 0000000..082510e --- /dev/null +++ b/demo/e2e/tests/sort.spec.ts @@ -0,0 +1,107 @@ +import {expect, test, type Page} from '@playwright/test'; + +// Seed data ordering summary (see seed-test.ts for details): +// +// modified DESC (default) → Alpha Item first (modified = BASE+10H) +// modified ASC → Test Item 200 first (modified = BASE−190H) +// created DESC → Kappa Item first (created = BASE+10H) +// created ASC → Test Item 011 first (created = BASE−190H) + +const TIMEOUT = 15_000; + +/** + * Assert that the row containing `text` is the first visible item in + * the scrollable viewport (i.e. closest to the top edge). Uses a retry + * loop because sort changes are async. + */ +async function expectFirstVisibleRow(page: Page, text: string) { + await expect( + async () => { + const isFirst = await page.evaluate((txt: string) => { + const viewport = document.querySelector('[class*="viewport"]'); + if (!viewport) return false; + const vpTop = viewport.getBoundingClientRect().top; + const rows = [...viewport.querySelectorAll('a[href^="#"]')]; + if (rows.length === 0) return false; + // Find the row closest to the viewport top. + let best: {el: Element; dist: number} | null = null; + for (const row of rows) { + const dist = Math.abs(row.getBoundingClientRect().top - vpTop); + if (!best || dist < best.dist) { + best = {el: row, dist}; + } + } + return best?.el.textContent?.includes(txt) ?? false; + }, text); + expect(isFirst).toBe(true); + }, + ).toPass({timeout: TIMEOUT}); +} + +test.describe('Sort controls', () => { + test.beforeEach(async ({page}) => { + await page.goto('/'); + // Wait until the list has loaded real rows. + await expect( + page.locator('[class*="viewport"] a[href^="#"]').first(), + ).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('default state: sort field button reads "Modified"', async ({page}) => { + await expect(page.getByRole('button', {name: 'Modified'})).toBeVisible(); + }); + + test('default state: sort direction button title is "Descending"', async ({ + page, + }) => { + await expect(page.getByRole('button', {name: 'Descending'})).toBeVisible(); + }); + + test('default (modified desc): Alpha Item is first', async ({page}) => { + await expectFirstVisibleRow(page, 'Alpha Item'); + }); + + test('toggle sort field to created → Kappa Item is first (created desc)', async ({ + page, + }) => { + // Click the sort-field button — it shows the current field and toggles. + await page.getByRole('button', {name: 'Modified'}).click(); + + await expect(page.getByRole('button', {name: 'Created'})).toBeVisible(); + + // Kappa Item has the highest created timestamp (BASE+10H). + await expectFirstVisibleRow(page, 'Kappa Item'); + }); + + test('toggle direction to asc while on created → Test Item 011 is first (created asc)', async ({ + page, + }) => { + await page.getByRole('button', {name: 'Modified'}).click(); + await expectFirstVisibleRow(page, 'Kappa Item'); + + // Flip direction: "Descending" → "Ascending". + await page.getByRole('button', {name: 'Descending'}).click(); + await expect(page.getByRole('button', {name: 'Ascending'})).toBeVisible(); + + // Test Item 011 has the lowest created timestamp (BASE−190H). + await expectFirstVisibleRow(page, 'Test Item 011'); + }); + + test('toggle field back to modified while on created asc → Test Item 200 is first (modified asc)', async ({ + page, + }) => { + // Navigate to created asc. + await page.getByRole('button', {name: 'Modified'}).click(); + await page.getByRole('button', {name: 'Descending'}).click(); + await expectFirstVisibleRow(page, 'Test Item 011'); + + // Switch field back to modified (direction stays asc → modified asc). + await page.getByRole('button', {name: 'Created'}).click(); + await expect(page.getByRole('button', {name: 'Modified'})).toBeVisible(); + + // Test Item 200 has the lowest modified timestamp (BASE−190H). + await expectFirstVisibleRow(page, 'Test Item 200'); + }); +}); diff --git a/demo/e2e/tsconfig.json b/demo/e2e/tsconfig.json new file mode 100644 index 0000000..5de5553 --- /dev/null +++ b/demo/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig-shared.json", + "compilerOptions": { + "types": ["@playwright/test"], + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["./**/*.ts", "../playwright.config.ts"] +} diff --git a/demo/main.tsx b/demo/main.tsx index 4705fce..887a6ea 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -5,6 +5,7 @@ import './index.css'; import {schema} from './schema.ts'; const userID = import.meta.env.VITE_PUBLIC_USER_ID ?? 'anon'; +const cachePort = import.meta.env.VITE_PUBLIC_CACHE_PORT ?? '5858'; const url = new URL(window.location.href); const apiBase = `${url.origin}/api/zero`; @@ -12,7 +13,7 @@ createRoot(document.getElementById('root')!).render( diff --git a/demo/package.json b/demo/package.json index 0cda8af..043a7c5 100644 --- a/demo/package.json +++ b/demo/package.json @@ -9,7 +9,8 @@ "dev:db-up": "docker compose --env-file .env -f ./docker/docker-compose.yml up", "dev:db-down": "docker compose --env-file .env -f ./docker/docker-compose.yml down", "dev:clean": "source .env && docker volume rm -f docker_zstart_pgdata && rm -rf \"${ZERO_REPLICA_FILE}\"*", - "seed": "node --env-file=.env seed.ts" + "seed": "node --env-file=.env seed.ts", + "test:e2e": "node --env-file=.env ./node_modules/@playwright/test/cli.js test" }, "dependencies": { "@hono/node-server": "^1.19.11", @@ -20,9 +21,12 @@ }, "devDependencies": { "@faker-js/faker": "^10.4.0", + "@playwright/test": "^1.59.0", "@tanstack/react-virtual": "^3.13.23", "@types/node": "^25.5.0", "@types/pg": "^8.20.0", + "playwright": "^1.59.0", + "playwright-core": "^1.59.0", "react": "^19.2.4", "react-dom": "^19.2.4", "vite": "^8.0.3" diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts new file mode 100644 index 0000000..18899db --- /dev/null +++ b/demo/playwright.config.ts @@ -0,0 +1,28 @@ +import {defineConfig} from '@playwright/test'; + +// Environment variables are loaded via `node --env-file=.env` in the +// test:e2e script, so no manual .env parsing is needed here. +export default defineConfig({ + testDir: './e2e/tests', + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + // Give CI more time — zero-cache cold-start and replication are slower there. + timeout: process.env.CI ? 60_000 : 30_000, + reporter: 'list', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + // Vite dev server — started after globalSetup, stopped after globalTeardown. + webServer: { + command: 'pnpm dev:ui', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/demo/tsconfig.app.json b/demo/tsconfig.app.json index 144304f..8aa7448 100644 --- a/demo/tsconfig.app.json +++ b/demo/tsconfig.app.json @@ -3,5 +3,6 @@ "compilerOptions": { "jsx": "react-jsx" }, - "include": ["./*.tsx", "./*.ts"] + "include": ["./*.tsx", "./*.ts"], + "exclude": ["./playwright.config.ts"] } diff --git a/demo/vite-env.d.ts b/demo/vite-env.d.ts index b8415a0..7fd3a4a 100644 --- a/demo/vite-env.d.ts +++ b/demo/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_PUBLIC_USER_ID: string; + readonly VITE_PUBLIC_CACHE_PORT: string; } interface ImportMeta { diff --git a/package.json b/package.json index a5efca6..5ea464c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "scripts": { "dev": "pnpm --filter demo dev", - "check-types": "tsgo -p src/tsconfig.json && tsgo -p demo/tsconfig.app.json && tsgo -p demo/tsconfig.node.json", + "check-types": "tsgo -p src/tsconfig.json && tsgo -p demo/tsconfig.app.json && tsgo -p demo/tsconfig.node.json && tsgo -p demo/e2e/tsconfig.json", "lint": "oxlint --type-aware", "check": "oxlint --type-aware --type-check", "format": "oxfmt", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c7e59b..704b362 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: '@faker-js/faker': specifier: ^10.4.0 version: 10.4.0 + '@playwright/test': + specifier: ^1.59.0 + version: 1.59.0 '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -88,6 +91,12 @@ importers: '@types/pg': specifier: ^8.20.0 version: 8.20.0 + playwright: + specifier: ^1.59.0 + version: 1.59.0 + playwright-core: + specifier: ^1.59.0 + version: 1.59.0 react: specifier: ^19.2.4 version: 19.2.4 @@ -1199,6 +1208,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.59.0': + resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2369,13 +2383,13 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + playwright-core@1.59.0: + resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==} engines: {node: '>=18'} hasBin: true - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + playwright@1.59.0: + resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==} engines: {node: '>=18'} hasBin: true @@ -4014,6 +4028,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.59.0': + dependencies: + playwright: 1.59.0 + '@polka/url@1.0.0-next.29': optional: true @@ -4308,11 +4326,11 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260327.2 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260327.2 - '@vitest/browser-playwright@4.1.2(playwright@1.58.2)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2)': + '@vitest/browser-playwright@4.1.2(playwright@1.59.0)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2)': dependencies: '@vitest/browser': 4.1.2(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2) '@vitest/mocker': 4.1.2(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0)) - playwright: 1.58.2 + playwright: 1.59.0 tinyrainbow: 3.1.0 vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.2)(happy-dom@20.8.9)(jsdom@29.0.1(@noble/hashes@1.8.0))(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0)) transitivePeerDependencies: @@ -5233,15 +5251,13 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 - playwright-core@1.58.2: - optional: true + playwright-core@1.59.0: {} - playwright@1.58.2: + playwright@1.59.0: dependencies: - playwright-core: 1.58.2 + playwright-core: 1.59.0 optionalDependencies: fsevents: 2.3.2 - optional: true pngjs@7.0.0: optional: true @@ -5638,7 +5654,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 25.5.0 - '@vitest/browser-playwright': 4.1.2(playwright@1.58.2)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2) + '@vitest/browser-playwright': 4.1.2(playwright@1.59.0)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(tsx@4.21.0))(vitest@4.1.2) happy-dom: 20.8.9 jsdom: 29.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: diff --git a/src/react/use-zero-virtualizer.ts b/src/react/use-zero-virtualizer.ts index c43fe12..52850bb 100644 --- a/src/react/use-zero-virtualizer.ts +++ b/src/react/use-zero-virtualizer.ts @@ -258,6 +258,11 @@ export function useZeroVirtualizer< // Settled state: starts unsettled, flips to true after settleTime ms of // no scroll activity. Resets on scroll or listContextParams change. const [settled, setSettled] = useState(false); + // Tracks that a programmatic scroll adjustment (scrollToOffset) has been + // issued but the browser scroll event has not yet been processed by the + // virtualizer. While true, virtual items and scrollOffset are stale and + // must not be used for paging decisions. + const awaitingScrollSettleRef = useRef(false); const scrollOffsetRef = useRef(undefined); const resetSettleTimer = useCallback(() => { @@ -389,11 +394,29 @@ export function useZeroVirtualizer< offset !== scrollOffsetRef.current; scrollOffsetRef.current = offset ?? undefined; if (didScroll) { + awaitingScrollSettleRef.current = false; return resetSettleTimer(); } return undefined; }, [virtualizer.scrollOffset, resetSettleTimer]); + // Wrappers that mark a programmatic scroll as pending so paging effects + // skip stale virtual items until the browser fires the real scroll event. + const scrollToOffset = (targetOffset: number) => { + const currentOffset = virtualizer.scrollOffset ?? 0; + virtualizer.scrollToOffset(targetOffset); + if (targetOffset !== currentOffset) { + awaitingScrollSettleRef.current = true; + } + }; + + const scrollToIndex = ( + ...args: Parameters + ) => { + virtualizer.scrollToIndex(...args); + awaitingScrollSettleRef.current = true; + }; + useEffect(() => { // Make sure page size is enough to fill the scroll element at least // 3 times. Don't shrink page size. @@ -466,12 +489,12 @@ export function useZeroVirtualizer< // Apply scroll adjustments synchronously with layout to prevent visual jumps useLayoutEffect(() => { if (pendingScrollAdjustment !== 0) { - virtualizer.scrollToOffset( + const targetOffset = (virtualizer.scrollOffset ?? 0) + - pendingScrollAdjustment * - // TODO: Support dynamic item sizes - estimateSize(0), - ); + pendingScrollAdjustment * + // TODO: Support dynamic item sizes + estimateSize(0); + scrollToOffset(targetOffset); dispatch({type: 'SCROLL_ADJUSTED'}); } @@ -533,7 +556,7 @@ export function useZeroVirtualizer< if (!isListContextCurrent || scrollStateChanged) { if (effectiveScrollState) { - virtualizer.scrollToOffset(effectiveScrollState.scrollTop); + scrollToOffset(effectiveScrollState.scrollTop); dispatch({ type: 'RESET_STATE', estimatedTotal: effectiveScrollState.estimatedTotal, @@ -553,17 +576,17 @@ export function useZeroVirtualizer< : undefined; if (permalinkVirtualItem) { - virtualizer.scrollToIndex(permalinkVirtualItem.index, { + scrollToIndex(permalinkVirtualItem.index, { align: 'auto', }); } else { // TODO(arv): Figure out if we should scroll to top or bottom. - virtualizer.scrollToOffset( + const targetOffset = NUM_ROWS_FOR_LOADING_SKELETON * - // TODO: Support dynamic item sizes - estimateSize(0), - ); + // TODO: Support dynamic item sizes + estimateSize(0); + scrollToOffset(targetOffset); dispatch({ type: 'RESET_STATE', estimatedTotal: NUM_ROWS_FOR_LOADING_SKELETON, @@ -574,7 +597,7 @@ export function useZeroVirtualizer< }); } } else { - virtualizer.scrollToOffset(0); + scrollToOffset(0); dispatch({ type: 'RESET_STATE', estimatedTotal: 0, @@ -612,6 +635,15 @@ export function useZeroVirtualizer< return; } + // After a scroll adjustment (scrollToOffset), the browser fires the scroll + // event asynchronously. Until then the virtualizer's virtual items and + // scrollOffset are stale — they still reflect the *previous* scroll + // position. Acting on stale items would cause spurious anchor updates + // and cascading shifts. + if (awaitingScrollSettleRef.current) { + return; + } + if (atStart) { if (firstRowIndex !== 0) { dispatch({type: 'UPDATE_ANCHOR', anchor: TOP_ANCHOR}); diff --git a/vitest.config.ts b/vitest.config.ts index ee1edf9..625c9f6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,6 @@ import {defineConfig} from 'vitest/config'; export default defineConfig({ test: { environment: 'happy-dom', + include: ['src/**/*.test.{ts,tsx}'], }, }); From 970b4818f0de02a3cdfb64e3435f2abda3819786 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Wed, 1 Apr 2026 16:49:32 +0200 Subject: [PATCH 08/11] format --- demo/e2e/tests/scroll.spec.ts | 4 ++-- demo/e2e/tests/sort.spec.ts | 38 +++++++++++++++++------------------ 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/demo/e2e/tests/scroll.spec.ts b/demo/e2e/tests/scroll.spec.ts index ba5936c..b553f96 100644 --- a/demo/e2e/tests/scroll.spec.ts +++ b/demo/e2e/tests/scroll.spec.ts @@ -1,5 +1,5 @@ -import { expect, test, type Page } from '@playwright/test'; -import { TEST_ITEMS } from '../seed-test.ts'; +import {expect, test, type Page} from '@playwright/test'; +import {TEST_ITEMS} from '../seed-test.ts'; const TIMEOUT = 20_000; diff --git a/demo/e2e/tests/sort.spec.ts b/demo/e2e/tests/sort.spec.ts index 082510e..c90e068 100644 --- a/demo/e2e/tests/sort.spec.ts +++ b/demo/e2e/tests/sort.spec.ts @@ -15,27 +15,25 @@ const TIMEOUT = 15_000; * loop because sort changes are async. */ async function expectFirstVisibleRow(page: Page, text: string) { - await expect( - async () => { - const isFirst = await page.evaluate((txt: string) => { - const viewport = document.querySelector('[class*="viewport"]'); - if (!viewport) return false; - const vpTop = viewport.getBoundingClientRect().top; - const rows = [...viewport.querySelectorAll('a[href^="#"]')]; - if (rows.length === 0) return false; - // Find the row closest to the viewport top. - let best: {el: Element; dist: number} | null = null; - for (const row of rows) { - const dist = Math.abs(row.getBoundingClientRect().top - vpTop); - if (!best || dist < best.dist) { - best = {el: row, dist}; - } + await expect(async () => { + const isFirst = await page.evaluate((txt: string) => { + const viewport = document.querySelector('[class*="viewport"]'); + if (!viewport) return false; + const vpTop = viewport.getBoundingClientRect().top; + const rows = [...viewport.querySelectorAll('a[href^="#"]')]; + if (rows.length === 0) return false; + // Find the row closest to the viewport top. + let best: {el: Element; dist: number} | null = null; + for (const row of rows) { + const dist = Math.abs(row.getBoundingClientRect().top - vpTop); + if (!best || dist < best.dist) { + best = {el: row, dist}; } - return best?.el.textContent?.includes(txt) ?? false; - }, text); - expect(isFirst).toBe(true); - }, - ).toPass({timeout: TIMEOUT}); + } + return best?.el.textContent?.includes(txt) ?? false; + }, text); + expect(isFirst).toBe(true); + }).toPass({timeout: TIMEOUT}); } test.describe('Sort controls', () => { From 7b778446837f4ec6056ddd484a709b3735b358cd Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Wed, 1 Apr 2026 16:54:03 +0200 Subject: [PATCH 09/11] chore: Update GitHub Actions to use latest action versions --- .github/workflows/check-types.yml | 6 +++--- .github/workflows/e2e.yml | 2 +- .github/workflows/format.yml | 6 +++--- .github/workflows/lint.yml | 7 +++---- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/check-types.yml b/.github/workflows/check-types.yml index 2ee0f1d..8aca13e 100644 --- a/.github/workflows/check-types.yml +++ b/.github/workflows/check-types.yml @@ -10,9 +10,9 @@ jobs: check-types: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 with: node-version: 24 cache: 'pnpm' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 17a8fac..d456aa8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -31,7 +31,7 @@ jobs: - name: Run e2e tests run: pnpm test:e2e - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: failure() with: name: playwright-report diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 80ed72d..2fc1fb7 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -10,9 +10,9 @@ jobs: format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 with: node-version: 24 cache: 'pnpm' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 283ce61..e0ea070 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,10 +10,9 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 with: node-version: 24 cache: 'pnpm' From 6b2275ecfdc8c959d3458928f1ca7fa75d37f6d2 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Wed, 1 Apr 2026 16:58:21 +0200 Subject: [PATCH 10/11] fix: Correctly conditionally set workers in Playwright config --- demo/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts index 18899db..e343351 100644 --- a/demo/playwright.config.ts +++ b/demo/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + ...(process.env.CI ? {workers: 1} : {}), // Give CI more time — zero-cache cold-start and replication are slower there. timeout: process.env.CI ? 60_000 : 30_000, reporter: 'list', From 5072055cc9f91bb913c0a8ebfdb868fd8b4241f5 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Wed, 1 Apr 2026 16:58:43 +0200 Subject: [PATCH 11/11] fix: Remove conditional workers setting in Playwright config --- demo/playwright.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts index e343351..0fbaf03 100644 --- a/demo/playwright.config.ts +++ b/demo/playwright.config.ts @@ -9,7 +9,6 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - ...(process.env.CI ? {workers: 1} : {}), // Give CI more time — zero-cache cold-start and replication are slower there. timeout: process.env.CI ? 60_000 : 30_000, reporter: 'list',