oxedyne/daimond/dev/verify_doorbell.mjs
16.5 KiB, 1 run
created by r2519314175:377, 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 | // dev/verify_doorbell.mjs -- the email doorbell: the switch that three published |
| 2 | // texts promised and the app did not have. |
| 3 | // |
| 4 | // The doorbell defaults ON for a beta account (decision 11, |
| 5 | // gateway/src/doorbell.rs:333): with push declined it is the only thing a closed |
| 6 | // tab ever hears. Three surfaces told people they could turn it off -- |
| 7 | // www/guide/legal/privacy.html, landing/privacy.html, and the doorbell email's |
| 8 | // own body, which names "Settings" -- while `?op=doorbell` had no caller |
| 9 | // anywhere in www/ and no i18n string existed for the notice decision 11 asks |
| 10 | // for. Beta accounts were emailed by default, told nothing, and pointed at a |
| 11 | // switch that was not there. |
| 12 | // |
| 13 | // FIVE PROPERTIES, and each one is a way the switch could exist and still be a |
| 14 | // lie: |
| 15 | // |
| 16 | // 1. IT IS REACHABLE. Not "the element is in the DOM": measured with |
| 17 | // `getBoundingClientRect()` and required to have real area on screen after |
| 18 | // pressing the cog a person presses. An absent element reports itself to a |
| 19 | // locator as hidden, so everything here is COUNTED before it is asserted. |
| 20 | // 2. IT READS BEFORE IT DRAWS. The row must ask `?view=doorbell` and paint the |
| 21 | // answer -- never paint a guess. A switch that showed "off" while the |
| 22 | // answer was unknown would tell somebody no mail is being sent when it is. |
| 23 | // 3. THE REACH IS DRAWN AS WELL AS THE STATE. "On" and "will ring" are |
| 24 | // different answers, which is why the gateway sends both. An account with |
| 25 | // no address on file has the second without the first, and a screen showing |
| 26 | // only the switch would be lying to the one person who could fix it. |
| 27 | // 4. PRESSING IT WRITES, AND DRAWS WHAT CAME BACK. `?op=doorbell` with the |
| 28 | // opposite of the state on screen, and the row then shows the SERVER's |
| 29 | // answer rather than the request -- a write that did not land must not |
| 30 | // leave the switch showing the state it failed to reach. |
| 31 | // 5. THE NOTICE COMES FIRST, ONCE, AND SAYS HOW TO STOP IT. Only where the |
| 32 | // default is in force and a bell could actually ring; never twice; and it |
| 33 | // changes nothing on its own, because a person who taps past a modal has |
| 34 | // not decided anything. |
| 35 | // |
| 36 | // No gateway: `/api/post` is routed in the browser, so what is measured is the |
| 37 | // CLIENT -- which request it makes, when, and what it puts on screen. The |
| 38 | // gateway's own half has its tests in gateway/src/doorbell.rs. |
| 39 | // |
| 40 | // node dev/verify_doorbell.mjs |
| 41 | // node dev/verify_doorbell.mjs --break noread # 2: the row paints a guess |
| 42 | // node dev/verify_doorbell.mjs --break noreach # 3: the reach is dropped |
| 43 | // node dev/verify_doorbell.mjs --break twice # 5: the notice repeats |
| 44 | |
| 45 | import { open, shot, errors } from './harness.mjs'; |
| 46 | |
| 47 | const BREAK = (process.argv.indexOf('--break') >= 0) |
| 48 | ? process.argv[process.argv.indexOf('--break') + 1] : ''; |
| 49 | |
| 50 | /// A deliberate damage, applied to the live page rather than to a served file: |
| 51 | /// each replaces one behaviour of the row with the mistake the check opposite it |
| 52 | /// is there to catch. A break that produces a GREEN run means that check is |
| 53 | /// checking nothing, and the run says so and exits 1. |
| 54 | const BREAKS = { |
| 55 | // 2. Paint without asking: the row decides it is off and never reads. |
| 56 | noread: () => { window.DaimondPost.doorbell = async () => |
| 57 | ({ ok: true, on: false, set: true, reach: 'declined', why: '' }); }, |
| 58 | // 3. Drop the reach, keeping the switch. This is the exact shape the audit |
| 59 | // warned about: "on" drawn for an account that cannot be rung. |
| 60 | noreach: () => { |
| 61 | const real = window.DaimondPost.doorbell; |
| 62 | window.DaimondPost.doorbell = async () => { |
| 63 | const r = await real(); |
| 64 | if (r && r.ok) { r.reach = 'ready'; r.why = ''; } |
| 65 | return r; |
| 66 | }; |
| 67 | }, |
| 68 | // 5. Never remember that the notice was shown, so it comes back every time. |
| 69 | // |
| 70 | // The shim goes on the INSTANCE, not on `Storage.prototype`: the app holds |
| 71 | // `localStorage` directly. And it swallows only this one key -- a shim that |
| 72 | // broke every write would fail the run for reasons that have nothing to do |
| 73 | // with the notice, which is a break passing by breaking something else. |
| 74 | twice: () => { |
| 75 | const real = localStorage.setItem.bind(localStorage); |
| 76 | localStorage.setItem = function (k, v) { |
| 77 | if (k === 'daimond-doorbell-told') return; |
| 78 | return real(k, v); |
| 79 | }; |
| 80 | }, |
| 81 | }; |
| 82 | if (BREAK && !BREAKS[BREAK]) { |
| 83 | console.log(`no such break '${BREAK}'; have: ${Object.keys(BREAKS).join(', ')}`); |
| 84 | process.exit(2); |
| 85 | } |
| 86 | |
| 87 | const ok = [], bad = []; |
| 88 | function check(name, pass, detail) { |
| 89 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 90 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 91 | } |
| 92 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 93 | |
| 94 | /// Real area, inside the viewport. Not an ancestry check and not `hidden`: |
| 95 | /// a row can be unhidden and still be nowhere a person could press it. |
| 96 | const onScreen = (page, sel) => page.evaluate((s) => { |
| 97 | const el = document.querySelector(s); |
| 98 | if (!el) return { found: false, w: 0, h: 0 }; |
| 99 | const r = el.getBoundingClientRect(); |
| 100 | return { |
| 101 | found: true, |
| 102 | w: Math.round(r.width), h: Math.round(r.height), |
| 103 | inView: r.top < innerHeight && r.bottom > 0 && r.left < innerWidth && r.right > 0, |
| 104 | text: (el.textContent || '').trim(), |
| 105 | }; |
| 106 | }, sel); |
| 107 | |
| 108 | const s = await open({ name: 'doorbell', connect: false }); |
| 109 | const { page } = s; |
| 110 | |
| 111 | // ── The relay, in the browser ──────────────────────────────── |
| 112 | // |
| 113 | // `state` is what the gateway would hold. The route reads it at each request |
| 114 | // rather than closing over a copy, so a write really changes what the next read |
| 115 | // answers -- otherwise property 4 would pass against a fixture that cannot move. |
| 116 | const server = { on: true, set: false, reach: 'ready', writes: [], reads: 0, fail: false }; |
| 117 | |
| 118 | await page.route('**/api/post**', async (route) => { |
| 119 | const req = route.request(); |
| 120 | const url = new URL(req.url()); |
| 121 | const answer = () => ({ |
| 122 | ok: true, on: server.on, set: server.set, reach: server.reach, |
| 123 | why: server.reach === 'no_address' |
| 124 | ? 'No address on file, so nothing can be sent. This waits until they next open Daimond.' |
| 125 | : 'A doorbell will ring for this account.', |
| 126 | last_ts: 0, status: '', reason: '', attempts: 0, |
| 127 | }); |
| 128 | if (req.method() === 'GET' && url.searchParams.get('view') === 'doorbell') { |
| 129 | server.reads += 1; |
| 130 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 131 | body: JSON.stringify(answer()) }); |
| 132 | } |
| 133 | if (req.method() === 'POST' && url.searchParams.get('op') === 'doorbell') { |
| 134 | const body = JSON.parse(req.postData() || '{}'); |
| 135 | server.writes.push(body); |
| 136 | if (server.fail) { |
| 137 | return route.fulfill({ status: 500, contentType: 'application/json', |
| 138 | body: JSON.stringify({ ok: false }) }); |
| 139 | } |
| 140 | server.on = !!body.on; |
| 141 | server.set = true; |
| 142 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 143 | body: JSON.stringify(answer()) }); |
| 144 | } |
| 145 | // Anything else on this path is not this file's business. |
| 146 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 147 | body: JSON.stringify({ ok: true, seq: 0, rows: [], more: false }) }); |
| 148 | }); |
| 149 | |
| 150 | /// The session the row needs. There is no gateway here, so `authed` is false and |
| 151 | /// the row would correctly hide itself -- which would make every check below |
| 152 | /// pass by measuring nothing. Faked at `DaimondGateway.state`, the one door the |
| 153 | /// row reads, and nowhere deeper. |
| 154 | async function authed() { |
| 155 | await page.evaluate(() => { |
| 156 | const st = window.DaimondGateway.state; |
| 157 | window.DaimondGateway.state = () => Object.assign({}, st(), { authed: true }); |
| 158 | }); |
| 159 | } |
| 160 | |
| 161 | try { |
| 162 | |
| 163 | // ── 5a. The notice, which must come before anything else ───── |
| 164 | // |
| 165 | // FIRST, because "before the first email could go" is the whole property and a |
| 166 | // check that ran it after opening Settings would be measuring a notice the |
| 167 | // person had already been given the switch for. |
| 168 | |
| 169 | await authed(); |
| 170 | if (BREAK) await page.evaluate(BREAKS[BREAK]); |
| 171 | const toldBefore = await page.evaluate(() => localStorage.getItem('daimond-doorbell-told')); |
| 172 | check('nothing has been said about the doorbell yet', !toldBefore, String(toldBefore)); |
| 173 | |
| 174 | await page.evaluate(() => window.dispatchEvent(new Event('daimond:authed'))); |
| 175 | await sleep(900); |
| 176 | |
| 177 | const notice = await onScreen(page, '.dlg-card, .dialog-card, [role="dialog"] .dlg-msg, #dlg-msg'); |
| 178 | const noticeAll = await page.evaluate(() => { |
| 179 | // COUNTED, not asked whether it is hidden: a dialog that was never built |
| 180 | // reports itself to a locator exactly as one that is hidden does. |
| 181 | const cards = Array.from(document.querySelectorAll('[role="dialog"], .dlg, .dialog')); |
| 182 | const live = cards.filter(c => { |
| 183 | const r = c.getBoundingClientRect(); |
| 184 | return r.width > 40 && r.height > 40; |
| 185 | }); |
| 186 | return { cards: cards.length, live: live.length, |
| 187 | text: live.map(c => (c.textContent || '').replace(/\s+/g, ' ').trim()).join(' | ') }; |
| 188 | }); |
| 189 | check('a notice is on screen before any doorbell could ring', |
| 190 | noticeAll.live >= 1, JSON.stringify({ cards: noticeAll.cards, live: noticeAll.live })); |
| 191 | check('it says what will happen: one email, and no sender or subject', |
| 192 | /one email/i.test(noticeAll.text) && /no sender/i.test(noticeAll.text), |
| 193 | noticeAll.text.slice(0, 160)); |
| 194 | check('and it says how to stop it, naming Settings and the row', |
| 195 | /Settings/i.test(noticeAll.text) && /Email doorbell/i.test(noticeAll.text), |
| 196 | noticeAll.text.slice(0, 160)); |
| 197 | check('and it changed nothing: the account is still on, still unchosen', |
| 198 | server.writes.length === 0 && server.on === true && server.set === false, |
| 199 | JSON.stringify({ writes: server.writes, on: server.on, set: server.set })); |
| 200 | |
| 201 | // Dismiss it the way a person does. |
| 202 | await page.evaluate(() => { |
| 203 | const btn = Array.from(document.querySelectorAll('[role="dialog"] button, .dlg button')) |
| 204 | .filter(b => b.offsetParent !== null)[0]; |
| 205 | if (btn) btn.click(); |
| 206 | }); |
| 207 | await sleep(400); |
| 208 | |
| 209 | // ── 5b. Once. ─────────────────────────────────────────────── |
| 210 | const before = noticeAll.live; |
| 211 | await page.evaluate(() => window.dispatchEvent(new Event('daimond:authed'))); |
| 212 | await sleep(900); |
| 213 | const again = await page.evaluate(() => Array.from( |
| 214 | document.querySelectorAll('[role="dialog"], .dlg, .dialog')) |
| 215 | .filter(c => { const r = c.getBoundingClientRect(); return r.width > 40 && r.height > 40; }) |
| 216 | .length); |
| 217 | check('and it is said once, not at every session', before >= 1 && again === 0, |
| 218 | JSON.stringify({ before, again })); |
| 219 | |
| 220 | // ── 1. The control is reachable, by pressing what a person presses ── |
| 221 | // |
| 222 | // THE COG, and nothing else. It opens the admin drawer's home view, which is |
| 223 | // where the sync switch lives and where daimond.js:8323 says a control has to |
| 224 | // be if anybody is to find it. A check that reached the row by calling |
| 225 | // `DaimondAdmin.settings()` would be proving the row exists somewhere in the |
| 226 | // app, which is a weaker claim than the one the privacy page makes. |
| 227 | |
| 228 | await page.evaluate(() => { document.getElementById('settings-btn').click(); }); |
| 229 | await sleep(900); |
| 230 | |
| 231 | // COUNTED FIRST. An absent element reports itself to a locator as hidden, so |
| 232 | // asking "is it visible" before asking "is it there" gets the same answer for a |
| 233 | // row that is scrolled away and a row that was never built. |
| 234 | const count = await page.evaluate(() => ({ |
| 235 | btn: document.querySelectorAll('#doorbell-btn').length, |
| 236 | note: document.querySelectorAll('#doorbell-note').length, |
| 237 | reach: document.querySelectorAll('#doorbell-reach').length, |
| 238 | })); |
| 239 | check('the drawer the cog opens has exactly one doorbell row', |
| 240 | count.btn === 1 && count.note === 1 && count.reach === 1, JSON.stringify(count)); |
| 241 | |
| 242 | // The drawer is long, so the row is brought into view the way a finger would. |
| 243 | // The property is that it is ON the page the cog opened and can be scrolled to, |
| 244 | // not that it happened to land in the first screenful. |
| 245 | await page.evaluate(() => { |
| 246 | const el = document.getElementById('doorbell-btn'); |
| 247 | if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center' }); |
| 248 | }); |
| 249 | await sleep(500); |
| 250 | |
| 251 | const btn = await onScreen(page, '#doorbell-btn'); |
| 252 | check('and the switch has real area on screen, where a hand could reach it', |
| 253 | btn.found && btn.w > 20 && btn.h > 10 && btn.inView, JSON.stringify(btn)); |
| 254 | check('and it is named for what it is', /doorbell/i.test(btn.text), btn.text); |
| 255 | |
| 256 | // ── 2. It read before it drew ─────────────────────────────── |
| 257 | |
| 258 | check('the row asked the gateway what the state is', server.reads >= 1, |
| 259 | 'reads: ' + server.reads); |
| 260 | check('and drew the state the server sent: on, so it offers OFF', |
| 261 | /turn the email doorbell off/i.test(btn.text), btn.text); |
| 262 | |
| 263 | const note1 = await onScreen(page, '#doorbell-note'); |
| 264 | check('the default is drawn AS a default, not as somebody\'s choice', |
| 265 | /default for a beta account/i.test(note1.text), note1.text.slice(0, 200)); |
| 266 | const reach1 = await onScreen(page, '#doorbell-reach'); |
| 267 | check('and with a bell that CAN ring, the reach line says nothing twice', |
| 268 | reach1.found && reach1.text === '', JSON.stringify(reach1).slice(0, 160)); |
| 269 | |
| 270 | // ── 3. The reach, which is not the switch ─────────────────── |
| 271 | // |
| 272 | // The account loses its address; the switch stays exactly where it was. The |
| 273 | // switch must not move, and the row must now say a bell cannot ring. |
| 274 | |
| 275 | server.reach = 'no_address'; |
| 276 | // Closed and reopened, which is what a person does and what re-reads the state. |
| 277 | await page.evaluate(() => { document.getElementById('settings-btn').click(); }); |
| 278 | await sleep(300); |
| 279 | await page.evaluate(() => { window.DaimondPost.doorbell().then(() => {}); }); |
| 280 | await page.evaluate(() => { document.getElementById('settings-btn').click(); }); |
| 281 | await sleep(900); |
| 282 | await page.evaluate(() => { |
| 283 | const el = document.getElementById('doorbell-btn'); |
| 284 | if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center' }); |
| 285 | }); |
| 286 | await sleep(400); |
| 287 | |
| 288 | const reachBtn = await onScreen(page, '#doorbell-btn'); |
| 289 | const reachLine = await onScreen(page, '#doorbell-reach'); |
| 290 | check('the switch has not moved: it is still on, and still offers OFF', |
| 291 | /turn the email doorbell off/i.test(reachBtn.text), reachBtn.text); |
| 292 | check('and the row says a bell CANNOT ring, which the switch alone never would', |
| 293 | reachLine.found && /no email address/i.test(reachLine.text), |
| 294 | JSON.stringify(reachLine).slice(0, 220)); |
| 295 | |
| 296 | // ── 4. Pressing it writes, and draws what came back ───────── |
| 297 | |
| 298 | server.reach = 'ready'; |
| 299 | const writesBefore = server.writes.length; |
| 300 | await page.evaluate(() => { const b = document.getElementById('doorbell-btn'); if (b) b.click(); }); |
| 301 | await sleep(900); |
| 302 | |
| 303 | check('pressing it made exactly one write', server.writes.length === writesBefore + 1, |
| 304 | JSON.stringify(server.writes)); |
| 305 | check('and it asked for the OPPOSITE of what was on screen', |
| 306 | server.writes[server.writes.length - 1] && |
| 307 | server.writes[server.writes.length - 1].on === false, |
| 308 | JSON.stringify(server.writes[server.writes.length - 1])); |
| 309 | |
| 310 | const after = await onScreen(page, '#doorbell-btn'); |
| 311 | check('and the row now offers to turn it back ON, which is the server\'s answer', |
| 312 | /turn the email doorbell on/i.test(after.text), after.text); |
| 313 | const note2 = await onScreen(page, '#doorbell-note'); |
| 314 | check('and says plainly that no email will be sent', |
| 315 | /no email will be sent/i.test(note2.text), note2.text.slice(0, 160)); |
| 316 | check('and no longer calls it a default, because it is now a choice', |
| 317 | !/default for a beta account/i.test(note2.text), note2.text.slice(0, 160)); |
| 318 | |
| 319 | // A write that does not land must not leave the switch showing the state it |
| 320 | // failed to reach. This is the check that a fixture which cannot fail would miss. |
| 321 | server.fail = true; |
| 322 | const writesBeforeFail = server.writes.length; |
| 323 | await page.evaluate(() => { const b = document.getElementById('doorbell-btn'); if (b) b.click(); }); |
| 324 | await sleep(900); |
| 325 | const afterFail = await onScreen(page, '#doorbell-btn'); |
| 326 | check('a refused write was attempted', server.writes.length === writesBeforeFail + 1, |
| 327 | String(server.writes.length)); |
| 328 | check('and the switch still shows OFF, the state the server is actually in', |
| 329 | /turn the email doorbell on/i.test(afterFail.text), afterFail.text); |
| 330 | const note3 = await onScreen(page, '#doorbell-note'); |
| 331 | check('and the row says the save did not take', /did not save/i.test(note3.text), |
| 332 | note3.text.slice(0, 160)); |
| 333 | server.fail = false; |
| 334 | |
| 335 | } finally { |
| 336 | await shot(s, 'doorbell' + (BREAK ? '-' + BREAK : '')); |
| 337 | const errs = errors(s).filter(e => !/Failed to load resource|status of 4\d\d/.test(e)); |
| 338 | check('no console errors along the way', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 339 | await s.close(); |
| 340 | } |
| 341 | |
| 342 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 343 | if (bad.length) { |
| 344 | console.log('failed: ' + bad.join('; ')); |
| 345 | process.exit(1); |
| 346 | } |
| 347 | if (BREAK) { |
| 348 | console.log(`\nbreak '${BREAK}' produced a GREEN run, which means the check it is ` |
| 349 | + 'aimed at is not checking anything.'); |
| 350 | process.exit(1); |
| 351 | } |