// 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 = `
${BOOT_GUARD}`; 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);