summaryrefslogtreecommitdiff
path: root/test/web/http_fs.mjs
blob: 8ac4961dfe4fd5d9f9927c6bfba10b9c5d458399 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
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(() => {});
}