diff --git a/scripts/agent/load-test-relay.mjs b/scripts/agent/load-test-relay.mjs index bff55206..27a8ca9f 100644 --- a/scripts/agent/load-test-relay.mjs +++ b/scripts/agent/load-test-relay.mjs @@ -68,6 +68,10 @@ export function startRelay(port = 8889) { if (typeof subId === "string") { subs.get(ws).delete(subId); } + } else if (type === "COUNT") { + const subId = payload[0]; + // Dummy count support + ws.send(JSON.stringify(["COUNT", subId, { count: 0 }])); } } catch (err) { // console.error("Relay error processing message:", err); diff --git a/scripts/agent/smoke-test.mjs b/scripts/agent/smoke-test.mjs index dcdf5788..af905fb2 100644 --- a/scripts/agent/smoke-test.mjs +++ b/scripts/agent/smoke-test.mjs @@ -1,286 +1,400 @@ -import './setup-test-env.js'; -import { indexedDB, IDBKeyRange } from "fake-indexeddb"; +import { chromium } from 'playwright'; import { WebSocket } from 'ws'; import * as NostrTools from 'nostr-tools'; +import { startRelay } from './load-test-relay.mjs'; +import { decryptDM } from '../../js/dmDecryptor.js'; 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'; -if (!fs.existsSync(ARTIFACTS_DIR)) { - fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); -} - -const RELAY_PORT = 8890; +const SCREENSHOTS_DIR = path.join(ARTIFACTS_DIR, 'screenshots'); +const RELAY_PORT = 8889; 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 dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const logFile = path.join(ARTIFACTS_DIR, `smoke-${dateStr}.log`); + +if (!fs.existsSync(ARTIFACTS_DIR)) fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); +if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + 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'); + 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 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'); + 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'); } // --- 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 +async function waitForServer(url, timeoutMs = 15000) { + 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, 500)); } - await new Promise(r => setTimeout(r, 200)); - } - return false; + return false; } -// --- Main Test Flow --- +// --- Main Script --- async function run() { - let relayServer = null; - let httpServer = null; - let client = null; - let success = true; + let relayServer = null; + let httpServer = null; + let browser = null; + let success = true; - try { - log('--- Starting Smoke Test ---'); + 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.'); + // 1. Start Relay + log(`Starting local relay on port ${RELAY_PORT}...`); + relayServer = startRelay(RELAY_PORT); + 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 - }); + // 2. Start HTTP Server + log(`Starting HTTP server on port ${HTTP_PORT}...`); + httpServer = spawn('npx', ['serve', '-p', String(HTTP_PORT)], { + stdio: 'ignore', + detached: false, + shell: true + }); - 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.'); + const serverReady = await waitForServer(HTTP_URL); + if (!serverReady) throw new Error(`HTTP server failed to start at ${HTTP_URL}`); + log('HTTP server ready.'); - // 3. Init NostrClient - log('Initializing NostrClient...'); - client = new NostrClient(); - client.relays = [RELAY_URL]; - client.readRelays = [RELAY_URL]; - client.writeRelays = [RELAY_URL]; + // 3. Launch Browser + log('Launching browser...'); + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); - // Override dev logger to avoid pollution if needed, or let it log - // We can rely on standard logging. + page.on('console', msg => { + const type = msg.type(); + const text = msg.text(); + if (type === 'error' || type === 'warning') { + log(`[BROWSER ${type.toUpperCase()}] ${text}`); + } + }); + page.on('pageerror', err => { + log(`[BROWSER UNCAUGHT] ${err.message}`); + }); - await client.init(); - log(`NostrClient initialized and connected to ${RELAY_URL}`); + // 4. Mock Extension Setup + const ephemeralSk = NostrTools.generateSecretKey(); + const ephemeralSkHex = Buffer.from(ephemeralSk).toString('hex'); // nostr-tools v2 uses Uint8Array, Buffer covers it + const ephemeralPk = NostrTools.getPublicKey(ephemeralSk); + log(`Ephemeral Pubkey: ${ephemeralPk}`); - // 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); + // Pre-seed Relay with User's Relay List (Kind 10002) + // This ensures the app finds the local relay when it syncs preferences, avoiding fallback to public defaults. + { + log('Seeding relay list event...'); + const seedWs = new WebSocket(RELAY_URL); + await new Promise((resolve) => seedWs.on('open', resolve)); - log(`Sender Pubkey: ${senderPk}`); + const relayListEvent = { + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags: [['r', RELAY_URL]], + content: '', + pubkey: ephemeralPk, + id: null, + sig: null + }; + const signedSeed = NostrTools.finalizeEvent(relayListEvent, ephemeralSk); - await client.registerPrivateKeySigner({ privateKey: senderSkHex, persist: false }); - client.pubkey = senderPk; // Manually set logged-in user + const seedPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Seed timeout')), 2000); + seedWs.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg[0] === 'OK' && msg[1] === signedSeed.id && msg[2]) { + clearTimeout(timeout); + resolve(); + } + }); + seedWs.send(JSON.stringify(['EVENT', signedSeed])); + }); - 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 - }; - - 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); + await seedPromise; + seedWs.close(); + log('Relay list seeded successfully.'); } - }); - // 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); + // Disable whitelist mode & Inject Relay Preferences + await page.addInitScript(({ pubkey, relayUrl }) => { + window.localStorage.setItem('bitvid_admin_whitelist_mode', 'false'); + const key = `bitvid:profile:${pubkey}:relayList:v1`; + const data = [{ url: relayUrl, mode: 'both' }]; + window.localStorage.setItem(key, JSON.stringify(data)); + }, { pubkey: ephemeralPk, relayUrl: RELAY_URL }); + + // Expose Node-side signing to Browser + await page.exposeFunction('mockGetPublicKey', async () => ephemeralPk); + await page.exposeFunction('mockSignEvent', async (event) => { + const signed = NostrTools.finalizeEvent(event, ephemeralSk); + return signed; + }); + await page.exposeFunction('mockNip04Encrypt', async (pubkey, plaintext) => { + return await NostrTools.nip04.encrypt(ephemeralSk, pubkey, plaintext); + }); + await page.exposeFunction('mockNip04Decrypt', async (pubkey, ciphertext) => { + return await NostrTools.nip04.decrypt(ephemeralSk, pubkey, ciphertext); + }); + // Add NIP-44 if needed, mostly NIP-07 uses nip04 for legacy support or nip44 + + await page.addInitScript(() => { + window.nostr = { + getPublicKey: () => window.mockGetPublicKey(), + signEvent: (event) => window.mockSignEvent(event), + nip04: { + encrypt: (pubkey, plaintext) => window.mockNip04Encrypt(pubkey, plaintext), + decrypt: (pubkey, ciphertext) => window.mockNip04Decrypt(pubkey, ciphertext) + } + }; + }); + + // 5. Navigate and Login + log('Navigating to app...'); + await page.goto(HTTP_URL); + + // Wait for app to initialize (fade-in or login button) + await page.waitForSelector('#loginButton'); + log('App loaded.'); + + // Override relays in the app to point to local relay? + // Ideally we should via UI, but simpler to just let it connect to defaults AND our local relay. + // Or we can inject settings. + // Let's assume the app will connect to default relays. We want it to use OUR relay for the test. + // We can execute: window.app.nostrClient.addRelay(...) if we could access it. + // Or we can just let it fail to connect to public relays and hope it doesn't block UI. + // BUT, for the publish verification to work, the app needs to publish to a relay we can read. + // So we MUST ensure the app connects to localhost:8889. + // We can try to inject it into localStorage before load? + // 'nostr-relays' key? + // Let's check `relayManager.js` or `localStorage` keys. + // Usually `nostr:relays` or similar. + // Assuming the app has a default list. + // We can try to add the relay via console after load. + // "window.nostrClient" might be available if exposed? + // Earlier I saw `js/index.js` doesn't expose it. + // BUT `js/nostrClientFacade.js` exports `nostrClient`. + // Maybe I can rely on the app logic: if I publish, it goes to "write relays". + // I'll try to use the "Manage Storage" or similar? No. + // Let's try to infer if I can just assume it works or if I need to force it. + // Actually, for smoke tests, we often want to test against a controlled relay. + // I will try to inject a relay into the default set if possible. + // `js/relayManager.js` might read from storage. + // Let's try to set `nostr:relays` in localStorage before load. + // Or I can just continue and see if it works (maybe defaults include localhost in dev mode?). + // If not, I'll use `page.evaluate` to find a way. + + // Login + await page.click('#loginButton'); + await page.waitForSelector('[data-provider-id="nip07"]', { timeout: 10000 }); + log('Login modal open.'); + + await page.click('[data-provider-id="nip07"]'); + log('Clicked NIP-07 Login.'); + + // Wait for login success (Upload button appears) + await page.waitForSelector('#uploadButton', { state: 'visible', timeout: 10000 }); + log('Logged in successfully.'); + + // 6. Publish Video + log('Publishing video...'); + await page.click('#uploadButton'); + await page.waitForSelector('#uploadModal', { state: 'visible' }); + + await page.click('#btn-mode-external'); + + const testTitle = `Smoke Test Video ${Date.now()}`; + await page.fill('#input-title', testTitle); + await page.fill('#input-description', 'This is a smoke test video.'); + await page.fill('#input-url', 'https://example.com/test.mp4'); // Fake HTTPS URL + + await page.click('#btn-submit'); + log('Submit clicked.'); + + // Wait for modal to close OR continue if it takes too long (public relay timeouts) + try { + await page.waitForSelector('#uploadModal', { state: 'hidden', timeout: 5000 }); + log('Upload modal closed.'); + } catch (e) { + log('Upload modal did not close quickly (likely waiting for public relays), proceeding to verify content...'); } - }); - const context = { - actorPubkey: recipientPk, - decryptors - }; + // Verify Video in Feed + // It might take a moment to appear. + // The app connects to relays. If it didn't connect to OUR relay, we might not see it if we are checking via Node. + // But the Browser is checking via the App's UI. + // The App needs to be connected to a relay where it published. + // If the App defaults to damus.io etc, and fails to connect (no internet in sandbox?), it might fail. + // Sandbox has internet. + // But I want it to use local relay. + // I'll try to add the local relay via UI? Or just hope. + // Wait, if I cannot force the relay, the test is flaky. + // I'll add a step to force the relay connection via `page.evaluate` leveraging the module system if possible? + // Or just `localStorage` key `relays`? + // Let's try to set `localStorage.setItem('relays', JSON.stringify([{url:'ws://localhost:8889', read:true, write:true}]))` before load. - const decryptResult = await decryptDM(dmEvent, context); + // Reloading with relay config + // Actually, let's do this before the first navigation or reload. + // I'll just clear local storage and set the relay. + /* + await page.goto(HTTP_URL); + await page.evaluate((url) => { + // Guessing the storage key. + // If I don't know it, I might be stuck. + // But usually apps respect NIP-07 `getRelays`? + // If I implement `window.nostr.getRelays`, the app might use them! + }, RELAY_URL); + */ - if (!decryptResult.ok) { - throw new Error(`DM decryption failed: ${JSON.stringify(decryptResult.errors)}`); + // Implementation of getRelays in mock + await page.addInitScript((relayUrl) => { + const existing = window.nostr || {}; + window.nostr = { + ...existing, + getRelays: async () => { + return { [relayUrl]: { read: true, write: true } }; + } + }; + }, RELAY_URL); + // App likely calls `getRelays` on login. + + log('Verifying video in UI...'); + // We might need to refresh or go to profile/feed. + // Let's wait for the title to appear in the body. + await page.waitForSelector(`text="${testTitle}"`, { timeout: 20000 }); + log('Video found in UI.'); + + // 7. DM Test (Node Side) + log('Starting DM Test...'); + + // Connect Node Client to Relay + const ws = new WebSocket(RELAY_URL); + await new Promise((resolve) => ws.on('open', resolve)); + log('Node client connected to relay.'); + + const senderSk = NostrTools.generateSecretKey(); + const senderPk = NostrTools.getPublicKey(senderSk); + const dmMessage = `Smoke DM ${Date.now()}`; + + // Send DM (Kind 4 for simplicity, or 1059) + // Using Kind 4 (Legacy) as it's easier to verify without complex wrapping if not strictly required. + // But `dmDecryptor` supports both. + // Let's send Kind 4. + const ciphertext = await NostrTools.nip04.encrypt(senderSk, ephemeralPk, dmMessage); + const dmEvent = { + kind: 4, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', ephemeralPk]], + content: ciphertext, + pubkey: senderPk + }; + const signedDm = NostrTools.finalizeEvent(dmEvent, senderSk); + + log('Sending DM...'); + ws.send(JSON.stringify(['EVENT', signedDm])); + + // Wait for it to be echoed (confirmation it was accepted) + // And wait for the "Recipient" (which we simulate using the code) to potentially see it. + // Actually, we want to verify we can DECRYPT it using the ephemeral keys we generated for the BROWSER user. + // So we act as the Browser User here in Node logic. + + // We need to fetch the event back from the relay to ensure it was stored. + // Or we can just use `signedDm` directly to test `decryptDM`. + // But better to fetch it to prove relay roundtrip. + + const fetchPromise = new Promise((resolve, reject) => { + const subId = 'sub-dm'; + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg[0] === 'EVENT' && msg[2].id === signedDm.id) { + resolve(msg[2]); + } + }); + ws.send(JSON.stringify(['REQ', subId, { ids: [signedDm.id] }])); + }); + + const fetchedDm = await Promise.race([ + fetchPromise, + new Promise((_, r) => setTimeout(() => r(new Error('DM fetch timeout')), 5000)) + ]); + + log('DM fetched from relay.'); + + // Verify Decryption + log('Decrypting DM...'); + const decryptors = [ + { + scheme: 'nip04', + decrypt: async (remotePubkey, text) => { + return await NostrTools.nip04.decrypt(ephemeralSk, remotePubkey, text); // Use Browser User's SK + } + } + ]; + + const dmContext = { + actorPubkey: ephemeralPk, + decryptors + }; + + const result = await decryptDM(fetchedDm, dmContext); + + if (!result.ok) throw new Error(`Decryption failed: ${JSON.stringify(result.errors)}`); + if (result.plaintext !== dmMessage) throw new Error(`Plaintext mismatch. Expected ${dmMessage}, got ${result.plaintext}`); + + log('DM Decrypted successfully.'); + ws.close(); + + } catch (err) { + success = false; + error('Smoke Test Failed:', err); + if (browser) { + const page = browser.contexts()[0]?.pages()[0]; + if (page) { + const screenshotPath = path.join(SCREENSHOTS_DIR, `failure-${Date.now()}.png`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + log(`Screenshot saved to ${screenshotPath}`); + } + } + } finally { + // Teardown + log('Teardown...'); + if (browser) await browser.close(); + if (httpServer) httpServer.kill(); // .kill() sends SIGTERM + if (relayServer) relayServer.close(); + + const summary = { + timestamp: new Date().toISOString(), + success, + logFile + }; + fs.writeFileSync(path.join(ARTIFACTS_DIR, `smoke-summary-${dateStr}.json`), JSON.stringify(summary, null, 2)); + log('Done.'); + process.exit(success ? 0 : 1); } - - 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); - } } run();