oxedyne/daimond/dev/verify_crystalpanel.mjs
14.3 KiB, 1 run
created by r2519314175:335, 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_crystalpanel.mjs — opening or closing a panel does not take the crystal's page down. |
| 2 | // |
| 3 | // THE DEFECT THIS IS WRITTEN FROM, in the owner's words (notes6, item 3): "adding or removing a |
| 4 | // panel in the stage makes the crystal show 'This Diamond's page did not show everything it |
| 5 | // holds…'". That note is `crystal.js` giving up on the page: `fell('partial')`, the frame gone, |
| 6 | // the built-in view in its place. Nothing about the crystal had changed — the user had merely |
| 7 | // opened the Tools panel. |
| 8 | // |
| 9 | // WHAT WAS ACTUALLY HAPPENING, measured before anything was fixed. Not a second `rendered` with |
| 10 | // fewer keys, which is the trap CAPP_CONTRACT §5 warns about: the key list was complete and |
| 11 | // identical throughout. It was the OTHER rule, §4. `DaimondPanels.apply()` re-seated the layout |
| 12 | // by appending every open panel to its zone, every time anything opened or closed anywhere — |
| 13 | // and `appendChild` on a node that is already in the document is a REMOVAL and a re-insertion. |
| 14 | // An iframe taken out of the document loses its content window and is navigated to its `src` |
| 15 | // again when it goes back in, so the crystal frame fired a second `load`; `crystal.js` reads |
| 16 | // that as the page navigating itself and shuts the channel, because a `postMessage` sent to |
| 17 | // `'*'` after the frame moved on would be delivered to whatever is there now. That rule is |
| 18 | // right and is not what changed. What changed is that the app stopped moving the frame. |
| 19 | // |
| 20 | // It could not have healed itself either: the blob URL is revoked once the document is fetched, |
| 21 | // so the reload had nothing to fetch. The frame was blank before the fallback replaced it. |
| 22 | // |
| 23 | // THE PROPERTIES: |
| 24 | // |
| 25 | // 1. A stage panel OPENING leaves the crystal's page up — and it is the SAME DOCUMENT, not a |
| 26 | // fresh one. A mark set inside the frame survives. This is the check that matters: an app |
| 27 | // that re-mounted the crystal after every panel change would look right in a screenshot |
| 28 | // and would still throw away whatever a capp had on screen — the half-typed entry, the |
| 29 | // lane you were reading, the scroll position. |
| 30 | // 2. A stage panel CLOSING, the same. Both directions, because the owner named both. |
| 31 | // 3. A DOCK panel opening and closing, the same. The crystal is in the stage, so a dock panel |
| 32 | // had no business touching it — and it did, because the stage was re-seated on every pass. |
| 33 | // 4. NO PANEL IS TAKEN OUT OF THE DOCUMENT AND PUT BACK because a DIFFERENT panel opened or |
| 34 | // closed. The mechanism, stated where it can be measured. It covers what checks 1-3 cannot |
| 35 | // see: the Web panel's browser and the Preview panel's `<embed>` were being restarted by |
| 36 | // every panel change too, and no crystal check would ever have noticed. |
| 37 | // 5. The layout is still right afterwards — the seats drawn in the asked-for order, left to |
| 38 | // right, with a divider between each pair. Without this a "fix" that seated nothing at all |
| 39 | // would pass everything above. |
| 40 | // |
| 41 | // The gestures are the user's: a chip in the panel row to open, the panel's own × to close. |
| 42 | // Driving `DaimondPanels.show()` from the console would test the engine and not the app. |
| 43 | // |
| 44 | // EACH CHECK PROVED AGAINST BROKEN CODE FIRST: |
| 45 | // |
| 46 | // node dev/verify_crystalpanel.mjs --break moves # 1, 2, 3, 4: the stage re-seats by re-appending |
| 47 | // node dev/verify_crystalpanel.mjs --break dockmoves # 4 alone: the dock does |
| 48 | // node dev/verify_crystalpanel.mjs # and then, clean |
| 49 | // |
| 50 | // `dockmoves` earns its place by turning ONE check red and no other, which is the whole reason |
| 51 | // check 4 is written in terms of the document and not in terms of the crystal: the dock holds |
| 52 | // no crystal, so every other check here is blind to it. The breaks go on the APP, as |
| 53 | // `verify_capp.mjs`'s do, and for the same reason — what is under test is what the app does to |
| 54 | // a page it did not write. |
| 55 | import fs from 'node:fs'; |
| 56 | import path from 'node:path'; |
| 57 | import { fileURLToPath } from 'node:url'; |
| 58 | import { open, signInAs, connectMock, errors } from './harness.mjs'; |
| 59 | |
| 60 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 61 | const WWW = path.join(HERE, '..', 'www'); |
| 62 | |
| 63 | const BREAK = (() => { |
| 64 | const i = process.argv.indexOf('--break'); |
| 65 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 66 | })(); |
| 67 | |
| 68 | const BREAKS = { |
| 69 | // The stage seating as it was: every seat and every divider appended in turn, wherever they |
| 70 | // already were. This is the shipped defect, byte for byte. |
| 71 | moves: { |
| 72 | file: 'js/daimond.js', |
| 73 | find: "\t\t\t\tplace(stageEl, seq);", |
| 74 | with: "\t\t\t\tseq.forEach(function (n) { stageEl.appendChild(n); });", |
| 75 | }, |
| 76 | // The same for the dock's columns. It cannot touch the crystal, which is in the stage — so |
| 77 | // if check 4 did not exist, this break would change nothing at all. |
| 78 | dockmoves: { |
| 79 | file: 'js/daimond.js', |
| 80 | find: "\t\t\t\tcols.forEach(function (c, i) { place(c, colSeq[i]); });", |
| 81 | with: "\t\t\t\tcols.forEach(function (c, i) { colSeq[i].forEach(function (n) { c.appendChild(n); }); });", |
| 82 | }, |
| 83 | }; |
| 84 | |
| 85 | const ok = [], bad = []; |
| 86 | const check = (name, pass, detail) => { |
| 87 | (pass ? ok : bad).push(name); |
| 88 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 89 | }; |
| 90 | |
| 91 | // A crystal with a key no page has ever heard of, because that is the case the coverage rule |
| 92 | // exists for: the reducer is a fresh model rewriting the whole crystal from one sentence, so |
| 93 | // `habits` is the expected shape of things and not a curiosity. The shipped page draws unknown |
| 94 | // keys generically and names them all in `rendered`, so a run that falls back has fallen back |
| 95 | // for a reason this file is about. |
| 96 | const CRYSTAL = { |
| 97 | title: 'Panels', |
| 98 | summary: 'A crystal to watch while the stage changes around it.', |
| 99 | facts: [{ k: 'Seats', v: 'as many as the width carries' }], |
| 100 | open: ['Open a panel without losing this page'], |
| 101 | habits: { watch: 'what happens to the frame' }, |
| 102 | }; |
| 103 | |
| 104 | const s = await open({ name: 'crystalpanel', signIn: false, connect: false }); |
| 105 | const { page } = s; |
| 106 | |
| 107 | if (BREAK) { |
| 108 | const spec = BREAKS[BREAK]; |
| 109 | if (!spec) { |
| 110 | console.error('no such break: ' + BREAK + '\nhave: ' + Object.keys(BREAKS).join(' ')); |
| 111 | process.exit(2); |
| 112 | } |
| 113 | const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 114 | const n = src.split(spec.find).length - 1; |
| 115 | if (n !== 1) { |
| 116 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, ` |
| 117 | + 'so nothing was broken and the run below would prove nothing.'); |
| 118 | process.exit(2); |
| 119 | } |
| 120 | await page.route('**/' + spec.file, r => r.fulfill({ |
| 121 | status: 200, contentType: 'application/javascript', body: src.replace(spec.find, spec.with), |
| 122 | })); |
| 123 | console.log(` (running with the app broken: ${BREAK})`); |
| 124 | } |
| 125 | |
| 126 | /// What the channel says is on screen. `_state()` is the verifier's window on it and the app |
| 127 | /// never uses it. |
| 128 | const state = () => page.evaluate(() => { |
| 129 | const st = window.DaimondCrystal && window.DaimondCrystal._state && window.DaimondCrystal._state(); |
| 130 | return st ? { mode: st.mode, reason: st.reason, keys: st.keys } : null; |
| 131 | }); |
| 132 | |
| 133 | /// The crystal's own frame. It is the only `blob:` frame on the page — the guide is a child of |
| 134 | /// the main frame too, so a search by parentage finds THAT one and every measurement after it |
| 135 | /// is of the wrong document. |
| 136 | const crystalFrame = () => page.frames().find(fr => fr.url().indexOf('blob:') === 0) || null; |
| 137 | |
| 138 | /// Which panels are drawn in the stage, left to right, and what stands between them. |
| 139 | const seating = () => page.evaluate(() => { |
| 140 | const out = []; |
| 141 | [].slice.call(document.getElementById('stage').children).forEach((el) => { |
| 142 | if (!el.getClientRects().length) return; |
| 143 | const r = el.getBoundingClientRect(); |
| 144 | out.push({ id: el.dataset.panel || (el.className.indexOf('phandle') >= 0 ? '|' : '?'), x: Math.round(r.x) }); |
| 145 | }); |
| 146 | return out.sort((a, b) => a.x - b.x).map(p => p.id).join(' '); |
| 147 | }); |
| 148 | |
| 149 | /// Watch for a panel leaving the document. `childList` on the whole tree, because the two zones |
| 150 | /// are different containers and a panel can be pulled out of either. |
| 151 | const watch = () => page.evaluate(() => { |
| 152 | window.__pulled = []; |
| 153 | if (window.__obs) window.__obs.disconnect(); |
| 154 | window.__obs = new MutationObserver((recs) => { |
| 155 | recs.forEach((r) => { |
| 156 | [].slice.call(r.removedNodes).forEach((n) => { |
| 157 | if (n.nodeType === 1 && n.dataset && n.dataset.panel) window.__pulled.push(n.dataset.panel); |
| 158 | }); |
| 159 | }); |
| 160 | }); |
| 161 | window.__obs.observe(document.body, { childList: true, subtree: true }); |
| 162 | }); |
| 163 | const pulled = () => page.evaluate(() => (window.__pulled || []).slice()); |
| 164 | |
| 165 | try { |
| 166 | await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' }); |
| 167 | await signInAs(s, 'crystalpanel'); |
| 168 | await connectMock(s); |
| 169 | await page.waitForTimeout(1500); |
| 170 | // Wide enough for a THIRD seat. Below 2000 the stage seats two, so opening a panel would |
| 171 | // evict the one beside the conversation and the act under test would be an eviction rather |
| 172 | // than an arrival — which is a different thing and not the one the owner reported. |
| 173 | await page.setViewportSize({ width: 2000, height: 1000 }); |
| 174 | await page.waitForTimeout(600); |
| 175 | const seatsMax = await page.evaluate(() => DaimondPanels.model().stageMax); |
| 176 | check('the stage has room for a third seat, so opening one evicts nothing', |
| 177 | seatsMax >= 3, String(seatsMax)); |
| 178 | |
| 179 | await page.click('#new-diamond-btn', { force: true }); |
| 180 | await page.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 181 | await page.fill('.dlg-input', 'Panels'); |
| 182 | await page.click('.dlg-ok', { force: true }); |
| 183 | await page.waitForTimeout(1800); |
| 184 | |
| 185 | const id = await page.evaluate(async () => { |
| 186 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 187 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 188 | window.__free = app; |
| 189 | const d = JSON.parse(await app.list_diamonds()).find(x => x.name === 'Panels'); |
| 190 | return d ? d.id : ''; |
| 191 | }); |
| 192 | check('a Diamond to watch', !!id, id); |
| 193 | |
| 194 | // Only the memory is written. The page is the shipped one, which the app writes into the |
| 195 | // Diamond on the first render — so what is under test is what every Diamond starts with. |
| 196 | await page.evaluate(async (a) => { |
| 197 | await window.__free.run_tool('file_write', JSON.stringify({ |
| 198 | path: 'diamonds/' + a.id + '/crystal.json', content: a.crystal })); |
| 199 | }, { id, crystal: JSON.stringify(CRYSTAL) }); |
| 200 | await page.$$eval('.diamond-box', els => els[0] && els[0].click()); |
| 201 | await page.waitForTimeout(2500); |
| 202 | |
| 203 | const st0 = await state(); |
| 204 | check('the crystal is drawn by its own page to start with', |
| 205 | !!st0 && st0.mode === 'frame', JSON.stringify(st0)); |
| 206 | const missing = Object.keys(CRYSTAL).filter(k => !(st0 && (st0.keys || []).includes(k))); |
| 207 | check('and the page named every content key, so nothing below is a coverage failure', |
| 208 | missing.length === 0, 'missing: ' + (missing.join(', ') || 'none')); |
| 209 | |
| 210 | // The mark. A DOM move destroys the frame's content window, and so does a re-mount by the |
| 211 | // app; both lose this, and nothing else does. |
| 212 | const mark = async () => { |
| 213 | const f = crystalFrame(); |
| 214 | if (!f) return false; |
| 215 | return await f.evaluate(() => { window.__kept = 'kept'; return true; }).catch(() => false); |
| 216 | }; |
| 217 | const kept = async () => { |
| 218 | const f = crystalFrame(); |
| 219 | if (!f) return 'no frame'; |
| 220 | return await f.evaluate(() => String(window.__kept || 'gone')).catch(e => 'unreachable'); |
| 221 | }; |
| 222 | check('a mark can be set inside the page', (await mark()) === true); |
| 223 | const seats0 = await seating(); |
| 224 | check('and the stage starts with the conversation and the Web panel beside it', |
| 225 | seats0 === 'ai | web', '"' + seats0 + '"'); |
| 226 | |
| 227 | /// One act, and everything it must not have done. |
| 228 | /// |
| 229 | /// `gesture` is what a person does; `panel` is the panel it is done to, which is the ONE |
| 230 | /// node allowed to move — a panel being seated for the first time has to come from |
| 231 | /// wherever the markup left it. |
| 232 | const act = async (name, panel, gesture, expectSeats) => { |
| 233 | await watch(); |
| 234 | await gesture(); |
| 235 | await page.waitForTimeout(900); |
| 236 | const st = await state(); |
| 237 | const note = await page.evaluate(() => !!document.querySelector('.crystal-fallback-note')); |
| 238 | check(name + ': THE CRYSTAL PAGE IS STILL UP', |
| 239 | !!st && st.mode === 'frame' && !note, |
| 240 | 'mode:' + (st && st.mode) + ' reason:' + (st && st.reason) + ' fallback:' + note); |
| 241 | check(name + ': and it is the same document, not a fresh one', |
| 242 | (await kept()) === 'kept', await kept()); |
| 243 | const moved = (await pulled()).filter(p => p !== panel); |
| 244 | check(name + ': and no other panel was pulled out of the document', |
| 245 | moved.length === 0, moved.join(', ') || 'none'); |
| 246 | const seats = await seating(); |
| 247 | check(name + ': and the stage is drawn as it was asked to be', seats === expectSeats, |
| 248 | '"' + seats + '" wanted "' + expectSeats + '"'); |
| 249 | }; |
| 250 | |
| 251 | // ── 1 and 2. A stage panel, opened by its chip and closed by its ×. |
| 252 | // |
| 253 | // Tools rather than Web: Web is already open on a first boot, so its chip would be a |
| 254 | // no-op and the act would prove nothing. |
| 255 | const chip = (p) => page.click('.ptag[data-panel="' + p + '"]', { timeout: 5000 }); |
| 256 | const closer = (p) => page.click('[data-close="' + p + '"]', { timeout: 5000 }); |
| 257 | await act('opening a stage panel', 'tools', () => chip('tools'), 'ai | web | tools'); |
| 258 | await act('closing a stage panel', 'tools', () => closer('tools'), 'ai | web'); |
| 259 | |
| 260 | // ── 3. A dock panel. The crystal is in the stage and this must not reach it at all — the |
| 261 | // stage's seating must come out of it completely unchanged. |
| 262 | await act('opening a dock panel', 'trash', () => chip('trash'), 'ai | web'); |
| 263 | await act('closing a dock panel', 'trash', () => closer('trash'), 'ai | web'); |
| 264 | |
| 265 | // And the acts were real: a check that passes because nothing happened is the failure this |
| 266 | // project keeps paying for. |
| 267 | const flips = await page.evaluate(() => ({ |
| 268 | tools: DaimondPanels.isOpen('tools'), trash: DaimondPanels.isOpen('trash'), |
| 269 | })); |
| 270 | check('the panels really opened and closed, so none of the above passed by standing still', |
| 271 | flips.tools === false && flips.trash === false, JSON.stringify(flips)); |
| 272 | |
| 273 | // The 502s are the account service on :9002, which a world does not run; they say nothing |
| 274 | // about the layout. Everything else counts. |
| 275 | const errs = errors(s).filter(e => !/502|Bad Gateway|account/i.test(e)); |
| 276 | check('no unexpected console errors', errs.length === 0, errs.slice(0, 2).join(' | ') || 'clean'); |
| 277 | } catch (e) { |
| 278 | check('the run completed', false, String((e && e.message) || e)); |
| 279 | } finally { |
| 280 | await s.close?.().catch(() => {}); |
| 281 | } |
| 282 | |
| 283 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 284 | if (BREAK) { |
| 285 | console.log(bad.length |
| 286 | ? `\nbreak '${BREAK}' produced failures, as it must.` |
| 287 | : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`); |
| 288 | } |
| 289 | process.exit(bad.length ? 1 : 0); |