oxedyne/daimond/dev/verify_composemerge.mjs
16.3 KiB, 9 runs
created by r2519314175:1506, 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_composemerge.mjs — note-capture merged into the Proposals view. |
| 2 | // |
| 3 | // The standalone Notes view is gone. A note is written in a compose box at the |
| 4 | // top of Proposals and posted straight as a proposal, in one of two modes that |
| 5 | // both auto-send: |
| 6 | // |
| 7 | // POST VERBATIM — the words become the proposal at once (the direct path). |
| 8 | // POLISH & POST — the model rewrites the note into a proposal, then that is |
| 9 | // posted (the triage draft path). |
| 10 | // |
| 11 | // A note that cannot be sent yet -- written offline, or a send that failed -- |
| 12 | // waits in a small queue under the box, REMEMBERING its mode, and is drained |
| 13 | // automatically when the browser comes back online. |
| 14 | // |
| 15 | // What this proves, the four the owner asked for: |
| 16 | // (a) verbatim online → an immediate proposal, in the list, note gone; |
| 17 | // (b) polish online → the DRAFTED proposal posted and in the list; |
| 18 | // (c) offline submit → queued, with its mode, nothing on the wire; |
| 19 | // (d) reconnect → the flush drains the queue, each note in its own mode. |
| 20 | // |
| 21 | // The model is not run for real: `DaimondTriage.polish` is overridden in the page |
| 22 | // to return a fixed draft, so what is proved is improve.js's WIRING -- that a |
| 23 | // polished note posts what the drafting returned, through the same door a verbatim |
| 24 | // note leaves by. `dev/verify_triage.mjs` proves the real model run. |
| 25 | // |
| 26 | // eval "$(bash dev/world.sh 8 --env)" |
| 27 | // node dev/verify_composemerge.mjs |
| 28 | // node dev/verify_composemerge.mjs --break noflush |
| 29 | |
| 30 | import fs from 'node:fs'; |
| 31 | import path from 'node:path'; |
| 32 | import { fileURLToPath } from 'node:url'; |
| 33 | import { open, shot, scratch, errors, signInAs } from './harness.mjs'; |
| 34 | |
| 35 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 36 | const WWW = path.join(HERE, '..', 'www'); |
| 37 | |
| 38 | const BREAK = (() => { |
| 39 | const i = process.argv.indexOf('--break'); |
| 40 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 41 | })(); |
| 42 | |
| 43 | const PROFILE = scratch('pw', 'composemerge' + (BREAK ? '-' + BREAK : '')); |
| 44 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 45 | |
| 46 | const ok = [], bad = []; |
| 47 | const check = (name, pass, detail) => { |
| 48 | (pass ? ok : bad).push(name); |
| 49 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 50 | }; |
| 51 | |
| 52 | // ── Seams: the merged surface must be wired, or the run proves nothing. |
| 53 | |
| 54 | const SEAM = [ |
| 55 | { file: 'index.html', want: 'data-act="improve-post"', |
| 56 | why: 'the compose box has no Post action' }, |
| 57 | { file: 'index.html', want: 'data-act="improve-polish"', |
| 58 | why: 'the compose box has no Polish action' }, |
| 59 | { file: 'js/improve.js', want: 'flushQueue: flushQueue', |
| 60 | why: 'the queue cannot be drained on reconnect' }, |
| 61 | { file: 'js/daimond.js', want: 'DaimondImprove.flushQueue()', |
| 62 | why: 'the online event never drains the queue' }, |
| 63 | { file: 'index.html', want: 'id="improve-raised"', |
| 64 | why: 'the compose box has no "Raised — see it in Improve" confirmation host' }, |
| 65 | { file: 'js/improve.js', want: "act === 'improve-open-hub'", |
| 66 | why: 'the Raised confirmation cannot open the Improve hub' }, |
| 67 | ]; |
| 68 | |
| 69 | function requireSeams() { |
| 70 | const missing = []; |
| 71 | for (const s of SEAM) { |
| 72 | const src = FILES.get(s.file) ?? fs.readFileSync(path.join(WWW, s.file), 'utf8'); |
| 73 | if (!src.includes(s.want)) missing.push(` ${s.file}: ${s.why}`); |
| 74 | } |
| 75 | if (missing.length) { |
| 76 | console.error('the merged compose surface is not wired, so this run would prove nothing:'); |
| 77 | for (const b of missing) console.error(b); |
| 78 | process.exit(2); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // ── The break: the reconnect flush is severed, so an offline note never sends. |
| 83 | |
| 84 | const BREAKS = { |
| 85 | // The reconnect flush is severed: it iterates an empty snapshot, so a queued |
| 86 | // note is never drained when the browser comes back. (a)-(c) stay green -- the |
| 87 | // immediate sends and the offline queueing are untouched -- and only (d) reddens. |
| 88 | noflush: [{ |
| 89 | file: 'js/improve.js', |
| 90 | find: '\t\t\tvar q = load().notes.slice(); // a snapshot of ids; the list changes under us', |
| 91 | with: '\t\t\tvar q = []; // snapshot severed by the break', |
| 92 | }], |
| 93 | }; |
| 94 | |
| 95 | if (BREAK && !BREAKS[BREAK]) { |
| 96 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 97 | process.exit(2); |
| 98 | } |
| 99 | |
| 100 | function edit(src, spec, what) { |
| 101 | const n = src.split(spec.find).length - 1; |
| 102 | if (n !== 1) { |
| 103 | console.error(`${what}: the anchor appears ${n} times in ${spec.file}, ` |
| 104 | + 'so nothing was changed and the run below would prove nothing.'); |
| 105 | process.exit(2); |
| 106 | } |
| 107 | return src.replace(spec.find, spec.with); |
| 108 | } |
| 109 | |
| 110 | const FILES = new Map(); |
| 111 | function build() { |
| 112 | if (BREAK) { |
| 113 | for (const spec of BREAKS[BREAK]) { |
| 114 | const p = spec.file; |
| 115 | const src = FILES.get(p) ?? fs.readFileSync(path.join(WWW, p), 'utf8'); |
| 116 | FILES.set(p, edit(src, spec, `break '${BREAK}'`)); |
| 117 | } |
| 118 | } |
| 119 | requireSeams(); |
| 120 | } |
| 121 | build(); |
| 122 | |
| 123 | // ── The forge, answered locally. GET reads an (empty) listing; a POST that opens |
| 124 | // a proposal is given the next number, from 100 up. |
| 125 | |
| 126 | const HDR = 'x-daimond-voice'; |
| 127 | // allowlist secret |
| 128 | const SECRET = 'mock-voice-ada-0000000000000'; |
| 129 | |
| 130 | let nextNew = 100; |
| 131 | const asked = []; |
| 132 | |
| 133 | async function improveRoute(r) { |
| 134 | const req = r.request(); |
| 135 | const u = new URL(req.url()); |
| 136 | const q = u.searchParams; |
| 137 | const method = req.method(); |
| 138 | const body = req.postData() || ''; |
| 139 | const headers = req.headers(); |
| 140 | asked.push({ method, body, query: Object.fromEntries(q) }); |
| 141 | |
| 142 | const json = (obj) => r.fulfill({ status: 200, contentType: 'application/json', |
| 143 | body: typeof obj === 'string' ? obj : JSON.stringify(obj) }); |
| 144 | const refuse = (status, error) => r.fulfill({ status, contentType: 'application/json', |
| 145 | body: JSON.stringify({ error, said: 'The forge refused: ' + error + '.' }) }); |
| 146 | |
| 147 | if (method === 'GET') { |
| 148 | if (q.get('n') !== null) { |
| 149 | const n = Number(q.get('n')); |
| 150 | return json({ number: n, title: 'Proposal ' + n, body: 'b', state: 'open', author: 'ada', |
| 151 | comments: 0, opened: 1, changed: 2, discussion: [], votes: { for: 0, against: 0 }, |
| 152 | mark: null, build: null, revisions: [] }); |
| 153 | } |
| 154 | return json({ proposals: [], total: 0, done: true }); |
| 155 | } |
| 156 | if (!headers[HDR]) return refuse(401, 'unvoiced'); |
| 157 | |
| 158 | const n = q.get('n'); |
| 159 | const num = n !== null ? Number(n) : nextNew++; |
| 160 | return json({ number: num, title: 'Proposal ' + num, body: 'b', state: 'open', author: 'ada', |
| 161 | comments: 0, opened: 1, changed: 2, discussion: [], votes: { for: 0, against: 0 }, |
| 162 | mark: null, build: null, revisions: [] }); |
| 163 | } |
| 164 | |
| 165 | const j = (body, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body) }); |
| 166 | |
| 167 | async function stub(page) { |
| 168 | for (const [p, body] of FILES) { |
| 169 | const type = p.endsWith('.html') ? 'text/html' : 'application/javascript'; |
| 170 | await page.route('**/' + p, r => r.fulfill({ status: 200, contentType: type, body })); |
| 171 | } |
| 172 | if (FILES.has('index.html')) { |
| 173 | await page.route(u => u.pathname === '/' || u.pathname === '/index.html', |
| 174 | r => r.fulfill({ status: 200, contentType: 'text/html', body: FILES.get('index.html') })); |
| 175 | } |
| 176 | await page.route(u => u.pathname === '/api/improve', improveRoute); |
| 177 | await page.route('**/api/telemetry', r => r.fulfill(j({ ok: true }))); |
| 178 | await page.route('**/api/account', r => r.fulfill(j({ ok: true }))); |
| 179 | await page.route('**/api/auth/challenge', r => r.fulfill(j({ ok: true, challenge: 'chal-cm', challenge_id: 'cid-1' }))); |
| 180 | await page.route('**/api/auth/verify', r => r.fulfill(j({ ok: true }))); |
| 181 | await page.route('**/api/balance', r => r.fulfill(j({ ok: true, credits_minor: 0, currency: 'usd', entries: [] }))); |
| 182 | await page.route('**/api/licence', r => r.fulfill(j({ ok: true, licence: false, currency: 'usd' }))); |
| 183 | } |
| 184 | |
| 185 | const opens = () => asked.filter(a => a.method === 'POST' && a.query.n === undefined); |
| 186 | const fields = (raw) => { const o = {}; for (const [k, v] of new URLSearchParams(raw)) o[k] = v; return o; }; |
| 187 | |
| 188 | const s = await open({ name: 'composemerge', profile: PROFILE, signIn: false, connect: false, route: stub }); |
| 189 | const { page } = s; |
| 190 | |
| 191 | await signInAs(s, 'composemerge'); |
| 192 | await page.waitForTimeout(1000); |
| 193 | |
| 194 | // A page helper: force the browser's online state, and (optionally) fire the event. |
| 195 | async function setOnline(on, fire) { |
| 196 | await page.evaluate(({ on, fire }) => { |
| 197 | Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => on }); |
| 198 | if (fire) window.dispatchEvent(new Event(on ? 'online' : 'offline')); |
| 199 | }, { on, fire }); |
| 200 | } |
| 201 | |
| 202 | // The polish path, without a real model: a fixed draft, so the WIRING is proved. |
| 203 | async function stubPolish() { |
| 204 | await page.evaluate(() => { |
| 205 | window.DaimondTriage = window.DaimondTriage || {}; |
| 206 | window.DaimondTriage.polish = async function (text) { |
| 207 | return { title: 'Polished: ' + String(text || '').split('\n')[0].slice(0, 40), |
| 208 | body: 'A tidier body the model wrote from the note.' }; |
| 209 | }; |
| 210 | }); |
| 211 | } |
| 212 | |
| 213 | async function typeAndClick(text, act) { |
| 214 | await page.evaluate((t) => { |
| 215 | const box = document.getElementById('improve-box'); |
| 216 | box.value = t; |
| 217 | box.dispatchEvent(new Event('input', { bubbles: true })); |
| 218 | }, text); |
| 219 | await page.waitForTimeout(80); |
| 220 | await page.click('#panel-social [data-act="' + act + '"]'); |
| 221 | } |
| 222 | |
| 223 | try { |
| 224 | await page.evaluate(() => { window.DaimondPanels.show('social'); }); |
| 225 | await page.waitForTimeout(300); |
| 226 | await page.evaluate(() => { if (window.DaimondImprove) window.DaimondImprove.onOpen(); }); |
| 227 | await page.evaluate(() => window.DaimondImprove.load(false)); |
| 228 | await page.waitForTimeout(400); |
| 229 | |
| 230 | // A voice, set through the Settings view where it now lives. |
| 231 | await page.evaluate(() => window.DaimondSocial.show('settings')); |
| 232 | await page.waitForTimeout(200); |
| 233 | await page.click('#panel-social [data-act="improve-voice-open"]'); |
| 234 | await page.waitForTimeout(150); |
| 235 | await page.fill('#improve-voice-in', SECRET); |
| 236 | await page.click('#panel-social [data-act="improve-voice-save"]'); |
| 237 | await page.waitForTimeout(500); |
| 238 | check('a voice is held (set from Settings), so the box can post', |
| 239 | await page.evaluate(() => window.DaimondVoice.has()) === true); |
| 240 | await page.evaluate(() => window.DaimondSocial.show('proposals')); |
| 241 | await page.waitForTimeout(200); |
| 242 | |
| 243 | await stubPolish(); |
| 244 | await setOnline(true, false); |
| 245 | |
| 246 | // ── (a) Verbatim online → an immediate proposal, note gone ── |
| 247 | let before = opens().length; |
| 248 | await typeAndClick('Reload empties the note box\nIt should keep what I typed.', 'improve-post'); |
| 249 | for (let i = 0; i < 40 && await page.evaluate(() => window.DaimondImprove.notes().length > 0); i++) { |
| 250 | await page.waitForTimeout(150); |
| 251 | } |
| 252 | await page.waitForTimeout(300); |
| 253 | const aPost = opens().length - before === 1 ? fields(opens()[opens().length - 1].body) : {}; |
| 254 | check('(a) verbatim online posted exactly one proposal', opens().length - before === 1, |
| 255 | `${opens().length - before} posts`); |
| 256 | check('(a) it carried the words verbatim, as title + body', |
| 257 | aPost.title === 'Reload empties the note box' && /keep what I typed/.test(aPost.body || ''), |
| 258 | JSON.stringify(aPost).slice(0, 140)); |
| 259 | check('(a) the note is gone from the queue: it became a proposal', |
| 260 | await page.evaluate(() => window.DaimondImprove.notes().length) === 0); |
| 261 | check('(a) and the proposal appears in the store', |
| 262 | await page.evaluate(() => window.DaimondImprove.forge.props().some(p => p.n === 100))); |
| 263 | |
| 264 | // ── Capture-only: the browse list is gone, a confirmation points at the hub ── |
| 265 | // The Social ▸ Proposals surface writes a proposal; the browse/vote list of all |
| 266 | // proposals is the Improve hub (js/tracker.js) now, not here, so the two surfaces |
| 267 | // no longer duplicate a list. |
| 268 | check('the browse list of all proposals is NOT on the capture surface', |
| 269 | await page.locator('#panel-social #improve-props').count() === 0, |
| 270 | `${await page.locator('#panel-social #improve-props').count()} list host(s)`); |
| 271 | check('after a raise, the "Raised — see it in Improve" confirmation shows', |
| 272 | await page.locator('#improve-raised [data-act="improve-open-hub"]').count() === 1); |
| 273 | const raisedText = await page.evaluate(() => { |
| 274 | const h = document.getElementById('improve-raised'); |
| 275 | return h ? (h.textContent || '').replace(/\s+/g, ' ').trim() : ''; |
| 276 | }); |
| 277 | check('the confirmation reads "Raised" and names the Improve hub', |
| 278 | /[Rr]aised/.test(raisedText) && /Improve/.test(raisedText), raisedText); |
| 279 | // The affordance opens the Improve hub panel (js/tracker.js, #panel-tracker). |
| 280 | await page.click('#improve-raised [data-act="improve-open-hub"]'); |
| 281 | await page.waitForTimeout(300); |
| 282 | check('pressing Improve opens the Improve hub panel', |
| 283 | await page.evaluate(() => !!(window.DaimondPanels && DaimondPanels.isOpen('tracker'))) === true); |
| 284 | // Back to Social for the checks that follow. |
| 285 | await page.evaluate(() => { window.DaimondPanels.show('social'); window.DaimondSocial.show('proposals'); }); |
| 286 | await page.waitForTimeout(200); |
| 287 | |
| 288 | // ── (b) Polish online → the DRAFTED proposal posted ───────── |
| 289 | before = opens().length; |
| 290 | await typeAndClick('the reply box scrolls to the top on send', 'improve-polish'); |
| 291 | for (let i = 0; i < 40 && await page.evaluate(() => window.DaimondImprove.notes().length > 0); i++) { |
| 292 | await page.waitForTimeout(150); |
| 293 | } |
| 294 | await page.waitForTimeout(300); |
| 295 | const bPost = opens().length - before === 1 ? fields(opens()[opens().length - 1].body) : {}; |
| 296 | check('(b) polish online posted exactly one proposal', opens().length - before === 1, |
| 297 | `${opens().length - before} posts`); |
| 298 | check('(b) it carried the model\'s DRAFT, not the raw note', |
| 299 | /^Polished:/.test(bPost.title || '') && /tidier body/.test(bPost.body || ''), |
| 300 | JSON.stringify(bPost).slice(0, 140)); |
| 301 | check('(b) the note is gone and the drafted proposal appears', |
| 302 | await page.evaluate(() => window.DaimondImprove.notes().length) === 0 |
| 303 | && await page.evaluate(() => window.DaimondImprove.forge.props().some(p => p.n === 101))); |
| 304 | |
| 305 | await shot(s, 'composemerge-posted' + (BREAK ? '-' + BREAK : '')); |
| 306 | |
| 307 | // ── (c) Offline submit → queued, with its mode, nothing sent ─ |
| 308 | await setOnline(false, true); |
| 309 | before = opens().length; |
| 310 | await typeAndClick('offline verbatim note about a crash', 'improve-post'); |
| 311 | await page.waitForTimeout(250); |
| 312 | await typeAndClick('offline polish note about wording', 'improve-polish'); |
| 313 | await page.waitForTimeout(250); |
| 314 | const queued = await page.evaluate(() => window.DaimondImprove.notes().map(n => ({ mode: n.mode, text: n.text.split('\n')[0] }))); |
| 315 | check('(c) offline, nothing reached the forge', opens().length - before === 0, |
| 316 | `${opens().length - before} posts`); |
| 317 | check('(c) both notes are queued, each remembering its mode', |
| 318 | queued.length === 2 |
| 319 | && queued.some(n => n.mode === 'verbatim' && /crash/.test(n.text)) |
| 320 | && queued.some(n => n.mode === 'polish' && /wording/.test(n.text)), |
| 321 | JSON.stringify(queued)); |
| 322 | const qHead = await page.evaluate(() => { |
| 323 | const h = document.querySelector('#improve-queue .imp-queue-head'); |
| 324 | return h ? h.textContent : ''; |
| 325 | }); |
| 326 | check('(c) the queue shows "Waiting to send (2)"', /2/.test(qHead) && /[Ww]aiting/.test(qHead), qHead); |
| 327 | check('(c) a queued (offline) note also shows the Raised confirmation', |
| 328 | await page.locator('#improve-raised [data-act="improve-open-hub"]').count() === 1); |
| 329 | await shot(s, 'composemerge-offline' + (BREAK ? '-' + BREAK : '')); |
| 330 | |
| 331 | // ── (d) Reconnect → the flush drains, each in its own mode ── |
| 332 | before = opens().length; |
| 333 | await stubPolish(); // the override does not survive if the panel reset; re-arm |
| 334 | await setOnline(true, true); // fires 'online' → daimond.js → flushQueue |
| 335 | for (let i = 0; i < 60 && await page.evaluate(() => window.DaimondImprove.notes().length > 0); i++) { |
| 336 | await page.waitForTimeout(200); |
| 337 | } |
| 338 | await page.waitForTimeout(400); |
| 339 | const drained = await page.evaluate(() => window.DaimondImprove.notes().length); |
| 340 | const newPosts = opens().slice(before).map(a => fields(a.body).title); |
| 341 | check('(d) reconnect drained the queue: nothing left waiting', drained === 0, `${drained} left`); |
| 342 | check('(d) both queued notes were sent on reconnect', opens().length - before === 2, |
| 343 | `${opens().length - before} posts`); |
| 344 | check('(d) the verbatim one went verbatim, the polish one as a draft', |
| 345 | newPosts.some(t => t === 'offline verbatim note about a crash') |
| 346 | && newPosts.some(t => /^Polished:/.test(t)), |
| 347 | JSON.stringify(newPosts)); |
| 348 | |
| 349 | const errs = errors(s).filter(e => !/Failed to load resource/.test(e)); |
| 350 | check('nothing above was reached by way of an unhandled error', errs.length === 0, |
| 351 | errs.slice(0, 3).join(' | ')); |
| 352 | } finally { |
| 353 | await s.close(); |
| 354 | } |
| 355 | |
| 356 | console.log(`\nforge opens: ${opens().length}`); |
| 357 | if (BREAK) { |
| 358 | console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed` |
| 359 | + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing')); |
| 360 | process.exit(bad.length ? 0 : 1); |
| 361 | } |
| 362 | console.log(bad.length === 0 ? `\nall ${ok.length} checks passed` : `\n${bad.length} check(s) FAILED`); |
| 363 | process.exit(bad.length === 0 ? 0 : 1); |