oxedyne/daimond/dev/verify_drafts.mjs
12.5 KiB, 1 run
created by r2519314175:381, 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_drafts.mjs — what somebody is half-way through typing survives a |
| 2 | // reload, and does NOT thereby become a send queue. |
| 3 | // |
| 4 | // The owner reported one case and stated the general rule: "A screen refresh |
| 5 | // deletes text entered but waiting and not processed into Social > Notes. I |
| 6 | // would expect all live text input to persist, stored temporarily in the |
| 7 | // browser so it returns after a refresh." |
| 8 | // |
| 9 | // He is right, and the box is the one place in an app where the user rather |
| 10 | // than the app holds the only copy. `www/js/drafts.js` keeps it. |
| 11 | // |
| 12 | // ── WHY THIS FILE IS HALF ABOUT WHAT MUST *NOT* HAPPEN ─────────────── |
| 13 | // |
| 14 | // `dev/IMPROVE_CONTRACT.md` §4: "A note leaves this device only when a person |
| 15 | // presses Send on that one note, and what leaves is exactly the characters that |
| 16 | // are on the screen at that moment. Nothing about a note is queued, retried, |
| 17 | // batched, synced or kept for later sending." |
| 18 | // |
| 19 | // Held text LOOKS like the thing that forbids, so the difference is asserted |
| 20 | // rather than argued. What is forbidden is SENDING WITHOUT A PRESS. A draft is |
| 21 | // not waiting to go; it is waiting to be looked at, by the person who typed it, |
| 22 | // on the device they typed it on. So every check that the words come back is |
| 23 | // paired with a check that they went NOWHERE — counted at the network, over the |
| 24 | // whole session, whatever the address, exactly as verify_improve.mjs counts. |
| 25 | // |
| 26 | // FIVE PROPERTIES: |
| 27 | // |
| 28 | // 1. THE NOTE BOX SURVIVES A RELOAD, and the words are the same characters. |
| 29 | // 2. AND NOT ONE REQUEST IN THE WHOLE SESSION CARRIED THEM. Not on the type, |
| 30 | // not on the reload, not afterwards. This is the check that says a draft is |
| 31 | // not a queue, and it is blind to the address on purpose. |
| 32 | // 3. A BOX THAT WAS EMPTIED IS EMPTY AFTER A RELOAD. A draft that outlived the |
| 33 | // act of clearing it would put words back that somebody had deleted, which |
| 34 | // is worse than losing them: it looks like the app arguing. |
| 35 | // 4. THE CHAT COMPOSER SURVIVES ONE TOO, and a draft belongs to ITS OWN |
| 36 | // conversation — switching away and back finds the same words, and a |
| 37 | // different chat does not inherit them. |
| 38 | // 5. NOTHING REACHES THE SYNC PARCEL. A draft that crossed to a phone would be |
| 39 | // text moving without a press, which is the thing §4 forbids, arriving by |
| 40 | // the side door. |
| 41 | // |
| 42 | // PROVED RED. `--unbuilt` serves the page with `DaimondDrafts` removed before |
| 43 | // anything binds to it, which is what the app did before this existed. |
| 44 | // |
| 45 | // ITS REACH IS THREE CHECKS AND THEY ARE NAMED, because a break whose reach is |
| 46 | // not written down is a break whose reach is not known: the note box coming |
| 47 | // back, the composer coming back, and the second conversation keeping its own. |
| 48 | // Those three are one property asserted over three surfaces, which is the point |
| 49 | // of asserting it three times — the owner said "all live text input", not "the |
| 50 | // note box". |
| 51 | // |
| 52 | // The other eleven STAY GREEN under it, and that is what makes them evidence |
| 53 | // rather than echo. They are the properties the old app also satisfied: nothing |
| 54 | // was sent, an emptied box stayed empty, no draft reached the parcel. A break |
| 55 | // that reddened everything would prove nothing about which check caught what. |
| 56 | // It also proves the call sites tolerate the module's absence — the run |
| 57 | // finishes, and the unhandled-error check is one of the eleven. |
| 58 | // |
| 59 | // eval "$(bash dev/world.sh 7 --env)" |
| 60 | // node dev/verify_drafts.mjs |
| 61 | // node dev/verify_drafts.mjs --unbuilt # expected to FAIL, exactly 3 checks |
| 62 | // |
| 63 | // Needs dev/serve.mjs and node. No gateway: nothing here reaches one, which is |
| 64 | // itself the point. |
| 65 | import { open, shot, scratch, errors, newChat } from './harness.mjs'; |
| 66 | |
| 67 | const UNBUILT = process.argv.includes('--unbuilt'); |
| 68 | |
| 69 | let bad = 0, n = 0; |
| 70 | const check = (ok, what, detail) => { |
| 71 | n++; |
| 72 | if (!ok) bad++; |
| 73 | console.log(` ${ok ? 'ok ' : 'FAIL'} ${what}${detail ? ' — ' + detail : ''}`); |
| 74 | }; |
| 75 | |
| 76 | // The markers. Distinctive enough that a substring search over every request |
| 77 | // body and URL in the session cannot match anything else. |
| 78 | const NOTE = 'the rail forgot where it was quokka-draft-note'; |
| 79 | const CHAT = 'ask the daimon about quokka-draft-chat'; |
| 80 | const SECOND = 'a different conversation quokka-draft-other'; |
| 81 | |
| 82 | const profile = scratch('pw', 'drafts-' + process.pid); |
| 83 | const s = await open({ |
| 84 | name: 'drafts' + process.pid, |
| 85 | profile, |
| 86 | defaults: false, |
| 87 | // Every request the page makes, whatever its address, so check 2 can be |
| 88 | // asked over the whole session rather than over one route. |
| 89 | route: async (page) => { |
| 90 | if (UNBUILT) { |
| 91 | // The app as it was: the module is served, and then taken away before |
| 92 | // anything can bind to it. Serving an EMPTY file would be the same |
| 93 | // break; taking the global away after the file has run also proves the |
| 94 | // call sites tolerate its absence, which they must. |
| 95 | await page.route('**/js/drafts.js', (r) => r.fulfill({ |
| 96 | status: 200, contentType: 'text/javascript', |
| 97 | body: '/* --unbuilt: the app before drafts.js */\n', |
| 98 | })); |
| 99 | } |
| 100 | }, |
| 101 | }); |
| 102 | const p = s.page; |
| 103 | |
| 104 | // Everything the page sent, in full. Read from the request rather than from a |
| 105 | // route handler, so a request that a handler would have had to fulfil is |
| 106 | // counted the same as one that went out. |
| 107 | const wire = []; |
| 108 | p.on('request', (r) => { |
| 109 | let body = ''; |
| 110 | try { body = r.postData() || ''; } catch (e) { body = ''; } |
| 111 | wire.push(r.url() + ' ' + body); |
| 112 | }); |
| 113 | const anywhereOnTheWire = (needle) => wire.filter((w) => w.includes(needle)); |
| 114 | |
| 115 | const reload = async () => { |
| 116 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 117 | // The gate is on a fresh load; the profile keeps the identity, so this is an |
| 118 | // unlock rather than a create. |
| 119 | await p.waitForTimeout(400); |
| 120 | const pass = await p.$('#id-pass'); |
| 121 | if (pass) { |
| 122 | await pass.fill('testpass1234'); |
| 123 | await p.evaluate(() => { |
| 124 | const b = document.getElementById('id-primary'); |
| 125 | if (b) b.click(); |
| 126 | }); |
| 127 | } |
| 128 | await p.waitForTimeout(1200); |
| 129 | }; |
| 130 | |
| 131 | const openSocial = async () => { |
| 132 | await p.evaluate(() => { |
| 133 | window.DaimondPanels.show('social'); |
| 134 | if (window.DaimondImprove) window.DaimondImprove.onOpen(); |
| 135 | }); |
| 136 | await p.waitForTimeout(600); |
| 137 | }; |
| 138 | |
| 139 | console.log(UNBUILT ? 'drafts (UNBUILT — 2 checks are expected to fail)' : 'drafts'); |
| 140 | |
| 141 | // ── 1 and 2. The note box ──────────────────────────────────────────── |
| 142 | console.log('the note box'); |
| 143 | await openSocial(); |
| 144 | const box = await p.$('#improve-box'); |
| 145 | check(!!box, 'the note box is on screen to type into'); |
| 146 | if (box) { |
| 147 | await box.fill(NOTE); |
| 148 | // The settle timer, plus a moment. Written as a wait rather than a flush, |
| 149 | // because what is being checked is that an ORDINARY pause is enough — a test |
| 150 | // that called `flush()` would prove the store works and nothing about whether |
| 151 | // the app ever reaches it. |
| 152 | await p.waitForTimeout(700); |
| 153 | await shot(s, 'drafts-typed'); |
| 154 | |
| 155 | const sentWhileTyping = anywhereOnTheWire('quokka-draft-note'); |
| 156 | check(sentWhileTyping.length === 0, |
| 157 | 'typing sends nothing — not one request in the session carries the words', |
| 158 | sentWhileTyping.length ? sentWhileTyping[0].slice(0, 160) : null); |
| 159 | |
| 160 | await reload(); |
| 161 | await openSocial(); |
| 162 | const back = await p.evaluate(() => { |
| 163 | const b = document.getElementById('improve-box'); |
| 164 | return b ? String(b.value || '') : null; |
| 165 | }); |
| 166 | check(back === NOTE, 'and after a reload the words are back, character for character', |
| 167 | JSON.stringify(back)); |
| 168 | await shot(s, 'drafts-restored'); |
| 169 | |
| 170 | // THE CHECK THIS FILE EXISTS FOR. The reload is exactly the moment a queue |
| 171 | // would flush, so it is asked after it and over the whole session. |
| 172 | const sentEver = anywhereOnTheWire('quokka-draft-note'); |
| 173 | check(sentEver.length === 0, |
| 174 | 'AND THE RELOAD SENT NOTHING EITHER — a draft is held, never queued', |
| 175 | sentEver.length ? `${sentEver.length}, e.g. ${sentEver[0].slice(0, 160)}` : null); |
| 176 | } |
| 177 | |
| 178 | // ── 3. Emptying it means it is empty ───────────────────────────────── |
| 179 | console.log('emptying it'); |
| 180 | { |
| 181 | await p.evaluate(() => { |
| 182 | const b = document.getElementById('improve-box'); |
| 183 | if (!b) return; |
| 184 | b.value = ''; |
| 185 | b.dispatchEvent(new Event('input', { bubbles: true })); |
| 186 | }); |
| 187 | await p.waitForTimeout(700); |
| 188 | await reload(); |
| 189 | await openSocial(); |
| 190 | const after = await p.evaluate(() => { |
| 191 | const b = document.getElementById('improve-box'); |
| 192 | return b ? String(b.value || '') : null; |
| 193 | }); |
| 194 | check(after === '', 'a box emptied on purpose is still empty after a reload', |
| 195 | JSON.stringify(after)); |
| 196 | } |
| 197 | |
| 198 | // ── 4. The chat composer ───────────────────────────────────────────── |
| 199 | console.log('the chat composer'); |
| 200 | { |
| 201 | // The Social panel is open over the stage, and the composer is on the stage. |
| 202 | // A chat of this run's own, so the draft has a conversation to belong to and |
| 203 | // the keying check below has a second one to switch to. |
| 204 | await p.evaluate(() => { try { window.DaimondPanels.hide('social'); } catch (e) { /* already */ } }); |
| 205 | await newChat(s); |
| 206 | await p.waitForTimeout(400); |
| 207 | const input = await p.$('#chat-input'); |
| 208 | check(!!input, 'the composer is on screen to type into'); |
| 209 | if (input) { |
| 210 | await input.fill(CHAT); |
| 211 | await p.waitForTimeout(700); |
| 212 | await reload(); |
| 213 | const back = await p.evaluate(() => { |
| 214 | const b = document.getElementById('chat-input'); |
| 215 | return b ? String(b.value || '') : null; |
| 216 | }); |
| 217 | check(back === CHAT, 'a half-typed message is back after a reload', JSON.stringify(back)); |
| 218 | const leaked = anywhereOnTheWire('quokka-draft-chat'); |
| 219 | check(leaked.length === 0, 'and it went nowhere either', |
| 220 | leaked.length ? leaked[0].slice(0, 160) : null); |
| 221 | |
| 222 | // A DRAFT BELONGS TO ITS OWN CONVERSATION. The composer is ONE element |
| 223 | // shared by every chat and both faces of every Diamond, so a draft keyed on |
| 224 | // the element rather than on the conversation would follow the user into |
| 225 | // the next one — which is how a stranger's half-sentence once ended up in a |
| 226 | // Diamond that had never seen it. |
| 227 | await newChat(s); |
| 228 | await p.waitForTimeout(400); |
| 229 | const afterSwitch = await p.evaluate(() => document.getElementById('chat-input').value); |
| 230 | check(afterSwitch === '', 'a new conversation does not inherit the last one\'s draft', |
| 231 | JSON.stringify(afterSwitch)); |
| 232 | // And it keeps one of its own, which is the other half: a key that was the |
| 233 | // same for every conversation would pass the check above only by losing |
| 234 | // both drafts. |
| 235 | await p.fill('#chat-input', SECOND); |
| 236 | await p.waitForTimeout(700); |
| 237 | await reload(); |
| 238 | const secondBack = await p.evaluate(() => document.getElementById('chat-input').value); |
| 239 | check(secondBack === SECOND, 'and it keeps its OWN across a reload', JSON.stringify(secondBack)); |
| 240 | const bothLeaked = anywhereOnTheWire('quokka-draft-other'); |
| 241 | check(bothLeaked.length === 0, 'and neither of them went anywhere'); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // ── 5. Nothing crosses to another device ───────────────────────────── |
| 246 | console.log('the sync parcel'); |
| 247 | { |
| 248 | const parcel = await p.evaluate(async () => { |
| 249 | try { |
| 250 | if (!window.DaimondSync || !DaimondSync.parcel) return null; |
| 251 | return JSON.stringify(await DaimondSync.parcel()); |
| 252 | } catch (e) { return 'ERR ' + String(e && e.message); } |
| 253 | }); |
| 254 | if (parcel === null) { |
| 255 | check(true, 'the sync module offers no parcel to read here, so nothing can be in it'); |
| 256 | } else { |
| 257 | check(!parcel.includes('quokka-draft'), |
| 258 | 'no draft is in the sync parcel — a draft that crossed devices would be text moving with no press', |
| 259 | parcel.startsWith('ERR') ? parcel : null); |
| 260 | check(!parcel.includes('daimond-drafts'), |
| 261 | 'and the store is not named in it either'); |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // ── The page did not fall over doing any of it ─────────────────────── |
| 266 | { |
| 267 | // 401 and 502 on `/api/...` are this world with no gateway of its own, which |
| 268 | // is deliberate: nothing this file checks reaches one. See dev/world.sh. |
| 269 | const errs = errors(s).filter((e) => !/401|502|Failed to fetch|NetworkError/i.test(e)); |
| 270 | check(errs.length === 0, 'nothing above was reached by way of an unhandled error', |
| 271 | errs.slice(0, 2).join(' | ')); |
| 272 | } |
| 273 | |
| 274 | await s.close(); |
| 275 | console.log(''); |
| 276 | if (UNBUILT) { |
| 277 | // The break must redden exactly the three "the words are back" checks. A break |
| 278 | // that reddened everything would say nothing about which check caught what. |
| 279 | console.log(bad === 3 |
| 280 | ? `--unbuilt: ${bad} of ${n} failed, which is the three restore checks and only those` |
| 281 | : `--unbuilt: ${bad} of ${n} failed — EXPECTED 3. A break whose reach is not known is not proof.`); |
| 282 | process.exit(bad === 3 ? 0 : 1); |
| 283 | } |
| 284 | console.log(bad ? `${bad} of ${n} failed` : `all ${n} checks passed`); |
| 285 | process.exit(bad ? 1 : 0); |