Skip to content

Commit 40cb9a9

Browse files
Merge pull request #187 from TheWizardsCode/feature/ge-hch.10-demo-port-guard
Stabilize npm test demo server startup (bge-hch.10)
2 parents 663ccca + 6c5119a commit 40cb9a9

File tree

7 files changed

+242
-5
lines changed

7 files changed

+242
-5
lines changed

.beads/issues.jsonl

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"validate-story": "node scripts/validate-story.js --glob \"web/stories/**/*.ink\" --output json --max-steps 2000",
1010
"test": "npm run test:unit && npm run test:demo",
1111
"test:unit": "jest",
12-
"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\"",
12+
"test:demo": "node scripts/run-demo-tests.js",
1313
"test:replay": "jest tests/replay/replay.spec.js",
1414
"lint:md": "remark .opencode/command/*.md --quiet --frail",
1515
"test:golden": "node tests/golden-path/run-golden.js",

playwright.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { defineConfig, devices } from '@playwright/test';
22

3+
const demoPort = process.env.DEMO_PORT || '4173';
4+
const baseURL = process.env.DEMO_BASE_URL || `http://127.0.0.1:${demoPort}`;
5+
36
export default defineConfig({
47
testDir: './tests',
58
testMatch: '**/*.spec.ts',
69
timeout: 20_000,
710
retries: 0,
811
use: {
9-
baseURL: 'http://127.0.0.1:4173',
12+
baseURL,
1013
headless: true,
1114
trace: 'on-first-retry',
1215
screenshot: 'only-on-failure',

scripts/ensure-demo-server.js

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env node
2+
3+
const { spawn } = require('child_process');
4+
const http = require('http');
5+
6+
const DEFAULT_PORT = Number(process.env.DEMO_PORT || 4173);
7+
const MAX_PORT = Number(process.env.DEMO_PORT_MAX || DEFAULT_PORT + 20);
8+
const HOST = process.env.DEMO_HOST || '127.0.0.1';
9+
const MARKER = process.env.DEMO_MARKER || 'InkJS Smoke Demo';
10+
11+
const OCCUPIED_ERROR = 'occupied';
12+
13+
function wait(ms) {
14+
return new Promise((resolve) => setTimeout(resolve, ms));
15+
}
16+
17+
function probeDemoServer(port, { host = HOST, marker = MARKER } = {}) {
18+
return new Promise((resolve) => {
19+
const req = http.get({ host, port, path: '/demo/', timeout: 1500 }, (res) => {
20+
let body = '';
21+
res.on('data', (chunk) => { body += chunk.toString(); });
22+
res.on('end', () => {
23+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 400 && body.includes(marker)) {
24+
resolve({ status: 'demo' });
25+
} else {
26+
resolve({ status: OCCUPIED_ERROR, reason: `Unexpected response code ${res.statusCode}` });
27+
}
28+
});
29+
});
30+
req.on('timeout', () => {
31+
req.destroy(new Error('timeout'));
32+
});
33+
req.on('error', (err) => {
34+
if (['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENOTFOUND', 'ETIMEDOUT'].includes(err.code)) {
35+
resolve({ status: 'free' });
36+
} else {
37+
resolve({ status: OCCUPIED_ERROR, reason: err.message });
38+
}
39+
});
40+
});
41+
}
42+
43+
async function findFreePort(start, end) {
44+
for (let port = start; port <= end; port += 1) {
45+
const probe = await probeDemoServer(port);
46+
if (probe.status === 'free') return port;
47+
if (probe.status === OCCUPIED_ERROR && port === start) throw new Error(`Port ${start} is in use by a non-demo process.`);
48+
}
49+
throw new Error(`No free port found in range ${start}-${end}`);
50+
}
51+
52+
async function waitForDemo(port, opts = {}) {
53+
const timeoutAt = Date.now() + (opts.timeoutMs || 30_000);
54+
while (Date.now() < timeoutAt) {
55+
const probe = await probeDemoServer(port, opts);
56+
if (probe.status === 'demo') return true;
57+
if (probe.status === OCCUPIED_ERROR) throw new Error(probe.reason || `Port ${port} is occupied by a non-demo process.`);
58+
await wait(300);
59+
}
60+
throw new Error(`Demo server did not become ready on port ${port}`);
61+
}
62+
63+
function startDemoServer(port, env = process.env) {
64+
const child = spawn('npm', ['run', 'serve-demo', '--', '--port', String(port)], { stdio: 'inherit', env });
65+
const exited = new Promise((resolve) => {
66+
child.on('exit', (code, signal) => resolve({ code, signal }));
67+
});
68+
const stop = async () => {
69+
if (!child || child.killed) return;
70+
child.kill('SIGTERM');
71+
await Promise.race([exited, wait(5000)]);
72+
};
73+
return { child, stop, exited };
74+
}
75+
76+
async function ensureDemoServer(options = {}) {
77+
const basePort = Number(options.basePort || DEFAULT_PORT);
78+
const maxPort = Number(options.maxPort || MAX_PORT);
79+
const host = options.host || HOST;
80+
const marker = options.marker || MARKER;
81+
const startServer = options.startServer || ((port) => startDemoServer(port, options.env));
82+
83+
// First check if something already answers; if demo, reuse.
84+
const probe = await probeDemoServer(basePort, { host, marker });
85+
if (probe.status === 'demo') {
86+
process.env.DEMO_PORT = String(basePort);
87+
return { port: basePort, reused: true, close: async () => {} };
88+
}
89+
if (probe.status === OCCUPIED_ERROR) {
90+
throw new Error(`Port ${basePort} is in use by a non-demo process.`);
91+
}
92+
93+
const port = await findFreePort(basePort, maxPort);
94+
const { stop, exited } = startServer(port);
95+
try {
96+
await waitForDemo(port, { host, marker });
97+
process.env.DEMO_PORT = String(port);
98+
return {
99+
port,
100+
reused: false,
101+
close: async () => {
102+
await stop();
103+
await exited;
104+
},
105+
};
106+
} catch (err) {
107+
await stop();
108+
await exited;
109+
throw err;
110+
}
111+
}
112+
113+
if (require.main === module) {
114+
(async () => {
115+
try {
116+
const { port, reused, close } = await ensureDemoServer();
117+
console.log(`[demo-server] Using port ${port}${reused ? ' (reused existing server)' : ''}`);
118+
const stop = async () => {
119+
await close();
120+
process.exit(0);
121+
};
122+
process.on('SIGINT', stop);
123+
process.on('SIGTERM', stop);
124+
// Keep process alive only if we started the server; otherwise exit immediately.
125+
if (reused) {
126+
process.exit(0);
127+
}
128+
await new Promise(() => {});
129+
} catch (err) {
130+
console.error('[demo-server] Failed to ensure demo server:', err.message);
131+
process.exit(1);
132+
}
133+
})();
134+
}
135+
136+
module.exports = {
137+
ensureDemoServer,
138+
probeDemoServer,
139+
waitForDemo,
140+
startDemoServer,
141+
};

scripts/run-demo-tests.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env node
2+
3+
const { spawn } = require('child_process');
4+
const { ensureDemoServer } = require('./ensure-demo-server');
5+
6+
function run(cmd, args, opts = {}) {
7+
return new Promise((resolve, reject) => {
8+
const child = spawn(cmd, args, { stdio: 'inherit', ...opts });
9+
child.on('exit', (code, signal) => {
10+
if (code === 0) return resolve();
11+
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
12+
reject(new Error(`${cmd} ${args.join(' ')} failed with ${reason}`));
13+
});
14+
child.on('error', reject);
15+
});
16+
}
17+
18+
(async () => {
19+
let close = async () => {};
20+
try {
21+
const { port, reused, close: closer } = await ensureDemoServer();
22+
close = closer;
23+
const env = {
24+
...process.env,
25+
DEMO_PORT: String(port),
26+
DEMO_BASE_URL: `http://127.0.0.1:${port}`,
27+
};
28+
console.log(`[demo-test] Running Playwright against ${env.DEMO_BASE_URL}${reused ? ' (reused existing server)' : ''}`);
29+
await run('npx', ['playwright', 'test', '--config=playwright.config.ts', '--reporter=list,html,junit'], { env });
30+
await close();
31+
process.exit(0);
32+
} catch (err) {
33+
console.error('[demo-test] Failed:', err.message);
34+
try {
35+
await close();
36+
} catch (closeErr) {
37+
console.error('[demo-test] Cleanup error:', closeErr.message);
38+
}
39+
process.exit(1);
40+
}
41+
})();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
const { spawnSync } = require('child_process');
2+
const net = require('net');
3+
4+
const scriptPath = require('path').join(__dirname, '..', '..', 'scripts', 'ensure-demo-server.js');
5+
jest.setTimeout(30000);
6+
7+
function isListening(port) {
8+
return new Promise((resolve) => {
9+
const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
10+
socket.end();
11+
resolve(true);
12+
});
13+
socket.on('error', () => resolve(false));
14+
});
15+
}
16+
17+
describe('ensure-demo-server script', () => {
18+
it('reuses existing server when port is busy', async () => {
19+
// Start a disposable server on the default port
20+
const server = net.createServer().listen(4173, '127.0.0.1');
21+
try {
22+
const result = spawnSync('node', [scriptPath], { env: { ...process.env }, encoding: 'utf8' });
23+
expect(result.status).toBe(0);
24+
expect(result.stdout).toMatch(/Using port 4173 \(reused existing server\)/);
25+
} finally {
26+
server.close();
27+
}
28+
});
29+
30+
it('errors when a non-demo process holds the default port', async () => {
31+
const server = net.createServer().listen(4173, '127.0.0.1');
32+
try {
33+
const result = spawnSync('node', [scriptPath], { env: { ...process.env, DEMO_MARKER: 'unlikely-marker' }, encoding: 'utf8' });
34+
expect(result.status).toBe(1);
35+
expect(result.stderr).toMatch(/in use by a non-demo process/);
36+
} finally {
37+
server.close();
38+
}
39+
});
40+
41+
it('starts new server on an alternate port when default is free', async () => {
42+
const result = spawnSync('node', [scriptPath], { env: { ...process.env, DEMO_PORT: '4180', DEMO_PORT_MAX: '4182' }, encoding: 'utf8' });
43+
expect(result.status).toBe(0);
44+
const match = result.stdout.match(/Using port (\d+)/);
45+
expect(match).not.toBeNull();
46+
const port = Number(match[1]);
47+
expect(port).toBeGreaterThanOrEqual(4180);
48+
expect(port).toBeLessThanOrEqual(4182);
49+
const listening = await isListening(port);
50+
expect(listening).toBe(true);
51+
});
52+
});

tests/unit/player-preference.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,6 @@ describe('PlayerPreference', () => {
6060
}
6161
const elapsed = performance.now() - start;
6262
expect(PlayerPreference.getPreference('exploration')).toBeGreaterThanOrEqual(0);
63-
expect(elapsed).toBeLessThan(10);
63+
expect(elapsed).toBeLessThan(20);
6464
});
6565
});

0 commit comments

Comments
 (0)