oxedyne/daimond/dev/verify_fileshow.mjs
29.2 KiB, 1 run
created by r2519314175:409, 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_fileshow.mjs — the daimon can put a file in front of the user, and it |
| 2 | // is told the truth about what they are now looking at. |
| 3 | // |
| 4 | // THE DEFECT, in the daimon's own words, asked to compile a Typst source and |
| 5 | // display the PDF: |
| 6 | // |
| 7 | // I do see a pre-existing thinking.pdf (1.08 MB) in the CheapThinking/ |
| 8 | // directory from a previous compile, but I cannot display a PDF inline here |
| 9 | // — the file tools return raw bytes for it rather than a rendered view. |
| 10 | // |
| 11 | // Every clause of that is true about its TOOLBOX and false about Daimond. |
| 12 | // `www/js/viewer.js` has handed `application/pdf` to the browser's own document |
| 13 | // viewer since the `doc` tier was written. What was missing was any way for the |
| 14 | // model to SAY SO, so it reasoned from the tools it held to a limitation of the |
| 15 | // app, told the user about it, and apologised. This file pins the repair: |
| 16 | // |
| 17 | // 1. A FILE THE MODEL NAMES ENDS UP ON THE SCREEN. `window.DaimondDoc.show` |
| 18 | // is the door `file_show` in src/tools.rs calls from the wasm, and what it |
| 19 | // must produce is not an element with a blob URL on it — that is what a |
| 20 | // broken PDF looks like too — but INK ON PAPER, read off a screenshot. |
| 21 | // 2. AND THE MODEL IS TOLD WHAT IS ON IT, from the viewer's own table rather |
| 22 | // than from a second opinion. The verdict must agree with the `data-viewer` |
| 23 | // the panel actually drew, including for a `.md`, which goes to the EDITOR |
| 24 | // and not to the viewer's markdown tier. |
| 25 | // 3. A FORMAT WITH NO VIEWER IS STILL SHOWN, and reported as the hex dump it |
| 26 | // is. There is no "cannot show" answer, and the model must not be handed |
| 27 | // one: a refusal shaped like "no viewer for this format" is an invitation |
| 28 | // to make the same false generalisation one file later. |
| 29 | // 4. IT IS A LIVE HANDLE AND NOT A SNAPSHOT. Rewrite the file, show the same |
| 30 | // path again, and what is on screen changes. This is what a recompiled |
| 31 | // document needs in order to reach the reader at all. |
| 32 | // 5. AND IT CAN BE AIMED. A PDF opened at page 3 is not a PDF opened at page |
| 33 | // 1. Measured, because the whole question of whether a watched document can |
| 34 | // keep a reader's place rests on it. |
| 35 | // 6. AND SHOWING IT AGAIN KEEPS THE PLACE. A re-show with no page named lands |
| 36 | // where the file was last aimed rather than snapping to page 1 — which, |
| 37 | // with (4), is what a rebuilt document needs. It is also the MOST that is |
| 38 | // available: nothing can read where the reader had scrolled to, so what is |
| 39 | // restored is where we put them and never where they went. |
| 40 | // 7. AND IT MAY NOT TAKE A SCREEN THAT BELONGS TO ANOTHER CONVERSATION. A |
| 41 | // daimon whose Diamond is not the one in view had the panel open over |
| 42 | // whatever the user was doing — reported live, from a daimon editing its |
| 43 | // crystal while the user worked in a different Diamond. `Tool::FileShow` |
| 44 | // already refused a dispatched WORKER for exactly this reason and in these |
| 45 | // words: "the document panel belongs to the conversation the user is |
| 46 | // actually in". The same question is now asked of every owner. The show is |
| 47 | // held, not dropped, and opens when the user reaches that Diamond. |
| 48 | // |
| 49 | // WHAT THIS FILE DOES NOT PROVE, and nothing here should be read as proving: |
| 50 | // the Rust half. `Tool::FileShow`, its guard, its refusals and the English it |
| 51 | // composes are not in `www/pkg` until the wasm is rebuilt, so this drives the |
| 52 | // JavaScript contract — `window.DaimondDoc` — which is exactly where the Rust |
| 53 | // edge lands. The guard and the sentence are covered by the unit tests in |
| 54 | // src/tools.rs. |
| 55 | // |
| 56 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 57 | // deliberately damaged copy of a source file to the real page through |
| 58 | // `page.route`, and the run is expected to FAIL. A break whose anchor does not |
| 59 | // appear exactly once aborts rather than passing quietly. |
| 60 | // |
| 61 | // node dev/verify_fileshow.mjs --break seam # 1 fails: nothing reaches the panel |
| 62 | // node dev/verify_fileshow.mjs --break verdict # 2 fails: a .md is reported as rendered |
| 63 | // node dev/verify_fileshow.mjs --break floor # 2 and 3 fail: everything claims to be a document |
| 64 | // node dev/verify_fileshow.mjs --break stale # 4 fails: the rewritten file is not redrawn |
| 65 | // node dev/verify_fileshow.mjs --break aim # 5 fails: page 3 opens at page 1 |
| 66 | // node dev/verify_fileshow.mjs --break place # 6 fails: the rebuild loses the reader's place |
| 67 | // node dev/verify_fileshow.mjs --break screen # 7 fails: a background Diamond takes the panel |
| 68 | // node dev/verify_fileshow.mjs # and then, clean |
| 69 | // |
| 70 | // A break may redden more than the check it is named for, and two here do: |
| 71 | // `stale` takes the aim checks with it, because a panel that never redraws never |
| 72 | // re-aims either. What matters is that each check goes red for the break that |
| 73 | // names it, and that no check is green when its own subject is broken. |
| 74 | // |
| 75 | // eval "$(bash dev/world.sh 4 --up)" |
| 76 | // node dev/verify_fileshow.mjs |
| 77 | // |
| 78 | // Needs dev/serve.mjs only. No gateway, no mock LLM, no wasm rebuild. |
| 79 | import fs from 'node:fs'; |
| 80 | import path from 'node:path'; |
| 81 | import { fileURLToPath } from 'node:url'; |
| 82 | import { open, scratch } from './harness.mjs'; |
| 83 | |
| 84 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 85 | const WWW = path.join(HERE, '..', 'www'); |
| 86 | |
| 87 | const BREAK = (() => { |
| 88 | const i = process.argv.indexOf('--break'); |
| 89 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 90 | })(); |
| 91 | |
| 92 | const PROFILE = scratch('pw', 'fileshow' + (BREAK ? '-' + BREAK : '')); |
| 93 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 94 | |
| 95 | let bad = 0; |
| 96 | const check = (pass, name, detail) => { |
| 97 | if (!pass) bad++; |
| 98 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 99 | }; |
| 100 | |
| 101 | // ── The seam ───────────────────────────────────────────────────────── |
| 102 | // |
| 103 | // Only `daimond.js` can open a file in the Doc panel: the panel, its header and |
| 104 | // the routing between the editor and the viewer are all inside that module's |
| 105 | // closure. So it hands its opener to `viewer.js`, which owns everything else |
| 106 | // about what showing a file means, and that one line is the seam. |
| 107 | // |
| 108 | // It is checked rather than assumed. If it has not landed, the run says so as a |
| 109 | // FAILURE and then serves a patched copy for the rest of the file, so the design |
| 110 | // can be seen working without the green being borrowed from code that is not |
| 111 | // there. Once the line is in `daimond.js`, nothing is patched at all. |
| 112 | |
| 113 | const SEAM_SPEC = { file: 'js/daimond.js', find: '\t})();\n\n\t// ── The Terminal panel' }; |
| 114 | const SEAM = '\t})();\n\n' |
| 115 | + '\t// The daimon\'s door to the Doc panel. `file_show` in src/tools.rs reaches it\n' |
| 116 | + '\t// from the wasm through `window.DaimondDoc`, which `js/viewer.js` installs --\n' |
| 117 | + '\t// but only THIS module can open a file: the panel, its header and the routing\n' |
| 118 | + '\t// between the editor and the viewer all live in this closure. So the opener is\n' |
| 119 | + '\t// handed over, and viewer.js keeps the question of what showing a file MEANS.\n' |
| 120 | + '\t//\n' |
| 121 | + '\t// Registered here rather than on first use of the panel: a tool call can arrive\n' |
| 122 | + '\t// before the user has opened anything, and a door wired lazily is a door the\n' |
| 123 | + '\t// model finds shut and reports as absent.\n' |
| 124 | + '\tif (window.DaimondViewer && DaimondViewer.opener) DaimondViewer.opener(Files.open);\n\n' |
| 125 | + '\t// ── The Terminal panel'; |
| 126 | |
| 127 | const seamLanded = () => fs.readFileSync(path.join(WWW, 'js/daimond.js'), 'utf8') |
| 128 | .indexOf('DaimondViewer.opener(Files.open)') !== -1; |
| 129 | |
| 130 | // ── The breaks ─────────────────────────────────────────────────────── |
| 131 | // Each is a real edit to a real file, served in place of it. |
| 132 | const BREAKS = { |
| 133 | // The door is never opened. This is the state the defect was reported from: |
| 134 | // the panel works, the viewer works, and nothing outside `daimond.js` can |
| 135 | // reach either — so a model asked to display a file correctly concludes it |
| 136 | // has no way to, which is the sentence this whole lane exists to delete. |
| 137 | seam: 'no-seam', |
| 138 | // The verdict stops asking the panel's routing question and answers with the |
| 139 | // viewer's tier. A `.md` is then reported to the model as rendered Markdown |
| 140 | // while the user is looking at an editor — a small lie the model will repeat. |
| 141 | verdict: [{ |
| 142 | file: 'js/viewer.js', |
| 143 | find: '\t\tvar tier = editable(info) ? \'editor\' : info.handler;', |
| 144 | with: '\t\tvar tier = info.handler;', |
| 145 | }], |
| 146 | // A second opinion, which is what the whole shape of this was written to |
| 147 | // avoid: the verdict asserts a document rather than reading the table. |
| 148 | floor: [{ |
| 149 | file: 'js/viewer.js', |
| 150 | find: '\t\tvar tier = editable(info) ? \'editor\' : info.handler;', |
| 151 | with: '\t\tvar tier = \'doc\';', |
| 152 | }], |
| 153 | // The classic wrong optimisation: opening the file that is already open is |
| 154 | // treated as nothing to do. Harmless until the file is REWRITTEN between the |
| 155 | // two calls, which is precisely what a recompile does — and then the view is |
| 156 | // a snapshot of the old bytes and the reader is never told. |
| 157 | // |
| 158 | // Note which check this reddens and which it does not. The verdict is probed |
| 159 | // fresh either way, so the SIZE the model is told is right while the screen is |
| 160 | // wrong: a check that only read the verdict would pass here. The ink is what |
| 161 | // catches it. |
| 162 | stale: [{ |
| 163 | file: 'js/daimond.js', |
| 164 | find: '\t\tasync function openFile(path, opts) {', |
| 165 | with: '\t\tasync function openFile(path, opts) {\n\t\t\tif (curFile === path) return;', |
| 166 | }], |
| 167 | // The aim is remembered and never spent, so every document opens at the top. |
| 168 | aim: [{ |
| 169 | file: 'js/viewer.js', |
| 170 | find: '\t\te.src = mint(blob) + aimFrag(path);', |
| 171 | with: '\t\te.src = mint(blob);', |
| 172 | }], |
| 173 | // A show with no page forgets where this file was. Harmless on a first show |
| 174 | // and ruinous on the second: it is what makes "the PDF was rebuilt" and "I |
| 175 | // lost where I was on page 214" the same event. |
| 176 | // The question is asked and the answer is thrown away, which is the state the |
| 177 | // defect was reported from: every owner may take every screen. Note that this |
| 178 | // is the ONLY break here that reddens a check about who is looking rather than |
| 179 | // about what is drawn — the panel is perfectly correct throughout, and shows |
| 180 | // the wrong person. |
| 181 | screen: [{ |
| 182 | file: 'js/viewer.js', |
| 183 | find: '\t\tif (!owner || !screenOwner) return true;', |
| 184 | with: '\t\tif (!owner || !screenOwner || owner) return true;', |
| 185 | }], |
| 186 | place: [{ |
| 187 | file: 'js/viewer.js', |
| 188 | find: '\t\tif (n > 0) { aim = { path: path, page: n }; return; }\n' |
| 189 | + '\t\tif (!aim || aim.path !== path) aim = null;', |
| 190 | with: '\t\taim = (n > 0) ? { path: path, page: n } : null;', |
| 191 | }], |
| 192 | }; |
| 193 | |
| 194 | if (BREAK && !BREAKS[BREAK]) { |
| 195 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 196 | process.exit(2); |
| 197 | } |
| 198 | |
| 199 | /// Apply one edit to `src`, or stop dead. Nothing is served that was not verified |
| 200 | /// to differ from the file on disk: a break whose anchor matched nothing would |
| 201 | /// leave the run green against working code and prove the opposite of its claim. |
| 202 | function apply(src, spec, why) { |
| 203 | const n = src.split(spec.find).length - 1; |
| 204 | if (n !== 1) { |
| 205 | console.error(`${why}: the anchor appears ${n} times in ${spec.file}, ` |
| 206 | + 'so nothing was changed and the run below would prove nothing.'); |
| 207 | process.exit(2); |
| 208 | } |
| 209 | return src.replace(spec.find, spec.with); |
| 210 | } |
| 211 | |
| 212 | // ── The fixtures ───────────────────────────────────────────────────── |
| 213 | |
| 214 | const ascii = (s) => Array.from(s, (c) => c.charCodeAt(0) & 0xff); |
| 215 | |
| 216 | /// A COMPLETE, VALID PDF whose pages carry different amounts of ink. |
| 217 | /// |
| 218 | /// The ink is the instrument. Every check below that says "the document is on |
| 219 | /// screen" or "it opened at page 3" reads a screenshot, because the DOM cannot |
| 220 | /// tell a rendered page from a grey box with a broken-page glyph in it — which |
| 221 | /// is exactly how a PDF that had never once appeared passed five green checks |
| 222 | /// for months in `verify_fileview.mjs`. |
| 223 | /// |
| 224 | /// Built rather than pasted: the cross-reference table carries byte offsets into |
| 225 | /// the file, and a hand-written one goes stale on the first edit. |
| 226 | /// |
| 227 | /// # Arguments |
| 228 | /// * `fracs` - One entry per page: how much of that page is a black bar. |
| 229 | function buildPdf(fracs) { |
| 230 | const n = fracs.length; |
| 231 | const objs = ['<< /Type /Catalog /Pages 2 0 R >>']; |
| 232 | const kids = []; |
| 233 | for (let i = 0; i < n; i++) kids.push(`${3 + i * 2} 0 R`); |
| 234 | objs.push(`<< /Type /Pages /Kids [${kids.join(' ')}] /Count ${n} >>`); |
| 235 | for (let i = 0; i < n; i++) { |
| 236 | const h = Math.max(1, Math.round(600 * fracs[i])); |
| 237 | const stream = `0 0 0 rg\n0 0 400 ${h} re\nf\n`; |
| 238 | objs.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 600] /Contents ${4 + i * 2} 0 R >>`); |
| 239 | objs.push(`<< /Length ${stream.length} >>\nstream\n${stream}endstream`); |
| 240 | } |
| 241 | let out = '%PDF-1.4\n%\xe2\xe3\xcf\xd3\n'; |
| 242 | const at = []; |
| 243 | objs.forEach((o, i) => { at.push(out.length); out += (i + 1) + ' 0 obj\n' + o + '\nendobj\n'; }); |
| 244 | const startxref = out.length; |
| 245 | out += 'xref\n0 ' + (objs.length + 1) + '\n0000000000 65535 f \n'; |
| 246 | at.forEach((x) => { out += String(x).padStart(10, '0') + ' 00000 n \n'; }); |
| 247 | out += 'trailer\n<< /Size ' + (objs.length + 1) + ' /Root 1 0 R >>\n' |
| 248 | + 'startxref\n' + startxref + '\n%%EOF\n'; |
| 249 | return ascii(out); |
| 250 | } |
| 251 | |
| 252 | // Three pages, each with visibly more ink than the last, so a census of the |
| 253 | // screen says which page the viewer chose to open at. |
| 254 | const PDF3 = buildPdf([0.05, 0.50, 0.95]); |
| 255 | // The same document REBUILT: one page, and much darker than page 1 above. This |
| 256 | // is the recompile, and the check is that the reader sees the new one. |
| 257 | const PDF_REDO = buildPdf([0.92]); |
| 258 | |
| 259 | const MD = ascii('# Heading\n\nsome *text*\n'); |
| 260 | |
| 261 | // Sixteen readable bytes then a NUL: unrecognisable to the probe, so it lands on |
| 262 | // the honest floor, and the readable run proves the dump is showing THESE bytes. |
| 263 | const BIN_HEAD = 'DAIMOND BYTES!!!'; |
| 264 | const BIN = ascii(BIN_HEAD).concat([0x00]); |
| 265 | while (BIN.length < 300) BIN.push((BIN.length * 37) & 0xff); |
| 266 | |
| 267 | // ── Driving ────────────────────────────────────────────────────────── |
| 268 | |
| 269 | /// Serve whatever this run needs to be different about the page. |
| 270 | /// |
| 271 | /// One pass over the sources, because the seam and a break can land in the SAME |
| 272 | /// file: `--break stale` damages `daimond.js`, which is also where the seam goes, |
| 273 | /// and two `page.route` handlers for one URL would leave one of the two edits |
| 274 | /// silently unserved. |
| 275 | async function stub(page) { |
| 276 | const edited = {}; |
| 277 | const edit = (spec, why) => { |
| 278 | const src = edited[spec.file] |
| 279 | || fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 280 | edited[spec.file] = apply(src, spec, why); |
| 281 | }; |
| 282 | // The seam, when it is not in the tree yet. `--break seam` withholds it EITHER |
| 283 | // WAY: it skips the patch while the line is absent, and STRIPS the line once |
| 284 | // it is there. |
| 285 | // |
| 286 | // The second half arrived with the seam itself. "Withhold" used to mean only |
| 287 | // "do not add", which is the same as doing nothing the moment the line landed |
| 288 | // in daimond.js -- and this break, the one that stages the very state the |
| 289 | // defect was reported from, then passed. A break that cannot fail proves |
| 290 | // nothing about the check it is named for. |
| 291 | if (BREAK === 'seam') { |
| 292 | if (seamLanded()) { |
| 293 | edit({ |
| 294 | file: 'js/daimond.js', |
| 295 | find: '\tif (window.DaimondViewer && DaimondViewer.opener) DaimondViewer.opener(Files.open);\n', |
| 296 | with: '', |
| 297 | }, 'break \'seam\''); |
| 298 | } |
| 299 | } else if (!seamLanded()) { |
| 300 | edit({ ...SEAM_SPEC, with: SEAM }, 'the seam patch'); |
| 301 | } |
| 302 | if (BREAK && BREAK !== 'seam') { |
| 303 | for (const spec of BREAKS[BREAK]) edit(spec, `break '${BREAK}'`); |
| 304 | } |
| 305 | for (const file of Object.keys(edited)) { |
| 306 | const body = edited[file]; |
| 307 | await page.route('**/' + file, (r) => r.fulfill({ |
| 308 | status: 200, contentType: 'application/javascript', body, |
| 309 | })); |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | const s = await open({ name: 'fileshow', profile: PROFILE, connect: false, defaults: false, |
| 314 | route: stub }); |
| 315 | const { page } = s; |
| 316 | |
| 317 | /// Write bytes into the workspace through the same door the app uses. |
| 318 | const put = (p, bytes) => page.evaluate(async ({ p, bytes }) => { |
| 319 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 320 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 321 | await app.write_bytes(p, new Uint8Array(bytes)); |
| 322 | }, { p, bytes }); |
| 323 | |
| 324 | /// Call the door the daimon calls, and hand back what the model would be told. |
| 325 | /// |
| 326 | /// Resolves `{ ok, verdict }` or `{ ok:false, error }`, because a rejection is an |
| 327 | /// answer too: it is what the model gets when there is no panel to show a file in. |
| 328 | const show = (p, pg, owner) => page.evaluate(async ({ p, pg, owner }) => { |
| 329 | if (!window.DaimondDoc || typeof DaimondDoc.show !== 'function') { |
| 330 | return { ok: false, error: 'window.DaimondDoc is not on the page' }; |
| 331 | } |
| 332 | try { |
| 333 | // Three arguments and not two: `owner` is what the wasm passes from |
| 334 | // `ToolContext::daimon`, and a call that omits it is the user's own act. |
| 335 | const json = await DaimondDoc.show(p, pg, owner); |
| 336 | return { ok: true, verdict: JSON.parse(json) }; |
| 337 | } catch (e) { |
| 338 | return { ok: false, error: String((e && e.message) || e) }; |
| 339 | } |
| 340 | }, { p, pg, owner }); |
| 341 | |
| 342 | /// What the PREVIEW PANEL holds — `#pv-view`, the panel a person reads, never a |
| 343 | /// host of this file's own. The claim being made is about the user's screen. |
| 344 | /// |
| 345 | /// It was `#doc-view` until the Doc panel was split in two. A file that is not |
| 346 | /// characters is a RENDERING of a file, so it is drawn in the Preview panel and the |
| 347 | /// Doc panel is left holding whatever is being edited — which is the point of the |
| 348 | /// split, and is why `docPre` is read as well: it is the assertion that the |
| 349 | /// document beside this one was NOT replaced by it. |
| 350 | const inPanel = () => page.evaluate(() => { |
| 351 | const fv = document.querySelector('#pv-view .fileview'); |
| 352 | const pre = document.querySelector('#pv-view .files-view-body'); |
| 353 | const em = document.querySelector('#pv-view .fileview embed'); |
| 354 | const hex = document.querySelector('#pv-view .fileview .fv-hex'); |
| 355 | const nm = document.getElementById('pv-name'); |
| 356 | const dp = document.querySelector('#doc-view .files-view-body'); |
| 357 | return { |
| 358 | viewer: fv ? fv.getAttribute('data-viewer') : null, |
| 359 | embed: em ? { type: em.getAttribute('type'), frag: (em.src.split('#')[1] || '') } : null, |
| 360 | hex: hex ? hex.textContent : null, |
| 361 | pre: pre ? pre.textContent.slice(0, 120) : null, |
| 362 | name: nm ? nm.textContent : null, |
| 363 | docPre: dp ? dp.textContent.slice(0, 120) : null, |
| 364 | }; |
| 365 | }); |
| 366 | |
| 367 | /// A census of light and dark inside `sel`, decoded from a screenshot by the page |
| 368 | /// itself. THE ONLY INSTRUMENT THAT CAN TELL A RENDERED PAGE FROM A BROKEN ONE: |
| 369 | /// both are an element with a `blob:` URL on it. |
| 370 | const inkCensus = async (sel) => { |
| 371 | const box = await page.locator(sel).boundingBox().catch(() => null); |
| 372 | if (!box) return null; |
| 373 | const png = await page.screenshot({ clip: box, timeout: 15000 }).catch(() => null); |
| 374 | if (!png) return null; |
| 375 | return page.evaluate((b64) => new Promise((res) => { |
| 376 | const img = new Image(); |
| 377 | img.onload = () => { |
| 378 | const c = document.createElement('canvas'); |
| 379 | c.width = img.naturalWidth; c.height = img.naturalHeight; |
| 380 | const g = c.getContext('2d'); |
| 381 | g.drawImage(img, 0, 0); |
| 382 | const d = g.getImageData(0, 0, c.width, c.height).data; |
| 383 | let dark = 0, light = 0, n = 0; |
| 384 | for (let i = 0; i < d.length; i += 4) { |
| 385 | const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]; |
| 386 | if (l < 40) dark++; else if (l > 230) light++; |
| 387 | n++; |
| 388 | } |
| 389 | res({ dark: dark / n, light: light / n }); |
| 390 | }; |
| 391 | img.onerror = () => res(null); |
| 392 | img.src = 'data:image/png;base64,' + b64; |
| 393 | }), png.toString('base64')); |
| 394 | }; |
| 395 | |
| 396 | const pct = (x) => (x == null ? '?' : (x * 100).toFixed(1) + '%'); |
| 397 | |
| 398 | try { |
| 399 | await page.waitForTimeout(1200); |
| 400 | |
| 401 | check(seamLanded(), 'the seam is in js/daimond.js: the panel hands its opener to the viewer', |
| 402 | seamLanded() ? '' : 'NOT YET — the rest of this run drives a patched copy served ' |
| 403 | + 'through page.route, so the green below belongs to the design and not to the tree'); |
| 404 | |
| 405 | await put('show/report.pdf', PDF3); |
| 406 | await put('show/notes.md', MD); |
| 407 | await put('show/thing.bin', BIN); |
| 408 | |
| 409 | // ── 1. A file the model names ends up on the screen ────────── |
| 410 | const one = await show('show/report.pdf'); |
| 411 | check(one.ok, 'the daimon has a door to the document panel at all', |
| 412 | one.ok ? '' : one.error); |
| 413 | const panel1 = one.ok ? await inPanel() : null; |
| 414 | check(!!(panel1 && panel1.viewer === 'doc' && panel1.embed |
| 415 | && panel1.embed.type === 'application/pdf'), |
| 416 | 'and naming a PDF puts it in the panel, typed for the browser’s own viewer', |
| 417 | panel1 ? JSON.stringify(panel1.embed) : 'the panel holds nothing'); |
| 418 | check(!!(panel1 && panel1.name === 'show/report.pdf'), |
| 419 | 'with the panel naming the file the model asked for', |
| 420 | panel1 ? String(panel1.name) : ''); |
| 421 | |
| 422 | // AND IT IS DRAWN. Everything above is true of a grey box with a |
| 423 | // broken-page glyph in it. The gap is enormous: a sandboxed frame over the |
| 424 | // same file scores 0.0% dark and 0.0% light, and a rendered page scores tens |
| 425 | // of percent of each, so 2% is nowhere near either state. |
| 426 | await page.waitForTimeout(2500); |
| 427 | const ink1 = await inkCensus('#pv-view .fv-doc'); |
| 428 | check(!!ink1 && ink1.dark > 0.02 && ink1.light > 0.02, |
| 429 | 'and the document is DRAWN — ink on paper, read off the screen', |
| 430 | ink1 ? `dark ${pct(ink1.dark)}, light ${pct(ink1.light)}` : 'nothing to measure'); |
| 431 | |
| 432 | // ── 2. The model is told what is on the screen ─────────────── |
| 433 | const v1 = one.ok ? one.verdict : {}; |
| 434 | check(v1.tier === 'doc' && v1.media === 'Pdf', |
| 435 | 'the model is told which tier drew it and what the format is', |
| 436 | JSON.stringify({ tier: v1.tier, media: v1.media })); |
| 437 | check(v1.size === PDF3.length, |
| 438 | 'and how big the file is, from the file rather than from a guess', |
| 439 | `said ${v1.size}, wrote ${PDF3.length}`); |
| 440 | check(!!v1.label && v1.label !== v1.media, |
| 441 | 'in words rather than in an identifier, so the sentence reads', |
| 442 | JSON.stringify(v1.label)); |
| 443 | |
| 444 | // The verdict answers the PANEL'S routing question, not the viewer's. A `.md` |
| 445 | // keeps the editor — this is where DAIMOND.md and the role prompts are |
| 446 | // changed — and a model told "rendered Markdown" would describe a screen the |
| 447 | // user is not looking at. |
| 448 | const md = await show('show/notes.md'); |
| 449 | const panelMd = await inPanel(); |
| 450 | check(md.ok && md.verdict.tier === 'editor', |
| 451 | 'a Markdown file is reported as being in the editor', |
| 452 | md.ok ? String(md.verdict.tier) : md.error); |
| 453 | // `docPre`, the DOC panel's body: an editable file goes there, beside whatever |
| 454 | // rendering the Preview panel is holding rather than over it. |
| 455 | check(!!(panelMd.docPre && /Heading/.test(panelMd.docPre)), |
| 456 | 'and that is what the panel is really showing', |
| 457 | JSON.stringify(panelMd.docPre)); |
| 458 | |
| 459 | // ── 3. A format with no viewer is shown, and said ──────────── |
| 460 | const bin = await show('show/thing.bin'); |
| 461 | const panelBin = await inPanel(); |
| 462 | check(bin.ok && bin.verdict.tier === 'hex', |
| 463 | 'a format nothing recognises is reported as the hex dump it is', |
| 464 | bin.ok ? String(bin.verdict.tier) : bin.error); |
| 465 | check(!!(panelBin.hex && panelBin.hex.indexOf(BIN_HEAD) !== -1), |
| 466 | 'and it IS shown — the dump on screen carries this file’s own bytes', |
| 467 | panelBin.hex ? JSON.stringify(panelBin.hex.slice(0, 60)) : 'no dump'); |
| 468 | |
| 469 | // ── 4. A live handle, not a snapshot ───────────────────────── |
| 470 | // |
| 471 | // The whole of what makes a recompiled document reach the reader. The tool |
| 472 | // names a PATH; the panel reads the file; so writing the file again and |
| 473 | // showing the same path again puts the new bytes on screen. A view handed |
| 474 | // CONTENT could not do this at all. |
| 475 | await show('show/report.pdf'); |
| 476 | await page.waitForTimeout(2500); |
| 477 | const before = await inkCensus('#pv-view .fv-doc'); |
| 478 | await put('show/report.pdf', PDF_REDO); |
| 479 | const redo = await show('show/report.pdf'); |
| 480 | await page.waitForTimeout(2500); |
| 481 | const after = await inkCensus('#pv-view .fv-doc'); |
| 482 | check(redo.ok && redo.verdict.size === PDF_REDO.length, |
| 483 | 'showing the same path after a rewrite reports the NEW file’s size', |
| 484 | redo.ok ? `said ${redo.verdict.size}, wrote ${PDF_REDO.length}` : redo.error); |
| 485 | check(!!(before && after && Math.abs(after.dark - before.dark) > 0.05), |
| 486 | 'and the reader is looking at the rewritten document, not the old one', |
| 487 | before && after ? `dark ${pct(before.dark)} → ${pct(after.dark)}` : 'nothing to measure'); |
| 488 | |
| 489 | // ── 5. And it can be aimed ─────────────────────────────────── |
| 490 | // |
| 491 | // Measured because everything a watched document could do about the reader's |
| 492 | // place rests on it. `#page=N` on a `blob:` URL DOES reach the browser's PDF |
| 493 | // viewer; nothing reads the position back out, so a document can be reopened |
| 494 | // where it was last AIMED and never where the reader had scrolled to. |
| 495 | await put('show/report.pdf', PDF3); |
| 496 | await show('show/report.pdf', 1); |
| 497 | await page.waitForTimeout(2500); |
| 498 | const at1 = await inkCensus('#pv-view .fv-doc'); |
| 499 | const aimed = await show('show/report.pdf', 3); |
| 500 | await page.waitForTimeout(2500); |
| 501 | const at3 = await inkCensus('#pv-view .fv-doc'); |
| 502 | const panelAimed = await inPanel(); |
| 503 | check(!!(panelAimed.embed && panelAimed.embed.frag === 'page=3'), |
| 504 | 'a page asked for reaches the element as a fragment', |
| 505 | panelAimed.embed ? JSON.stringify(panelAimed.embed.frag) : 'no embed'); |
| 506 | check(!!(at1 && at3 && at3.dark - at1.dark > 0.1), |
| 507 | 'and page 3 is on screen rather than page 1 — the ink says so', |
| 508 | at1 && at3 ? `dark ${pct(at1.dark)} at page 1, ${pct(at3.dark)} at page 3` : 'nothing to measure'); |
| 509 | check(aimed.ok && aimed.verdict.page === 3, |
| 510 | 'with the model told which page it is actually looking at', |
| 511 | aimed.ok ? String(aimed.verdict.page) : aimed.error); |
| 512 | |
| 513 | // ── 6. And showing it AGAIN keeps the place ────────────────── |
| 514 | // |
| 515 | // The two halves of a watched document: it must come back with the new bytes |
| 516 | // (4) and it must come back where the reader was (this). Nothing can read |
| 517 | // where they had SCROLLED to — the browser's PDF viewer answers no query and |
| 518 | // says nothing when it moves — so the most that is available is that a |
| 519 | // re-show with no page named lands where the file was last aimed, rather than |
| 520 | // snapping to page 1. That is what is checked here, and the page reported to |
| 521 | // the model is the one the panel used and not the argument it was given. |
| 522 | await put('show/report.pdf', PDF3); // the rebuild: same path, same three pages |
| 523 | const again = await show('show/report.pdf'); |
| 524 | await page.waitForTimeout(2500); |
| 525 | const stillAt3 = await inkCensus('#pv-view .fv-doc'); |
| 526 | check(again.ok && again.verdict.page === 3, |
| 527 | 'showing it again with no page named reports the page it was left at', |
| 528 | again.ok ? String(again.verdict.page) : again.error); |
| 529 | check(!!(stillAt3 && at1 && stillAt3.dark - at1.dark > 0.1), |
| 530 | 'and the reader is still on page 3 rather than back at page 1', |
| 531 | stillAt3 && at1 ? `dark ${pct(stillAt3.dark)} against ${pct(at1.dark)} at page 1` |
| 532 | : 'nothing to measure'); |
| 533 | |
| 534 | // ── 7. And it may not take a screen that belongs elsewhere ─── |
| 535 | // |
| 536 | // The owner is stubbed rather than driven through the rail: what is under test |
| 537 | // is the RULE — does a show name the conversation asking, and is it compared |
| 538 | // with the one in view — and building two Diamonds to answer it would put the |
| 539 | // rail's own defects between this check and the thing it measures. The stub |
| 540 | // goes in through the same door `daimond.js` uses, so the seam being exercised |
| 541 | // is the shipped one and not a copy. |
| 542 | // |
| 543 | // Note what is asserted about the REFUSED show, because the weak version of |
| 544 | // this check is "it returned something": the panel must still hold the file |
| 545 | // from the show BEFORE it, which is the actual complaint — a user mid-document |
| 546 | // having it swapped underneath them. |
| 547 | await put('show/other.pdf', PDF3); |
| 548 | await page.evaluate(() => DaimondViewer.screenOwner(() => 'd-onscreen')); |
| 549 | |
| 550 | const mine = await show('show/report.pdf', 1, 'd-onscreen'); |
| 551 | await page.waitForTimeout(1500); |
| 552 | check(mine.ok && mine.verdict.shown === true, |
| 553 | 'the Diamond the user is looking at may put a file on their screen', |
| 554 | mine.ok ? JSON.stringify(mine.verdict.shown) : mine.error); |
| 555 | const held = await inPanel(); |
| 556 | |
| 557 | const theirs = await show('show/other.pdf', 1, 'd-elsewhere'); |
| 558 | await page.waitForTimeout(1500); |
| 559 | check(theirs.ok && theirs.verdict.shown === false, |
| 560 | 'and a Diamond the user is NOT looking at is told its show did not land', |
| 561 | theirs.ok ? JSON.stringify(theirs.verdict.shown) : theirs.error); |
| 562 | const kept = await inPanel(); |
| 563 | check(kept.name === held.name && held.name === 'show/report.pdf', |
| 564 | 'while the panel goes on holding the document the user was actually reading', |
| 565 | `was ${JSON.stringify(held.name)}, now ${JSON.stringify(kept.name)}`); |
| 566 | |
| 567 | // HELD, NOT DROPPED. The refusal is "not yet", so the file has to be waiting |
| 568 | // when the user reaches that Diamond — and waiting exactly once, or every |
| 569 | // later visit reopens a panel about a turn everyone has forgotten. |
| 570 | const waiting = await page.evaluate(() => [ |
| 571 | DaimondViewer.takeDeferred('d-elsewhere'), |
| 572 | DaimondViewer.takeDeferred('d-elsewhere'), |
| 573 | ]); |
| 574 | check(waiting[0] === 'show/other.pdf', |
| 575 | 'and the file it wanted shown is held for when the user opens that Diamond', |
| 576 | JSON.stringify(waiting[0])); |
| 577 | check(waiting[0] !== '' && waiting[1] === '', |
| 578 | 'once, so a later visit does not reopen a panel about a forgotten turn', |
| 579 | JSON.stringify(waiting)); |
| 580 | } finally { |
| 581 | await s.close(); |
| 582 | } |
| 583 | |
| 584 | console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed'); |
| 585 | process.exit(bad ? 1 : 0); |