diff options
| -rw-r--r-- | build.js | 10 | ||||
| -rw-r--r-- | src/app.js | 32 | ||||
| -rw-r--r-- | src/ark/manager.js | 18 | ||||
| -rw-r--r-- | src/features/ark.js | 30 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/sw-nwc.js | 15 | ||||
| -rw-r--r-- | tools/ln-receive-twin-test.js | 14 | ||||
| -rw-r--r-- | tools/safe-boot-test.js | 77 |
8 files changed, 199 insertions, 2 deletions
@@ -159,6 +159,15 @@ const SW_REGISTER = `navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` + `addEventListener('load',function(){navigator.serviceWorker.register('/sw.js',{updateViaCache:'none'}).catch(function(){})})}</script>`; +// Crash-loop guard. iOS kills a page that dies twice in a row while opening +// ("A problem repeatedly occurred") and the wallet is locked out with nothing +// to go on. Count page starts here, before the app runs; the app clears the +// count once it has lived a while or is left cleanly (hidden / pagehide) and +// notes the boot phase it reaches. Three starts with no clear = the app +// opens in safe mode: no automatic connections, a notice naming the last +// phase, and a way back to a normal boot. +export const BOOT_GUARD = `<script>try{var _bk='coinos-boot',_bn=+(localStorage.getItem(_bk)||0)+1;localStorage.setItem(_bk,String(_bn));window.__bootPhase=localStorage.getItem('coinos-boot-phase')||'';if(_bn>=3)window.__safeBoot=_bn;}catch(e){}</script>`; + // Lazy bundles load as classic scripts into the page's global scope, where // the main bundle's minified top-level names already live — two Bun outputs // can mint the same identifier and die with "already been declared". An IIFE @@ -434,6 +443,7 @@ ${siteTags(staging)}${pwa ? PWA_HEAD : ''}<script>try{var t=localStorage.getItem <body> <div id="app">${bootShell(await brandSvg(), staging)}</div> ${BOOT_SHELL_SCRIPT} +${BOOT_GUARD} ${scriptHtml} ${pwa && !NO_SW ? SW_REGISTER + '\n' : ''}</body> </html>`; @@ -585,6 +585,15 @@ let _publicChatMode = false; // welcome screen is the one screen that must never be guessed at. The boot // shell holds the frame for those few ms instead; see the dispatch below. let _bootDeciding = true; +// Boot phases for the crash-loop guard (build.js BOOT_GUARD counts page +// starts): the app notes the phase it reaches, and clears the count once it +// has lived eight seconds or leaves cleanly. A page iOS kills while opening +// does neither, so the third start in a row opens in safe mode. +export function bootPhase(p) { try { localStorage.setItem('coinos-boot-phase', p); } catch {} } +function bootSettled() { try { localStorage.removeItem('coinos-boot'); } catch {} } +export const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot); +document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') bootSettled(); }); +addEventListener('pagehide', bootSettled); // Belt and braces: a boot path that returns early (or throws) must not leave // the welcome screen held back forever. Nothing legitimate is still deciding // four seconds in — the store wait alone caps at 800ms. @@ -4478,6 +4487,7 @@ function walletScreen() { brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, + safeBootNotice(), backupNotice(), ...featureAll('walletNotices'), tabsBar(), @@ -4536,6 +4546,24 @@ function backupNotice() { } }, t('backupReminderGo'))); } +// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a +// row, so this boot made no automatic connections. Say so, name the phase +// the last boot reached, and offer the way back. +function safeBootNotice() { + if (!safeBoot()) return null; + const phase = (typeof window !== 'undefined' && window.__bootPhase) || '?'; + const report = () => ['Coinos safe mode', 'build ' + (typeof window._v === 'string' ? window._v : '?'), + 'starts without a clean boot: ' + window.__safeBoot, 'last phase: ' + phase, navigator.userAgent].join('\n'); + return h('div', { class: 'notice info col', style: 'margin:12px 0 0;gap:8px' }, + h('b', {}, t('safeBootTitle')), + h('span', { class: 'small' }, t('safeBootBody', { n: window.__safeBoot - 1, phase })), + h('div', { class: 'row', style: 'gap:8px' }, + h('button', { class: 'btn-sm', onClick: () => { bootSettled(); location.reload(); } }, t('safeBootOpen')), + h('button', { class: 'btn-sm', onClick: (e) => { + navigator.clipboard?.writeText(report()).then(() => { e.target.textContent = t('safeBootCopied'); }).catch(() => {}); + } }, t('safeBootCopy')))); +} + function offlineBanner() { return h( 'div', @@ -6824,6 +6852,7 @@ window.addEventListener('load', () => { }, 1000); }); loadLocale(getLang()).finally(async () => { + bootPhase('boot'); applyBootAutoLogout(); // clear an overdue session before we read it for claim targets // Public read-only community chat: no wallet, no login — the join material // ships in the bundle, so the page decrypts client-side (src/public-chat.js). @@ -6849,6 +6878,7 @@ loadLocale(getLang()).finally(async () => { // and a wallet opening in fiat should open with a number in it. refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000); + bootPhase('store'); const storeReady = featureHook('arkStoreReady'); // the IndexedDB caches (profiles, reports, …) open alongside: a wallet's // caches are read as soon as it opens @@ -6859,6 +6889,8 @@ loadLocale(getLang()).finally(async () => { _bootDeciding = false; // from here on the screen is a decision, not a guess const opened = restoreAccountsState(); if (!opened) render(); + bootPhase('ui'); + setTimeout(bootSettled, 8000); if (WAKE_PAYLOAD != null) { // Nothing opened — a locked vault, or no wallet on this device. There is // nothing to answer with, so say so now. diff --git a/src/ark/manager.js b/src/ark/manager.js index 33bac55..0723fc1 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -119,6 +119,24 @@ export function lnFoldedIds(movements) { } if (best) out.add(best.id); } + // A Lightning payment the closed app's worker made is told twice once the + // app opens: the worker's own ln-send row (merged in from the mirror) and + // the "while closed" note the app adds from the worker's spend log. Per + // preimage (or payment hash), keep the row that names its inputs — the + // manager's — else the earliest. + const sends = (movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete' && (m.preimage || m.paymentHash)); + const keyOf = new Map(); // payment hash → the preimage key, where a row knows both + for (const s of sends) if (s.preimage && s.paymentHash) keyOf.set(s.paymentHash, 'p:' + s.preimage); + const byPay = new Map(); + for (const s of sends) { + const k = s.preimage ? 'p:' + s.preimage : (keyOf.get(s.paymentHash) || 'h:' + s.paymentHash); + (byPay.get(k) || byPay.set(k, []).get(k)).push(s); + } + for (const rows of byPay.values()) { + if (rows.length < 2) continue; + const keep = rows.find((r) => r.inputIds && r.inputIds.length) || rows.reduce((a, b) => ((a.ts || 0) <= (b.ts || 0) ? a : b)); + for (const r of rows) if (r !== keep) out.add(r.id); + } return out; } diff --git a/src/features/ark.js b/src/features/ark.js index 94dfc09..5a8dc61 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -24,6 +24,11 @@ function freeCoins(st) { function exitableCoins(st, tip) { return freeCoins(st).filter((v) => !(tip && v.expiryHeight && v.expiryHeight <= tip)); } +// The crash-loop guard's phase notes (build.js BOOT_GUARD, app.js bootPhase): +// a boot iOS killed shows the last one it reached, and three in a row open +// the app in safe mode, where nothing connects on its own. +const bootPhase = (p) => { try { localStorage.setItem('coinos-boot-phase', p); } catch {} }; +const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot); import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee, p2trAddress } from '../ark/board.js'; import { maybeBolt11, maybeLnInvoice, lnSendFee, lnShortfall } from '../ark/lightning.js'; @@ -547,9 +552,11 @@ export function arkFeature(ctx) { // holds is on screen the moment it can be. const store = wallet._arkStore; if (store && !store.ready) { + bootPhase('ark-store'); store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {}); return; } + if (safeBoot()) return; // safe mode: the coins show from the store; nothing dials out unasked if (arkAvailable() && arkWanted()) { lastAutoInit = Date.now(); connectArk().then(() => rescueOtherServers()).catch(() => {}); } } @@ -596,7 +603,7 @@ export function arkFeature(ctx) { } } function maybeInitArk() { - if (ark || arkConnectPromise) return; + if (ark || arkConnectPromise || safeBoot()) return; if (Date.now() - lastAutoInit < 20000) return; // A fresh snapshot may be held under its server pubkey, so arkWanted() // cannot see it until the handshake identifies the server and adopts it. @@ -681,8 +688,10 @@ export function arkFeature(ctx) { // Open the server connection while Nostr restores, but do not replay the // mailbox before the snapshot's history and cursor have arrived. const restored = Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr?.()).catch(() => false); + bootPhase('ark-init'); const inited = mgr.init().then(() => { if (gen !== arkInitGen) return; + bootPhase('ark-inited'); arkInited = mgr; // readable now; `ark` (and the mailbox) wait for the snapshot if (!mgr.offline) rememberArkInfo(cfg.ark, mgr.info, mgr._tipH); render(); @@ -699,6 +708,7 @@ export function arkFeature(ctx) { arkConnectPromise = Promise.all([inited, restored]).then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; + bootPhase('ark-adopt'); // Sync may have arrived while init awaited the server handshake. adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark)); // Boards that died before funding (a failed tx build, a closed tab) @@ -739,12 +749,14 @@ export function arkFeature(ctx) { // sync() already reconciles on its first pass. Start the live stream // afterwards, at the caught-up cursor: running both together replayed // the backlog one message/render at a time alongside the batch read. + bootPhase('ark-sync'); tick().finally(() => { if (ark === mgr) mgr.startMailboxStream(); }); mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. scheduleLnTag(mgr); // receives that landed while this device was closed arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); + bootPhase('ark-ready'); render(); return mgr; }).catch((e) => { @@ -1018,6 +1030,7 @@ export function arkFeature(ctx) { return; } if (!rec.mgr) return; + bootPhase('ark-merge'); const merged = mergeArkStates(mgr.state, rec.mgr); // the worker's view of OUR vtxos is newer for anything it spent const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); @@ -1027,8 +1040,11 @@ export function arkFeature(ctx) { merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); + bootPhase('ark-merged'); await mgr.reconcile().catch(() => {}); + bootPhase('ark-reconciled'); await mgr.resumePending().catch(() => {}); + bootPhase('ark-resumed'); } // A leftover chain-6 pouch from before whole-balance background spending: @@ -4028,9 +4044,21 @@ export function arkFeature(ctx) { async arkBgNoteSpends(spends) { const mgr = await connectArk(); for (const sp of spends) { + // The worker's own manager wrote this payment's row into the mirror, + // and the merge on connect brings it over: a second row here showed + // every closed-app payment twice (PullCap, 2026-10-23). Stamp the + // row that exists instead; lnFoldedIds hides a twin that still + // forms when the merge lands after this note. + const twin = (mgr.state.movements || []).find((m) => m.type === 'ln-send' + && ((sp.preimage && m.preimage === sp.preimage) || (sp.paymentHash && m.paymentHash === sp.paymentHash))); + if (twin) { + if (!/while closed/.test(twin.detail || '')) twin.detail = `while closed · fee ${sp.feeSat || 0} sat`; + continue; + } mgr._movement({ type: 'ln-send', amountSat: sp.amountSat, status: 'complete', detail: `while closed · fee ${sp.feeSat || 0} sat`, invoice: sp.invoice || '', preimage: sp.preimage || '', + paymentHash: sp.paymentHash || undefined, }); } mgr._save(); diff --git a/src/i18n.js b/src/i18n.js index edf9102..48faa49 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -783,6 +783,11 @@ const STR = { arkCoopSelectedAmt: '{n} selected coins · {amount}', arkCoopSelectedAmtOne: '1 selected coin · {amount}', arkCustodyTitle: 'Custody strategy', + safeBootTitle: 'Coinos opened in safe mode', + safeBootBody: 'The app was closed {n} times in a row before it finished opening, so automatic connections are paused this time. Your coins and keys are untouched. Last step reached: {phase}.', + safeBootOpen: 'Open normally', + safeBootCopy: 'Copy report', + safeBootCopied: 'Copied', arkCustodyDesc: 'Each coin expires about {days} days after it was made. Until then you can bring it on-chain yourself, without Coinos; after that, Coinos keeps honoring it. Renewing before expiry keeps your own exit, for a fee, so it is a choice:', arkCustodyCustodial: 'Allow coins to expire', arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. A coin keeps its own exit until it expires. After that, paying still works, but bringing it on-chain needs Coinos’s cooperation.', diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 0b65268..dbd2d98 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -155,14 +155,25 @@ self.addEventListener('push', (e) => { if (data.type !== 'nwc') { settle(async () => {}); return; } const startedAt = Date.now(); settle(async () => { + // WebKit's silent-push quota counts a push whose notification comes + // late: a payment can take most of the push budget, and a worker stopped + // before its last line never shows one (PullCap's night of 2026-10-22: + // four payments, no line for the fourth, then no more wakes). On an + // Apple endpoint the stand-in goes up FIRST; the outcome replaces it. + const apple = await applePush(); + const dropStandIn = async () => { + const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); + for (const n of open) n.close(); + }; + if (apple) { showed = true; await standIn(false).catch(() => {}); } // An open window handles requests itself with full wallet state — nudge // it and stay out of the way. On iOS only one on screen counts: a // backgrounded app is suspended, so a nudge to it went unanswered. const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - const apple = await applePush(); const clients = all.filter((c) => !apple || c.visibilityState === 'visible'); if (clients.length) { for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); + if (apple) await dropStandIn(); // the window on screen shows it live return; } // A wake is the only moment a closed wallet gets to look after its coins: @@ -189,6 +200,7 @@ self.addEventListener('push', (e) => { tag: handled.notify.tag || 'nwc-incoming', renotify: true, data: { url: './' }, }); + if (apple) await dropStandIn(); await tend(); return; } @@ -204,6 +216,7 @@ self.addEventListener('push', (e) => { tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, data: { url: './' }, }); + if (apple) await dropStandIn(); }); }); diff --git a/tools/ln-receive-twin-test.js b/tools/ln-receive-twin-test.js index 20d4f48..167a26b 100644 --- a/tools/ln-receive-twin-test.js +++ b/tools/ln-receive-twin-test.js @@ -35,5 +35,19 @@ f = lnFoldedIds([rcv('A', 1000, T), { ...rcv('B', 1000, T + 2000), vtxoId: 'v-A' check('...or the earliest when none is tagged', f.size === 1 && f.has('B')); f = lnFoldedIds([rcv('A', 1000, T), rcv('B', 1000, T + 2000)]); check('two coins of the same amount stay two rows', f.size === 0); +// a Lightning payment the closed app's worker made: its own ln-send row +// (merged from the mirror) plus the app's "while closed" note of the same +// preimage — one row, the one that names its inputs +const snd = (id, ts, extra = {}) => ({ id, type: 'ln-send', status: 'complete', amountSat: 1, ts, ...extra }); +f = lnFoldedIds([snd('W', T, { preimage: 'pi1', paymentHash: 'h1', inputIds: ['v1'] }), snd('N', T + 60_000, { preimage: 'pi1', detail: 'while closed · fee 0 sat' })]); +check('a closed-app payment told twice shows once, the row with inputs', f.size === 1 && f.has('N')); +f = lnFoldedIds([snd('N', T, { preimage: 'pi1' }), snd('W', T + 5000, { preimage: 'pi1', paymentHash: 'h1', inputIds: ['v1'] })]); +check('...whichever order they were written', f.size === 1 && f.has('N')); +f = lnFoldedIds([snd('W', T, { paymentHash: 'h1', preimage: 'pi1', inputIds: ['v1'] }), snd('N', T + 1000, { paymentHash: 'h1' })]); +check('...matched by payment hash when the note has no preimage', f.size === 1 && f.has('N')); +f = lnFoldedIds([snd('A', T, { preimage: 'pi1', inputIds: ['v1'] }), snd('B', T + 1000, { preimage: 'pi2', inputIds: ['v2'] })]); +check('two payments stay two rows', f.size === 0); +f = lnFoldedIds([snd('A', T, { preimage: 'pi1' }), snd('B', T + 1000, { preimage: 'pi1' })]); +check('two notes with no inputs keep the earliest', f.size === 1 && f.has('B')); console.log(ok ? '\n✅ a claimed LN receive is told once' : '\n❌ failed'); process.exit(ok ? 0 : 1); diff --git a/tools/safe-boot-test.js b/tools/safe-boot-test.js new file mode 100644 index 0000000..a23c067 --- /dev/null +++ b/tools/safe-boot-test.js @@ -0,0 +1,77 @@ +// The crash-loop guard: the inline BOOT_GUARD counts page starts, the app +// clears the count once it has lived or leaves cleanly, and a third start +// in a row opens in safe mode — a notice naming the last boot phase, no +// automatic Ark connection, and "Open normally" back to a plain boot. +// Run: bun tools/safe-boot-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { BOOT_GUARD } from '../build.js'; + +const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true, + plugins: [{ name: 'safe-boot-test', setup(build) { + build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + ` +window.sbApp = { ui, wallet, render, enterWallet, get ready() { return !_bootDeciding; } }; +` })); + build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents: + (await Bun.file(path).text()).replace(" return {\n id: 'ark',", ` + window.sbArk = { get ark() { return ark; }, get connecting() { return !!arkConnectPromise; }, get lastAutoInit() { return lastAutoInit; } }; + return {\n id: 'ark',`) })); + build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); + } }] }); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div>${BOOT_GUARD}<script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/' + ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +try { + const page = await browser.newPage(), errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844 }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()); + await page.evaluateOnNewDocument(() => { + if (!localStorage.getItem('sb-seeded')) { + localStorage.setItem('sb-seeded', '1'); + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); + // two starts already, neither cleared: the last one died merging the worker's state + localStorage.setItem('coinos-boot', '2'); + localStorage.setItem('coinos-boot-phase', 'ark-merge'); + } + }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.sbApp?.ready); + const guard = await page.evaluate(() => ({ safe: window.__safeBoot, phase: window.__bootPhase, count: localStorage.getItem('coinos-boot') })); + check('the third start in a row opens in safe mode, knowing the phase the last one reached', guard.safe === 3 && guard.phase === 'ark-merge' && guard.count === '3', JSON.stringify(guard)); + await page.evaluate(async (mn) => { + await sbApp.enterWallet(mn, '', { fresh: true, generated: true }); + sbApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step'); + sbApp.ui.tab = 'wallet'; sbApp.render(); + }, generateMnemonic(wordlist)); + await new Promise((r) => setTimeout(r, 1500)); + const notice = await page.evaluate(() => { const n = [...document.querySelectorAll('.notice')].find((x) => /safe mode/i.test(x.textContent)); return n ? n.textContent : ''; }); + check('the wallet home says so and names the phase', /safe mode/i.test(notice) && /ark-merge/.test(notice) && /2 times/.test(notice), notice.slice(0, 120)); + const arkState = await page.evaluate(() => ({ ark: !!sbArk.ark, connecting: sbArk.connecting, auto: sbArk.lastAutoInit })); + check('nothing dialled the Ark server on its own', !arkState.ark && !arkState.connecting && !arkState.auto, JSON.stringify(arkState)); + // a clean leave clears the count + await page.evaluate(() => dispatchEvent(new Event('pagehide'))); + check('leaving the page cleanly clears the count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null); + // "Open normally": the count is cleared and the page reloads into a plain boot + await page.evaluate(() => localStorage.setItem('coinos-boot', '3')); + await Promise.all([ + page.waitForNavigation({ waitUntil: 'domcontentloaded' }), + page.evaluate(() => { const b = [...document.querySelectorAll('.notice button')].find((x) => /open normally/i.test(x.textContent)); b.click(); }), + ]); + await page.waitForFunction(() => window.sbApp?.ready); + const after = await page.evaluate(() => ({ safe: window.__safeBoot, count: localStorage.getItem('coinos-boot'), notice: [...document.querySelectorAll('.notice')].some((x) => /safe mode/i.test(x.textContent)) })); + check('"Open normally" reloads into a plain boot (one start counted, no notice)', after.safe === undefined && after.count === '1' && !after.notice, JSON.stringify(after)); + await new Promise((r) => setTimeout(r, 8600)); + check('a boot that lives eight seconds clears its count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null); + check('no page errors', errors.length === 0, errors.join(' | ').slice(0, 200)); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ safe mode after a crash loop' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
