Files
bitvid/scripts/agent/smoke-test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 b844b71f55 fix(csp,smoke): allow ws://localhost relays; fix smoke publish await
Two pre-existing issues surfaced by running the smoke test:

1. CSP connect-src allowed `wss:` but not `ws:`, so ALL insecure-websocket
   relays were blocked — including ws://localhost (dev + local relays + the
   smoke mock relay). Add `ws://localhost:* ws://127.0.0.1:*` to connect-src in
   index.html and embed.html. Remote cleartext ws:// stays blocked (deliberate
   security posture — flagged for product decision).

2. smoke-test.mjs published via Promise.any(relays.map(url => pool.publish([url],
   evt))). SimplePool.publish returns an ARRAY of per-relay promises, so this
   Promise.any'd arrays-of-arrays — resolving on the array object immediately
   without awaiting the publish and swallowing errors. Fixed to await
   pool.publish(relays, evt) via Promise.allSettled and surface per-relay
   results.

Smoke test now passes (publish -> relay verify). CSP policy test green; build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-19 22:24:58 -04:00

368 lines
12 KiB
JavaScript

import { chromium } from "playwright";
import {
finalizeEvent,
generateSecretKey,
getPublicKey,
nip04,
nip44
} from "nostr-tools";
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { startRelay } from "./simple-relay.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// --- Configuration ---
const ARGS = process.argv.slice(2);
const CONFIG = {
serve: parseArg("serve", "npx"), // 'npx', 'python', 'none'
relays: parseArg("relays", ""), // CSV
out: parseArg("out", "artifacts/"),
headless: !ARGS.includes("--no-headless"),
timeout: parseInt(parseArg("timeout", "30"), 10) * 1000,
confirmPublic: ARGS.includes("--confirm-public")
};
function parseArg(key, defaultVal) {
const flag = `--${key}=`;
const arg = ARGS.find((a) => a.startsWith(flag));
return arg ? arg.substring(flag.length) : defaultVal;
}
const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, "-");
const LOG_FILE = path.join(CONFIG.out, `smoke-${TIMESTAMP}.log`);
const JSON_FILE = path.join(CONFIG.out, `smoke-${TIMESTAMP}.json`);
const SCREENSHOT_DIR = path.join(CONFIG.out, `smoke-${TIMESTAMP}-screenshots`);
// --- Logging ---
const LOGS = [];
const STEPS = [];
function log(msg, type = "INFO") {
const line = `[${new Date().toISOString()}] [${type}] ${msg}`;
console.log(line);
LOGS.push(line);
fs.appendFileSync(LOG_FILE, line + "\n");
}
function recordStep(name, status, details = {}) {
STEPS.push({ name, status, at: Date.now(), details });
}
// --- Main ---
async function main() {
if (!fs.existsSync(CONFIG.out)) fs.mkdirSync(CONFIG.out, { recursive: true });
if (!fs.existsSync(SCREENSHOT_DIR))
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
fs.writeFileSync(LOG_FILE, "");
log(`Starting smoke test. Config: ${JSON.stringify(CONFIG)}`);
let relayServer = null;
let appProcess = null;
let browser = null;
let relayUrl = "";
try {
// 1. Start Relay
if (CONFIG.relays) {
relayUrl = CONFIG.relays.split(",")[0];
log(`Using provided relay: ${relayUrl}`);
} else {
const port = 8877 + Math.floor(Math.random() * 100); // Random port to avoid collisions
log(`Starting local relay on port ${port}...`);
relayServer = startRelay(port);
relayUrl = `ws://127.0.0.1:${port}`;
log(`Local relay started at ${relayUrl}`);
}
// 2. Start App
let appUrl = "http://localhost:8000"; // Default for python/npx serve
if (CONFIG.serve !== "none") {
const port = 8000 + Math.floor(Math.random() * 1000); // Random port
appUrl = `http://localhost:${port}`;
log(`Starting app server (${CONFIG.serve}) on port ${port}...`);
if (CONFIG.serve === "npx") {
// npx serve
appProcess = spawn("npx", ["serve", "-p", String(port)], {
stdio: "ignore", // 'inherit' for debugging
detached: false
});
} else if (CONFIG.serve === "python") {
appProcess = spawn("python3", ["-m", "http.server", String(port)], {
stdio: "ignore"
});
} else {
throw new Error(`Unknown serve mode: ${CONFIG.serve}`);
}
log(`Waiting for app at ${appUrl}...`);
await waitForUrl(appUrl, 10000);
log(`App server ready.`);
} else {
// Assume default port if 'none' (or handle logic to detect)
// If serve=none, user presumably has it running.
// We'll just assume localhost:8000 or 3000?
// Let's verify if port 3000 (npm start) or 8000 (npx serve) is up?
// For simplicity, let's assume 8000 or default to 3000 if 8000 fails?
// Prompt says "app start/serve (per README)", examples use 8000 or 3000.
// Let's try 8000 first, then 3000.
if (await checkUrl("http://localhost:8000")) {
appUrl = "http://localhost:8000";
} else if (await checkUrl("http://localhost:3000")) {
appUrl = "http://localhost:3000";
} else {
log(
"WARNING: Could not detect running app on 8000 or 3000. Assuming 8000."
);
}
}
// 3. Launch Browser
log("Launching browser...");
browser = await chromium.launch({ headless: CONFIG.headless });
const context = await browser.newContext();
const page = await context.newPage();
// 4. Test: Navigation
const testUrl = `${appUrl}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`;
log(`Navigating to ${testUrl}`);
await page.goto(testUrl);
// Wait for harness
log("Waiting for test harness...");
await page.waitForFunction(() => window.__bitvidTest__, null, {
timeout: 15000
});
recordStep("Navigation", "PASS");
// 5. Test: Login
log("Testing Login...");
const sk = generateSecretKey();
const pk = getPublicKey(sk);
const hexSk = Buffer.from(sk).toString("hex");
await page.evaluate(async (key) => {
await window.__bitvidTest__.loginWithNsec(key);
}, hexSk);
const appState = await page.evaluate(() =>
window.__bitvidTest__.getAppState()
);
if (appState.isLoggedIn && appState.activePubkey === pk) {
log(`Login successful as ${pk}`);
recordStep("Login", "PASS", { pubkey: pk });
} else {
throw new Error(`Login failed. State: ${JSON.stringify(appState)}`);
}
// 6. Test: Publish Video
log("Testing Publish...");
const dTag = `smoke-${Date.now()}`;
const videoEvent = {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", dTag],
["t", "smoke-test"],
["s", `nostr:${dTag}`] // Minimal pointer
],
content: JSON.stringify({
version: 3,
title: "Smoke Test Video",
videoRootId: dTag,
description: "Generated by smoke-test.mjs"
}),
pubkey: pk
};
const signedVideo = finalizeEvent(videoEvent, sk);
// Inject and publish the externally-signed event through the app's pool to
// verify pool -> relay connectivity.
const pubResult = await page.evaluate(async (evt) => {
const client = window.__bitvidTest__.nostrClient;
const pool = client.pool;
const relays = client.writeRelays || [];
// SimplePool.publish(relays, event) returns an ARRAY of per-relay
// promises — it must be awaited directly. (The old code wrapped each url
// in its own publish() and then Promise.any'd the resulting arrays, which
// resolves on the array object immediately without awaiting the publish
// and silently swallows publish errors.)
const settled = await Promise.allSettled(pool.publish(relays, evt));
return {
relays,
fulfilled: settled.filter((r) => r.status === "fulfilled").length,
errors: settled
.filter((r) => r.status === "rejected")
.map((r) => String(r.reason)),
};
}, signedVideo);
log(
`Publish sent to [${pubResult.relays.join(", ")}] — ${pubResult.fulfilled} ok` +
(pubResult.errors.length ? `, errors: ${pubResult.errors.join("; ")}` : "")
);
// Verify on relay side
await new Promise((r) => setTimeout(r, 1000)); // Give it a sec
const relayEvents = relayServer ? relayServer.getEvents() : null;
if (relayServer) {
if (relayEvents.has(signedVideo.id)) {
log(`Event ${signedVideo.id} verified on relay.`);
recordStep("Publish", "PASS", { eventId: signedVideo.id });
} else {
throw new Error(`Event ${signedVideo.id} not found on relay.`);
}
} else {
log("Skipping relay verification (external relay).");
recordStep("Publish", "PASS", { note: "Verification skipped" });
}
// 7. Test: DM Decrypt
log("Testing DM Decrypt...");
const recipientSk = generateSecretKey();
const recipientPk = getPublicKey(recipientSk);
const recipientHexSk = Buffer.from(recipientSk).toString("hex");
// Switch login to recipient
await page.evaluate(() => window.__bitvidTest__.logout());
await page.evaluate(async (key) => {
await window.__bitvidTest__.loginWithNsec(key);
}, recipientHexSk);
// Create DM addressed to recipient (sender = pk, recipient = recipientPk)
const msgContent = `Smoke Test DM ${Date.now()}`;
const encryptedContent = await nip04.encrypt(sk, recipientPk, msgContent);
const dmEvent = finalizeEvent(
{
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", recipientPk]],
content: encryptedContent,
pubkey: pk
},
sk
);
// Seed relay directly
if (relayServer) {
relayServer.seedEvent(dmEvent);
} else {
// Publish via page from sender context? Too complex to switch back and forth.
// Just rely on seeding or publishing via a new ephemeral connection if needed.
// For simplicity, we assume we can inject into the test relay if local.
// If external relay, we'd need to publish properly.
// Let's try to publish via an ephemeral ws connection in Node.
// (Not implemented here for brevity, assuming local relay for smoke test default)
log(
"Warning: DM test requires relay seeding. Assuming local relay works."
);
}
// Verify Decryption in Browser
const decrypted = await page.evaluate(async (evt) => {
try {
// Import the decryptor module
const { decryptDM } = await import("/js/dmDecryptor.js");
const { nostrClient } = window.__bitvidTest__;
// Context for decryptDM needs 'decryptors'
// The app's `authService` or `nostrClient` usually sets this up.
// But here we are calling the low-level function.
// We can construct a decryptor using the active signer from the client.
const signer = nostrClient.signerManager.getActiveSigner();
if (!signer) throw new Error("No active signer found");
const decryptors = [
{
scheme: "nip04",
decrypt: (pk, ciphertext) => signer.nip04Decrypt(pk, ciphertext)
}
];
const result = await decryptDM(evt, {
actorPubkey: signer.pubkey,
decryptors
});
return result;
} catch (e) {
return { ok: false, error: e.toString() };
}
}, dmEvent);
if (decrypted.ok && decrypted.message.content === msgContent) {
log("DM Decryption successful.");
recordStep("DM Decrypt", "PASS");
} else {
throw new Error(`DM Decryption failed: ${JSON.stringify(decrypted)}`);
}
log("Smoke test passed!");
fs.writeFileSync(
JSON_FILE,
JSON.stringify({ status: "PASS", steps: STEPS, config: CONFIG }, null, 2)
);
} catch (error) {
log(`ERROR: ${error.message}`, "ERROR");
console.error(error);
recordStep("Fatal", "FAIL", { error: error.message });
if (browser) {
try {
const page = browser.contexts()[0]?.pages()[0];
if (page) {
await page.screenshot({
path: path.join(SCREENSHOT_DIR, "failure.png")
});
log(
`Screenshot saved to ${path.join(SCREENSHOT_DIR, "failure.png")}`
);
}
} catch (e) {
log(`Failed to take screenshot: ${e.message}`, "ERROR");
}
}
fs.writeFileSync(
JSON_FILE,
JSON.stringify(
{ status: "FAIL", error: error.message, steps: STEPS, config: CONFIG },
null,
2
)
);
process.exit(1);
} finally {
if (browser) await browser.close();
if (appProcess) process.kill(appProcess.pid);
if (relayServer) relayServer.close();
}
}
async function waitForUrl(url, timeout) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (await checkUrl(url)) return true;
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Timeout waiting for ${url}`);
}
async function checkUrl(url) {
try {
const res = await fetch(url);
return res.ok;
} catch {
return false;
}
}
main();