oxedyne/daimond/dev/verify_crystalmigrate.mjs
21.1 KiB, 1 run
created by r2519314175:331, 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_crystalmigrate.mjs — `crystal.md` becomes `crystal.json`, and NOTHING |
| 2 | // IS LOST DOING IT. |
| 3 | // |
| 4 | // A Diamond's crystal stops being markdown and becomes data. Every Diamond that |
| 5 | // exists today has the old shape, so the change reaches all of them at once, |
| 6 | // through one migration, on one boot — and the crystal is the only place a |
| 7 | // Diamond's reduced state lives. A migration that drops the last two sections of |
| 8 | // a file is not a bug the user reports; it is a bug they discover months later |
| 9 | // when they go looking for something that used to be there. |
| 10 | // |
| 11 | // So the property asserted here is NOT the steps of the conversion. Steps can be |
| 12 | // read off the code and re-asserted in a test that agrees with the code and with |
| 13 | // nothing else. The property is a ROUND TRIP: rendering the produced data back to |
| 14 | // markdown reproduces the original file byte for byte. That holds or it does not, |
| 15 | // whatever the conversion does in the middle, and it is the only statement that |
| 16 | // covers a shape nobody thought of. Where a shape cannot survive being reshaped, |
| 17 | // the contract's answer is to carry it verbatim in one section — which the round |
| 18 | // trip accepts and a step-by-step test would reject. |
| 19 | // |
| 20 | // The awkward inputs are chosen because each breaks a different plausible |
| 21 | // implementation: |
| 22 | // |
| 23 | // * a `##` inside a fenced code block breaks a splitter that scans for `^## `, |
| 24 | // and that is the whole of the naive implementation. A crystal that documents |
| 25 | // markdown, or holds a snippet of a config file, has one. |
| 26 | // * a `#` inside a fence BEFORE any real heading gives that splitter the wrong |
| 27 | // title, and the title is what the rail shows and what `name_from_crystal` |
| 28 | // reads back when a Diamond loses its metadata. |
| 29 | // * text before the first heading has nowhere to go in the schema, so it is the |
| 30 | // first thing a conversion silently drops. |
| 31 | // * an empty file, a whitespace-only file and a file with no headings at all |
| 32 | // are what a young Diamond actually holds — `create_diamond` writes an empty |
| 33 | // crystal, so EVERY Diamond starts as case 9. |
| 34 | // * CRLF, trailing spaces after a heading, and a missing final newline are the |
| 35 | // three ways a file that looks identical on screen is not identical in bytes. |
| 36 | // |
| 37 | // Then the two properties this migration inherits from the `brief.md` → `crystal.md` |
| 38 | // rename that came before it, because it runs in the same place on the same |
| 39 | // trigger: it is IDEMPOTENT, and IT NEVER CLOBBERS. A Diamond holding both files |
| 40 | // is left alone rather than merged; whichever way a merge went it would be |
| 41 | // guessing, and the losing side is somebody's work. |
| 42 | // |
| 43 | // And the redundancy path, which is the most destructive thing in this whole |
| 44 | // change to get wrong: a Diamond whose `crystal.json` is missing reads its newest |
| 45 | // `versions/NNNN.json`. A Diamond whose crystal is not found reads as an EMPTY |
| 46 | // one, and an agent handed an empty crystal will write a new one over work it |
| 47 | // never saw. |
| 48 | // |
| 49 | // How these go red (this lane could not run a browser, so the lead's batched pass |
| 50 | // is the first time they are exercised): |
| 51 | // |
| 52 | // * split on `^## ` without tracking fences → the four fence cases go red and |
| 53 | // the ordinary one stays green, which is the shape a fence-blind splitter has; |
| 54 | // * drop the text before the first heading → case 4 goes red; |
| 55 | // * `toMarkdown` always ending its output with a newline → cases 9 and 11 go red; |
| 56 | // * let the migration run over a Diamond that already has both files → the |
| 57 | // clobber check goes red; |
| 58 | // * revert `read_crystal_data` to a bare read → the two redundancy checks go red. |
| 59 | // |
| 60 | // node dev/verify_crystalmigrate.mjs |
| 61 | // |
| 62 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway, no mock LLM: nothing |
| 63 | // here runs a turn. |
| 64 | import fs from 'node:fs'; |
| 65 | import { open, scratch } from './harness.mjs'; |
| 66 | |
| 67 | const PROFILE = scratch('pw', 'crystalmigrate'); |
| 68 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 69 | |
| 70 | let bad = 0; |
| 71 | const check = (pass, name, detail) => { |
| 72 | if (!pass) bad++; |
| 73 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 74 | }; |
| 75 | |
| 76 | /// Every awkward shape a crystal can arrive in, and what makes each awkward. |
| 77 | /// |
| 78 | /// Written with explicit `\n` rather than as template literals: a fence inside a |
| 79 | /// template literal has to be escaped, and an escaped fence in a test about |
| 80 | /// fences is one transcription error away from testing nothing. |
| 81 | const ROUNDTRIP = [ |
| 82 | { name: 'an ordinary crystal', |
| 83 | md: '# Title\n\nWhat this Diamond is for.\n\n## One\n\nThe first thing.\n\n## Two\n\nThe second.\n' }, |
| 84 | { name: 'no headings at all', |
| 85 | md: 'Just a paragraph, and then another.\n\nNothing in it is a heading.\n' }, |
| 86 | { name: 'a heading with nothing under it', |
| 87 | md: '# Only a title\n' }, |
| 88 | { name: 'text before the first heading', |
| 89 | md: 'A line that arrived before anything named it.\n\n# Title\n\n## One\n\nBody.\n' }, |
| 90 | { name: 'a ## line inside a fenced code block', |
| 91 | md: '# Title\n\nSummary.\n\n## Real section\n\n```\n## not a heading\n```\n\nAfter the fence.\n' }, |
| 92 | { name: 'a ## line inside a ~~~ fence', |
| 93 | md: '# Title\n\n~~~\n## not a heading either\n~~~\n' }, |
| 94 | { name: 'a # line inside a fence, before any real heading', |
| 95 | md: '```\n# not the title\n```\n\n# The actual title\n\nBody.\n' }, |
| 96 | { name: 'a ## line indented into a code block', |
| 97 | md: '# Title\n\n ## four spaces in, so it is code\n\nAfter it.\n' }, |
| 98 | { name: 'an empty file', |
| 99 | md: '' }, |
| 100 | { name: 'a file that is only whitespace', |
| 101 | md: '\n \n\t\n\n' }, |
| 102 | { name: 'a file that does not end in a newline', |
| 103 | md: '# Title\n\n## One\n\nIt stops here.' }, |
| 104 | { name: 'a ### under a ##, which is body and not a section of its own', |
| 105 | md: '# Title\n\n## One\n\n### Deeper\n\nUnder the deeper one.\n' }, |
| 106 | { name: '#Nospace, which is not a heading at all', |
| 107 | md: '#Nospace\n\n##Nor is this one.\n' }, |
| 108 | { name: 'a second # heading later in the file', |
| 109 | md: '# First\n\nA.\n\n# Second\n\nB.\n' }, |
| 110 | { name: 'a heading with trailing spaces after it', |
| 111 | md: '# Title \n\n## One \n\nBody.\n' }, |
| 112 | { name: 'CRLF line endings', |
| 113 | md: '# Title\r\n\r\nSummary.\r\n\r\n## One\r\n\r\nBody.\r\n' }, |
| 114 | { name: 'blank lines before anything else', |
| 115 | md: '\n\n# Title\n\nBody.\n' }, |
| 116 | { name: 'a fence nobody closed', |
| 117 | md: '# Title\n\n```\n## inside a fence with no end\n' }, |
| 118 | ]; |
| 119 | |
| 120 | const s = await open({ name: 'crystalmigrate', profile: PROFILE, connect: false }); |
| 121 | const { page } = s; |
| 122 | |
| 123 | try { |
| 124 | await page.waitForTimeout(1500); |
| 125 | |
| 126 | // ── The instrument, installed once ─────────────────────────── |
| 127 | await page.evaluate(async () => { |
| 128 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 129 | window.__d = { |
| 130 | mod, |
| 131 | app: new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true), |
| 132 | root: await navigator.storage.getDirectory(), |
| 133 | }; |
| 134 | // Seeded through the directory handle, not through the store: the state |
| 135 | // under test is a PRE-MIGRATION Diamond, which the store's own writers can |
| 136 | // no longer produce. |
| 137 | window.__put = async (path, body) => { |
| 138 | let cur = __d.root; |
| 139 | const parts = path.split('/'); |
| 140 | for (let i = 0; i < parts.length - 1; i++) { |
| 141 | cur = await cur.getDirectoryHandle(parts[i], { create: true }); |
| 142 | } |
| 143 | const fh = await cur.getFileHandle(parts[parts.length - 1], { create: true }); |
| 144 | const w = await fh.createWritable(); |
| 145 | await w.write(body); |
| 146 | await w.close(); |
| 147 | }; |
| 148 | window.__meta = (name) => JSON.stringify( |
| 149 | { name: name, crystal_version: 0, updated: 1, touched: 1 }); |
| 150 | // The bytes on disk, or null. `store_read` pins the OPFS root, where a |
| 151 | // Diamond lives whatever folder the user has open; `file_read` is a |
| 152 | // model-facing rendering and would number every line. |
| 153 | window.__bytes = async (path) => { |
| 154 | try { return await __d.mod.store_read(path); } catch (e) { return null; } |
| 155 | }; |
| 156 | // Key order is not meaning. Two migrations that agree on the data can |
| 157 | // disagree on the order they emit it in, and a raw string comparison would |
| 158 | // call that a difference. |
| 159 | window.__canon = (v) => { |
| 160 | if (Array.isArray(v)) return '[' + v.map(__canon).join(',') + ']'; |
| 161 | if (v && typeof v === 'object') { |
| 162 | return '{' + Object.keys(v).sort().map((k) => |
| 163 | JSON.stringify(k) + ':' + __canon(v[k])).join(',') + '}'; |
| 164 | } |
| 165 | return JSON.stringify(v === undefined ? null : v); |
| 166 | }; |
| 167 | // Where two strings first part company, in a form that survives the console. |
| 168 | window.__where = (a, b) => { |
| 169 | if (a === b) return ''; |
| 170 | const n = Math.min(a.length, b.length); |
| 171 | let i = 0; |
| 172 | while (i < n && a[i] === b[i]) i++; |
| 173 | const win = (x) => JSON.stringify(x.slice(Math.max(0, i - 12), i + 24)); |
| 174 | return 'at ' + i + ' of ' + a.length + '/' + b.length |
| 175 | + ' want ' + win(a) + ' got ' + win(b); |
| 176 | }; |
| 177 | }); |
| 178 | |
| 179 | const hasModule = await page.evaluate(() => !!(window.DaimondCrystal |
| 180 | && typeof DaimondCrystal.fromMarkdown === 'function' |
| 181 | && typeof DaimondCrystal.toMarkdown === 'function' |
| 182 | && typeof DaimondCrystal.parse === 'function')); |
| 183 | check(hasModule, 'the crystal module is loaded, with the migration and its inverse', |
| 184 | hasModule ? '' : 'window.DaimondCrystal is not there, or is missing a half'); |
| 185 | |
| 186 | // ── The round trip, over shapes that break different splitters ── |
| 187 | const trips = await page.evaluate((cases) => cases.map((c) => { |
| 188 | let data, out, err = ''; |
| 189 | try { data = DaimondCrystal.fromMarkdown(c.md); } |
| 190 | catch (e) { err = 'fromMarkdown threw: ' + (e && e.message || e); } |
| 191 | if (!err) { |
| 192 | try { out = DaimondCrystal.toMarkdown(data); } |
| 193 | catch (e) { err = 'toMarkdown threw: ' + (e && e.message || e); } |
| 194 | } |
| 195 | return { |
| 196 | name: c.name, |
| 197 | ok: !err && out === c.md, |
| 198 | why: err || __where(c.md, String(out)), |
| 199 | }; |
| 200 | }), ROUNDTRIP); |
| 201 | |
| 202 | for (const t of trips) { |
| 203 | check(t.ok, 'it round-trips ' + t.name, t.why); |
| 204 | } |
| 205 | |
| 206 | // ── ...and the conversion is a conversion ──────────────────── |
| 207 | // |
| 208 | // The round trip alone is satisfied by a migration that does nothing: put the |
| 209 | // whole file in one nameless section and hand it back, and all eighteen cases |
| 210 | // above pass while no Diamond has ever gained a title, a summary or a section. |
| 211 | // That is the vacuous pass this file would otherwise ship with, so the ordinary |
| 212 | // case is also asked what it FOUND. Read by meaning rather than by position: |
| 213 | // the set of headings, not `sections[1].heading`. |
| 214 | const shape = await page.evaluate((md) => { |
| 215 | const d = DaimondCrystal.fromMarkdown(md) || {}; |
| 216 | return { |
| 217 | title: d.title || '', |
| 218 | summary: String(d.summary || ''), |
| 219 | headings: (d.sections || []).map((x) => String(x && x.heading || '')), |
| 220 | bodies: (d.sections || []).map((x) => String(x && x.body || '')), |
| 221 | }; |
| 222 | }, ROUNDTRIP[0].md); |
| 223 | check(shape.title === 'Title', 'the first # heading becomes the title', JSON.stringify(shape.title)); |
| 224 | check(/What this Diamond is for\./.test(shape.summary), |
| 225 | 'the text under it becomes the summary', JSON.stringify(shape.summary)); |
| 226 | check(shape.headings.includes('One') && shape.headings.includes('Two'), |
| 227 | 'and every ## becomes a section of its own', JSON.stringify(shape.headings)); |
| 228 | check(shape.bodies.some((b) => /The second\./.test(b)), |
| 229 | 'carrying the text that was under it', JSON.stringify(shape.bodies)); |
| 230 | |
| 231 | // A file with no headings becomes one section with an empty heading, which is |
| 232 | // what the contract says and what keeps `sections` the place everything ends up |
| 233 | // when nothing else fits. |
| 234 | const bare = await page.evaluate((md) => { |
| 235 | const d = DaimondCrystal.fromMarkdown(md) || {}; |
| 236 | return { |
| 237 | headings: (d.sections || []).map((x) => String(x && x.heading || '')), |
| 238 | kept: JSON.stringify(d).indexOf('Nothing in it is a heading') >= 0, |
| 239 | }; |
| 240 | }, ROUNDTRIP[1].md); |
| 241 | check(bare.kept, 'a file with no headings keeps its text somewhere', JSON.stringify(bare)); |
| 242 | check(bare.headings.length > 0 && bare.headings.every((h) => h === ''), |
| 243 | 'under a section with no heading, rather than under an invented one', |
| 244 | JSON.stringify(bare.headings)); |
| 245 | |
| 246 | // `parse` is the door every one of these arrives through, and it must never |
| 247 | // throw: the ✎ editor and the fallback view both call it on text a person or a |
| 248 | // model just typed. |
| 249 | const parsed = await page.evaluate(() => { |
| 250 | const bad1 = DaimondCrystal.parse('{"title": "unterminated'); |
| 251 | const good = DaimondCrystal.parse('{"title":"fine"}'); |
| 252 | return { |
| 253 | badOk: !!(bad1 && bad1.ok === false && bad1.error), |
| 254 | goodOk: !!(good && good.ok === true && good.data && good.data.title === 'fine'), |
| 255 | }; |
| 256 | }); |
| 257 | check(parsed.badOk, 'unparseable text is refused by parse() with a reason, not by a throw'); |
| 258 | check(parsed.goodOk, 'and text that parses comes back as data', JSON.stringify(parsed)); |
| 259 | |
| 260 | // ── The real migration, through the store ──────────────────── |
| 261 | // |
| 262 | // Everything above is the JS half. This is the one that runs on every Diamond |
| 263 | // the user owns, in Rust, on the trigger `migrate_crystal_file` already uses. |
| 264 | const LEG = 'a11decade001'; |
| 265 | const LEGMD = '# Kept from before\n\nWhy this Diamond exists.\n\n## Notes\n\n```\n## not a heading\n```\n\n## Next\n\nWhat is left to do.\n'; |
| 266 | const migrated = await page.evaluate(async (arg) => { |
| 267 | const dir = 'diamonds/' + arg.id; |
| 268 | await __put(dir + '/.daimond/meta.json', __meta('Kept from before')); |
| 269 | await __put(dir + '/crystal.md', arg.md); |
| 270 | // The trigger, exactly where it runs today. |
| 271 | await __d.app.list_diamonds(); |
| 272 | |
| 273 | const json = await __bytes(dir + '/crystal.json'); |
| 274 | const stale = await __bytes(dir + '/crystal.md'); |
| 275 | let back = null, viaApp = null, err = ''; |
| 276 | try { viaApp = await __d.app.read_crystal_data(arg.id); } |
| 277 | catch (e) { err = String(e && e.message || e); } |
| 278 | const p = DaimondCrystal.parse(json || viaApp || ''); |
| 279 | if (p && p.ok) { try { back = DaimondCrystal.toMarkdown(p.data); } catch (e) { err = String(e); } } |
| 280 | return { |
| 281 | wrote: json !== null, |
| 282 | stale: stale !== null, |
| 283 | viaApp: viaApp, |
| 284 | same: json !== null && viaApp !== null && json === viaApp, |
| 285 | back: back, |
| 286 | trip: back === arg.md, |
| 287 | why: err || __where(arg.md, String(back)), |
| 288 | agree: (p && p.ok) |
| 289 | ? __canon(p.data) === __canon(DaimondCrystal.fromMarkdown(arg.md)) |
| 290 | : false, |
| 291 | rust: (p && p.ok) ? __canon(p.data) : 'unparseable: ' + (p && p.error), |
| 292 | js: __canon(DaimondCrystal.fromMarkdown(arg.md)), |
| 293 | }; |
| 294 | }, { id: LEG, md: LEGMD }); |
| 295 | |
| 296 | check(migrated.wrote, 'a legacy Diamond gains a crystal.json where it had a crystal.md'); |
| 297 | check(migrated.trip, 'and rendering it back reproduces the original file byte for byte', |
| 298 | migrated.why); |
| 299 | check(migrated.same, 'the app reads back the same bytes that are on disk, so the ' |
| 300 | + 'migration WROTE rather than converting afresh on every read'); |
| 301 | // Two migrations, one in Rust and one in JS, and the JS one is what every test |
| 302 | // above measures. If they disagree, those eighteen cases prove nothing about |
| 303 | // the code that will actually touch the user's Diamonds. |
| 304 | check(migrated.agree, 'the migration in Rust and the migration in JS produce the same data', |
| 305 | 'rust ' + String(migrated.rust).slice(0, 120) + ' | js ' + String(migrated.js).slice(0, 120)); |
| 306 | // NOT a rename, unlike the brief.md migration, and the reason is worth keeping. |
| 307 | // The self-check proves the BYTES round-trip and structurally cannot prove the |
| 308 | // STRUCTURE is right: a `##` inside a fence rejoins to identical bytes whether or |
| 309 | // not the fence was honoured. So the one failure the check cannot see is exactly |
| 310 | // the one that would justify still having the markdown. And `import_diamond` |
| 311 | // deletes a Diamond's directory before rewriting it, so a bad conversion |
| 312 | // propagates back over a good copy on the next sync -- the shape this project |
| 313 | // has already lost data to once. |
| 314 | // |
| 315 | // The cost is a few kilobytes riding in the parcel. The user's real backup was |
| 316 | // thirteen Diamonds and 15,786 bytes of workspace in total, so it is a few |
| 317 | // kilobytes of a very small number, and a later release can drop the file once |
| 318 | // the conversion has run against real workspaces without complaint. |
| 319 | check(migrated.stale, 'the legacy markdown is KEPT, because a lossless conversion ' |
| 320 | + 'is not the same as a provably correct one'); |
| 321 | |
| 322 | // ── Idempotent ─────────────────────────────────────────────── |
| 323 | const twice = await page.evaluate(async (id) => { |
| 324 | const path = 'diamonds/' + id + '/crystal.json'; |
| 325 | const before = await __bytes(path); |
| 326 | await __d.app.list_diamonds(); |
| 327 | await __d.app.list_diamonds(); |
| 328 | const after = await __bytes(path); |
| 329 | return { ok: before !== null && before === after, why: __where(String(before), String(after)) }; |
| 330 | }, LEG); |
| 331 | check(twice.ok, 'running the migration again changes nothing', twice.why); |
| 332 | |
| 333 | // ── It never clobbers ──────────────────────────────────────── |
| 334 | // |
| 335 | // A Diamond holding both files has already been migrated on another device and |
| 336 | // synced back, or was migrated here and then had a legacy file restored from a |
| 337 | // backup. Either way a merge would be guessing, and the losing side is work. |
| 338 | const BOTH = 'b0thf11e5001'; |
| 339 | const KEPT = '{"title":"The data that was already here","summary":"Written after the migration."}'; |
| 340 | const both = await page.evaluate(async (arg) => { |
| 341 | const dir = 'diamonds/' + arg.id; |
| 342 | await __put(dir + '/.daimond/meta.json', __meta('Holds both')); |
| 343 | await __put(dir + '/crystal.md', '# An older markdown crystal\n\nWhich must not win.\n'); |
| 344 | await __put(dir + '/crystal.json', arg.kept); |
| 345 | await __d.app.list_diamonds(); |
| 346 | const json = await __bytes(dir + '/crystal.json'); |
| 347 | let viaApp = null; |
| 348 | try { viaApp = await __d.app.read_crystal_data(arg.id); } catch (e) { viaApp = 'ERR ' + e; } |
| 349 | return { json, viaApp }; |
| 350 | }, { id: BOTH, kept: KEPT }); |
| 351 | |
| 352 | check(both.json === KEPT, 'a Diamond holding both files keeps the data it already had', |
| 353 | String(both.json).slice(0, 120)); |
| 354 | check(!/older markdown crystal/.test(String(both.json) + String(both.viaApp)), |
| 355 | 'and nothing from the markdown is merged into it, whichever way a merge would have gone'); |
| 356 | |
| 357 | // ── The redundancy path ────────────────────────────────────── |
| 358 | // |
| 359 | // The most destructive failure in this change: a Diamond whose crystal is not |
| 360 | // found reads as an empty one, and an agent handed an empty crystal will write |
| 361 | // a new one over work it never saw. The versions are the store's own backup and |
| 362 | // nothing but this ever reads them. |
| 363 | const VER = 'c0deca11ab1e'; |
| 364 | const ver = await page.evaluate(async (id) => { |
| 365 | const dir = 'diamonds/' + id; |
| 366 | await __put(dir + '/.daimond/meta.json', __meta('Crystal lost, versions kept')); |
| 367 | await __put(dir + '/versions/0001.json', '{"title":"the first crystal"}'); |
| 368 | await __put(dir + '/versions/0009.json', '{"title":"as it was last left"}'); |
| 369 | await __put(dir + '/versions/0002.json', '{"title":"a middle one"}'); |
| 370 | let text = null, err = ''; |
| 371 | try { text = await __d.app.read_crystal_data(id); } |
| 372 | catch (e) { err = String(e && e.message || e); } |
| 373 | const p = DaimondCrystal.parse(text || ''); |
| 374 | return { err, title: (p && p.ok && p.data) ? p.data.title : null, text }; |
| 375 | }, VER); |
| 376 | check(!ver.err, 'a Diamond whose crystal.json is gone still opens', ver.err); |
| 377 | check(ver.title === 'as it was last left', |
| 378 | 'reading its NEWEST version snapshot rather than opening empty', |
| 379 | JSON.stringify(ver.title) + ' from ' + JSON.stringify(String(ver.text).slice(0, 80))); |
| 380 | |
| 381 | // A Diamond that holds only the legacy markdown must read as its own content |
| 382 | // too, whether it gets there by having been migrated or by a migrated read. |
| 383 | // The failure being guarded is the same one either way: opening empty. |
| 384 | const OLD = 'd0c0mdon1y001'; |
| 385 | const old = await page.evaluate(async (id) => { |
| 386 | const dir = 'diamonds/' + id; |
| 387 | await __put(dir + '/.daimond/meta.json', __meta('Only markdown')); |
| 388 | await __put(dir + '/crystal.md', '# Still here\n\nAnd it must not read as empty.\n'); |
| 389 | let text = null, err = ''; |
| 390 | try { text = await __d.app.read_crystal_data(id); } |
| 391 | catch (e) { err = String(e && e.message || e); } |
| 392 | return { err, text }; |
| 393 | }, OLD); |
| 394 | check(!old.err, 'a Diamond holding only a legacy markdown crystal opens', old.err); |
| 395 | check(/Still here/.test(String(old.text)), |
| 396 | 'with what it says, rather than as an empty crystal an agent would write over', |
| 397 | JSON.stringify(String(old.text).slice(0, 120))); |
| 398 | |
| 399 | // And the control, without which "never opens empty" could be met by opening |
| 400 | // something. A Diamond that genuinely has nothing must still open, and must not |
| 401 | // acquire content from anywhere. |
| 402 | const NIL = 'e3117n0th1ng1'; |
| 403 | const nil = await page.evaluate(async (id) => { |
| 404 | await __put('diamonds/' + id + '/.daimond/meta.json', __meta('Nothing at all')); |
| 405 | try { return { text: await __d.app.read_crystal_data(id) }; } |
| 406 | catch (e) { return { err: String(e && e.message || e) }; } |
| 407 | }, NIL); |
| 408 | check(nil.err === undefined, 'a Diamond that truly holds nothing opens rather than throwing', |
| 409 | nil.err); |
| 410 | |
| 411 | // A resource the browser could not load is the dev stack, not the page: no |
| 412 | // gateway runs here, so its probes answer 401 or 502 and neither is a throw. |
| 413 | const noise = s.errs.filter((e) => |
| 414 | !/favicon|ERR_ABORTED|net::ERR|Failed to load resource|i18n: no string/i.test(e)); |
| 415 | check(noise.length === 0, 'the page threw nothing along the way', noise.slice(0, 3).join(' | ')); |
| 416 | |
| 417 | } catch (e) { |
| 418 | check(false, 'the run finished', String(e && e.message || e)); |
| 419 | } finally { |
| 420 | await s.close(); |
| 421 | } |
| 422 | |
| 423 | console.log(bad === 0 ? '\nall checks passed' : `\n${bad} check(s) FAILED`); |
| 424 | process.exit(bad === 0 ? 0 : 1); |