diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-20 03:53:17 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-20 03:53:17 -0300 |
| commit | f1b53c1533539aecbf16ad19fd9156deae091f92 (patch) | |
| tree | 38ff00810ef5e0ad271c26e218cf6af4578c71ce /test/web/http_fs.mjs | |
| parent | ba7ec40782ba7020d82a56896a5eb1b52578d6aa (diff) | |
| download | cloud9-f1b53c1533539aecbf16ad19fd9156deae091f92.tar.gz cloud9-f1b53c1533539aecbf16ad19fd9156deae091f92.zip | |
9web: multiplexer, HTTP view of the tree, live streams, richer page
One upstream 9P connection now serves any number of browser WebSocket
sessions and, with --serve, plain 9P clients over TCP or Unix (a 9pserve-
style frame remux: tags and fids remapped, Tflush forwarded, reconnect on
upstream loss). /fs/<path> maps HTTP onto the tree: GET file or directory
(JSON or HTML), Range, HEAD with 9P headers, PUT (create, truncate, append,
trailing slash makes a directory), DELETE, and ?follow=1 or text/event-stream
turning a blocking read into server-sent events with Tflush on disconnect.
The page gains a lazy tree, stat panel, create, rename, delete, upload and
follow mode. --probe embeds 9proc for self-introspection. New mux-test and
http-fs-test steps; e2e still passes.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'test/web/http_fs.mjs')
| -rw-r--r-- | test/web/http_fs.mjs | 131 |
1 files changed, 131 insertions, 0 deletions
diff --git a/test/web/http_fs.mjs b/test/web/http_fs.mjs new file mode 100644 index 0000000..8ac4961 --- /dev/null +++ b/test/web/http_fs.mjs @@ -0,0 +1,131 @@ +// 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(), /<li>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(() => {}); +} |
