From b52396b6f777c3bde4228476a6f742542959083a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 07:16:20 +0000 Subject: [PATCH] feat: add protocol interop tests script - Added `scripts/agent/interop-test.mjs` to verify Nostr protocol compatibility (Video Post, View Event, DM) using ephemeral keys. - Added `scripts/agent/setup-test-env.js` to polyfill browser globals for Node.js execution. - Ensures events are constructed via schema builders and published to test relays. --- scripts/agent/interop-test.mjs | 556 ++++++++++++++------------------ scripts/agent/setup-test-env.js | 42 +++ 2 files changed, 291 insertions(+), 307 deletions(-) create mode 100644 scripts/agent/setup-test-env.js diff --git a/scripts/agent/interop-test.mjs b/scripts/agent/interop-test.mjs index fcc2a5e7..39bf95ca 100644 --- a/scripts/agent/interop-test.mjs +++ b/scripts/agent/interop-test.mjs @@ -1,325 +1,267 @@ -import { chromium } from '@playwright/test'; -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +// Import environment setup FIRST to ensure polyfills are ready before other imports +import './setup-test-env.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '../../'); +import { generateSecretKey, getPublicKey, nip19, nip04, nip44 } from 'nostr-tools'; +import * as NostrTools from 'nostr-tools'; -const ARTIFACTS_DIR = path.join(REPO_ROOT, 'artifacts'); -const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, '-'); -const LOG_FILE = path.join(ARTIFACTS_DIR, `interop-${TIMESTAMP}.log`); -const REPORT_FILE = path.join(ARTIFACTS_DIR, `interop-report-${TIMESTAMP}.json`); -const RELAY_PORT = 8008; -const HTTP_PORT = 8001; +// --- Imports from Codebase --- +// Relative path from scripts/agent/interop-test.mjs to js/nostr/client.js is ../../js/nostr/client.js +import { NostrClient } from '../../js/nostr/client.js'; +import { decryptDM } from '../../js/dmDecryptor.js'; +import { + buildVideoPostEvent, + buildViewEvent, + NOTE_TYPES +} from '../../js/nostrEventSchemas.js'; -// Ensure artifacts dir exists -if (!fs.existsSync(ARTIFACTS_DIR)) { - fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); +// --- Configuration --- +const RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol' +]; +// Optional: Local relay if running +const LOCAL_RELAY = 'ws://localhost:8008'; + +// --- Helpers --- +function bytesToHex(bytes) { + return Buffer.from(bytes).toString('hex'); } -function log(message, type = 'INFO') { - const msg = `[${new Date().toISOString()}] [${type}] ${message}`; - console.log(msg); - fs.appendFileSync(LOG_FILE, msg + '\n'); +async function checkRelay(url) { + return new Promise(resolve => { + try { + const ws = new WebSocket(url); + ws.on('open', () => { ws.close(); resolve(true); }); + ws.on('error', () => { resolve(false); }); + } catch { resolve(false); } + }); } -const stats = { - steps: 0, - failures: 0, - details: [] -}; +// --- Main Test --- +async function runTests() { + console.log("=== Starting Protocol & Interop Tests ==="); -function recordStep(name, success, error = null) { - stats.steps++; - const detail = { name, success, timestamp: new Date().toISOString() }; - if (!success) { - stats.failures++; - detail.error = error?.message || String(error); - log(`Step Failed: ${name} - ${detail.error}`, 'ERROR'); - if (error?.stack) { - log(error.stack, 'ERROR'); - } + // 1. Setup Relays + const activeRelays = []; + if (await checkRelay(LOCAL_RELAY)) { + console.log(`[Setup] Local relay found: ${LOCAL_RELAY}`); + activeRelays.push(LOCAL_RELAY); + } else { + console.log(`[Setup] Local relay not found. Using public test relays.`); + activeRelays.push(...RELAYS); + } + console.log(`[Setup] Active Relays:`, activeRelays); + + // 2. Initialize Client + const client = new NostrClient(); + client.relays = activeRelays; + client.readRelays = activeRelays; + client.writeRelays = activeRelays; + + await client.init(); + console.log("[Setup] NostrClient initialized."); + + // 3. Generate Ephemeral Keys + const aliceSkBytes = generateSecretKey(); + const aliceSk = bytesToHex(aliceSkBytes); + const alicePk = getPublicKey(aliceSkBytes); + console.log(`[Keys] Alice (Sender): ${alicePk}`); + + const bobSkBytes = generateSecretKey(); + const bobSk = bytesToHex(bobSkBytes); + const bobPk = getPublicKey(bobSkBytes); + console.log(`[Keys] Bob (Receiver): ${bobPk}`); + + // Register Alice as signer + await client.registerPrivateKeySigner({ privateKey: aliceSk }); + console.log("[Setup] Alice registered as active signer."); + + // --- Test A: Video Post --- + console.log("\n>>> Test A: Publish VIDEO_POST (Kind 30078)"); + const videoRootId = `test-${Date.now()}-${Math.random().toString(36).substring(2)}`; + const dTagValue = videoRootId; + const videoContent = { + version: 3, + title: `Interop Test ${Date.now()}`, + description: "Automated interop test with ephemeral keys.", + url: "https://example.com/video.mp4", + thumbnail: "https://example.com/thumb.jpg", + mimeType: "video/mp4", + isForKids: false, + isNsfw: false, + enableComments: false, // Guardrail: no session actor comments + videoRootId, + mode: "live" + }; + + // Explicitly using schema builder as per instructions + const videoEventTemplate = buildVideoPostEvent({ + pubkey: alicePk, + created_at: Math.floor(Date.now() / 1000), + dTagValue, + content: videoContent + }); + + let videoEvent; + try { + const result = await client.signAndPublishEvent(videoEventTemplate); + videoEvent = result.signedEvent; + + // Check acceptance. The structure of summary is { accepted: [...], failed: [...] } + const summary = result.summary; + if (!summary || !summary.accepted || summary.accepted.length === 0) { + throw new Error("Relays rejected the video event."); + } + console.log(`[Pass] Published Video Event ID: ${videoEvent.id}`); + } catch (e) { + console.error("[Fail] Video publish failed:", e); + process.exit(1); + } + + // --- Test B: Fetch & Verify --- + console.log("\n>>> Test B: Fetch & Verify Roundtrip"); + // Give relays a moment to index + await new Promise(r => setTimeout(r, 1500)); + + // Request raw event to verify protocol details + const result = await client.getEventById(videoEvent.id, { includeRaw: true }); + if (!result || !result.rawEvent) { + console.error("[Fail] Could not fetch raw event back from relays."); + process.exit(1); + } + + const { video, rawEvent } = result; + + if (rawEvent.id !== videoEvent.id) { + console.error(`[Fail] ID mismatch. Expected ${videoEvent.id}, got ${rawEvent.id}`); + process.exit(1); + } + + // Verify shape by parsing raw content + let content; + try { + content = JSON.parse(rawEvent.content); + } catch { + content = {}; + } + + if (content.title !== videoContent.title) { + console.error(`[Fail] Content mismatch in raw event. Title: ${content.title} vs ${videoContent.title}`); + process.exit(1); + } + + if (video.title !== videoContent.title) { + console.error(`[Fail] Content mismatch in normalized video object. Title: ${video.title}`); + process.exit(1); + } + + console.log("[Pass] Event fetched and verified successfully (Raw + Normalized)."); + + // --- Test C: View Event --- + console.log("\n>>> Test C: Publish VIEW_EVENT"); + // Explicitly using schema builder as per instructions + // View event points to the video + const viewEventTemplate = buildViewEvent({ + pubkey: alicePk, + created_at: Math.floor(Date.now() / 1000), + pointerValue: videoEvent.id, + pointerTag: ['e', videoEvent.id], + dedupeTag: `view:${videoEvent.id}:${Date.now()}`, + includeSessionTag: false // Not a session actor + }); + + try { + const viewResult = await client.signAndPublishEvent(viewEventTemplate); + const viewSummary = viewResult.summary; + if (viewSummary && viewSummary.accepted && viewSummary.accepted.length > 0) { + console.log(`[Pass] View Event published. ID: ${viewResult.signedEvent.id}`); } else { - log(`Step Passed: ${name}`, 'SUCCESS'); + console.warn(`[Warn] View Event publish result not OK:`, viewResult); } - stats.details.push(detail); -} + } catch (e) { + console.error("[Fail] View Event publish threw:", e); + } -async function startProcess(command, args, cwd, readyCheck) { - return new Promise((resolve, reject) => { - const proc = spawn(command, args, { cwd, stdio: 'pipe' }); - let started = false; - const label = args.some(a => a.includes('relay')) ? 'RELAY' : command; + // --- Test D: Encrypted DM --- + console.log("\n>>> Test D: Direct Message (Encryption/Decryption)"); + const dmMessage = `Secret message ${Date.now()}`; - const checkData = (data) => { - const output = data.toString(); - if (started) { - if (label === 'RELAY') log(`[RELAY] ${output.trim()}`, 'DEBUG'); - return; - } - if (readyCheck(output)) { - started = true; - resolve(proc); - } - }; + console.log(`[DM] Sending from Alice to Bob...`); + try { + const bobNpub = nip19.npubEncode(bobPk); + const realSendRes = await client.sendDirectMessage(bobNpub, dmMessage); + if (!realSendRes.ok) { + throw new Error(realSendRes.error || "Unknown error"); + } + console.log("[DM] Message sent."); + } catch (e) { + console.error("[Fail] DM Send failed:", e); + process.exit(1); + } - proc.stdout.on('data', (data) => { - checkData(data); - }); - proc.stderr.on('data', (data) => { - checkData(data); - }); + // Verify by fetching as Bob + await new Promise(r => setTimeout(r, 2000)); - proc.on('error', (err) => reject(err)); - proc.on('exit', (code) => { - if (!started) reject(new Error(`${command} exited early with code ${code}`)); - }); + console.log(`[DM] Fetching for Bob...`); + const filter = { + kinds: [4, 1059], // Legacy and GiftWrap + '#p': [bobPk], + authors: [alicePk] // Sent by Alice + }; - // Timeout fallback - setTimeout(() => { - if (!started) { - // For http server, sometimes it doesn't output much - if (command.includes('python') || command.includes('http.server')) { - started = true; - resolve(proc); - } else { - reject(new Error(`${command} timed out waiting for ready signal`)); - } - } - }, 3000); + const dmEvents = await client.pool.list(activeRelays, [filter]); + console.log(`[DM] Found ${dmEvents.length} events for Bob.`); + + if (dmEvents.length === 0) { + console.error("[Fail] No DM events found on relays."); + process.exit(1); + } + + const targetEvent = dmEvents[0]; + console.log(`[DM] Decrypting event kind ${targetEvent.kind}...`); + + const bobDecryptors = []; + + if (nip44) { + bobDecryptors.push({ + scheme: 'nip44', + decrypt: (pk, ct) => nip44.decrypt(bobSk, pk, ct), + priority: 1, + supportsGiftWrap: true }); + } + if (nip04) { + bobDecryptors.push({ + scheme: 'nip04', + decrypt: async (pk, ct) => nip04.decrypt(bobSk, pk, ct), + priority: 0 + }); + } + + const decryptContext = { + actorPubkey: bobPk, + decryptors: bobDecryptors + }; + + const decryptRes = await decryptDM(targetEvent, decryptContext); + + if (!decryptRes.ok) { + console.error("[Fail] Decryption failed:", decryptRes.errors); + process.exit(1); + } + + if (decryptRes.plaintext !== dmMessage) { + console.error(`[Fail] Message mismatch. Got: "${decryptRes.plaintext}", Expected: "${dmMessage}"`); + process.exit(1); + } + + console.log(`[Pass] Decryption verified: "${decryptRes.plaintext}"`); + + console.log("\n=== All Tests Passed ==="); + process.exit(0); } -async function runInteropTest() { - let relayProc, serverProc, browser; - - try { - log('Starting interop test...'); - - // 1. Start Relay - log('Starting local relay...'); - relayProc = await startProcess( - 'node', - ['scripts/agent/simple-relay.mjs'], - REPO_ROOT, - (output) => output.includes(`running on ws://localhost:${RELAY_PORT}`) || output.includes(`running on port ${RELAY_PORT}`) - ); - - } catch (e) { - log('Failed to start relay: ' + e.message, 'FATAL'); - } - - try { - // 2. Start HTTP Server - log('Starting HTTP server...'); - serverProc = await startProcess( - 'python3', - ['-m', 'http.server', String(HTTP_PORT)], - REPO_ROOT, - (output) => output.includes(`Serving HTTP on 0.0.0.0 port ${HTTP_PORT}`) - ); - - // 3. Launch Browser - log('Launching browser...'); - browser = await chromium.launch({ headless: true }); - const context = await browser.newContext(); - const page = await context.newPage(); - - // 4. Navigate to app - log(`Navigating to http://localhost:${HTTP_PORT}...`); - await page.goto(`http://localhost:${HTTP_PORT}`, { waitUntil: 'networkidle' }); - - // 5. Inject Test Logic - log('Executing test logic in browser...'); - - const testResult = await page.evaluate(async ({ relayUrl }) => { - const logs = []; - const steps = []; - - function log(msg) { console.log(msg); logs.push(msg); } - - try { - // Import dependencies - const { nostrClient } = await import('./js/nostr/defaultClient.js'); - const schemas = await import('./js/nostrEventSchemas.js'); - - // Wait for NostrTools - if (!window.NostrTools) { - await new Promise(r => setTimeout(r, 1000)); - if (!window.NostrTools) throw new Error("NostrTools not available on window"); - } - const NostrTools = window.NostrTools; - - // --- Step 1: Init --- - nostrClient.relays = [relayUrl]; - nostrClient.readRelays = [relayUrl]; - nostrClient.writeRelays = [relayUrl]; - await nostrClient.init(); - await nostrClient.connectToRelays(); - - steps.push({ name: 'Init & Connect', success: true }); - - // --- Step 2: Setup Alice (Video Publisher) --- - const skAlice = NostrTools.generateSecretKey(); - const pkAlice = NostrTools.getPublicKey(skAlice); - const hexSkAlice = NostrTools.utils.bytesToHex(skAlice); - - await nostrClient.registerPrivateKeySigner({ privateKey: hexSkAlice, pubkey: pkAlice }); - - steps.push({ name: 'Setup Alice', success: true }); - - // --- Test A: Publish VIDEO_POST --- - const dTagValue = `test-video-${Date.now()}`; - const videoContent = { - version: 3, - title: 'Interop Test Video', - description: 'Testing event schemas', - videoRootId: `root-${dTagValue}`, - mode: 'dev', - url: 'https://example.com/video.mp4', - magnet: 'magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&dn=video.mp4' - }; - - const videoEventSkeleton = schemas.buildVideoPostEvent({ - pubkey: pkAlice, - created_at: Math.floor(Date.now() / 1000), - dTagValue: dTagValue, - content: videoContent - }); - - // Use signAndPublishEvent to sign and publish - const { signedEvent: publishedVideo } = await nostrClient.signAndPublishEvent(videoEventSkeleton); - - if (!publishedVideo || !publishedVideo.id || !publishedVideo.sig) { - throw new Error("Video publish returned incomplete event"); - } - - steps.push({ name: 'Publish VIDEO_POST', success: true }); - - // --- Test B: Fetch and Validate Video --- - await new Promise(r => setTimeout(r, 500)); // wait for relay - const fetchedVideo = await nostrClient.getEventById(publishedVideo.id, { includeRaw: true }); - - if (!fetchedVideo) throw new Error("Failed to fetch video event back"); - - // Validate shape - const fetchedRaw = fetchedVideo.rawEvent || fetchedVideo; // handle wrapper - if (fetchedRaw.id !== publishedVideo.id) throw new Error("ID mismatch"); - if (fetchedRaw.pubkey !== pkAlice) throw new Error("Pubkey mismatch"); - - // Parse content - const fetchedContent = typeof fetchedRaw.content === 'string' ? JSON.parse(fetchedRaw.content) : fetchedRaw.content; - if (fetchedContent.title !== videoContent.title) throw new Error("Content title mismatch"); - if (fetchedContent.videoRootId !== videoContent.videoRootId) throw new Error("Content rootId mismatch"); - - steps.push({ name: 'Verify VIDEO_POST Roundtrip', success: true }); - - // --- Test C: Publish VIEW_EVENT --- - const viewEventSkeleton = schemas.buildViewEvent({ - pubkey: pkAlice, - created_at: Math.floor(Date.now() / 1000), - pointerTag: ['e', publishedVideo.id], // pointing to the video - content: 'test view' - }); - - const { signedEvent: publishedView } = await nostrClient.signAndPublishEvent(viewEventSkeleton); - - await new Promise(r => setTimeout(r, 500)); - const fetchedView = await nostrClient.fetchRawEventById(publishedView.id); - if (!fetchedView) throw new Error("Failed to fetch view event back"); - - steps.push({ name: 'Publish & Verify VIEW_EVENT', success: true }); - - // --- Test D: Encrypted DM (Alice -> Bob) --- - const skBob = NostrTools.generateSecretKey(); - const pkBob = NostrTools.getPublicKey(skBob); - const hexSkBob = NostrTools.utils.bytesToHex(skBob); - const npubBob = NostrTools.nip19.npubEncode(pkBob); - - // Alice is currently logged in - const dmMessage = `Hello Bob ${Date.now()}`; - const dmResult = await nostrClient.sendDirectMessage(npubBob, dmMessage); - if (!dmResult.ok) throw new Error("Failed to send DM: " + dmResult.error); - - steps.push({ name: 'Send Encrypted DM', success: true }); - - // Switch to Bob to decrypt - nostrClient.logout(); - await nostrClient.registerPrivateKeySigner({ privateKey: hexSkBob, pubkey: pkBob }); - - await new Promise(r => setTimeout(r, 500)); - - const dms = await nostrClient.listDirectMessages(pkBob); - const receivedDm = dms.find(m => m.plaintext === dmMessage); - - if (!receivedDm) { - throw new Error(`Bob could not find/decrypt the DM. Found: ${dms.length} messages.`); - } - - if (receivedDm.sender.pubkey !== pkAlice) { - throw new Error("DM sender mismatch"); - } - - steps.push({ name: 'Decrypt DM', success: true }); - - return { success: true, steps, logs }; - - } catch (e) { - return { success: false, steps, logs, error: e.message, stack: e.stack }; - } - }, { relayUrl: `ws://localhost:${RELAY_PORT}` }); - - // Process results - if (testResult.logs) { - testResult.logs.forEach(l => log(`[Browser] ${l}`)); - } - - if (testResult.steps) { - testResult.steps.forEach(s => recordStep(s.name, s.success)); - } - - if (!testResult.success) { - throw new Error(testResult.error || "Unknown browser test failure"); - } - - } catch (err) { - log(`Test failed: ${err.message}`, 'FATAL'); - recordStep('Interop Test Suite', false, err); - // Take screenshot if browser is active - if (browser) { - try { - const page = browser.contexts()[0].pages()[0]; - if (page) { - await page.screenshot({ path: path.join(ARTIFACTS_DIR, `interop-failure-${TIMESTAMP}.png`) }); - log(`Screenshot saved to interop-failure-${TIMESTAMP}.png`); - } - } catch (e) { - log('Failed to capture screenshot: ' + e.message); - } - } - } finally { - // Cleanup - if (browser) await browser.close(); - if (serverProc) { - try { process.kill(serverProc.pid); } catch (e) { /* ignore */ } - } - if (relayProc) { - try { process.kill(relayProc.pid); } catch (e) { /* ignore */ } - } - - // Report - fs.writeFileSync(REPORT_FILE, JSON.stringify({ - timestamp: new Date().toISOString(), - stats, - logFile: LOG_FILE - }, null, 2)); - - console.log(`Report saved to ${REPORT_FILE}`); - if (stats.failures > 0) process.exit(1); - } -} - -runInteropTest(); +runTests().catch(e => { + console.error("Unhandled Error:", e); + process.exit(1); +}); diff --git a/scripts/agent/setup-test-env.js b/scripts/agent/setup-test-env.js new file mode 100644 index 00000000..52ad8800 --- /dev/null +++ b/scripts/agent/setup-test-env.js @@ -0,0 +1,42 @@ +import WebSocket from 'ws'; +import { webcrypto } from 'node:crypto'; + +// Polyfill WebSocket +if (typeof globalThis.WebSocket === 'undefined') { + globalThis.WebSocket = WebSocket; +} + +// Polyfill window and self +if (typeof globalThis.window === 'undefined') { + globalThis.window = globalThis; +} +if (typeof globalThis.self === 'undefined') { + globalThis.self = globalThis; +} + +// Polyfill crypto +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = webcrypto; +} + +// Polyfill localStorage +if (typeof globalThis.localStorage === 'undefined') { + const storage = new Map(); + globalThis.localStorage = { + getItem: (k) => storage.get(k) || null, + setItem: (k, v) => storage.set(String(k), String(v)), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + key: (i) => Array.from(storage.keys())[i] || null, + get length() { return storage.size; } + }; +} + +if (typeof globalThis.window.localStorage === 'undefined') { + globalThis.window.localStorage = globalThis.localStorage; +} + +// Mock navigator +if (typeof globalThis.navigator === 'undefined') { + globalThis.navigator = { userAgent: 'node' }; +}