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
4 changes: 2 additions & 2 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"validate-story": "node scripts/validate-story.js --glob \"web/stories/**/*.ink\" --output json --max-steps 2000",
"test": "npm run test:unit && npm run test:demo",
"test:unit": "jest",
"test:demo": "start-server-and-test \"npm run serve-demo -- --port 4173\" http://127.0.0.1:4173/demo \"npx playwright test --config=playwright.config.ts --reporter=list,html,junit\"",
"test:demo": "node scripts/run-demo-tests.js",
"test:replay": "jest tests/replay/replay.spec.js",
"lint:md": "remark .opencode/command/*.md --quiet --frail",
"test:golden": "node tests/golden-path/run-golden.js",
Expand Down
5 changes: 4 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { defineConfig, devices } from '@playwright/test';

const demoPort = process.env.DEMO_PORT || '4173';
const baseURL = process.env.DEMO_BASE_URL || `http://127.0.0.1:${demoPort}`;

export default defineConfig({
testDir: './tests',
testMatch: '**/*.spec.ts',
timeout: 20_000,
retries: 0,
use: {
baseURL: 'http://127.0.0.1:4173',
baseURL,
headless: true,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
Expand Down
141 changes: 141 additions & 0 deletions scripts/ensure-demo-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env node

const { spawn } = require('child_process');
const http = require('http');

const DEFAULT_PORT = Number(process.env.DEMO_PORT || 4173);
const MAX_PORT = Number(process.env.DEMO_PORT_MAX || DEFAULT_PORT + 20);
const HOST = process.env.DEMO_HOST || '127.0.0.1';
const MARKER = process.env.DEMO_MARKER || 'InkJS Smoke Demo';

const OCCUPIED_ERROR = 'occupied';

function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function probeDemoServer(port, { host = HOST, marker = MARKER } = {}) {
return new Promise((resolve) => {
const req = http.get({ host, port, path: '/demo/', timeout: 1500 }, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk.toString(); });
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 400 && body.includes(marker)) {
resolve({ status: 'demo' });
} else {
resolve({ status: OCCUPIED_ERROR, reason: `Unexpected response code ${res.statusCode}` });
}
});
});
req.on('timeout', () => {
req.destroy(new Error('timeout'));
});
req.on('error', (err) => {
if (['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENOTFOUND', 'ETIMEDOUT'].includes(err.code)) {
resolve({ status: 'free' });
} else {
resolve({ status: OCCUPIED_ERROR, reason: err.message });
}
});
});
}

async function findFreePort(start, end) {
for (let port = start; port <= end; port += 1) {
const probe = await probeDemoServer(port);
if (probe.status === 'free') return port;
if (probe.status === OCCUPIED_ERROR && port === start) throw new Error(`Port ${start} is in use by a non-demo process.`);
}
throw new Error(`No free port found in range ${start}-${end}`);
}

async function waitForDemo(port, opts = {}) {
const timeoutAt = Date.now() + (opts.timeoutMs || 30_000);
while (Date.now() < timeoutAt) {
const probe = await probeDemoServer(port, opts);
if (probe.status === 'demo') return true;
if (probe.status === OCCUPIED_ERROR) throw new Error(probe.reason || `Port ${port} is occupied by a non-demo process.`);
await wait(300);
}
throw new Error(`Demo server did not become ready on port ${port}`);
}

function startDemoServer(port, env = process.env) {
const child = spawn('npm', ['run', 'serve-demo', '--', '--port', String(port)], { stdio: 'inherit', env });
const exited = new Promise((resolve) => {
child.on('exit', (code, signal) => resolve({ code, signal }));
});
const stop = async () => {
if (!child || child.killed) return;
child.kill('SIGTERM');
await Promise.race([exited, wait(5000)]);
};
return { child, stop, exited };
}

async function ensureDemoServer(options = {}) {
const basePort = Number(options.basePort || DEFAULT_PORT);
const maxPort = Number(options.maxPort || MAX_PORT);
const host = options.host || HOST;
const marker = options.marker || MARKER;
const startServer = options.startServer || ((port) => startDemoServer(port, options.env));

// First check if something already answers; if demo, reuse.
const probe = await probeDemoServer(basePort, { host, marker });
if (probe.status === 'demo') {
process.env.DEMO_PORT = String(basePort);
return { port: basePort, reused: true, close: async () => {} };
}
if (probe.status === OCCUPIED_ERROR) {
throw new Error(`Port ${basePort} is in use by a non-demo process.`);
}

const port = await findFreePort(basePort, maxPort);
const { stop, exited } = startServer(port);
try {
await waitForDemo(port, { host, marker });
process.env.DEMO_PORT = String(port);
return {
port,
reused: false,
close: async () => {
await stop();
await exited;
},
};
} catch (err) {
await stop();
await exited;
throw err;
}
}

