diff --git a/docs/agents/task-logs/weekly/2026-02-23_18-05-00_smoke-agent_completed.md b/docs/agents/task-logs/weekly/2026-02-23_18-05-00_smoke-agent_completed.md new file mode 100644 index 00000000..84778409 --- /dev/null +++ b/docs/agents/task-logs/weekly/2026-02-23_18-05-00_smoke-agent_completed.md @@ -0,0 +1,18 @@ +# Smoke Agent Task Completion + +**Date:** 2026-02-23 +**Agent:** smoke-agent +**Status:** Success + +## Summary +Executed the smoke test harness and verified critical application flows (login, relay connection, publishing, DM decryption). + +## Actions Taken +1. Refactored `scripts/agent/smoke-test.mjs` to support CLI arguments (`--relays`, `--serve`, `--out`, etc.). +2. Created `docs/smoke-test.md` with usage instructions and CI examples. +3. Verified the smoke test by running it locally with success. +4. Generated artifacts in `artifacts/smoke-verify/`. + +## Artifacts +- `docs/smoke-test.md` +- `scripts/agent/smoke-test.mjs` (updated) diff --git a/docs/smoke-test.md b/docs/smoke-test.md new file mode 100644 index 00000000..bd9c18d2 --- /dev/null +++ b/docs/smoke-test.md @@ -0,0 +1,77 @@ +# Smoke Test Harness + +The `scripts/agent/smoke-test.mjs` script verifies critical application flows (login, relay connection, publishing, DM decryption) using a headless browser (Playwright) and a Node.js Nostr client. + +It is designed to be lightweight, fast, and suitable for both local development and CI/CD pipelines. + +## Features + +- **Ephemeral Keys**: Generates fresh keys for every run; never touches your private keys. +- **Headless Browser**: Uses Playwright to verify UI rendering and login state. +- **End-to-End Verification**: Publishes events via Node.js client and verifies them in the browser, and vice-versa. +- **Artifact Generation**: produces logs, JSON summaries, and screenshots on failure (or success). +- **Flexible Configuration**: Supports local relays, external relays, and custom timeouts. + +## Usage + +### Local Development (Default) + +Runs with a local ephemeral relay (port 8899) and starts the app with `npx serve` (port 8000). + +```bash +npm run test:smoke +# OR +node scripts/agent/smoke-test.mjs +``` + +### Custom Output Directory + +```bash +node scripts/agent/smoke-test.mjs --out=artifacts/my-run +``` + +### External Relays + +To test against real relays (e.g., in a staging environment): + +```bash +node scripts/agent/smoke-test.mjs --relays="wss://relay.example.com,wss://relay2.example.com" --serve=none --confirm-public +``` + +**Note**: When using public/external relays, you must provide `--confirm-public` to acknowledge that test data will be published. The test uses a unique `d` tag and ephemeral keys to minimize pollution, but always use test-specific relays when possible. + +### CLI Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `--relays` | string | `ws://localhost:8899` | Comma-separated list of relay URLs. | +| `--serve` | string | `npx` | How to serve the app: `npx`, `python`, or `none`. | +| `--out` | string | `artifacts` | Directory to save logs, screenshots, and summaries. | +| `--timeout` | number | `30` | Timeout in seconds for UI and network operations. | +| `--burst` | number | `1` | (Unused currently) Number of events to publish in burst. | +| `--dry-run` | boolean | `false` | If true, skips publishing and browser interactions. | +| `--confirm-public` | boolean | `false` | Required if non-localhost relays are used. | + +## Artifacts + +After a run, check the output directory (default `artifacts/`): + +- `smoke-YYYYMMDD.log`: Full execution log. +- `smoke-summary-YYYYMMDD.json`: Structured summary (status, event IDs, pubkeys). +- `smoke-test-ui.png`: Screenshot of the feed (success state). +- `smoke-fail-*.png`: Screenshots captured upon failure (login or feed). + +## CI Integration + +To run this in GitHub Actions or other CI: + +```yaml +- name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + +- name: Build App + run: npm run build + +- name: Run Smoke Test + run: node scripts/agent/smoke-test.mjs --serve=npx --timeout=60 +``` diff --git a/scripts/agent/smoke-test.mjs b/scripts/agent/smoke-test.mjs index 74c5cd7e..6d8cc1e1 100644 --- a/scripts/agent/smoke-test.mjs +++ b/scripts/agent/smoke-test.mjs @@ -16,14 +16,43 @@ import { chromium } from 'playwright'; import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { parseArgs } from 'node:util'; + +// Parse Arguments +const { values } = parseArgs({ + options: { + relays: { type: 'string' }, + serve: { type: 'string', default: 'npx' }, + 'dry-run': { type: 'boolean' }, + timeout: { type: 'string', default: '30' }, + burst: { type: 'string', default: '1' }, + out: { type: 'string', default: 'artifacts' }, + 'confirm-public': { type: 'boolean' }, + }, + allowPositionals: true, +}); const RELAY_PORT = 8899; -const RELAY_URL = `ws://localhost:${RELAY_PORT}`; +const DEFAULT_RELAY_URL = `ws://localhost:${RELAY_PORT}`; const HTTP_PORT = 8000; const HTTP_URL = `http://localhost:${HTTP_PORT}`; -// Artifacts -const ARTIFACTS_DIR = 'artifacts'; +// Configuration from Args +const ARTIFACTS_DIR = values.out; +const SERVE_MODE = values.serve; +const TIMEOUT_MS = parseInt(values.timeout, 10) * 1000; +const RELAY_URLS = values.relays ? values.relays.split(',').map(r => r.trim()) : [DEFAULT_RELAY_URL]; +const IS_DRY_RUN = values['dry-run']; +const CONFIRM_PUBLIC = values['confirm-public']; + +// Safety Check for Public Relays +const hasPublicRelay = RELAY_URLS.some(url => !url.includes('localhost') && !url.includes('127.0.0.1')); +if (hasPublicRelay && !CONFIRM_PUBLIC) { + console.error('ERROR: Public relays detected without --confirm-public. Aborting.'); + process.exit(1); +} + +// Artifacts Setup const LOG_FILE = path.join(ARTIFACTS_DIR, `smoke-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.log`); const SCREENSHOT_FILE = path.join(ARTIFACTS_DIR, 'smoke-test-ui.png'); @@ -32,7 +61,9 @@ function log(message) { const timestamp = new Date().toISOString(); const line = `[${timestamp}] ${message}`; console.log(line); - fs.appendFileSync(LOG_FILE, line + '\n'); + if (fs.existsSync(ARTIFACTS_DIR)) { + fs.appendFileSync(LOG_FILE, line + '\n'); + } } // Generate Keys @@ -42,11 +73,27 @@ const EPHEMERAL_SK = bytesToHex(EPHEMERAL_SK_BYTES); const EPHEMERAL_PK = getPublicKey(EPHEMERAL_SK_BYTES); async function startHttpServer() { - log('Starting HTTP Server...'); - const serve = spawn('npx', ['serve', '.', '-p', String(HTTP_PORT)], { - stdio: 'ignore', - shell: true - }); + if (SERVE_MODE === 'none') { + log('Skipping HTTP Server start (--serve=none). Assuming app is running.'); + return null; + } + + log(`Starting HTTP Server (${SERVE_MODE})...`); + let serve; + + if (SERVE_MODE === 'npx') { + serve = spawn('npx', ['serve', '.', '-p', String(HTTP_PORT)], { + stdio: 'ignore', + shell: true + }); + } else if (SERVE_MODE === 'python') { + serve = spawn('python3', ['-m', 'http.server', String(HTTP_PORT)], { + stdio: 'ignore', + shell: true + }); + } else { + throw new Error(`Unknown serve mode: ${SERVE_MODE}`); + } const maxRetries = 20; for (let i = 0; i < maxRetries; i++) { @@ -58,14 +105,14 @@ async function startHttpServer() { await new Promise(r => setTimeout(r, 250)); } } - serve.kill(); + if (serve) serve.kill(); throw new Error('HTTP Server failed to start within timeout.'); } async function runSmokeTest() { // Ensure artifacts dir if (!fs.existsSync(ARTIFACTS_DIR)) { - fs.mkdirSync(ARTIFACTS_DIR); + fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); } fs.writeFileSync(LOG_FILE, ''); // Clear log @@ -78,22 +125,34 @@ async function runSmokeTest() { try { log('--- Smoke Test Started ---'); log(`Ephemeral Pubkey: ${EPHEMERAL_PK}`); + log(`Relays: ${RELAY_URLS.join(', ')}`); // 1. Start Infrastructure - relayServer = startRelay(RELAY_PORT); + // Only start local relay if we are using the default local URL and it wasn't overridden + if (RELAY_URLS.length === 1 && RELAY_URLS[0] === DEFAULT_RELAY_URL) { + relayServer = startRelay(RELAY_PORT); + } else { + log('Using external/provided relays, skipping local relay start.'); + } + httpServer = await startHttpServer(); // 2. Initialize Node Client log('Initializing Node Client...'); nodeClient = new NostrClient(); - nodeClient.relays = [RELAY_URL]; - nodeClient.writeRelays = [RELAY_URL]; - nodeClient.readRelays = [RELAY_URL]; + nodeClient.relays = [...RELAY_URLS]; + nodeClient.writeRelays = [...RELAY_URLS]; + nodeClient.readRelays = [...RELAY_URLS]; await nodeClient.registerPrivateKeySigner({ privateKey: EPHEMERAL_SK }); - await nodeClient.init(); + await nodeClient.init(); // Wait for connection log('Node Client connected.'); + if (IS_DRY_RUN) { + log('Dry run enabled. Skipping publish and browser steps.'); + return; + } + // 3. Publish Video Event (Node) log('Node: Publishing Video Post...'); const videoEventTemplate = buildVideoPostEvent({ @@ -144,7 +203,8 @@ async function runSmokeTest() { activePubkey: EPHEMERAL_PK, version: 1 }; - const relayList = [{ url: RELAY_URL, mode: "both" }]; + // Transform RELAY_URLS into object format expected by app + const relayList = RELAY_URLS.map(url => ({ url, mode: "both" })); // Note: Key format derived from investigation: bitvid:profile::relayList:v1 const relayStorageKey = `bitvid:profile:${EPHEMERAL_PK}:relayList:v1`; @@ -155,6 +215,10 @@ async function runSmokeTest() { window.localStorage.setItem('bitvid:savedProfiles:v1', JSON.stringify(profiles)); window.localStorage.setItem(relayKey, JSON.stringify(relays)); + + // Inject test relays for isolation + window.__bitvidTestRelays__ = relays.map(r => r.url); + // Mock NIP-07 to avoid errors if app tries to use it (though we won't sign in browser) window.nostr = { getPublicKey: async () => profiles.activePubkey, @@ -176,13 +240,14 @@ async function runSmokeTest() { log('Browser: Verifying Login...'); // #profileButton might be hidden initially, wait for it try { - await page.waitForSelector('#profileButton', { state: 'visible', timeout: 15000 }); + await page.waitForSelector('#profileButton', { state: 'visible', timeout: TIMEOUT_MS }); log('Browser: Login verified (Profile button visible).'); } catch (e) { log('Browser: Login verification failed (Profile button not found). Dumping localStorage...'); const storage = await page.evaluate(() => JSON.stringify(localStorage)); log(`[Browser Storage] ${storage}`); await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'smoke-fail-login.png') }); + throw e; } // Verify Feed (Video Card presence) @@ -190,7 +255,8 @@ async function runSmokeTest() { // Look for the video title try { // Wait longer for feed to load (relays might be slow) - await page.waitForSelector(`text=Smoke Test Video`, { timeout: 60000 }); + // Use 2x timeout for network/relay operations + await page.waitForSelector(`text=Smoke Test Video`, { timeout: TIMEOUT_MS * 2 }); log('Browser: Video found in feed!'); } catch (e) { log('Browser: Video NOT found in feed. Taking screenshot...'); @@ -198,10 +264,8 @@ async function runSmokeTest() { // Debug: Check relays const relayState = await page.evaluate(() => { - // Try to access global nostrClient if exposed, or check localStorage again return { localStorage: JSON.stringify(localStorage), - // If we can't access client directly, we rely on logs }; }); log(`[Browser Debug] Storage: ${relayState.localStorage}`); @@ -244,7 +308,8 @@ async function runSmokeTest() { details: { videoEventId: publishedVideo.id, dmEventId: publishedDM.id, - ephemeralPubkey: EPHEMERAL_PK + ephemeralPubkey: EPHEMERAL_PK, + relays: RELAY_URLS } }; const summaryFile = path.join(ARTIFACTS_DIR, `smoke-summary-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.json`);