Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_crystalpage.mjs

56.4 KiB, 1 run

created by r2519314175:333, 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_crystalpage.mjs — a Diamond's crystal is rendered by a page a MODEL
2// wrote, and that page is a stranger in the house.
3//
4// The page is the first thing in this app that runs code the user never saw,
5// written by a model that may itself have been steered by a web page it read a
6// moment ago — and unlike every other injection surface here, this one PERSISTS.
7// A hostile line in a page is absent from the standing context, unseen by the
8// reducer and unseen by the fold diff: the one injection in Daimond that survives
9// a turn, and it syncs to every device the user owns. It is NOT exempt from a cap,
10// which this list said until 2026-08-28 -- `dev/verify_crystalcap.mjs` enforces
11// `CRYSTAL_PAGE_CAP_DEFAULT` on it and has since 2026-08-09. The clause was never
12// carrying the argument: a line survives because nothing here reads the page, not
13// because nothing weighs it.
14//
15// TWO mechanisms hold it, and they close different halves of the problem.
16//
17// The SANDBOX stops the page READING. `allow-scripts` and nothing else, which is
18// easy to lose: the page is served from a `blob:` URL, and a blob URL INHERITS
19// OUR ORIGIN. Add `allow-same-origin` and the page is not sandboxed at all — it
20// runs as Daimond, reads the `localStorage` the account key lives in, and reaches
21// OPFS. Nothing about the screen would look different.
22//
23// The CONTENT SECURITY POLICY stops the page SENDING, and the sandbox does
24// nothing about that. The page is handed the whole crystal by design, so without
25// a policy a model-authored page can beacon it to a third party on every render,
26// on every device, silently, with no egress gate anywhere near it. That is the
27// same shape of risk that removed the `ask` verb, and `mount` answers it by
28// injecting `default-src 'none'` into any page that did not bring its own.
29//
30// What is pinned here, in the order of how much damage its absence does:
31//
32// 1. The frame is sandboxed, and the sandbox is REAL — asserted by the parent
33// being unable to reach into the frame, not only by reading an attribute.
34// 2. The page cannot reach the network. Asserted by the page TRYING, from
35// inside, and reporting that it was stopped.
36// 3. There is no `ask` verb and no write verb. The channel is pull-only, and a
37// message claiming otherwise changes nothing. A parent cannot verify user
38// activation across the boundary, so a timer is indistinguishable from a
39// click; that is why `ask()` was dropped and why the ask-the-daimon box
40// lives in app chrome BELOW the frame, where a click is provably a person.
41// 4. A message that is not from the frame's own `contentWindow`, or does not
42// carry `{dc:1, v:1}`, is ignored. Any script on the page can postMessage.
43// 5. `asset` reads this Diamond's own files and nothing else.
44// 6. A page that never answers falls back to the built-in view, visibly. This
45// is what a syntax error looks like from outside an opaque origin: there is
46// no error to see, only silence. So does a page that answers and reports
47// rendered keys NOT covering the data's content-carrying keys — the case a
48// naive test misses, and the one that catches a page rendering three
49// sections of seven after a key rename. So does a page that says `ready` and
50// never reports at all, and a page that navigates itself somewhere else.
51// (The Claude Artifacts lesson: the sandbox is not the hard part; everything
52// it forbids fails silently.)
53// 7. Nothing is dropped. An unknown top-level key survives the built-in view
54// and a round trip through the form editor. Home Assistant is the precedent
55// — Lovelace's structured editor silently deletes `card_mod` config it
56// cannot express, and an LLM is not more careful than a form editor.
57// 8. A link goes through the egress gate, and the gate is asked AGAIN for the
58// same host, because one reasonable approval must not let the page walk the
59// crystal out in URL fragments. A link to Daimond's own address never gets
60// that far: `egressAllowed` answers `allow` for our own host before any
61// field can influence it, so `crystal.js` refuses it itself.
62// 9. The editor refuses unparseable JSON and says so. Today `editCrystal`
63// writes a textarea straight through — harmless for markdown, which has no
64// parse failure, and a data-loss path for JSON.
65//
66// THE INSTRUMENT, and why it is trustworthy. Most of these are assertions that
67// NOTHING happened, and a probe that never fired satisfies every one of them. So
68// the harness supplies each Diamond's page itself: a page that relays whatever
69// the harness asks it to say, so a message under test genuinely originates in the
70// frame's own `contentWindow` — nothing else can produce one. It also reports on
71// its own document from the inside, which is the only place the injected policy
72// and the resulting quirks mode can honestly be read. The egress check runs FIRST
73// and proves the relay reaches the app; every "nothing happened" check below it
74// is therefore about the app's answer and not about a dead probe.
75//
76// `DaimondCrystal._state()` exists for this file. It is used for the machine
77// half of each fallback — which of the two reasons fired — and never on its own:
78// a module agreeing with itself proves nothing about what a person sees, so every
79// fallback is also read off the screen.
80//
81// How these go red (this lane could not run a browser, so the lead's batched pass
82// is the first time they are exercised):
83//
84// * add `allow-same-origin` to the sandbox → checks 1a and 1c go red, and 1c
85// stays red even if the attribute is later split across two `setAttribute`
86// calls that a string comparison would miss;
87// * skip the policy injection → the beacon check goes red and every other
88// check in the file stays green, which is exactly why it is here;
89// * put the injected `<meta>` ahead of the doctype → the quirks-mode check goes
90// red and the policy check stays green;
91// * accept a message without comparing `e.source` → the three source checks go
92// red and nothing else does;
93// * drop the coverage half of the fallback rule → the partial check goes red
94// and the timeout check stays green, which is the shape of the naive build;
95// * make the coverage rule "every key" rather than "every key that carries
96// content" → the sparse control goes red;
97// * memoise the egress answer per host → the second-time check goes red.
98//
99// node dev/verify_crystalpage.mjs
100//
101// Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway, no mock LLM: nothing
102// here runs a turn.
103import fs from 'node:fs';
104import { open, scratch, signInAs, APP } from './harness.mjs';
105
106const PROFILE = scratch('pw', 'crystalpage');
107fs.rmSync(PROFILE, { recursive: true, force: true });
108
109let bad = 0;
110const check = (pass, name, detail) => {
111 if (!pass) bad++;
112 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
113};
114
115/// Apostrophes and runs of whitespace, flattened.
116///
117/// A string compared against what is on screen must survive the difference
118/// between a typewriter apostrophe and a typographic one, and between a newline
119/// in the source and a space in `innerText`. Neither difference is the property.
120const norm = (x) => String(x == null ? '' : x)
121 .replace(/[‘’ʼ´]/g, "'")
122 .replace(/\s+/g, ' ')
123 .trim();
124
125/// The policy `mount` injects into a page that arrived without one.
126///
127/// READ FROM THE APP, not copied. A literal here goes green for ever the moment the
128/// policy is tightened or loosened, which is the one thing a check on a security
129/// property must not do -- it would have said "the page is given a policy" about
130/// whatever string happened to be there. `DaimondCrystal.PAGE_CSP` is exported for
131/// exactly this.
132///
133/// Reading it from the app makes THIS check self-consistent, so it cannot be the
134/// only one: `admitsNoHost` below asserts the property the policy exists for, and
135/// the beacon check asserts the consequence by trying the network from inside the
136/// frame and being stopped. Those two are the evidence; this one only says the
137/// page was given the app's policy rather than some other.
138let POLICY = null;
139
140/// Whether a policy admits no network origin at all.
141///
142/// The only sources allowed to appear are the ones that cannot reach a host:
143/// `'none'`, `'self'` is NOT among them, `'unsafe-inline'`, and `data:`. Anything
144/// naming a scheme, a wildcard or a domain is a way out, and the whole point of the
145/// policy is that a page handed the crystal has none.
146const admitsNoHost = (policy) => String(policy)
147 .split(';')
148 .map((d) => d.trim())
149 .filter(Boolean)
150 .every((d) => d.split(/\s+/).slice(1)
151 .every((src) => /^('none'|'unsafe-inline'|data:)$/.test(src)));
152/// A page that brought its own. Deliberately short of `style-src`, so "the page
153/// kept its own" and "the app added its own as well" are different strings.
154const OWN_POLICY = "default-src 'none'; script-src 'unsafe-inline'; img-src data:";
155/// Somewhere that certainly answers, so a blocked beacon means the policy and not
156/// a dead host. `no-cors`, so CORS cannot be mistaken for the thing under test.
157const BEACON = APP + '/index.html';
158
159// ── The probe pages ──────────────────────────────────────────────────
160//
161// Each is a real page written into a real Diamond's `crystal.html`, so the whole
162// path runs: the store reads it, the app mounts it, the channel carries it.
163
164/// A page that speaks the protocol and relays anything the harness asks it to say.
165///
166/// The relay is the only way to produce a message whose `source` really is this
167/// frame's `contentWindow`. The echo goes back out under a verb the parent does
168/// not know, so the harness can watch the far side of the channel — including the
169/// parent's own answers — without changing what the app does with them.
170///
171/// # Arguments
172/// * `o.report` - Source of a function returning the keys to report as rendered;
173/// omit it entirely for a page that says `ready` and then nothing.
174/// * `o.csp` - A policy the page brings itself, for the case where `mount` must
175/// leave it alone.
176const probePage = (o) => {
177 o = o || {};
178 const own = o.csp
179 ? '<meta http-equiv="Content-Security-Policy" content="' + o.csp + '">'
180 : '';
181 return [
182 '<!doctype html><meta charset="utf-8"><title>probe</title>' + own,
183 '<body><div id="out">probe page</div>',
184 '<script>',
185 '(function () {',
186 ' var send = function (m) { parent.postMessage(m, "*"); };',
187 ' addEventListener("message", function (e) {',
188 ' var d = e.data;',
189 ' if (!d || typeof d !== "object") return;',
190 ' if (d.__say) { send(d.__say); return; }',
191 ' if (d.__nav) { location.href = "about:blank"; return; }',
192 ' if (d.__self) {',
193 ' var ms = [].slice.call(document.querySelectorAll(',
194 ' "meta[http-equiv=\'Content-Security-Policy\']"));',
195 ' var out = {',
196 ' compat: document.compatMode,',
197 ' doctype: !!document.doctype,',
198 ' policies: ms.map(function (m) { return m.getAttribute("content") || ""; }),',
199 ' };',
200 ' var done = function (b) {',
201 ' out.beacon = b;',
202 ' send({ dc: 1, v: 1, cmd: "__probe_echo", self: out });',
203 ' };',
204 ' try {',
205 ' fetch("' + BEACON + '", { mode: "no-cors" })',
206 ' .then(function () { done("allowed"); }, function () { done("blocked"); });',
207 ' } catch (err) { done("blocked"); }',
208 ' return;',
209 ' }',
210 ' send({ dc: 1, v: 1, cmd: "__probe_echo", said: d });',
211 ' if (d.dc === 1 && d.cmd === "data") {',
212 ' var data = d.data || {};',
213 ' var keys = Object.keys(data);',
214 ' document.getElementById("out").textContent = keys.join(", ");',
215 (o.report
216 ? ' send({ dc: 1, v: 1, cmd: "rendered", keys: (' + o.report + ')(data, keys) });'
217 : ' /* deliberately silent: ready, and then nothing at all */'),
218 ' }',
219 ' });',
220 ' send({ dc: 1, v: 1, cmd: "ready" });',
221 '}());',
222 '<\/script>',
223 ].join('\n');
224};
225
226/// Every key it was given.
227const REPORT_ALL = 'function (d, k) { return k; }';
228/// The title and nothing else: a page that renders one section of several.
229const REPORT_TITLE = 'function (d, k) { return k.filter(function (x) { return x === "title"; }); }';
230/// Every key that carries content, and only those.
231///
232/// A key holding an empty string or an empty list has nothing to render, so a
233/// page that skips it has skipped nothing, and a parent that fell back over it
234/// would fall back on most Diamonds. Nor does this page report the channel's own
235/// keys, the ones with a leading underscore: they are not the Diamond's data and
236/// a page is not failing by leaving them out.
237const REPORT_CARRYING = 'function (d, k) { return k.filter(function (x) {'
238 + ' var v = d[x];'
239 + ' if (x.charAt(0) === "_") return false;'
240 + ' if (v === "" || v === null || v === undefined) return false;'
241 + ' if (Array.isArray(v) && !v.length) return false;'
242 + ' return true; }); }';
243
244/// A page that cannot be parsed, which is the failure the parent CANNOT see: no
245/// error crosses an opaque origin, so silence is the only symptom.
246const PAGE_BROKEN = '<!doctype html><meta charset="utf-8"><title>broken</title>'
247 + '<body>a page that will not parse<script>this is not ) valid javascript(<\/script>';
248
249/// A real, protocol-speaking page with a solid background it chose for itself --
250/// what a page looks like after a person asked the daimon for one, or hand-edited
251/// `crystal.html` directly. Content is deliberately ONE short line, so the page's
252/// own measured height is far short of the panel: the property under test is
253/// whether the REST of the panel picks up this colour or the app's ordinary
254/// chrome shows through beneath it.
255const SOLID_BG = '#ff00aa';
256const PAGE_SOLID_BG = [
257 '<!doctype html><meta charset="utf-8"><title>solid</title>',
258 '<style>html,body{background:' + SOLID_BG + ';margin:0;padding:0}',
259 'body{font-family:sans-serif;color:#fff;padding:8px}</style>',
260 '<body><div id="r"></div><script>',
261 '(function(){',
262 'var R=document.getElementById("r"),last=-1;',
263 'function post(o){o.dc=1;o.v=1;parent.postMessage(o,"*");}',
264 'function measure(){var px=Math.ceil(Math.max(document.body.scrollHeight,',
265 'R.getBoundingClientRect().height))+2;',
266 'if(Math.abs(px-last)<2)return;last=px;post({cmd:"height",px:px});}',
267 'addEventListener("message",function(e){if(e.source!==parent)return;',
268 'var m=e.data;if(!m||m.dc!==1||m.v!==1)return;',
269 'if(m.cmd==="data"){var d=m.data||{};',
270 'var h="<h1>"+(d.title||"")+"</h1><p>"+(d.summary||"")+"</p>";',
271 // `lines`, rendered one `<p>` per entry: how the TALL fixture below gets
272 // genuine, measurable content height rather than a hard-coded number this
273 // page would have to be trusted to report honestly.
274 '(d.lines||[]).forEach(function(l){h+="<p>"+l+"</p>";});',
275 'R.innerHTML=h;',
276 'post({cmd:"rendered",keys:Object.keys(d)});measure();}});',
277 'if(window.ResizeObserver)new ResizeObserver(measure).observe(document.body);',
278 'post({cmd:"ready"});',
279 '})();',
280 '<\/script>',
281].join('\n');
282
283// ── The fixtures ─────────────────────────────────────────────────────
284const D = {
285 good: { name: 'Page Good', page: probePage({ report: REPORT_ALL }) },
286 broken: { name: 'Page Broken', page: PAGE_BROKEN },
287 partial: { name: 'Page Partial', page: probePage({ report: REPORT_TITLE }) },
288 silent: { name: 'Page Silent', page: probePage({}) },
289 sparse: { name: 'Page Sparse', page: probePage({ report: REPORT_CARRYING, csp: OWN_POLICY }) },
290 renav: { name: 'Page Renav', page: probePage({ report: REPORT_ALL }) },
291 extra: { name: 'Page Extra', page: PAGE_BROKEN },
292 bgshort: { name: 'Page BgShort', page: PAGE_SOLID_BG },
293 bgtall: { name: 'Page BgTall', page: PAGE_SOLID_BG },
294};
295D.good.data = {
296 title: 'The good page',
297 summary: 'Everything here is rendered.',
298 sections: [{ heading: 'One', body: 'The first thing.' }],
299 facts: [{ k: 'state', v: 'open' }],
300};
301D.broken.data = { title: 'BROKEN-PAGE-TITLE', summary: 'The data must still be readable.' };
302D.partial.data = {
303 title: 'PARTIAL-PAGE-TITLE',
304 summary: 'Shown by nothing.',
305 sections: [{ heading: 'Unrendered', body: 'A section the page never drew.' }],
306};
307D.silent.data = { title: 'SILENT-PAGE-TITLE', summary: 'The page said hello and nothing else.' };
308// Two keys that carry nothing. A page that skips them has skipped nothing.
309D.sparse.data = { title: 'SPARSE-PAGE-TITLE', summary: 'All of it, drawn.', facts: [], open: [] };
310D.renav.data = { title: 'RENAV-PAGE-TITLE', summary: 'Then it went somewhere else.' };
311// One short line, deliberately: the whole point of this fixture is that its
312// measured content height is a fraction of the panel's.
313D.bgshort.data = { title: 'BgShort', summary: 'One short line.' };
314// The other half of the same fix, on the same page format: content that is
315// genuinely tall, measured by the page itself rather than asserted here.
316D.bgtall.data = {
317 title: 'BgTall', summary: 'Overflow probe.',
318 lines: Array.from({ length: 150 }, (_, i) => 'Line ' + i + ' of a very long crystal page.'),
319};
320// The unknown key is an OBJECT, not a string: a form that keeps unknown keys by
321// stringifying them passes the easy version of this check and mangles the real
322// one. And it does NOT begin with an underscore — those belong to the channel
323// itself (`_theme`, `_labels`), and are stripped by design rather than dropped.
324D.extra.data = {
325 title: 'Extra keys',
326 summary: 'Holds a key nothing in this app knows about.',
327 provenance: { from: 'PROVENANCE-KEEPSAKE', when: 7, tags: ['a', 'b'] },
328};
329
330// The English the contract fixes for each string, used when the key is not in the
331// table yet. `tOr('key', 'English')` renders exactly this, so the comparison holds
332// whether or not the i18n lane has landed.
333const EN = {
334 'crystal.page_failed': 'This Diamond\'s page did not load, so its data is shown instead.',
335 'crystal.page_partial': 'This Diamond\'s page did not show everything it holds, so its data is shown instead.',
336 'crystal.page_reset': 'Reset the page',
337 'crystal.edit_json': 'Edit as JSON',
338 'crystal.json_invalid': 'That is not valid JSON, so nothing was saved.',
339 'crystal.other_fields': 'Other fields',
340 'crystal.field_title': 'Title',
341 'common.save': 'Save',
342};
343
344const s = await open({ name: 'crystalpage', profile: PROFILE, connect: false });
345const p = s.page;
346
347/// The app's own words for a key, falling back to the contract's English when the
348/// string has not landed. `t()` returns the KEY itself when it finds nothing, and
349/// a check comparing against a raw key name would be comparing against noise.
350const T = async (key) => {
351 const got = await p.evaluate((k) => {
352 try { return window.DaimondI18n ? DaimondI18n.t(k) : k; } catch (e) { return k; }
353 }, key);
354 return (got === key) ? EN[key] : got;
355};
356
357/// Open a Diamond by name and put its crystal face up.
358///
359/// Reports rather than throws: one fixture missing from the rail must cost the
360/// checks that need it, not the whole run.
361const showDiamond = async (name) => {
362 const r = await p.evaluate((nm) => {
363 const box = [...document.querySelectorAll('#diamond-list .diamond-box')]
364 .find((b) => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === nm);
365 if (!box) {
366 return { ok: false, saw: [...document.querySelectorAll('#diamond-list .session-box-name')]
367 .map((n) => (n.textContent || '').trim()).join(' | ') };
368 }
369 box.click();
370 return { ok: true, saw: '' };
371 }, name);
372 check(r.ok, 'the Diamond "' + name + '" is on the rail', r.ok ? '' : 'rail holds: ' + r.saw);
373 await p.waitForTimeout(700);
374 await p.evaluate(() => {
375 const b = document.getElementById('dview-crystal');
376 if (b && b.getAttribute('aria-pressed') !== 'true') b.click();
377 });
378 await p.waitForTimeout(300);
379 return r.ok;
380};
381
382/// Say something to the parent AS THE FRAME. Throws if there is no frame, so a
383/// probe can never quietly measure nothing.
384const say = (msg) => p.evaluate((m) => {
385 const f = document.querySelector('.crystal-frame');
386 if (!f || !f.contentWindow) throw new Error('there is no crystal frame to speak from');
387 f.contentWindow.postMessage({ __say: m }, '*');
388 return true;
389}, msg);
390
391/// Ask the page about its own document, from the inside.
392const selfReport = async () => {
393 await p.evaluate(() => {
394 const f = document.querySelector('.crystal-frame');
395 if (!f || !f.contentWindow) throw new Error('there is no crystal frame to ask');
396 f.contentWindow.postMessage({ __self: 1 }, '*');
397 });
398 await p.waitForTimeout(2000); // the beacon has to be given time to fail
399 const rows = await p.evaluate(() => window.__seen.slice());
400 const hit = rows.filter((x) => x.data && x.data.self).pop();
401 return hit ? hit.data.self : null;
402};
403
404/// Wait for a dialog, answer it, and report what it said. `null` if none came.
405const answerDialog = async (yes, ms = 3500) => {
406 const t0 = Date.now();
407 while (Date.now() - t0 < ms) {
408 const seen = await p.evaluate(() => {
409 const d = document.querySelector('.dlg .dlg-card');
410 return d ? { text: (d.innerText || d.textContent || '').slice(0, 400) } : null;
411 });
412 if (seen) {
413 await p.evaluate((ok) => {
414 const d = document.querySelector('.dlg .dlg-card');
415 if (!d) return;
416 const btn = ok ? d.querySelector('.dlg-ok')
417 : (d.querySelector('.dlg-cancel') || d.querySelector('.dlg-ok'));
418 if (btn) btn.click();
419 }, yes);
420 await p.waitForTimeout(250);
421 return seen;
422 }
423 await p.waitForTimeout(100);
424 }
425 return null;
426};
427
428/// What is in the crystal panel now, as a person would find it.
429const face = () => p.evaluate(() => {
430 const wrap = document.getElementById('crystal-frame-wrap');
431 const frame = document.querySelector('.crystal-frame');
432 const fb = document.querySelector('.crystal-fallback');
433 const note = document.querySelector('.crystal-fallback-note');
434 const reset = document.querySelector('.crystal-reset');
435 const body = document.getElementById('crystal-body');
436 const seen = (e) => !!e && !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length);
437 let st = null;
438 try { st = window.DaimondCrystal ? DaimondCrystal._state() : null; } catch (e) { st = null; }
439 return {
440 hasWrap: !!wrap,
441 hasFrame: !!frame,
442 framed: seen(frame),
443 inWrap: !!(frame && wrap && wrap.contains(frame)),
444 sandbox: frame ? frame.getAttribute('sandbox') : null,
445 src: frame ? String(frame.getAttribute('src') || '') : null,
446 reachable: frame ? (frame.contentDocument !== null) : null,
447 fallback: seen(fb),
448 note: note ? (note.innerText || note.textContent || '') : null,
449 reset: reset ? (reset.innerText || reset.textContent || '') : null,
450 text: body ? (body.innerText || '') : '',
451 mode: st ? String(st.mode) : null,
452 ready: st ? !!st.ready : null,
453 reason: st ? (st.reason || '') : null,
454 keys: st ? (st.keys || []) : null,
455 };
456});
457
458/// The crystal data as it is on disk, read through the store rather than through
459/// whatever the panel believes it is showing.
460const stored = (id) => p.evaluate(async (x) => {
461 try { return await window.__probe.app.read_crystal_data(x); }
462 catch (e) { return 'ERR ' + (e && e.message || e); }
463}, id);
464
465const ids = {};
466
467try {
468 await p.waitForTimeout(1500);
469
470 // ── Build the fixtures through the store ─────────────────────
471 const built = await p.evaluate(async (fix) => {
472 const mod = await import('../pkg/oxedyne_daimond.js');
473 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
474 const absent = ['write_crystal_data', 'write_crystal_page', 'read_crystal_data']
475 .filter((fn) => typeof app[fn] !== 'function');
476 if (absent.length) return { absent };
477 const out = {};
478 for (const key of Object.keys(fix)) {
479 const id = await app.create_diamond(fix[key].name);
480 await app.write_crystal_data(id, JSON.stringify(fix[key].data));
481 await app.write_crystal_page(id, fix[key].page);
482 out[key] = id;
483 }
484 // A file of this Diamond's own, for the `asset` verb to find.
485 await app.run_tool('file_write', JSON.stringify({
486 path: 'diamonds/' + out.good + '/note.md', content: 'ASSET-IN-SCOPE\n' }));
487 return { absent: [], ids: out };
488 }, D);
489
490 check(built.absent.length === 0, 'the engine can store a Diamond\'s data and its page',
491 built.absent.length ? 'missing: ' + built.absent.join(', ') : '');
492 Object.assign(ids, built.ids || {});
493
494 // The rail is redrawn from disk the way a person would see it.
495 await p.reload({ waitUntil: 'domcontentloaded' });
496 await signInAs(s, 'crystalpage');
497 await p.waitForFunction(() => !!window.DaimondCrystal && !!window.__daimondEgressAllowed,
498 null, { timeout: 20000 }).catch(() => {});
499 await p.waitForTimeout(1500);
500
501 POLICY = await p.evaluate(() => (window.DaimondCrystal || {}).PAGE_CSP || '');
502 check(!!POLICY, 'the app publishes the policy it injects, so this file need not keep a copy',
503 'DaimondCrystal.PAGE_CSP was ' + JSON.stringify(POLICY));
504 // The property, asserted independently of what the string happens to say. A
505 // policy that admitted one host would still be "a policy", and the page is
506 // handed the whole crystal.
507 check(admitsNoHost(POLICY), 'and that policy admits no network origin anywhere in it',
508 POLICY);
509
510 const hasModule = await p.evaluate(() => !!(window.DaimondCrystal
511 && typeof DaimondCrystal.mount === 'function'
512 && typeof DaimondCrystal.parse === 'function'
513 && typeof DaimondCrystal._state === 'function'));
514 check(hasModule, 'the crystal module is loaded, with the verifier hook',
515 hasModule ? '' : 'window.DaimondCrystal is not there, or has no _state()');
516
517 const FALLBACK_MS = await p.evaluate(() =>
518 (window.DaimondCrystal && DaimondCrystal.FALLBACK_MS) || 1500);
519 // Long enough that a slow machine is not mistaken for a silent page. A page
520 // that says `ready` and then nothing is given TWO windows, because that is the
521 // rule: the second one starts when the first is answered.
522 const settle = FALLBACK_MS + 2500;
523 const settle2 = FALLBACK_MS * 2 + 3000;
524
525 // The instruments. Reinstalled after the reload, because the reload took the
526 // previous window with it — `__probe` included, which is why the store handle
527 // is rebuilt here rather than reused from the fixture build above.
528 await p.evaluate(async () => {
529 const mod = await import('../pkg/oxedyne_daimond.js');
530 window.__probe = {
531 mod,
532 app: new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true),
533 };
534 window.__gate = [];
535 const realGate = window.__daimondEgressAllowed;
536 window.__daimondEgressAllowed = function (payload) {
537 window.__gate.push(String(payload));
538 return realGate ? realGate.apply(this, arguments) : Promise.resolve('deny');
539 };
540 // A refused link must not be opened anyway. Stubbed rather than watched,
541 // because a real tab in a headless run is a hang, not a failure.
542 window.__opened = [];
543 window.open = function (u) { window.__opened.push(String(u)); return null; };
544 // Everything the frame says, and whether it really came from the frame.
545 window.__seen = [];
546 window.addEventListener('message', function (e) {
547 let mine = false;
548 try {
549 const f = document.querySelector('.crystal-frame');
550 mine = !!(f && e.source === f.contentWindow);
551 } catch (x) { /* an opaque origin refuses the comparison */ }
552 try { window.__seen.push({ mine, data: JSON.parse(JSON.stringify(e.data)) }); }
553 catch (x) { window.__seen.push({ mine, data: null }); }
554 });
555 });
556 const resetProbes = () => p.evaluate(() => {
557 window.__gate = []; window.__opened = []; window.__seen = [];
558 return true;
559 });
560 const probes = () => p.evaluate(() => ({
561 gate: window.__gate.slice(),
562 opened: window.__opened.slice(),
563 seen: window.__seen.slice(),
564 }));
565
566 // ══ 1. The frame, and the sandbox ═══════════════════════════
567 await resetProbes();
568 await showDiamond(D.good.name);
569 await p.waitForTimeout(settle);
570 let f = await face();
571
572 check(f.hasFrame && f.inWrap && f.framed,
573 'a Diamond with a page shows it in a frame inside #crystal-frame-wrap',
574 JSON.stringify({ frame: f.hasFrame, wrap: f.hasWrap, inWrap: f.inWrap, shown: f.framed }));
575 // 1a. The attribute in full, so ADDING a token fails the check. A test for the
576 // absence of `allow-same-origin` alone would wave through `allow-popups` and
577 // `allow-top-navigation`, either of which lets the page act on the user's
578 // behalf somewhere else.
579 check(norm(f.sandbox) === 'allow-scripts',
580 'the frame is sandboxed with allow-scripts and NOTHING else',
581 JSON.stringify(f.sandbox));
582 // 1b. And named, so a failure says which token arrived rather than printing
583 // two strings and leaving the reader to diff them.
584 const forbidden = String(f.sandbox || '').split(/\s+/).filter(Boolean)
585 .filter((x) => x !== 'allow-scripts');
586 check(forbidden.length === 0, 'no other sandbox token was granted', forbidden.join(', '));
587 // 1c. THE PROPERTY, not the attribute. A frame in an opaque origin refuses the
588 // parent's reach; a blob-URL frame with `allow-same-origin` hands its whole
589 // document over. This is the check that survives the sandbox being set
590 // somewhere the string comparison above is not looking.
591 check(f.reachable === false,
592 'and the sandbox is real: the parent cannot reach into the frame\'s document',
593 'contentDocument reachable: ' + String(f.reachable));
594 check(String(f.src).slice(0, 5) === 'blob:',
595 'the page is served from a blob URL, which is why the sandbox matters',
596 String(f.src).slice(0, 40));
597 // 1d. The handshake completed. Everything below that asserts NOTHING happened
598 // depends on this: a page that never loaded satisfies every negative check in
599 // this file, and this is the positive that stops them being vacuous.
600 check(!f.fallback && f.ready === true && !f.reason,
601 'a page that answers and renders everything is left alone',
602 JSON.stringify({ fallback: f.fallback, ready: f.ready, reason: f.reason }));
603 const frameMode = f.mode;
604 // 1e. And it was handed the crystal unprompted, which is the half of the
605 // handshake nothing else here would notice: a parent that answered `ready` with
606 // silence would leave a correct page drawing an empty document, and the page
607 // would report no keys and be blamed for it.
608 const handed = await p.evaluate(() => {
609 const said = window.__seen.map((x) => x.data)
610 .filter((d) => d && d.said && d.said.cmd === 'data').pop();
611 return said ? Object.keys(said.said.data || {}) : null;
612 });
613 check(!!handed && handed.includes('title') && handed.includes('sections'),
614 'and it was handed the crystal, unprompted, as soon as it said it was ready',
615 JSON.stringify(handed));
616
617 // ── The blob URL is spent once the page has it ───────────────
618 // It inherits our origin, so anything else on the origin could fetch the page
619 // back out of it for as long as it lives. The control fetch first, because a
620 // `blob:` fetch that fails for some unrelated reason would make the real check
621 // pass having proved nothing.
622 const blob = await p.evaluate(async (src) => {
623 const mine = URL.createObjectURL(new Blob(['control'], { type: 'text/html' }));
624 let control = 'unknown', spent = 'unknown';
625 try { await fetch(mine); control = 'fetchable'; } catch (e) { control = 'refused'; }
626 URL.revokeObjectURL(mine);
627 try { await fetch(src); spent = 'fetchable'; } catch (e) { spent = 'refused'; }
628 return { control, spent };
629 }, f.src);
630 check(blob.control === 'fetchable', 'a live blob URL can be fetched from this page',
631 blob.control);
632 check(blob.spent === 'refused', 'and the page\'s own blob URL has already been revoked',
633 blob.spent);
634
635 // ══ 2. The page cannot reach the network ════════════════════
636 //
637 // The sandbox stops the page reading our storage and does nothing to stop it
638 // SENDING, and the page is handed the whole crystal by design. Read from
639 // inside the frame, because that is the only place the injected policy exists.
640 await resetProbes();
641 const self = await selfReport();
642 check(!!self, 'the page can be asked about its own document',
643 self ? '' : 'no self report came back from the frame');
644 if (self) {
645 check(self.beacon === 'blocked',
646 'a page that tries to send the crystal to a third party is stopped',
647 'the beacon was ' + String(self.beacon));
648 check(self.policies.some((x) => norm(x) === norm(POLICY)),
649 'because a page that brought no policy is given one',
650 JSON.stringify(self.policies));
651 // A `<meta>` ahead of the doctype puts the whole authored page into quirks
652 // mode, which changes how every model-written stylesheet lays out.
653 check(self.doctype === true && self.compat === 'CSS1Compat',
654 'and the policy went in AFTER the doctype, so the page is not in quirks mode',
655 'compatMode ' + String(self.compat) + ', doctype ' + String(self.doctype));
656 }
657
658 // ══ 3. `asset` reads this Diamond, and only this Diamond ════
659 await resetProbes();
660 await say({ dc: 1, v: 1, cmd: 'asset', id: 'in', path: 'note.md' });
661 await say({ dc: 1, v: 1, cmd: 'asset', id: 'out', path: '../' + ids.extra + '/crystal.json' });
662 await p.waitForTimeout(1500);
663 let pr = await probes();
664 const answered = (id) => {
665 const hit = pr.seen.map((x) => x.data)
666 .filter((d) => d && d.said && d.said.id === id).pop();
667 return hit ? hit.said : null;
668 };
669 const inScope = answered('in');
670 const escaped = answered('out');
671 check(!!inScope && typeof inScope.text === 'string' && /ASSET-IN-SCOPE/.test(inScope.text),
672 'a page may read a file of its own Diamond\'s',
673 JSON.stringify(inScope).slice(0, 140));
674 check(!!escaped && !escaped.text && !!escaped.error,
675 'and a path climbing out of it is answered with an error, not a file',
676 JSON.stringify(escaped).slice(0, 140));
677 check(!/Extra keys|PROVENANCE/.test(JSON.stringify(escaped || {})),
678 'so no other Diamond\'s crystal comes back through the channel');
679
680 // ══ 4. A link goes through the egress gate ══════════════════
681 //
682 // Run before the "nothing happened" probes, because it is the one that proves
683 // the relay reaches the app at all.
684 await resetProbes();
685 await say({ dc: 1, v: 1, cmd: 'open', href: 'https://elsewhere.test/one' });
686 const askedOnce = await answerDialog(false);
687 pr = await probes();
688 check(!!askedOnce, 'a link the page asks to follow puts the question to the user',
689 askedOnce ? askedOnce.text.slice(0, 80) : 'no dialog appeared within 3.5s');
690 // THE DIALOG IS THE INSTRUMENT, not `window.__daimondEgressAllowed`. That global
691 // is the bridge the WASM side calls, and it deliberately asks the gate without
692 // the strict flag; a page's link goes through `pageEgressAllowed`, which calls
693 // the gate directly and strictly. Hooking the global therefore watched a door
694 // this traffic does not use, and the check passed or failed on the plumbing
695 // rather than on the property.
696 //
697 // What the property actually is: the user is asked, and the question NAMES THE
698 // DESTINATION. A dialog that asked "allow this link?" without saying where would
699 // satisfy "the user was asked" and be worth nothing, since the whole risk is a
700 // host that carries the crystal out in its path.
701 check(/elsewhere\.test/.test(askedOnce ? askedOnce.text : ''),
702 'and the question names the address, so the user can see where it goes',
703 askedOnce ? askedOnce.text.slice(0, 160) : 'no dialog');
704 check(pr.opened.length === 0, 'declining it opens nothing', pr.opened.join(', '));
705
706 // The same host again. The gate remembers a host for ordinary fetches, which is
707 // right for an agent reading a documentation site and wrong here: one yes about
708 // a host would license every later `host/<the whole crystal>`.
709 await resetProbes();
710 await say({ dc: 1, v: 1, cmd: 'open', href: 'https://elsewhere.test/two' });
711 const askedTwice = await answerDialog(false);
712 pr = await probes();
713 check(!!askedTwice, 'the SAME host is asked about again, with no memoisation',
714 askedTwice ? '' : 'the second link went through without asking');
715 check(/elsewhere\.test/.test(askedTwice ? askedTwice.text : ''),
716 'and the second question names the address too',
717 askedTwice ? askedTwice.text.slice(0, 160) : 'no dialog');
718
719 // Daimond's own address gets no free pass — and cannot be given one at the
720 // gate, which answers `allow` for our own host before any field of the request
721 // can influence it. So the refusal has to happen before the gate is asked.
722 // Non-vacuous because the two probes above went down the identical path and
723 // did reach it.
724 await resetProbes();
725 const ownHref = await p.evaluate(() =>
726 location.origin + '/guide/index.html?carried=' + 'x'.repeat(40));
727 await say({ dc: 1, v: 1, cmd: 'open', href: ownHref });
728 const askedOwn = await answerDialog(false);
729 pr = await probes();
730 // NOT VACUOUS: the two hrefs above went down the identical path and each raised a
731 // dialog naming its host, so "no dialog" here is the refusal and not a broken
732 // relay. It has to be refused BEFORE the gate, because the gate answers `allow`
733 // for our own host before any field of the request can influence it -- so there
734 // is no question that could be put to it that it would answer correctly.
735 check(!askedOwn,
736 'a link to Daimond\'s own address is refused before the gate, which would allow it',
737 askedOwn ? askedOwn.text.slice(0, 160) : '');
738 check(pr.opened.length === 0, 'and nothing is opened for it', pr.opened.join(', '));
739
740 // ══ 5. No ask verb, and no write verb ═══════════════════════
741 //
742 // The relay is now known to reach the app, so a message that changes nothing is
743 // the app refusing rather than the probe misfiring.
744 const beforeVerbs = await stored(ids.good);
745 await resetProbes();
746 await say({ dc: 1, v: 1, cmd: 'ask', text: 'Type your passphrase to continue', id: 'a1' });
747 await say({ dc: 1, v: 1, cmd: 'prompt', text: 'Anything at all' });
748 const askedByPage = await answerDialog(false, 1500);
749 check(!askedByPage, 'a page that claims an `ask` verb puts no question on screen',
750 askedByPage ? askedByPage.text.slice(0, 120) : '');
751
752 for (const verb of [
753 { dc: 1, v: 1, cmd: 'write', path: 'crystal.json', content: '{"title":"OWNED"}' },
754 { dc: 1, v: 1, cmd: 'save', data: { title: 'OWNED' } },
755 { dc: 1, v: 1, cmd: 'set', key: 'title', value: 'OWNED' },
756 // `data` is the PARENT's verb. A handler keyed on the name alone, without
757 // caring which way the message was travelling, would take this as a write.
758 { dc: 1, v: 1, cmd: 'data', data: { title: 'OWNED' } },
759 ]) {
760 await say(verb);
761 }
762 await p.waitForTimeout(1200);
763 const afterVerbs = await stored(ids.good);
764 check(afterVerbs === beforeVerbs, 'and no message from the page can write the crystal',
765 String(afterVerbs).slice(0, 120));
766 f = await face();
767 check(f.hasFrame && !f.fallback,
768 'the frame is still standing after all of that, so nothing threw',
769 JSON.stringify({ frame: f.hasFrame, fallback: f.fallback, reason: f.reason }));
770
771 // ══ 6. Who is speaking, and in what envelope ════════════════
772 //
773 // Any script on the page can postMessage, and the app's own window is the
774 // easiest source of all to be.
775 await resetProbes();
776 await p.evaluate(() => window.postMessage(
777 { dc: 1, v: 1, cmd: 'open', href: 'https://topwindow.test/x' }, '*'));
778 await p.waitForTimeout(900);
779 pr = await probes();
780 check(!pr.gate.some((x) => /topwindow\.test/.test(x)),
781 'a message from the app\'s own window is not the page speaking, and is ignored',
782 pr.gate.join(' | ').slice(0, 160));
783
784 await resetProbes();
785 await p.evaluate(() => {
786 const f2 = document.createElement('iframe');
787 f2.id = 'probe-stranger';
788 f2.style.cssText = 'width:1px;height:1px;position:absolute;left:-9999px';
789 f2.srcdoc = '<script>parent.postMessage('
790 + '{dc:1,v:1,cmd:"open",href:"https://stranger.test/x"}, "*");<\/script>';
791 document.body.appendChild(f2);
792 });
793 await p.waitForTimeout(1200);
794 pr = await probes();
795 await p.evaluate(() => { const x = document.getElementById('probe-stranger'); if (x) x.remove(); });
796 check(!pr.gate.some((x) => /stranger\.test/.test(x)),
797 'nor is another frame on the page, however well it knows the protocol',
798 pr.gate.join(' | ').slice(0, 160));
799
800 await resetProbes();
801 await say({ cmd: 'open', href: 'https://noenvelope.test/x' });
802 await say({ dc: 1, cmd: 'open', href: 'https://noversion.test/x' });
803 await p.waitForTimeout(900);
804 pr = await probes();
805 check(!pr.gate.some((x) => /noenvelope\.test/.test(x)),
806 'a message with no `dc` marker is ignored even from the frame itself',
807 pr.gate.join(' | ').slice(0, 160));
808 check(!pr.gate.some((x) => /noversion\.test/.test(x)),
809 'and so is one with no protocol version', pr.gate.join(' | ').slice(0, 160));
810 // The control for the three above: the relay still works, so their silence is
811 // the app's judgement and not a broken probe.
812 await resetProbes();
813 await say({ dc: 1, v: 1, cmd: 'open', href: 'https://stillworks.test/x' });
814 const stillAsked = await answerDialog(false);
815 pr = await probes();
816 check(!!stillAsked || pr.gate.some((x) => /stillworks\.test/.test(x)),
817 'while a properly addressed message from the frame still gets through, so '
818 + 'the four checks above are the app refusing and not the probe failing');
819
820 // ══ 7. Falling back, visibly, for four different reasons ════
821 const wantFailed = await T('crystal.page_failed');
822 const wantPartial = await T('crystal.page_partial');
823 const wantReset = await T('crystal.page_reset');
824
825 await showDiamond(D.broken.name);
826 await p.waitForTimeout(settle);
827 const bro = await face();
828 check(bro.fallback, 'a page that never answers is replaced by the built-in view',
829 'still framed: ' + String(bro.framed));
830 check(bro.reason === 'timeout', 'and the module knows why: it timed out',
831 JSON.stringify(bro.reason));
832 check(bro.mode !== frameMode, 'which is a different state from showing the page',
833 JSON.stringify({ frame: frameMode, now: bro.mode }));
834 check(bro.note !== null, 'the reason is on screen, not swallowed', 'no .crystal-fallback-note');
835 check(norm(bro.note).includes(norm(wantFailed)),
836 'saying the page did not load', JSON.stringify(String(bro.note).slice(0, 140)));
837 check(bro.reset !== null && norm(bro.reset).includes(norm(wantReset)),
838 'with a way to put the standard page back', JSON.stringify(bro.reset));
839 check(/BROKEN-PAGE-TITLE/.test(bro.text),
840 'and the Diamond\'s data is readable in it, which is the point of falling back');
841
842 await showDiamond(D.partial.name);
843 await p.waitForTimeout(settle);
844 const par = await face();
845 check(par.fallback && par.reason === 'partial',
846 'a page that answers but renders only some of what it holds ALSO falls back',
847 JSON.stringify({ fallback: par.fallback, reason: par.reason, keys: par.keys }));
848 check(norm(par.note || '').includes(norm(wantPartial)),
849 'and says THAT, rather than reporting a page that did not load',
850 JSON.stringify(String(par.note).slice(0, 140)));
851 // Two reasons, two notes. If the app shows one message for both, the user is
852 // told to look for a load failure in a page that loaded perfectly well.
853 check(norm(par.note || '') !== norm(bro.note || ''),
854 'the two failures do not share one note', JSON.stringify(String(par.note).slice(0, 80)));
855 check(/PARTIAL-PAGE-TITLE/.test(par.text), 'the data is readable in that one too');
856
857 // A page that says hello and then nothing is UNVERIFIABLE, and unverifiable is
858 // treated as broken. It is the rule most likely to be "fixed" later by somebody
859 // who reads it as a bug, so it is pinned on its own.
860 await showDiamond(D.silent.name);
861 await p.waitForTimeout(settle2);
862 const sil = await face();
863 check(sil.fallback && sil.reason === 'partial',
864 'a page that says `ready` and never reports what it drew falls back too',
865 JSON.stringify({ fallback: sil.fallback, reason: sil.reason }));
866 check(/SILENT-PAGE-TITLE/.test(sil.text), 'showing the data it never drew');
867
868 // The control. Without it, "always fall back" passes every check above and the
869 // page is never used at all.
870 await showDiamond(D.sparse.name);
871 await p.waitForTimeout(settle);
872 const spa = await face();
873 check(!spa.fallback && spa.hasFrame,
874 'a page that renders every key CARRYING CONTENT is left alone, though it '
875 + 'reported nothing for the empty ones',
876 JSON.stringify({ reason: spa.reason, keys: spa.keys }));
877 // And that page brought its own policy, which must be left alone as well: a
878 // second injected policy is not additive, it is the intersection, and it would
879 // silently break a page that asked for something the default forbids.
880 await resetProbes();
881 const ownSelf = await selfReport();
882 check(!!ownSelf && ownSelf.policies.some((x) => norm(x) === norm(OWN_POLICY)),
883 'a page that brought its own policy keeps it',
884 JSON.stringify(ownSelf && ownSelf.policies));
885 // AND IS GIVEN OURS AS WELL. Two policies on one document are enforced
886 // conjunctively -- a resource must satisfy both -- so ours can only ever
887 // tighten what the page brought and can never widen it. Skipping injection for
888 // a page that carries a policy of its own would hand the decision to the page,
889 // which is written by the model this is defending against: it would only have
890 // to declare a permissive policy of its own to be spared the real one.
891 check(!!ownSelf && ownSelf.policies.some((x) => norm(x) === norm(POLICY)),
892 'and is given ours as well, because two policies restrict and never widen',
893 JSON.stringify(ownSelf && ownSelf.policies));
894
895 // A page that navigates itself away has broken the channel: a reply must be
896 // posted to `'*'`, because an opaque origin cannot be named, so whatever is in
897 // the frame now would receive the next one.
898 await showDiamond(D.renav.name);
899 await p.waitForTimeout(settle);
900 const before = await face();
901 check(!before.fallback, 'the page that will navigate is showing first',
902 JSON.stringify({ reason: before.reason }));
903 await p.evaluate(() => {
904 const fr = document.querySelector('.crystal-frame');
905 if (fr && fr.contentWindow) fr.contentWindow.postMessage({ __nav: 1 }, '*');
906 });
907 await p.waitForTimeout(2500);
908 const nav = await face();
909 check(nav.fallback && nav.reason === 'partial',
910 'a page that navigates itself somewhere else loses the channel and falls back',
911 JSON.stringify({ fallback: nav.fallback, reason: nav.reason }));
912
913 // ══ 8. Nothing is dropped ═══════════════════════════════════
914 await showDiamond(D.extra.name);
915 await p.waitForTimeout(settle);
916 const ext = await face();
917 check(ext.fallback, 'the Diamond with unknown keys is showing the built-in view',
918 'the fixture needs the fallback up to read it');
919 check(/PROVENANCE-KEEPSAKE/.test(ext.text),
920 'and an unknown top-level key is rendered there rather than skipped',
921 ext.text.slice(0, 160));
922
923 const wantOther = await T('crystal.other_fields');
924 const wantTitle = await T('crystal.field_title');
925 const wantSave = await T('common.save');
926 const wantJson = await T('crystal.edit_json');
927 const wantBad = await T('crystal.json_invalid');
928
929 /// Click a control in the crystal panel by the words on it.
930 const clickByText = async (words, what) => {
931 const found = await p.evaluate((arg) => {
932 const root = document.getElementById('crystal-view')
933 || document.getElementById('crystal-body');
934 if (!root) return { ok: false, saw: 'no crystal panel' };
935 const flat = (x) => String(x || '').replace(/\s+/g, ' ').trim().toLowerCase();
936 const btns = [...root.querySelectorAll('button, [role="button"], a')];
937 const hit = btns.find((b) => flat(b.textContent).indexOf(flat(arg.words)) >= 0);
938 if (!hit) return { ok: false, saw: btns.map((b) => flat(b.textContent)).join(' | ') };
939 hit.click();
940 return { ok: true, saw: '' };
941 }, { words });
942 check(found.ok, 'the crystal offers ' + what, found.ok ? '' : 'controls on screen: ' + found.saw);
943 await p.waitForTimeout(500);
944 return found.ok;
945 };
946
947 const wasStored = await stored(ids.extra);
948 if (await clickByText('✎', 'the ✎ editor')) {
949 const form = await p.evaluate(() => {
950 const f2 = document.querySelector('.crystal-form');
951 const extra = document.querySelector('.crystal-form-extra');
952 const flat = (x) => String(x || '').replace(/\s+/g, ' ').trim();
953 return {
954 hasForm: !!f2,
955 hasExtra: !!extra,
956 formTxt: f2 ? flat(f2.innerText || f2.textContent) : '',
957 extraTxt: extra ? flat(extra.innerText || extra.textContent) : '',
958 };
959 });
960 check(form.hasForm, 'the ✎ editor is a form built from the core keys',
961 form.hasForm ? '' : 'no .crystal-form');
962 check(form.hasExtra, 'with a place for the fields it does not know',
963 form.hasExtra ? '' : 'no .crystal-form-extra');
964 check(/PROVENANCE-KEEPSAKE/.test(form.extraTxt),
965 'and the unknown key is shown there, so nothing vanishes silently',
966 form.extraTxt.slice(0, 160));
967 check(norm(form.formTxt).includes(norm(wantOther)),
968 'under a heading that says what those fields are',
969 JSON.stringify(form.formTxt.slice(0, 120)));
970
971 // A real edit, so the save is known to have written something. Saving an
972 // untouched form and finding the file unchanged proves nothing at all: a
973 // save that does nothing passes it.
974 const typed = await p.evaluate((arg) => {
975 const flat = (x) => String(x || '').replace(/\s+/g, ' ').trim().toLowerCase();
976 const rows = [...document.querySelectorAll('.crystal-form-row')];
977 const row = rows.find((r) => {
978 const lab = r.querySelector('label');
979 return flat(lab ? lab.textContent : '').indexOf(flat(arg.title)) === 0;
980 });
981 const box = row ? row.querySelector('input, textarea') : null;
982 if (!box) return { ok: false, saw: rows.map((r) => flat(r.textContent).slice(0, 30)).join(' | ') };
983 box.value = 'Extra keys, retitled';
984 box.dispatchEvent(new Event('input', { bubbles: true }));
985 box.dispatchEvent(new Event('change', { bubbles: true }));
986 return { ok: true, saw: '' };
987 }, { title: wantTitle });
988 check(typed.ok, 'the form has a field for the title, named as such',
989 typed.ok ? '' : 'rows on screen: ' + typed.saw);
990
991 await clickByText(wantSave, 'a way to save the form');
992 await p.waitForTimeout(1200);
993 const now = await stored(ids.extra);
994 const kept = await p.evaluate((arg) => {
995 const a = DaimondCrystal.parse(arg.was), b = DaimondCrystal.parse(arg.now);
996 if (!a.ok || !b.ok) return { ok: false, why: 'one side did not parse' };
997 return {
998 wrote: b.data.title === 'Extra keys, retitled',
999 same: JSON.stringify(a.data.provenance) === JSON.stringify(b.data.provenance),
1000 got: JSON.stringify(b.data.provenance),
1001 had: JSON.stringify(a.data.provenance),
1002 title: String(b.data.title),
1003 summary: String(b.data.summary || ''),
1004 };
1005 }, { was: wasStored, now });
1006 check(kept.wrote, 'saving the form writes the change that was typed',
1007 'title on disk: ' + JSON.stringify(kept.title));
1008 check(kept.same, 'AND the unknown key comes back exactly as it went in',
1009 'was ' + kept.had + ', now ' + kept.got);
1010 check(/Holds a key nothing/.test(kept.summary || ''),
1011 'with the core keys it did not touch left alone', JSON.stringify(kept.summary));
1012 }
1013
1014 // ══ 9. The editor refuses unparseable JSON ══════════════════
1015 const beforeJson = await stored(ids.extra);
1016 if (await clickByText('✎', 'the ✎ editor a second time')) {
1017 if (await clickByText(wantJson, 'raw JSON behind a second click')) {
1018 const put = await p.evaluate(() => {
1019 const ta = document.querySelector('.crystal-json');
1020 if (!ta) return false;
1021 ta.value = '{ "title": "half a document"';
1022 ta.dispatchEvent(new Event('input', { bubbles: true }));
1023 ta.dispatchEvent(new Event('change', { bubbles: true }));
1024 return true;
1025 });
1026 check(put, 'the raw JSON is in a .crystal-json box', put ? '' : 'no .crystal-json');
1027 await clickByText(wantSave, 'a way to save the raw JSON');
1028 await p.waitForTimeout(900);
1029 const said = await p.evaluate(() => ({
1030 body: (document.body.innerText || ''),
1031 dlg: (document.querySelector('.dlg .dlg-card') || {}).innerText || '',
1032 }));
1033 check(norm(said.body + ' ' + said.dlg).includes(norm(wantBad)),
1034 'saving unparseable JSON says so',
1035 JSON.stringify(String(said.dlg || said.body).slice(-160)));
1036 const afterJson = await stored(ids.extra);
1037 check(afterJson === beforeJson,
1038 'and nothing is saved, which is the difference between markdown and data',
1039 String(afterJson).slice(0, 120));
1040 await answerDialog(true, 1200);
1041 }
1042 }
1043
1044 // ══ 10. A page shorter than the panel still fills the panel ═══════
1045 //
1046 // notes4.txt: "When I changed the background of a crystal, it did not take
1047 // up the whole useable area of the panel." `PAGE_SOLID_BG` is one short
1048 // line on a solid colour it chose for itself -- so its OWN measured height
1049 // is a fraction of the panel's, and what is asked is whether the REST of
1050 // the panel picks up that colour or shows the app's ordinary chrome
1051 // beneath it.
1052 //
1053 // Measured, not eyeballed, and measured so the check DISCRIMINATES: a probe
1054 // that only asked "is the frame present" or "is SOME colour showing" would
1055 // read the same whether the frame fills the panel or stops a hundred pixels
1056 // in — coverage, the frame's height as a fraction of the panel's, is the
1057 // number that tells the two apart. Run against the code as it shipped
1058 // before this fix (`.crystal-frame` pinned to `height: <content px>`) this
1059 // reads about 12%; the fix (`height: 100%` floored by a `min-height` the
1060 // content sets) reads at or near 100%.
1061 await showDiamond(D.bgshort.name);
1062 await p.waitForTimeout(900);
1063 const cover = await p.evaluate(() => {
1064 const body = document.getElementById('crystal-body');
1065 const frame = document.querySelector('.crystal-frame');
1066 const b = body ? body.getBoundingClientRect() : null;
1067 const f = frame ? frame.getBoundingClientRect() : null;
1068 return {
1069 bodyH: b ? b.height : 0,
1070 frameH: f ? f.height : 0,
1071 // The colour a page too short to reach the bottom of `crystal-body`
1072 // would leave exposed there, sampled where the frame USED to stop.
1073 // `getComputedStyle` on an element outside the sandboxed frame --
1074 // this reads the app's own chrome, never the page's.
1075 belowColor: (b && f) ? getComputedStyle(body).backgroundColor
1076 : null,
1077 };
1078 });
1079 const coverage = cover.bodyH ? (cover.frameH / cover.bodyH) : 0;
1080 // A LITTLE under 100%, not exactly: a scrollbar or a sub-pixel layout
1081 // rounding is not the property under test. 85% is well clear of both —
1082 // and worlds away from the ~12% the code shipped with before this fix.
1083 check(coverage > 0.85,
1084 'a page far shorter than the panel still fills nearly all of it',
1085 `frame ${cover.frameH.toFixed(0)}px of ${cover.bodyH.toFixed(0)}px available `
1086 + `(${(coverage * 100).toFixed(0)}%)`);
1087
1088 // A page taller than the panel must still scroll rather than being
1089 // squashed down to the panel's own height -- the other half of the same
1090 // fix, on the same page format so the only variable is content length.
1091 await showDiamond(D.bgtall.name);
1092 await p.waitForTimeout(900);
1093 const tall = await p.evaluate(() => {
1094 const body = document.getElementById('crystal-body');
1095 const frame = document.querySelector('.crystal-frame');
1096 return {
1097 bodyClientH: body ? body.clientHeight : 0,
1098 bodyScrollH: body ? body.scrollHeight : 0,
1099 frameH: frame ? frame.getBoundingClientRect().height : 0,
1100 };
1101 });
1102 check(tall.frameH > tall.bodyClientH * 1.5,
1103 'a page much TALLER than the panel is not squashed down to fit it',
1104 `frame ${tall.frameH.toFixed(0)}px, panel ${tall.bodyClientH.toFixed(0)}px`);
1105 check(tall.bodyScrollH > tall.bodyClientH,
1106 'and the panel scrolls to reach the rest of it',
1107 `scrollHeight ${tall.bodyScrollH.toFixed(0)}px, clientHeight ${tall.bodyClientH.toFixed(0)}px`);
1108
1109 // A resource the browser could not load is the dev stack, not the page: no
1110 // gateway runs here, so its probes answer 401 or 502 and neither is a throw.
1111 // The refused beacon and the spent blob URL are this file's own doing, and the
1112 // frame's own console never reaches us, which is the point of §5.
1113 //
1114 // `PAGE_BROKEN`'s parse error is NOT noise in the ordinary sense -- it is the
1115 // subject of check 6. A syntax error is the whole of what a page that never
1116 // answers looks like, and this file writes one deliberately to produce it. It
1117 // surfaces here because Playwright reports a frame's `pageerror` against the
1118 // page that holds the frame, so an error raised inside the opaque origin is
1119 // indistinguishable at this level from one the app raised. Matched on the
1120 // fixture's own text rather than on "SyntaxError", so a real syntax error
1121 // anywhere else -- in `crystal.js`, in the shipped page, in a page a model
1122 // wrote -- still fails this check, which is the only reason it exists.
1123 const noise = s.errs.filter((e) =>
1124 !/favicon|ERR_ABORTED|net::ERR|Failed to load resource|i18n: no string|Content Security Policy|Refused to connect/i.test(e)
1125 && !/Unexpected identifier 'is'|Unexpected token '\)'/.test(e));
1126 check(noise.length === 0, 'the app threw nothing along the way', noise.slice(0, 3).join(' | '));
1127
1128} catch (e) {
1129 check(false, 'the run finished', String(e && e.message || e));
1130} finally {
1131 await s.close();
1132}
1133
1134console.log(bad === 0 ? '\nall checks passed' : `\n${bad} check(s) FAILED`);
1135process.exit(bad === 0 ? 0 : 1);