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 | |
| 34 | import fs from 'node:fs'; |
| 35 | import path from 'node:path'; |
| 36 | import { fileURLToPath } from 'node:url'; |
| 37 | import { open, shot, errors } from './harness.mjs'; |
| 38 | |
| 39 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 40 | const WWW = path.join(HERE, '..', 'www'); |
| 41 | |
| 42 | const ok = [], bad = []; |
| 43 | const check = (name, pass, detail) => { |
| 44 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 45 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 46 | }; |
| 47 | const 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. |
| 56 | const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })(); |
| 57 | const 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 | |
| 70 | function 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 | |
| 83 | async 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. |
| 94 | const s = await open({ name: 'template', route: serveBreaks, connect: false }); |
| 95 | const 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. |
| 99 | const 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. |
| 111 | const 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. |
| 122 | await p.evaluate(async (id) => { |
| 123 | await window.DaimondCore.triggerSet(id, { id: 'ta1', kind: 'timer', every: 3600, say: 'go' }); |
| 124 | }, src.id); |
| 125 | const before = await stored(); |
| 126 | check('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 ─────────── |
| 130 | const 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); |
| 139 | check('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)); |
| 142 | check('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)); |
| 146 | check('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)); |
| 149 | check('1d it says it is a template, which is what keeps it off the overwrite door', |
| 150 | shape.pack.kind === 'template', shape.pack.kind); |
| 151 | check('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. |
| 159 | await 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); |
| 164 | await p.waitForSelector('.tile-dlg-card', { timeout: 10000 }); |
| 165 | const 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 | }); |
| 174 | check('2a the Diamond’s cog offers to save it as a template', !!cog.save, cog.save || '(no button)'); |
| 175 | check('2b with the door back to a complete copy beside it', cog.conv); |
| 176 | const cogSays = /new Diamond/i.test(cog.text) && /trigger/i.test(cog.text); |
| 177 | check('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'); |
| 179 | await 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. |
| 185 | const wait = p.waitForEvent('download', { timeout: 15000 }).catch(() => null); |
| 186 | await p.evaluate(() => document.querySelector('.tile-dlg-tmpl-save').click()); |
| 187 | const dl = await wait; |
| 188 | check('3 saving hands over a .dtemplate file, named after the Diamond', |
| 189 | !!dl && /\.dtemplate$/.test(dl.suggestedFilename()), |
| 190 | dl ? dl.suggestedFilename() : '(no download)'); |
| 191 | await p.evaluate(() => { |
| 192 | const x = document.querySelector('.tile-dlg-x'); |
| 193 | if (x) x.click(); |
| 194 | }); |
| 195 | await 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. |
| 202 | async 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 | |
| 225 | const declined = await offer(false); |
| 226 | const afterNo = await stored(); |
| 227 | check('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'); |
| 229 | const named = declined.asked && /crystal\.html/.test(declined.words); |
| 230 | check('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')); |
| 232 | const twoFacts = declined.asked && /new Diamond/i.test(declined.words); |
| 233 | check('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')); |
| 235 | check('4d DECLINING WRITES NOTHING AT ALL', |
| 236 | afterNo.length === before.length && !declined.result?.ok, |
| 237 | `${before.length} Diamond(s) before, ${afterNo.length} after`); |
| 238 | await shot(s, 'template-2-declined'); |
| 239 | |
| 240 | // ── 5. Accepting opens it as a NEW Diamond, and never over an existing one ── |
| 241 | const taken = await offer(true); |
| 242 | const afterYes = await stored(); |
| 243 | check('5a accepting opens it', !!(taken.result && taken.result.ok), |
| 244 | taken.error || JSON.stringify(taken.result)); |
| 245 | check('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)`); |
| 250 | check('5c and the Diamond it was made from is untouched', |
| 251 | afterYes.some(d => d.id === src.id), src.id); |
| 252 | |
| 253 | const 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 || ''); |
| 257 | check('5d the page arrived', landed.includes('crystal.html'), JSON.stringify(landed)); |
| 258 | check('5e AND THE TRIGGER DID NOT: a trigger fires with nobody pressing anything', |
| 259 | !landed.includes('triggers.json'), JSON.stringify(landed)); |
| 260 | check('5f nor did what the Diamond had recorded', |
| 261 | !landed.includes('log/monday.md'), JSON.stringify(landed)); |
| 262 | await 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. |
| 268 | await p.evaluate(() => { |
| 269 | window.DaimondPanels.show('social'); |
| 270 | document.querySelector('.imp-chip[data-view="share"]').click(); |
| 271 | }); |
| 272 | await sleep(600); |
| 273 | const 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 | }); |
| 278 | check('6a the Share view offers to open a template', !!panel.btn, panel.btn || '(no control)'); |
| 279 | const panelSays = /new Diamond/i.test(panel.text) && /trigger/i.test(panel.text); |
| 280 | check('6b and says the two facts before anything is pressed', panelSays, |
| 281 | panelSays ? '' : 'one of the two sentences is missing from the block'); |
| 282 | await shot(s, 'template-4-panel'); |
| 283 | |
| 284 | const errs = errors(s).filter(e => !/502|Account service|429/.test(e)); |
| 285 | check('7 none of it raised anything in the console', errs.length === 0, |
| 286 | errs.slice(0, 2).join(' | ')); |
| 287 | |
| 288 | await s.close(); |
| 289 | |
| 290 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 291 | if (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 | } |
| 296 | process.exit(bad.length ? 1 : 0); |