diff --git a/scripts/agent/smoke-test.mjs b/scripts/agent/smoke-test.mjs index 20e86b08..dcdf5788 100644 --- a/scripts/agent/smoke-test.mjs +++ b/scripts/agent/smoke-test.mjs @@ -1,285 +1,286 @@ import './setup-test-env.js'; -import "fake-indexeddb/auto"; -import { startRelay } from './simple-relay.mjs'; -import { NostrClient } from '../../js/nostr/client.js'; -import { buildVideoPostEvent } from '../../js/nostrEventSchemas.js'; -import { decryptDM } from '../../js/dmDecryptor.js'; +import { indexedDB, IDBKeyRange } from "fake-indexeddb"; +import { WebSocket } from 'ws'; import * as NostrTools from 'nostr-tools'; -import { chromium } from 'playwright'; -import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; -import http from 'http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'url'; +// --- Polyfills --- +if (!global.indexedDB) { + global.indexedDB = indexedDB; +} +if (!global.IDBKeyRange) { + global.IDBKeyRange = IDBKeyRange; +} +if (!global.NostrTools) { + global.NostrTools = NostrTools; +} +// setup-test-env.js handles WebSocket, crypto, localStorage, window, self, navigator + +// --- Imports from Codebase --- +// Note: We need to use relative paths from scripts/agent/ +import { NostrClient } from '../../js/nostr/client.js'; +import { decryptDM } from '../../js/dmDecryptor.js'; +import { startRelay } from './load-test-relay.mjs'; + +// --- Configuration --- const ARTIFACTS_DIR = 'artifacts'; -const LOG_FILE = path.join(ARTIFACTS_DIR, `smoke-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.log`); -const REPORT_FILE = path.join(ARTIFACTS_DIR, `smoke-summary-${new Date().toISOString().replace(/[:.]/g, '-')}.json`); -const UI_SCREENSHOT = path.join(ARTIFACTS_DIR, 'smoke-ui.png'); - if (!fs.existsSync(ARTIFACTS_DIR)) { - fs.mkdirSync(ARTIFACTS_DIR); + fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); } -// Logging helper -function log(msg, type = 'INFO') { - const timestamp = new Date().toISOString(); - const line = `[${timestamp}] [${type}] ${msg}`; - console.log(line); - fs.appendFileSync(LOG_FILE, line + '\n'); +const RELAY_PORT = 8890; +const RELAY_URL = `ws://localhost:${RELAY_PORT}`; +const HTTP_PORT = 8000; +const HTTP_URL = `http://localhost:${HTTP_PORT}`; + +// --- Logging --- +const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD +const logFile = path.join(ARTIFACTS_DIR, `smoke-${dateStr}.log`); +const logStream = fs.createWriteStream(logFile, { flags: 'a' }); + +function log(msg, ...args) { + const timestamp = new Date().toISOString(); + const message = `[${timestamp}] ${msg} ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ')}`; + console.log(message); + logStream.write(message + '\n'); } -function bytesToHex(bytes) { - return Buffer.from(bytes).toString('hex'); +function error(msg, ...args) { + const timestamp = new Date().toISOString(); + const message = `[${timestamp}] [ERROR] ${msg} ${args.map(a => (a instanceof Error ? a.stack : (typeof a === 'object' ? JSON.stringify(a) : a))).join(' ')}`; + console.error(message); + logStream.write(message + '\n'); } -// Helper to wait for server -function waitForServer(url, timeoutMs = 10000) { - const start = Date.now(); - return new Promise((resolve, reject) => { - const check = () => { - const req = http.get(url, (res) => { - if (res.statusCode === 200) { - resolve(true); - } else { - retry(); - } - }); - req.on('error', retry); - req.end(); - }; +// --- Helpers --- +async function waitForServer(url, timeoutMs = 10000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url); + if (res.ok) return true; + } catch (e) { + // ignore + } + await new Promise(r => setTimeout(r, 200)); + } + return false; +} - const retry = () => { - if (Date.now() - start > timeoutMs) { - reject(new Error(`Server at ${url} did not respond within ${timeoutMs}ms`)); - } else { - setTimeout(check, 500); - } - }; +// --- Main Test Flow --- +async function run() { + let relayServer = null; + let httpServer = null; + let client = null; + let success = true; - check(); + try { + log('--- Starting Smoke Test ---'); + + // 1. Start Relay + log(`Starting local relay on port ${RELAY_PORT}...`); + relayServer = startRelay(RELAY_PORT); + // Wait a bit for relay to be ready (startRelay is synchronous in setup but async in effect?) + // It uses WebSocketServer which starts immediately. + await new Promise(r => setTimeout(r, 500)); + log('Relay started.'); + + // 2. Start HTTP Server + log(`Starting HTTP server (npx serve) on port ${HTTP_PORT}...`); + httpServer = spawn('npx', ['serve', '-p', String(HTTP_PORT)], { + stdio: 'ignore', // or 'inherit' to see output + detached: false }); -} -async function runSmokeTest() { - log("Starting Smoke Test..."); - let relay; - let serverProcess; - let exitCode = 0; - const summary = { - timestamp: new Date().toISOString(), - steps: {} + const serverReady = await waitForServer(HTTP_URL); + if (!serverReady) { + throw new Error(`HTTP server did not start on ${HTTP_URL} within timeout.`); + } + log('HTTP server is ready.'); + + // 3. Init NostrClient + log('Initializing NostrClient...'); + client = new NostrClient(); + client.relays = [RELAY_URL]; + client.readRelays = [RELAY_URL]; + client.writeRelays = [RELAY_URL]; + + // Override dev logger to avoid pollution if needed, or let it log + // We can rely on standard logging. + + await client.init(); + log(`NostrClient initialized and connected to ${RELAY_URL}`); + + // 4. Login (Ephemeral) + log('Generating ephemeral keys for Sender...'); + const senderSk = NostrTools.generateSecretKey(); + const senderSkHex = Buffer.from(senderSk).toString('hex'); + const senderPk = NostrTools.getPublicKey(senderSk); + + log(`Sender Pubkey: ${senderPk}`); + + await client.registerPrivateKeySigner({ privateKey: senderSkHex, persist: false }); + client.pubkey = senderPk; // Manually set logged-in user + + if (client.pubkey !== senderPk) { + throw new Error(`Client pubkey mismatch. Expected ${senderPk}, got ${client.pubkey}`); + } + log('Sender logged in.'); + + // 5. Publish Video + log('Publishing test video...'); + const videoPayload = { + title: `Smoke Test Video ${Date.now()}`, + description: 'Smoke test description', + magnet: `magnet:?xt=urn:btih:${Math.random().toString(16).slice(2)}`, + url: `${HTTP_URL}/test.mp4`, // Fake URL + thumbnail: `${HTTP_URL}/thumb.jpg`, + mode: 'live', + videoRootId: `smoke-${Date.now()}`, + isNsfw: false, + isForKids: true }; - try { - // --- Step 1: Start Infrastructure --- - log("Step 1: Starting Infrastructure"); - - // Start Relay - const RELAY_PORT = 8899; - relay = startRelay(RELAY_PORT); - const RELAY_URL = `ws://localhost:${RELAY_PORT}`; - log(`Relay started at ${RELAY_URL}`); - summary.steps.relay = "SUCCESS"; - - // Start Web Server - log("Starting web server (npx serve)..."); - serverProcess = spawn('npx', ['serve', '-p', '3000'], { - stdio: 'ignore', // Suppress serve output to avoid clutter, or pipe to log if needed - shell: true, - detached: false // Ensure we can kill it - }); - - await waitForServer('http://localhost:3000'); - log("Web server is up at http://localhost:3000"); - summary.steps.server = "SUCCESS"; - - // --- Step 2: Protocol Smoke Test (Headless Client) --- - log("Step 2: Protocol Smoke Test"); - - const client = new NostrClient(); - client.relays = [RELAY_URL]; - client.readRelays = [RELAY_URL]; - client.writeRelays = [RELAY_URL]; - - log("Initializing NostrClient..."); - await client.init(); - log("NostrClient initialized and connected."); - - // Generate Identity - const secretKey = NostrTools.generateSecretKey(); - const privateKey = bytesToHex(secretKey); - const pubkey = NostrTools.getPublicKey(secretKey); - log(`Generated Identity: ${pubkey}`); - - await client.registerPrivateKeySigner({ - privateKey: privateKey, - pubkey: pubkey, - persist: false - }); - log("Identity registered."); - - // A. Video Flow - log("--- Subtest: Video Flow ---"); - const videoEvent = buildVideoPostEvent({ - pubkey: pubkey, - created_at: Math.floor(Date.now() / 1000), - dTagValue: `smoke-${Date.now()}`, - content: { - version: 3, - title: "Smoke Test Video", - description: "Smoke testing video publish", - url: "https://example.com/smoke.mp4", - magnet: "magnet:?xt=urn:btih:1234567890abcdef&dn=smoke", - isForKids: false, - isNsfw: false, - enableComments: true, - videoRootId: `root-${Date.now()}`, - mode: "live" - } - }); - - const { signedEvent: publishedVideo } = await client.signAndPublishEvent(videoEvent); - log(`Video Published: ${publishedVideo.id}`); - - await new Promise(r => setTimeout(r, 100)); // Allow propagation - const fetchedVideo = await client.getEventById(publishedVideo.id); - if (!fetchedVideo) throw new Error("Failed to fetch published video"); - if (fetchedVideo.title !== "Smoke Test Video") throw new Error("Fetched video title mismatch"); - log("Video verified."); - summary.steps.video_flow = "SUCCESS"; - - // B. View Flow - log("--- Subtest: View Flow ---"); - const videoPointer = { - type: 'e', - value: publishedVideo.id, - relay: RELAY_URL - }; - const viewResult = await client.publishViewEvent(videoPointer); - log(`View Event Published: ${viewResult.event ? viewResult.event.id : 'undefined'}`); - summary.steps.view_flow = "SUCCESS"; - - // C. DM Flow - log("--- Subtest: DM Flow ---"); - const dmMessage = "Smoke Test DM Payload"; - const dmResult = await client.sendDirectMessage(NostrTools.nip19.npubEncode(pubkey), dmMessage); - if (!dmResult.ok) throw new Error(`DM Send Failed: ${dmResult.error}`); - log("DM Sent."); - - await new Promise(r => setTimeout(r, 500)); - - // C1. Verify via listDirectMessages (High level) - const dms = await client.listDirectMessages(pubkey); - const foundDm = dms.find(dm => dm.plaintext === dmMessage); - if (!foundDm) { - // Debug info - log(`DMs found: ${dms.length}`); - if (dms.length) log(`First DM plaintext: ${dms[0].plaintext}`); - throw new Error("DM not found via listDirectMessages"); - } - log("DM verified via listDirectMessages."); - - // C2. Verify via raw event + decryptDM (Low level) - // We find the event ID from the high level list or list pool directly. - // Let's use the ID from the found message. - const dmEventId = foundDm.event.id; - const rawDmEvent = await client.fetchRawEventById(dmEventId); - if (!rawDmEvent) throw new Error("Failed to fetch raw DM event"); - - log("Decrypting raw DM event manually..."); - const decryptContext = { - actorPubkey: pubkey, - decryptors: [ - { - scheme: 'nip04', - decrypt: async (targetPubkey, ciphertext) => { - // NIP-04: decrypt(privKey, pubKey, ciphertext) - // Here, since we are receiver, we use our privKey. - // The targetPubkey passed by decryptDM for legacy DMs (kind 4) is the sender's pubkey. - return await NostrTools.nip04.decrypt(privateKey, targetPubkey, ciphertext); - }, - priority: 1 - } - ] - }; - - const manualDecryptResult = await decryptDM(rawDmEvent, decryptContext); - if (!manualDecryptResult.ok) { - log(`Manual decryption failed: ${JSON.stringify(manualDecryptResult.errors)}`, 'ERROR'); - throw new Error("Manual decryption failed"); - } - if (manualDecryptResult.plaintext !== dmMessage) { - throw new Error(`Manual decryption mismatch. Got: ${manualDecryptResult.plaintext}`); - } - log("DM verified via manual decryptDM."); - summary.steps.dm_flow = "SUCCESS"; - - // --- Step 3: UI Smoke Test (Headless Browser) --- - log("Step 3: UI Smoke Test"); - log("Launching browser..."); - const browser = await chromium.launch(); - const page = await browser.newPage(); - - try { - await page.goto('http://localhost:3000', { waitUntil: 'networkidle' }); - const title = await page.title(); - log(`Page Title: ${title}`); - - // Take screenshot - await page.screenshot({ path: UI_SCREENSHOT }); - log(`Screenshot saved to ${UI_SCREENSHOT}`); - - summary.steps.ui_test = "SUCCESS"; - } catch (e) { - log(`UI Test Failed: ${e.message}`, 'ERROR'); - summary.steps.ui_test = "FAILED"; - throw e; - } finally { - await browser.close(); - } - - summary.status = "PASS"; - log("Smoke Test Completed Successfully."); - - } catch (error) { - log(`Smoke Test Failed: ${error.stack}`, 'ERROR'); - summary.status = "failure"; - summary.error = error.message; - exitCode = 1; - } finally { - // --- Write Report --- - try { - if (fs.existsSync(LOG_FILE)) { - const logs = fs.readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean); - summary.logs = logs; - } - fs.writeFileSync(REPORT_FILE, JSON.stringify(summary, null, 2)); - log(`Summary written to ${REPORT_FILE}`); - } catch (e) { - log(`Failed to write report: ${e.message}`, 'ERROR'); - } - - // --- Cleanup --- - log("Cleaning up..."); - - try { - if (relay && relay.close) { - // Don't await indefinitely - await Promise.race([ - relay.close(), - new Promise(r => setTimeout(r, 2000)) - ]); - } - } catch (e) { - log(`Relay close error: ${e.message}`, 'WARN'); - } - - if (serverProcess) { - serverProcess.kill(); - } - - process.exit(exitCode); + const publishedEvent = await client.publishVideo(videoPayload, senderPk); + if (!publishedEvent || !publishedEvent.id) { + throw new Error('Failed to publish video event.'); } + log(`Video published. Event ID: ${publishedEvent.id}`); + + // Verify Read Back + log('Verifying video can be fetched...'); + // We use subscribeVideos for main feed, or getEventById. + // Let's use getEventById for direct verification. + const fetched = await client.getEventById(publishedEvent.id); + if (!fetched) { + throw new Error('Failed to fetch published video.'); + } + if (fetched.title !== videoPayload.title) { + throw new Error(`Fetched video title mismatch. Expected "${videoPayload.title}", got "${fetched.title}"`); + } + log('Video fetched successfully.'); + + // 6. DM Flow + log('Testing DM Flow...'); + const recipientSk = NostrTools.generateSecretKey(); + const recipientSkHex = Buffer.from(recipientSk).toString('hex'); + const recipientPk = NostrTools.getPublicKey(recipientSk); + const recipientNpub = NostrTools.nip19.npubEncode(recipientPk); + + log(`Recipient Pubkey: ${recipientPk}`); + + const dmMessage = `Smoke Test Message ${Date.now()}`; + + // Send DM + const sendResult = await client.sendDirectMessage(recipientNpub, dmMessage); + if (!sendResult.ok) { + throw new Error(`Failed to send DM: ${sendResult.error}`); + } + log('DM sent successfully.'); + + // Verify Decryption (Simulate Recipient) + log('Verifying DM decryption (Recipient view)...'); + + // We need to find the DM event. + // Since we don't have a second client syncing, we can manually query the pool/relay. + const filter = { + kinds: [4, 1059], // 4 for legacy, 1059 for NIP-17 + '#p': [recipientPk], + limit: 1 + }; + + // wait a moment for propagation + await new Promise(r => setTimeout(r, 200)); + + const events = await client.pool.list([RELAY_URL], [filter]); + if (events.length === 0) { + throw new Error('Recipient could not find DM event on relay.'); + } + + const dmEvent = events[0]; + log(`Found DM event kind ${dmEvent.kind} id ${dmEvent.id}`); + + // Construct decryption context for decryptDM + // We need a decryptor for the recipient. + // Since we have the recipient's private key, we can use NostrTools.nip04 or nip44. + + const decryptors = []; + + // NIP-04 Decryptor + decryptors.push({ + scheme: 'nip04', + decrypt: async (remotePubkey, ciphertext) => { + return await NostrTools.nip04.decrypt(recipientSkHex, remotePubkey, ciphertext); + } + }); + + // NIP-44 Decryptor + decryptors.push({ + scheme: 'nip44', + supportsGiftWrap: true, + decrypt: (remotePubkey, ciphertext) => { + const conversationKey = NostrTools.nip44.v2.utils.getConversationKey(recipientSk, remotePubkey); + return NostrTools.nip44.v2.decrypt(ciphertext, conversationKey); + } + }); + + const context = { + actorPubkey: recipientPk, + decryptors + }; + + const decryptResult = await decryptDM(dmEvent, context); + + if (!decryptResult.ok) { + throw new Error(`DM decryption failed: ${JSON.stringify(decryptResult.errors)}`); + } + + if (decryptResult.plaintext !== dmMessage) { + throw new Error(`DM plaintext mismatch. Expected "${dmMessage}", got "${decryptResult.plaintext}"`); + } + + log('DM verified successfully.'); + + } catch (err) { + success = false; + error('Smoke Test Failed!', err); + } finally { + // Report + const summary = { + timestamp: new Date().toISOString(), + success, + logFile + }; + const summaryFile = path.join(ARTIFACTS_DIR, `smoke-summary-${dateStr}.json`); + fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2)); + log(`Summary saved to ${summaryFile}`); + + // Teardown + log('Teardown...'); + if (client) { + // client.logout(); // Cleans up + // Close pool connections? + // client.pool.close(client.relays); // SimplePool doesn't have explicit close usually? + // NostrTools SimplePool usually manages connections. + // We can just rely on process exit. + } + + if (relayServer) { + relayServer.close(); + log('Relay stopped.'); + } + + if (httpServer) { + httpServer.kill(); + log('HTTP server stopped.'); + } + + log('Done.'); + process.exit(success ? 0 : 1); + } } -runSmokeTest(); +run();