Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_template.mjs

15.0 KiB, 1 run

created by r2519314175:731, 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_template.mjs — a Diamond saved as a template, and one opened.
2//
3// ── WHY ──────────────────────────────────────────────────────────────────────
4//
5// `DaimondApp.export_template` and `import_template` landed with no caller: the
6// engine could take a Diamond's SHAPE and open one, and nothing in the app
7// reached either. A module with no production caller is not done, and this tree
8// has shipped that failure before.
9//
10// ── WHAT IT ASSERTS, AND WHY THE CONSENT CHECK IS THE ONE THAT MATTERS ───────
11//
12// A Diamond carrying a crystal page is carrying a PROGRAM somebody else wrote,
13// and `js/share.js`'s standing rule is that data travels freely and code travels
14// only by consent. `import_template` writes UNCONDITIONALLY — there is no
15// `withCode` on that door and no half-landing behind it — so the question has to
16// be asked before the call, in the app's own chrome, and DECLINING MUST WRITE
17// NOTHING. A naive button skips exactly that step and every other check here
18// would still be green.
19//
20// The other property with teeth is that opening one **mints a new Diamond**. The
21// id inside a template says where it was MADE, so a door that took it literally
22// would destroy the Log Life of the person most likely to open a Log Life
23// template. That is why `import_diamond` and `import_template` are two doors.
24//
25// node dev/verify_template.mjs
26// node dev/verify_template.mjs --break noconsent # the page is written unasked
27// node dev/verify_template.mjs --break blindcode # nothing counts as code
28//
29// A `--break` run EXPECTS to fail: exit 0 when something reddened, 1 when
30// nothing did, because a break that changes nothing is itself a failing run.
31//
32// Needs dev/serve.mjs (:8777). No model is consulted, so no mock and no gateway.
33
34import fs from 'node:fs';
35import path from 'node:path';
36import { fileURLToPath } from 'node:url';
37import { open, shot, errors } from './harness.mjs';
38
39const HERE = path.dirname(fileURLToPath(import.meta.url));
40const WWW = path.join(HERE, '..', 'www');
41
42const ok = [], bad = [];
43const check = (name, pass, detail) => {
44 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
45 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
46};
47const sleep = ms => new Promise(r => setTimeout(r, ms));
48
49// ── The breaks ───────────────────────────────────────────────────────────────
50//
51// `noconsent` is the naive button: the page is written without the question,
52// which is the whole failure this file exists to catch. `blindcode` leaves the
53// question in and blinds the judgement behind it, which is the subtler half —
54// an ask that never fires because nothing is ever called code is an ask that is
55// not there.
56const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })();
57const BREAKS = {
58 noconsent: [{
59 file: 'js/share.js',
60 find: " if (desc.code.length) {\n var yes = await askAboutTemplate(desc);",
61 with: " if (false) { /* --break noconsent */\n var yes = await askAboutTemplate(desc);",
62 }],
63 blindcode: [{
64 file: 'js/share.js',
65 find: " function isCodePath(path) {\n var lower = String(path || '').toLowerCase();",
66 with: " function isCodePath(path) {\n return false; /* --break blindcode */\n var lower = String(path || '').toLowerCase();",
67 }],
68};
69
70function damagedFiles() {
71 const byFile = new Map();
72 for (const spec of (BREAKS[BREAK] || [])) {
73 const src = byFile.get(spec.file) || fs.readFileSync(path.join(WWW, spec.file), 'utf8');
74 if (!src.includes(spec.find)) {
75 console.error(`--break ${BREAK}: anchor not found in ${spec.file}. The break is stale.`);
76 process.exit(1);
77 }
78 byFile.set(spec.file, src.replace(spec.find, spec.with));
79 }
80 return byFile;
81}
82
83async function serveBreaks(page) {
84 if (!BREAK) return;
85 for (const [file, body] of damagedFiles()) {
86 await page.route('**/' + file, r => r.fulfill({
87 status: 200, contentType: 'application/javascript', body,
88 }));
89 }
90}
91
92// `connect: false` — nothing here talks to a model, and it drops the requirement
93// for a mock this run owns.
94const s = await open({ name: 'template', route: serveBreaks, connect: false });
95const p = s.page;
96
97/// Every Diamond the STORE holds, asked of the engine rather than of the rail: a
98/// rail is a drawing and the question here is what was written.
99const stored = () => p.evaluate(async () => {
100 const m = await import('/pkg/oxedyne_daimond.js');
101 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
102 try { return JSON.parse(await app.list_diamonds()).map(d => ({ id: d.id, name: d.name })); }
103 catch (e) { return []; }
104});
105
106// ── 0. A Diamond worth taking the shape of ──────────────────────────────────
107//
108// A page (which is CODE), a capp's own folder beside it (which is shape and must
109// travel), a trigger, an entry and a memory (none of which may). Written through
110// the engine's own doors, which is where the app writes them from.
111const src = await p.evaluate(async () => {
112 const id = document.querySelector('#diamond-list [data-id]').dataset.id;
113 const m = await import('/pkg/oxedyne_daimond.js');
114 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
115 await app.write_crystal_page(id, '<h1>Log Life</h1><script>window.LOGLIFE = 1;<\/script>');
116 await m.store_write('diamonds/' + id + '/recipes/seed.json', '{"kind":"log"}');
117 await m.store_write('diamonds/' + id + '/log/monday.md', 'what I did on Monday');
118 return { id, name: (document.querySelector('#diamond-list [data-id] .session-name') || {}).textContent || '' };
119});
120// The trigger through the app's own setter, so what is armed is what the product
121// arms — and it is the one thing a template must be proved to leave behind.
122await p.evaluate(async (id) => {
123 await window.DaimondCore.triggerSet(id, { id: 'ta1', kind: 'timer', every: 3600, say: 'go' });
124}, src.id);
125const before = await stored();
126check('0 the fixture Diamond is on the store, with a page in it',
127 !!src.id && before.some(d => d.id === src.id), src.id || '(none)');
128
129// ── 1. What a template carries, and what it deliberately does not ───────────
130const shape = await p.evaluate(async (id) => {
131 const pack = await window.DaimondDiamond.template(id, false);
132 const full = await window.DaimondDiamond.template(id, true);
133 return {
134 pack: window.DaimondShare.readTemplate(pack),
135 full: window.DaimondShare.readTemplate(full),
136 bytes: pack,
137 };
138}, src.id);
139check('1a a template carries the page it draws through and a capp’s own folder',
140 shape.pack.files.includes('crystal.html') && shape.pack.files.includes('recipes/seed.json'),
141 JSON.stringify(shape.pack.files));
142check('1b AND NOT THE TRIGGER, the memory, the entries, the log or the history',
143 !shape.pack.files.some(f => f === 'triggers.json' || f === 'crystal.json'
144 || f.startsWith('log/') || f.startsWith('.daimond/') || f.startsWith('versions/')),
145 JSON.stringify(shape.pack.files));
146check('1c the page is NAMED as code, so the question can say which file',
147 shape.pack.code.length === 1 && shape.pack.code[0] === 'crystal.html',
148 JSON.stringify(shape.pack.code));
149check('1d it says it is a template, which is what keeps it off the overwrite door',
150 shape.pack.kind === 'template', shape.pack.kind);
151check('1e "include what it recorded" is the door back to a complete copy',
152 shape.full.files.includes('crystal.json') && shape.full.files.includes('log/monday.md')
153 && shape.full.kind === 'template',
154 JSON.stringify(shape.full.files));
155
156// ── 2. Where the control lives: behind the Diamond's cog ────────────────────
157//
158// Everything about ONE Diamond is behind its cog, which is where a person looks.
159await p.evaluate((id) => {
160 const tile = document.querySelector('#diamond-list [data-id="' + id + '"]');
161 // NOT page.click: force-clicking a tile is silently inert headless.
162 tile.querySelector('.tile-cog').click();
163}, src.id);
164await p.waitForSelector('.tile-dlg-card', { timeout: 10000 });
165const cog = await p.evaluate(() => {
166 const card = document.querySelector('.tile-dlg-card');
167 const btn = card.querySelector('.tile-dlg-tmpl-save');
168 return {
169 save: btn ? (btn.textContent || '') : '',
170 conv: !!card.querySelector('.tile-dlg-tmpl .tile-dlg-check'),
171 text: (card.textContent || ''),
172 };
173});
174check('2a the Diamond’s cog offers to save it as a template', !!cog.save, cog.save || '(no button)');
175check('2b with the door back to a complete copy beside it', cog.conv);
176const cogSays = /new Diamond/i.test(cog.text) && /trigger/i.test(cog.text);
177check('2c and it says the two things nobody can guess: a NEW Diamond, and no triggers',
178 cogSays, cogSays ? '' : 'one of the two sentences is missing from the dialog');
179await shot(s, 'template-1-cog');
180
181// ── 3. Pressing it hands over a file ────────────────────────────────────────
182//
183// The same handover every other file in Daimond takes: one Blob, one object URL,
184// a synthetic `<a download>`, the URL revoked straight after.
185const wait = p.waitForEvent('download', { timeout: 15000 }).catch(() => null);
186await p.evaluate(() => document.querySelector('.tile-dlg-tmpl-save').click());
187const dl = await wait;
188check('3 saving hands over a .dtemplate file, named after the Diamond',
189 !!dl && /\.dtemplate$/.test(dl.suggestedFilename()),
190 dl ? dl.suggestedFilename() : '(no download)');
191await p.evaluate(() => {
192 const x = document.querySelector('.tile-dlg-x');
193 if (x) x.click();
194});
195await sleep(300);
196
197// ── 4. THE CONSENT STEP ─────────────────────────────────────────────────────
198//
199// Asked BEFORE the call, because there is nothing after it to undo. The promise
200// is parked on the window and the dialog is answered the way a person answers
201// it, so what is driven is the app's own box rather than a stub.
202async function offer(answer) {
203 await p.evaluate((json) => {
204 window.__tmpl = { done: false, result: null, error: '' };
205 window.DaimondShare.takeTemplate(json).then(
206 r => { window.__tmpl.result = r; window.__tmpl.done = true; },
207 e => { window.__tmpl.error = (e && e.message) || String(e); window.__tmpl.done = true; });
208 }, shape.bytes);
209 // The dialog, if one is drawn at all. Its absence is the finding when it is.
210 const asked = await p.waitForSelector('.dlg-card .dlg-ok', { timeout: 6000 })
211 .then(() => true).catch(() => false);
212 if (asked) {
213 const words = await p.evaluate(() => (document.querySelector('.dlg-card') || {}).textContent || '');
214 await p.evaluate((yes) => {
215 const card = document.querySelector('.dlg-card');
216 (yes ? card.querySelector('.dlg-ok') : card.querySelector('.dlg-cancel')).click();
217 }, answer);
218 await p.waitForFunction(() => window.__tmpl.done, null, { timeout: 20000 }).catch(() => {});
219 return { asked: true, words, ...(await p.evaluate(() => window.__tmpl)) };
220 }
221 await p.waitForFunction(() => window.__tmpl.done, null, { timeout: 20000 }).catch(() => {});
222 return { asked: false, words: '', ...(await p.evaluate(() => window.__tmpl)) };
223}
224
225const declined = await offer(false);
226const afterNo = await stored();
227check('4a A TEMPLATE CARRYING A PAGE IS ASKED ABOUT BEFORE IT IS WRITTEN',
228 declined.asked, declined.asked ? '' : 'no question was drawn: it imported unasked');
229const named = declined.asked && /crystal\.html/.test(declined.words);
230check('4b and the question NAMES the file it would add', named,
231 named ? '' : (declined.words ? 'the question drew without naming the path' : 'no question drew'));
232const twoFacts = declined.asked && /new Diamond/i.test(declined.words);
233check('4c and it says the two facts: a NEW Diamond, and declining writes nothing',
234 twoFacts, twoFacts ? '' : (declined.asked ? 'the question drew without them' : 'no question drew'));
235check('4d DECLINING WRITES NOTHING AT ALL',
236 afterNo.length === before.length && !declined.result?.ok,
237 `${before.length} Diamond(s) before, ${afterNo.length} after`);
238await shot(s, 'template-2-declined');
239
240// ── 5. Accepting opens it as a NEW Diamond, and never over an existing one ──
241const taken = await offer(true);
242const afterYes = await stored();
243check('5a accepting opens it', !!(taken.result && taken.result.ok),
244 taken.error || JSON.stringify(taken.result));
245check('5b AS A NEW DIAMOND, not over the one it was made from',
246 afterYes.length === before.length + 1
247 && taken.result && taken.result.id && taken.result.id !== src.id,
248 `made from ${src.id}, opened as ${(taken.result || {}).id || '(none)'}; `
249 + `${before.length} → ${afterYes.length} Diamond(s)`);
250check('5c and the Diamond it was made from is untouched',
251 afterYes.some(d => d.id === src.id), src.id);
252
253const landed = await p.evaluate(async (id) => {
254 const files = (await window.DaimondDiamond.files(id)).map(f => f.path);
255 return files;
256}, (taken.result || {}).id || '');
257check('5d the page arrived', landed.includes('crystal.html'), JSON.stringify(landed));
258check('5e AND THE TRIGGER DID NOT: a trigger fires with nobody pressing anything',
259 !landed.includes('triggers.json'), JSON.stringify(landed));
260check('5f nor did what the Diamond had recorded',
261 !landed.includes('log/monday.md'), JSON.stringify(landed));
262await shot(s, 'template-3-opened');
263
264// ── 6. And a person can reach it ────────────────────────────────────────────
265//
266// In the Share view of the Social panel: the one place in Daimond where
267// something arrives AS A FILE and is asked about before it is written.
268await p.evaluate(() => {
269 window.DaimondPanels.show('social');
270 document.querySelector('.imp-chip[data-view="share"]').click();
271});
272await sleep(600);
273const panel = await p.evaluate(() => {
274 const host = document.getElementById('social-share-list');
275 const btn = host ? host.querySelector('.shr-tmpl') : null;
276 return { btn: btn ? (btn.textContent || '') : '', text: host ? (host.textContent || '') : '' };
277});
278check('6a the Share view offers to open a template', !!panel.btn, panel.btn || '(no control)');
279const panelSays = /new Diamond/i.test(panel.text) && /trigger/i.test(panel.text);
280check('6b and says the two facts before anything is pressed', panelSays,
281 panelSays ? '' : 'one of the two sentences is missing from the block');
282await shot(s, 'template-4-panel');
283
284const errs = errors(s).filter(e => !/502|Account service|429/.test(e));
285check('7 none of it raised anything in the console', errs.length === 0,
286 errs.slice(0, 2).join(' | '));
287
288await s.close();
289
290console.log(`\n${ok.length} passed, ${bad.length} failed`);
291if (BREAK) {
292 console.log(bad.length ? `--break ${BREAK}: reddened ${bad.length} check(s), as it must`
293 : `--break ${BREAK}: CHANGED NOTHING — the check it names is not testing what it says`);
294 process.exit(bad.length ? 0 : 1);
295}
296process.exit(bad.length ? 1 : 0);