if (require.main === module) {
(async () => {
try {
const { port, reused, close } = await ensureDemoServer();
console.log(`[demo-server] Using port ${port}${reused ? ' (reused existing server)' : ''}`);
const stop = async () => {
await close();
process.exit(0);
};
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
// Keep process alive only if we started the server; otherwise exit immediately.
if (reused) {
process.exit(0);
}
await new Promise(() => {});
} catch (err) {
console.error('[demo-server] Failed to ensure demo server:', err.message);
process.exit(1);
}
})();
}

module.exports = {
ensureDemoServer,
probeDemoServer,
waitForDemo,
startDemoServer,
};
41 changes: 41 additions & 0 deletions scripts/run-demo-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env node

const { spawn } = require('child_process');
const { ensureDemoServer } = require('./ensure-demo-server');

function run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: 'inherit', ...opts });
child.on('exit', (code, signal) => {
if (code === 0) return resolve();
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
reject(new Error(`${cmd} ${args.join(' ')} failed with ${reason}`));
});
child.on('error', reject);
});
}

(async () => {
let close = async () => {};
try {
const { port, reused, close: closer } = await ensureDemoServer();
close = closer;
const env = {
...process.env,
DEMO_PORT: String(port),
DEMO_BASE_URL: `http://127.0.0.1:${port}`,
};
console.log(`[demo-test] Running Playwright against ${env.DEMO_BASE_URL}${reused ? ' (reused existing server)' : ''}`);
await run('npx', ['playwright', 'test', '--config=playwright.config.ts', '--reporter=list,html,junit'], { env });
await close();
process.exit(0);
} catch (err) {
console.error('[demo-test] Failed:', err.message);
try {
await close();
} catch (closeErr) {
console.error('[demo-test] Cleanup error:', closeErr.message);
}
process.exit(1);
}
})();
52 changes: 52 additions & 0 deletions tests/unit/ensure-demo-server.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
const { spawnSync } = require('child_process');
const net = require('net');

const scriptPath = require('path').join(__dirname, '..', '..', 'scripts', 'ensure-demo-server.js');
jest.setTimeout(30000);

function isListening(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
socket.end();
resolve(true);
});
socket.on('error', () => resolve(false));
});
}

describe('ensure-demo-server script', () => {
it('reuses existing server when port is busy', async () => {
// Start a disposable server on the default port
const server = net.createServer().listen(4173, '127.0.0.1');
try {
const result = spawnSync('node', [scriptPath], { env: { ...process.env }, encoding: 'utf8' });
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/Using port 4173 \(reused existing server\)/);
} finally {
server.close();
}
});

it('errors when a non-demo process holds the default port', async () => {
const server = net.createServer().listen(4173, '127.0.0.1');
try {
const result = spawnSync('node', [scriptPath], { env: { ...process.env, DEMO_MARKER: 'unlikely-marker' }, encoding: 'utf8' });
expect(result.status).toBe(1);
expect(result.stderr).toMatch(/in use by a non-demo process/);
} finally {
server.close();
}
});

it('starts new server on an alternate port when default is free', async () => {
const result = spawnSync('node', [scriptPath], { env: { ...process.env, DEMO_PORT: '4180', DEMO_PORT_MAX: '4182' }, encoding: 'utf8' });
expect(result.status).toBe(0);
const match = result.stdout.match(/Using port (\d+)/);
expect(match).not.toBeNull();
const port = Number(match[1]);
expect(port).toBeGreaterThanOrEqual(4180);
expect(port).toBeLessThanOrEqual(4182);
const listening = await isListening(port);
expect(listening).toBe(true);
});
});
2 changes: 1 addition & 1 deletion tests/unit/player-preference.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,6 @@ describe('PlayerPreference', () => {
}
const elapsed = performance.now() - start;
expect(PlayerPreference.getPreference('exploration')).toBeGreaterThanOrEqual(0);
expect(elapsed).toBeLessThan(10);
expect(elapsed).toBeLessThan(20);
});
});