'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, '
HTML preview
'); 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, 'second
'); 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, 'other
'); 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)); } });