1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
|
// Deterministic browser coverage: real post actions and zap state, fake payment
// settlement. No wallet funds, relays, or payment services are contacted.
// Run: bun tools/zap-feedback-test.js
import assert from 'node:assert/strict';
import puppeteer from './puppeteer.js';
const app = await Bun.file('src/app.js').text();
const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
const entry = `
import { messagesFeature } from './src/features/messages.js';
import { zapsFeature } from './src/features/zaps.js';
import { arkFeature } from './src/features/ark.js';
${domHelper}
const me = '1'.repeat(64), author = '2'.repeat(64), id = '3'.repeat(64);
const ev = { id, pubkey: author, kind: 1, tags: [], content: 'A little lightning goes a long way.' };
let feature, ark, arkResolve, arkReject, amount = 21, sound = true, mode = 'pending', calls = 0, toasts = [], loginPk = null;
const ui = { screen: 'wallet', chatOpen: true };
const wallet = { nostr: { pk: me }, loadFeatureState: () => ({}), saveFeatureState() {}, loadArkState() {}, registerCacheExtension() {},
nostrProfile: async () => { throw new Error('Payment unavailable'); } };
const ctx = { h, ui, wallet, render, toast: (msg) => toasts.push(msg),
zapDefaultSat: () => amount, setZapDefaultSat: (n) => amount = n,
zapSound: () => sound, setZapSound: (on) => sound = on,
brandHeader: () => null,
testPay: () => new Promise((resolve, reject) => { arkResolve = resolve; arkReject = reject; }),
showSend: () => { ui.chatOpen = false; },
hook(name, ...args) {
if (name === 'zapNpub') { calls++; if (mode === 'ark') ark.testStart(...args); return mode !== 'unavailable'; }
if (name === 'canLnPay' || name === 'arkReady') return true;
if (name === 'zapSettled') return feature.zapSettled(...args);
if (name === 'nostrLoginIdentity') return loginPk ? { pubkey: loginPk } : null;
return false;
},
};
function render() {
if (!feature) return;
document.querySelector('#actions').replaceChildren(feature.testActions(author, ev, { canZap: true }));
document.querySelector('#setup').replaceChildren(...(ui.zapSetup ? [feature.testSetup()] : []));
}
feature = messagesFeature(ctx);
ark = arkFeature(ctx);
function receipt(receiptId, pk, sats) {
feature.testReceipt({ id: receiptId, kind: 9737, pubkey: pk, created_at: Math.floor(Date.now()/1000), tags: [['e', id], ['amount', String(sats)]], content: '' });
}
receipt('base', author, 100); render();
window.test = {
get calls() { return calls; }, get toasts() { return toasts; }, get amount() { return amount; }, ui, render,
settle: (ok) => feature.zapSettled(id, ok, amount),
receipt: () => receipt('ours', me, amount),
unavailable: () => mode = 'unavailable',
setup: () => { amount = 0; mode = 'pending'; },
lightningFailure: () => zapsFeature(ctx).lnZapNpub(author, '', id, 21),
useArk: () => { mode = 'ark'; amount = 21; },
finishArk: (ok) => ok ? arkResolve() : arkReject(new Error('Ark payment failed')),
login: (pk) => loginPk = pk,
zapper: (pubkey, P) => feature.testZapper({ kind: 9737, pubkey, tags: [['e', id], ['amount', '21'], ...(P ? [['P', P]] : [])] }),
senderTag: () => ark.testSenderTag(),
};
`;
const bundle = await Bun.build({
entrypoints: ['zap-test-entry'], target: 'browser',
plugins: [{ name: 'zap-test', setup(build) {
build.onResolve({ filter: /^zap-test-entry$/ }, () => ({ path: 'entry', namespace: 'zap-test' }));
build.onLoad({ filter: /.*/, namespace: 'zap-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({
contents: (await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testActions: noteActions, testReceipt: noteReceipt, testSetup: zapSetupScreen, testZapper: zapperOf,"), loader: 'js',
}));
build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({
contents: (await Bun.file(path).text()).replace("id: 'ark',", `id: 'ark', testSenderTag: zapSenderTag, testStart(...args) {
connectArk = async () => ({});
lookupArkZapTarget = async () => ({ status: 'ready', address: 'test' });
performArkZap = ctx.testPay;
startNpubPay(...args);
},`), loader: 'js',
}));
} }],
});
assert(bundle.success, bundle.logs.join('\n'));
const css = await Bun.file('src/style.css').text();
const js = await bundle.outputs[0].text();
const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}
body { padding: 110px 16px; } article { max-width: 480px; margin: auto; padding: 20px; background: var(--surface); border: 1px solid var(--line); border-radius: 20px; } #actions { margin-top: 30px; }
</style><article data-zap-post="${'3'.repeat(64)}"><strong>@coinos</strong><p>A little lightning goes a long way.</p><div id="actions"></div></article><div id="setup"></div><script type="module">${js}</script>`;
const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
window.zapAudio = { starts: 0, context: null, buffer: null };
const NativeAudioContext = window.AudioContext;
window.AudioContext = class extends NativeAudioContext {
constructor(...args) { super(...args); zapAudio.context = this; }
createBufferSource() {
const source = super.createBufferSource(), start = source.start.bind(source);
source.start = (...args) => { zapAudio.starts++; zapAudio.buffer = source.buffer; return start(...args); };
return source;
}
};
});
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
const check = (name, value) => { assert(value, name); console.log(' ✓ ' + name); };
const state = () => page.evaluate(() => ({
tally: document.querySelector('.note-zap').textContent.trim(),
flying: document.querySelector('.note-zap').classList.contains('flying'),
effects: document.querySelectorAll('.zap-fx').length, calls: test.calls,
}));
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(server.url.href);
await page.waitForSelector('.note-zap');
await pause(600);
// a live AudioContext opens the phone's speaker path (a pop on every open
// or refocus of the installed app) — none may exist before a tap
check('no audio context is opened before the first tap', await page.evaluate(() => zapAudio.context === null));
await page.click('.note-zap');
let s = await state();
check('tap starts effect and adds amount before payment settles', s.effects === 1 && s.tally === '121' && s.flying && s.calls === 1);
check('the entire post trembles on tap', await page.evaluate(() => {
window.tremble = document.querySelector('article').getAnimations()[0];
return !!tremble && tremble.effect.getTiming().duration === 360;
}));
check('the same tap plays the supplied MP3 clip', await page.evaluate(() => {
const samples = zapAudio.buffer?.getChannelData(0);
return zapAudio.starts === 1 && zapAudio.context.state === 'running'
&& zapAudio.buffer.duration > 1.2 && zapAudio.buffer.duration < 1.6
&& samples.some(v => Math.abs(v) > .05)
&& samples.every(v => Number.isFinite(v));
}));
await page.evaluate(() => { window.effect = document.querySelector('.zap-fx'); test.render(); });
s = await state();
check('rerenders preserve the effect and charge nothing', s.effects === 1 && s.calls === 1 && await page.evaluate(() => effect.isConnected));
check('rerenders do not repeat the sound', await page.evaluate(() => zapAudio.starts === 1));
check('rerenders preserve the same post tremble', await page.evaluate(() => document.querySelector('article').getAnimations()[0] === tremble));
// A zap still in the air is no reason to refuse the next one.
await page.click('.note-zap');
s = await state();
check('a second tap while the first flies stacks another zap', s.effects === 2 && s.calls === 2 && s.tally === '142' && s.flying);
check('the second tap has its own sound', await page.evaluate(() => zapAudio.starts === 2));
// Freeze animations at their impact frame for an inspectable mobile preview.
await page.evaluate(() => document.querySelectorAll('.zap-fx *').forEach((e) => e.getAnimations().forEach((a) => { a.pause(); a.currentTime = 230; })));
const bounds = await page.$eval('.zap-fx-amount', (e) => { const r = e.getBoundingClientRect(); return { left: r.left, right: r.right }; });
check('amount stays in the mobile viewport', bounds.left >= 0 && bounds.right <= 390);
await page.screenshot({ path: '/tmp/coinos-zap-feedback.png' });
await page.evaluate(() => test.settle(false)); await pause(100);
s = await state();
check('one failure takes one zap off; the other keeps flying', s.tally === '121' && s.flying && s.effects === 2);
await page.evaluate(() => test.settle(false)); await pause(100);
s = await state();
check('failure restores the previous total without another animation', s.tally === '100' && !s.flying && s.effects === 2);
check('failure does not play another sound', await page.evaluate(() => zapAudio.starts === 2));
await pause(1750);
check('effect cleans up', (await state()).effects === 0);
check('the post returns to rest without residual transforms', await page.$eval('article', e => !e.getAnimations().length && getComputedStyle(e).transform === 'none'));
await page.click('.note-zap');
await page.evaluate(() => { test.receipt(); test.settle(true); }); await pause(100);
s = await state();
check('receipt arriving before settlement does not double count', s.tally === '121' && !s.flying && s.effects === 1);
await pause(1750);
await page.evaluate(() => test.settle(true)); await pause(100);
check('late confirmation never replays the effect', (await state()).effects === 0);
check('confirmation never replays the sound', await page.evaluate(() => zapAudio.starts === 3));
check('a repeated confirmation does not count the zap again', (await state()).tally === '121');
await page.evaluate(() => test.unavailable());
await page.click('.note-zap'); await pause(100);
check('unavailable payments roll back in place with an error toast', (await state()).tally === '121' && await page.evaluate(() => test.toasts.length === 1 && test.ui.chatOpen));
await page.evaluate(() => test.lightningFailure()); await pause(100);
check('Lightning rejection reports its error without navigation', await page.evaluate(() => test.toasts.at(-1).includes('Payment unavailable') && test.ui.chatOpen));
await pause(1750);
await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]);
await page.evaluate(() => test.setup());
await page.click('.note-zap');
check('first-time setup waits for an amount before animating', (await state()).effects === 0);
await page.click('#setup .btn-primary');
check('saving the first amount animates immediately', (await state()).effects === 1);
check('reduced motion uses a quiet fade without strike or particles', await page.evaluate(() => getComputedStyle(document.querySelector('.zap-fx-bolt')).display === 'none' && getComputedStyle(document.querySelector('.zap-fx-amount')).animationName === 'zap-amount-reduced'));
check('reduced motion disables the post tremble', await page.$eval('article', e => !e.getAnimations().length));
await page.evaluate(() => { test.settle(false); test.useArk(); }); await pause(100);
await page.click('.note-zap'); await pause(100);
check('Ark payment begins with the optimistic amount', (await state()).tally === '142');
await page.evaluate(() => test.finishArk(false)); await pause(100);
check('Ark failure rolls back and toasts without opening Send', (await state()).tally === '121' && await page.evaluate(() => test.ui.chatOpen && !test.ui.arkZap && test.toasts.at(-1).includes('Ark payment failed')));
await pause(1750);
await page.click('.note-zap'); await pause(100);
const toastCount = await page.evaluate(() => test.toasts.length);
await page.evaluate(() => test.finishArk(true)); await pause(100);
check('Ark success settles silently without a second effect', (await state()).tally === '142' && !(await state()).flying && (await state()).effects === 1 && await page.evaluate((n) => test.toasts.length === n, toastCount));
// Holding the bolt opens the amount screen instead of paying.
const calls = await page.evaluate(() => test.calls);
const box = await (await page.$('.note-zap')).boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down(); await pause(650); await page.mouse.up(); await pause(100);
check('holding the bolt opens the amount screen without paying', await page.evaluate((n) => test.calls === n && document.querySelector('#setup input')?.value === '21', calls));
await page.evaluate(() => { const i = document.querySelector('#setup input'); i.value = '50'; i.dispatchEvent(new Event('input')); });
await page.click('#setup .btn-primary'); await pause(100);
check('saving from the held screen changes the amount and pays nothing', await page.evaluate((n) => test.calls === n && !document.querySelector('#setup input') && test.amount === 50, calls));
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down(); await pause(200); await page.mouse.up(); await pause(100);
check('a short press is still a tap', await page.evaluate((n) => test.calls === n + 1 && !document.querySelector('#setup input'), calls));
await page.evaluate(() => test.finishArk(true)); await pause(100);
// The same screen switches the sound off; the strike and tally stay.
await page.mouse.down(); await pause(650); await page.mouse.up(); await pause(100);
check('the held screen offers the sound switch, on by default', await page.$eval('#setup input[type=checkbox]', (e) => e.checked));
await page.click('#setup input[type=checkbox]');
await page.click('#setup .btn-primary'); await pause(100);
const starts = await page.evaluate(() => zapAudio.starts);
await page.click('.note-zap'); await pause(100);
check('with the sound off a tap still strikes and counts, silently', await page.evaluate((n, c) => zapAudio.starts === n && test.calls === c + 2 && !!document.querySelector('.zap-fx'), starts, calls));
await page.evaluate(() => test.finishArk(true)); await pause(100);
// the last sound was a while ago: the speaker path is closed again
await page.waitForFunction(() => zapAudio.context.state === 'suspended', { timeout: 6000 }).catch(() => {});
check('the audio context is suspended again once the sound has played', await page.evaluate(() => zapAudio.context.state === 'suspended'));
const silentPage = await browser.newPage();
silentPage.on('pageerror', e => errors.push(e.message));
await silentPage.evaluateOnNewDocument(() => {
window.AudioContext = class { constructor() { throw new Error('Audio disabled'); } };
});
await silentPage.setRequestInterception(true);
silentPage.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
await silentPage.goto(server.url.href);
await silentPage.waitForSelector('.note-zap');
await silentPage.click('.note-zap');
check('unavailable audio cannot block the zap or animation', await silentPage.evaluate(() => test.calls === 1 && !!document.querySelector('.zap-fx') && document.querySelector('.note-zap').textContent.trim() === '121'));
await silentPage.close();
// Ark receipts are signed by the wallet key; the sender tag names the person.
check('an Ark receipt without a sender tag credits its author', await page.evaluate(() => test.zapper('2'.repeat(64)) === '2'.repeat(64)));
check('an Ark receipt credits its sender tag over its author', await page.evaluate(() => test.zapper('2'.repeat(64), 'a'.repeat(64)) === 'a'.repeat(64)));
check('no login identity: receipts carry no sender tag', await page.evaluate(() => test.senderTag().length === 0));
await page.evaluate(() => test.login('b'.repeat(64)));
check('a linked login identity is named as the sender', await page.evaluate(() => JSON.stringify(test.senderTag()) === JSON.stringify([['P', 'b'.repeat(64)]])));
check('our own older receipts read as our login identity', await page.evaluate(() => test.zapper('1'.repeat(64)) === 'b'.repeat(64)));
check("someone else's untagged receipt still reads as them", await page.evaluate(() => test.zapper('2'.repeat(64)) === '2'.repeat(64)));
check('no browser errors', errors.length === 0);
} finally { await browser.close(); server.stop(true); }
|