oxedyne/daimond/dev/verify_pty.mjs
29.0 KiB, 1 run
created by r2519314175:607, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_pty.mjs — the terminal relay, bytes and all, against a real host |
| 2 | // process. |
| 3 | // |
| 4 | // `www/js/handpty.js` is the page half of a pty session: it composes no fence, |
| 5 | // owns no transport, and does exactly three things worth testing. It carries |
| 6 | // bytes without touching them, it says so when bytes are missing, and it tells a |
| 7 | // hand that STOPPED apart from one that was never installed. This drives all |
| 8 | // three in a real browser. |
| 9 | // |
| 10 | // ── What it runs against ──────────────────────────────────────────── |
| 11 | // |
| 12 | // `dev/mock_pty_host.py`, spawned as a real process, speaking Chrome's native |
| 13 | // messaging framing and the pty half of `hand/src/wire.rs`. It is a copy of |
| 14 | // `hand/install/mock_host.py` with the terminal messages added, because that |
| 15 | // file belongs to whoever is teaching the hand about terminals; when the real |
| 16 | // one speaks pty, this copy should go and the constant below point at it. |
| 17 | // |
| 18 | // The oracle for the byte path is deliberately three independent |
| 19 | // implementations: Python's `base64` encodes, the browser's `atob` decodes, and |
| 20 | // Node's `Buffer` compares. A payload that survives that is not surviving one |
| 21 | // library's agreement with itself. The payload carries NULs, a lone |
| 22 | // continuation byte, a truncated sequence, a surrogate encoding and every byte |
| 23 | // value from 0 to 255, and one `output` frame boundary is placed THROUGH the |
| 24 | // middle of a multi-byte character, which is exactly the case a text conversion |
| 25 | // would quietly destroy. |
| 26 | // |
| 27 | // ── What it does NOT prove ────────────────────────────────────────── |
| 28 | // |
| 29 | // The link between the page and the host is a bridge in this file, not the |
| 30 | // extension, for one reason: `ext/hand.js` refuses a message type it does not |
| 31 | // know, and `open`, `input` and `resize` are not yet among them (see the report |
| 32 | // accompanying this file). So this proves the page relay and the wire, and it |
| 33 | // does not prove the extension forwards a pty message or that the hand |
| 34 | // allocates a terminal. `dev/verify_hand.mjs` is where the extension's own half |
| 35 | // is proved, and this file should grow the same end-to-end phase the day the |
| 36 | // extension carries these messages. |
| 37 | // |
| 38 | // It also does not prove anything about the fence. The fence is composed by |
| 39 | // `fence_spec` in `src/tools.rs`, vetted by the extension and enforced by the |
| 40 | // hand; the only thing tested here is that a request arriving WITHOUT one is |
| 41 | // refused rather than sent, because this relay must never be the place a weaker |
| 42 | // path to the machine begins. |
| 43 | // |
| 44 | // Headless, and needs nothing running for the first phase. The app phase starts |
| 45 | // the dev server and the mock provider itself if they are not already up — |
| 46 | // without the mock provider every model turn fails and the transcript says only |
| 47 | // that Daimond could not answer, which reads as a broken app rather than a |
| 48 | // missing server. |
| 49 | // |
| 50 | // node dev/verify_pty.mjs |
| 51 | import fs from 'node:fs'; |
| 52 | import http from 'node:http'; |
| 53 | import net from 'node:net'; |
| 54 | import os from 'node:os'; |
| 55 | import path from 'node:path'; |
| 56 | import { spawn } from 'node:child_process'; |
| 57 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 58 | |
| 59 | const PW = process.env.DAIMOND_PW |
| 60 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 61 | const { chromium } = await import(pathToFileURL(PW).href); |
| 62 | const CHROME = process.env.DAIMOND_CHROME |
| 63 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 64 | |
| 65 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 66 | const ROOT = path.join(HERE, '..'); |
| 67 | const RELAY = path.join(ROOT, 'www/js/handpty.js'); |
| 68 | const HANDJS = path.join(ROOT, 'www/js/hand.js'); |
| 69 | const MOCK = path.join(HERE, 'mock_pty_host.py'); |
| 70 | // Not /tmp -- see the SCRATCH note in harness.mjs. |
| 71 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 72 | const WORK = path.join(SCRATCH, `verify-pty-${process.pid}`); |
| 73 | |
| 74 | const ok = [], bad = []; |
| 75 | const check = (name, pass, detail) => { |
| 76 | (pass ? ok : bad).push(name); |
| 77 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 78 | }; |
| 79 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 80 | |
| 81 | fs.rmSync(WORK, { recursive: true, force: true }); |
| 82 | fs.mkdirSync(WORK, { recursive: true }); |
| 83 | |
| 84 | // ── The payload ───────────────────────────────────────────────────── |
| 85 | // |
| 86 | // Everything a terminal actually carries and a string cannot. The offsets |
| 87 | // matter: `SPLITS` cuts the euro sign in half, so the two halves of one |
| 88 | // character travel in different frames and only an exact byte path puts it back |
| 89 | // together. |
| 90 | |
| 91 | const HEAD = Buffer.from('hello ', 'ascii'); |
| 92 | const EURO = Buffer.from([0xe2, 0x82, 0xac]); // one character, three bytes |
| 93 | const NASTY = Buffer.from([ |
| 94 | 0x00, 0x00, // NUL, which a C string ends at |
| 95 | 0x80, 0x81, // continuation bytes with nothing to continue |
| 96 | 0xc3, // a two-byte sequence that stops after one |
| 97 | 0xed, 0xa0, 0x80, // a surrogate, which UTF-8 forbids outright |
| 98 | 0xf0, 0x28, 0x8c, 0x28, // a four-byte sequence that is not one |
| 99 | 0xff, 0xfe, // bytes that never appear in UTF-8 at all |
| 100 | ]); |
| 101 | const ALL = Buffer.from(Array.from({ length: 256 }, (_, i) => i)); |
| 102 | const PAYLOAD = Buffer.concat([HEAD, EURO, Buffer.from('\r\n'), NASTY, ALL, NASTY]); |
| 103 | // Through the middle of the euro sign, and then twice more. |
| 104 | const SPLITS = [HEAD.length + 1, HEAD.length + 12, HEAD.length + 120]; |
| 105 | const PAYFILE = path.join(WORK, 'payload.bin'); |
| 106 | fs.writeFileSync(PAYFILE, PAYLOAD); |
| 107 | |
| 108 | /// A fence a request may carry. It is NOT composed here in earnest — that is |
| 109 | /// `fence_spec`'s job in src/tools.rs — it is the shape of one, so the relay |
| 110 | /// can be shown passing it through untouched. |
| 111 | const FENCE = { rw: [WORK], ro: [], deny: [], net: false }; |
| 112 | |
| 113 | /// A well-formed `open`, with anything overridden and anything dropped. |
| 114 | function spec(id, over, drop) { |
| 115 | const m = { |
| 116 | t: 'open', id, argv: ['bash', '-i'], cwd: WORK, env: [['TERM', 'xterm-256color']], |
| 117 | size: { cols: 80, rows: 24 }, fence: FENCE, |
| 118 | }; |
| 119 | Object.assign(m, over || {}); |
| 120 | for (const k of (drop || [])) delete m[k]; |
| 121 | return m; |
| 122 | } |
| 123 | |
| 124 | // ── hand.js's own sentences ───────────────────────────────────────── |
| 125 | // |
| 126 | // Read out of the file rather than copied, so this test cannot drift into |
| 127 | // asserting a sentence the product no longer says. WHICH of them a dead link |
| 128 | // produces is hand.js's decision and dev/verify_handrun.mjs proves it; what is |
| 129 | // proved here is that handpty.js repeats whichever it is given, word for word, |
| 130 | // and marks the two apart. |
| 131 | |
| 132 | /// The value of a `var NAME = '…' + '…';` in a source file, evaluated. |
| 133 | function sentence(src, name) { |
| 134 | const hit = new RegExp(`var\\s+${name}\\s*=\\s*([\\s\\S]*?);\\n`).exec(src); |
| 135 | if (!hit) return ''; |
| 136 | // eslint-disable-next-line no-new-func |
| 137 | try { return new Function('return ' + hit[1])(); } catch (e) { return ''; } |
| 138 | } |
| 139 | const HANDSRC = fs.readFileSync(HANDJS, 'utf8'); |
| 140 | const NO_HAND = sentence(HANDSRC, 'NO_HAND'); |
| 141 | const HAND_GONE = sentence(HANDSRC, 'HAND_GONE'); |
| 142 | |
| 143 | // ── The stub page ─────────────────────────────────────────────────── |
| 144 | // |
| 145 | // The relay under test, plus the SMALLEST thing that satisfies the interface it |
| 146 | // asks of hand.js. The double is a bridge and nothing more: every message goes |
| 147 | // to a real host process and every answer comes back from one. It supplies no |
| 148 | // wire behaviour of its own, so nothing here can agree with handpty.js by |
| 149 | // construction. |
| 150 | |
| 151 | const PAGE = `<!doctype html><meta charset="utf-8"><title>pty</title> |
| 152 | <body><h1>pty harness</h1><script> |
| 153 | // What hand.js is asked to provide. Two functions; see the report beside this file. |
| 154 | window.__subs = {}; |
| 155 | window.__sent = []; |
| 156 | window.__met = false; |
| 157 | window.DaimondHand = { |
| 158 | send: function (m) { |
| 159 | window.__sent.push(m); |
| 160 | if (m && m.t !== 'hello') window.__met = true; |
| 161 | return window.__ptyOut(JSON.stringify(m)); |
| 162 | }, |
| 163 | subscribe: function (id, fn) { |
| 164 | (window.__subs[id] = window.__subs[id] || []).push(fn); |
| 165 | return function () { |
| 166 | var a = window.__subs[id] || []; |
| 167 | var i = a.indexOf(fn); |
| 168 | if (i >= 0) a.splice(i, 1); |
| 169 | }; |
| 170 | }, |
| 171 | status: function () { |
| 172 | return Promise.resolve(JSON.stringify({ |
| 173 | paired: true, transport: 'machine', machine: 'mock', os: 'linux', |
| 174 | root: '/nowhere', caps: ['fence:linux', 'pty'], |
| 175 | })); |
| 176 | }, |
| 177 | }; |
| 178 | /// One message from the host, to whoever the id belongs to. |
| 179 | window.__ptyIn = function (json) { |
| 180 | var m = JSON.parse(json); |
| 181 | var a = (window.__subs[m.id] || []).slice(); |
| 182 | for (var i = 0; i < a.length; i++) a[i](m); |
| 183 | }; |
| 184 | /// The link died. \`message\` and \`met\` are hand.js's to decide; this passes on |
| 185 | /// whatever the test says it decided. |
| 186 | window.__gone = function (message, met) { |
| 187 | for (var id in window.__subs) { |
| 188 | var a = (window.__subs[id] || []).slice(); |
| 189 | for (var i = 0; i < a.length; i++) a[i]({ t: '__gone', message: message, met: met }); |
| 190 | } |
| 191 | }; |
| 192 | /// Everything one session said, recorded for the far end to compare. |
| 193 | window.__watch = function (id) { |
| 194 | window.__seen = { chunks: [], types: [], gaps: [], errs: [], closed: null }; |
| 195 | return { |
| 196 | onOutput: function (b) { |
| 197 | window.__seen.types.push(Object.prototype.toString.call(b)); |
| 198 | window.__seen.chunks.push(Array.prototype.slice.call(b)); |
| 199 | }, |
| 200 | onGap: function (g) { window.__seen.gaps.push(g); }, |
| 201 | onError: function (e) { window.__seen.errs.push(e); }, |
| 202 | onClosed: function (c) { window.__seen.closed = c; }, |
| 203 | }; |
| 204 | }; |
| 205 | <\/script> |
| 206 | <script src="/handpty.js"><\/script></body>`; |
| 207 | |
| 208 | /// Serves the stub page, and the relay itself as the file it really is. |
| 209 | async function serveStub(port) { |
| 210 | const s = http.createServer((req, res) => { |
| 211 | if (/^\/handpty\.js/.test(req.url || '')) { |
| 212 | res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }); |
| 213 | res.end(fs.readFileSync(RELAY)); |
| 214 | return; |
| 215 | } |
| 216 | res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); |
| 217 | res.end(PAGE); |
| 218 | }); |
| 219 | await new Promise((resolve, reject) => { s.once('error', reject); s.listen(port, '127.0.0.1', resolve); }); |
| 220 | return s; |
| 221 | } |
| 222 | |
| 223 | /// The first free port from `from`, so a dev server already holding one is left |
| 224 | /// alone rather than fought over. |
| 225 | async function stubOnFreePort(from) { |
| 226 | for (let port = from; port < from + 40; port++) { |
| 227 | try { return { s: await serveStub(port), port }; } |
| 228 | catch (e) { if (e.code !== 'EADDRINUSE') throw e; } |
| 229 | } |
| 230 | throw new Error(`No free port from ${from}.`); |
| 231 | } |
| 232 | |
| 233 | // ── The bridge ────────────────────────────────────────────────────── |
| 234 | |
| 235 | let host = null; // the mock host process, or null |
| 236 | let hostLog = ''; // where it is writing what it saw |
| 237 | let deliver = Promise.resolve(); // host -> page, strictly in order |
| 238 | |
| 239 | /// Starts a fresh host with the behaviour this case needs. |
| 240 | /// |
| 241 | /// One process per case, because the host reads its configuration when it |
| 242 | /// starts — and because a session that outlived its case would answer the next |
| 243 | /// one. |
| 244 | function startHost(cfg, page) { |
| 245 | stopHost(); |
| 246 | const n = Math.random().toString(36).slice(2, 8); |
| 247 | const cf = path.join(WORK, `cfg-${n}.json`); |
| 248 | hostLog = path.join(WORK, `log-${n}.jsonl`); |
| 249 | fs.writeFileSync(cf, JSON.stringify(Object.assign({ payload: PAYFILE, splits: SPLITS }, cfg), null, '\t')); |
| 250 | const p = spawn('python3', [MOCK, cf, hostLog], { stdio: ['pipe', 'pipe', 'inherit'] }); |
| 251 | let buf = Buffer.alloc(0); |
| 252 | p.stdout.on('data', (d) => { |
| 253 | buf = Buffer.concat([buf, d]); |
| 254 | for (;;) { |
| 255 | if (buf.length < 4) break; |
| 256 | // Native byte order, which on every machine this runs on is little. |
| 257 | const n2 = buf.readUInt32LE(0); |
| 258 | if (buf.length < 4 + n2) break; |
| 259 | const body = buf.subarray(4, 4 + n2).toString('utf8'); |
| 260 | buf = buf.subarray(4 + n2); |
| 261 | deliver = deliver.then(() => page.evaluate((j) => window.__ptyIn(j), body).catch(() => {})); |
| 262 | } |
| 263 | }); |
| 264 | host = p; |
| 265 | return p; |
| 266 | } |
| 267 | |
| 268 | /// Stops it, so the next case gets a fresh one. |
| 269 | function stopHost() { |
| 270 | if (!host) return; |
| 271 | try { host.kill('SIGKILL'); } catch (e) { /* already gone */ } |
| 272 | host = null; |
| 273 | } |
| 274 | |
| 275 | /// Everything the host recorded, as objects. |
| 276 | function heard() { |
| 277 | if (!hostLog || !fs.existsSync(hostLog)) return []; |
| 278 | return fs.readFileSync(hostLog, 'utf8').split('\n').filter(Boolean) |
| 279 | .map((l) => { try { return JSON.parse(l); } catch (e) { return null; } }).filter(Boolean); |
| 280 | } |
| 281 | |
| 282 | /// What the host was SENT of a given type, which is the honest way to ask |
| 283 | /// whether a message travelled. |
| 284 | function received(t) { |
| 285 | return heard().filter((e) => e.dir === '<-' && e.msg && e.msg.t === t).map((e) => e.msg); |
| 286 | } |
| 287 | |
| 288 | /// Everything the page has been given about the session under watch, once the |
| 289 | /// host has finished talking. |
| 290 | async function seen(page) { |
| 291 | await deliver; |
| 292 | await sleep(120); |
| 293 | await deliver; |
| 294 | return page.evaluate(() => window.__seen); |
| 295 | } |
| 296 | |
| 297 | /// The bytes a subscriber was handed, joined. |
| 298 | function bytes(s) { |
| 299 | return Buffer.concat((s.chunks || []).map((c) => Buffer.from(c))); |
| 300 | } |
| 301 | |
| 302 | // ── The two servers, for the app phase ────────────────────────────── |
| 303 | |
| 304 | function listening(port) { |
| 305 | return new Promise((resolve) => { |
| 306 | const s = net.connect(port, '127.0.0.1'); |
| 307 | s.once('connect', () => { s.destroy(); resolve(true); }); |
| 308 | s.once('error', () => resolve(false)); |
| 309 | }); |
| 310 | } |
| 311 | const started = []; |
| 312 | async function serve(name, args, port) { |
| 313 | if (await listening(port)) { console.log(` (${name} already up on ${port})`); return; } |
| 314 | const p = spawn('node', args, { cwd: ROOT, stdio: 'ignore' }); |
| 315 | started.push(p); |
| 316 | for (let i = 0; i < 100; i++) { |
| 317 | if (await listening(port)) { console.log(` (started ${name} on ${port})`); return; } |
| 318 | await sleep(100); |
| 319 | } |
| 320 | throw new Error(`${name} did not come up on ${port}`); |
| 321 | } |
| 322 | |
| 323 | // ── Phase one: the relay, the wire, and a real host ───────────────── |
| 324 | |
| 325 | const stub = await stubOnFreePort(8977); |
| 326 | const b = await chromium.launchPersistentContext(path.join(WORK, 'profile'), { |
| 327 | executablePath: CHROME, |
| 328 | headless: false, |
| 329 | args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'], |
| 330 | viewport: { width: 1000, height: 700 }, |
| 331 | }); |
| 332 | const page = b.pages()[0] || await b.newPage(); |
| 333 | const errs = []; |
| 334 | page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); }); |
| 335 | page.on('pageerror', (e) => errs.push(`pageerror: ${e.message}`)); |
| 336 | await page.exposeFunction('__ptyOut', (json) => { |
| 337 | if (!host) return; |
| 338 | const data = Buffer.from(json, 'utf8'); |
| 339 | const head = Buffer.alloc(4); |
| 340 | head.writeUInt32LE(data.length, 0); |
| 341 | try { host.stdin.write(Buffer.concat([head, data])); } catch (e) { /* it has gone */ } |
| 342 | }); |
| 343 | await page.goto(`http://127.0.0.1:${stub.port}/`, { waitUntil: 'domcontentloaded' }); |
| 344 | |
| 345 | try { |
| 346 | check('the payload is the awkward one, or the test proves nothing', |
| 347 | PAYLOAD.includes(0x00) && PAYLOAD.includes(0x80) && PAYLOAD.includes(0xff) |
| 348 | && !Buffer.from(PAYLOAD.toString('utf8'), 'utf8').equals(PAYLOAD), |
| 349 | `${PAYLOAD.length} bytes, and a text round trip does not survive it`); |
| 350 | check('the relay loaded and installed itself', |
| 351 | await page.evaluate(() => !!(window.DaimondPty && window.DaimondPty.open))); |
| 352 | |
| 353 | // ── A session opens, and its id comes back ────────────────────── |
| 354 | startHost({}, page); |
| 355 | let got = await page.evaluate(async (sp) => { |
| 356 | const h = window.__watch(sp.id); |
| 357 | return await window.DaimondPty.open(sp, h).then((v) => ({ ok: v }), (e) => ({ err: e.message })); |
| 358 | }, spec('t1')); |
| 359 | check('a terminal opens and resolves with its id', !!got.ok && got.ok.id === 't1', JSON.stringify(got)); |
| 360 | check('and with the process it is attached to', !!got.ok && got.ok.pid > 0, JSON.stringify(got.ok)); |
| 361 | |
| 362 | // ── The bytes are the bytes ───────────────────────────────────── |
| 363 | let s = await seen(page); |
| 364 | const back = bytes(s); |
| 365 | check('the subscriber is handed raw bytes, never base64', |
| 366 | s.types.length > 0 && s.types.every((t) => t === '[object Uint8Array]'), s.types.join(' ')); |
| 367 | check('the output is byte-for-byte what the program wrote', |
| 368 | back.equals(PAYLOAD), `${back.length} of ${PAYLOAD.length} bytes`); |
| 369 | check('including the NULs and the invalid UTF-8', |
| 370 | back.includes(0x00) && back.includes(0x80) && back.includes(0xed) && back.includes(0xff), |
| 371 | back.slice(0, 24).toString('hex')); |
| 372 | check('a character cut in half by a frame boundary is put back together', |
| 373 | received('open').length === 1 && back.indexOf(EURO) === HEAD.length, |
| 374 | `euro at ${back.indexOf(EURO)}, expected ${HEAD.length}`); |
| 375 | check('it really did arrive in more than one frame', |
| 376 | heard().filter((e) => e.dir === '->' && e.msg.t === 'output').length >= SPLITS.length + 1, |
| 377 | String(heard().filter((e) => e.dir === '->' && e.msg.t === 'output').length)); |
| 378 | check('nothing was reported missing when nothing was', s.gaps.length === 0, JSON.stringify(s.gaps)); |
| 379 | |
| 380 | // ── Typing travels, exactly ───────────────────────────────────── |
| 381 | const typed = Buffer.from([0x03, 0x1b, 0x5b, 0x41, 0x00, 0xff, 0xc3, 0xa9, 0x0d]); |
| 382 | await page.evaluate(async ({ id, t }) => { |
| 383 | await window.DaimondPty.input(id, new Uint8Array(t)); |
| 384 | }, { id: 't1', t: Array.from(typed) }); |
| 385 | await sleep(300); |
| 386 | const ins = received('input'); |
| 387 | check('keystrokes travel as one input message', ins.length === 1, JSON.stringify(ins).slice(0, 120)); |
| 388 | check('and carry exactly the bytes typed, Ctrl-C, NUL and all', |
| 389 | ins.length === 1 && Buffer.from(ins[0].data, 'base64').equals(typed), |
| 390 | ins.length ? Buffer.from(ins[0].data, 'base64').toString('hex') : ''); |
| 391 | s = await seen(page); |
| 392 | check('and what the terminal echoes comes back as those same bytes', |
| 393 | bytes(s).equals(Buffer.concat([PAYLOAD, typed])), `${bytes(s).length} bytes`); |
| 394 | |
| 395 | // ── A resize travels ──────────────────────────────────────────── |
| 396 | await page.evaluate(() => window.DaimondPty.resize('t1', 132, 43)); |
| 397 | await sleep(250); |
| 398 | const rs = received('resize'); |
| 399 | check('a resize travels, with the new size', |
| 400 | rs.length === 1 && rs[0].size.cols === 132 && rs[0].size.rows === 43, JSON.stringify(rs)); |
| 401 | |
| 402 | // ── Asking it to stop ─────────────────────────────────────────── |
| 403 | await page.evaluate(() => window.DaimondPty.close('t1')); |
| 404 | await sleep(300); |
| 405 | const sig = received('signal'); |
| 406 | s = await seen(page); |
| 407 | check('closing asks the program to stop rather than insisting', |
| 408 | sig.length === 1 && sig[0].sig === 'term', JSON.stringify(sig)); |
| 409 | check('and the session ends with the status the hand reported', |
| 410 | !!s.closed && s.closed.exit === 0, JSON.stringify(s.closed)); |
| 411 | check('a closed session is no longer live in the page', |
| 412 | (await page.evaluate(() => window.DaimondPty.sessions())).length === 0); |
| 413 | |
| 414 | // ── A hole is surfaced, not stitched ──────────────────────────── |
| 415 | startHost({ gap: true }, page); |
| 416 | got = await page.evaluate(async (sp) => { |
| 417 | const h = window.__watch(sp.id); |
| 418 | return await window.DaimondPty.open(sp, h).then((v) => ({ ok: v }), (e) => ({ err: e.message })); |
| 419 | }, spec('t2')); |
| 420 | s = await seen(page); |
| 421 | check('a hole in the sequence is reported', s.gaps.length === 1, JSON.stringify(s.gaps)); |
| 422 | check('and it says which chunk is missing, and how many', |
| 423 | s.gaps.length === 1 && s.gaps[0].missing === 1 && s.gaps[0].got === s.gaps[0].expected + 1, |
| 424 | JSON.stringify(s.gaps[0])); |
| 425 | check('the bytes still go through, hole and all — nothing is invented and nothing dropped', |
| 426 | bytes(s).equals(PAYLOAD), `${bytes(s).length} of ${PAYLOAD.length}`); |
| 427 | check('and the marker is beside the stream, not written into it', |
| 428 | !bytes(s).includes(Buffer.from('missing')), ''); |
| 429 | await page.evaluate(() => window.DaimondPty.forget('t2')); |
| 430 | |
| 431 | // ── A host that dies mid-session ──────────────────────────────── |
| 432 | // |
| 433 | // The host really exits; the bridge sees the pipe close, exactly as the |
| 434 | // extension would. WHICH sentence a dead link produces is hand.js's |
| 435 | // decision and dev/verify_handrun.mjs proves it. What is proved here is |
| 436 | // that a terminal repeats it word for word and marks a hand that STOPPED |
| 437 | // apart from one that was never there. |
| 438 | startHost({ crash_after_ms: 400 }, page); |
| 439 | got = await page.evaluate(async (sp) => { |
| 440 | const h = window.__watch(sp.id); |
| 441 | return await window.DaimondPty.open(sp, h).then((v) => ({ ok: v }), (e) => ({ err: e.message })); |
| 442 | }, spec('t3')); |
| 443 | check('the session was open before the host died', !!got.ok, JSON.stringify(got)); |
| 444 | await new Promise((r) => { if (!host || host.exitCode !== null) r(); else host.once('exit', r); }); |
| 445 | await page.evaluate((m) => window.__gone(m, true), HAND_GONE); |
| 446 | s = await seen(page); |
| 447 | check('a host that dies mid-session ends it rather than leaving it hanging', |
| 448 | !!s.closed && s.closed.exit === -1 && s.closed.killed === true, JSON.stringify(s.closed)); |
| 449 | check('it is reported as a hand that STOPPED, not one that is absent', |
| 450 | !!s.closed && s.closed.stopped === true && s.closed.absent === false, JSON.stringify(s.closed)); |
| 451 | check('and the sentence is hand.js\'s own, word for word', |
| 452 | !!s.closed && s.closed.reason === HAND_GONE && HAND_GONE.length > 40, |
| 453 | (s.closed || {}).reason); |
| 454 | // HAND_GONE now says the hand "is installed and does not need installing |
| 455 | // again" -- it used to instruct a daimon not to tell the user to install it, |
| 456 | // which was the same fact addressed to the wrong reader, and the Terminal |
| 457 | // panel shows this sentence to the user. Either way the question that matters |
| 458 | // is unchanged: it is not the never-installed sentence, and it does not say |
| 459 | // the hand is not installed. |
| 460 | check('so it does NOT tell the user to install what they have already installed', |
| 461 | !!s.closed && s.closed.reason !== NO_HAND && !/is not installed/i.test(s.closed.reason || ''), |
| 462 | (s.closed || {}).reason); |
| 463 | |
| 464 | // And the other half of the same distinction, with the link dying while the |
| 465 | // opening is still in flight — nothing answers at all, which is what a hand |
| 466 | // that was never installed looks like from here. |
| 467 | stopHost(); |
| 468 | await page.evaluate(async (sp) => { |
| 469 | const h = window.__watch(sp.id); |
| 470 | window.__t4 = window.DaimondPty.open(sp, h).then((v) => ({ ok: v }), (e) => ({ err: e.message })); |
| 471 | }, spec('t4')); |
| 472 | await sleep(300); |
| 473 | await page.evaluate((m) => window.__gone(m, false), NO_HAND); |
| 474 | s = await seen(page); |
| 475 | const t4 = await page.evaluate(() => window.__t4); |
| 476 | check('a link that never met a hand is reported as absent instead', |
| 477 | !!s.closed && s.closed.absent === true && s.closed.stopped === false, JSON.stringify(s.closed)); |
| 478 | check('and a caller still waiting on the opening is given that sentence, not a hang', |
| 479 | !!t4.err && t4.err === NO_HAND, JSON.stringify(t4).slice(0, 200)); |
| 480 | |
| 481 | // ── The fence is not this file's to invent ────────────────────── |
| 482 | startHost({}, page); |
| 483 | const nofence = await page.evaluate(async (sp) => |
| 484 | await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })), |
| 485 | spec('t5', {}, ['fence'])); |
| 486 | const norootFence = await page.evaluate(async (sp) => |
| 487 | await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })), |
| 488 | spec('t6', { fence: { rw: [], ro: [], deny: [], net: true } })); |
| 489 | const relcwd = await page.evaluate(async (sp) => |
| 490 | await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })), |
| 491 | spec('t7', { cwd: 'work' })); |
| 492 | check('a terminal with no fence is refused, naming where one comes from', |
| 493 | !!nofence.err && /fence_spec/.test(nofence.err), (nofence.err || '').slice(0, 120)); |
| 494 | check('and one whose fence names no root at all', |
| 495 | !!norootFence.err && /no root/.test(norootFence.err), (norootFence.err || '').slice(0, 120)); |
| 496 | check('and one with a working directory that is not absolute', |
| 497 | !!relcwd.err && /absolute/.test(relcwd.err), (relcwd.err || '').slice(0, 120)); |
| 498 | check('none of them reached the host', |
| 499 | received('open').length === 0, JSON.stringify(received('open')).slice(0, 120)); |
| 500 | |
| 501 | // ── A page whose relay cannot carry terminals ─────────────────── |
| 502 | const noCarry = await page.evaluate(async (sp) => { |
| 503 | const real = window.DaimondHand; |
| 504 | window.DaimondHand = { status: real.status }; // an older hand.js: no send, no subscribe |
| 505 | const r = await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })); |
| 506 | const st = JSON.parse(await window.DaimondPty.status()); |
| 507 | window.DaimondHand = real; |
| 508 | return { r, st }; |
| 509 | }, spec('t8')); |
| 510 | check('a page whose relay predates terminals says so, and blames the page', |
| 511 | !!noCarry.r.err && /Reload the app/.test(noCarry.r.err), (noCarry.r.err || '').slice(0, 140)); |
| 512 | check('and does not send the user off to reinstall a working hand', |
| 513 | !!noCarry.r.err && !/install/i.test(noCarry.r.err), (noCarry.r.err || '').slice(0, 140)); |
| 514 | check('status says the page cannot carry them, without pretending to be a hand', |
| 515 | noCarry.st.carries === false && !!noCarry.st.reason, JSON.stringify(noCarry.st).slice(0, 160)); |
| 516 | |
| 517 | const noRelay = await page.evaluate(async (sp) => { |
| 518 | const real = window.DaimondHand; |
| 519 | delete window.DaimondHand; |
| 520 | const r = await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })); |
| 521 | window.DaimondHand = real; |
| 522 | return r; |
| 523 | }, spec('t9')); |
| 524 | check('and a page with no hand relay at all is a fault in the app, said as one', |
| 525 | !!noRelay.err && /js\/hand\.js/.test(noRelay.err), (noRelay.err || '').slice(0, 140)); |
| 526 | |
| 527 | // ── Output that arrives before anyone is drawing ──────────────── |
| 528 | // |
| 529 | // A program writes its first screen at once, and a terminal that missed its |
| 530 | // own first screen is broken. Opened with no handlers, subscribed to |
| 531 | // afterwards, and everything must still be there. |
| 532 | startHost({}, page); |
| 533 | const late = await page.evaluate(async (sp) => { |
| 534 | const r = await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })); |
| 535 | return r; |
| 536 | }, spec('ta')); |
| 537 | await sleep(400); |
| 538 | await page.evaluate((id) => { window.DaimondPty.subscribe(id, window.__watch(id)); }, 'ta'); |
| 539 | s = await seen(page); |
| 540 | check('output that arrived before a renderer attached is still delivered', |
| 541 | !!late.ok && bytes(s).equals(PAYLOAD), `${bytes(s).length} of ${PAYLOAD.length}`); |
| 542 | await page.evaluate(() => window.DaimondPty.forget('ta')); |
| 543 | |
| 544 | check('the page threw nothing along the way', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 545 | } finally { |
| 546 | stopHost(); |
| 547 | await b.close().catch(() => {}); |
| 548 | stub.s.close(); |
| 549 | } |
| 550 | |
| 551 | // ── Phase two: the file in the real app ───────────────────────────── |
| 552 | // |
| 553 | // The relay is a page script and has to behave inside the page it ships in: it |
| 554 | // must load beside the rest, and it must degrade into a sentence rather than an |
| 555 | // exception when the machine hand is not there — which, on a plain dev profile |
| 556 | // with no extension, it is not. |
| 557 | |
| 558 | // What the children will bind: `serve.mjs` reads DAIMOND_PORT and `mockllm.mjs` |
| 559 | // DAIMOND_MOCK_PORT, so the wait below is asking about the port they chose. |
| 560 | const APP_PORT = Number(process.env.DAIMOND_PORT || 8777); |
| 561 | const MOCK_PORT = Number(process.env.DAIMOND_MOCK_PORT || 9099); |
| 562 | await serve('dev server', ['dev/serve.mjs'], APP_PORT); |
| 563 | await serve('mock provider', ['dev/mockllm.mjs'], MOCK_PORT); |
| 564 | |
| 565 | const { open: openApp } = await import(pathToFileURL(path.join(HERE, 'harness.mjs')).href); |
| 566 | const app = await openApp({ name: 'pty', connect: false }); |
| 567 | try { |
| 568 | await app.page.addScriptTag({ path: RELAY }); |
| 569 | await sleep(200); |
| 570 | const st = JSON.parse(await app.page.evaluate(() => window.DaimondPty.status())); |
| 571 | check('the relay loads into the real app and answers about itself', |
| 572 | typeof st.carries === 'boolean' && st.sessions === 0, JSON.stringify(st).slice(0, 200)); |
| 573 | const r = await app.page.evaluate(async (sp) => |
| 574 | await window.DaimondPty.open(sp).then((v) => ({ ok: v }), (e) => ({ err: e.message })), |
| 575 | { t: 'open', id: 'app1', argv: ['bash'], cwd: '/nowhere', env: [], size: { cols: 80, rows: 24 }, |
| 576 | fence: { rw: ['/nowhere'], ro: [], deny: [], net: false } }); |
| 577 | // The sentence is the LINK's, not this relay's, and that is the change worth |
| 578 | // noting: hand.js now carries terminal messages, so an `open` on a machine |
| 579 | // with no hand gets as far as the link and comes back with the sentence |
| 580 | // hand.js already writes for a hand that is not installed -- verbatim, which |
| 581 | // is what handpty.js's header asks of it. Before hand.js grew `send` and |
| 582 | // `subscribe` this refusal was NO_CARRY, which is a different fault with a |
| 583 | // different instruction: reload the app, rather than install the hand. |
| 584 | check('and with no hand on this machine it refuses in a whole sentence, not an exception', |
| 585 | !!r.err && r.err.length > 60 && /machine hand|terminal/i.test(r.err), |
| 586 | (r.err || '').slice(0, 160)); |
| 587 | const noise = app.errs.filter((e) => !/favicon|ERR_ABORTED|502|Bad Gateway/i.test(e)); |
| 588 | check('the app threw nothing when it loaded', noise.length === 0, noise.slice(0, 3).join(' | ')); |
| 589 | } finally { |
| 590 | await app.close().catch(() => {}); |
| 591 | for (const p of started) { try { p.kill(); } catch (e) { /* already gone */ } } |
| 592 | fs.rmSync(WORK, { recursive: true, force: true }); |
| 593 | } |
| 594 | |
| 595 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 596 | process.exit(bad.length ? 1 : 0); |