Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/check-types.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
39 changes: 39 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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@v7
if: failure()
with:
name: playwright-report
path: demo/playwright-report/
retention-days: 30
6 changes: 3 additions & 3 deletions .github/workflows/format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ dist/
*.db-wal2
rocicorp-zero-virtual-*.tgz
docs
.last-run.json
2 changes: 2 additions & 0 deletions demo/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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

180 changes: 180 additions & 0 deletions demo/e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
arv marked this conversation as resolved.

export default async function globalSetup(): Promise<void> {
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<void> {
await new Promise<void>((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<void> {
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<void> {
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',
Comment on lines +129 to +134
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PATH is being constructed with a hard-coded : separator. That breaks on Windows where the path delimiter is ;. Use path.delimiter when concatenating PATH entries so the e2e setup works cross-platform.

Copilot uses AI. Check for mistakes.
};

// 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,
});
Comment on lines +141 to +146
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spawnZeroCache uses detached: true, but teardown only sends SIGTERM to the recorded pid. If zero-cache-dev spawns child processes, killing only the parent pid may leave orphaned processes and ports in use.

Consider either avoiding detached: true, or (on POSIX) killing the whole process group (e.g. signal -pid) and/or adding a more robust shutdown mechanism.

Copilot uses AI. Check for mistakes.

proc.stdout?.on('data', (d: Buffer) =>
process.stdout.write(`[zero-cache] ${d}`),

Check warning on line 149 in demo/e2e/global-setup.ts

View workflow job for this annotation

GitHub Actions / lint

typescript-eslint(restrict-template-expressions)

Invalid type used in template literal expression.
);
proc.stderr?.on('data', (d: Buffer) =>
process.stderr.write(`[zero-cache] ${d}`),

Check warning on line 152 in demo/e2e/global-setup.ts

View workflow job for this annotation

GitHub Actions / lint

typescript-eslint(restrict-template-expressions)

Invalid type used in template literal expression.
);

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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
17 changes: 17 additions & 0 deletions demo/e2e/global-teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {existsSync, readFileSync, rmSync} from 'node:fs';
import {PID_FILE} from './global-setup.ts';

export default async function globalTeardown(): Promise<void> {
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});
}
Loading
Loading