summaryrefslogtreecommitdiff
path: root/test/web/http_fs.mjs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-20 03:53:17 -0300
committerGabriel Schneider <[email protected]>2026-09-20 03:53:17 -0300
commitf1b53c1533539aecbf16ad19fd9156deae091f92 (patch)
tree38ff00810ef5e0ad271c26e218cf6af4578c71ce /test/web/http_fs.mjs
parentba7ec40782ba7020d82a56896a5eb1b52578d6aa (diff)
downloadcloud9-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.mjs131
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(() => {});
+}