feat: group prototype journeys and simplify HTML preview

This commit is contained in:
2026-10-19 10:37:56 +08:00
parent adaa8e4edd
commit 49cd594233
25 changed files with 765 additions and 410 deletions
+184
View File
@@ -0,0 +1,184 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const { spawn } = require('node:child_process');
const script = path.resolve(__dirname, '../scripts/serve_file.js');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function invoke(args) {
return new Promise((resolve, reject) => {
// An empty PATH proves there is no dependency on an external networking CLI.
const child = spawn(process.execPath, [script, ...args], { env: { ...process.env, PATH: '' } });
let stdout = '', stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr }));
});
}
function request(url, method = 'GET') {
return new Promise((resolve, reject) => {
const req = http.request(url, { method, timeout: 1000 }, (response) => {
let body = '';
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body }));
response.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('timeout')));
req.end();
});
}
async function fixture(t) {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'previewer-test-'));
const root = path.join(directory, 'site');
await fs.mkdir(root);
const file = path.join(root, '预览 page.html');
await fs.writeFile(file, '<!doctype html><title>预览</title><link rel="stylesheet" href="theme.css"><p>HTML preview</p>');
await fs.writeFile(path.join(root, 'theme.css'), 'body { color: red; }');
await fs.writeFile(path.join(root, 'app.js'), 'document.title = "preview";');
await fs.mkdir(path.join(root, 'assets'));
await fs.writeFile(path.join(directory, 'secret.txt'), 'outside-root');
const pids = new Set();
t.after(async () => {
for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch {} }
await wait(80);
await fs.rm(directory, { recursive: true, force: true });
});
return {
directory, root, file,
async launch(extra = []) {
const result = await invoke([file, '--ttl', '30', ...extra]);
assert.equal(result.code, 0, result.stderr);
assert.equal(result.stderr, '');
const state = JSON.parse(result.stdout);
pids.add(state.pid);
return state;
},
track(state) { pids.add(state.pid); },
};
}
test('serves existing HTML, relative assets and HEAD without network tools', async (t) => {
const f = await fixture(t);
const state = await f.launch();
assert.equal(state.host, '127.0.0.1');
assert.equal(state.reused, false);
assert.ok(state.port > 0);
assert.equal('token' in state, false);
assert.equal('ip' in state, false);
assert.match(state.stopCommand, /^kill -TERM \d+$/);
const html = await request(state.url);
assert.equal(html.status, 200);
assert.match(html.headers['content-type'], /^text\/html/);
assert.match(html.body, /HTML preview/);
assert.match((await request(new URL('/', state.url))).body, /HTML preview/);
const css = await request(new URL('theme.css', state.url));
assert.equal(css.status, 200);
assert.match(css.headers['content-type'], /^text\/css/);
assert.match(css.body, /color: red/);
const js = await request(new URL('app.js', state.url));
assert.equal(js.status, 200);
assert.match(js.headers['content-type'], /^text\/javascript/);
assert.equal((await request(state.url, 'HEAD')).body, '');
assert.equal((await request(state.url, 'POST')).status, 405);
assert.equal((await request(new URL('assets/', state.url))).status, 404);
});
test('sequential and concurrent calls reuse a verified service without extending TTL', async (t) => {
const f = await fixture(t);
const first = await f.launch();
const again = await f.launch();
assert.equal(again.reused, true);
assert.equal(again.pid, first.pid);
assert.equal(again.url, first.url);
assert.equal(again.expiresAt, first.expiresAt);
const concurrent = await Promise.all(Array.from({ length: 4 }, () => f.launch()));
for (const state of concurrent) {
assert.equal(state.pid, first.pid);
assert.equal(state.reused, true);
assert.equal(state.expiresAt, first.expiresAt);
}
// Race the first launch too, not just reuse of an already running worker.
const other = path.join(f.root, 'second.html');
await fs.writeFile(other, '<p>second</p>');
const created = await Promise.all(Array.from({ length: 4 }, () => invoke([other, '--ttl', '30'])));
const states = created.map((result) => {
assert.equal(result.code, 0, result.stderr);
const state = JSON.parse(result.stdout);
f.track(state);
return state;
});
assert.equal(new Set(states.map((state) => state.pid)).size, 1);
assert.equal(states.filter((state) => state.reused === false).length, 1);
});
test('occupied port fails instead of replacing its owner', async (t) => {
const f = await fixture(t);
const first = await f.launch();
const other = path.join(f.root, 'other.html');
await fs.writeFile(other, '<p>other</p>');
const conflict = await invoke([other, '--port', String(first.port), '--ttl', '30']);
assert.equal(conflict.code, 2);
assert.equal(conflict.stdout, '');
assert.equal(JSON.parse(conflict.stderr).code, 'PORT_IN_USE');
assert.equal((await request(first.url)).status, 200);
});
test('outside symlinks, traversal and private status endpoint cannot disclose files', async (t) => {
const f = await fixture(t);
await fs.symlink(path.join(f.directory, 'secret.txt'), path.join(f.root, 'external.txt'));
const state = await f.launch();
const symlink = await request(new URL('external.txt', state.url));
assert.equal(symlink.status, 403);
const traversal = await request(new URL('/..%2fsecret.txt', state.url));
assert.equal(traversal.status, 403);
assert.equal((await request(new URL('/.previewer-status', state.url))).status, 404);
assert.equal((await request(new URL('/%ZZ', state.url))).status, 400);
});
test('expiry stops the service and a later call creates a fresh preview', async (t) => {
const f = await fixture(t);
const first = await f.launch(['--ttl', '1']);
const deadline = Date.now() + 4000;
let stopped = false;
while (Date.now() < deadline) {
try { await request(first.url); } catch { stopped = true; break; }
await wait(80);
}
assert.equal(stopped, true);
const next = await f.launch(['--ttl', '1']);
assert.equal(next.reused, false);
assert.notEqual(next.pid, first.pid);
assert.notEqual(next.expiresAt, first.expiresAt);
});
test('all-interface preview uses only Node and lists local access URLs', async (t) => {
const f = await fixture(t);
const state = await f.launch(['--host', '0.0.0.0']);
assert.equal(state.host, '0.0.0.0');
assert.match(state.url, /^http:\/\/127\.0\.0\.1:/);
assert.ok(state.urls.includes(state.url));
assert.equal((await request(state.url)).status, 200);
});
test('invalid inputs and removed options return structured errors without starting servers', async () => {
for (const args of [
[], ['/missing/preview.html'], [__filename, '--port', '65536'],
[__filename, '--ttl', '0'], [__filename, '--ttl', '2147484'],
[__filename, '--host', 'not-an-ip'], [__filename, '--host'],
[__filename, '--mount-path', '/preview/test'], [__filename, '--tailscale-port', '80'],
]) {
const result = await invoke(args);
assert.equal(result.code, 2);
assert.equal(result.stdout, '');
assert.ok(['ARGUMENT', 'FILE_INVALID'].includes(JSON.parse(result.stderr).code));
}
});