// HTTP /fs mapping tests: spawn 9proc-demo as the upstream and 9web as the // gateway, then exercise GET (file/dir/Range/SSE), HEAD, PUT (create/truncate/ // append), DELETE, mkdir, 404 mapping, and concurrent fan-out over the one // shared upstream. No npm packages; Node 22+ builtins only. import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; const [demoBin, webBin] = process.argv.slice(2).map(p => path.resolve(p)); if (!webBin) throw new Error('usage: node test/web/http_fs.mjs 9proc-demo 9web'); const children = []; const delay = ms => new Promise(r => setTimeout(r, ms)); function start(cmd, args) { const c = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); c.out = ''; c.err = ''; c.stdout.on('data', b => { c.out += b; }); c.stderr.on('data', b => { c.err += b; }); c.on('error', e => { c.err += e.message; }); children.push(c); return c; } async function wait(check, msg, timeout = 8000) { const end = Date.now() + timeout; while (Date.now() < end) { try { const r = await check(); if (r) return r; } catch {} await delay(40); } throw new Error('timeout: ' + msg); } let sock, url; try { sock = path.join(await fs.mkdtemp(path.join(os.tmpdir(), '9web-')), 'up'); const demo = start(demoBin, ['--unix', sock]); await wait(() => demo.err.includes('listening') || demo.out.includes('listening'), '9proc-demo ready'); const web = start(webBin, ['--listen', '127.0.0.1:0', '--upstream', `unix:${sock}`, '--timeout-ms', '0']); const m = await wait(() => web.err.match(/9web (http:\/\/127\.0\.0\.1:\d+)/), '9web ready'); url = m[1]; // GET directory -> JSON with real stats. const root = await (await fetch(`${url}/fs/`)).json(); const names = root.map(e => e.name); assert.ok(names.includes('README') && names.includes('runtime') && names.includes('scratch'), 'root listing'); const readme = root.find(e => e.name === 'README'); assert.equal(readme.dir, false); assert.ok(readme.qid && typeof readme.qid.path === 'number' || typeof readme.qid.path === 'bigint'); // GET directory as HTML. const htmlRes = await fetch(`${url}/fs/`, { headers: { accept: 'text/html' } }); assert.match(htmlRes.headers.get('content-type'), /text\/html/); assert.match(await htmlRes.text(), /
  • README<\/li>/); // GET file. const rd = await fetch(`${url}/fs/README`); assert.equal(rd.status, 200); assert.match(rd.headers.get('content-type'), /text\/plain/); assert.match(await rd.text(), /9P2000/); // GET a dynamic (length-0) file streams its content. const now = (await (await fetch(`${url}/fs/runtime/fn/now`)).text()).trim(); assert.match(now, /^\d+$/, 'runtime/fn/now'); // HEAD. const hd = await fetch(`${url}/fs/README`, { method: 'HEAD' }); assert.equal(hd.status, 200); assert.ok(Number(hd.headers.get('content-length')) > 0); assert.ok(hd.headers.get('x-9p-qid')); // Range. const rg = await fetch(`${url}/fs/README`, { headers: { range: 'bytes=0-4' } }); assert.equal(rg.status, 206); assert.equal((await rg.text()).length, 5); // 404 mapping. assert.equal((await fetch(`${url}/fs/nope/missing`)).status, 404); // PUT create + GET + truncate + append + DELETE round trip on /scratch. let put = await fetch(`${url}/fs/scratch/rt.txt`, { method: 'PUT', body: 'hello mux' }); assert.equal(put.status, 201); assert.equal(await (await fetch(`${url}/fs/scratch/rt.txt`)).text(), 'hello mux'); put = await fetch(`${url}/fs/scratch/rt.txt`, { method: 'PUT', body: 'AA' }); assert.equal(put.status, 200); // existed -> truncate assert.equal(await (await fetch(`${url}/fs/scratch/rt.txt`)).text(), 'AA'); await fetch(`${url}/fs/scratch/rt.txt?append=1`, { method: 'PUT', body: 'BB' }); assert.equal(await (await fetch(`${url}/fs/scratch/rt.txt`)).text(), 'AABB'); assert.equal((await fetch(`${url}/fs/scratch/rt.txt`, { method: 'DELETE' })).status, 204); assert.equal((await fetch(`${url}/fs/scratch/rt.txt`)).status, 404); // mkdir via trailing slash. assert.equal((await fetch(`${url}/fs/scratch/sub/`, { method: 'PUT' })).status, 201); assert.equal((await fetch(`${url}/fs/scratch/sub`)).status, 200); // Larger body: multi-frame streamed PUT then GET byte-exact. const big = Buffer.alloc(150000); for (let i = 0; i < big.length; i++) big[i] = (i * 17) % 251; assert.equal((await fetch(`${url}/fs/scratch/big.bin`, { method: 'PUT', body: big })).status, 201); const back = Buffer.from(await (await fetch(`${url}/fs/scratch/big.bin`)).arrayBuffer()); assert.ok(back.equals(big), 'byte-exact large round trip'); // SSE: follow a file streams its content as data: events. const ac = new AbortController(); const sse = await fetch(`${url}/fs/README?follow=1`, { signal: ac.signal }); assert.match(sse.headers.get('content-type'), /text\/event-stream/); const reader = sse.body.getReader(); let text = ''; while (text.length < 40) { const { value, done } = await reader.read(); if (done) break; text += Buffer.from(value).toString(); } ac.abort(); assert.match(text, /data: /, 'SSE data event'); // Fan-out: many concurrent requests share the one upstream (16 tags). const results = await Promise.all(Array.from({ length: 16 }, () => fetch(`${url}/fs/runtime/fn/now`).then(r => r.status).catch(e => 'ERR:' + e.message))); const bad = results.filter(s => s !== 200); assert.ok(bad.length === 0, 'concurrent fan-out, non-200: ' + JSON.stringify(bad)); console.log('http_fs: all checks passed'); } catch (e) { console.error(e.stack || e); for (const c of children) if (c.err) console.error(c.spawnargs.join(' '), '\n', c.err.slice(-2000)); process.exitCode = 1; } finally { for (const c of children) if (c.exitCode === null) c.kill('SIGTERM'); await delay(200); for (const c of children) if (c.exitCode === null) c.kill('SIGKILL'); if (sock) await fs.rm(path.dirname(sock), { recursive: true, force: true }).catch(() => {}); }