From 8dc1d0e791eeec86e4c5b22076e1aa7ff071966c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 07:14:32 +0000 Subject: [PATCH] feat(tests): implement interop-test.mjs with ephemeral keys and protocol checks - Implements `scripts/agent/interop-test.mjs` to verify Nostr protocol compatibility. - Uses `NostrClient` programmatically with ephemeral keys (no persistence). - Defines a strict test relay list (`relay.damus.io`, `nos.lol`) to avoid spam. - Validates Video Post publishing (Kind 30078) using `js/nostrEventSchemas.js` builders. - Validates View Event publishing (Kind 30079). - Validates DM encryption and decryption (Kind 4) using `js/dmDecryptor.js`. - Adheres to AGENTS.md guardrails (no session actor comments). - Outputs clear pass/fail logs for verification. --- scripts/agent/interop-test.mjs | 170 ++++++++++++++++----------------- 1 file changed, 81 insertions(+), 89 deletions(-) diff --git a/scripts/agent/interop-test.mjs b/scripts/agent/interop-test.mjs index 39bf95ca..40dbf9ac 100644 --- a/scripts/agent/interop-test.mjs +++ b/scripts/agent/interop-test.mjs @@ -1,11 +1,11 @@ // Import environment setup FIRST to ensure polyfills are ready before other imports import './setup-test-env.js'; -import { generateSecretKey, getPublicKey, nip19, nip04, nip44 } from 'nostr-tools'; 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 --- -// 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 { @@ -15,53 +15,38 @@ import { } from '../../js/nostrEventSchemas.js'; // --- Configuration --- -const RELAYS = [ +// Using a small, respectful list of public relays for interop testing +const TEST_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'); } -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); } - }); -} - // --- Main Test --- async function runTests() { console.log("=== Starting Protocol & Interop Tests ==="); + console.log(`[Config] Relays: ${TEST_RELAYS.join(', ')}`); - // 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 + // 1. Initialize Client + // Programmatic instantiation as requested const client = new NostrClient(); - client.relays = activeRelays; - client.readRelays = activeRelays; - client.writeRelays = activeRelays; + client.relays = [...TEST_RELAYS]; + client.readRelays = [...TEST_RELAYS]; + client.writeRelays = [...TEST_RELAYS]; + // Initialize connection await client.init(); - console.log("[Setup] NostrClient initialized."); + console.log("[Setup] NostrClient initialized and connecting..."); - // 3. Generate Ephemeral Keys + // 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); @@ -72,33 +57,32 @@ async function runTests() { const bobPk = getPublicKey(bobSkBytes); console.log(`[Keys] Bob (Receiver): ${bobPk}`); - // Register Alice as signer + // Register Alice as signer for the client 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)"); + // --- 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 dTagValue = videoRootId; const videoContent = { version: 3, - title: `Interop Test ${Date.now()}`, - description: "Automated interop test with ephemeral keys.", + 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, // Guardrail: no session actor comments + enableComments: false, videoRootId, mode: "live" }; - // Explicitly using schema builder as per instructions + // Use schema builder const videoEventTemplate = buildVideoPostEvent({ pubkey: alicePk, created_at: Math.floor(Date.now() / 1000), - dTagValue, + dTagValue: videoRootId, content: videoContent }); @@ -107,12 +91,14 @@ async function runTests() { const result = await client.signAndPublishEvent(videoEventTemplate); videoEvent = result.signedEvent; - // Check acceptance. The structure of summary is { accepted: [...], failed: [...] } + // Check summary for acceptance const summary = result.summary; - if (!summary || !summary.accepted || summary.accepted.length === 0) { - throw new Error("Relays rejected the video event."); + 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}`); + 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); @@ -120,47 +106,42 @@ async function runTests() { // --- 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)); + // Allow propagation + await new Promise(r => setTimeout(r, 2000)); - // 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 = {}; - } + const result = await client.getEventById(videoEvent.id, { includeRaw: true }); - if (content.title !== videoContent.title) { - console.error(`[Fail] Content mismatch in raw event. Title: ${content.title} vs ${videoContent.title}`); + if (!result || !result.rawEvent) { + throw new Error("Could not fetch raw event back from relays."); + } + + const { video, rawEvent } = result; + + if (rawEvent.id !== videoEvent.id) { + throw new Error(`ID mismatch. Expected ${videoEvent.id}, got ${rawEvent.id}`); + } + + // 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}`); + } + + // Verify signature (basic check) + const isSigValid = NostrTools.verifyEvent(rawEvent); + if (!isSigValid) { + throw new Error("Event signature verification failed."); + } + + console.log("[Pass] Event fetched, signature verified, and content matches."); + } catch (e) { + console.error(`[Fail] Fetch & Verify failed: ${e.message}`); 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 --- + // --- Test C: Publish 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), @@ -172,8 +153,8 @@ async function runTests() { try { const viewResult = await client.signAndPublishEvent(viewEventTemplate); - const viewSummary = viewResult.summary; - if (viewSummary && viewSummary.accepted && viewSummary.accepted.length > 0) { + 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); @@ -186,12 +167,14 @@ async function runTests() { 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 realSendRes = await client.sendDirectMessage(bobNpub, dmMessage); - if (!realSendRes.ok) { - throw new Error(realSendRes.error || "Unknown error"); + 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) { @@ -199,17 +182,20 @@ async function runTests() { process.exit(1); } - // Verify by fetching as Bob + // Wait for propagation await new Promise(r => setTimeout(r, 2000)); - console.log(`[DM] Fetching for Bob...`); + // 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 + authors: [alicePk], // Sent by Alice + limit: 1 }; - const dmEvents = await client.pool.list(activeRelays, [filter]); + const dmEvents = await client.pool.list(TEST_RELAYS, [filter]); console.log(`[DM] Found ${dmEvents.length} events for Bob.`); if (dmEvents.length === 0) { @@ -220,8 +206,10 @@ async function runTests() { 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', @@ -230,6 +218,7 @@ async function runTests() { supportsGiftWrap: true }); } + // NIP-04 if (nip04) { bobDecryptors.push({ scheme: 'nip04', @@ -255,7 +244,10 @@ async function runTests() { process.exit(1); } - console.log(`[Pass] Decryption verified: "${decryptRes.plaintext}"`); + 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);