Oregami
Repositories/oxedyne/daimond

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
26import { open, chat, signInAs, newChat } from './harness.mjs';
27import { makePagePro } from './pro.mjs';
28import { GW_URL } from './ports.mjs';
29
30const ok = [], bad = [];
31const 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.
41const 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.
52const census = (pg) => pg.evaluate(() => {
53 const out = {};
54 window.DaimondCore.chatStore().stored().forEach((c) => { out[c.id] = (c.messages || []).length; });
55 return out;
56});
57const 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.
60const 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.
76const 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?
83const tallied = (said) => said !== '(hidden)' && said !== '(nothing yet)';
84
85const version = (pg) => pg.evaluate(() => window.DaimondSync.state().version);
86const 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.
90const 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.
99const 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.
111const 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
122const a = await open({ name: 'views', signIn: true, connect: true, defaults: false });
123let b = null;
124try {
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
272console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
273if (bad.length) { for (const l of bad) console.log(' FAILED: ' + l); }
274process.exit(bad.length ? 1 : 0);