oxedyne/daimond/dev/verify_fileview.mjs
33.2 KiB, 1 run
created by r2519314175:411, 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_fileview.mjs — a file is shown as what it IS, and never as characters |
| 2 | // it is not. |
| 3 | // |
| 4 | // Clicking a PDF or a PNG in the workspace filled the document panel with |
| 5 | // replacement characters. `read_file` ends in `from_utf8_lossy`, so every byte |
| 6 | // that is not valid UTF-8 arrived as U+FFFD and went straight into a `<pre>`. |
| 7 | // There WAS a binary guard in `openFile`, and it never fired for a workspace |
| 8 | // file: it asked `DaimondCloud.fileAt`, which resolves through the cloud |
| 9 | // offload cache and not the workspace at all, so the one door that checked |
| 10 | // covered the files least likely to need it. |
| 11 | // |
| 12 | // `file_probe` and `read_bytes` in the wasm answer "what is this" and "give me |
| 13 | // these bytes" separately, and `www/js/viewer.js` spends them. This file pins |
| 14 | // what that viewer must do, and every check here reads the RENDERED DOM rather |
| 15 | // than the viewer's own bookkeeping: |
| 16 | // |
| 17 | // * a PNG written into the workspace is DECODED BY THE BROWSER, at the size |
| 18 | // the fixture says, with no replacement character anywhere on screen; |
| 19 | // * a PDF lands in a frame whose sandbox is EXACTLY `allow-scripts` -- a |
| 20 | // `blob:` URL inherits our origin, so `allow-same-origin` would hand the |
| 21 | // framed file `localStorage`, where the API key lives, and OPFS with it; |
| 22 | // * SVG is in the image list and not the frame list, because script inside an |
| 23 | // SVG executes in a frame and does not in an `<img>`; |
| 24 | // * an unrecognised binary gets a hex dump that shows ITS OWN bytes and names |
| 25 | // how many there are; |
| 26 | // * a `.log` full of NULs is NOT shown as characters, which is the original |
| 27 | // bug stated exactly: the name says text, the bytes say otherwise, and the |
| 28 | // bytes win; |
| 29 | // * a file whose name and bytes disagree SAYS SO, naming both; |
| 30 | // * every object URL the viewer mints is dead after `close()`, proved by |
| 31 | // fetching them rather than by counting our own bookkeeping. |
| 32 | // |
| 33 | // AND THEN IT CLICKS THE ROW. Every check above drove the viewer into a host of |
| 34 | // this file's own, and all of them were green while clicking a PDF in the |
| 35 | // Workspace tree filled the Doc panel with `%PDF-1.4` and the object table, in |
| 36 | // the editor, numbered. The viewer was never what decided: `openFile` in |
| 37 | // daimond.js is, and nothing drove it. It asked "do these bytes decode as |
| 38 | // characters", which the front of a PDF with no binary comment does. The last |
| 39 | // section therefore opens files the way a person does and reads `#pv-view`. |
| 40 | // |
| 41 | // TO SEE THESE FAIL, break it like this -- this lane could not run a browser |
| 42 | // (a subagent launching one is what has OOMed this machine), so the lead should |
| 43 | // run each mutation once before trusting the green: |
| 44 | // |
| 45 | // * `viewer.js`, `handlerFor`: drop the `if (TEXTY[h] && !info.text)` line and |
| 46 | // the `.log` check goes red -- that line IS the fix. |
| 47 | // * `viewer.js`, `frame`: add `allow-same-origin` to the sandbox and both |
| 48 | // frame checks go red. |
| 49 | // * `viewer.js`, `media`: build the `Blob` with no `type` and the PNG check |
| 50 | // goes red, because a `Blob` typed `application/octet-stream` is a picture |
| 51 | // that does not appear. |
| 52 | // * `viewer.js`, `close`: skip the `revokeObjectURL` loop and the last check |
| 53 | // goes red. |
| 54 | // * `viewer.js`, `show`: drop the `info.disagree` branch and the disagreement |
| 55 | // check goes red. |
| 56 | // * `viewer.js`, `editable`: return `!!(info && info.chars)` -- which is what |
| 57 | // `openFile` used to ask -- and the PDF with no binary comment goes red in |
| 58 | // the Doc panel section while the one with a comment stays green. That pair |
| 59 | // is the shipped bug, exactly. (Run: 2 checks FAILED, both of them that PDF.) |
| 60 | // * `viewer.js`, `handlerFor` is NOT what the Doc panel routes on, so breaking |
| 61 | // it moves nothing in that section. `editable` is the one to reach for. |
| 62 | // |
| 63 | // node dev/verify_fileview.mjs |
| 64 | // |
| 65 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and a wasm built since |
| 66 | // `file_probe` landed. No gateway, no mock LLM. |
| 67 | import fs from 'node:fs'; |
| 68 | import { open, scratch } from './harness.mjs'; |
| 69 | |
| 70 | const PROFILE = scratch('pw', 'fileview'); |
| 71 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 72 | |
| 73 | let bad = 0; |
| 74 | const check = (pass, name, detail) => { |
| 75 | if (!pass) bad++; |
| 76 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 77 | }; |
| 78 | |
| 79 | /// A string as bytes, for the fixtures that are text-shaped headers. |
| 80 | const ascii = (s) => Array.from(s, (c) => c.charCodeAt(0) & 0xff); |
| 81 | |
| 82 | // ── The fixtures, as bytes, written by the test ────────────────── |
| 83 | // |
| 84 | // A real 2×3 PNG rather than a signature and filler: the check asserts that the |
| 85 | // BROWSER decoded it and got 2×3 back, which is an answer no amount of our own |
| 86 | // code can fake. Signature, IHDR (2×3, 8-bit, truecolour), one IDAT, IEND. |
| 87 | const PNG = [ |
| 88 | 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, |
| 89 | 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, |
| 90 | 0x08, 0x02, 0x00, 0x00, 0x00, 0x36, 0x88, 0x49, 0xd6, 0x00, 0x00, 0x00, |
| 91 | 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0x00, 0x04, |
| 92 | 0xff, 0x19, 0x50, 0x28, 0x00, 0x3e, 0xd6, 0x05, 0xfb, 0xb6, 0xd6, 0xf9, |
| 93 | 0xda, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, |
| 94 | 0x82, |
| 95 | ]; |
| 96 | const PNG_W = 2, PNG_H = 3; |
| 97 | |
| 98 | /// A COMPLETE, VALID PDF: one page carrying a black square, and ASCII throughout. |
| 99 | /// |
| 100 | /// It used to be a fragment with no cross-reference table and no page contents, |
| 101 | /// under a comment saying that nothing here asserts it RENDERS. That is the hole |
| 102 | /// this fixture closes. Every PDF check was green for months while the browser |
| 103 | /// drew a grey box with a broken-page glyph in it, because the panel framed the |
| 104 | /// file with a `sandbox` attribute and a sandboxed frame may not reach Chrome's |
| 105 | /// PDF viewer. A file that cannot render is no use for finding that out. |
| 106 | /// |
| 107 | /// Built rather than pasted, because the cross-reference table carries byte |
| 108 | /// offsets into the file: written by hand they go stale on the first edit, and |
| 109 | /// Chrome silently rebuilds a broken table, so the check would pass for the |
| 110 | /// wrong reason exactly when the fixture was wrong. |
| 111 | /// |
| 112 | /// `comment` is the binary comment line most producers write after the header, |
| 113 | /// and it is optional in the format. With it, the first 512 bytes are not valid |
| 114 | /// UTF-8 and the file reads as binary; without it they are characters, and that |
| 115 | /// difference is the whole of the Doc panel bug below. The offsets are computed |
| 116 | /// after it is inserted, so both files are valid rather than one of them being |
| 117 | /// the other with four bytes wedged into the middle. |
| 118 | function buildPdf(comment) { |
| 119 | const stream = '0 0 0 rg\n20 20 160 160 re\nf\n'; |
| 120 | const objs = [ |
| 121 | '<< /Type /Catalog /Pages 2 0 R >>', |
| 122 | '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', |
| 123 | '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>', |
| 124 | '<< /Length ' + stream.length + ' >>\nstream\n' + stream + 'endstream', |
| 125 | ]; |
| 126 | let out = '%PDF-1.4\n' + (comment ? '%' + comment + '\n' : ''); |
| 127 | const at = []; |
| 128 | objs.forEach((o, i) => { |
| 129 | at.push(out.length); |
| 130 | out += (i + 1) + ' 0 obj\n' + o + '\nendobj\n'; |
| 131 | }); |
| 132 | const startxref = out.length; |
| 133 | out += 'xref\n0 ' + (objs.length + 1) + '\n0000000000 65535 f \n'; |
| 134 | at.forEach((n) => { out += String(n).padStart(10, '0') + ' 00000 n \n'; }); |
| 135 | out += 'trailer\n<< /Size ' + (objs.length + 1) + ' /Root 1 0 R >>\n' |
| 136 | + 'startxref\n' + startxref + '\n%%EOF\n'; |
| 137 | return out; |
| 138 | } |
| 139 | const PDF = ascii(buildPdf('')); |
| 140 | |
| 141 | // An HTML page, which is the OTHER thing that used to share the framed tier and |
| 142 | // is now the only thing in it. The sandbox argument is about this file and was |
| 143 | // never about the PDF: a `blob:` URL inherits our origin, so a page an agent |
| 144 | // wrote after reading the web would run as the app. Splitting the two would |
| 145 | // have left that rule with nothing testing it. |
| 146 | const HTML = ascii('<!doctype html><html><body><p>a page</p></body></html>'); |
| 147 | |
| 148 | // The SAME PDF with the binary comment most producers write on its second line, |
| 149 | // which is four bytes that are not valid UTF-8. |
| 150 | // |
| 151 | // The pair is the point, and it is why the Doc panel checks below need two |
| 152 | // files. The comment is OPTIONAL in the format, and a PDF that omits it and |
| 153 | // carries no compressed stream in its first 512 bytes reads as characters -- |
| 154 | // 19 of the 1044 PDFs on the author's disk do. The panel routed on that reading |
| 155 | // and put such a file in its EDITOR, `%PDF-1.4` and all, which is the bug this |
| 156 | // section exists for. A file with the comment took the other branch, so every |
| 157 | // PDF anyone happened to test with worked. |
| 158 | const PDF_BINCOMMENT = ascii(buildPdf('âãÏÓ')); |
| 159 | |
| 160 | // A file with no extension at all, whose bytes are plainly characters: what |
| 161 | // `Makefile` and `LICENSE` are, and they must stay in the editor. |
| 162 | // |
| 163 | // The name says nothing, so the answer rests entirely on `Media::sniff` falling |
| 164 | // back to `Media::Text` for a run of characters it recognises nothing else in. |
| 165 | // The fix to the PDF routing leans on that fallback, so this fixture is what |
| 166 | // holds it up: if fe2o3 ever stops making it, this check is what goes red. |
| 167 | const NOEXT = ascii('all: build\n\tcargo build\n'); |
| 168 | |
| 169 | // Sixteen readable bytes, then a NUL, then filler. The readable run is exactly |
| 170 | // one dump line, so the ASCII column proves the dump is showing THESE bytes and |
| 171 | // not any bytes; the NUL is what makes it binary to the probe. |
| 172 | const BIN_HEAD = 'DAIMOND BYTES!!!'; |
| 173 | const BIN = ascii(BIN_HEAD).concat([0x00]); |
| 174 | while (BIN.length < 300) BIN.push((BIN.length * 37) & 0xff); |
| 175 | |
| 176 | // Named as text, and not text. This is the original defect stated as a fixture. |
| 177 | const LOGBYTES = ascii('hello').concat([0x00], ascii('world')); |
| 178 | |
| 179 | const MD = ascii('# Heading\n\nsome *text*\n'); |
| 180 | const JSON_ = ascii('{"a":[1,2],"b":"x"}'); |
| 181 | // The quoted field holds the delimiter, so the table proves it parsed rather |
| 182 | // than split. |
| 183 | const CSV = ascii('name,qty\n"a,b",2\n'); |
| 184 | |
| 185 | const s = await open({ name: 'fileview', profile: PROFILE, connect: false, defaults: false }); |
| 186 | const { page } = s; |
| 187 | |
| 188 | /// Write bytes into the workspace through the same door the app uses. |
| 189 | /// |
| 190 | /// `write_bytes` on the wasm app, not a hand-rolled OPFS walk: it applies the |
| 191 | /// path jail, the real-folder override and the per-account namespace, and the |
| 192 | /// last of those is why a walk of the origin root would write somewhere the |
| 193 | /// viewer does not read. |
| 194 | const put = (path, bytes) => page.evaluate(async ({ path, bytes }) => { |
| 195 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 196 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 197 | await app.write_bytes(path, new Uint8Array(bytes)); |
| 198 | }, { path, bytes }); |
| 199 | |
| 200 | /// Probe and render one file into a host element of the test's own, and answer |
| 201 | /// with everything the checks read. |
| 202 | /// |
| 203 | /// The host is the test's, not the Doc panel's, so this file pins the VIEWER |
| 204 | /// and stays green whatever the panel around it looks like on the day. |
| 205 | const view = (path) => page.evaluate(async (path) => { |
| 206 | let host = document.getElementById('fv-host'); |
| 207 | if (!host) { |
| 208 | host = document.createElement('div'); |
| 209 | host.id = 'fv-host'; |
| 210 | host.style.cssText = 'position:fixed;left:0;bottom:0;width:420px;height:320px;' |
| 211 | + 'display:flex;flex-direction:column;z-index:99999'; |
| 212 | document.body.appendChild(host); |
| 213 | } |
| 214 | const info = await window.DaimondViewer.probe(path, {}); |
| 215 | window.__fvWatch = 1; |
| 216 | try { |
| 217 | await window.DaimondViewer.show(host, path, info, { |
| 218 | t: (k, v) => (window.DaimondI18n ? DaimondI18n.t(k, v) : k), |
| 219 | onError: (e) => { window.__fvLastError = String(e && e.message ? e.message : e); }, |
| 220 | }); |
| 221 | } finally { window.__fvWatch = 0; } |
| 222 | return info; |
| 223 | }, path); |
| 224 | |
| 225 | /// What is on screen, read by meaning: the words on the controls, the words in |
| 226 | /// the warnings, and what the browser made of the bytes. |
| 227 | const seen = () => page.evaluate(() => { |
| 228 | const root = document.querySelector('#fv-host .fileview'); |
| 229 | if (!root) return null; |
| 230 | const txt = (sel) => { const n = root.querySelector(sel); return n ? n.textContent : null; }; |
| 231 | const img = root.querySelector('img'); |
| 232 | const fr = root.querySelector('iframe'); |
| 233 | const em = root.querySelector('embed'); |
| 234 | // The tree row whose KEY is the one asked about, found by its label rather |
| 235 | // than by its position. |
| 236 | const jrow = (key) => { |
| 237 | const rows = Array.from(root.querySelectorAll('.fv-jrow')); |
| 238 | const hit = rows.find((r) => { |
| 239 | const k = r.querySelector('.fv-jkey'); |
| 240 | return k && k.textContent === key; |
| 241 | }); |
| 242 | if (!hit) return null; |
| 243 | const v = hit.querySelector('.fv-jval'); |
| 244 | return v ? v.textContent : null; |
| 245 | }; |
| 246 | const cells = Array.from(root.querySelectorAll('.fv-table td')).map((c) => c.textContent); |
| 247 | const heads = Array.from(root.querySelectorAll('.fv-table th')).map((c) => c.textContent); |
| 248 | return { |
| 249 | handler: root.getAttribute('data-viewer'), |
| 250 | all: root.textContent || '', |
| 251 | meta: txt('.fv-meta') || '', |
| 252 | disagree: txt('.fv-disagree'), |
| 253 | hex: txt('.fv-hex'), |
| 254 | hexAt: txt('.fv-hexat'), |
| 255 | plain: txt('.fv-plain'), |
| 256 | mdHtml: (() => { const n = root.querySelector('.fv-md'); return n ? n.innerHTML : null; })(), |
| 257 | mdH1: (() => { const n = root.querySelector('.fv-md h1'); return n ? n.textContent : null; })(), |
| 258 | jsonB: jrow('b'), |
| 259 | heads: heads, |
| 260 | cells: cells, |
| 261 | img: img ? { w: img.naturalWidth, h: img.naturalHeight, blob: img.src.slice(0, 5) } : null, |
| 262 | frame: fr ? { sandbox: fr.getAttribute('sandbox'), blob: fr.src.slice(0, 5) } : null, |
| 263 | // `sandbox` is read as an ATTRIBUTE, so its absence is null rather than |
| 264 | // the empty string an absent property would give -- and absent is the |
| 265 | // claim being made about it here. |
| 266 | embed: em ? { |
| 267 | type: em.getAttribute('type'), |
| 268 | sandbox: em.getAttribute('sandbox'), |
| 269 | blob: em.src.slice(0, 5), |
| 270 | } : null, |
| 271 | lostBytes: /�/.test(root.textContent || ''), |
| 272 | }; |
| 273 | }); |
| 274 | |
| 275 | /// WHAT IS ACTUALLY ON SCREEN INSIDE `sel`, as a census of light and dark. |
| 276 | /// |
| 277 | /// EVERY OTHER CHECK IN THIS FILE READS THE DOM, AND THE DOM CANNOT TELL A |
| 278 | /// RENDERED DOCUMENT FROM A BROKEN ONE. Both are an element with a `blob:` URL |
| 279 | /// on it. That is exactly how a PDF that had never once appeared passed five |
| 280 | /// green checks for months: the panel drew a flat grey box with a broken-page |
| 281 | /// glyph in the middle of it and named the format helpfully above. |
| 282 | /// |
| 283 | /// The two numbers separate those states with room to spare. A page that |
| 284 | /// rendered has PAPER (near-white) and INK (near-black) on it, and the viewer |
| 285 | /// puts its own dark furniture around them. The broken box is one flat grey: |
| 286 | /// nothing is near-white, nothing is near-black. |
| 287 | /// |
| 288 | /// Playwright hands back a PNG and node here has no image library, so the page |
| 289 | /// decodes its own screenshot -- a `data:` URL of our own bytes, onto a canvas. |
| 290 | /// |
| 291 | /// # Arguments |
| 292 | /// * `sel` - A CSS selector for the element to look at. |
| 293 | const inkCensus = async (sel) => { |
| 294 | const box = await page.locator(sel).boundingBox().catch(() => null); |
| 295 | if (!box) return null; |
| 296 | const png = await page.screenshot({ clip: box, timeout: 15000 }).catch(() => null); |
| 297 | if (!png) return null; |
| 298 | return page.evaluate((b64) => new Promise((res) => { |
| 299 | const img = new Image(); |
| 300 | img.onload = () => { |
| 301 | const c = document.createElement('canvas'); |
| 302 | c.width = img.naturalWidth; c.height = img.naturalHeight; |
| 303 | const g = c.getContext('2d'); |
| 304 | g.drawImage(img, 0, 0); |
| 305 | const d = g.getImageData(0, 0, c.width, c.height).data; |
| 306 | let dark = 0, light = 0, n = 0; |
| 307 | for (let i = 0; i < d.length; i += 4) { |
| 308 | const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]; |
| 309 | if (l < 40) dark++; else if (l > 230) light++; |
| 310 | n++; |
| 311 | } |
| 312 | res({ dark: dark / n, light: light / n, px: n }); |
| 313 | }; |
| 314 | img.onerror = () => res(null); |
| 315 | img.src = 'data:image/png;base64,' + b64; |
| 316 | }), png.toString('base64')); |
| 317 | }; |
| 318 | |
| 319 | /// An `<img>` decodes after its `src` is set, so wait for the browser to have |
| 320 | /// finished with it either way — a decode that failed is `complete` too, and |
| 321 | /// the viewer will have swapped the element for a warning. |
| 322 | const settled = () => page.waitForFunction(() => { |
| 323 | const root = document.querySelector('#fv-host .fileview'); |
| 324 | if (!root) return false; |
| 325 | const i = root.querySelector('img'); |
| 326 | return !i || i.complete; |
| 327 | }, null, { timeout: 10000 }).catch(() => {}); |
| 328 | |
| 329 | try { |
| 330 | await page.waitForTimeout(1200); |
| 331 | |
| 332 | // ── The viewer is on the page at all ───────────────────────── |
| 333 | // A FAIL and not a skip: a surface nothing loads is not a surface that works, |
| 334 | // and every check below would otherwise report the same absence eight times. |
| 335 | const loaded = await page.evaluate(() => typeof window.DaimondViewer === 'object' |
| 336 | && typeof window.DaimondViewer.probe === 'function'); |
| 337 | check(loaded, 'the page loads the viewer', |
| 338 | loaded ? '' : 'www/index.html needs <script src="js/viewer.js"> and ' |
| 339 | + '<link rel="stylesheet" href="css/viewer.css">'); |
| 340 | if (!loaded) throw new Error('DaimondViewer is not on the page'); |
| 341 | |
| 342 | const hasProbe = await page.evaluate(async () => { |
| 343 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 344 | return typeof m.file_probe === 'function' && typeof m.read_bytes === 'function'; |
| 345 | }); |
| 346 | check(hasProbe, 'the wasm exports file_probe and read_bytes', |
| 347 | hasProbe ? '' : 'rebuild it: dev/build-wasm.sh'); |
| 348 | if (!hasProbe) throw new Error('the wasm predates file_probe'); |
| 349 | |
| 350 | // Count only what the viewer mints, and only while it is drawing: the app |
| 351 | // mints object URLs of its own and one of those staying live is not a leak |
| 352 | // in this file's subject. |
| 353 | await page.evaluate(() => { |
| 354 | window.__fvUrls = []; |
| 355 | const real = URL.createObjectURL.bind(URL); |
| 356 | URL.createObjectURL = function (blob) { |
| 357 | const u = real(blob); |
| 358 | if (window.__fvWatch) window.__fvUrls.push(u); |
| 359 | return u; |
| 360 | }; |
| 361 | }); |
| 362 | |
| 363 | // ── A picture is a picture ─────────────────────────────────── |
| 364 | await put('view/pic.png', PNG); |
| 365 | await view('view/pic.png'); |
| 366 | await settled(); |
| 367 | const pic = await seen(); |
| 368 | check(!!pic && pic.handler === 'image', 'a PNG is drawn by the picture tier', |
| 369 | pic ? pic.handler : 'nothing rendered'); |
| 370 | check(!!(pic && pic.img && pic.img.w === PNG_W && pic.img.h === PNG_H), |
| 371 | 'and the browser decoded it, at the size the fixture wrote', |
| 372 | pic && pic.img ? `${pic.img.w}x${pic.img.h}` : 'no <img> survived'); |
| 373 | check(!!(pic && pic.img && pic.img.blob === 'blob:'), |
| 374 | 'from a blob URL rather than a path the frame could follow', |
| 375 | pic && pic.img ? pic.img.blob : ''); |
| 376 | // Gated on the picture being there: an empty panel has no replacement |
| 377 | // characters either, and that is the vacuous pass this whole file is about. |
| 378 | check(!!(pic && pic.img && pic.img.w > 0) && pic.lostBytes === false, |
| 379 | 'and not one replacement character reached the screen', |
| 380 | pic && pic.lostBytes ? 'U+FFFD is on screen' : ''); |
| 381 | check(!!(pic && pic.meta.indexOf(String(PNG.length)) !== -1), |
| 382 | 'the header names how many bytes it is', pic ? pic.meta : ''); |
| 383 | |
| 384 | // ── A PDF goes to the browser's own viewer ─────────────────── |
| 385 | // |
| 386 | // And NOT into the sandboxed frame, which is where it went for months: a |
| 387 | // sandbox attribute of any value stops Chrome instantiating the PDF viewer, |
| 388 | // so the panel drew a grey box with a broken-page glyph and named the format |
| 389 | // helpfully above it. |
| 390 | await put('view/doc.pdf', PDF); |
| 391 | await view('view/doc.pdf'); |
| 392 | const doc = await seen(); |
| 393 | check(!!doc && doc.handler === 'doc', 'a PDF goes to the browser’s document viewer', |
| 394 | doc ? doc.handler : 'nothing rendered'); |
| 395 | check(!!(doc && doc.embed && doc.embed.type === 'application/pdf' |
| 396 | && doc.embed.blob === 'blob:'), |
| 397 | 'on an element that names the type, so nothing has to sniff it', |
| 398 | doc && doc.embed ? JSON.stringify(doc.embed) : 'no embed'); |
| 399 | // What keeps a file from running as the app here is the BLOB'S TYPE and not a |
| 400 | // sandbox -- see the note on `doc` in viewer.js, where it is measured. A |
| 401 | // sandbox on this element would take the viewer away again, so its ABSENCE is |
| 402 | // what has to be pinned. |
| 403 | check(!!(doc && doc.embed && doc.embed.sandbox === null), |
| 404 | 'and carries no sandbox, which would take that viewer away', |
| 405 | doc && doc.embed ? String(doc.embed.sandbox) : 'no embed'); |
| 406 | |
| 407 | // ── An HTML page IS framed, and the frame is a cell ────────── |
| 408 | // |
| 409 | // This is the tier the PDF left, and the sandbox rule was always about this |
| 410 | // file: a `blob:` URL inherits our origin, so a page an agent wrote after |
| 411 | // reading the web would otherwise run as the app, read `localStorage` where |
| 412 | // the API key is, and reach OPFS. |
| 413 | await put('view/page.html', HTML); |
| 414 | await view('view/page.html'); |
| 415 | const web = await seen(); |
| 416 | check(!!web && web.handler === 'frame', 'an HTML file is drawn in a frame', |
| 417 | web ? web.handler : 'nothing rendered'); |
| 418 | // The whole attribute, not a search for the bad tokens: a check that asks |
| 419 | // "does it contain allow-same-origin" passes on a sandbox that has gained |
| 420 | // allow-popups instead. |
| 421 | check(!!(web && web.frame && web.frame.sandbox === 'allow-scripts'), |
| 422 | 'and the frame is sandboxed to allow-scripts and nothing else', |
| 423 | web && web.frame ? JSON.stringify(web.frame.sandbox) : 'no frame'); |
| 424 | |
| 425 | // SVG must never reach that frame: script inside an SVG runs in a frame and |
| 426 | // does not in an `<img>`. Read off the table, which is published so this can |
| 427 | // be asked without rendering anything. |
| 428 | const table = await page.evaluate(() => window.DaimondViewer.KIND_HANDLERS); |
| 429 | check(table.Svg === 'image', 'an SVG goes through <img> and never a frame', |
| 430 | 'KIND_HANDLERS.Svg = ' + table.Svg); |
| 431 | check(table['*'] === 'hex', 'and every format with no viewer of its own falls to the bytes', |
| 432 | "KIND_HANDLERS['*'] = " + table['*']); |
| 433 | |
| 434 | // ── The honest floor ───────────────────────────────────────── |
| 435 | await put('view/thing.bin', BIN); |
| 436 | const binInfo = await view('view/thing.bin'); |
| 437 | const bin = await seen(); |
| 438 | check(binInfo.media === 'Unknown', 'a format nothing recognises is reported as unknown', |
| 439 | binInfo.media); |
| 440 | check(!!bin && bin.handler === 'hex', 'and it is shown as its bytes', |
| 441 | bin ? bin.handler : 'nothing rendered'); |
| 442 | check(!!(bin && bin.hex && bin.hex.indexOf(BIN_HEAD) !== -1), |
| 443 | 'the dump shows THIS file’s bytes, in the ASCII column', |
| 444 | bin && bin.hex ? JSON.stringify(bin.hex.slice(0, 80)) : 'no dump'); |
| 445 | // The exact count, formatted the way the page formats it, so the assertion |
| 446 | // tracks the app's own grouping rather than a hard-coded string. |
| 447 | const binTotal = await page.evaluate((n) => Number(n).toLocaleString(), BIN.length); |
| 448 | check(!!(bin && bin.hexAt && bin.hexAt.indexOf(binTotal) !== -1), |
| 449 | 'and the readout names how big the file is', |
| 450 | bin ? String(bin.hexAt) : ''); |
| 451 | // The two paging controls are found by the words on them, in whatever |
| 452 | // language the app is speaking, and never by their position in the bar. |
| 453 | // |
| 454 | // THE CATALOGUE IS THE AUTHORITY, and it was not always. `fileview.hex_prev` |
| 455 | // and `fileview.hex_next` were absent from `i18n/en.js` until 2026-08-11, so |
| 456 | // this held 'Earlier bytes' and 'Later bytes' itself and compared the buttons |
| 457 | // against its own copy. A check carrying its own wording goes on passing after |
| 458 | // the catalogue's wording changes -- it stops asking about the app and starts |
| 459 | // asking about itself. A key the catalogue has not got is now a FAILURE here, |
| 460 | // which is what it should have been: the panel would be showing a raw key. |
| 461 | const paging = await page.evaluate(() => { |
| 462 | const cat = (k) => { |
| 463 | const s = window.DaimondI18n ? window.DaimondI18n.t(k) : null; |
| 464 | return (s == null || s === k) ? null : s; |
| 465 | }; |
| 466 | const want = ['fileview.hex_prev', 'fileview.hex_next'].map((k) => ({ key: k, said: cat(k) })); |
| 467 | const say = Array.from(document.querySelectorAll('#fv-host .fv-hexbar .fv-btn')) |
| 468 | .map((b) => b.textContent); |
| 469 | return { want, say, ok: want.every((w) => w.said !== null && say.indexOf(w.said) !== -1) }; |
| 470 | }); |
| 471 | check(paging.ok, 'the dump can be walked forwards and back, by controls the catalogue names', |
| 472 | paging.ok ? '' : JSON.stringify(paging)); |
| 473 | |
| 474 | // ── The original bug, stated as a fixture ──────────────────── |
| 475 | await put('view/notes.log', LOGBYTES); |
| 476 | const logInfo = await view('view/notes.log'); |
| 477 | const log = await seen(); |
| 478 | check(logInfo.text === false, |
| 479 | 'a .log holding a NUL is not characters, whatever its name says', |
| 480 | 'probe said text=' + logInfo.text); |
| 481 | check(!!log && log.handler === 'hex', |
| 482 | 'so it is shown as bytes and not as a screen of replacement characters', |
| 483 | log ? log.handler : 'nothing rendered'); |
| 484 | check(!!(log && log.lostBytes === false && log.hex), |
| 485 | 'and nothing on screen is a replacement character'); |
| 486 | |
| 487 | // ── When the name and the bytes disagree ───────────────────── |
| 488 | await put('view/export.png', PDF); |
| 489 | const liarInfo = await view('view/export.png'); |
| 490 | const liar = await seen(); |
| 491 | check(liarInfo.disagree === true, 'a .png holding PDF bytes is reported as a disagreement'); |
| 492 | check(!!liar && liar.handler === 'doc', 'the bytes win: it is shown as the PDF it is', |
| 493 | liar ? liar.handler : 'nothing rendered'); |
| 494 | check(!!(liar && liar.disagree && /png/i.test(liar.disagree) && /pdf/i.test(liar.disagree)), |
| 495 | 'and one line names both the name’s claim and the bytes’ evidence', |
| 496 | liar && liar.disagree ? liar.disagree : 'no line'); |
| 497 | // The name is not what decides which element it lands in, and this is the |
| 498 | // case that proves it: the type on the element comes from the BYTES, so a |
| 499 | // `.pdf` full of HTML would go to the sandboxed frame instead. |
| 500 | check(!!(liar && liar.embed && liar.embed.type === 'application/pdf'), |
| 501 | 'and the element is typed from the bytes, not from the name', |
| 502 | liar && liar.embed ? JSON.stringify(liar.embed.type) : 'no embed'); |
| 503 | |
| 504 | // ── The structured text tiers ──────────────────────────────── |
| 505 | await put('view/notes.md', MD); |
| 506 | await view('view/notes.md'); |
| 507 | const mdSeen = await seen(); |
| 508 | check(!!mdSeen && mdSeen.handler === 'markdown', 'Markdown goes through the app’s renderer', |
| 509 | mdSeen ? mdSeen.handler : 'nothing rendered'); |
| 510 | check(mdSeen && mdSeen.mdH1 === 'Heading', 'and a heading arrives as a heading', |
| 511 | mdSeen ? String(mdSeen.mdH1) : ''); |
| 512 | // The renderer drops these whole, and this file must not have loosened it. |
| 513 | check(!!(mdSeen && mdSeen.mdHtml !== null && !/<script|<iframe/i.test(mdSeen.mdHtml)), |
| 514 | 'with the sanitiser still dropping script and frame markup'); |
| 515 | |
| 516 | await put('view/data.json', JSON_); |
| 517 | await view('view/data.json'); |
| 518 | const js = await seen(); |
| 519 | check(!!js && js.handler === 'json', 'JSON becomes a tree', js ? js.handler : 'nothing rendered'); |
| 520 | check(js && js.jsonB === 'x', 'whose named key carries its own value', |
| 521 | js ? String(js.jsonB) : ''); |
| 522 | |
| 523 | await put('view/grid.csv', CSV); |
| 524 | await view('view/grid.csv'); |
| 525 | const csv = await seen(); |
| 526 | check(!!csv && csv.handler === 'table', 'CSV becomes a table', |
| 527 | csv ? csv.handler : 'nothing rendered'); |
| 528 | check(!!(csv && csv.heads.indexOf('name') !== -1 && csv.heads.indexOf('qty') !== -1), |
| 529 | 'headed by the words in its first row', csv ? JSON.stringify(csv.heads) : ''); |
| 530 | check(!!(csv && csv.cells.indexOf('a,b') !== -1), |
| 531 | 'and a quoted field holding the delimiter stays one cell', |
| 532 | csv ? JSON.stringify(csv.cells) : ''); |
| 533 | |
| 534 | // ── Nothing is left holding a file ─────────────────────────── |
| 535 | // |
| 536 | // Proved by asking the browser, not by counting our own revocations: a |
| 537 | // revoked blob URL cannot be fetched, and a live one can. |
| 538 | const leaks = await page.evaluate(async () => { |
| 539 | window.DaimondViewer.close(); |
| 540 | const urls = window.__fvUrls || []; |
| 541 | const live = []; |
| 542 | for (const u of urls) { |
| 543 | try { await fetch(u); live.push(u); } catch (e) { /* revoked, as it should be */ } |
| 544 | } |
| 545 | return { minted: urls.length, live: live.length }; |
| 546 | }); |
| 547 | // Gated on something having been minted: zero URLs are trivially all dead, |
| 548 | // and that is the vacuous pass. |
| 549 | check(leaks.minted > 0, 'the viewer minted object URLs to show those files', |
| 550 | 'minted ' + leaks.minted); |
| 551 | check(leaks.minted > 0 && leaks.live === 0, |
| 552 | 'and close() leaves not one of them alive', |
| 553 | leaks.live + ' of ' + leaks.minted + ' still fetchable'); |
| 554 | |
| 555 | // ── THE DOC PANEL, WHICH IS THE DOOR THE USER ACTUALLY OPENS ─ |
| 556 | // |
| 557 | // Everything above drives `DaimondViewer` into a host of the test's own, and |
| 558 | // every one of those checks was green while clicking a PDF in the Workspace |
| 559 | // tree showed `%PDF-1.4` in a <pre>. The viewer was never the thing that |
| 560 | // decided; `openFile` in daimond.js decides, and nothing drove it. So this |
| 561 | // section clicks the row. |
| 562 | // |
| 563 | // The four fixtures are the whole of the routing question, and each one is |
| 564 | // there because getting it wrong is a bug somebody has already shipped: |
| 565 | // |
| 566 | // * a PDF WITHOUT the binary comment -- characters, and not for editing; |
| 567 | // * a PDF WITH it -- the case that always worked, so a fix that only moves |
| 568 | // the boundary is caught rather than congratulated; |
| 569 | // * a Markdown file -- which must keep the EDITOR, because `DAIMOND.md` and |
| 570 | // `prompts/*.md` are edited here and routing on the viewer's handler once |
| 571 | // took that away; |
| 572 | // * a file with no extension -- no format to go on, so the bytes alone |
| 573 | // decide and the answer is the editor. |
| 574 | await put('panel-plain.pdf', PDF); |
| 575 | await put('panel-comment.pdf', PDF_BINCOMMENT); |
| 576 | await put('panel-notes.md', MD); |
| 577 | await put('panel-recipe', NOEXT); |
| 578 | |
| 579 | await page.evaluate(() => { try { DaimondPanels.show('work'); } catch (e) { /* mobile shell */ } }); |
| 580 | await page.waitForTimeout(600); |
| 581 | // The tree was drawn before those writes, so it is relisted through the |
| 582 | // panel's own Refresh rather than by calling in. |
| 583 | await page.evaluate(() => { |
| 584 | const r = document.querySelector('.files-actions [data-act="refresh"]'); |
| 585 | if (r) r.click(); |
| 586 | }); |
| 587 | await page.waitForTimeout(800); |
| 588 | |
| 589 | /// Click a row in the Workspace tree and say what the PREVIEW panel then holds. |
| 590 | /// |
| 591 | /// `#pv-view` and not the test's own host: the claim is about the panel. It was |
| 592 | /// `#doc-view` until the Doc panel was split in two — a file that is not |
| 593 | /// characters is a RENDERING, and renderings go to the Preview panel so that |
| 594 | /// whatever is being edited can stay on screen beside them. `pre` still reads the |
| 595 | /// DOC panel, because "not one character of it reaches the editor" is a claim |
| 596 | /// about the editor. |
| 597 | const inPanel = async (name) => { |
| 598 | const clicked = await page.evaluate((n) => { |
| 599 | const r = document.querySelector('.files-tree .files-row[data-path="' + n + '"]'); |
| 600 | if (!r) return false; |
| 601 | r.click(); |
| 602 | return true; |
| 603 | }, name); |
| 604 | await page.waitForTimeout(1200); |
| 605 | const got = await page.evaluate(() => { |
| 606 | const fv = document.querySelector('#pv-view .fileview'); |
| 607 | const pre = document.querySelector('#doc-view .files-view-body'); |
| 608 | return { |
| 609 | viewer: fv ? fv.getAttribute('data-viewer') : null, |
| 610 | frame: !!document.querySelector('#pv-view .fileview iframe'), |
| 611 | embed: !!document.querySelector('#pv-view .fileview embed'), |
| 612 | pre: pre ? pre.textContent.slice(0, 80) : null, |
| 613 | }; |
| 614 | }); |
| 615 | got.clicked = clicked; |
| 616 | return got; |
| 617 | }; |
| 618 | |
| 619 | const plain = await inPanel('panel-plain.pdf'); |
| 620 | check(plain.clicked && plain.viewer === 'doc' && plain.embed === true, |
| 621 | 'clicking a PDF with no binary comment sends it to the document viewer', |
| 622 | JSON.stringify(plain)); |
| 623 | check(plain.pre === null, |
| 624 | 'and not one character of it reaches the editor', |
| 625 | plain.pre === null ? 'no <pre>' : JSON.stringify(plain.pre)); |
| 626 | |
| 627 | // AND THE PAGE IS ON THE SCREEN. Everything above this line was true while |
| 628 | // the panel showed a grey box with a broken-page glyph in it. |
| 629 | // The threshold is 2%, and it is set from a measurement of both states over |
| 630 | // the same file: the sandboxed frame this replaces scores 0.00% dark and |
| 631 | // 0.00% light -- not "nearly none", none, because a flat grey box has no |
| 632 | // near-black and no near-white pixel in it at all -- and this fixture, |
| 633 | // rendered, scores about 46% and 25%. Anywhere in that gap would do. |
| 634 | await page.waitForTimeout(2500); // the viewer loads and paints |
| 635 | const ink = await inkCensus('#pv-view .fv-doc'); |
| 636 | check(!!ink && ink.dark > 0.02 && ink.light > 0.02, |
| 637 | 'and the page is drawn: there is ink on paper where the PDF is', |
| 638 | ink ? `dark ${(ink.dark * 100).toFixed(1)}%, light ${(ink.light * 100).toFixed(1)}%` |
| 639 | : 'nothing to measure'); |
| 640 | |
| 641 | // AND IT FILLS THE PANEL. Everything inside the viewer is written to stretch, |
| 642 | // but the box the panel draws it into had no rule of its own, so the chain |
| 643 | // collapsed to the content's height -- an <embed>'s default 150px. A document |
| 644 | // in a letterbox with empty panel beneath it is not "showing the PDF", and no |
| 645 | // check that reads the DOM would ever have noticed. |
| 646 | const fills = await page.evaluate(() => { |
| 647 | const e = document.querySelector('#pv-view .fv-doc'); |
| 648 | const p = document.getElementById('pv-view'); |
| 649 | if (!e || !p) return null; |
| 650 | return { doc: e.getBoundingClientRect().height, panel: p.getBoundingClientRect().height }; |
| 651 | }); |
| 652 | check(!!fills && fills.panel > 200 && fills.doc / fills.panel > 0.6, |
| 653 | 'and it fills the panel rather than sitting in a letterbox', |
| 654 | fills ? `${Math.round(fills.doc)}px of ${Math.round(fills.panel)}px` : 'nothing to measure'); |
| 655 | |
| 656 | const withComment = await inPanel('panel-comment.pdf'); |
| 657 | check(withComment.clicked && withComment.viewer === 'doc' && withComment.embed === true, |
| 658 | 'and the ordinary kind of PDF still does too', |
| 659 | JSON.stringify(withComment)); |
| 660 | |
| 661 | const md = await inPanel('panel-notes.md'); |
| 662 | check(md.clicked && md.pre !== null && /Heading/.test(md.pre || ''), |
| 663 | 'Markdown keeps the editor, which is where DAIMOND.md is changed', |
| 664 | JSON.stringify(md)); |
| 665 | |
| 666 | const noext = await inPanel('panel-recipe'); |
| 667 | check(noext.clicked && noext.pre !== null && /cargo build/.test(noext.pre || ''), |
| 668 | 'and so does a file with no extension to recognise', |
| 669 | JSON.stringify(noext)); |
| 670 | } finally { |
| 671 | await s.close(); |
| 672 | } |
| 673 | |
| 674 | console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed'); |
| 675 | process.exit(bad ? 1 : 0); |