Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_sessionrenew.mjs

25.4 KiB, 1 run

created by r2519314175:671, 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_sessionrenew.mjs — a gateway session that ends mid-sitting is taken
2// again, without a reload; and when it cannot be, the user is told and the
3// device stops hammering the door.
4//
5// THE BUG THIS EXISTS FOR. The gateway's session lives exactly an hour
6// (SESSION_TTL_SECS, gateway/src/handlers/common.rs) and the only thing that
7// ever minted one was `DaimondGateway.bootstrap()`, called once per unlock. So
8// an hour into a sitting every request became a 401, and every path swallowed
9// it: the pull hid the chip, the push logged one console line, the wake channel
10// reconnected on a backoff for ever, and the account dot went on being drawn
11// from `state.authed`, which nothing ever cleared. One real account: session
12// opened 07:27:28, gone at 08:27:30, then four hours and fifty minutes of
13// refused wake upgrades at about two hundred and forty an hour, and seven
14// pushes of the user's work discarded with nothing said anywhere.
15//
16// HOW HONESTLY THIS REPRODUCES IT. The session is ended SERVER-SIDE with a raw
17// POST to /api/auth/logout — not `DaimondGateway.logout()`, which would tell the
18// client. That leaves precisely the production state: a live page that believes
19// it is signed in, holding a cookie that names nothing. Everything below runs
20// against the REAL gateway on :9002 over that state.
21//
22// 1. The client does not notice by itself — asserted, so that a pass below
23// cannot come from the session having quietly survived.
24// 2. A push after the session died lands anyway: the version advances, the
25// work is in the mailbox, and the PAGE WAS NEVER RELOADED (a marker set
26// before the kill is still in the window).
27// 3. One renewal, however many callers were refused at once.
28// 4. When the renewal cannot work, the chip says so — held, with the reason on
29// hover, and no dialog over the app — and the app stops claiming a session
30// it does not have.
31// 5. The wake channel STOPS. That is the free consequence of clearing
32// `state.authed`, and it is measured rather than assumed: no /api/sync
33// request and no new WebSocket after the teardown.
34// 6. And it comes back on its own. A gateway that was down for a minute must
35// not cost the tab the rest of the day, because every trigger that would
36// retry is itself gated on there being a session.
37// 7. The standing refusals keep their order: a parcel that will not fit is
38// true whatever the session is doing, so it is still the thing said first.
39import { open, chat } from './harness.mjs';
40import { makePagePro } from './pro.mjs';
41import { GW_PORT, GW_URL } from './ports.mjs';
42import path from 'node:path';
43import { fileURLToPath } from 'node:url';
44
45const ok = [], bad = [];
46const check = (name, pass, detail) => {
47 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
48 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
49};
50const sleep = ms => new Promise(r => setTimeout(r, ms));
51
52/// Is the gateway answering?
53async function gatewayUp() {
54 try {
55 const r = await fetch(`${GW_URL}/api/health`, { signal: AbortSignal.timeout(2000) });
56 return r.ok;
57 } catch (e) { return false; }
58}
59
60if (!await gatewayUp()) {
61 console.log(`SKIP verify_sessionrenew — no gateway on :${GW_PORT}, this world's own `
62 + `(build it: cd gateway && cargo build --release, then dev/devgw.sh)`);
63 process.exit(0);
64}
65
66const s = await open({ name: 'sessrenew', signIn: true, connect: true });
67const { page } = s;
68
69/// Push, and wait until the parcel has actually LANDED. A single push() that
70/// finds another in flight only reschedules, so awaiting it proves nothing.
71async function pushLanded(pg, ms = 8000) {
72 return await pg.evaluate(async (limit) => {
73 const v0 = window.DaimondSync.state().version;
74 const t0 = Date.now();
75 while (window.DaimondSync.state().version <= v0 && Date.now() - t0 < limit) {
76 await window.DaimondSync.push();
77 await new Promise(r => setTimeout(r, 150));
78 }
79 return window.DaimondSync.state().version > v0;
80 }, ms);
81}
82
83/// End the session on the GATEWAY without telling the client — what an expiry
84/// looks like from inside the page.
85const killSession = () => page.evaluate(async () => {
86 await window.__realFetch('/api/auth/logout', {
87 method: 'POST', credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
88 });
89});
90
91/// A genuine local change, so a push is not skipped as a no-op.
92///
93/// A message-tombstone for an id that never existed. Transcripts moved into
94/// IndexedDB, so the old trick of editing `daimond-chats` in localStorage no
95/// longer changes anything `collectSync()` reads; the tombstone maps are still
96/// localStorage and still travel in the parcel. Inert by construction — it names
97/// nothing — so it moves the parcel and touches no work.
98const bump = tag => page.evaluate((t) => {
99 const k = 'daimond-msgs-deleted';
100 const m = JSON.parse(localStorage.getItem(k) || '{}');
101 m['ghost-' + t] = Date.now();
102 localStorage.setItem(k, JSON.stringify(m));
103 return true;
104}, tag);
105
106const chipOf = () => page.evaluate(() => {
107 const c = document.getElementById('sync-chip');
108 if (!c) return null;
109 return {
110 state: c.dataset.state || '',
111 text: (c.querySelector('.stext') || {}).textContent || '',
112 title: c.title || '',
113 shown: c.style.display !== 'none',
114 };
115});
116
117try {
118 await page.waitForFunction(
119 () => !!window.DaimondSync && !!window.DaimondCore && !!window.DaimondGateway
120 && DaimondGateway.state().authed,
121 null, { timeout: 12000 },
122 ).catch(() => {});
123 check('a gateway session exists to begin with',
124 await page.evaluate(() => DaimondGateway.state().authed));
125
126 // Sync is a Pro capability, so without the licence the gateway answers 402
127 // and there is no 401 to measure.
128 const GWDIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'gateway');
129 const lic = await makePagePro(page, GWDIR);
130 check('the account holds Pro, so sync may run at all', lic.pro === true,
131 `webhook ${lic.status}, pro=${lic.pro}`);
132
133 // Something real to lose, and a first parcel in the mailbox.
134 await chat(s, 'Remember the codeword SESSMARK-1 for later.');
135 await pushLanded(page);
136
137 // Instruments: how many sessions were minted, how many /api/sync requests
138 // were made, and how many WebSockets were opened. `__realFetch` is kept back
139 // so this file can ask the gateway things without its own questions landing
140 // in its own counters.
141 //
142 // `__sessRenewPage` is what says the recovery below happened in THIS page. A
143 // reload would take it with it, and "recovered after a reload" is not a fix,
144 // it is the workaround the user already had.
145 await page.evaluate(() => {
146 window.__sessRenewPage = 'alive';
147 window.__gw = { verify: 0, account: 0, sync: 0, socket: 0 };
148 const real = window.fetch;
149 window.__realFetch = real;
150 window.fetch = function (u, o) {
151 const url = String((u && u.url) || u || '');
152 if (url.indexOf('/api/auth/verify') !== -1) window.__gw.verify++;
153 if (url.indexOf('/api/account') !== -1) window.__gw.account++;
154 if (url.indexOf('/api/sync') !== -1) window.__gw.sync++;
155 return real.apply(this, arguments);
156 };
157 const WS = window.WebSocket;
158 window.WebSocket = function (url, p) { window.__gw.socket++; return p ? new WS(url, p) : new WS(url); };
159 window.WebSocket.prototype = WS.prototype;
160 });
161 // The wake channel is a caller like any other, and it would mint a session of
162 // its own in the middle of the two measurements below. It is turned off for
163 // them and brought back at (5), which is where it is the thing under test.
164 await page.evaluate(() => window.DaimondSync.wakeVia('off'));
165
166 // ── (1) The client does not notice ─────────────────────────────────
167 await killSession();
168 const unaware = await page.evaluate(async () => {
169 const r = await window.__realFetch('/api/sync', {
170 credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
171 });
172 return { status: r.status, authed: DaimondGateway.state().authed };
173 });
174 check('the session really is gone on the gateway (a bare /api/sync is 401)',
175 unaware.status === 401, 'status=' + unaware.status);
176 check('and the page still believes it is signed in — which is the bug',
177 unaware.authed === true);
178
179 // ── (2) Sync recovers, in the same page ────────────────────────────
180 const before = await page.evaluate(() => ({
181 version: window.DaimondSync.state().version, verify: window.__gw.verify,
182 }));
183 // Real work, made AFTER the session died: a turn through the model, appended
184 // to a transcript and persisted exactly as a user's would be. A synthetic
185 // edit would prove the request recovered; this proves the WORK travelled.
186 const MARK2 = 'SESSMARK-' + '2';
187 await chat(s, 'And remember the codeword ' + MARK2 + ' too.');
188 // The property, tested so it cannot pass by a timing accident: the renewed
189 // push LANDS -- the mailbox moves past where the work started -- AND the client
190 // then reports that same version and HOLDS it. `pushLanded` used to sample the
191 // client's own cursor once, after `chat()` had already let the work push, so a
192 // correct engine that settled the version before the sample read as "no
193 // advance", and the lost-update regression passed only because its cursor
194 // happened to dip below that late sample and recover inside the window. Both
195 // are timing, not the property.
196 //
197 // So this measures the client cursor against the MAILBOX (a `__realFetch` GET,
198 // the authority the client cannot fake) across a settle window LONGER than the
199 // engine's ~5s catch-up throttle. The regression leaves the client a step
200 // behind the mailbox it just wrote to for those whole seconds -- caught here
201 // whether or not a later pull recovers it -- while a correct engine never lags.
202 // The dip is floored at the throttle, so no sampling rate or machine speed can
203 // slip past it. See www/js/sync.js `adoptVersion`.
204 const watch = await page.evaluate(async (start) => {
205 const readServer = async () => {
206 const r = await window.__realFetch('/api/sync', {
207 credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
208 });
209 if (r.status !== 200) return -1;
210 const j = await r.json();
211 return j.version | 0;
212 };
213 // Phase 1: drive the push until the MAILBOX itself moves past the start.
214 const t0 = Date.now();
215 let serverV = start, landed = false;
216 while (Date.now() - t0 < 12000) {
217 await window.DaimondSync.push();
218 const v = await readServer();
219 if (v > serverV) serverV = v;
220 if (serverV > start) { landed = true; break; }
221 await new Promise(r => setTimeout(r, 150));
222 }
223 // Phase 2: over a window that outlasts the catch-up throttle, and with no
224 // push of our own to move it, the client cursor must equal the mailbox at
225 // every sample. Any moment a step behind is the lost update.
226 let lagged = false, maxLag = 0, samples = 0;
227 let clientFinal = window.DaimondSync.state().version;
228 const settleEnd = Date.now() + 8000;
229 while (Date.now() < settleEnd) {
230 const cv = window.DaimondSync.state().version;
231 const sv = await readServer();
232 if (sv > serverV) serverV = sv;
233 clientFinal = cv;
234 samples++;
235 if (serverV > start && cv < serverV) { lagged = true; maxLag = Math.max(maxLag, serverV - cv); }
236 await new Promise(r => setTimeout(r, 200));
237 }
238 return { landed, serverV, clientFinal, lagged, maxLag, samples, start };
239 }, before.version);
240 const after = await page.evaluate(async () => {
241 const r = await window.__realFetch('/api/sync', {
242 credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
243 });
244 const j = r.status === 200 ? await r.json() : {};
245 let plain = '';
246 try { plain = await window.DaimondIdentity.unwrap(j.blob); } catch (e) { plain = ''; }
247 return {
248 version: j.version | 0,
249 carries: plain,
250 // `state.authed` is what the app SAYS, and it is what was lying before
251 // the fix -- so it is not evidence on its own. The status of a request
252 // the gateway actually served is.
253 authed: DaimondGateway.state().authed,
254 served: r.status,
255 verify: window.__gw.verify,
256 pageSame: window.__sessRenewPage === 'alive',
257 };
258 });
259 check('a push after the session died lands — the mailbox advances past where the work started',
260 watch.landed && watch.serverV > watch.start && after.version > before.version,
261 'mailbox ' + watch.start + ' -> ' + watch.serverV);
262 check('and the client holds that version, never a step behind the mailbox it just wrote — no lost update',
263 watch.lagged === false && watch.clientFinal === watch.serverV,
264 watch.lagged
265 ? 'client fell ' + watch.maxLag + ' behind the mailbox during the settle window (lost update)'
266 : 'client=' + watch.clientFinal + ' mailbox=' + watch.serverV + ' held across ' + watch.samples + ' samples');
267 check('and the mailbox really holds the work that was pushed over the dead session',
268 after.carries.indexOf(MARK2) !== -1,
269 after.carries ? 'blob opened, ' + after.carries.length + ' bytes' : 'blob did not open');
270 check('the client is signed in again, without anybody asking it to be',
271 after.authed === true && after.served === 200,
272 'authed=' + after.authed + ' /api/sync=' + after.served);
273 check('one session was taken to do it, not one per refused request',
274 after.verify - before.verify === 1, (after.verify - before.verify) + ' /api/auth/verify calls');
275 check('and it was the SAME page throughout — no reload',
276 after.pageSame === true);
277
278 // ── (3) One renewal, however many callers are refused at once ──────
279 await killSession();
280 const flight = await page.evaluate(async () => {
281 const v0 = window.__gw.verify;
282 // Four independent gateway reads, all of which believe there is a session
283 // and all of which are about to be told there is not.
284 await Promise.all([
285 DaimondGateway.refreshBalance(),
286 DaimondGateway.refreshLicence(),
287 DaimondGateway.ledger(),
288 DaimondGateway.autoReload(),
289 ]);
290 const r = await window.__realFetch('/api/sync', {
291 credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
292 });
293 return { minted: window.__gw.verify - v0, authed: DaimondGateway.state().authed, served: r.status };
294 });
295 check('four callers refused in the same moment mint ONE session between them',
296 flight.minted === 1, flight.minted + ' minted');
297 check('and all four end up on a session the gateway will actually serve',
298 flight.authed === true && flight.served === 200,
299 'authed=' + flight.authed + ' /api/sync=' + flight.served);
300
301 // ── (4) When it cannot be renewed, the user is told ────────────────
302 // The session is ended AND the way back is blocked, which is what a gateway
303 // that is down, a revoked binding or a network that has gone all look like.
304 // The engine must not go quiet about it.
305 //
306 // The wake channel is brought back first, because (5) measures it going away
307 // and a channel that was never open proves nothing.
308 await page.evaluate(() => window.DaimondSync.wakeVia(''));
309 await page.waitForFunction(() => window.DaimondSync.wake().open === true,
310 null, { timeout: 20000 }).catch(() => {});
311 const chanUp = await page.evaluate(() => window.DaimondSync.wake());
312 check('the wake channel is open before the session is taken away',
313 chanUp.open === true, JSON.stringify(chanUp));
314
315 await killSession();
316 const told = await page.evaluate(async () => {
317 const gated = window.fetch;
318 // Only the authentication is blocked; /api/sync answers its honest 401.
319 window.fetch = function (u, o) {
320 const url = String((u && u.url) || u || '');
321 if (url.indexOf('/api/auth/') !== -1 || url.indexOf('/api/account') !== -1) {
322 return Promise.reject(new TypeError('blocked for the test'));
323 }
324 return gated.apply(this, arguments);
325 };
326 window.__unblock = function () { window.fetch = gated; };
327 await window.DaimondSync.pull();
328 await new Promise(r => setTimeout(r, 200));
329 const c = document.getElementById('sync-chip');
330 const modals = [...document.querySelectorAll('.modal')]
331 .filter(m => getComputedStyle(m).display !== 'none')
332 .map(m => (m.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60));
333 // A bland title, read back out of the same attribute by the same path, so the
334 // hover check below can be shown going red. Set and restored with no await
335 // between, so no repaint of the chip can land in the gap and nothing else in
336 // this run can see it.
337 let blinded = null;
338 if (c) {
339 const was = c.title;
340 c.title = 'Sync paused.';
341 blinded = document.getElementById('sync-chip').title;
342 c.title = was;
343 }
344 return {
345 chip: c ? { state: c.dataset.state || '', text: (c.querySelector('.stext') || {}).textContent || '',
346 title: c.title || '', shown: c.style.display !== 'none' } : null,
347 blinded: blinded,
348 api: window.DaimondSync.state(),
349 authed: DaimondGateway.state().authed,
350 modals: modals,
351 };
352 });
353 check('a session that cannot be renewed puts a held, stalled state on the chip',
354 !!(told.chip && told.chip.shown && told.chip.state === 'stalled'),
355 JSON.stringify(told.chip));
356 // ── What the hover has to carry ────────────────────────────────────
357 //
358 // A chip that says only "Sync paused" tells a person their work has stopped and
359 // nothing else. The hover owes them two facts: WHY it stopped — this device has no
360 // session — and WHAT HAPPENS NEXT, so that they know whether to sit still or act.
361 //
362 // Those two facts are the property; the sentence is not. This matched `signed in`
363 // literally and went red on 2026-08-11 when a concision pass turned "no longer signed
364 // in to" into "signed out of" — the same two facts, better said. Each fact is now
365 // asked for in any of the ways English states it.
366 const HOVER = {
367 'that this device has no session':
368 /signed[- ]out|not signed in|no longer signed in|could not sign in|cannot sign in|signed in again/i,
369 'what happens next':
370 /resume|comes? back|carry on|carries on|will sign in|starts? again|picks? up again|as soon as/i,
371 };
372 const hoverSays = (s) => Object.entries(HOVER).filter(([, re]) => !re.test(String(s || ''))).map(([k]) => k);
373 const hoverGaps = hoverSays(told.chip && told.chip.title);
374 check('and the hover says the device is not signed in, and what happens next',
375 !!(told.chip && told.chip.title) && hoverGaps.length === 0,
376 hoverGaps.length ? 'the hover never says: ' + hoverGaps.join('; ') + ' — '
377 + (told.chip && told.chip.title || '').replace(/\n/g, ' | ').slice(0, 160)
378 : (told.chip && told.chip.title || '').replace(/\n/g, ' | ').slice(0, 160));
379 check('and that is a check that can fail — a hover saying only “paused” is caught',
380 told.blinded !== null && hoverSays(told.blinded).length === Object.keys(HOVER).length
381 && told.chip.title !== told.blinded,
382 told.blinded === null ? 'there was no chip to blind'
383 : '“' + told.blinded + '” is missing both facts');
384 check('the engine reports it through its own surface, named',
385 !!(told.api && told.api.stalled === true && told.api.stalledWhy === 'signed_out'
386 && told.api.sessionGone === true),
387 JSON.stringify({ stalled: told.api.stalled, why: told.api.stalledWhy }));
388 check('the app stops claiming to be connected', told.authed === false);
389 check('and nothing was raised over the app — nobody asked for the round that failed',
390 told.modals.length === 0, told.modals.join(' | '));
391
392 // ── (5) The wake channel stops hammering ───────────────────────────
393 // `ready()` is false the moment `state.authed` is cleared, so `wakeWanted()`
394 // is false and the supervisor tears the channel down on its next tick. That
395 // is the difference between a device that is quiet and one that is refused an
396 // upgrade every fifteen seconds for five hours.
397 await page.waitForFunction(() => window.DaimondSync.wake().open === false,
398 null, { timeout: 25000 }).catch(() => {});
399 const quiet0 = await page.evaluate(() => ({
400 wake: window.DaimondSync.wake(), sync: window.__gw.sync, socket: window.__gw.socket,
401 }));
402 check('the wake channel that WAS open is shut once there is no session',
403 chanUp.open === true && quiet0.wake.open === false, JSON.stringify(quiet0.wake));
404 await sleep(8000);
405 const quiet1 = await page.evaluate(() => ({
406 wake: window.DaimondSync.wake(), sync: window.__gw.sync,
407 socket: window.__gw.socket, account: window.__gw.account,
408 }));
409 check('and it stays shut: no /api/sync request in the eight seconds after',
410 quiet1.sync === quiet0.sync, quiet0.sync + ' -> ' + quiet1.sync);
411 check('and no further WebSocket upgrade is attempted',
412 quiet1.socket === quiet0.socket, quiet0.socket + ' -> ' + quiet1.socket);
413 check('the standing retry that will bring it back is bounded, not a spin',
414 quiet1.account <= 8, quiet1.account + ' /api/account attempts in all');
415
416 // ── (6) And it comes back on its own ───────────────────────────────
417 // Nothing else in the app would ever ask again: every trigger is gated on
418 // there being a session, so without a retry of its own the tab is signed out
419 // until somebody reloads it.
420 await page.evaluate(() => { window.__unblock(); });
421 // Asked of the GATEWAY, not of `state.authed`: the flag was the thing lying
422 // before the fix, so a live session is the only honest evidence.
423 let back = null;
424 for (let i = 0; i < 45 && !(back && back.authed && back.served === 200); i++) {
425 await sleep(2000);
426 back = await page.evaluate(async () => {
427 const r = await window.__realFetch('/api/sync', {
428 credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
429 });
430 return { authed: DaimondGateway.state().authed, served: r.status };
431 });
432 }
433 check('the tab signs itself back in once the way is open again, unprompted',
434 !!(back && back.authed === true && back.served === 200), JSON.stringify(back));
435 await bump('SESSMARK-3');
436 const moved = await pushLanded(page, 15000);
437 const healed = await page.evaluate(() => ({
438 api: window.DaimondSync.state(), pageSame: window.__sessRenewPage === 'alive',
439 }));
440 check('and pushes again once it has', moved === true, 'version ' + healed.api.version);
441 check('the standing refusal is cleared, not left over a working engine',
442 healed.api.sessionGone === false && healed.api.stalledWhy !== 'signed_out',
443 JSON.stringify({ why: healed.api.stalledWhy }));
444 check('still the same page — the whole recovery needed no reload',
445 healed.pageSame === true);
446 const chipEnd = await chipOf();
447 check('the chip does not sit on a stall the engine has cleared',
448 !!(chipEnd && (chipEnd.state === 'synced' || !chipEnd.shown)), JSON.stringify(chipEnd));
449
450 // ── (7) The standing refusals keep their order ─────────────────────
451 // A parcel over the gateway's ceiling is a state of the parcel, true whatever
452 // the session is doing, and a person cannot act on it by signing in. So it
453 // stays on the chip when the session goes as well. The 413 is stubbed: the
454 // real ceiling is 32 MB and building that to test a status chip would cost
455 // more than the behaviour it proves.
456 await page.evaluate(() => window.DaimondSync.wakeVia('off'));
457 await bump('rank-1');
458 const ranked = await page.evaluate(async () => {
459 const gated = window.fetch;
460 window.fetch = function (u, o) {
461 const url = String((u && u.url) || u || '');
462 if (url.indexOf('/api/sync') !== -1 && o && o.method === 'POST') {
463 return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'too large' }),
464 { status: 413, headers: { 'content-type': 'application/json' } }));
465 }
466 return gated.apply(this, arguments);
467 };
468 await window.DaimondSync.push();
469 const c = () => {
470 const e = document.getElementById('sync-chip');
471 return e ? { state: e.dataset.state || '', title: e.title || '', shown: e.style.display !== 'none' } : null;
472 };
473 const big = c();
474 // Now take the session away underneath it, and block the way back.
475 await window.__realFetch('/api/auth/logout', {
476 method: 'POST', credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
477 });
478 window.fetch = function (u, o) {
479 const url = String((u && u.url) || u || '');
480 if (url.indexOf('/api/auth/') !== -1 || url.indexOf('/api/account') !== -1) {
481 return Promise.reject(new TypeError('blocked for the test'));
482 }
483 return gated.apply(this, arguments);
484 };
485 await window.DaimondSync.pull();
486 await new Promise(r => setTimeout(r, 200));
487 const both = c();
488 const api = window.DaimondSync.state();
489 window.fetch = gated;
490 return { big, both, api };
491 });
492 check('a 413 stalls the chip on its own', !!(ranked.big && ranked.big.state === 'stalled'
493 && /too large/i.test(ranked.big.title)), JSON.stringify(ranked.big).slice(0, 120));
494 check('and a session going as well does not push the parcel refusal off the chip',
495 !!(ranked.both && /too large/i.test(ranked.both.title) && !/signed in/i.test(ranked.both.title)),
496 (ranked.both && ranked.both.title || '').replace(/\n/g, ' | ').slice(0, 140));
497 check('the engine names the same one first', ranked.api.stalledWhy === 'too_big',
498 ranked.api.stalledWhy + ' (sessionGone=' + ranked.api.sessionGone + ')');
499 check('while still knowing the session is gone underneath it',
500 ranked.api.sessionGone === true);
501
502 // Console noise from a deliberately refused round is expected; anything else
503 // is not.
504 const errs = s.errs.filter(e =>
505 !/favicon|ERR_|Failed to load resource|401|402|409|413|426|502|Unauthorized|blocked for the test/.test(e)
506 && !/WebSocket connection to '[^']*\/api\/sync\/ws/.test(e));
507 check('no unexpected console errors through the whole recovery', errs.length === 0,
508 errs.slice(0, 3).join(' | '));
509} finally {
510 await s.close();
511}
512
513console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
514if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); }