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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
|
// Send clears on leaving; profile Pay starts fresh; messaging Back pops history.
// Run: bun tools/profile-actions-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 { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
import { cacheKeyFor } from '../src/wallet.js';
const peer = getPublicKey(generateSecretKey()), nextPeer = getPublicKey(generateSecretKey());
const searchPeer = getPublicKey(generateSecretKey());
const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
plugins: [{ name: 'profile-actions-test', setup(build) {
build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
window.profileActions = { ui, wallet, render, showSend, enterWallet, openImage: ctx.openImage,
get ready() { return !_bootDeciding; },
openProfile: (pk) => featureHook('openProfile', pk) };
` }));
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><script type="module">${await bundle.outputs[0].text()}</script>`;
const server = Bun.serve({ port: 0, fetch(req) {
if (new URL(req.url).pathname === '/recipient-portrait.svg') return new Response('<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" fill="#2685c8"/><circle cx="48" cy="40" r="22" fill="#ffcc77"/></svg>', { headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' } });
if (/^\/punks\/\d+\.webp$/.test(new URL(req.url).pathname)) return new Response(Bun.file('static' + new URL(req.url).pathname));
return new URL(req.url).pathname === '/' || new URL(req.url).pathname.startsWith('/npub1')
? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 });
} });
const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
try {
const page = await browser.newPage(), errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setViewport({ width: 390, height: 844 });
await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]);
const cdp = await page.createCDPSession();
await cdp.send('Network.enable');
await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
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(() => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-ark-provider:regtest', 'off'); });
await page.evaluateOnNewDocument((pk) => localStorage.setItem('btc-wallet-search-queries-v2', JSON.stringify({
carol: { t: Date.now(), rows: [{ pk, name: 'Carol', picture: '/recipient-portrait.svg', pri: 1 }] },
})), searchPeer);
await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.profileActions?.ready);
const mnemonic = generateMnemonic(wordlist);
await page.evaluate(([base, peer, nextPeer]) => localStorage.setItem(base + ':profiles', JSON.stringify({
[peer]: { name: 'Alice', picture: '/punks/18.webp', t: Date.now() },
[nextPeer]: { name: 'Bob', picture: '/punks/54.webp', t: Date.now() },
})), [cacheKeyFor(mnemonic + '\n'), peer, nextPeer]);
await page.evaluate(async (mn) => {
await profileActions.enterWallet(mn, '', { fresh: true, generated: true });
profileActions.ui.onb = null;
localStorage.removeItem('btc-wallet-onb-step');
profileActions.render();
}, mnemonic);
await page.waitForSelector('.app-bottom-nav');
// Search owns a separate profile cache. A cold portrait cache must inherit
// the suggestion's metadata and fetch the large image before selection.
await page.evaluate(() => { profileActions.showSend({ fresh: true }); profileActions.render(); });
const portraitResponse = page.waitForResponse(r => r.url().endsWith('/recipient-portrait.svg'));
await page.type('textarea.send-dest', 'carol');
await page.waitForSelector('.send-suggest .ava-img');
await portraitResponse;
await page.evaluate(() => {
window.recipientFrames = [];
const record = () => {
const a = document.querySelector('.send-avatar');
if (a) recipientFrames.push({ punk: !!a.querySelector('.punk'), hidden: getComputedStyle(a).visibility === 'hidden', picture: a.style.backgroundImage });
};
window.recipientObserver = new MutationObserver(record);
recipientObserver.observe(document.querySelector('#app'), { subtree: true, childList: true, attributes: true });
});
await page.click('.send-suggest .chat-thread-row');
await page.waitForSelector('.send-avatar');
assert.equal(await page.$eval('textarea.send-dest', e => e.value), npubEncode(searchPeer));
assert(await page.$eval('.send-avatar', e => e.classList.contains('ava-img') && e.style.backgroundImage.includes('/recipient-portrait.svg') && getComputedStyle(e).visibility !== 'hidden'), 'selection immediately uses the already-fetched suggestion portrait');
assert(await page.evaluate(() => recipientFrames.length > 0 && recipientFrames.every(f => !f.punk && !f.hidden && f.picture.includes('/recipient-portrait.svg'))), 'every selected-recipient frame has the real avatar, without fallback or placeholder');
await page.evaluate(() => recipientObserver.disconnect());
console.log('✓ Autosuggest warms the large recipient picture before selection and displays it immediately');
const openProfile = async (pk) => {
await page.evaluate((pk) => profileActions.openProfile(pk), pk);
await page.waitForSelector('.prof-actions');
};
const action = (label) => page.evaluate((label) => [...document.querySelectorAll('.prof-actions button')].find((b) => b.textContent.trim().replace(/^⚡\s*/, '') === label).click(), label);
// Exercise each real success view without making any payment.
const successes = [
{ sendResult: { txid: '1'.repeat(64) } },
{ arkLnPaid: { amountSat: 21, meta: { name: 'Previous person' } } },
{ arkZapped: { amountSat: 21, npub: npubEncode(peer) } },
{ arkSent: { amountSat: 21 } },
];
for (const previous of successes) {
await page.evaluate((previous) => {
const { ui, render, showSend } = profileActions;
ui.profilePk = null; ui.chatOpen = false;
showSend({ fresh: true });
Object.assign(ui, previous);
render();
}, previous);
assert.equal(await page.$('textarea.send-dest'), null, 'previous payment displays its success view');
await openProfile(nextPeer);
await action('Pay');
await page.waitForSelector('textarea.send-dest');
assert.equal(await page.$eval('textarea.send-dest', (e) => e.value), npubEncode(nextPeer));
assert(!(await page.$('.check-badge')), 'previous success must not win over the new payment form');
const state = await page.evaluate(() => ({
recipients: profileActions.ui.send.recipients.map(({ address, amount }) => ({ address, amount })),
results: ['sendResult', 'arkLnPaid', 'arkZapped', 'arkSent'].map((k) => profileActions.ui[k]),
}));
assert.deepEqual(state.recipients, [{ address: npubEncode(nextPeer), amount: '' }]);
assert(state.results.every((v) => v === null));
}
console.log('✓ Profile Pay replaces prior on-chain, Lightning, zap and Ark success screens with a fresh recipient form');
await openProfile(peer);
await page.evaluate(() => {
const { ui } = profileActions;
ui.send.recipients = [{ address: 'old person', amount: '5' }, { address: 'another old person', amount: '7' }];
ui.send.max = true;
ui.draft = { stale: true }; ui.broadcastTx = { stale: true }; ui.sendError = 'Previous error';
ui.arkSend = { stale: true }; ui.arkLnPay = { stale: true }; ui.arkZap = { stale: true };
ui.arkLnFromSavings = { stale: true }; ui.arkLnFromSavingsOk = true; ui.arkOffboardSend = { stale: true };
ui.zap = { stale: true }; ui.nameResolve = { text: '[email protected]' };
});
await action('Pay');
await page.waitForSelector('textarea.send-dest');
assert.equal(await page.$eval('textarea.send-dest', (e) => e.value), npubEncode(peer));
assert(await page.evaluate(() => {
const { ui } = profileActions;
return !ui.send.max && !ui.arkLnFromSavingsOk && ui.send.recipients.length === 1 && ui.send.recipients[0].amount === ''
&& ['draft', 'broadcastTx', 'arkSend', 'arkLnPay', 'arkZap', 'arkLnFromSavings', 'arkOffboardSend', 'zap', 'nameResolve', 'sendError'].every((k) => !ui[k]);
}), 'old review, resolution, amount, max and extra recipients are cleared');
console.log('✓ Fresh profile payment also clears prior drafts and recipient amounts');
const portrait = await page.$eval('.send-person', (e) => {
const a = e.querySelector('.send-avatar').getBoundingClientRect(), name = e.querySelector('span').getBoundingClientRect();
const card = e.closest('.card').getBoundingClientRect(), style = getComputedStyle(e);
return { width: a.width, height: a.height, centered: Math.abs(a.x + a.width / 2 - card.x - card.width / 2) < 1,
nameBelow: name.top >= a.bottom, border: style.borderWidth, background: style.backgroundColor, name: e.textContent };
});
assert.deepEqual(portrait, { width: 96, height: 96, centered: true, nameBelow: true, border: '0px', background: 'rgba(0, 0, 0, 0)', name: 'Alice' });
await page.screenshot({ path: '/tmp/coinos-send-profile.png' });
console.log('✓ Recipient portrait is 96px, centered above the name, without a badge border or background');
const assertBlank = async () => {
assert(await page.evaluate(() => {
const { ui } = profileActions;
return ui.send.recipients.length === 1 && ui.send.recipients[0].address === '' && ui.send.recipients[0].amount === ''
&& !ui.send.max && ui.send.coins.size === 0 && !ui.arkLnFromSavingsOk
&& ['draft', 'broadcastTx', 'sendResult', 'sendError', 'arkSend', 'arkSent', 'arkLnPay', 'arkLnPaid', 'arkZap', 'arkZapped',
'arkLnFromSavings', 'arkOffboardSend', 'zap', 'nameResolve'].every((k) => !ui[k]);
}), 'leaving Send clears the recipient, amount, selection, drafts and all feature payment states');
};
const pendingSend = async () => {
await page.evaluate((npub) => {
const { ui, render, showSend } = profileActions;
ui.profilePk = null; ui.chatOpen = false; ui.msgView = null;
showSend({ fresh: true });
ui.send.recipients[0] = { address: npub, amount: '21' };
render(); render();
}, npubEncode(peer));
assert.deepEqual(await page.evaluate(() => profileActions.ui.send.recipients.map(({ address, amount }) => ({ address, amount }))),
[{ address: npubEncode(peer), amount: '21' }], 'background renders retain an in-progress payment on Send');
// Stale hidden feature state is deliberately seeded after painting the form.
await page.evaluate(() => {
const { ui } = profileActions;
ui.draft = { stale: true }; ui.broadcastTx = { stale: true }; ui.sendResult = { stale: true }; ui.sendError = 'Previous error';
ui.arkSend = { stale: true }; ui.arkSent = { stale: true }; ui.arkLnPay = { stale: true }; ui.arkLnPaid = { stale: true };
ui.arkZap = { stale: true }; ui.arkZapped = { stale: true }; ui.arkLnFromSavings = { stale: true };
ui.arkLnFromSavingsOk = true; ui.arkOffboardSend = { stale: true }; ui.zap = { stale: true }; ui.nameResolve = { text: '[email protected]' };
});
};
for (const label of ['Wallet', 'Messages', 'Feed', 'Notifications']) {
await pendingSend();
await page.evaluate((label) => document.querySelector(`.app-nav-button[aria-label="${label}"]`).click(), label);
await assertBlank();
}
await pendingSend();
await page.evaluate(() => document.querySelector('.send-person').click());
await page.waitForSelector('.prof-actions');
assert.equal(await page.evaluate(() => profileActions.ui.profilePk), peer);
await assertBlank();
await page.goBack();
await page.waitForSelector('textarea.send-dest');
await assertBlank();
assert.equal(await page.$('.send-person'), null, 'returning to Send has no old recipient portrait');
// Native Back/Forward also leave and reopen the Send pane, without a draft.
await page.evaluate(() => { const { ui, render } = profileActions; ui.tab = 'receive'; render(); });
await pendingSend();
await page.goBack();
await page.waitForFunction(() => profileActions.ui.tab === 'receive');
await assertBlank();
await page.goForward();
await page.waitForSelector('textarea.send-dest');
await assertBlank();
console.log('✓ Leaving Send for navigation tabs, a recipient profile or browser Back clears it; returning starts blank');
await openProfile(peer);
const before = await page.evaluate(() => ({ i: history.state.i, length: history.length, path: location.pathname }));
await action('Messages');
await page.waitForSelector('.chat-head .chat-back');
assert.equal(await page.evaluate(() => profileActions.ui.msgPeer), peer);
await page.evaluate(() => Promise.all([...document.querySelectorAll('.anim-page')].flatMap((n) => n.getAnimations()).map((a) => a.finished.catch(() => {}))));
await page.click('.chat-head .chat-back');
await page.waitForSelector('.prof-actions');
assert.equal(await page.evaluate(() => profileActions.ui.profilePk), peer);
assert.deepEqual(await page.evaluate(() => ({ i: history.state.i, length: history.length, path: location.pathname })), { ...before, length: before.length + 1 },
'on-screen Back pops the DM entry and restores the exact profile history entry');
await page.goForward();
await page.waitForSelector('.chat-head .chat-back');
assert.equal(await page.evaluate(() => profileActions.ui.profilePk), null);
assert.equal(await page.evaluate(() => profileActions.ui.msgPeer), peer);
await page.goBack();
await page.waitForSelector('.prof-actions');
assert.equal(await page.evaluate(() => profileActions.ui.profilePk), peer);
console.log('✓ Messaging Back returns to its profile, and native browser Forward/Back traverse the same entries');
// A conversation opened from the chat list still returns to that list.
await page.evaluate((peer) => {
const { ui, render } = profileActions;
ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'home'; render();
ui.msgView = 'dm'; ui.msgPeer = peer; render();
}, nextPeer);
await page.waitForSelector('.chat-head .chat-back');
await page.evaluate(() => Promise.all([...document.querySelectorAll('.anim-page')].flatMap((n) => n.getAnimations()).map((a) => a.finished.catch(() => {}))));
await page.click('.chat-head .chat-back');
await page.waitForFunction(() => profileActions.ui.msgView === 'home');
assert.equal(await page.evaluate(() => profileActions.ui.profilePk), null);
console.log('✓ Conversations opened from the chat list return to it');
// Photo dismissal removes the overlay synchronously, then pops its history
// entry. Background morphs must keep the handler tied to the mounted box.
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
const photoParent = await page.evaluate(() => ({ i: history.state.i, nav: history.state.nav, path: location.pathname }));
await page.evaluate(() => { profileActions.openImage('/recipient-portrait.svg'); profileActions.render(); });
await page.waitForSelector('.lightbox img');
assert.equal(await page.evaluate(() => history.state.i), photoParent.i + 1);
await page.evaluate(() => document.addEventListener('click', () => {
window.photoClosedDuringClick = !document.querySelector('.lightbox') && !document.documentElement.classList.contains('no-scroll');
}, { once: true }));
const photoCenter = await page.$eval('.lightbox img', e => { const r = e.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });
await page.touchscreen.tap(photoCenter.x, photoCenter.y);
assert(await page.evaluate(() => photoClosedDuringClick), 'photo and scroll lock disappear during the tap, without waiting for popstate');
await page.waitForFunction(i => history.state.i === i && !document.querySelector('.lightbox'), {}, photoParent.i);
assert.deepEqual(await page.evaluate(() => ({ i: history.state.i, nav: history.state.nav, path: location.pathname })), photoParent,
'photo dismissal restores the exact originating page');
await page.goForward();
await page.waitForSelector('.lightbox');
assert(await page.evaluate(() => !!profileActions.ui.lightbox && document.documentElement.classList.contains('no-scroll')), 'Forward reopens the viewer and locks scrolling');
await page.goBack();
await page.waitForFunction(() => !profileActions.ui.lightbox && !document.querySelector('.lightbox') && !document.documentElement.classList.contains('no-scroll'));
assert.equal(await page.evaluate(() => history.state.i), photoParent.i);
assert.deepEqual(errors, []);
console.log('✓ Photo tap dismisses immediately after a render, restores its originating page, and preserves native Back/Forward; no browser errors');
} finally { await browser.close(); server.stop(true); }
|