oxedyne/daimond/dev/verify_doc.mjs
16.6 KiB, 1 run
created by r2519314175:371, 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_doc.mjs — where a document goes when you open it. |
| 2 | // |
| 3 | // The rule this asserts, which was decided rather than inherited: |
| 4 | // |
| 5 | // The stage holds what you are ATTENDING to -- a web page, a mail message, a |
| 6 | // document. The dock holds AMBIENT state you want in the corner of your eye, |
| 7 | // including the file tree. So a file tree is dock furniture and a file you |
| 8 | // have opened is stage furniture, whatever format it happens to be in. |
| 9 | // |
| 10 | // Before this, text opened inside the ~260px Workspace tile and only a compiled |
| 11 | // PDF reached the wide Doc panel -- a split by file format, which is a fact |
| 12 | // about the implementation and not about the reader. Worse, opening a file hid |
| 13 | // the tree to make room, so reading cost you your bearings. |
| 14 | // |
| 15 | // And what the panel does when it is ASKED FOR with nothing to show, which until |
| 16 | // now was nothing at all: no hook, a blank title and an empty `display:none` |
| 17 | // body. It offers a new document instead, and the checks below require it to |
| 18 | // name itself, to name which of the two filesystems Save will write it to, and |
| 19 | // then to write exactly the bytes typed and no others. |
| 20 | // |
| 21 | // node dev/verify_doc.mjs |
| 22 | |
| 23 | import fs from 'node:fs'; |
| 24 | import os from 'node:os'; |
| 25 | import path from 'node:path'; |
| 26 | import { spawn } from 'node:child_process'; |
| 27 | import { fileURLToPath } from 'node:url'; |
| 28 | import * as H from './harness.mjs'; |
| 29 | |
| 30 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 31 | const ROOT = path.join(HERE, '..'); |
| 32 | |
| 33 | const ok = [], bad = []; |
| 34 | const check = (name, pass, detail) => { |
| 35 | (pass ? ok : bad).push(name); |
| 36 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 37 | }; |
| 38 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 39 | |
| 40 | /// A screenshot of one element, PROVEN to exist. `H.shot` swallows a failed |
| 41 | /// capture, and a clean run is otherwise no evidence that anything was taken. |
| 42 | async function shotOf(page, name, sel) { |
| 43 | const dir = path.join(os.homedir(), '.cache/daimond/lane-v-shots'); |
| 44 | const out = path.join(dir, name + '.png'); |
| 45 | try { |
| 46 | fs.mkdirSync(dir, { recursive: true }); |
| 47 | const el = await page.$(sel); |
| 48 | if (!el) { console.log(` note no ${sel} to photograph`); return null; } |
| 49 | await el.screenshot({ path: out, timeout: 8000 }); |
| 50 | } catch (e) { console.log(` note screenshot ${name} failed: ${String(e).split('\n')[0]}`); return null; } |
| 51 | if (!fs.existsSync(out) || fs.statSync(out).size < 500) { console.log(` note ${name} is not on disk`); return null; } |
| 52 | console.log(' shot ' + out); |
| 53 | return out; |
| 54 | } |
| 55 | const procs = []; |
| 56 | async function waitFor(fn, ms = 15000, gap = 300) { |
| 57 | const t0 = Date.now(); |
| 58 | for (;;) { |
| 59 | try { if (await fn()) return true; } catch (e) {} |
| 60 | if (Date.now() - t0 > ms) return false; |
| 61 | await sleep(gap); |
| 62 | } |
| 63 | } |
| 64 | function cleanup(s) { |
| 65 | if (s) { try { s.browser.close(); } catch (e) {} } |
| 66 | for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } |
| 67 | } |
| 68 | |
| 69 | /// The state of the two panels, as a reader would see it. |
| 70 | async function surfaces(page) { |
| 71 | return await page.evaluate(() => { |
| 72 | const shown = el => !!el && el.offsetParent !== null; |
| 73 | const doc = document.getElementById('panel-doc'); |
| 74 | const work = document.getElementById('panel-work'); |
| 75 | const view = document.getElementById('doc-view'); |
| 76 | return { |
| 77 | docOpen: shown(doc), |
| 78 | workOpen: shown(work), |
| 79 | treeShown: shown(work && work.querySelector('.files-tree')), |
| 80 | docName: (document.getElementById('doc-name') || {}).textContent || '', |
| 81 | docText: ((view && view.querySelector('.files-view-body')) || {}).textContent || '', |
| 82 | viewInDoc: !!(view && view.querySelector('.files-view-body')), |
| 83 | viewInWork: !!(work && work.querySelector('.files-view-body')), |
| 84 | embedShown: shown(document.getElementById('doc-embed')), |
| 85 | }; |
| 86 | }); |
| 87 | } |
| 88 | |
| 89 | let s = null; |
| 90 | (async () => { |
| 91 | let served = false; |
| 92 | try { served = (await fetch(H.APP + '/')).ok; } catch (e) {} |
| 93 | if (!served) { |
| 94 | procs.push(spawn('node', ['dev/serve.mjs'], { cwd: ROOT, stdio: ['ignore', 'ignore', 'ignore'] })); |
| 95 | served = await waitFor(async () => (await fetch(H.APP + '/')).ok, 10000); |
| 96 | } |
| 97 | check('dev server serves the app', served); |
| 98 | if (!served) { cleanup(null); process.exit(1); } |
| 99 | |
| 100 | s = await H.open({ name: 'doc-panel', connect: false }); |
| 101 | const page = s.page; |
| 102 | |
| 103 | // Write a file straight through the tool the agent uses, so this tests the |
| 104 | // panels and not the file dialogs. |
| 105 | const body = 'Chapter one.\nThe document panel is where a document goes.\nLine three.\n'; |
| 106 | await page.evaluate(async (text) => { |
| 107 | await window.DaimondPanels.show('work'); |
| 108 | const app = window.__daimondToolHost || null; |
| 109 | // The Workspace panel's own writer, which is what the New File button uses. |
| 110 | const w = await navigator.storage.getDirectory(); |
| 111 | const fh = await w.getFileHandle('chapter.txt', { create: true }); |
| 112 | const ws = await fh.createWritable(); |
| 113 | await ws.write(new TextEncoder().encode(text)); |
| 114 | await ws.close(); |
| 115 | return !!app; |
| 116 | }, body); |
| 117 | |
| 118 | await page.evaluate(() => window.DaimondPanels.show('work')); |
| 119 | await sleep(600); |
| 120 | await page.evaluate(() => { |
| 121 | const r = document.querySelector('#panel-work [data-act="refresh"]'); |
| 122 | if (r) r.click(); |
| 123 | }); |
| 124 | await sleep(900); |
| 125 | |
| 126 | const before = await surfaces(page); |
| 127 | check('the Workspace tile is open with its tree showing', |
| 128 | before.workOpen && before.treeShown, JSON.stringify(before)); |
| 129 | check('Doc is not open before anything is opened', !before.docOpen); |
| 130 | // The viewer must not still be built into the Workspace panel. |
| 131 | check('the Workspace panel no longer contains a document view', !before.viewInWork); |
| 132 | |
| 133 | // Open it the way a person does: click the row in the tree. |
| 134 | const opened = await page.evaluate(() => { |
| 135 | const rows = Array.from(document.querySelectorAll('#panel-work .files-row')); |
| 136 | const row = rows.find(r => /chapter\.txt/.test(r.textContent || '')); |
| 137 | if (!row) return 'chapter.txt is not in the tree: ' + rows.map(r => r.textContent).join(','); |
| 138 | row.click(); |
| 139 | return true; |
| 140 | }); |
| 141 | check('the file is in the tree and can be clicked', opened === true, String(opened)); |
| 142 | await sleep(1200); |
| 143 | |
| 144 | const after = await surfaces(page); |
| 145 | check('opening a text file raises the Doc panel', after.docOpen, JSON.stringify(after)); |
| 146 | check('the document renders as text in Doc', after.viewInDoc && /Chapter one/.test(after.docText), |
| 147 | after.docText.slice(0, 40)); |
| 148 | check('Doc names the file', /chapter\.txt/.test(after.docName), after.docName); |
| 149 | // The whole point of moving it: you keep your bearings while reading. |
| 150 | check('the file tree is STILL showing while the document is open', |
| 151 | after.workOpen && after.treeShown, JSON.stringify({ work: after.workOpen, tree: after.treeShown })); |
| 152 | check('the PDF embed stays out of the way for a text file', !after.embedShown); |
| 153 | |
| 154 | // Editing happens where reading happens, or the split is back. |
| 155 | const canEdit = await page.evaluate(() => { |
| 156 | const v = document.getElementById('doc-view'); |
| 157 | return !!(v && v.querySelector('[data-act="edit"]') && v.querySelector('[data-act="download"]')); |
| 158 | }); |
| 159 | check('the document can be edited and downloaded from Doc', canEdit); |
| 160 | |
| 161 | // ── The button row is one height, not two ────────────────────────── |
| 162 | // ◈ only shows itself with a Diamond open (it names what that Diamond |
| 163 | // holds), so one is made here purely to bring the button on screen. |
| 164 | // |
| 165 | // Measured, not eyeballed: "consistent" means every visible button in |
| 166 | // `.files-view-head` reports the SAME `getBoundingClientRect().height`, |
| 167 | // to within sub-pixel layout rounding. ◈ used to carry a bigger font on |
| 168 | // the same padding as its neighbours, which grew the whole button by the |
| 169 | // same fraction as the glyph — 23.6px against their 22.4px, a difference |
| 170 | // too small to name on sight and exactly the kind a screenshot alone |
| 171 | // would miss and a measurement catches every time. |
| 172 | // The new-Diamond dialog refuses to create one with no model chosen, and |
| 173 | // this suite runs with `connect: false` for everything up to here. |
| 174 | await H.connectMock(s); |
| 175 | await sleep(400); |
| 176 | await page.click('#new-diamond-btn', { force: true }); |
| 177 | await page.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 178 | await page.fill('.dlg-input', 'Doc header probe'); |
| 179 | await page.click('.dlg-ok', { force: true }); |
| 180 | await sleep(900); |
| 181 | await page.evaluate(() => { |
| 182 | const r = document.querySelector('#panel-work [data-act="refresh"]'); |
| 183 | if (r) r.click(); |
| 184 | }); |
| 185 | await sleep(700); |
| 186 | const reopened = await page.evaluate(() => { |
| 187 | const rows = Array.from(document.querySelectorAll('#panel-work .files-row')); |
| 188 | const row = rows.find(r => /chapter\.txt/.test(r.textContent || '')); |
| 189 | if (!row) return false; |
| 190 | row.click(); |
| 191 | return true; |
| 192 | }); |
| 193 | check('the file can be reopened with a Diamond in focus', reopened); |
| 194 | await sleep(900); |
| 195 | |
| 196 | const heights = await page.evaluate(() => { |
| 197 | return Array.from(document.querySelectorAll('.files-view-head .files-btn')) |
| 198 | .filter(b => getComputedStyle(b).display !== 'none') |
| 199 | .map(b => ({ act: b.dataset.act, h: b.getBoundingClientRect().height })); |
| 200 | }); |
| 201 | // `attach`, not `hold`: the ◈ became the paperclip when attaching stopped |
| 202 | // meaning "keep with this Diamond" and started meaning "attach to whatever is |
| 203 | // in focus". The height property is unchanged — this is the button's name. |
| 204 | const holdShown = heights.some(b => b.act === 'attach'); |
| 205 | check('the attach button is on screen for this measurement', holdShown, JSON.stringify(heights)); |
| 206 | const distinct = Array.from(new Set(heights.map(b => Math.round(b.h * 10) / 10))); |
| 207 | check('every button in the row reports the same height', |
| 208 | holdShown && distinct.length === 1, |
| 209 | `${JSON.stringify(heights)} — ${distinct.length} distinct height(s)`); |
| 210 | |
| 211 | // Closing it puts the panel away and leaves the tree where it was. |
| 212 | await page.evaluate(() => { |
| 213 | const b = document.querySelector('#doc-view [data-act="back"]'); |
| 214 | if (b) b.click(); |
| 215 | }); |
| 216 | await sleep(800); |
| 217 | const closed = await surfaces(page); |
| 218 | check('closing the document closes Doc', !closed.docOpen, JSON.stringify(closed)); |
| 219 | check('and the tree is untouched', closed.workOpen && closed.treeShown); |
| 220 | |
| 221 | // ── The panel asked for with nothing to show ─────────────────────── |
| 222 | // |
| 223 | // It held NOTHING. `#doc-view` starts empty and `display:none`, the title |
| 224 | // starts blank, and `DaimondPanels.show` carried an onOpen hook for the |
| 225 | // Workspace, Mail, Spending, the Terminal, the Trash and Social -- and none |
| 226 | // for this panel. So the Doc chip opened a rectangle with a close button in |
| 227 | // it and no other mark, which reads as broken rather than as empty. |
| 228 | // |
| 229 | // What it does now is offer a NEW DOCUMENT, and the three things a new |
| 230 | // document must not leave unsaid are each asserted rather than admired: |
| 231 | // what it is called, that it is Save and not time that writes it, and WHICH |
| 232 | // FILESYSTEM it lands in -- the last being the confusion this project has |
| 233 | // paid most for, so the sentence is required to name the place in the same |
| 234 | // words the Workspace panel's own mode chip uses. |
| 235 | const coldPath = 'notes-' + new Date().toISOString().slice(0, 10) + '.md'; |
| 236 | // The panel names the cold document by TODAY, computed when it opens -- not |
| 237 | // when this line runs. A run that straddles midnight sees the date advance in |
| 238 | // between, so the name the panel shows may be this day's or the next. Both are |
| 239 | // covered here, and the file is read back below under the name it ACTUALLY |
| 240 | // showed, so a rollover mid-run is a fact about the clock and not a red. |
| 241 | const nextPath = 'notes-' |
| 242 | + new Date(Date.parse(coldPath.slice(6, 16)) + 86400000).toISOString().slice(0, 10) + '.md'; |
| 243 | // It must not exist BEFORE the panel is opened, or "Save created it" is a |
| 244 | // claim about a file that was already there. Neither candidate name may be on |
| 245 | // disk, so whichever the panel picks, Save is what wrote it. |
| 246 | const existedBefore = await page.evaluate(async (rels) => { |
| 247 | for (const rel of rels) { |
| 248 | try { const m = await import('/pkg/oxedyne_daimond.js'); await m.read_file(rel); return rel; } |
| 249 | catch (e) { /* good: not on disk */ } |
| 250 | } |
| 251 | return ''; |
| 252 | }, [coldPath, nextPath]); |
| 253 | check('the document this will propose is not on disk yet', !existedBefore, |
| 254 | existedBefore || (coldPath + ' / ' + nextPath)); |
| 255 | |
| 256 | await page.evaluate(() => { |
| 257 | window.DaimondPanels.markUsed('doc'); |
| 258 | window.DaimondPanels.show('doc'); |
| 259 | window.DaimondPanels.reflow(); |
| 260 | }); |
| 261 | await sleep(1500); |
| 262 | const cold = await page.evaluate(() => { |
| 263 | const view = document.getElementById('doc-view'); |
| 264 | const shown = (sel) => { |
| 265 | const e = view && view.querySelector(sel); |
| 266 | return !!e && e.getClientRects().length > 0; |
| 267 | }; |
| 268 | return { |
| 269 | name: (document.getElementById('doc-name') || {}).textContent || '', |
| 270 | editing: !!(view && view.querySelector('.files-edit')), |
| 271 | said: ((view && view.querySelector('.files-view-msg')) || {}).textContent || '', |
| 272 | saidShown: shown('.files-view-msg'), |
| 273 | // The Browser chip's own word, read off the mode row rather than |
| 274 | // restated here: the check is that the two agree, and hard-coding the |
| 275 | // word would let them drift apart while it went on passing. |
| 276 | place: ((document.querySelector('#panel-work .files-mode-chip.active')) || {}).textContent || '', |
| 277 | saveBtn: ((view && view.querySelector('[data-act="edit"]')) || {}).textContent || '', |
| 278 | download: shown('[data-act="download"]'), |
| 279 | attach: shown('[data-act="attach"]'), |
| 280 | }; |
| 281 | }); |
| 282 | check('opening Doc cold puts an editable document in it, not nothing', |
| 283 | cold.editing, JSON.stringify(cold)); |
| 284 | check('and it is named, so it can be found again', cold.name === coldPath || cold.name === nextPath, |
| 285 | cold.name + (cold.name === nextPath ? ' (the clock rolled to the next day mid-run)' : '')); |
| 286 | check('the panel says where the document will land, in the mode row\'s own word', |
| 287 | cold.saidShown && !!cold.place.trim() && cold.said.includes(cold.place.trim()), |
| 288 | JSON.stringify({ said: cold.said, place: cold.place })); |
| 289 | check('the button says which state it is in: there are edits to write', |
| 290 | /Save/i.test(cold.saveBtn), cold.saveBtn); |
| 291 | // The picture, because the checks above say the panel is not empty and only a |
| 292 | // picture says whether what replaced the emptiness is worth looking at. |
| 293 | await shotOf(page, 'doc-cold', '#panel-doc'); |
| 294 | check('nothing that acts on a file is offered before there is one', |
| 295 | cold.editing && !cold.download && !cold.attach, |
| 296 | JSON.stringify({ editing: cold.editing, download: cold.download, attach: cold.attach })); |
| 297 | |
| 298 | // And it really saves, at the name it showed, with the bytes that were typed |
| 299 | // and no others -- the property `verify_docroundtrip` asserts for a file that |
| 300 | // already exists, asserted here for the one this panel invents. |
| 301 | const NEW_BODY = '# Cold open\n\nTyped into a document that did not exist.\n'; |
| 302 | await page.evaluate((text) => { |
| 303 | const ta = document.querySelector('#doc-view .files-edit'); |
| 304 | if (ta) { ta.value = text; ta.dispatchEvent(new Event('input', { bubbles: true })); } |
| 305 | }, NEW_BODY); |
| 306 | await page.evaluate(() => { |
| 307 | const b = document.querySelector('#doc-view [data-act="edit"]'); |
| 308 | if (b) b.click(); |
| 309 | }); |
| 310 | await sleep(2500); |
| 311 | // Read back under the name the panel ACTUALLY showed, not the one guessed |
| 312 | // before it opened: on a midnight-straddling run those differ, and the round |
| 313 | // trip is about the bytes at the panel's own name. |
| 314 | const saved = await page.evaluate(async (rel) => { |
| 315 | const view = document.getElementById('doc-view'); |
| 316 | let onDisk = null; |
| 317 | try { const m = await import('/pkg/oxedyne_daimond.js'); onDisk = await m.read_file(rel); } |
| 318 | catch (e) { onDisk = 'READ FAILED: ' + e; } |
| 319 | const shown = (sel) => { |
| 320 | const e = view && view.querySelector(sel); |
| 321 | return !!e && e.getClientRects().length > 0; |
| 322 | }; |
| 323 | return { |
| 324 | onDisk, |
| 325 | said: ((view && view.querySelector('.files-view-msg')) || {}).textContent || '', |
| 326 | editBtn: ((view && view.querySelector('[data-act="edit"]')) || {}).textContent || '', |
| 327 | download: shown('[data-act="download"]'), |
| 328 | attach: shown('[data-act="attach"]'), |
| 329 | }; |
| 330 | }, cold.name); |
| 331 | check('Save writes exactly what was typed, at the name the panel showed', |
| 332 | saved.onDisk === NEW_BODY, JSON.stringify(saved.onDisk)); |
| 333 | check('and the button flips back, so the user can tell it is written', |
| 334 | /Edit/i.test(saved.editBtn) && /Saved/i.test(saved.said), |
| 335 | JSON.stringify({ btn: saved.editBtn, said: saved.said })); |
| 336 | check('the file controls come back now that there is a file', |
| 337 | saved.download && saved.attach, |
| 338 | JSON.stringify({ download: saved.download, attach: saved.attach })); |
| 339 | await shotOf(page, 'doc-cold-saved', '#panel-doc'); |
| 340 | |
| 341 | // The gateway is not running for this suite, and the app is meant to work |
| 342 | // without one, so its 502s are the expected answer rather than a fault. |
| 343 | const real = s.errs.filter(e => !/502|Bad Gateway|Failed to load resource/.test(e)); |
| 344 | check('no page errors', real.length === 0, real.slice(0, 3).join(' | ')); |
| 345 | |
| 346 | await H.shot(s, 'doc-panel').catch(() => {}); |
| 347 | cleanup(s); |
| 348 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 349 | process.exit(bad.length ? 1 : 0); |
| 350 | })().catch(e => { cleanup(s); console.error(e); process.exit(1); }); |