diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/cordn-interop-test.js | 127 | ||||
| -rw-r--r-- | tools/cordn-ui-test.js | 89 |
2 files changed, 216 insertions, 0 deletions
diff --git a/tools/cordn-interop-test.js b/tools/cordn-interop-test.js new file mode 100644 index 0000000..e3f3f82 --- /dev/null +++ b/tools/cordn-interop-test.js @@ -0,0 +1,127 @@ +// Cordn interop, both directions, against the public coordinator: our +// cordn-client.js on one side, the official @cordn/cli (ts-mls) on the other. +// +// 1. Alice (CLI) invites Bob (us): Bob's KeyPackage is on the coordinator, +// Alice adds him, Bob joins from the Welcome, reads Alice, answers, and +// Alice's CLI shows the answer. +// 2. Bob (us) founds a group and invites Alice: Alice accepts the Welcome +// the way her CLI does, reads Bob, answers, and Bob reads her. +// +// Needs the CLI rig: npm i @cordn/cli in CORDN_CLI_DIR (default: the +// scratchpad path below) with a state file alice.json that already has a +// group "office" and a published KeyPackage ("gen-kp main"). +// +// Run: bun tools/cordn-interop-test.js +// CORDN_DEBUG=1 shows the client's dlog lines (skipped records and why) +if (process.env.CORDN_DEBUG && typeof localStorage === 'undefined') { + globalThis.localStorage = { getItem: (k) => (k === 'coinos-debug' ? '1' : null), setItem() {}, removeItem() {} }; +} +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +// imported after the shim above (static imports would run first) +const { cordnClient } = await import('../src/cordn-client.js'); +const C = await import('../src/cordn.js'); + +const CLI_DIR = process.env.CORDN_CLI_DIR || '/tmp/claude-1000/-home-adam-coinosv3/77efc6a1-2602-46f7-b851-5580b0742cf1/scratchpad/cordn/cli'; +const CLI = `${CLI_DIR}/node_modules/.bin/cordn`; +const ALICE_STATE = `${CLI_DIR}/alice.json`; +let failed = false; +const ok = (name, cond, detail = '') => { console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) failed = true; return cond; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function alice(cmd) { + const p = Bun.spawnSync([CLI, '--state-file', ALICE_STATE, '--command', cmd], { timeout: 90_000, stdout: 'pipe', stderr: 'pipe' }); + const out = (p.stdout.toString() + p.stderr.toString()).replace(/\x1b\[[0-9;]*m/g, '').trim(); + console.log(` [alice] ${cmd}\n ${out.split('\n').slice(-5).join('\n ')}`); + return out; +} +const alicePk = () => /stablePubkey:\s*([0-9a-f]{64})/.exec(alice('status'))?.[1]; + +// an in-memory stand-in for the IndexedDB store +const memStore = () => { const m = new Map(); return { all: async () => new Map(m), put: async (k, v) => { m.set(k, structuredClone(v)); }, del: async (k) => { m.delete(k); }, wipe: async () => m.clear() }; }; + +const sk = generateSecretKey(), bob = getPublicKey(sk); +console.log('bob =', bob); +const events = []; +const bobClient = cordnClient({ + scope: 'test', pubkey: bob, identity: async () => ({ pubkey: bob, signer: sk }), + on: (type, g, data) => { events.push({ type, g, data }); if (type === 'message') console.log(` [bob] ${g.id.slice(0, 8)} <- ${data.pubkey.slice(0, 8)} k${data.kind}: ${data.content}`); }, + store: memStore(), +}); +const waitFor = async (fn, ms = 90_000) => { const t = Date.now(); while (Date.now() - t < ms) { if (fn()) return true; await bobClient.poll(); await sleep(1500); } return false; }; + +try { + await bobClient.start(); + ok('ready: KeyPackage published', await bobClient.ready()); + const apk = alicePk(); + // her regular packages are consumed by every invitation: a fresh one, and its ref names the Welcome to look for + const aliceRef = /ref=([0-9a-f]{64})/.exec(alice('gen-kp kp' + Date.now().toString(36)))?.[1]; + ok('alice reachable (her KeyPackage is on the coordinator)', !!(await bobClient.lookup(apk)), apk && apk.slice(0, 8)); + + // ---- 1. Alice invites Bob ---- (CORDN_TEST_PART=2 skips it) + if (process.env.CORDN_TEST_PART !== '2') { + alice(`add-member office ${bob}`); + ok('joined from the Welcome', await waitFor(() => [...bobClient.groups.values()].some((g) => g.fresh))); + const g1 = [...bobClient.groups.values()].find((g) => g.fresh); + if (g1) { + const v = bobClient.view(g1); + ok('metadata + inviter + members', v.name === 'Office' && g1.inviter === apk && bobClient.accounts(g1).includes(apk), JSON.stringify({ name: v.name, icon: v.icon, admins: v.admins.length, members: bobClient.accounts(g1).length, epoch: g1.tip.epoch })); + bobClient.accept(g1); + bobClient.focus(g1.id); + alice('send-to office hello bob from the cli'); + ok('read alice', await waitFor(() => g1.log.some((m) => m.content === 'hello bob from the cli'))); + const inner = bobClient.event(9, 'hello alice from coinos', []); + ok('sent', await bobClient.send(g1, inner)); + alice('sync office'); + ok("alice's CLI shows our message", alice('messages office').includes('hello alice from coinos')); + // a reaction travels too + ok('reaction sent', await bobClient.send(g1, bobClient.event(7, '👍', [['e', g1.log.find((m) => m.content === 'hello bob from the cli').id], ['p', apk], ['k', '9']]))); + } + } + + // ---- 2. Bob founds a group and invites Alice ---- + const alias = 'fc' + Date.now().toString(36); + let g2 = null; + try { g2 = await bobClient.create({ name: 'From coinos', icon: '🪙', members: [apk] }); } catch (e) { console.log(' create failed:', e.message); } + ok('founded a group with alice in it', !!g2 && bobClient.accounts(g2).includes(apk) && g2.tip.epoch === 1, g2 && JSON.stringify({ gid: g2.id, members: bobClient.accounts(g2).length, cursor: g2.cursor })); + if (g2) { + ok('group ref encodes/decodes', C.decodeGroupRef(bobClient.groupRef(g2))?.gid === g2.id, bobClient.groupRef(g2).slice(0, 40) + '…'); + ok('sent to the new group', await bobClient.send(g2, bobClient.event(9, 'welcome to my group, alice', []))); + alice('fetch-welcomes'); + const w = alice('welcomes'); + // several Welcomes may be pending from earlier runs: the one for the package we took + const id = w.split('\n').map((l) => /([0-9a-f]{64}:([0-9a-f]{64}):\d+)/.exec(l)).filter(Boolean).sort((a, b) => Number(b[1].split(':')[2]) - Number(a[1].split(':')[2]))[0]?.[1]; // the newest: the one just stored + ok('alice sees the Welcome', !!id, id && id.slice(0, 24)); + if (id) { + const acc = alice(`accept-welcome ${id} ${alias}`); + ok('alice accepted it', !/error|failed/i.test(acc) || /accept|joined/i.test(acc)); + alice(`sync ${alias}`); + ok("alice reads bob's message", alice(`messages ${alias}`).includes('welcome to my group, alice')); + alice(`send-to ${alias} hi bob, nice group`); + bobClient.focus(g2.id); + ok("bob reads alice's reply", await waitFor(() => g2.log.some((m) => m.content === 'hi bob, nice group'))); + if (process.env.CORDN_DEBUG) { + const { debugOn } = await import('../src/debug.js'); + const { cvmClient } = await import('../src/contextvm.js'); + const { finalizeEvent } = await import('nostr-tools/pure'); + const { nip44 } = await import('nostr-tools'); + console.log(' [dbg] debugOn', debugOn(), 'g2 cursor', g2.cursor, 'epoch', g2.tip.epoch, 'seen', g2.seen.length, 'log', g2.log.map((m) => m.kind + ':' + m.content.slice(0, 20))); + const probe = cvmClient({ server: C.DEFAULT_COORDINATOR.pubkey, relays: C.DEFAULT_COORDINATOR.relays, pubkey: bob, sign: async (e) => finalizeEvent(e, sk), decrypt: async (p, ct) => nip44.v2.decrypt(ct, nip44.v2.utils.getConversationKey(sk, p)) }); + try { + const r = await probe.call('msg_fetch_many', { groups: [{ gid: g2.id }] }); + console.log(' [dbg] coordinator holds cursors', (r.messages || []).map((m) => m.cursor + '@' + m.gid.slice(0, 8)).join(' ')); + for (const m of (r.messages || [])) { try { const out = C.receive(g2, { ...m, cursor: m.cursor }); console.log(' [dbg] receive', m.cursor, JSON.stringify(out).slice(0, 160)); } catch (e) { console.log(' [dbg] receive threw', m.cursor, e.message); } } + } catch (e) { console.log(' [dbg] probe failed', e.message); } + probe.stop(); + } + // rename: a GroupContextExtensions commit their side must apply + try { await bobClient.rename(g2, 'Renamed by coinos'); } catch (e) { console.log(' rename failed:', e.message); } + ok('rename applied here', bobClient.view(g2).name === 'Renamed by coinos', bobClient.view(g2).name); + alice(`sync ${alias}`); + ok("alice's CLI sees the new name", /Renamed by coinos/.test(alice(`group-info ${alias}`))); + alice(`send-to ${alias} still here after the rename`); + ok('bob reads after the rename', await waitFor(() => g2.log.some((m) => m.content === 'still here after the rename'))); + } + } + console.log(failed ? '\nFAILED' : '\nINTEROP OK'); +} catch (e) { console.log('\nCRASH:', e.stack || e.message); failed = true; } +finally { bobClient.stop(); await sleep(300); process.exit(failed ? 1 : 0); } diff --git a/tools/cordn-ui-test.js b/tools/cordn-ui-test.js new file mode 100644 index 0000000..6948428 --- /dev/null +++ b/tools/cordn-ui-test.js @@ -0,0 +1,89 @@ +// Cordn in the app, end to end, against the public coordinator: a fresh +// wallet makes a Cordn group from Messages → New group, invites Alice (the +// official @cordn/cli), sends; Alice's CLI accepts the Welcome, reads it and +// answers; the app shows her answer. Needs the CLI rig (see +// cordn-interop-test.js). +// +// Run: bun tools/cordn-ui-test.js [screenshot.png] +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { npubEncode } from 'nostr-tools/nip19'; +import { buildHtml } from '../build.js'; + +const CLI_DIR = process.env.CORDN_CLI_DIR || '/tmp/claude-1000/-home-adam-coinosv3/77efc6a1-2602-46f7-b851-5580b0742cf1/scratchpad/cordn/cli'; +const CLI = `${CLI_DIR}/node_modules/.bin/cordn`; +const ALICE_STATE = `${CLI_DIR}/alice.json`; +const shotPath = process.argv[2] || '/tmp/cordn-ui-test.png'; +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; return c; }; +function alice(cmd) { + const p = Bun.spawnSync([CLI, '--state-file', ALICE_STATE, '--command', cmd], { timeout: 90_000, stdout: 'pipe', stderr: 'pipe' }); + const out = (p.stdout.toString() + p.stderr.toString()).replace(/\x1b\[[0-9;]*m/g, '').trim(); + console.log(` [alice] ${cmd}\n ${out.split('\n').slice(-4).join('\n ')}`); + return out; +} +const apk = /stablePubkey:\s*([0-9a-f]{64})/.exec(alice('status'))?.[1]; +const aliceRef = /ref=([0-9a-f]{64})/.exec(alice('gen-kp kp' + Date.now().toString(36)))?.[1]; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5234, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => { errs.push(e.message); console.log(' [pageerror]', e.message); }); +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase() === x.toLowerCase()) || [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); +const waitFor = async (fn, ms = 25000, arg) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(250); } return false; }; +const bodyText = () => page.evaluate(() => document.body.innerText); + +try { + await page.setViewport({ width: 420, height: 860 }); + await page.goto('http://localhost:5234/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('button', 'Create a new wallet'); await sleep(300); + await click('button', 'Have an existing seed'); await sleep(300); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('button', 'Open wallet'); + await waitFor(() => /receive/i.test(document.body.innerText), 15000); + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => /message/i.test(b.textContent))?.click()); + check('messages screen', await waitFor(() => /New group/.test(document.body.innerText), 15000)); + + // New group → Cordn → Alice → create + await click('button', 'New group'); await sleep(300); + check('protocol choice shown', await waitFor(() => [...document.querySelectorAll('.seg button')].some((b) => b.textContent.trim() === 'Cordn'), 5000)); + await click('.seg button', 'Cordn'); await sleep(200); + await page.type('input[placeholder="Group name"]', 'UI test group'); + await page.type('input[placeholder^="Add people"]', npubEncode(apk)); + await page.keyboard.press('Enter'); + check('alice chip shows reachable', await waitFor(() => [...document.querySelectorAll('.chat-react.on')].some((c) => !/checking|can't/.test(c.textContent)), 30000), (await bodyText()).match(/checking…|can't join[^\n]*/)?.[0]); + await click('button', 'Create group'); + check('group screen opened', await waitFor(() => !!document.querySelector('.chat-title') && /UI test group/.test(document.querySelector('.chat-title').textContent), 60000)); + await page.type('#msg-draft', 'hello from the coinos app'); + await click('button', 'Send'); + check('our message is on screen', await waitFor(() => [...document.querySelectorAll('.chat-bubble')].some((b) => b.textContent.includes('hello from the coinos app')), 10000)); + + // Alice's side + alice('fetch-welcomes'); + const id = alice('welcomes').split('\n').map((l) => /([0-9a-f]{64}:([0-9a-f]{64}):\d+)/.exec(l)).filter(Boolean).sort((a, b) => Number(b[1].split(':')[2]) - Number(a[1].split(':')[2]))[0]?.[1]; // the newest: the one just stored + check('alice got the Welcome', !!id); + const alias = 'ui' + Date.now().toString(36); + if (id) { + alice(`accept-welcome ${id} ${alias}`); + alice(`sync ${alias}`); + check("alice reads the app's message", alice(`messages ${alias}`).includes('hello from the coinos app')); + alice(`send-to ${alias} hello app, from the cli`); + check("the app shows alice's reply", await waitFor(() => [...document.querySelectorAll('.chat-bubble')].some((b) => b.textContent.includes('hello app, from the cli')), 30000)); + check('member list shows both', await waitFor(() => /2 members/.test(document.body.innerText), 5000)); + } + await sleep(500); + await page.screenshot({ path: shotPath }); + // back home: the group row is in the list with the last message + await page.evaluate(() => document.querySelector('.chat-head button')?.click()); + check('home lists the group', await waitFor(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => /UI test group/.test(r.textContent)), 5000)); + check('no page errors', errs.length === 0, errs.join(' | ').slice(0, 300)); + console.log(ok ? '\nUI OK' : '\nFAILED'); +} finally { await browser.close(); server.stop(); process.exit(ok ? 0 : 1); } |
