From e88efec6c5317f05ad58f6438d4742e64f1febf0 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 07:40:57 +0000 Subject: [PATCH] Add interoperability test script This commit adds `scripts/agent/interop-test.mjs` to verify protocol interoperability using `NostrClient` and `nostrEventSchemas` against a local test relay. Covers Video Post (kind 30078), View Event (kind 30079), and Encrypted DM. Verified locally with Node.js and `simple-relay.mjs`. --- scripts/agent/interop-test.mjs | 413 ++++++++++++++------------------- 1 file changed, 178 insertions(+), 235 deletions(-) diff --git a/scripts/agent/interop-test.mjs b/scripts/agent/interop-test.mjs index 40dbf9ac..6fc492a9 100644 --- a/scripts/agent/interop-test.mjs +++ b/scripts/agent/interop-test.mjs @@ -1,259 +1,202 @@ -// Import environment setup FIRST to ensure polyfills are ready before other imports import './setup-test-env.js'; - -import * as NostrTools from 'nostr-tools'; -// Destructure what we need, handling potential version differences if necessary -const { generateSecretKey, getPublicKey, nip19, nip04, nip44 } = NostrTools; - -// --- Imports from Codebase --- +import { spawn } from 'node:child_process'; import { NostrClient } from '../../js/nostr/client.js'; -import { decryptDM } from '../../js/dmDecryptor.js'; -import { - buildVideoPostEvent, - buildViewEvent, - NOTE_TYPES -} from '../../js/nostrEventSchemas.js'; +import { buildVideoPostEvent } from '../../js/nostrEventSchemas.js'; +import * as NostrTools from 'nostr-tools'; +import { nip19 } from 'nostr-tools'; +import fs from 'node:fs'; +import path from 'node:path'; -// --- Configuration --- -// Using a small, respectful list of public relays for interop testing -const TEST_RELAYS = [ - 'wss://relay.damus.io', - 'wss://nos.lol' -]; +// Polyfill global for fallback if dynamic import fails in bootstrap (unlikely in Node but safe) +global.NostrTools = NostrTools; -// --- Helpers --- -function bytesToHex(bytes) { - return Buffer.from(bytes).toString('hex'); +const ARTIFACTS_DIR = 'artifacts'; +if (!fs.existsSync(ARTIFACTS_DIR)) { + fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); } -// --- Main Test --- -async function runTests() { - console.log("=== Starting Protocol & Interop Tests ==="); - console.log(`[Config] Relays: ${TEST_RELAYS.join(', ')}`); +async function run() { + console.log('--- Starting Interop Test ---'); - // 1. Initialize Client - // Programmatic instantiation as requested - const client = new NostrClient(); - client.relays = [...TEST_RELAYS]; - client.readRelays = [...TEST_RELAYS]; - client.writeRelays = [...TEST_RELAYS]; - - // Initialize connection - await client.init(); - console.log("[Setup] NostrClient initialized and connecting..."); - - // Wait a bit for connections to establish - await new Promise(r => setTimeout(r, 1000)); - - // 2. Generate Ephemeral Keys - // Use ephemeral keys (generate locally) as requested - 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 for the client - await client.registerPrivateKeySigner({ privateKey: aliceSk }); - console.log("[Setup] Alice registered as active signer."); - - // --- Test A: Publish VIDEO_POST --- - console.log("\n>>> Test A: Publish VIDEO_POST"); - const videoRootId = `test-${Date.now()}-${Math.random().toString(36).substring(2)}`; - const videoContent = { - version: 3, - title: `Interop Test Video ${Date.now()}`, - description: "Automated interop test video event.", - url: "https://example.com/video.mp4", - thumbnail: "https://example.com/thumb.jpg", - mimeType: "video/mp4", - isForKids: false, - isNsfw: false, - enableComments: false, - videoRootId, - mode: "live" - }; - - // Use schema builder - const videoEventTemplate = buildVideoPostEvent({ - pubkey: alicePk, - created_at: Math.floor(Date.now() / 1000), - dTagValue: videoRootId, - content: videoContent + // 1. Start Local Relay + console.log('[Setup] Spawning simple-relay.mjs...'); + const relayLog = fs.openSync(path.join(ARTIFACTS_DIR, 'interop-relay.log'), 'w'); + const relayProcess = spawn('node', ['scripts/agent/simple-relay.mjs'], { + stdio: ['ignore', relayLog, relayLog], + env: { ...process.env, PORT: '8008' } }); - let videoEvent; - try { - const result = await client.signAndPublishEvent(videoEventTemplate); - videoEvent = result.signedEvent; + // Give relay time to boot + await new Promise(resolve => setTimeout(resolve, 2000)); + console.log('[Setup] Relay assumed running on ws://localhost:8008'); - // Check summary for acceptance - const summary = result.summary; - const acceptedCount = summary?.accepted?.length || 0; - - if (acceptedCount === 0) { - throw new Error(`Relays rejected the video event. Details: ${JSON.stringify(summary)}`); - } - console.log(`[Pass] Published Video Event ID: ${videoEvent.id} (Accepted by ${acceptedCount} relays)`); - } catch (e) { - console.error("[Fail] Video publish failed:", e); - process.exit(1); - } - - // --- Test B: Fetch & Verify --- - console.log("\n>>> Test B: Fetch & Verify Roundtrip"); - // Allow propagation - await new Promise(r => setTimeout(r, 2000)); + let exitCode = 0; try { - const result = await client.getEventById(videoEvent.id, { includeRaw: true }); + // 2. Client Init (Sender - Identity A) + console.log('[Client A] Initializing...'); + const clientA = new NostrClient(); - if (!result || !result.rawEvent) { - throw new Error("Could not fetch raw event back from relays."); - } + // Override relays to local + const testRelays = ['ws://localhost:8008']; + clientA.relays = [...testRelays]; + clientA.readRelays = [...testRelays]; + clientA.writeRelays = [...testRelays]; - const { video, rawEvent } = result; + await clientA.init(); + console.log('[Client A] Connected.'); - if (rawEvent.id !== videoEvent.id) { - throw new Error(`ID mismatch. Expected ${videoEvent.id}, got ${rawEvent.id}`); - } + // Generate keys + const skA = NostrTools.generateSecretKey(); + const pkA = NostrTools.getPublicKey(skA); + const hexSkA = NostrTools.utils.bytesToHex(skA); + const npubA = nip19.npubEncode(pkA); - // Verify content shape - const content = JSON.parse(rawEvent.content); - if (content.title !== videoContent.title) { - throw new Error(`Content mismatch. Title: ${content.title} vs ${videoContent.title}`); - } + console.log(`[Client A] Identity: ${pkA} (${npubA})`); - // Verify signature (basic check) - const isSigValid = NostrTools.verifyEvent(rawEvent); - if (!isSigValid) { - throw new Error("Event signature verification failed."); - } + // Register signer + await clientA.registerPrivateKeySigner({ privateKey: hexSkA, pubkey: pkA }); + console.log('[Client A] Signer registered.'); - console.log("[Pass] Event fetched, signature verified, and content matches."); - } catch (e) { - console.error(`[Fail] Fetch & Verify failed: ${e.message}`); - process.exit(1); - } + // --- TEST 1: Video Post --- + console.log('\n--- Test 1: Video Post ---'); + // Using buildVideoPostEvent directly to verify schema interop as requested + const videoPayload = { + version: 3, + title: 'Interop Video Test', + description: 'Testing video publish via NostrClient', + magnet: 'magnet:?xt=urn:btih:c91104e1e82813136287e0767786431206d048d0&dn=test-video', + mode: 'live', + isPrivate: false, + videoRootId: `test-root-${Date.now()}`, + isNsfw: false, + isForKids: false, + url: '', + thumbnail: '', + deleted: false, + enableComments: true + }; - // --- Test C: Publish VIEW_EVENT --- - console.log("\n>>> Test C: Publish VIEW_EVENT"); - 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 acceptedCount = viewResult.summary?.accepted?.length || 0; - if (acceptedCount > 0) { - console.log(`[Pass] View Event published. ID: ${viewResult.signedEvent.id}`); - } else { - console.warn(`[Warn] View Event publish result not OK:`, viewResult); - } - } catch (e) { - console.error("[Fail] View Event publish threw:", e); - } - - // --- Test D: Encrypted DM --- - console.log("\n>>> Test D: Direct Message (Encryption/Decryption)"); - const dmMessage = `Secret message ${Date.now()}`; - - // We are Alice. Send to Bob. - // Note: client.sendDirectMessage uses the registered signer (Alice) - console.log(`[DM] Sending from Alice to Bob...`); - try { - const bobNpub = nip19.npubEncode(bobPk); - const sendRes = await client.sendDirectMessage(bobNpub, dmMessage); - if (!sendRes.ok) { - throw new Error(sendRes.error || "Unknown error sending DM"); - } - console.log("[DM] Message sent."); - } catch (e) { - console.error("[Fail] DM Send failed:", e); - process.exit(1); - } - - // Wait for propagation - await new Promise(r => setTimeout(r, 2000)); - - // Verify by fetching as Bob - // We need to use the pool directly to fetch, as 'client' is configured as Alice - console.log(`[DM] Fetching events for Bob (p=${bobPk})...`); - const filter = { - kinds: [4, 1059], // Legacy and GiftWrap - '#p': [bobPk], - authors: [alicePk], // Sent by Alice - limit: 1 - }; - - const dmEvents = await client.pool.list(TEST_RELAYS, [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}...`); - - // Setup Bob's decryptors - const bobDecryptors = []; - - // NIP-44 - if (nip44) { - bobDecryptors.push({ - scheme: 'nip44', - decrypt: (pk, ct) => nip44.decrypt(bobSk, pk, ct), - priority: 1, - supportsGiftWrap: true - }); - } - // NIP-04 - if (nip04) { - bobDecryptors.push({ - scheme: 'nip04', - decrypt: async (pk, ct) => nip04.decrypt(bobSk, pk, ct), - priority: 0 + console.log('[Test 1] Building and Publishing video...'); + const rawEvent = buildVideoPostEvent({ + pubkey: pkA, + created_at: Math.floor(Date.now() / 1000), + content: videoPayload, + dTagValue: `interop-${Date.now()}` }); + + const { signedEvent: videoEvent } = await clientA.signAndPublishEvent(rawEvent); + + if (!videoEvent || !videoEvent.id) { + throw new Error('Video publish returned no event or ID'); + } + console.log(`[Test 1] Video published. ID: ${videoEvent.id}`); + + // Fetch back + console.log('[Test 1] Fetching event back...'); + const fetchedEvent = await clientA.getEventById(videoEvent.id); + if (!fetchedEvent) { + throw new Error('Failed to fetch video event back'); + } + + // Validate + // getEventById returns the Video model (parsed object), not the raw event, unless includeRaw is true. + // The Video model has 'title' at top level. + if (fetchedEvent.title !== videoPayload.title) { + throw new Error(`Title mismatch: expected ${videoPayload.title}, got ${fetchedEvent.title}`); + } + console.log('[Test 1] Verified: Content matches.'); + + + // --- TEST 2: View Event --- + console.log('\n--- Test 2: View Event ---'); + // publishViewEvent expects a pointer object { type: 'e'|'a', value: hex|address } or a string. + // We'll use the event ID ('e' tag) for this test. + const viewPointer = { + type: 'e', + value: videoEvent.id + }; + + console.log('[Test 2] Publishing view event...'); + await clientA.publishViewEvent(viewPointer); + + console.log('[Test 2] Polling for view event...'); + // We poll because simple-relay.mjs does not support live subscriptions + let receivedView = null; + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 500)); + const views = await clientA.listVideoViewEvents(viewPointer); + // Find one signed by us + receivedView = views.find(v => v.pubkey === pkA); + if (receivedView) break; + } + + if (!receivedView) { + throw new Error('View event not found after polling'); + } + console.log(`[Test 2] View event verified: ${receivedView.id}`); + + + // --- TEST 3: Encrypted DM --- + console.log('\n--- Test 3: Direct Message ---'); + + // Client B (Recipient) + console.log('[Client B] Initializing...'); + const clientB = new NostrClient(); + clientB.relays = [...testRelays]; + clientB.readRelays = [...testRelays]; + clientB.writeRelays = [...testRelays]; + await clientB.init(); + + const skB = NostrTools.generateSecretKey(); + const pkB = NostrTools.getPublicKey(skB); + const hexSkB = NostrTools.utils.bytesToHex(skB); + const npubB = nip19.npubEncode(pkB); + console.log(`[Client B] Identity: ${pkB} (${npubB})`); + + await clientB.registerPrivateKeySigner({ privateKey: hexSkB, pubkey: pkB }); + + const dmMessage = 'Hello from Client A to Client B ' + Date.now(); + + console.log(`[Test 3] Client A sending DM to ${npubB}...`); + const sendResult = await clientA.sendDirectMessage(npubB, dmMessage); + if (!sendResult.ok) { + throw new Error(`Failed to send DM: ${sendResult.error}`); + } + console.log('[Test 3] DM Sent.'); + + // Wait a bit for relay + await new Promise(r => setTimeout(r, 500)); + + console.log('[Test 3] Client B listing messages...'); + // listDirectMessages uses decryptDirectMessageEvent internally which uses dmDecryptor.js + // It requires actorPubkeyInput or uses internal session actor. + // We verified registerPrivateKeySigner sets session actor. + const messages = await clientB.listDirectMessages(pkB, { limit: 10 }); + + const found = messages.find(msg => msg.plaintext === dmMessage); + if (!found) { + console.log('Messages found:', messages.map(m => m.plaintext)); + throw new Error('Client B did not receive/decrypt the expected message.'); + } + + console.log(`[Test 3] Verified DM content: "${found.plaintext}"`); + console.log(`[Test 3] Encryption scheme: ${found.scheme}`); + + + console.log('\n--- All Tests Passed ---'); + + } catch (error) { + console.error('\n!!! TEST FAILED !!!'); + console.error(error); + exitCode = 1; + } finally { + console.log('[Cleanup] Killing relay...'); + relayProcess.kill(); + fs.closeSync(relayLog); } - 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. Scheme: ${decryptRes.scheme}, Message: "${decryptRes.plaintext}"`); - - // Cleanup - // In a real scenario we might delete the events, but these are ephemeral and test relays usually clean up or we accept the dust for this test. - - console.log("\n=== All Tests Passed ==="); - process.exit(0); + process.exit(exitCode); } -runTests().catch(e => { - console.error("Unhandled Error:", e); - process.exit(1); -}); +run();