diff --git a/scripts/agent/interop-test.mjs b/scripts/agent/interop-test.mjs index b5cde18c..fcc2a5e7 100644 --- a/scripts/agent/interop-test.mjs +++ b/scripts/agent/interop-test.mjs @@ -1,198 +1,325 @@ +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 { WebSocket } from 'ws'; -import * as crypto from 'node:crypto'; -import * as NostrTools from 'nostr-tools'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, '../../'); -// Polyfills -globalThis.WebSocket = WebSocket; -if (!globalThis.crypto) { - globalThis.crypto = crypto; +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; + +// Ensure artifacts dir exists +if (!fs.existsSync(ARTIFACTS_DIR)) { + fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); } -globalThis.NostrTools = NostrTools; -// Mock localStorage -const localStorageMock = { - store: {}, - getItem: function(key) { return this.store[key] || null; }, - setItem: function(key, value) { this.store[key] = String(value); }, - removeItem: function(key) { delete this.store[key]; }, - clear: function() { this.store = {}; } +function log(message, type = 'INFO') { + const msg = `[${new Date().toISOString()}] [${type}] ${message}`; + console.log(msg); + fs.appendFileSync(LOG_FILE, msg + '\n'); +} + +const stats = { + steps: 0, + failures: 0, + details: [] }; -globalThis.localStorage = localStorageMock; -// Prevent crash on unhandled rejection (e.g. from nostr-tools timeouts) -process.on('unhandledRejection', (reason, p) => { - // console.log('[TEST] Unhandled Rejection (suppressed):', reason); -}); - -// Configuration -const TEST_RELAYS = [ - 'wss://relay.damus.io', - 'wss://nos.lol' -]; - -async function runTest() { - const { NostrClient } = await import('../../js/nostr/client.js'); - // Removed unused imports - - console.log('Starting Interop Test...'); - const logs = []; - const log = (msg, data) => { - console.log(`[TEST] ${msg}`, data ? JSON.stringify(data, null, 2) : ''); - logs.push({ msg, data }); - }; - - try { - // ------------------------------------------------------------------------- - // 1. Setup Sender - // ------------------------------------------------------------------------- - log('Initializing Sender Client...'); - const senderClient = new NostrClient(); - - // Explicitly set relays - senderClient.relays = [...TEST_RELAYS]; - senderClient.writeRelays = [...TEST_RELAYS]; - senderClient.readRelays = [...TEST_RELAYS]; - log('Sender Relays set to:', senderClient.relays); - - // Generate Ephemeral Key for Sender - const senderSecret = NostrTools.generateSecretKey(); - const senderPrivKey = Buffer.from(senderSecret).toString('hex'); - - const { pubkey: senderPubkey } = await senderClient.registerPrivateKeySigner({ privateKey: senderPrivKey }); - senderClient.pubkey = senderPubkey; - log('Sender Pubkey:', senderClient.pubkey); - - await senderClient.init(); - log('Sender Client Initialized.'); - - // ------------------------------------------------------------------------- - // 2. Publish Video Post - // ------------------------------------------------------------------------- - log('Test A: Publish Video Post'); - const videoRootId = `interop-test-${Date.now()}`; - const magnet = `magnet:?xt=urn:btih:${crypto.randomBytes(20).toString('hex')}&dn=test-video`; - const title = 'Interop Test Video ' + new Date().toISOString(); - - const videoData = { - title, - videoRootId, - magnet, - description: 'Automated interop test video', - mimeType: 'video/mp4' - }; - - log('Publishing video...', videoData); - const signedVideoEvent = await senderClient.publishVideo(videoData, senderClient.pubkey); - log('Video Published. Event ID:', signedVideoEvent.id); - - // Verify - log('Verifying video event fetch...'); - // Allow some propagation time - await new Promise(r => setTimeout(r, 2000)); - - const fetchedVideo = await senderClient.getEventById(signedVideoEvent.id); - if (!fetchedVideo) throw new Error('Failed to fetch published video event'); - if (fetchedVideo.id !== signedVideoEvent.id) throw new Error('Fetched event ID mismatch'); - - const isSigValid = NostrTools.verifyEvent(signedVideoEvent); - if (!isSigValid) throw new Error('Invalid event signature'); - - // Verify content shape - if (fetchedVideo.title !== title) throw new Error(`Title mismatch: expected "${title}", got "${fetchedVideo.title}"`); - if (fetchedVideo.magnet !== magnet) throw new Error(`Magnet mismatch: expected "${magnet}", got "${fetchedVideo.magnet}"`); - // Note: publishVideo generates a new videoRootId, so we check against the signed event's content, not our input - const signedContent = JSON.parse(signedVideoEvent.content); - if (fetchedVideo.videoRootId !== signedContent.videoRootId) throw new Error(`Root ID mismatch: expected "${signedContent.videoRootId}", got "${fetchedVideo.videoRootId}"`); - - log('Test A PASSED: Video event published and verified (content matched).'); - - // ------------------------------------------------------------------------- - // 3. Publish View Event - // ------------------------------------------------------------------------- - log('Test B: Publish View Event'); - const pointer = { - type: 'e', - value: signedVideoEvent.id, - relay: TEST_RELAYS[0] - }; - - log('Publishing view event...', pointer); - const viewResult = await senderClient.publishViewEvent(pointer); - - if (!viewResult.ok) throw new Error(`Failed to publish view event: ${viewResult.error}`); - log('View event published.'); - - // Verify View Event Visibility - log('Verifying view event visibility...'); - await new Promise(r => setTimeout(r, 2000)); - - // We can list view events for this pointer - const viewEvents = await senderClient.listVideoViewEvents(pointer, { limit: 5 }); - const foundView = viewEvents.find(v => v.pubkey === senderPubkey); // It should be signed by sender - - if (!foundView) { - log('Fetched View Events:', viewEvents); - throw new Error('Could not verify visibility of published view event.'); +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'); + } + } else { + log(`Step Passed: ${name}`, 'SUCCESS'); } - log('Test B PASSED: View event verified visible.'); - - // ------------------------------------------------------------------------- - // 4. Encrypted Direct Message (Sender -> Receiver) - // ------------------------------------------------------------------------- - log('Test C: Encrypted DM'); - - // Setup Receiver - const receiverClient = new NostrClient(); - receiverClient.relays = [...TEST_RELAYS]; - receiverClient.writeRelays = [...TEST_RELAYS]; - receiverClient.readRelays = [...TEST_RELAYS]; - - const receiverSecret = NostrTools.generateSecretKey(); - const receiverPrivKey = Buffer.from(receiverSecret).toString('hex'); - - const { pubkey: receiverPubkey } = await receiverClient.registerPrivateKeySigner({ privateKey: receiverPrivKey }); - receiverClient.pubkey = receiverPubkey; - log('Receiver Pubkey:', receiverPubkey); - - await receiverClient.init(); - - // Sender sends DM - const messageContent = `Hello Interop ${Date.now()}`; - log(`Sender sending DM to ${receiverPubkey}: "${messageContent}"`); - - // sendDirectMessage is on the sender client - const sendResult = await senderClient.sendDirectMessage( - NostrTools.nip19.npubEncode(receiverPubkey), - messageContent - ); - - if (!sendResult.ok) throw new Error(`Failed to send DM: ${sendResult.error}`); - log('DM Sent.'); - - // Wait for propagation - await new Promise(r => setTimeout(r, 5000)); - - // Receiver fetches DMs - log('Receiver fetching DMs...'); - const dms = await receiverClient.listDirectMessages(receiverPubkey); - - const foundDm = dms.find(dm => dm.plaintext === messageContent); - - if (!foundDm) { - log('Fetched DMs:', dms.map(d => ({ plain: d.plaintext, sender: d.sender }))); - throw new Error('Receiver could not find or decrypt the sent message.'); - } - - log('Test C PASSED: DM received and decrypted.'); - - console.log('\nAll Interop Tests PASSED!'); - process.exit(0); - - } catch (err) { - console.error('\nTest FAILED:', err); - process.exit(1); - } + stats.details.push(detail); } -runTest(); +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; + + 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); + } + }; + + proc.stdout.on('data', (data) => { + checkData(data); + }); + proc.stderr.on('data', (data) => { + checkData(data); + }); + + proc.on('error', (err) => reject(err)); + proc.on('exit', (code) => { + if (!started) reject(new Error(`${command} exited early with code ${code}`)); + }); + + // 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); + }); +} + +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();