aboutsummaryrefslogtreecommitdiffstats
path: root/tools/signup-quick-test.js
blob: a269faf5de8fccbb2d512837e7cbe0f815b8c9f8 (plain) (blame)
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
// Signing up is a name and a look, nothing else (Adam, 2026-11-15): "i'd like
// to move away from requiring people to backup and verify a seed phrase to
// sign up. we should just have them claim a name and pick their avatar/cover
// and they should be in. we should also not create a savings account by
// default but let them add it later … adding additional auth methods like
// passkey, nostr, or google should be possible once they've created their
// account."
//
// Mainnet, with the Ark server's info read for real and the names registrar
// mocked (nothing is claimed): Create an account → no twelve words, no check
// → Claim your name (no "Not now") → Choose your look → in, on a Spending-only
// wallet (no carousel, no Savings face); the wallet line asks for a way back
// in; Settings → Wallet shows "Ways back in" (passkey via a CDP virtual
// authenticator → listed, the line goes away) and "Add a savings account"
// (→ the Savings face appears); a reload keeps both.
// Run: bun tools/signup-quick-test.js   (NO_BUILD=1 to reuse dist/)
import puppeteer from './puppeteer.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
const server = Bun.serve({ port: 0, fetch: async (req) => {
  const path = new URL(req.url).pathname;
  const f = Bun.file('dist' + path);
  return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
const errs = [];
const registered = [];
const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type,authorization', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS' };
await page.setRequestInterception(true);
page.on('request', (r) => {
  const u = new URL(r.url());
  if (u.origin === server.url.origin || u.hostname === 'ark.coinos.io') return r.continue();
  if (u.hostname !== 'names.coinos.io') return r.abort();
  if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '{}' });
  const reply = (j, status = 200) => r.respond({ status, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(j) });
  if (u.pathname === '/register' && r.method() === 'POST') { const j = JSON.parse(r.postData() || '{}'); registered.push(j.name); return reply({ ok: true, name: j.name }); }
  if (u.pathname.startsWith('/name/')) return reply({ taken: false });
  if (u.pathname.startsWith('/pubkey/')) return reply({});
  return reply({});
});
page.on('pageerror', (e) => errs.push(String(e)));
const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x);
const text = () => page.evaluate(() => document.body.innerText);
const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const openWalletPage = async () => {
  await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
  await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(600);
};
try {
  await page.setViewport({ width: 390, height: 844 });
  const cdp = await page.createCDPSession();
  await cdp.send('WebAuthn.enable');
  await cdp.send('WebAuthn.addVirtualAuthenticator', { options: {
    protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, hasPrf: true, automaticPresenceSimulation: true } });
  await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet'));
  await page.reload({ waitUntil: 'domcontentloaded' });
  await page.waitForSelector('#onb-create', { timeout: 15000 });
  check('the front door says Create an account', (await page.$eval('#onb-create', (e) => e.textContent.trim())) === 'Create an account');
  await page.click('#onb-create');
  check('no seed screens: straight to Claim your name', await waitText('claim your name', 20000));
  const t1 = await text();
  check('no twelve words or check on the way', !(await page.$('.words')) && !/which word/i.test(t1));
  check('no "Not now" on a sign-up\'s name step', !/not now/i.test(t1));
  await page.waitForSelector('.onb input[type=text]', { timeout: 20000 });
  await page.type('.onb input[type=text]', 'quicktester');
  await click('claim');
  check('the name goes to the registrar', await (async () => { for (let i = 0; i < 80 && !registered.length; i++) await sleep(250); return registered.includes('quicktester'); })(), registered.join());
  check('then the look: avatar and cover', await (async () => { for (let i = 0; i < 80; i++) { if (await page.$('.onb-cover')) return true; await sleep(250); } return false; })());
  await click('continue'); await sleep(800);
  if (await waitText("you're all set", 8000)) await click('enter');
  await page.waitForSelector('.balance-face', { timeout: 20000 });
  await sleep(1500);
  const shape = async () => page.evaluate(() => ({
    faces: document.querySelectorAll('.balance-face').length,
    carousel: !!document.querySelector('.bal-carousel'),
    label: document.querySelector('.balance-face .small.faint')?.textContent.trim(),
    body: document.body.innerText,
  }));
  let s = await shape();
  check('in, on Spending alone (no Savings face, no name on it)', s.faces === 1 && !s.carousel && !s.label, JSON.stringify({ faces: s.faces, carousel: s.carousel, label: s.label }));
  // asked once, and only with money in the account (2026-11-15): not on an empty one
  check('no way-back-in prompt on an empty account', !/add a way back into your account/i.test(s.body) && !(await page.$('.ways-back-prompt')));
  // money arrives in Spending (the ark feature, found on the heap, made to
  // report a balance; then a repaint)
  const giveBalance = async () => {
    const protoH = await page.evaluateHandle(() => Object.prototype);
    const all = await page.queryObjects(protoH);
    const found = await page.evaluate((objs) => {
      const f = objs.find((o) => { try { return o && o.id === 'ark' && typeof o.spendingSat === 'function'; } catch { return false; } });
      if (!f) return false;
      f.spendingSat = () => 5000;
      window.dispatchEvent(new Event('resize'));
      document.querySelector('.tabs button')?.click(); document.querySelector('.tabs button')?.click();
      return true;
    }, all);
    await all.dispose(); await protoH.dispose();
    await sleep(800);
    return found;
  };
  check('(test: found the ark feature)', await giveBalance());
  check('with a balance, it asks once for a way back in', !!(await page.$('.ways-back-prompt')), (await text()).slice(0, 300).replace(/\n/g, ' | '));
  await page.evaluate(() => [...document.querySelectorAll('.ways-back-prompt button')].find((b) => /not now/i.test(b.textContent)).click()); await sleep(400);
  check('Not now puts it away', !(await page.$('.ways-back-prompt')));
  await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.balance-face', { timeout: 20000 }); await sleep(1500);
  await giveBalance();
  check('...and never again, not after a reload', !(await page.$('.ways-back-prompt')));
  for (let i = 0; i < 60 && !(await page.$('#recv-amount')); i++) await sleep(250);
  check('Receive has an Amount button', !!(await page.$('#recv-amount')));
  check('no History heading on an empty wallet', !/\bHISTORY\b/.test(await page.evaluate(() => document.body.innerText)));
  if (process.env.SHOT3) await page.screenshot({ path: process.env.SHOT3 });
  await page.click('#recv-amount'); await sleep(500);
  check('Amount opens the amount pad', !!(await page.$('.pos-amount')) && /request an amount/i.test(await text()));
  if (process.env.SHOT4) await page.screenshot({ path: process.env.SHOT4 });
  await click('back'); await sleep(500);
  check('Back returns to Receive', !(await page.$('.pos-amount')) && !!(await page.$('#recv-amount')));
  const acct = await page.evaluate(() => JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}'));
  const a0 = (acct.accounts || [])[0] || {};
  check('the account: spending-only, phrase unseen, saved to the device', a0.kind === 'spending' && a0.savingsLater && a0.seedSeen === false && a0.persisted, JSON.stringify({ kind: a0.kind, later: a0.savingsLater, seen: a0.seedSeen, persisted: a0.persisted }));

  await openWalletPage();
  check('Settings → Wallet opens with Ways back in', !!(await page.$('.ways-back')) && /ways back in/i.test(await text()));
  const views = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view));
  check('the ways back in read as auth buttons', (await page.$$eval('.ways-back-add', (l) => l.map((e) => e.textContent.trim()))).join('|') === 'Passkey auth|Google auth|Nostr auth|Pen and paper', (await page.$$eval('.ways-back-add', (l) => l.map((e) => e.textContent.trim()))).join('|'));
  check('only Spending is listed, with Add a savings account', views.join() === 'spending' && !!(await page.$('#add-savings')), views.join());
  if (process.env.SHOT2) { await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); await page.evaluate(() => document.querySelector('.balance-settings-row').closest('.card').scrollIntoView()); await sleep(300); await page.screenshot({ path: process.env.SHOT2 }); await page.evaluate(() => document.documentElement.removeAttribute('data-theme')); }
  if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.ways-back').scrollIntoView()); await page.screenshot({ path: process.env.SHOT }); }
  // a passkey: the relays are stubbed, so the publish fails offline — the
  // error says so and nothing is listed
  await page.evaluate(() => document.querySelector('.ways-back-add[data-way="passkey"]')?.click());
  await sleep(4000);
  const pk = await page.evaluate(() => ({ rows: [...document.querySelectorAll('.ways-back-row')].map((e) => e.dataset.way), err: document.querySelector('.ways-back .notice.err')?.textContent || '' }));
  check('adding a passkey lists it', pk.rows.includes('passkey') && !pk.err, JSON.stringify(pk));
  const backups = await page.evaluate(() => (window.__relayGuard.events || []).filter((e) => e.kind === 30078 && e.tags.some((t) => t[0] === 'd' && t[1] === 'coinos:wallet:v1')));
  // the list and the reminder, as the app sees them (record a method directly)
  check('the phrase card waits behind Pen and paper', !(await page.$('.recovery-card')));
  await page.evaluate(() => document.querySelector('.ways-back-add[data-way="phrase"]')?.click());
  await sleep(500);
  check('Pen and paper opens the recovery phrase card', !!(await page.$('.recovery-card .words')));

  await page.evaluate(() => document.getElementById('add-savings')?.click()); await sleep(800);
  const views2 = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view));
  check('Add a savings account adds Savings beside Spending', views2.join() === 'spending,savings' && !(await page.$('#add-savings')), views2.join());
  await page.goBack(); await page.goBack(); await sleep(800);
  s = await shape();
  check('the wallet now has both faces', s.carousel && s.faces === 2, JSON.stringify({ faces: s.faces, carousel: s.carousel }));
  await page.reload({ waitUntil: 'domcontentloaded' });
  await page.waitForSelector('.balance-face', { timeout: 20000 }); await sleep(1500);
  s = await shape();
  check('a reload keeps Savings', s.carousel && s.faces === 2);

  // A new device: everything here wiped, the relays holding only what the
  // passkey published. Sign in → Passkey opens THIS account, as itself.
  const before = await page.evaluate(() => {
    const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
    const a = (st.accounts || [])[0] || {};
    return { mnemonic: a.mnemonic, nostrPk: a.nostrPk };
  });
  before.backups = backups;
  check('the passkey published one encrypted seed copy', before.backups.length >= 1 && !before.backups[0].content.includes(before.mnemonic.split(' ')[0] + ' '), String(before.backups.length));
  const backup = before.backups[before.backups.length - 1];
  await page.evaluateOnNewDocument((ev) => {
    const send = WebSocket.prototype.send;
    WebSocket.prototype.send = function (d) {
      try {
        const m = JSON.parse(d);
        if (m[0] === 'REQ' && m.slice(2).some((f) => (f.kinds || []).includes(30078) && (f.authors || []).includes(ev.pubkey))) {
          const ws = this;
          setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })), 1);
        }
      } catch {}
      return send.call(this, d);
    };
  }, backup);
  await page.evaluate(async () => {
    localStorage.clear(); sessionStorage.clear();
    for (const d of (await indexedDB.databases?.() || [])) if (d.name) indexedDB.deleteDatabase(d.name);
    localStorage.setItem('btc-wallet-network', 'mainnet');
  });
  await page.reload({ waitUntil: 'domcontentloaded' });
  await page.waitForSelector('#onb-signin', { timeout: 15000 });
  await page.click('#onb-signin'); await sleep(500);
  await click('passkey');
  for (let i = 0; i < 120 && !(await page.$('.balance-face')); i++) await sleep(250);
  if (!(await page.$('.balance-face'))) console.log((await text()).slice(0, 800), errs);
  const after = await page.evaluate(() => {
    const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
    const a = (st.accounts || [])[0] || {};
    const login = Object.keys(localStorage).concat([]).filter((k) => /:nostrlogin$/.test(k)).map((k) => localStorage.getItem(k));
    return { mnemonic: a.mnemonic, nostrPk: a.nostrPk, kind: a.kind, login, onb: localStorage.getItem('btc-wallet-onb-step') };
  });
  check('the passkey opened the same account', after.mnemonic === before.mnemonic);
  check('as itself: same identity, the passkey key not adopted', after.nostrPk === before.nostrPk && !after.login.some((v) => v && v.includes(backup.pubkey)), JSON.stringify({ was: before.nostrPk?.slice(0, 8), now: after.nostrPk?.slice(0, 8) }));
  check('no wizard on the way back in', !after.onb && !(await page.$('.onb')));
  check('no page errors', errs.length === 0, errs.join(' | '));
} finally {
  await browser.close(); server.stop(true);
}
console.log(ok ? 'PASS' : 'FAIL');
process.exit(ok ? 0 : 1);