oxedyne/daimond/dev/verify_look.mjs
16.7 KiB, 1 run
created by r2519314175:515, 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_look.mjs — a device that joins an account by any route arrives wearing |
| 2 | // the account's look, once, and never argues about it afterwards. |
| 3 | // |
| 4 | // The pairing bundle carried theme, skin, language, currency, reading size and |
| 5 | // layout to a device linked by a QR code, and `applyLook` had exactly one caller. |
| 6 | // A device brought across by a passkey — which the catalogue advertises as |
| 7 | // bringing the account over "without a pairing code or a passphrase" — or one |
| 8 | // that simply holds the identity and is unlocked by typing it, got none of it. A |
| 9 | // user who works in French on a 125% reading size signed in on a new machine and |
| 10 | // was met by an English app at 100%. |
| 11 | // |
| 12 | // So the look now rides the sync parcel, which is the only channel every route |
| 13 | // ends at. Two things have to be true of it at once, and they pull in opposite |
| 14 | // directions: |
| 15 | // |
| 16 | // IT MUST ARRIVE. A device that has never had a look of its own puts on the |
| 17 | // account's, live, through the same services the appearance menu calls. |
| 18 | // |
| 19 | // IT MUST NOT LOOP. The parcel is a FIXED POINT or two devices push at each |
| 20 | // other for ever — reported from a freshly paired iPhone as "the syncing |
| 21 | // seemed to go into an endless loop". A device that has a look of its own |
| 22 | // RECORDS the account's without wearing it, and reports that same record back |
| 23 | // unchanged. Nothing on the receiving path may stamp. |
| 24 | // |
| 25 | // The second device here is a genuinely separate browser profile that adopts the |
| 26 | // identity the way `adoptWithPasskey` does — `importBundle` of the exported |
| 27 | // bundle, then an ordinary passphrase unlock — and the file asserts that it |
| 28 | // never went near /api/pair. That is the route the bug was about. |
| 29 | // |
| 30 | // node dev/verify_look.mjs |
| 31 | // |
| 32 | // Needs dev/serve.mjs (DAIMOND_PORT) AND the gateway on :9002. |
| 33 | import { open, signInAs } from './harness.mjs'; |
| 34 | import { makePagePro } from './pro.mjs'; |
| 35 | import path from 'node:path'; |
| 36 | import { fileURLToPath } from 'node:url'; |
| 37 | |
| 38 | const ok = [], bad = []; |
| 39 | const check = (name, pass, detail) => { |
| 40 | (pass ? ok : bad).push(name); |
| 41 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 42 | }; |
| 43 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 44 | |
| 45 | /// Push until THIS device's own parcel is what the mailbox holds. See |
| 46 | /// verify_sync, where the same helper explains why the version advancing is not |
| 47 | /// evidence that this device's work went anywhere. |
| 48 | async function pushLanded(pg) { |
| 49 | return await pg.evaluate(async (ms) => { |
| 50 | const mailbox = async () => { |
| 51 | const res = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 52 | const j = await res.json(); |
| 53 | if (!j.present) return null; |
| 54 | try { return await window.DaimondIdentity.unwrap(j.blob); } catch (e) { return null; } |
| 55 | }; |
| 56 | const mine = new Set(); |
| 57 | const t0 = Date.now(); |
| 58 | while (Date.now() - t0 < ms) { |
| 59 | await window.DaimondSync.push(); |
| 60 | mine.add(JSON.stringify(await window.DaimondSync.parcel())); |
| 61 | const held = await mailbox(); |
| 62 | if (held !== null && mine.has(held)) return true; |
| 63 | await new Promise(r => setTimeout(r, 200)); |
| 64 | } |
| 65 | return false; |
| 66 | }, 25000); |
| 67 | } |
| 68 | |
| 69 | /// How a device looks, read from the places that decide it rather than from the |
| 70 | /// keys that were written. The DOM attributes are what the palette and the skin |
| 71 | /// actually hang off; `--fs-scale` is what the type is sized by; and the sample |
| 72 | /// string is the app speaking, which is the only proof a language arrived. |
| 73 | const looksLike = (pg) => pg.evaluate(() => ({ |
| 74 | theme: document.documentElement.getAttribute('data-theme'), |
| 75 | tone: document.documentElement.getAttribute('data-tone'), |
| 76 | skin: document.documentElement.getAttribute('data-skin'), |
| 77 | lang: document.documentElement.lang, |
| 78 | scale: getComputedStyle(document.documentElement).getPropertyValue('--fs-scale').trim(), |
| 79 | locale: window.DaimondI18n ? DaimondI18n.locale() : '', |
| 80 | currency: window.DaimondI18n ? DaimondI18n.currency() : '', |
| 81 | sample: window.DaimondI18n ? DaimondI18n.t('sync.synced') : '', |
| 82 | layout: localStorage.getItem('daimond-layout') || '', |
| 83 | stored: { |
| 84 | theme: localStorage.getItem('daimond-theme'), |
| 85 | skin: localStorage.getItem('daimond-skin'), |
| 86 | locale: localStorage.getItem('daimond-locale'), |
| 87 | currency: localStorage.getItem('daimond-currency'), |
| 88 | scale: localStorage.getItem('daimond-fs-scale'), |
| 89 | }, |
| 90 | })); |
| 91 | |
| 92 | const GWDIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'gateway'); |
| 93 | const s = await open({ name: 'look', signIn: true, connect: false, defaults: false }); |
| 94 | const { page } = s; |
| 95 | let child = null; |
| 96 | |
| 97 | try { |
| 98 | await page.waitForFunction( |
| 99 | () => !!window.DaimondSync && !!window.DaimondGateway && DaimondGateway.state().authed, |
| 100 | null, { timeout: 20000 }).catch(() => {}); |
| 101 | const lic = await makePagePro(page, GWDIR); |
| 102 | check('the account holds Pro, so a parcel can travel at all', |
| 103 | lic.pro === true, `webhook ${lic.status}, pro=${lic.pro}`); |
| 104 | |
| 105 | // ── (1) The first device is dressed by its user ─────────────────── |
| 106 | // Through the app's own services, which is what a person pressing the |
| 107 | // controls does. A test that wrote the keys directly would be asserting |
| 108 | // against a state the app never produces. |
| 109 | await page.evaluate(async () => { |
| 110 | DaimondTheme.set('amber'); |
| 111 | DaimondSkin.set('sharp'); |
| 112 | DaimondI18n.setCurrency('EUR'); |
| 113 | DaimondWorkspace.setScale(1.3); |
| 114 | await DaimondI18n.setLocale('fr'); |
| 115 | }); |
| 116 | await sleep(600); |
| 117 | const dressed = await looksLike(page); |
| 118 | check('the first device is wearing a look of its own', |
| 119 | dressed.theme === 'amber' && dressed.skin === 'sharp' && dressed.locale === 'fr' |
| 120 | && dressed.currency === 'EUR' && dressed.stored.scale === '1.3', |
| 121 | JSON.stringify(dressed).slice(0, 160)); |
| 122 | |
| 123 | check('the first device gets its parcel into the mailbox', await pushLanded(page)); |
| 124 | const sent = await page.evaluate(() => window.DaimondSync.parcel()); |
| 125 | check('the parcel carries the look as a stamped record', |
| 126 | !!(sent.look && sent.look.t > 0 && sent.look.v |
| 127 | && sent.look.v['daimond-theme'] === 'amber'), |
| 128 | JSON.stringify(sent.look)); |
| 129 | check('and it carries the five that travel and NOT the layout, which is the screen’s', |
| 130 | !!sent.look && Object.keys(sent.look.v).sort().join(',') |
| 131 | === 'daimond-currency,daimond-fs-scale,daimond-locale,daimond-skin,daimond-theme', |
| 132 | Object.keys((sent.look || {}).v || {}).sort().join(',')); |
| 133 | |
| 134 | // ── (2) A second device, by a route that is NOT a pairing bundle ── |
| 135 | // The identity is transplanted exactly as `adoptWithPasskey` transplants it: |
| 136 | // `importBundle` of the bundle, then an ordinary passphrase unlock. What is |
| 137 | // deliberately absent is `DaimondPairing.redeem`, which is the one caller |
| 138 | // `applyLook` ever had. |
| 139 | child = await open({ name: 'lookmate', signIn: false, connect: false }); |
| 140 | const pairCalls = []; |
| 141 | child.page.on('request', (r) => { if (r.url().includes('/api/pair')) pairCalls.push(r.url()); }); |
| 142 | await child.page.waitForFunction(() => !!window.DaimondIdentity, null, { timeout: 20000 }); |
| 143 | const virgin = await looksLike(child.page); |
| 144 | check('the new device starts on the defaults, in English, looking nothing like it', |
| 145 | virgin.theme !== 'amber' && virgin.skin !== 'sharp' && virgin.locale === 'en', |
| 146 | `${virgin.theme}/${virgin.skin}/${virgin.locale} "${virgin.sample}"`); |
| 147 | |
| 148 | const bundle = await page.evaluate(() => DaimondIdentity.exportBundle()); |
| 149 | const took = await child.page.evaluate(b => DaimondIdentity.importBundle(b), bundle); |
| 150 | check('the new device takes the identity without a pairing code', took === true); |
| 151 | await child.page.reload({ waitUntil: 'domcontentloaded' }); |
| 152 | await signInAs(child, 'look'); |
| 153 | await child.page.waitForFunction( |
| 154 | () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed, |
| 155 | null, { timeout: 20000 }).catch(() => {}); |
| 156 | const same = await child.page.evaluate(() => window.DaimondIdentity.publicKeyB64url()); |
| 157 | const mine = await page.evaluate(() => window.DaimondIdentity.publicKeyB64url()); |
| 158 | check('and it is the SAME account, unlocked with the passphrase', same === mine, same.slice(0, 12)); |
| 159 | check('with no pairing bundle anywhere in it — this is the route that got nothing', |
| 160 | pairCalls.length === 0, pairCalls.join(' | ') || 'no /api/pair request was made'); |
| 161 | // This device's own dock arrangement, as it stands before anything arrives. |
| 162 | // The layout is the one setting that must NOT travel, and "did not change" |
| 163 | // is the exact form of that -- two fresh profiles may well have byte-identical |
| 164 | // default layouts, so comparing the two devices would prove nothing. |
| 165 | const preLook = await looksLike(child.page); |
| 166 | |
| 167 | // ── (3) The look arrives, and is WORN ───────────────────────────── |
| 168 | // Nothing is done to the device but wait: the first pull after signing in is |
| 169 | // what carries it. |
| 170 | const worn = await (async () => { |
| 171 | const t0 = Date.now(); |
| 172 | let seen = null; |
| 173 | while (Date.now() - t0 < 30000) { |
| 174 | seen = await looksLike(child.page); |
| 175 | if (seen.theme === 'amber' && seen.locale === 'fr') return seen; |
| 176 | await sleep(400); |
| 177 | } |
| 178 | return seen; |
| 179 | })(); |
| 180 | check('the new device puts on the account’s palette, with nothing asked of it', |
| 181 | worn.theme === 'amber' && worn.tone === 'dark', `${worn.theme}/${worn.tone}`); |
| 182 | check('and its skin', worn.skin === 'sharp', worn.skin); |
| 183 | check('and it speaks the account’s language — the app itself, not the stored key', |
| 184 | worn.locale === 'fr' && worn.lang === 'fr' && worn.sample !== virgin.sample |
| 185 | && worn.sample === dressed.sample, |
| 186 | `${worn.lang} "${worn.sample}"`); |
| 187 | check('and shows money in the account’s currency', worn.currency === 'EUR', worn.currency); |
| 188 | check('and reads at the account’s size, in the type itself', |
| 189 | worn.stored.scale === '1.3' && parseFloat(worn.scale) === 1.3, |
| 190 | `stored ${worn.stored.scale}, --fs-scale ${worn.scale}`); |
| 191 | check('but NOT the other device’s dock layout — its own arrangement is untouched', |
| 192 | !!worn.layout && worn.layout === preLook.layout, |
| 193 | (worn.layout || '(none)').slice(0, 70)); |
| 194 | |
| 195 | // ── (4) It is worn ONCE ─────────────────────────────────────────── |
| 196 | // The second device now chooses for itself. Whatever the account does after |
| 197 | // that is recorded and not imposed: a phone and a desk may differ. |
| 198 | await child.page.evaluate(() => DaimondTheme.set('forest')); |
| 199 | await sleep(400); |
| 200 | await pushLanded(child.page); |
| 201 | await page.evaluate(() => window.DaimondSync.pull()); |
| 202 | await page.evaluate(() => DaimondTheme.set('midnight')); |
| 203 | await sleep(400); |
| 204 | check('the first device pushes a later look', await pushLanded(page)); |
| 205 | const held = await (async () => { |
| 206 | const t0 = Date.now(); |
| 207 | let seen = null; |
| 208 | while (Date.now() - t0 < 20000) { |
| 209 | await child.page.evaluate(() => window.DaimondSync.pull()); |
| 210 | seen = await child.page.evaluate(async () => ({ |
| 211 | look: (await window.DaimondSync.parcel()).look, |
| 212 | theme: document.documentElement.getAttribute('data-theme'), |
| 213 | })); |
| 214 | if (seen.look && seen.look.v['daimond-theme'] === 'midnight') return seen; |
| 215 | await sleep(500); |
| 216 | } |
| 217 | return seen; |
| 218 | })(); |
| 219 | check('a device that has a look of its own RECORDS the account’s later one', |
| 220 | !!(held.look && held.look.v['daimond-theme'] === 'midnight'), |
| 221 | JSON.stringify(held.look)); |
| 222 | check('and does not put it on — the look arrives once, at first login, and never again', |
| 223 | !!(held.look && held.look.v['daimond-theme'] === 'midnight') && held.theme === 'forest', |
| 224 | `holding ${held.look && held.look.v['daimond-theme']}, wearing ${held.theme}`); |
| 225 | |
| 226 | // ── (4b) And a device that was here before the record was ───────── |
| 227 | // The migration, which is the case that decides whether shipping this is |
| 228 | // safe. Every device in an existing account wakes up one morning with no |
| 229 | // record of its own look and a mailbox that may already hold somebody else's |
| 230 | // -- and if "has this device got a look" were answered by looking at the keys, |
| 231 | // the answer would be no, because they are only there at all thanks to the |
| 232 | // default theme and skin the app writes on every boot. A whole cohort would be |
| 233 | // redressed by whichever device published first. |
| 234 | // |
| 235 | // What is asked instead is whether this device has ever read this account's |
| 236 | // mailbox, which nothing but this device having been here can produce. So: |
| 237 | // wipe what the feature knows about this device, keep the sync cursor, and |
| 238 | // come back. |
| 239 | const cursor = await child.page.evaluate(() => { |
| 240 | localStorage.removeItem('daimond-look'); |
| 241 | localStorage.removeItem('daimond-look-base'); |
| 242 | return localStorage.getItem('daimond-sync-version'); |
| 243 | }); |
| 244 | check('the established device still has the one thing that says it was here', |
| 245 | (cursor | 0) > 0, `sync cursor ${cursor}`); |
| 246 | await child.page.reload({ waitUntil: 'domcontentloaded' }); |
| 247 | await signInAs(child, 'look'); |
| 248 | await child.page.waitForFunction( |
| 249 | () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed, |
| 250 | null, { timeout: 20000 }).catch(() => {}); |
| 251 | const migrated = await (async () => { |
| 252 | const t0 = Date.now(); |
| 253 | let seen = null; |
| 254 | while (Date.now() - t0 < 20000) { |
| 255 | await child.page.evaluate(() => window.DaimondSync.pull()); |
| 256 | seen = await child.page.evaluate(async () => ({ |
| 257 | look: (await window.DaimondSync.parcel()).look, |
| 258 | theme: document.documentElement.getAttribute('data-theme'), |
| 259 | })); |
| 260 | if (seen.look && seen.look.v['daimond-theme']) return seen; |
| 261 | await sleep(500); |
| 262 | } |
| 263 | return seen; |
| 264 | })(); |
| 265 | check('a device that has synced before is NOT redressed by the record arriving', |
| 266 | migrated.theme === 'forest', migrated.theme); |
| 267 | check('and it carries the account’s record all the same', |
| 268 | !!(migrated.look && migrated.look.v['daimond-theme'] === 'midnight'), |
| 269 | JSON.stringify(migrated.look)); |
| 270 | |
| 271 | // ── (5) The fixed point, which is what stops the endless loop ───── |
| 272 | // Measured on the device that DISAGREES with the record it holds, because |
| 273 | // that is the state a restamp would show up in: collect, apply what you just |
| 274 | // collected, collect again. And then apply the OTHER device's parcel, which |
| 275 | // is the cross-device shape the iPhone was caught in. |
| 276 | const stable = await child.page.evaluate(async () => { |
| 277 | const a = JSON.stringify(await window.DaimondSync.parcel()); |
| 278 | await window.DaimondSync.apply(JSON.parse(a)); |
| 279 | const b = JSON.stringify(await window.DaimondSync.parcel()); |
| 280 | return { a, b }; |
| 281 | }); |
| 282 | check('applying its own parcel leaves the next one byte-identical', |
| 283 | stable.a === stable.b, |
| 284 | stable.a === stable.b ? `${stable.a.length} bytes` : 'the parcel moved under an apply'); |
| 285 | const theirs = await page.evaluate(async () => JSON.stringify(await window.DaimondSync.parcel())); |
| 286 | const crossed = await child.page.evaluate(async (p) => { |
| 287 | const before = (await window.DaimondSync.parcel()).look; |
| 288 | await window.DaimondSync.apply(JSON.parse(p)); |
| 289 | const one = (await window.DaimondSync.parcel()).look; |
| 290 | await window.DaimondSync.apply(JSON.parse(p)); |
| 291 | const two = (await window.DaimondSync.parcel()).look; |
| 292 | return { sent: JSON.parse(p).look, before, one, two, |
| 293 | theme: document.documentElement.getAttribute('data-theme') }; |
| 294 | }, theirs); |
| 295 | check('and applying the OTHER device’s parcel gives that record straight back, unstamped', |
| 296 | !!(crossed.sent && crossed.sent.t) && JSON.stringify(crossed.one) === JSON.stringify(crossed.sent), |
| 297 | `sent ${JSON.stringify(crossed.sent)} → would send ${JSON.stringify(crossed.one)}`); |
| 298 | check('twice over, so nothing is drifting a stamp at a time', |
| 299 | !!(crossed.one && crossed.one.t) && JSON.stringify(crossed.one) === JSON.stringify(crossed.two), |
| 300 | `${JSON.stringify(crossed.one)} then ${JSON.stringify(crossed.two)}`); |
| 301 | check('and merging a look it will not wear does not change how it looks', |
| 302 | !!(crossed.sent && crossed.sent.v['daimond-theme'] === 'midnight') && crossed.theme === 'forest', |
| 303 | `merged ${crossed.sent && crossed.sent.v['daimond-theme']}, wearing ${crossed.theme}`); |
| 304 | |
| 305 | // And the same on the first device, which is the other half of the loop. |
| 306 | const stableA = await page.evaluate(async () => { |
| 307 | const a = JSON.stringify(await window.DaimondSync.parcel()); |
| 308 | await window.DaimondSync.apply(JSON.parse(a)); |
| 309 | const b = JSON.stringify(await window.DaimondSync.parcel()); |
| 310 | return a === b; |
| 311 | }); |
| 312 | check('the first device’s parcel is a fixed point too', stableA === true); |
| 313 | |
| 314 | const clean = (errs) => errs.filter(e => |
| 315 | !/favicon|ERR_|Failed to load resource|401|402|409|426|502|Unauthorized/.test(e)); |
| 316 | check('no unexpected console errors on the new device', clean(child.errs).length === 0, |
| 317 | clean(child.errs).slice(0, 3).join(' | ')); |
| 318 | check('no unexpected console errors on the first one', clean(s.errs).length === 0, |
| 319 | clean(s.errs).slice(0, 3).join(' | ')); |
| 320 | } catch (e) { |
| 321 | check('verify_look ran without throwing', false, String(e && e.message || e)); |
| 322 | } finally { |
| 323 | await child?.close?.().catch?.(() => {}); |
| 324 | await s.close?.().catch?.(() => {}); |
| 325 | } |
| 326 | |
| 327 | console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`)); |
| 328 | process.exit(bad.length ? 1 : 0); |