- scripts/cordn-browser-probe.mjs: headless-Chromium proof that the built native chunk runs in a real browser — imports the chunk, runs a session against the coordinator (publish kp, create group, send), then reopens from the Dexie/IndexedDB store. Not part of npm test (needs network + Chromium). - Group ref: the panel shows/copies the group's cordn1... ref (encodeGroupRef from the crew coordinate + coordinator), so it can be opened in Staircase/cordn-web. Computed in the hook via the lazy native import. - Images: attach button uploads to Blossom (useUploadFile) and sends the URL inside the encrypted MLS envelope (['image', url]); CordnChatMessage.imageUrl + ImageWithFallback rendering. Covered by the live test. - Unread badge deliberately not built (needs a background signed request). npm test + smoke + browser probe + 3 live scenarios all pass.
206 lines
8.3 KiB
JavaScript
206 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Cordn-native browser probe.
|
|
*
|
|
* The Node live test proves the MLS logic; this proves the **browser runtime**
|
|
* of the same stack — the built native chunk actually loading in Chromium, then
|
|
* a real session against the coordinator over a browser WebSocket, with MLS
|
|
* crypto and the Dexie/IndexedDB store.
|
|
*
|
|
* It is deliberately NOT part of `npm test` (it needs network + Chromium):
|
|
*
|
|
* node scripts/cordn-browser-probe.mjs
|
|
*
|
|
* Exit 0 = the browser stack worked end to end.
|
|
*/
|
|
import { createServer } from 'node:http';
|
|
import { spawn } from 'node:child_process';
|
|
import { readFile, readdir } from 'node:fs/promises';
|
|
import { existsSync, rmSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join, extname, normalize } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import net from 'node:net';
|
|
import { randomBytes } from 'node:crypto';
|
|
import WebSocket from 'ws';
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const distDir = join(root, 'dist');
|
|
const assetsDir = join(distDir, 'assets');
|
|
const chromePath = process.env.CHROME_PATH || '/snap/bin/chromium';
|
|
const log = (m) => console.log(`[cordn-probe] ${m}`);
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// The SkateSpots coordinator (public facts, same as src/lib/coordinator.ts).
|
|
const COORDINATOR_PUBKEY = '02816a05b1d4e70a7df9c712ef6f7272774269d65bd8a2f262ce0330d0885693';
|
|
const COORDINATOR_RELAY = 'wss://relay.skate-spots.org';
|
|
|
|
if (!existsSync(join(distDir, 'index.html'))) {
|
|
console.error('[cordn-probe] dist/index.html missing — run `npm run build` first.');
|
|
process.exit(1);
|
|
}
|
|
if (!existsSync(chromePath)) {
|
|
console.error(`[cordn-probe] Chromium not found at ${chromePath}. Set CHROME_PATH.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Find the lazy chunk that carries the native Cordn module (the barrel chunk —
|
|
// it contains the coordinator method strings and the store factory).
|
|
const assets = (await readdir(assetsDir)).filter((f) => f.endsWith('.js'));
|
|
let nativeChunk = null;
|
|
for (const f of assets) {
|
|
const text = await readFile(join(assetsDir, f), 'utf8');
|
|
if (text.includes('kp_publish') && text.includes('createCordnStore')) {
|
|
nativeChunk = f;
|
|
break;
|
|
}
|
|
}
|
|
if (!nativeChunk) {
|
|
console.error('[cordn-probe] could not find the native Cordn chunk in dist/assets (build it, and make sure something imports src/lib/cordn/native).');
|
|
process.exit(1);
|
|
}
|
|
log(`native chunk: ${nativeChunk}`);
|
|
|
|
const MIME = {
|
|
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
'.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json',
|
|
'.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm',
|
|
};
|
|
|
|
const freePort = () => new Promise((res) => {
|
|
const s = net.createServer();
|
|
s.on('error', () => res(0));
|
|
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
|
|
});
|
|
|
|
const servePort = await freePort();
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
|
|
let filePath = join(distDir, normalize(urlPath).replace(/^(\.\.[/\\])+/, ''));
|
|
if (urlPath === '/' || !extname(filePath)) filePath = join(distDir, 'index.html');
|
|
let body;
|
|
try { body = await readFile(filePath); }
|
|
catch { filePath = join(distDir, 'index.html'); body = await readFile(filePath); }
|
|
res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream' });
|
|
res.end(body);
|
|
} catch {
|
|
res.writeHead(500); res.end('error');
|
|
}
|
|
});
|
|
await new Promise((r) => server.listen(servePort, '127.0.0.1', r));
|
|
const appUrl = `http://127.0.0.1:${servePort}/`;
|
|
|
|
const cdpPort = await freePort();
|
|
const profileDir = join(tmpdir(), `skatespots-cordn-probe-${process.pid}-${cdpPort}`);
|
|
const chrome = spawn(chromePath, [
|
|
'--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
|
|
'--disable-extensions', '--no-first-run', '--no-default-browser-check',
|
|
`--user-data-dir=${profileDir}`, `--remote-debugging-port=${cdpPort}`, appUrl,
|
|
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
chrome.stderr.on('data', () => {});
|
|
|
|
const cleanup = () => {
|
|
try { chrome.kill('SIGKILL'); } catch {}
|
|
try { server.close(); } catch {}
|
|
try { rmSync(profileDir, { recursive: true, force: true }); } catch {}
|
|
};
|
|
process.on('exit', cleanup);
|
|
|
|
async function findPageTarget() {
|
|
for (let i = 0; i < 60; i++) {
|
|
try {
|
|
const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
|
|
const p = list.find((t) => t.type === 'page' && t.url.startsWith('http'));
|
|
if (p) return p;
|
|
} catch {}
|
|
await sleep(300);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const target = await findPageTarget();
|
|
if (!target) { console.error('[cordn-probe] FAIL: no Chromium page target'); cleanup(); process.exit(1); }
|
|
|
|
const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
|
|
let id = 0; const pend = new Map(); const exceptions = [];
|
|
ws.on('message', (d) => {
|
|
const m = JSON.parse(d);
|
|
if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); return; }
|
|
if (m.method === 'Runtime.exceptionThrown') {
|
|
const e = m.params.exceptionDetails;
|
|
exceptions.push(e.exception?.description || e.text || 'unknown exception');
|
|
}
|
|
});
|
|
const send = (method, params = {}) => new Promise((res) => { const i = ++id; pend.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
|
|
await new Promise((r) => ws.on('open', r));
|
|
await send('Runtime.enable');
|
|
await send('Page.enable');
|
|
|
|
// Wait for the app to mount so the module graph is warm.
|
|
for (let i = 0; i < 40; i++) {
|
|
const kids = (await send('Runtime.evaluate', { expression: 'document.getElementById("root")?.children.length ?? 0', returnByValue: true }))?.result?.result?.value;
|
|
if (kids > 0) break;
|
|
await sleep(300);
|
|
}
|
|
|
|
const privateKeyHex = randomBytes(32).toString('hex');
|
|
const accountKey = `probe-${Date.now()}`;
|
|
const groupId = `browser-probe-${Date.now()}`;
|
|
|
|
const probe = `(async () => {
|
|
const ns = await import('/assets/${nativeChunk}');
|
|
const m = ns.CordnSession ? ns : (ns.a || ns.default || ns);
|
|
const store = m.createCordnStore('${accountKey}');
|
|
const session = await m.CordnSession.open({
|
|
coordinator: { serverPubkey: '${COORDINATOR_PUBKEY}', relays: ['${COORDINATOR_RELAY}'] },
|
|
privateKeyHex: '${privateKeyHex}',
|
|
store,
|
|
});
|
|
await session.generateKeyPackage('probe');
|
|
await session.publishKeyPackage('probe');
|
|
const group = await session.createGroup({ groupId: '${groupId}', name: 'Browser probe' });
|
|
await session.send(group.gid, 'hello from headless chromium');
|
|
const local = session.messages(group.gid).map((x) => x.content);
|
|
await session.disconnect();
|
|
|
|
// Reopen with the same IndexedDB store: the group must restore.
|
|
const reopened = await m.CordnSession.open({
|
|
coordinator: { serverPubkey: '${COORDINATOR_PUBKEY}', relays: ['${COORDINATOR_RELAY}'] },
|
|
privateKeyHex: '${privateKeyHex}',
|
|
store: m.createCordnStore('${accountKey}'),
|
|
});
|
|
const restored = reopened.listGroups().length;
|
|
const restoredMsgs = reopened.listGroups()[0] ? reopened.messages(reopened.listGroups()[0].gid).length : 0;
|
|
await reopened.disconnect();
|
|
return { ok: true, pubkey: session.pubkey, gid: group.gid, local, restored, restoredMsgs };
|
|
})()`;
|
|
|
|
const result = await send('Runtime.evaluate', { expression: probe, returnByValue: true, awaitPromise: true });
|
|
const value = result?.result?.result?.value;
|
|
const thrown = result?.result?.exceptionDetails;
|
|
|
|
ws.close();
|
|
|
|
if (thrown) {
|
|
console.error('[cordn-probe] FAIL — the browser threw:');
|
|
console.error(' ' + (thrown.exception?.description || thrown.text));
|
|
process.exit(1);
|
|
}
|
|
if (exceptions.length > 0) {
|
|
console.error('[cordn-probe] FAIL — uncaught exceptions in the page:');
|
|
for (const e of exceptions) console.error(' • ' + e);
|
|
process.exit(1);
|
|
}
|
|
if (!value?.ok) {
|
|
console.error('[cordn-probe] FAIL — unexpected result:', JSON.stringify(value));
|
|
process.exit(1);
|
|
}
|
|
|
|
log(`PASS — native stack ran in Chromium (${COORDINATOR_RELAY})`);
|
|
log(` pubkey: ${value.pubkey}`);
|
|
log(` group: ${value.gid}`);
|
|
log(` sent locally: ${JSON.stringify(value.local)}`);
|
|
log(` restored (IDB): ${value.restored} group(s), ${value.restoredMsgs} message(s)`);
|
|
process.exit(0);
|