Oregami
Repositories/oxedyne/daimond

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.
67import fs from 'node:fs';
68import { open, scratch } from './harness.mjs';
69
70const PROFILE = scratch('pw', 'fileview');
71fs.rmSync(PROFILE, { recursive: true, force: true });
72
73let bad = 0;
74const 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.
80const 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.
87const 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];
96const 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.
118function 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}
139const 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.
146const 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.
158const 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.
167const 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.
172const BIN_HEAD = 'DAIMOND BYTES!!!';
173const BIN = ascii(BIN_HEAD).concat([0x00]);
174while (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.
177const LOGBYTES = ascii('hello').concat([0x00], ascii('world'));
178
179const MD = ascii('# Heading\n\nsome *text*\n');
180const JSON_ = ascii('{"a":[1,2],"b":"x"}');
181// The quoted field holds the delimiter, so the table proves it parsed rather
182// than split.
183const CSV = ascii('name,qty\n"a,b",2\n');
184
185const s = await open({ name: 'fileview', profile: PROFILE, connect: false, defaults: false });
186const { 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.
194const 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.
205const 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.
227const 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.
293const 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.
322const 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
329try {
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
674console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed');
675process.exit(bad ? 1 : 0);