oxedyne/daimond/dev/verify_migration.mjs
11.3 KiB, 1 run
created by r2519314175:533, 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_migration.mjs — a workspace written before the rename must open with everything in it. |
| 2 | // |
| 3 | // The Red → Daimond rename moved two things a user's workspace actually holds: the per-Diamond |
| 4 | // store (`diamonds/<id>/.red/` → `.daimond/`) and the standing-instructions file (`RED.md` → |
| 5 | // `DAIMOND.md`). Neither is Daimond's to lose. A Diamond whose store is not found does not fail |
| 6 | // loudly — `read_meta` errors, `list` skips it, and a pursuit with a year of folds in it simply |
| 7 | // is not in the list any more. That is the failure this drives. |
| 8 | // |
| 9 | // So the test seeds a workspace exactly as the old code would have left one, boots the app on it |
| 10 | // the way a user does, and then asks the questions a user would: is my Diamond there, is its |
| 11 | // history there, and can I still read the delta of a fold I made before the rename? |
| 12 | // |
| 13 | // The seed below lands AFTER a boot, and that is the harder of the two orders on purpose. |
| 14 | // `open()` signs in, which draws the app, which runs `Instructions.refresh()` -- and since the |
| 15 | // starter shipped that refresh puts a `DAIMOND.md` in the store before this file has written a |
| 16 | // line. So the pre-rename `RED.md` here arrives beside a starter, exactly as one carried in by |
| 17 | // sync or found in a folder opened later does, and the migration has to beat a file that is |
| 18 | // already sitting at its destination. The easy order -- `RED.md` alone, no starter anywhere -- |
| 19 | // is the one that never needed a check. |
| 20 | import { open, signInAs, shot } from './harness.mjs'; |
| 21 | |
| 22 | const ok = [], bad = []; |
| 23 | const check = (name, pass, detail) => { |
| 24 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 25 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 26 | }; |
| 27 | |
| 28 | const ID = 'legacy1'; |
| 29 | const RULE = 'HOUSE RULE: every answer ends with ZZ-HOUSE.'; |
| 30 | |
| 31 | // A fresh profile, so the OPFS sandbox starts empty and what is in it is what we put there. |
| 32 | const s = await open({ name: 'migrate', connect: false }); |
| 33 | const p = s.page; |
| 34 | await p.waitForTimeout(1200); |
| 35 | |
| 36 | // ── Seed a workspace as the pre-rename code would have left it ────────── |
| 37 | // |
| 38 | // Written through the wasm file surface, which with no real folder open resolves against the |
| 39 | // same OPFS sandbox the Diamond store lives in — so these are the very bytes the old app wrote. |
| 40 | const seeded = await p.evaluate(async ({ id, rule }) => { |
| 41 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 42 | const w = (path, content) => mod.write_file(path, content); |
| 43 | |
| 44 | // The crystal and its snapshots sit OUTSIDE the store directory and never moved. |
| 45 | // It is markdown, because that is what a workspace of this vintage holds: the |
| 46 | // boot below therefore runs the crystal's own conversion as well as the store |
| 47 | // move, which is exactly what a real one of these does. |
| 48 | await w(`diamonds/${id}/crystal.md`, '# The old pursuit\n\nA crystal written before the rename.\n'); |
| 49 | await w(`diamonds/${id}/versions/0000.md`, ''); |
| 50 | await w(`diamonds/${id}/versions/0001.md`, '# The old pursuit\n\nA crystal written before the rename.\n'); |
| 51 | |
| 52 | // The store, under its old name. |
| 53 | await w(`diamonds/${id}/.red/meta.json`, |
| 54 | '{"name":"An old pursuit","crystal_version":1,"updated":1750000000000}'); |
| 55 | await w(`diamonds/${id}/.red/deltas/0001.md`, 'THE-OLD-DELTA: what the fold consumed.'); |
| 56 | // Two log records. The fold's `delta_ref` is a PATH, and it points into the old directory — |
| 57 | // which is why moving the files alone would not be enough. |
| 58 | await w(`diamonds/${id}/.red/log`, |
| 59 | '{"id":"r1","ts":1750000000000,"kind":"create","agent":"user","task":"create diamond",' |
| 60 | + '"parent_crystal_version":-1,"crystal_version":0,"delta_ref":"","note":"An old pursuit"}\n' |
| 61 | + '{"id":"r2","ts":1750000000001,"kind":"fold","agent":"reducer","task":"fold delta",' |
| 62 | + '"parent_crystal_version":0,"crystal_version":1,' |
| 63 | + `"delta_ref":"diamonds/${id}/.red/deltas/0001.md","note":"folded before the rename"}\n`); |
| 64 | |
| 65 | // And the house rules, at the workspace root, under their old name. |
| 66 | await w('RED.md', rule); |
| 67 | return true; |
| 68 | }, { id: ID, rule: RULE }); |
| 69 | check('a pre-rename workspace was seeded', seeded === true); |
| 70 | |
| 71 | // ── Boot the app on it, the way a user does ───────────────────────────── |
| 72 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 73 | await signInAs(s, 'migrate'); |
| 74 | await p.waitForTimeout(2500); // loadDiamonds() and Instructions.refresh() run on unlock |
| 75 | |
| 76 | // ── What the user sees ────────────────────────────────────────────────── |
| 77 | const listed = await p.$eval('#diamond-list', e => e.textContent); |
| 78 | check('the Diamond is still in the list', /An old pursuit/.test(listed), listed.trim().slice(0, 60)); |
| 79 | |
| 80 | const chip = await p.evaluate(() => { |
| 81 | const el = document.getElementById('instructions-chip'); |
| 82 | return el && el.style.display !== 'none' ? el.textContent.trim() : '(hidden)'; |
| 83 | }); |
| 84 | check('the standing instructions are still in force', /DAIMOND\.md/.test(chip), chip); |
| 85 | |
| 86 | // ── What is actually on disk ──────────────────────────────────────────── |
| 87 | const disk = await p.evaluate(async ({ id }) => { |
| 88 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 89 | const read = async (path) => { |
| 90 | try { return await mod.read_file(path); } |
| 91 | catch (e) { return null; } // absent, which for the old paths is the point |
| 92 | }; |
| 93 | return { |
| 94 | newMeta: await read(`diamonds/${id}/.daimond/meta.json`), |
| 95 | newLog: await read(`diamonds/${id}/.daimond/log`), |
| 96 | newDelta: await read(`diamonds/${id}/.daimond/deltas/0001.md`), |
| 97 | oldMeta: await read(`diamonds/${id}/.red/meta.json`), |
| 98 | oldLog: await read(`diamonds/${id}/.red/log`), |
| 99 | oldDelta: await read(`diamonds/${id}/.red/deltas/0001.md`), |
| 100 | crystal: await read(`diamonds/${id}/crystal.json`), |
| 101 | oldCrystal: await read(`diamonds/${id}/crystal.md`), |
| 102 | version: await read(`diamonds/${id}/versions/0001.md`), |
| 103 | newRules: await read('DAIMOND.md'), |
| 104 | oldRules: await read('RED.md'), |
| 105 | }; |
| 106 | }, { id: ID }); |
| 107 | |
| 108 | check('the store moved to .daimond/', !!disk.newMeta && /An old pursuit/.test(disk.newMeta)); |
| 109 | check('the log came with it', !!disk.newLog && /folded before the rename/.test(disk.newLog)); |
| 110 | check('the retained delta came with it', |
| 111 | disk.newDelta === 'THE-OLD-DELTA: what the fold consumed.'); |
| 112 | check('nothing was left behind in .red/', |
| 113 | disk.oldMeta === null && disk.oldLog === null && disk.oldDelta === null); |
| 114 | // The store move leaves the crystal alone; the crystal's own conversion, which |
| 115 | // runs in the same pass, turns it into data. Both migrations are asserted by |
| 116 | // what SURVIVES rather than by what moved: the user's words are in `crystal.json` |
| 117 | // and the markdown history is exactly where it was. |
| 118 | check('the crystal came through as data, carrying the same words', |
| 119 | /A crystal written before the rename/.test(disk.crystal || '') |
| 120 | && (disk.crystal || '').trim().startsWith('{'), |
| 121 | (disk.crystal || '(absent)').slice(0, 80)); |
| 122 | // The markdown is KEPT beside it, deliberately. The conversion self-checks by |
| 123 | // rendering back and comparing bytes, which structurally cannot prove the |
| 124 | // STRUCTURE is right -- a `##` inside a fence rejoins to identical bytes whether |
| 125 | // or not the fence was honoured -- so the one failure that would justify still |
| 126 | // having the markdown is exactly the one the check cannot see. And |
| 127 | // `import_diamond` deletes a Diamond's directory before rewriting it, so a bad |
| 128 | // conversion propagates back over a good copy on the next sync. |
| 129 | check('and the markdown is kept beside it, because lossless is not the same as proven', |
| 130 | disk.oldCrystal !== null, disk.oldCrystal === null ? 'DELETED' : 'kept'); |
| 131 | check('the markdown snapshots were untouched, so the conversion is reversible', |
| 132 | /A crystal written before the rename/.test(disk.version || '')); |
| 133 | |
| 134 | // The one that a directory move alone would fail: the log points at the delta BY PATH. |
| 135 | check('the log’s delta_ref was rewritten to the new path', |
| 136 | !!disk.newLog && disk.newLog.includes(`diamonds/${ID}/.daimond/deltas/0001.md`) |
| 137 | && !disk.newLog.includes('.red/'), |
| 138 | (disk.newLog || '').split('\n')[1]?.slice(0, 72)); |
| 139 | |
| 140 | check('RED.md became DAIMOND.md', disk.newRules === RULE && disk.oldRules === null); |
| 141 | |
| 142 | // ── The history the user opens, and the delta they click ──────────────── |
| 143 | const history = await p.evaluate(async ({ id }) => { |
| 144 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 145 | // Read the delta exactly as the History panel does: by the path the log record carries. |
| 146 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 147 | const recs = JSON.parse(await app.log_read(id) || '[]'); |
| 148 | const fold = recs.find(r => r.kind === 'fold'); |
| 149 | if (!fold || !fold.delta_ref) return { recs: recs.length, delta: null }; |
| 150 | const delta = await app.run_tool('file_read', JSON.stringify({ path: fold.delta_ref })); |
| 151 | return { recs: recs.length, ref: fold.delta_ref, delta }; |
| 152 | }, { id: ID }); |
| 153 | check('the history reads back whole', history.recs === 2, history.recs + ' records'); |
| 154 | check('a fold made before the rename can still show its delta', |
| 155 | /THE-OLD-DELTA/.test(history.delta || ''), |
| 156 | history.ref); |
| 157 | |
| 158 | // ── Idempotence: a second boot must not undo the first ────────────────── |
| 159 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 160 | await signInAs(s, 'migrate'); |
| 161 | await p.waitForTimeout(2000); |
| 162 | const again = await p.$eval('#diamond-list', e => e.textContent); |
| 163 | const stillThere = await p.evaluate(async ({ id }) => { |
| 164 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 165 | try { return await mod.read_file(`diamonds/${id}/.daimond/meta.json`); } catch { return null; } |
| 166 | }, { id: ID }); |
| 167 | check('a second boot migrates nothing and breaks nothing', |
| 168 | /An old pursuit/.test(again) && !!stillThere); |
| 169 | |
| 170 | // ── The other side of it: rules the user WROTE are not overwritten ────── |
| 171 | // |
| 172 | // `migrate` carries `RED.md` over a `DAIMOND.md` that is the starter to the byte. This is the |
| 173 | // check that keeps that from becoming "carries it over anything": edit the file the way a user |
| 174 | // would, put an old `RED.md` back beside it, and the edit must still be there afterwards. Break |
| 175 | // the byte comparison and this goes red while the check above stays green, which is the only |
| 176 | // arrangement in which either of them is worth reading. |
| 177 | const MINE = 'MY OWN RULES: answer in Latin.'; |
| 178 | await p.evaluate(async ({ mine, rule }) => { |
| 179 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 180 | await mod.write_file('DAIMOND.md', mine); |
| 181 | await mod.write_file('RED.md', rule); |
| 182 | }, { mine: MINE, rule: RULE }); |
| 183 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 184 | await signInAs(s, 'migrate'); |
| 185 | await p.waitForTimeout(2500); |
| 186 | const kept = await p.evaluate(async () => { |
| 187 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 188 | const read = async (path) => { try { return await mod.read_file(path); } catch (e) { return null; } }; |
| 189 | return { mine: await read('DAIMOND.md'), old: await read('RED.md') }; |
| 190 | }); |
| 191 | check('rules the user wrote are not replaced by their old ones', |
| 192 | kept.mine === MINE, JSON.stringify(kept.mine)); |
| 193 | check('and the old file is left where it was, not silently dropped', |
| 194 | kept.old === RULE, JSON.stringify(kept.old)); |
| 195 | |
| 196 | await shot(s, 'migration'); |
| 197 | const errs = s.errs.filter(e => !/favicon|404|net::ERR/.test(e)); |
| 198 | console.log('\nconsole errors:', errs.slice(0, 4)); |
| 199 | await s.close(); |
| 200 | |
| 201 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 202 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 203 | process.exit(bad.length ? 1 : 0); |