aboutsummaryrefslogtreecommitdiffstats
path: root/tools/nwc-sw-test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/nwc-sw-test.js')
-rw-r--r--tools/nwc-sw-test.js271
1 files changed, 270 insertions, 1 deletions
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index 62248a4..5a15d48 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -5,7 +5,8 @@ import { hex } from '@scure/base';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
-import { respondFromBg } from '../src/nwc-respond.js';
+import { respondFromBg, tendLnPays } from '../src/nwc-respond.js';
+import { maybeBolt11 } from '../src/ark/lightning.js';
let fails = 0;
const check = (name, ok, detail = '') => {
@@ -118,6 +119,24 @@ console.log('\n[merchant methods (BTCPay)]');
check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs));
}
{
+ // PullCap 2026-10-24: lookups of a send in flight came back "not found".
+ // Every copy of the connection is asked; an outgoing send answers as one.
+ const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g';
+ const H = '7e9d690de6b475ef4f9e964b8a5180afc5fb11af12f0056fece6fa1788398f7e';
+ const fresher = baseRec(); fresher.updated = 2; fresher.ark = { ...fresher.ark, network: 'signet' };
+ const live = baseRec(); live.updated = 1;
+ live.mgr.actions.push({ id: 'lnpay-1', type: 'ln-pay', step: 'done', paymentHash: H, preimage: 'cd'.repeat(32), amountSat: 21, invoice: INV });
+ const { deps, published, decryptReply } = harness({ recs: [{ walletKey: 'old', rec: fresher }, { walletKey: 'w1', rec: live }] });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, deps);
+ const r = decryptReply(published[0]).result || {};
+ check('lookup_invoice finds a send in the older copy of the connection → outgoing, settled, its preimage', r.type === 'outgoing' && r.state === 'settled' && r.preimage === 'cd'.repeat(32), JSON.stringify(r));
+ live.mgr.actions[0].step = 'initiated'; delete live.mgr.actions[0].preimage;
+ const t0 = Date.now();
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, { ...deps, deadline: Date.now() + 2500 });
+ const r2 = decryptReply(published[1]).result || {};
+ check('…one still in flight (ASP unreachable here) → outgoing, pending, inside the push budget', r2.type === 'outgoing' && r2.state === 'pending' && Date.now() - t0 < 4000, JSON.stringify(r2) + ` ${Date.now() - t0} ms`);
+}
+{
const { deps } = harness(); // a mirror without receive keys
check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false);
}
@@ -178,6 +197,112 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z
check('exhausted key window → wake the user', handled === false);
}
+console.log('\n[pay outcome]');
+{
+ // a payment that goes through: the worker hands the push handler a
+ // "Payment sent" heads-up (unless the user turned those off)
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const orig = { init: ArkManager.prototype.init, pay: ArkManager.prototype.payLnInvoice, wait: ArkManager.prototype.awaitLnPay, act: ArkManager.prototype.lnAction };
+ const done = { id: 'ln-1', step: 'done', amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) };
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ ArkManager.prototype.payLnInvoice = async () => 'ln-1';
+ ArkManager.prototype.awaitLnPay = async () => done;
+ ArkManager.prototype.lnAction = () => done;
+ try {
+ const { deps, published, decryptReply } = harness();
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('a paid invoice answers with the preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === done.preimage, JSON.stringify(handled));
+ check('…and asks for a "Payment sent" notification', handled && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled));
+ const rec = baseRec(); rec.prefs = { paySent: false };
+ const h2 = harness({ rec });
+ const quiet = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, h2.deps);
+ check('payments-sent notifications off → paid quietly', quiet === true && h2.published.length === 1);
+ } finally {
+ Object.assign(ArkManager.prototype, { init: orig.init, payLnInvoice: orig.pay, awaitLnPay: orig.wait, lnAction: orig.act });
+ }
+}
+
+console.log('\n[sends begun on an earlier wake]');
+// The worker died mid-payment on an earlier wake: the action sits in the
+// mirror at 'created' with its coin reserved (PullCap, 2026-10-23).
+{
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const HASH = maybeBolt11(INV21).paymentHash;
+ const PRE = 'cd'.repeat(32);
+ const names = ['init', 'payLnInvoice', 'awaitLnPay', 'lnAction', 'driveLn'];
+ const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]]));
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ // a fresh attempt at an invoice with an action on the books is refused…
+ ArkManager.prototype.payLnInvoice = async function (invoice) {
+ const hash = maybeBolt11(invoice).paymentHash;
+ if (this.state.actions.some((a) => a.type === 'ln-pay' && a.paymentHash === hash && a.step !== 'failed')) throw new Error('a payment for this invoice is already in progress');
+ const a = { id: 'lnpay-new', type: 'ln-pay', step: 'done', invoice, paymentHash: hash, amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) };
+ this.state.actions.push(a); this.storage.save(this.state);
+ return a.id;
+ };
+ // …and a drive takes the stuck one through to done, freeing its change
+ ArkManager.prototype.awaitLnPay = async function (id) { return this.driveLn(id); };
+ ArkManager.prototype.driveLn = async function (id) {
+ const a = this.state.actions.find((x) => x.id === id);
+ if (a && a.step !== 'done') {
+ a.step = 'done'; a.preimage = PRE;
+ for (const v of this.state.vtxos) if (v.state === 'pending') v.state = 'spendable';
+ this.storage.save(this.state);
+ }
+ return a;
+ };
+ ArkManager.prototype.lnAction = function (id) { return this.state.actions.find((x) => x.id === id); };
+ const stuckRec = (hash) => {
+ const rec = baseRec();
+ rec.mgr.vtxos[0].state = 'pending';
+ rec.mgr.actions.push({ id: 'lnpay-stuck', type: 'ln-pay', step: 'created', invoice: INV21, paymentHash: hash, amountSat: 21, feeSat: 0 });
+ rec.pendingPays = [{ hash, connId: 'c1', ts: 1 }];
+ return rec;
+ };
+ try {
+ {
+ // the SAME invoice asked again: finish that send, answer with ITS preimage
+ const rec = stuckRec(HASH);
+ const log = [];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, log: (m) => log.push(m) });
+ check('same invoice → the stuck send is finished and answered with its preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE, JSON.stringify(handled));
+ check('…recorded once on the spend log, under its connection', rec.spends.length === 1 && rec.spends[0].connId === 'c1' && rec.spends[0].preimage === PRE && rec.pendingPays.length === 0, JSON.stringify(rec.spends));
+ check('…with a "Payment sent" heads-up', handled && handled.notify && handled.notify.title === 'Payment sent');
+ check('…and the diary names the step it reached', log.some((m) => /already created/.test(m)) && log.some((m) => /→ done/.test(m)), log.join(' | '));
+ }
+ {
+ // a send that settled earlier (in the app, say): its preimage again,
+ // nothing new on the spend log
+ const rec = baseRec();
+ rec.mgr.actions.push({ id: 'lnpay-old', type: 'ln-pay', step: 'done', invoice: INV21, paymentHash: HASH, amountSat: 21, feeSat: 0, preimage: PRE });
+ const { deps, published, decryptReply } = harness({ rec });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('an invoice paid earlier answers with the same preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE);
+ check('…without a second spend', (rec.spends || []).length === 0);
+ }
+ {
+ // a stuck send for ANOTHER invoice holds the only coin: finish it first,
+ // then pay the new request from the freed coin
+ const rec = stuckRec('ee'.repeat(32));
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('another invoice → the earlier send is finished, then this one paid', published.length === 1 && decryptReply(published[0]).result?.preimage === 'ab'.repeat(32), JSON.stringify(handled));
+ check('…both on the spend log', rec.spends.length === 2 && rec.spends[0].preimage === PRE && rec.spends[1].preimage === 'ab'.repeat(32));
+ check('…the earlier one gets its own "Payment sent" line', handled && handled.notices && handled.notices.length === 1 && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled));
+ }
+ {
+ // any wake at all finishes it (a lookup, a push for something else)
+ const rec = stuckRec(HASH);
+ const notices = await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} });
+ check('tendLnPays finishes a stuck send and reports it', notices.length === 1 && notices[0].title === 'Payment sent' && rec.spends.length === 1 && rec.spends[0].connId === 'c1', JSON.stringify(notices));
+ check('…and a second wake has nothing left to do', (await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} })).length === 0);
+ }
+ } finally {
+ Object.assign(ArkManager.prototype, orig);
+ }
+}
+
console.log('\n[network fit]');
{
// a mutinynet-era mirror must not answer a mainnet invoice with a nonsense
@@ -239,5 +364,149 @@ console.log('\n[sibling record hygiene]');
check('stale record keeps its coin state', saved.wStale.mgr.vtxos.length === 1);
}
+
+console.log('\n[period budgets + get_budget]');
+{
+ const { budgetWindow } = await import('../src/nwc-budget.js');
+ // a monthly budget: the app's counter (mirrored, stamped with this month) plus the worker's own spends count
+ const rec = baseRec();
+ rec.connections = [{ ...conn, period: 'month', dailySat: 2000, spentDate: budgetWindow('month'), spentToday: 1500 }];
+ rec.spends = [{ connId: 'c1', amountSat: 490, feeSat: 0, ts: Date.now() }];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ const r = decryptReply(published[0]);
+ check('app counter + worker spends over the monthly budget → refused, naming the period', handled && r.error?.code === 'QUOTA_EXCEEDED' && /monthly budget \(10 sat\)/.test(r.error.message), JSON.stringify(r.error));
+}
+{
+ const { budgetWindow } = await import('../src/nwc-budget.js');
+ const rec = baseRec();
+ // a counter from LAST month and a worker spend from last month: neither counts this month
+ const lastMonth = new Date(); lastMonth.setUTCMonth(lastMonth.getUTCMonth() - 1);
+ rec.connections = [{ ...conn, period: 'month', dailySat: 2000, spentDate: budgetWindow('month', lastMonth.getTime()), spentToday: 1999 }];
+ rec.spends = [{ connId: 'c1', amountSat: 1999, feeSat: 0, ts: lastMonth.getTime() }];
+ rec.mgr.vtxos = []; // so the pay stops at "empty mirror" right after the budget check
+ const { deps, published } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('last month\'s spending does not count this month (budget passed, mirror empty → wake)', handled === false && published.length === 0);
+}
+{
+ const { budgetWindow, budgetRenewsAt } = await import('../src/nwc-budget.js');
+ const rec = baseRec();
+ rec.connections = [{ ...conn, period: 'week', dailySat: 7000, spentDate: budgetWindow('week'), spentToday: 1000 }];
+ rec.spends = [{ connId: 'c1', amountSat: 200, feeSat: 1, ts: Date.now() }];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('get_budget')) }, deps);
+ const r = decryptReply(published[0]);
+ check('get_budget: used, total, renews_at and the period, in msat', handled && r.result?.used_budget === 1201000 && r.result?.total_budget === 7000000 && r.result?.renewal_period === 'weekly' && r.result?.renews_at === budgetRenewsAt('week'), JSON.stringify(r));
+ const { deps: d2, published: p2, decryptReply: dr2 } = harness();
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('get_info')) }, d2);
+ check('get_info lists get_budget', dr2(p2[0]).result.methods.includes('get_budget'));
+}
+
+console.log('\n[revoked connection]');
+{
+ const rec = baseRec();
+ rec.connections = [{ ...conn, revoked: true }];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ const r = decryptReply(published[0]);
+ check('a pay on a revoked connection is refused UNAUTHORIZED, not ignored', handled === true && r.error?.code === 'UNAUTHORIZED' && r.result_type === 'pay_invoice', JSON.stringify(r));
+ const { deps: d2, published: p2, decryptReply: dr2 } = harness({ rec });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('get_balance', {}, 'nip04')) }, d2);
+ check('…so is a balance ask, in the asker\'s scheme', dr2(p2[0]).error?.code === 'UNAUTHORIZED' && p2[0].tags.find((t) => t[0] === 'encryption')[1] === 'nip04');
+}
+
+console.log('\n[the mirror carries what the worker needs]');
+{
+ const { buildBg } = await import('../src/nwc-bg.js');
+ const keyFor = (chain, i) => `${chain}:${i}`.padEnd(8, '0');
+ const mgrState = {
+ nextKeyIndex: 40, nextLnRecvIndex: 0,
+ vtxos: [{ id: 'v1', keyIndex: 7, state: 'spendable' }, { id: 'v0', keyIndex: 3, state: 'spent' }],
+ scheduled: [{ unlockHash: 'u1', inputIds: ['v1'], outKeyIndex: 12 }],
+ actions: [{ type: 'refresh', step: 'submitted', outKeyIndex: 15 }, { type: 'refresh', step: 'done', outKeyIndex: 16 }],
+ };
+ const rec = buildBg({ ark: {}, mgrState, keyFor, connections: [{ ...conn, period: 'month', spentDate: '2026-10', spentToday: 77, revoked: true }] });
+ check('a standing order\'s output key travels', rec.keys['12'] === keyFor(3, 12));
+ check('a renewal in flight\'s output key travels', rec.keys['15'] === keyFor(3, 15));
+ check('a finished renewal\'s does not', !rec.keys['16']);
+ check('the spent coin\'s key does not, the live one\'s does', !rec.keys['3'] && !!rec.keys['7']);
+ const c = rec.connections[0];
+ check('period, the spent counter and the revocation ride along', c.period === 'month' && c.spentDate === '2026-10' && c.spentToday === 77 && c.revoked === true);
+ check('…and the budget period defaults to a day', buildBg({ ark: {}, mgrState, keyFor, connections: [conn] }).connections[0].period === 'day');
+}
+{
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const m = ArkManager.mergeScheduled([{ unlockHash: 'a' }, { unlockHash: 'b' }], [{ unlockHash: 'b' }, { unlockHash: 'c' }, { inputIds: [] }]);
+ check('standing orders merge by unlock hash', m.map((s) => s.unlockHash).join() === 'a,b,c', JSON.stringify(m));
+}
+
+console.log('\n[tending renewals while closed]');
+{
+ const { tendRenewals } = await import('../src/nwc-respond.js');
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const calls = [];
+ const orig = { init: ArkManager.prototype.init, tip: ArkManager.prototype._tipMemo, drive: ArkManager.prototype._driveScheduled, ensure: ArkManager.prototype.ensureScheduledRefreshes, refresh: ArkManager.prototype._driveRefresh };
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); calls.push(['init', this.network]); return this; };
+ ArkManager.prototype._tipMemo = async function () { calls.push(['tip']); return 100; };
+ ArkManager.prototype._driveScheduled = async function () {
+ calls.push(['drive', (this.state.scheduled || []).length]);
+ // the round for one order ran: it is promoted — out of scheduled, into a refresh action in flight
+ const ran = (this.state.scheduled || []).find((s) => s.unlockHash === 'ran');
+ this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== ran);
+ if (ran) (this.state.actions ||= []).push({ id: 'r1', type: 'refresh', step: 'submitted', unlockHash: ran.unlockHash });
+ };
+ ArkManager.prototype._driveRefresh = async function (a) { calls.push(['claim', a.id, a.step]); a.step = 'done'; };
+ ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max, grid) { calls.push(['ensure', margin, max, grid]); return 1; };
+ try {
+ const rec = baseRec();
+ rec.key4 = 'ff'.repeat(32);
+ rec.custody = 'self'; // a renewing tier: the worker covers bare coins
+ rec.mgr.nextKeyIndex = 5;
+ rec.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; // two upcoming indices in the window
+ rec.mgr.scheduled = [{ unlockHash: 'ran', inputIds: ['v1'], outKeyIndex: 2 }, { unlockHash: 'waiting', inputIds: ['v1'], outKeyIndex: 3 }];
+ const saved = [];
+ const n = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async (k, r) => saved.push(k), log: () => {} });
+ check('claims what ran, then covers what is bare: orders limited to the key window, snapped to the 48-block grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2, 48]]), JSON.stringify(calls));
+ check('one claim and one order counted, the record saved', n === 2 && saved.includes('w1'));
+ check('the claimed order is gone from the mirror, its claim done', rec.mgr.scheduled.length === 1 && rec.mgr.scheduled[0].unlockHash === 'waiting' && rec.mgr.actions[0].step === 'done');
+ // a claim left in flight by an earlier wake (out of time, unconfirmed) is driven on by the next
+ calls.length = 0;
+ const rec5 = baseRec(); rec5.key4 = 'ff'.repeat(32); rec5.keys = { 1: 'aa' };
+ rec5.mgr.actions = [{ id: 'r2', type: 'refresh', step: 'issued' }, { id: 'r3', type: 'refresh', step: 'done' }, { id: 's1', type: 'send', step: 'created' }];
+ const n5 = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w5', rec: rec5 }], saveFn: async () => {} });
+ check('a renewal claim in flight is driven on; finished ones and other actions are not', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['claim', 'r2', 'issued']]) && n5 === 1, JSON.stringify(calls));
+
+ // the default tier — coinos holds the coins — registers no order at all,
+ // however many keys the window holds (the claim of an order that already
+ // ran still happens: it was priced and promised when registered)
+ calls.length = 0;
+ const rec6 = baseRec(); rec6.key4 = 'ff'.repeat(32); rec6.mgr.nextKeyIndex = 5; rec6.keys = { 1: 'aa', 5: 'bb', 6: 'cc' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w6', rec: rec6 }], saveFn: async () => {} });
+ check('custodial (default, or a mirror without the field) → claims only, no orders', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0]]), JSON.stringify(calls));
+ calls.length = 0;
+ const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'exit'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} });
+ check('exit tier → the same backstop as self (depth is the app\'s business)', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 36, 1, 48]]), JSON.stringify(calls));
+
+ calls.length = 0;
+ const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.custody = 'self'; rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w2', rec: rec2 }], saveFn: async () => {} });
+ check('no key for an order\'s output → claims still, registers nothing (regtest margin would be 6)', JSON.stringify(calls) === JSON.stringify([['init', 'regtest'], ['tip'], ['drive', 0]]), JSON.stringify(calls));
+
+ calls.length = 0;
+ const old = baseRec(); // no key4: a mirror from before invoices — can't sign for the mailbox
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w3', rec: old }], saveFn: async () => {} });
+ check('an old mirror is left alone', calls.length === 0);
+
+ calls.length = 0;
+ const rec4 = baseRec(); rec4.key4 = 'ff'.repeat(32);
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w4', rec: rec4 }], saveFn: async () => {}, deadline: Date.now() - 1 });
+ check('out of push budget → nothing started', calls.length === 0);
+ } finally {
+ Object.assign(ArkManager.prototype, { init: orig.init, _tipMemo: orig.tip, _driveScheduled: orig.drive, ensureScheduledRefreshes: orig.ensure, _driveRefresh: orig.refresh });
+ }
+}
+
console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ background responder behaves');
process.exit(fails ? 1 : 0);