From 7ac4d4eee719ab4a230a4d545f09d95fd237cf85 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 07:34:26 +0000 Subject: [PATCH 1/4] feat: add Nostr interop test script This script validates protocol interoperability by publishing Video Post and View events, and sending Encrypted DMs using ephemeral keys against public relays. It ensures the NostrClient facade and event builders function correctly in a Node.js environment. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- scripts/agent/interop-test.mjs | 334 +++++++++++++++------------------ 1 file changed, 152 insertions(+), 182 deletions(-) diff --git a/scripts/agent/interop-test.mjs b/scripts/agent/interop-test.mjs index 9c3f55c4..1df7bb14 100644 --- a/scripts/agent/interop-test.mjs +++ b/scripts/agent/interop-test.mjs @@ -1,215 +1,185 @@ +import './setup-test-env.js'; +import { NostrClient } from '../../js/nostr/client.js'; +import { buildVideoPostEvent, buildViewEvent, NOTE_TYPES } from '../../js/nostrEventSchemas.js'; +import * as NostrTools from 'nostr-tools'; -import { WebSocket } from 'ws'; -if (!global.WebSocket) { - global.WebSocket = WebSocket; -} +const bytesToHex = (bytes) => { + return Buffer.from(bytes).toString('hex'); +}; -import { NostrClient, resolveActiveSigner } from '../../js/nostr/client.js'; -import { startRelay } from './load-test-relay.mjs'; -import { - buildVideoPostEvent, - buildViewEvent, - buildLegacyDirectMessageEvent -} from '../../js/nostrEventSchemas.js'; -import { decryptDM } from '../../js/dmDecryptor.js'; -import { generateSecretKey, getPublicKey } from 'nostr-tools'; -import { bytesToHex } from '../../vendor/crypto-helpers.bundle.min.js'; +const RELAYS = [ + "wss://relay.damus.io", + "wss://nos.lol" +]; -// Configuration -const RELAY_PORT = 8899; -const RELAY_URL = `ws://localhost:${RELAY_PORT}`; -const EPHEMERAL_SK_BYTES = generateSecretKey(); -const EPHEMERAL_SK = bytesToHex(EPHEMERAL_SK_BYTES); -const EPHEMERAL_PK = getPublicKey(EPHEMERAL_SK_BYTES); +async function runTests() { + console.log("Starting Protocol & Interop Tests..."); -console.log(`[Setup] Generated Ephemeral Public Key: ${EPHEMERAL_PK}`); + // --- Setup --- + console.log("[Setup] Generating ephemeral keys..."); + const aliceSkBytes = NostrTools.generateSecretKey(); + const aliceSk = bytesToHex(aliceSkBytes); + const alicePk = NostrTools.getPublicKey(aliceSkBytes); -async function runInteropTests() { - let relayServer; - let client; + const bobSkBytes = NostrTools.generateSecretKey(); + const bobSk = bytesToHex(bobSkBytes); + const bobPk = NostrTools.getPublicKey(bobSkBytes); - try { - // 1. Start Local Relay - console.log('[Setup] Starting local test relay...'); - relayServer = startRelay(RELAY_PORT); + console.log(`[Setup] Alice: ${alicePk}`); + console.log(`[Setup] Bob: ${bobPk}`); - // 2. Initialize Client - console.log('[Setup] Initializing NostrClient...'); - client = new NostrClient(); - client.relays = [RELAY_URL]; - client.writeRelays = [RELAY_URL]; - client.readRelays = [RELAY_URL]; + const aliceClient = new NostrClient(); + aliceClient.relays = [...RELAYS]; + aliceClient.readRelays = [...RELAYS]; + aliceClient.writeRelays = [...RELAYS]; - // Register signer - await client.registerPrivateKeySigner({ privateKey: EPHEMERAL_SK }); - console.log('[Setup] Registered private key signer.'); + const bobClient = new NostrClient(); + bobClient.relays = [...RELAYS]; + bobClient.readRelays = [...RELAYS]; + bobClient.writeRelays = [...RELAYS]; - // Init client (connects to relay) - await client.init(); - console.log('[Setup] Client initialized and connected.'); + console.log("[Setup] Connecting clients..."); + // Initialize to connect + await aliceClient.init(); // This might try to load stored session, but shouldn't matter + await bobClient.init(); - // --- Test A: Video Post --- - console.log('\n[Test A] Video Post Event Roundtrip'); - const videoContent = { - version: 3, - title: 'Interop Test Video', - url: 'https://example.com/video.mp4', - description: 'A test video for interoperability verification.', - mode: 'live', - videoRootId: 'interop-test-root-1' - }; + // Register signers + await aliceClient.registerPrivateKeySigner({ privateKey: aliceSk, pubkey: alicePk }); + aliceClient.pubkey = alicePk; // Explicitly set client pubkey to ensure correct signer resolution - const videoEventTemplate = buildVideoPostEvent({ - pubkey: EPHEMERAL_PK, - created_at: Math.floor(Date.now() / 1000), - dTagValue: 'interop-test-root-1', // Using rootId as d-tag for simplicity - content: videoContent - }); + await bobClient.registerPrivateKeySigner({ privateKey: bobSk, pubkey: bobPk }); + bobClient.pubkey = bobPk; // Explicitly set client pubkey to ensure correct signer resolution - console.log('[Test A] Publishing Video Post...'); - const { signedEvent: publishedVideo } = await client.signAndPublishEvent(videoEventTemplate, { - context: 'test-video-post' - }); + let testsPassed = 0; + let testsTotal = 0; - if (!publishedVideo || !publishedVideo.id) { - throw new Error('[Test A] Failed to publish video event (no ID returned).'); - } - console.log(`[Test A] Published Video Event ID: ${publishedVideo.id}`); + // --- Test A: Video Post --- + testsTotal++; + console.log("\n[Test A] Publishing VIDEO_POST..."); + try { + const videoPayload = { + version: 3, + title: "Interop Test Video " + Date.now(), + description: "This is a test video event.", + videoRootId: "interop-test-" + Date.now(), + mode: "dev", + isPrivate: false, + isNsfw: false, + isForKids: false, + enableComments: true + }; - // Fetch back - console.log('[Test A] Fetching event back...'); - // Small delay to ensure relay processing - await new Promise(r => setTimeout(r, 200)); + // We use signAndPublishEvent directly with a manually built event to test the schema helper + const event = buildVideoPostEvent({ + pubkey: alicePk, + created_at: Math.floor(Date.now() / 1000), + dTagValue: videoPayload.videoRootId, + content: videoPayload + }); - const fetchedVideo = await client.getEventById(publishedVideo.id, { includeRaw: true }); + const { signedEvent } = await aliceClient.signAndPublishEvent(event); + console.log(`[Test A] Published event ${signedEvent.id}`); - if (!fetchedVideo) { - throw new Error('[Test A] Failed to fetch video event back from relay.'); - } + // Verify + console.log("[Test A] Verifying roundtrip..."); + // Give relays a moment + await new Promise(r => setTimeout(r, 2000)); - const rawVideoEvent = fetchedVideo.rawEvent || fetchedVideo; // handle if getEventById returns raw or object wrapper + // getEventById returns the Video object (converted), but we can ask for raw too + const result = await bobClient.getEventById(signedEvent.id, { includeRaw: true }); + if (!result || !result.rawEvent) throw new Error("Failed to fetch event back"); - if (rawVideoEvent.id !== publishedVideo.id) { - throw new Error(`[Test A] ID mismatch: expected ${publishedVideo.id}, got ${rawVideoEvent.id}`); - } + const fetched = result.rawEvent; + if (fetched.id !== signedEvent.id) throw new Error("Fetched ID mismatch"); - // Validate content shape (simplified check) - const parsedContent = typeof rawVideoEvent.content === 'string' ? JSON.parse(rawVideoEvent.content) : rawVideoEvent.content; - if (parsedContent.title !== videoContent.title) { - throw new Error(`[Test A] Content mismatch: expected title "${videoContent.title}", got "${parsedContent.title}"`); - } + // Parse content + const content = typeof fetched.content === 'string' ? JSON.parse(fetched.content) : fetched.content; + if (content.title !== videoPayload.title) throw new Error("Content mismatch: title"); - console.log('[Test A] SUCCESS: Video Post roundtrip verified.'); + console.log("[Test A] PASSED"); + testsPassed++; + } catch (e) { + console.error("[Test A] FAILED:", e); + } + // --- Test B: View Event --- + testsTotal++; + console.log("\n[Test B] Publishing VIEW_EVENT..."); + try { + const viewEvent = buildViewEvent({ + pubkey: alicePk, + created_at: Math.floor(Date.now() / 1000), + pointerValue: "test-video-pointer", // Arbitrary pointer for test + content: "view-test" + }); - // --- Test B: View Event --- - console.log('\n[Test B] View Event Visibility'); - const viewEventTemplate = buildViewEvent({ - pubkey: EPHEMERAL_PK, - created_at: Math.floor(Date.now() / 1000), - pointerValue: 'interop-test-root-1', // referencing the video we just created - pointerTag: ['d', 'interop-test-root-1'] - }); + const { signedEvent: signedView } = await aliceClient.signAndPublishEvent(viewEvent); + console.log(`[Test B] Published view event ${signedView.id}`); - console.log('[Test B] Publishing View Event...'); - const { signedEvent: publishedView } = await client.signAndPublishEvent(viewEventTemplate, { - context: 'test-view-event' - }); + await new Promise(r => setTimeout(r, 2000)); - if (!publishedView || !publishedView.id) { - throw new Error('[Test B] Failed to publish view event.'); - } - console.log(`[Test B] Published View Event ID: ${publishedView.id}`); + // Bob fetches raw event + const fetchedView = await bobClient.fetchRawEventById(signedView.id); + if (!fetchedView) throw new Error("Failed to fetch view event"); + if (fetchedView.id !== signedView.id) throw new Error("View event ID mismatch"); - // Fetch back to verify - await new Promise(r => setTimeout(r, 200)); - const fetchedView = await client.fetchRawEventById(publishedView.id); - if (!fetchedView) { - throw new Error('[Test B] Failed to fetch view event back.'); - } - console.log('[Test B] SUCCESS: View Event verified.'); + console.log("[Test B] PASSED"); + testsPassed++; + } catch (e) { + console.error("[Test B] FAILED:", e); + } + // --- Test C: Direct Message --- + testsTotal++; + console.log("\n[Test C] Sending Direct Message..."); + try { + const message = "Hello Bob, this is Alice " + Date.now(); + const npubBob = NostrTools.nip19.npubEncode(bobPk); - // --- Test C: Encrypted DM --- - console.log('\n[Test C] Encrypted DM Roundtrip (Self-DM)'); - const dmMessage = "This is a secret interop message."; + const sendResult = await aliceClient.sendDirectMessage(npubBob, message); + if (!sendResult.ok) throw new Error("Failed to send DM: " + (sendResult.error || "unknown")); + console.log("[Test C] DM sent successfully"); - // 1. Encrypt - const signer = resolveActiveSigner(EPHEMERAL_PK); - if (!signer || typeof signer.nip04Encrypt !== 'function') { - throw new Error('[Test C] Signer does not support NIP-04 encryption.'); - } + await new Promise(r => setTimeout(r, 3000)); - console.log('[Test C] Encrypting message...'); - const ciphertext = await signer.nip04Encrypt(EPHEMERAL_PK, dmMessage); + console.log("[Test C] Bob checking messages..."); + // Force bob to list messages (which triggers decryption using his registered signer) + const messages = await bobClient.listDirectMessages(bobPk, { limit: 5 }); - // 2. Build Event - const dmTemplate = buildLegacyDirectMessageEvent({ - pubkey: EPHEMERAL_PK, - created_at: Math.floor(Date.now() / 1000), - recipientPubkey: EPHEMERAL_PK, - ciphertext: ciphertext - }); - - // 3. Publish - console.log('[Test C] Publishing DM...'); - const { signedEvent: publishedDM } = await client.signAndPublishEvent(dmTemplate, { - context: 'test-dm' - }); - console.log(`[Test C] Published DM Event ID: ${publishedDM.id}`); - - // 4. Fetch - await new Promise(r => setTimeout(r, 200)); - const fetchedDM = await client.fetchRawEventById(publishedDM.id); - if (!fetchedDM) { - throw new Error('[Test C] Failed to fetch DM event.'); - } - - // 5. Decrypt using dmDecryptor.js helper - console.log('[Test C] Attempting decryption...'); - - // We need to construct the context expected by decryptDM - // It expects { actorPubkey, decryptors } - // We can reuse client.buildDmDecryptContext, but let's do it manually to test the decryptor isolation if possible, - // or just use the client helper to ensure we are testing the full stack availability. - // Let's use `client.decryptDirectMessageEvent` which wraps `decryptDM`. - // But the prompt asked to "attempt decryption using the appropriate decryptor helper (see js/dmDecryptor.js / docs)". - // So I'll call decryptDM directly to verify strict compliance with that instruction. - - const decryptContext = await client.buildDmDecryptContext(EPHEMERAL_PK); - const decryptionResult = await decryptDM(fetchedDM, decryptContext); - - if (!decryptionResult.ok) { - console.error('[Test C] Decryption errors:', decryptionResult.errors); - throw new Error('[Test C] Decryption failed.'); - } - - if (decryptionResult.plaintext !== dmMessage) { - throw new Error(`[Test C] Decryption mismatch: expected "${dmMessage}", got "${decryptionResult.plaintext}"`); - } - - console.log(`[Test C] Decrypted text: "${decryptionResult.plaintext}"`); - console.log('[Test C] SUCCESS: Encrypted DM roundtrip verified.'); - - - } catch (err) { - console.error('\n[Error] Test failed:', err); - process.exit(1); - } finally { - console.log('\n[Teardown] Cleaning up...'); - if (client && client.pool) { - // Close pool connections to allow exit - if (typeof client.pool.close === 'function') { - client.pool.close(client.relays); - } - } - if (relayServer) { - await relayServer.close(); - console.log('[Teardown] Local relay stopped.'); - } - console.log('[Teardown] Done.'); - process.exit(0); + const received = messages.find(m => m.plaintext === message); + if (!received) { + console.log("Received messages:", messages.map(m => m.plaintext)); + throw new Error("Bob did not find the specific message"); } + + if (received.sender.pubkey !== alicePk) { + console.log(`[Test C] Sender mismatch: Expected ${alicePk}, got ${received.sender.pubkey}`); + throw new Error("Sender mismatch"); + } + + console.log("[Test C] PASSED"); + testsPassed++; + } catch (e) { + console.error("[Test C] FAILED:", e); + } + + // --- Summary --- + console.log("\n---------------------------------------------------"); + console.log(`Tests Completed: ${testsPassed}/${testsTotal}`); + + aliceClient.logout(); + bobClient.logout(); + + if (testsPassed === testsTotal) { + console.log("ALL TESTS PASSED"); + process.exit(0); + } else { + console.error("SOME TESTS FAILED"); + process.exit(1); + } } -runInteropTests(); +runTests().catch(e => { + console.error("Unhandled execution error:", e); + process.exit(1); +}); From 7a3476f92641db429de95c72fc0fbae5ec8707f2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 07:37:47 +0000 Subject: [PATCH 2/4] fix: flaky unit test in profile batch fetcher Updated `tests/app-batch-fetch-profiles.test.mjs` to correctly mock `readRelays` in addition to `relays` and `writeRelays`. `profileMetadataService.js` prioritizes `readRelays`, causing the test to make unexpected network calls when falling back to default values. This fix ensures the test isolation holds and assertions pass consistently. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/app-batch-fetch-profiles.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/app-batch-fetch-profiles.test.mjs b/tests/app-batch-fetch-profiles.test.mjs index df90a4bc..45148645 100644 --- a/tests/app-batch-fetch-profiles.test.mjs +++ b/tests/app-batch-fetch-profiles.test.mjs @@ -49,6 +49,9 @@ test("batchFetchProfiles handles fast and failing relays", async () => { const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : []; + const originalReadRelays = Array.isArray(nostrClient.readRelays) + ? [...nostrClient.readRelays] + : nostrClient.readRelays; const originalWriteRelays = Array.isArray(nostrClient.writeRelays) ? [...nostrClient.writeRelays] : nostrClient.writeRelays; @@ -57,6 +60,7 @@ test("batchFetchProfiles handles fast and failing relays", async () => { const poolListCalls = []; nostrClient.relays = [FAST_RELAY, FAIL_RELAY]; + nostrClient.readRelays = [FAST_RELAY, FAIL_RELAY]; nostrClient.writeRelays = [FAST_RELAY, FAIL_RELAY]; nostrClient.pool = { list: (relays, filters) => { @@ -92,6 +96,7 @@ test("batchFetchProfiles handles fast and failing relays", async () => { }); } finally { nostrClient.relays = originalRelays; + nostrClient.readRelays = originalReadRelays; nostrClient.writeRelays = originalWriteRelays; nostrClient.pool = originalPool; } From 51c5845ae1fdc7200a5417e2c855a8661ea10a17 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 07:48:19 +0000 Subject: [PATCH 3/4] fix: resolve missing mock method in profile modal tests Added the missing `load()` method to the `hashtagPreferences` mock in `tests/profile-modal-controller.test.mjs`. This resolves the `TypeError` encountered during the `handleAddHashtagPreference` test case, ensuring the test suite passes consistently in CI. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/profile-modal-controller.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index f14c3730..e6b541a5 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -274,6 +274,7 @@ function createController(options = {}) { return hashtagStore.disinterests.delete(normalized); }, publish: async () => ({ ok: true }), + load: async () => true, on: () => () => {}, }; From 6d5170d3fca66074fb2ea06adce9452f00232d60 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 07:55:06 +0000 Subject: [PATCH 4/4] fix: flaky unit test in profile batch fetcher Updated `tests/app-batch-fetch-profiles.test.mjs` to correctly mock `readRelays` in addition to `relays` and `writeRelays`. `profileMetadataService.js` prioritizes `readRelays`, causing the test to make unexpected network calls when falling back to default values. This fix ensures the test isolation holds and assertions pass consistently. Also fixed a failing mock in `tests/profile-modal-controller.test.mjs` by adding a missing `load()` method to the `hashtagPreferences` service stub. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>