oxedyne/daimond/dev/verify_syncviews.mjs
13.4 KiB, 1 run
created by r2519314175:719, 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_syncviews.mjs — two views of one account, and whether they agree. |
| 2 | // |
| 3 | // Sync converging is not the same question as a PERSON seeing it converge. Every |
| 4 | // check in dev/verify_sync.mjs either drives the engine by hand or leans on the |
| 5 | // wake channel, and the wake channel is a WebSocket — or a parked request — |
| 6 | // through whatever front door the account is reached by. Where that door will not |
| 7 | // carry one, sync.js turns the channel off for the life of the page, and what is |
| 8 | // left is triggers that all describe something happening HERE. A window nobody is |
| 9 | // typing at has none of them. |
| 10 | // |
| 11 | // That was reported twice, from a real account, as two different faults: |
| 12 | // |
| 13 | // * turns taken in one desktop browser did not appear in the other, and |
| 14 | // * the token cost tallies in the rail's footer showed two different figures |
| 15 | // in two views of the same account. |
| 16 | // |
| 17 | // Both are the one thing: the reading device never asked. So this file runs the |
| 18 | // second device with the channel deliberately shut — the park is answered as a |
| 19 | // front door that dropped the query, which is exactly what sync.js gives up on — |
| 20 | // and then touches it only in the ways a person does. |
| 21 | // |
| 22 | // It needs the dev stack: the app (DAIMOND_PORT), the mock provider, and a |
| 23 | // gateway on DAIMOND_GW_PORT. Sync is Pro-gated, so the account is granted Pro |
| 24 | // the one way the gateway trusts (dev/pro.mjs). |
| 25 | |
| 26 | import { open, chat, signInAs, newChat } from './harness.mjs'; |
| 27 | import { makePagePro } from './pro.mjs'; |
| 28 | import { GW_URL } from './ports.mjs'; |
| 29 | |
| 30 | const ok = [], bad = []; |
| 31 | const check = (name, pass, detail) => { |
| 32 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 33 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 34 | }; |
| 35 | |
| 36 | /// Answer this page's wake parks as a front door that dropped the query. |
| 37 | /// |
| 38 | /// `waited` is how sync.js tells a real park from an ordinary pull served in its |
| 39 | /// place, so an answer without it is the one thing that shuts the channel for |
| 40 | /// good. Plain pulls carry no `ms` and are passed straight through. |
| 41 | const shutTheChannel = (page) => page.route( |
| 42 | (u) => { |
| 43 | try { const x = new URL(u); return x.pathname === '/api/sync' && x.searchParams.has('ms'); } |
| 44 | catch (e) { return false; } |
| 45 | }, |
| 46 | (r) => r.fulfill({ |
| 47 | status: 200, contentType: 'application/json', |
| 48 | body: JSON.stringify({ ok: true, present: false }), |
| 49 | })); |
| 50 | |
| 51 | /// Every chat this device's store holds, id → turns in it. |
| 52 | const census = (pg) => pg.evaluate(() => { |
| 53 | const out = {}; |
| 54 | window.DaimondCore.chatStore().stored().forEach((c) => { out[c.id] = (c.messages || []).length; }); |
| 55 | return out; |
| 56 | }); |
| 57 | const turns = async (pg, id) => (await census(pg))[id] || 0; |
| 58 | |
| 59 | /// The conversation as it is drawn, which is the only copy the user can read. |
| 60 | const onScreen = (pg) => pg.evaluate(() => { |
| 61 | const e = document.getElementById('chat-output'); |
| 62 | return e ? e.innerText : ''; |
| 63 | }); |
| 64 | |
| 65 | /// The rail's footer, as it reads. |
| 66 | /// |
| 67 | /// '(hidden)' while there is nothing to show, and `(nothing yet)` where the row |
| 68 | /// is on screen carrying no figure at all. |
| 69 | /// |
| 70 | /// The second is the one that matters now. The row used to hide itself while |
| 71 | /// every total was zero, so "is it on screen" happened to answer "has anything |
| 72 | /// been spent"; it is permanent since 2026-08-28 (see `updateSpend` in |
| 73 | /// js/daimond.js -- a row that came and went was moving the rail's controls), |
| 74 | /// and the two checks below ask whether a tally TRAVELLED. Two views both |
| 75 | /// reading $0.00 would satisfy an is-it-there test while proving nothing. |
| 76 | const footer = (pg) => pg.evaluate(() => { |
| 77 | const e = document.getElementById('spend-row'); |
| 78 | if (!e || e.style.display === 'none') return '(hidden)'; |
| 79 | const said = e.innerText.replace(/\s+/g, ' ').trim(); |
| 80 | return /[1-9]/.test(said) ? said : '(nothing yet)'; |
| 81 | }); |
| 82 | /// Has this view a tally worth comparing at all? |
| 83 | const tallied = (said) => said !== '(hidden)' && said !== '(nothing yet)'; |
| 84 | |
| 85 | const version = (pg) => pg.evaluate(() => window.DaimondSync.state().version); |
| 86 | const wake = (pg) => pg.evaluate(() => window.DaimondSync.wake()); |
| 87 | |
| 88 | /// Does the mailbox hold a parcel THIS device produced? Read-only: nothing here |
| 89 | /// pushes, so a check that waits on this is waiting on the app's own triggers. |
| 90 | const mailboxIsMine = (pg) => pg.evaluate(async () => { |
| 91 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 92 | const j = await r.json(); |
| 93 | if (!j.present) return false; |
| 94 | try { return (await window.DaimondIdentity.unwrap(j.blob)) === JSON.stringify(await window.DaimondSync.parcel()); } |
| 95 | catch (e) { return false; } |
| 96 | }); |
| 97 | |
| 98 | /// Wait until this device's own work has reached the mailbox, by its own doing. |
| 99 | const sent = async (pg, ms = 25000) => { |
| 100 | const t0 = Date.now(); |
| 101 | while (Date.now() - t0 < ms) { |
| 102 | if (await mailboxIsMine(pg)) return true; |
| 103 | await pg.waitForTimeout(400); |
| 104 | } |
| 105 | return false; |
| 106 | }; |
| 107 | |
| 108 | /// Wait for a chat on `pg` to hold `want` turns, and answer with what it holds. |
| 109 | /// Bounded, and it returns what it last read rather than throwing, so a check |
| 110 | /// fails with the number it actually found. |
| 111 | const turnsSettle = async (pg, id, want, ms) => { |
| 112 | const t0 = Date.now(); |
| 113 | let seen = 0; |
| 114 | do { |
| 115 | seen = await turns(pg, id); |
| 116 | if (seen >= want) return seen; |
| 117 | await pg.waitForTimeout(500); |
| 118 | } while (Date.now() - t0 < ms); |
| 119 | return seen; |
| 120 | }; |
| 121 | |
| 122 | const a = await open({ name: 'views', signIn: true, connect: true, defaults: false }); |
| 123 | let b = null; |
| 124 | try { |
| 125 | await a.page.waitForFunction( |
| 126 | () => !!window.DaimondSync && !!window.DaimondCore && window.DaimondGateway |
| 127 | && DaimondGateway.state().authed, |
| 128 | null, { timeout: 15000 }).catch(() => {}); |
| 129 | const pro = await makePagePro(a.page, new URL('../gateway', import.meta.url).pathname, GW_URL); |
| 130 | check('the account holds Pro, so sync can run at all', pro.pro === true, JSON.stringify(pro)); |
| 131 | |
| 132 | // Plain pulls this device makes of its own accord, so check (5) can say |
| 133 | // whether a working channel was turned into a poll. |
| 134 | let aPulls = 0; |
| 135 | a.page.on('request', (r) => { |
| 136 | try { |
| 137 | const u = new URL(r.url()); |
| 138 | if (u.pathname === '/api/sync' && r.method() === 'GET' && !u.searchParams.has('ms')) aPulls++; |
| 139 | } catch (e) { /* not a URL this counts */ } |
| 140 | }); |
| 141 | |
| 142 | const chatId = await newChat(a); |
| 143 | await chat(a, 'ALPHA in the first browser'); |
| 144 | check('the first browser sends its own work without being asked', await sent(a.page)); |
| 145 | |
| 146 | // ── The second view: a browser of its own, with no channel ───────── |
| 147 | b = await open({ name: 'viewsmate', signIn: false, connect: false }); |
| 148 | await shutTheChannel(b.page); |
| 149 | await b.page.waitForFunction(() => !!window.DaimondPairing, null, { timeout: 15000 }).catch(() => {}); |
| 150 | const code = await a.page.evaluate(() => DaimondPairing.create()); |
| 151 | await b.page.evaluate((c) => DaimondPairing.redeem(c), code.code); |
| 152 | await b.page.reload({ waitUntil: 'domcontentloaded' }); |
| 153 | await signInAs(b, 'views'); |
| 154 | await b.page.waitForFunction( |
| 155 | () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed, |
| 156 | null, { timeout: 20000 }).catch(() => {}); |
| 157 | await turnsSettle(b.page, chatId, 3, 15000); |
| 158 | // The channel does not decide it is out until its first park comes back |
| 159 | // unparked, which is a request in flight at this moment. Waited for, or the |
| 160 | // check below reads the channel mid-probe and reports a mode of ''. |
| 161 | await b.page.waitForFunction( |
| 162 | () => window.DaimondSync.wake().mode === 'off', null, { timeout: 20000 }).catch(() => {}); |
| 163 | const shut = await wake(b.page); |
| 164 | check('the second view is running with no wake channel, as a shut front door leaves it', |
| 165 | shut.mode === 'off' && shut.open === false, JSON.stringify(shut)); |
| 166 | check('and it has the first browser’s work to begin with', |
| 167 | (await turns(b.page, chatId)) >= 3, String(await turns(b.page, chatId))); |
| 168 | |
| 169 | // ── (1) Nobody touches it ────────────────────────────────────────── |
| 170 | // The whole complaint. The second window is open on a desk, focused, and |
| 171 | // its owner is typing in the other browser. No focus event, no turn ending, |
| 172 | // nothing renamed: the engine's own catch-up is the only thing that can |
| 173 | // bring it level, and until it existed nothing did. |
| 174 | const had = await turns(b.page, chatId); |
| 175 | await chat(a, 'BETA in the first browser'); |
| 176 | check('the first browser sends the second turn too', await sent(a.page)); |
| 177 | const learned = await turnsSettle(b.page, chatId, had + 3, 45000); |
| 178 | check('a second view nobody touches catches up on its own', |
| 179 | learned >= had + 3, learned + ' turns, was ' + had); |
| 180 | // And the footer with it, WITHOUT a refresh. This is the second report in |
| 181 | // its own words: the tallies are built from the ledger, the ledger travels |
| 182 | // in the same parcel as the transcripts, so a view that is behind on one is |
| 183 | // behind on the other. Measured here rather than after the refresh below, |
| 184 | // where a pull is guaranteed and the check would prove nothing. |
| 185 | const fa1 = await footer(a.page); |
| 186 | let fb1 = await footer(b.page); |
| 187 | for (let i = 0; i < 30 && fb1 !== fa1; i++) { await b.page.waitForTimeout(500); fb1 = await footer(b.page); } |
| 188 | check('and its token cost tally follows, with no refresh and nothing touched', |
| 189 | fb1 === fa1 && tallied(fa1), JSON.stringify(fa1) + ' vs ' + JSON.stringify(fb1)); |
| 190 | |
| 191 | // ── (2) Coming back to the window ────────────────────────────────── |
| 192 | // The trigger a person actually uses, and the one they use OFTEN: working |
| 193 | // in one browser and glancing at the other is a thing done all afternoon. |
| 194 | // So the return is made TWICE — once to arm whatever throttle stands behind |
| 195 | // it, and once a few seconds later with news waiting. The second is the one |
| 196 | // that matters, and it is the one a person makes. |
| 197 | await b.page.evaluate(() => { |
| 198 | window.dispatchEvent(new Event('focus')); |
| 199 | document.dispatchEvent(new Event('visibilitychange')); |
| 200 | }); |
| 201 | await b.page.waitForTimeout(3000); |
| 202 | await chat(a, 'GAMMA in the first browser'); |
| 203 | check('the first browser sends the third turn too', await sent(a.page)); |
| 204 | const hadG = await turns(b.page, chatId); |
| 205 | const moved = await b.page.evaluate(async () => { |
| 206 | const v0 = window.DaimondSync.state().version; |
| 207 | window.dispatchEvent(new Event('focus')); |
| 208 | document.dispatchEvent(new Event('visibilitychange')); |
| 209 | await new Promise((r) => setTimeout(r, 6000)); |
| 210 | return { v0, v1: window.DaimondSync.state().version }; |
| 211 | }); |
| 212 | const gotG = await turns(b.page, chatId); |
| 213 | check('coming back to the second window pulls, seconds after the last time', |
| 214 | gotG >= hadG + 3, |
| 215 | 'version ' + moved.v0 + ' -> ' + moved.v1 + ', ' + gotG + ' turns, was ' + hadG); |
| 216 | |
| 217 | // ── (3) The hard refresh, and what is ON SCREEN ──────────────────── |
| 218 | // The store agreeing is not the claim; the claim is that the person reading |
| 219 | // the conversation sees the turns. A refresh is what the reporter reached |
| 220 | // for, so a refresh is what is measured. |
| 221 | await chat(a, 'DELTA in the first browser'); |
| 222 | check('the first browser sends the fourth turn too', await sent(a.page)); |
| 223 | await b.page.reload({ waitUntil: 'domcontentloaded' }); |
| 224 | await shutTheChannel(b.page); |
| 225 | await signInAs(b, 'views'); |
| 226 | await b.page.waitForFunction( |
| 227 | () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed, |
| 228 | null, { timeout: 20000 }).catch(() => {}); |
| 229 | await turnsSettle(b.page, chatId, 12, 25000); |
| 230 | let drawn = ''; |
| 231 | for (let i = 0; i < 24; i++) { |
| 232 | drawn = await onScreen(b.page); |
| 233 | if (/DELTA/.test(drawn)) break; |
| 234 | await b.page.waitForTimeout(500); |
| 235 | } |
| 236 | check('a refresh brings the second view level, on the screen and not merely in the store', |
| 237 | /ALPHA/.test(drawn) && /DELTA/.test(drawn), JSON.stringify(drawn.slice(-90))); |
| 238 | |
| 239 | // ── (4) The two footers ──────────────────────────────────────────── |
| 240 | // The tallies are built from the ledger, and the ledger travels in the same |
| 241 | // parcel as the transcripts. So a view that is behind on one is behind on |
| 242 | // the other, and the report of two different figures is this same fault |
| 243 | // wearing the footer's clothes. |
| 244 | const fa = await footer(a.page); |
| 245 | let fb = await footer(b.page); |
| 246 | for (let i = 0; i < 24 && fb !== fa; i++) { await b.page.waitForTimeout(500); fb = await footer(b.page); } |
| 247 | check('the two views show the same token cost tally', fa === fb && tallied(fa), |
| 248 | JSON.stringify(fa) + ' vs ' + JSON.stringify(fb)); |
| 249 | |
| 250 | // ── (5) And a device that CAN be told is not made to poll ─────────── |
| 251 | // The catch-up above must cost nothing on a device the gateway can reach. |
| 252 | // A pull every so often on every open tab is a real bill on a real account, |
| 253 | // so the guard is here rather than in a comment. The wake channel's own |
| 254 | // pulls are subtracted: those are the gateway saying there is something to |
| 255 | // fetch, which is the opposite of a poll. |
| 256 | const chan = await wake(a.page); |
| 257 | const was = aPulls; |
| 258 | await a.page.waitForTimeout(35000); |
| 259 | const after = await wake(a.page); |
| 260 | const unprompted = (aPulls - was) - (after.wakes - chan.wakes); |
| 261 | check('a device whose channel is carrying does not start polling for itself', |
| 262 | chan.open === true && unprompted <= 0, |
| 263 | 'channel ' + chan.mode + (chan.open ? '/open' : '/shut') + ', ' + (aPulls - was) |
| 264 | + ' pulls in 35s of which ' + (after.wakes - chan.wakes) + ' were the gateway asking'); |
| 265 | } catch (e) { |
| 266 | check('the run finished', false, String((e && e.stack) || e)); |
| 267 | } finally { |
| 268 | if (b) await b.close(); |
| 269 | await a.close(); |
| 270 | } |
| 271 | |
| 272 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 273 | if (bad.length) { for (const l of bad) console.log(' FAILED: ' + l); } |
| 274 | process.exit(bad.length ? 1 : 0); |