oxedyne/daimond/dev/verify_pausespend.mjs
19.4 KiB, 1 run
created by r2519314175:579, 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_pausespend.mjs — a pause is refused where the money is committed. |
| 2 | // |
| 3 | // The PPTW of NOTES2_PLAN §1.1 is one control with three states, and §1.1 says |
| 4 | // what makes it real: "Not in the UI. A pause has to be refused at the point |
| 5 | // money is committed, or it is decoration." So nothing here reads the flag back. |
| 6 | // Every assertion is counted AT THE NETWORK — `page.route` sits between the page |
| 7 | // and every host it talks to, so a request that was refused never reaches a |
| 8 | // counter, and one that was merely hidden does. |
| 9 | // |
| 10 | // Each boundary is asked BOTH questions, and the second is the one that matters: |
| 11 | // |
| 12 | // * paused → no request left the page; |
| 13 | // * resumed → the request DOES leave. |
| 14 | // |
| 15 | // A check that only ever proves silence passes with the feature entirely broken — |
| 16 | // with the network unplugged, with the button doing nothing, with the whole |
| 17 | // module deleted. The resume half is what tells a refusal from an outage. |
| 18 | // |
| 19 | // The boundaries, and what drives each: |
| 20 | // |
| 21 | // 1. THE WORKER-SLOT MINT. A real conductor turn dispatching three agents, |
| 22 | // with `root/workers` paused. Every worker key is minted at |
| 23 | // /api/inference-key with a slot of 1 or more; a paused pump mints none. |
| 24 | // 2. THE CHAT'S MINT. `remint(gen, node)` for a paused leaf. Called directly: |
| 25 | // it is the call daimond.js makes at www/js/daimond.js:7781, with the |
| 26 | // argument it has yet to pass. |
| 27 | // 3. THE DISPATCH GATE. `assessDispatch(n, node)` comes back refused through |
| 28 | // the object it already answers with. A decision check, not a network one — |
| 29 | // the network half of the same boundary is (1). |
| 30 | // 4. WEB FETCH. The real DaimondWeb.fetch, against a stubbed /api/web/fetch. |
| 31 | // 5. MAIL. The real DaimondMail.sync, against a stubbed /api/mail/sync. |
| 32 | // 6. READING IS NOT PAUSED. With every leaf held, a Diamond still opens, its |
| 33 | // crystal still renders and its files still list. |
| 34 | // |
| 35 | // Everything below the stub is the real code: the real wasm, the real models.js, |
| 36 | // the real gateway.js, the real Workers pool. The gateway itself is not run. |
| 37 | // |
| 38 | // eval "$(bash dev/world.sh 3 --up)" |
| 39 | // node dev/verify_pausespend.mjs |
| 40 | // |
| 41 | // Needs dev/serve.mjs and dev/mockllm.mjs (DAIMOND_PORT / DAIMOND_MOCK_PORT). |
| 42 | import fs from 'node:fs'; |
| 43 | import { open, signInAs, shot, scratch, errors, MOCK } from './harness.mjs'; |
| 44 | import { IMAP_PORT, SMTP_PORT } from './ports.mjs'; |
| 45 | |
| 46 | const PROFILE = scratch('pw', 'pausespend'); |
| 47 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 48 | |
| 49 | const ok = [], bad = []; |
| 50 | const check = (name, pass, detail) => { |
| 51 | (pass ? ok : bad).push(name); |
| 52 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 53 | }; |
| 54 | |
| 55 | const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; |
| 56 | const json = (body, status = 200) => ({ |
| 57 | status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body), |
| 58 | }); |
| 59 | const OR_BASE = 'https://openrouter.ai/api/v1'; |
| 60 | const OR_URL = `${OR_BASE}/chat/completions`; |
| 61 | const MAILBOX = 'alice@test.local'; |
| 62 | |
| 63 | // Everything that left the page, by boundary. A refusal shows as a count that |
| 64 | // did not move. |
| 65 | const net = { mint: [], web: [], mailSync: [], mailSend: [], mailFolders: 0, provider: 0 }; |
| 66 | const slotMints = () => net.mint.filter(s => s >= 1).length; |
| 67 | |
| 68 | async function stub(page) { |
| 69 | await page.route('**/api/account', r => r.fulfill(json({ ok: true }))); |
| 70 | await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-pause', challenge_id: 'cid-1' }))); |
| 71 | await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true }))); |
| 72 | await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 5000, currency: 'usd', entries: [] }))); |
| 73 | await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: true, held: true, currency: 'usd' }))); |
| 74 | |
| 75 | await page.route('**/api/inference-key', r => { |
| 76 | let slot = 0; |
| 77 | try { slot = (JSON.parse(r.request().postData() || '{}').slot) | 0; } catch (e) { slot = 0; } |
| 78 | net.mint.push(slot); |
| 79 | return r.fulfill(json({ |
| 80 | ok: true, key: `sk-or-v1-SLOT${slot}-pausemarker00000000000000000000000000`, |
| 81 | url: OR_BASE, limit_minor: 200, credits_minor: 5000, currency: 'usd', |
| 82 | })); |
| 83 | }); |
| 84 | |
| 85 | await page.route(`${OR_BASE}/models`, |
| 86 | r => r.fulfill(json({ data: [{ id: 'anthropic/claude-opus-4.5' }] }))); |
| 87 | await page.route(OR_URL, async (r) => { |
| 88 | net.provider++; |
| 89 | const sent = r.request().postData() || ''; |
| 90 | const res = await fetch(MOCK, { method: 'POST', headers: { 'content-type': 'application/json' }, body: sent }); |
| 91 | const body = await res.text(); |
| 92 | return r.fulfill({ status: res.status, headers: CORS, body, |
| 93 | contentType: res.headers.get('content-type') || 'application/json' }); |
| 94 | }); |
| 95 | |
| 96 | // The three credit-spending routes this file measures. |
| 97 | await page.route('**/api/web/fetch', r => { |
| 98 | net.web.push(r.request().postData() || ''); |
| 99 | return r.fulfill(json({ ok: true, url: 'https://example.com/', title: 'Example', |
| 100 | text: 'a page', bytes: 6, credits_minor: 4990 })); |
| 101 | }); |
| 102 | await page.route('**/api/web/head', r => r.fulfill(json({ ok: true, framable: false }))); |
| 103 | await page.route('**/api/mail/sync', r => { |
| 104 | net.mailSync.push(r.request().postData() || ''); |
| 105 | return r.fulfill(json({ ok: true, uid_validity: 7, messages: [], credits_minor: 4980 })); |
| 106 | }); |
| 107 | await page.route('**/api/mail/send', r => { |
| 108 | net.mailSend.push(r.request().postData() || ''); |
| 109 | return r.fulfill(json({ ok: true, credits_minor: 4970 })); |
| 110 | }); |
| 111 | // Listing is READING, and reading is never paused. |
| 112 | await page.route('**/api/mail/folders', r => { |
| 113 | net.mailFolders++; |
| 114 | return r.fulfill(json({ ok: true, folders: [{ name: 'INBOX' }, { name: 'Sent', role: 'sent' }] })); |
| 115 | }); |
| 116 | await page.route('**/api/mail/accounts', r => r.fulfill(json({ ok: true, allowed: 3, used: 0 }))); |
| 117 | } |
| 118 | |
| 119 | // ── The tree, driven the way the widget will drive it ──────────────── |
| 120 | // Only leaves are set, so none of this needs the live tree daimond.js has yet |
| 121 | // to register: `DaimondPause.set` on an id it cannot find writes that id alone, |
| 122 | // which for a leaf is exactly right. |
| 123 | const pauseLeaf = (page, id) => page.evaluate(i => window.DaimondPause.set(i, false), id); |
| 124 | const playLeaf = (page, id) => page.evaluate(i => window.DaimondPause.set(i, true), id); |
| 125 | const pausedIds = (page) => page.evaluate(() => window.DaimondPause.pausedIds()); |
| 126 | |
| 127 | const s = await open({ name: 'pausespend', profile: PROFILE, signIn: false, connect: false }); |
| 128 | const { page } = s; |
| 129 | await stub(page); |
| 130 | await signInAs(s, 'pausespend'); |
| 131 | await page.waitForTimeout(2500); // unlock → bootstrap → mint slot 0 → catalogue |
| 132 | |
| 133 | try { |
| 134 | await page.evaluate(() => window.DaimondModels.setDefault('credits', 'anthropic/claude-opus-4.5')); |
| 135 | check('the account is on credits and slot 0 was minted at unlock', |
| 136 | net.mint.includes(0), 'mints ' + JSON.stringify(net.mint)); |
| 137 | |
| 138 | // ── 1. The worker-slot mint ────────────────────────────────── |
| 139 | const WORKERS = 'root/workers'; |
| 140 | await pauseLeaf(page, WORKERS); |
| 141 | |
| 142 | // The Admin drawer opens over the rail on an unconnected profile and swallows |
| 143 | // the click; close it the way a person would, then force past the fade that |
| 144 | // keeps failing Playwright's stability check. |
| 145 | const drawer = page.locator('#admin-close'); |
| 146 | if (await drawer.isVisible().catch(() => false)) { |
| 147 | await drawer.click({ force: true }); |
| 148 | await page.waitForTimeout(300); |
| 149 | } |
| 150 | await page.click('#new-diamond-btn', { force: true }); |
| 151 | await page.waitForSelector('.dlg-input', { timeout: 8000 }); |
| 152 | await page.fill('.dlg-input', 'Held'); |
| 153 | await page.click('.dlg-ok', { force: true }); |
| 154 | await page.waitForSelector('#chat-input', { timeout: 10000 }); |
| 155 | await page.waitForTimeout(400); |
| 156 | |
| 157 | const steer = '@tools spawn_agent {"name":"a","task":"one"} ;; ' |
| 158 | + 'spawn_agent {"name":"b","task":"two"} ;; ' |
| 159 | + 'spawn_agent {"name":"c","task":"three"}'; |
| 160 | const before = slotMints(); |
| 161 | await page.fill('#chat-input', steer); |
| 162 | await page.click('#chat-send', { force: true }); |
| 163 | await page.waitForTimeout(7000); |
| 164 | check('a paused worker pump dispatches nothing and mints nothing', |
| 165 | slotMints() === before, 'slot mints ' + JSON.stringify(net.mint)); |
| 166 | |
| 167 | // AND THE MINT ITSELF, asked directly. The check above passes with the mint |
| 168 | // gate deleted, because daimond.js's pump reads the same leaf and never |
| 169 | // reaches the mint — which is two guards doing their job and one check |
| 170 | // proving only the outer one. This is the inner one on its own: the call the |
| 171 | // pump makes at www/js/daimond.js:8453, made here with the pump held. |
| 172 | const beforeDirect = net.mint.length; |
| 173 | const refusedSlot = await page.evaluate(async () => { |
| 174 | try { await window.DaimondModels.mintSlot(1); return { threw: false, msg: '' }; } |
| 175 | catch (e) { return { threw: true, msg: String(e && e.message || e), node: e && e.pauseNode }; } |
| 176 | finally { window.DaimondModels.forgetSlot(1); } |
| 177 | }); |
| 178 | check('and a worker key asked for directly is refused at the mint', |
| 179 | net.mint.length === beforeDirect && refusedSlot.threw, |
| 180 | refusedSlot.msg || ('mints ' + JSON.stringify(net.mint))); |
| 181 | check('and that refusal names the pump and how to resume it', |
| 182 | refusedSlot.node === WORKERS && refusedSlot.msg.includes(WORKERS) |
| 183 | && /play/i.test(refusedSlot.msg), refusedSlot.msg); |
| 184 | |
| 185 | await playLeaf(page, WORKERS); |
| 186 | const afterDirect = await page.evaluate(async () => { |
| 187 | try { await window.DaimondModels.mintSlot(1); return 'ok'; } |
| 188 | catch (e) { return String(e && e.message || e); } |
| 189 | finally { window.DaimondModels.forgetSlot(1); } |
| 190 | }); |
| 191 | check('and a resumed pump mints — so the silence above was the pause', |
| 192 | net.mint.length > beforeDirect, afterDirect + ' — mints ' + JSON.stringify(net.mint)); |
| 193 | |
| 194 | await page.fill('#chat-input', steer); |
| 195 | await page.click('#chat-send', { force: true }); |
| 196 | await page.waitForTimeout(9000); |
| 197 | check('and a resumed pump dispatches again', |
| 198 | slotMints() > before, 'slot mints ' + JSON.stringify(net.mint)); |
| 199 | |
| 200 | // ── 2. The chat's own mint ─────────────────────────────────── |
| 201 | // `remint(gen, node)` is the call at www/js/daimond.js:7781, given the |
| 202 | // argument the widget's own work will pass it. |
| 203 | const CHAT = 'root/chats/pausespend-1'; |
| 204 | await pauseLeaf(page, CHAT); |
| 205 | const n2 = net.mint.length; |
| 206 | // The LIVE generation in BOTH halves. `remint` hands an OLD generation the key |
| 207 | // somebody else has already minted rather than buying another, so a stale |
| 208 | // number makes the paused half silent whatever the guard does — a check that |
| 209 | // passes with the guard deleted, which is no check at all. |
| 210 | const refusedMint = await page.evaluate(async (node) => { |
| 211 | try { |
| 212 | await window.DaimondModels.remint(window.DaimondModels.creditsGen(), node); |
| 213 | return { threw: false, msg: '' }; |
| 214 | } catch (e) { |
| 215 | return { threw: true, msg: String(e && e.message || e), node: e && e.pauseNode, flag: !!(e && e.paused) }; |
| 216 | } |
| 217 | }, CHAT); |
| 218 | check('a paused chat mints no key', net.mint.length === n2 && refusedMint.threw, |
| 219 | 'mints ' + JSON.stringify(net.mint)); |
| 220 | check('and the refusal names the node and how to resume it', |
| 221 | refusedMint.threw && refusedMint.node === CHAT |
| 222 | && /play/i.test(refusedMint.msg) && refusedMint.msg.includes(CHAT), |
| 223 | refusedMint.msg); |
| 224 | check('and it is marked a pause rather than a fault', refusedMint.flag === true); |
| 225 | |
| 226 | await playLeaf(page, CHAT); |
| 227 | // The LIVE generation, or `remint` rightly hands back the key somebody else |
| 228 | // has already minted instead of buying another — and the resume half would |
| 229 | // pass without a request ever leaving. |
| 230 | const mintedAfter = await page.evaluate(async (node) => { |
| 231 | try { |
| 232 | await window.DaimondModels.remint(window.DaimondModels.creditsGen(), node); |
| 233 | return 'ok'; |
| 234 | } catch (e) { return String(e && e.message || e); } |
| 235 | }, CHAT); |
| 236 | check('and resuming it mints', net.mint.length > n2, |
| 237 | mintedAfter + ' — mints ' + JSON.stringify(net.mint)); |
| 238 | |
| 239 | // ── 3. The dispatch gate ───────────────────────────────────── |
| 240 | const DIA = 'root/diamonds/d1/self'; |
| 241 | await pauseLeaf(page, DIA); |
| 242 | const gate = await page.evaluate((node) => ({ |
| 243 | held: window.DaimondGovernor.assessDispatch(3, node), |
| 244 | free: window.DaimondGovernor.assessDispatch(3, 'root/diamonds/d2/self'), |
| 245 | none: window.DaimondGovernor.assessDispatch(3), |
| 246 | }), DIA); |
| 247 | check('a paused node comes back refused through the same decision object', |
| 248 | gate.held.refused === true && gate.held.pauseNode === DIA, |
| 249 | JSON.stringify({ refused: gate.held.refused, node: gate.held.pauseNode })); |
| 250 | check('and its refusal names the node and how to resume it', |
| 251 | typeof gate.held.refusal === 'string' && gate.held.refusal.includes(DIA) |
| 252 | && /play/i.test(gate.held.refusal), gate.held.refusal); |
| 253 | check('and it also stops a caller that only reads needsConfirm', |
| 254 | gate.held.needsConfirm === true); |
| 255 | check('a node that is playing is not refused', gate.free.refused === false); |
| 256 | check('and neither is a caller that names no node at all', gate.none.refused === false); |
| 257 | await playLeaf(page, DIA); |
| 258 | |
| 259 | // ── 4. Web fetch ───────────────────────────────────────────── |
| 260 | const WEB = 'root/web'; |
| 261 | await pauseLeaf(page, WEB); |
| 262 | const n4 = net.web.length; |
| 263 | const refusedWeb = await page.evaluate(async () => { |
| 264 | try { await window.DaimondWeb.fetch('https://example.com/'); return { threw: false, msg: '' }; } |
| 265 | catch (e) { return { threw: true, msg: String(e && e.message || e) }; } |
| 266 | }); |
| 267 | check('a paused Web panel fetches no page', net.web.length === n4, |
| 268 | net.web.length + ' fetch(es)'); |
| 269 | check('and the refusal names the node and how to resume it', |
| 270 | refusedWeb.threw && refusedWeb.msg.includes(WEB) && /play/i.test(refusedWeb.msg), |
| 271 | refusedWeb.msg); |
| 272 | |
| 273 | await playLeaf(page, WEB); |
| 274 | const gotWeb = await page.evaluate(async () => { |
| 275 | try { return (await window.DaimondWeb.fetch('https://example.com/')).title || 'no title'; } |
| 276 | catch (e) { return 'THREW ' + (e && e.message || e); } |
| 277 | }); |
| 278 | check('and resuming it fetches', net.web.length === n4 + 1, |
| 279 | gotWeb + ' — ' + net.web.length + ' fetch(es)'); |
| 280 | |
| 281 | // The global control is the root of the same tree, and holds a fetch that |
| 282 | // belongs to no leaf of its own. |
| 283 | await pauseLeaf(page, 'root'); |
| 284 | const n4b = net.web.length; |
| 285 | await page.evaluate(async () => { try { await window.DaimondWeb.fetch('https://example.com/'); } catch (e) {} }); |
| 286 | check('the global pause holds a page fetch that names no leaf', |
| 287 | net.web.length === n4b, net.web.length + ' fetch(es)'); |
| 288 | await playLeaf(page, 'root'); |
| 289 | |
| 290 | // ── 5. Mail ────────────────────────────────────────────────── |
| 291 | await page.evaluate(async ([addr, PORTS]) => { |
| 292 | const pass = await window.DaimondIdentity.wrap('test-app-password'); |
| 293 | localStorage.setItem('daimond-mail', JSON.stringify({ |
| 294 | accounts: [{ |
| 295 | address: addr, host: '127.0.0.1', port: PORTS.imap, security: 'plain', |
| 296 | smtpHost: '127.0.0.1', smtpPort: PORTS.smtp, smtpSecurity: 'plain', |
| 297 | user: addr, pass, folder: 'INBOX', folders: {}, lastSync: 0, |
| 298 | }], |
| 299 | sel: addr, |
| 300 | })); |
| 301 | window.DaimondMail.reload(); |
| 302 | window.DaimondPanels.show('mail'); |
| 303 | window.DaimondMail.onOpen(); |
| 304 | }, [MAILBOX, { imap: IMAP_PORT, smtp: SMTP_PORT }]); |
| 305 | await page.waitForTimeout(1200); |
| 306 | |
| 307 | const INBOX = `root/mail/${MAILBOX}/INBOX`; |
| 308 | const MBOX = `root/mail/${MAILBOX}/self`; |
| 309 | await pauseLeaf(page, INBOX); |
| 310 | const n5 = net.mailSync.length; |
| 311 | await page.evaluate(() => window.DaimondMail.sync()); |
| 312 | await page.waitForTimeout(1500); |
| 313 | check('a paused mail folder contacts no server', net.mailSync.length === n5, |
| 314 | net.mailSync.length + ' sync(s)'); |
| 315 | |
| 316 | await playLeaf(page, INBOX); |
| 317 | await page.evaluate(() => window.DaimondMail.sync()); |
| 318 | await page.waitForTimeout(1500); |
| 319 | check('and resuming it syncs', net.mailSync.length === n5 + 1, |
| 320 | net.mailSync.length + ' sync(s)'); |
| 321 | |
| 322 | // The mailbox's own leaf holds every folder under it, or a paused mailbox |
| 323 | // would go on reaching the server one folder at a time. |
| 324 | await pauseLeaf(page, MBOX); |
| 325 | const n5b = net.mailSync.length; |
| 326 | await page.evaluate(() => window.DaimondMail.sync()); |
| 327 | await page.waitForTimeout(1500); |
| 328 | check('a paused mailbox holds a sync of a folder that is playing', |
| 329 | net.mailSync.length === n5b, net.mailSync.length + ' sync(s)'); |
| 330 | |
| 331 | const sendHeld = await page.evaluate((addr) => window.DaimondGateway.spendRefusal( |
| 332 | '/api/mail/send', { body: JSON.stringify({ address: addr }) }), MAILBOX); |
| 333 | check('and it holds a send too', !!sendHeld && sendHeld.node === MBOX, |
| 334 | JSON.stringify(sendHeld)); |
| 335 | await playLeaf(page, MBOX); |
| 336 | |
| 337 | // ── 6. Reading is not paused ───────────────────────────────── |
| 338 | // Everything held: the folder list is still asked for, the Diamond still |
| 339 | // opens, its crystal still renders and its files still list. |
| 340 | await pauseLeaf(page, 'root'); |
| 341 | await pauseLeaf(page, WORKERS); |
| 342 | await pauseLeaf(page, INBOX); |
| 343 | await pauseLeaf(page, MBOX); |
| 344 | |
| 345 | const foldersBefore = net.mailFolders; |
| 346 | await page.evaluate(() => window.DaimondMail.loadFolders(undefined, true)); |
| 347 | await page.waitForTimeout(1200); |
| 348 | check('the folder list is still asked for — a list is reading, not spending', |
| 349 | net.mailFolders > foldersBefore, net.mailFolders + ' listing(s)'); |
| 350 | |
| 351 | await page.evaluate(() => { window.DaimondPanels.show('ai'); window.DaimondPanels.show('work'); }); |
| 352 | await page.click('.diamond-item, #diamond-list > *', { force: true }).catch(() => {}); |
| 353 | await page.waitForTimeout(2000); |
| 354 | const read = await page.evaluate(() => { |
| 355 | const body = document.getElementById('crystal-body'); |
| 356 | const view = document.getElementById('crystal-view'); |
| 357 | return { |
| 358 | // A CRYSTAL WITH ANYTHING IN IT IS NOW A FRAME, and a frame contributes |
| 359 | // no text to its container -- so counting characters here would have |
| 360 | // answered zero for every Diamond and failed pointing at pause, which is |
| 361 | // not where the fault would have been. What this ever meant is "the |
| 362 | // crystal face drew something", and the frame reports that itself. |
| 363 | crystal: !!(body && (body.textContent.trim().length > 0 |
| 364 | || body.querySelector('.crystal-frame, .crystal-fallback, .crystal-empty'))), |
| 365 | shown: !!(view && view.style.display !== 'none'), |
| 366 | files: document.querySelectorAll('#panel-work .files-tree *').length, |
| 367 | steer: !!document.getElementById('chat-input'), |
| 368 | }; |
| 369 | }); |
| 370 | check('a paused Diamond still opens and its crystal still renders', |
| 371 | read.crystal && read.shown, JSON.stringify(read)); |
| 372 | check('and its files still list, and its steer input is still there — ' |
| 373 | + 'pause is about spending, not access', |
| 374 | read.files > 0 && read.steer, JSON.stringify(read)); |
| 375 | |
| 376 | // A refusal is a decision, not a fault, so it must not arrive as a thrown |
| 377 | // stack in the console: an app that logs an error every time a paused node |
| 378 | // is asked to spend has taught the user to ignore its console. |
| 379 | // "Failed to load resource" is the dev server answering the routes this |
| 380 | // stub does not cover (/api/sync, /api/admin); it is noise, not a refusal. |
| 381 | const errs = errors(s).filter(e => !/Paused:/.test(e) && !/Failed to load resource/.test(e)); |
| 382 | check('nothing was refused by way of an unhandled error', errs.length === 0, |
| 383 | errs.slice(0, 3).join(' | ')); |
| 384 | |
| 385 | console.log('\npaused when finished: ' + JSON.stringify(await pausedIds(page))); |
| 386 | await shot(s, 'pausespend'); |
| 387 | } finally { |
| 388 | await s.close(); |
| 389 | } |
| 390 | |
| 391 | console.log('\nmints: ' + JSON.stringify(net.mint)); |
| 392 | console.log('web: ' + net.web.length + ' mail sync: ' + net.mailSync.length |
| 393 | + ' mail folders: ' + net.mailFolders + ' provider: ' + net.provider); |
| 394 | console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`); |
| 395 | process.exit(bad.length === 0 ? 0 : 1); |