Merge branch 'unstable' into telemetry-aggregation-fix-6349074633801704985

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-30 09:19:03 -05:00
commit 667b48c301
19 files changed
+1042 -797

No files matched your search

+240 -269
View File
@@ -1,286 +1,257 @@
import './setup-test-env.js';
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
import { WebSocket } from 'ws';
import * as NostrTools from 'nostr-tools';
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 { startRelay } from './load-test-relay.mjs';
import { NostrClient } from '../../js/nostr/client.js';
import { decryptDM } from '../../js/dmDecryptor.js';
import { startRelay } from './load-test-relay.mjs';
import {
buildVideoPostEvent,
buildLegacyDirectMessageEvent
} from '../../js/nostrEventSchemas.js';
import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { chromium } from 'playwright';
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
// --- Configuration ---
const ARTIFACTS_DIR = 'artifacts';
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
}
const RELAY_PORT = 8890;
const RELAY_PORT = 8899;
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' });
// Artifacts
const ARTIFACTS_DIR = 'artifacts';
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');
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');
// Logger
function log(message) {
const timestamp = new Date().toISOString();
const line = `[${timestamp}] ${message}`;
console.log(line);
fs.appendFileSync(LOG_FILE, line + '\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');
// Generate Keys
const bytesToHex = (bytes) => Buffer.from(bytes).toString('hex');
const EPHEMERAL_SK_BYTES = generateSecretKey();
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
});
const maxRetries = 20;
for (let i = 0; i < maxRetries; i++) {
try {
await fetch(HTTP_URL);
log('HTTP Server is ready.');
return serve;
} catch (e) {
await new Promise(r => setTimeout(r, 250));
}
}
serve.kill();
throw new Error('HTTP Server failed to start within timeout.');
}
// --- Helpers ---
async function waitForServer(url, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
async function runSmokeTest() {
// Ensure artifacts dir
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR);
}
fs.writeFileSync(LOG_FILE, ''); // Clear log
let relayServer;
let httpServer;
let browser;
let nodeClient;
let exitCode = 0;
try {
const res = await fetch(url);
if (res.ok) return true;
} catch (e) {
// ignore
log('--- Smoke Test Started ---');
log(`Ephemeral Pubkey: ${EPHEMERAL_PK}`);
// 1. Start Infrastructure
relayServer = startRelay(RELAY_PORT);
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];
await nodeClient.registerPrivateKeySigner({ privateKey: EPHEMERAL_SK });
await nodeClient.init();
log('Node Client connected.');
// 3. Publish Video Event (Node)
log('Node: Publishing Video Post...');
const videoEventTemplate = buildVideoPostEvent({
pubkey: EPHEMERAL_PK,
created_at: Math.floor(Date.now() / 1000),
dTagValue: `smoke-test-${Date.now()}`,
content: {
version: 3,
title: 'Smoke Test Video',
url: 'https://example.com/video.mp4',
description: 'A video to verify feed rendering.',
mode: 'live',
videoRootId: `smoke-root-${Date.now()}`
}
});
const { signedEvent: publishedVideo } = await nodeClient.signAndPublishEvent(videoEventTemplate, {
context: 'smoke-video'
});
log(`Node: Published Video ID: ${publishedVideo.id}`);
// 4. Publish DM (Node -> Self)
log('Node: Publishing Encrypted DM...');
const dmMessage = "Smoke Test Secret Message";
const signer = await nodeClient.ensureActiveSignerForPubkey(EPHEMERAL_PK);
const ciphertext = await signer.nip04Encrypt(EPHEMERAL_PK, dmMessage);
const dmTemplate = buildLegacyDirectMessageEvent({
pubkey: EPHEMERAL_PK,
created_at: Math.floor(Date.now() / 1000),
recipientPubkey: EPHEMERAL_PK,
ciphertext: ciphertext
});
const { signedEvent: publishedDM } = await nodeClient.signAndPublishEvent(dmTemplate, {
context: 'smoke-dm'
});
log(`Node: Published DM ID: ${publishedDM.id}`);
// 5. Browser Test (Playwright)
log('Browser: Launching...');
browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
// Inject LocalStorage for Auto-Login and Relay Config
const savedProfiles = {
entries: [{ pubkey: EPHEMERAL_PK, authType: "nip07" }],
activePubkey: EPHEMERAL_PK,
version: 1
};
const relayList = [{ url: RELAY_URL, mode: "both" }];
// Note: Key format derived from investigation: bitvid:profile:<pubkey>:relayList:v1
const relayStorageKey = `bitvid:profile:${EPHEMERAL_PK}:relayList:v1`;
await context.addInitScript(({ profiles, relays, relayKey }) => {
// Disable Whitelist Mode to allow fresh pubkey login
window.localStorage.setItem('bitvid_admin_whitelist_mode', 'false');
window.localStorage.setItem('bitvid:savedProfiles:v1', JSON.stringify(profiles));
window.localStorage.setItem(relayKey, JSON.stringify(relays));
// 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,
signEvent: async () => { throw new Error("Mock NIP-07 cannot sign"); }
};
}, { profiles: savedProfiles, relays: relayList, relayKey: relayStorageKey });
const page = await context.newPage();
log(`Browser: Navigating to ${HTTP_URL}...`);
// Capture Console
page.on('console', msg => log(`[Browser Console] ${msg.type()}: ${msg.text()}`));
page.on('pageerror', err => log(`[Browser Error] ${err.message}`));
await page.goto(HTTP_URL);
// Verify Login (Profile Button presence)
log('Browser: Verifying Login...');
// #profileButton might be hidden initially, wait for it
try {
await page.waitForSelector('#profileButton', { state: 'visible', timeout: 15000 });
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') });
}
// Verify Feed (Video Card presence)
log('Browser: Verifying Video in Feed...');
// 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 });
log('Browser: Video found in feed!');
} catch (e) {
log('Browser: Video NOT found in feed. Taking screenshot...');
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'smoke-fail-feed.png') });
// 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}`);
throw new Error('Browser: Video NOT found in feed.');
}
// Screenshot
await page.screenshot({ path: SCREENSHOT_FILE });
log(`Browser: Screenshot saved to ${SCREENSHOT_FILE}`);
// 6. Node Verification (DM Decrypt)
log('Node: Verifying DM Decryption...');
// Fetch back
await new Promise(r => setTimeout(r, 500));
const fetchedDM = await nodeClient.fetchRawEventById(publishedDM.id);
if (!fetchedDM) {
throw new Error('Node: Failed to fetch DM back.');
}
const decryptContext = await nodeClient.buildDmDecryptContext(EPHEMERAL_PK);
const decryptionResult = await decryptDM(fetchedDM, decryptContext);
if (!decryptionResult.ok) {
log(`Node: Decryption failed: ${JSON.stringify(decryptionResult.errors)}`);
throw new Error('Node: Decryption failed.');
}
if (decryptionResult.plaintext !== dmMessage) {
throw new Error(`Node: Decryption mismatch. Got "${decryptionResult.plaintext}"`);
}
log(`Node: DM Decrypted successfully: "${decryptionResult.plaintext}"`);
log('--- Smoke Test PASSED ---');
} catch (err) {
log(`--- Smoke Test FAILED: ${err.message} ---`);
if (err.stack) log(err.stack);
exitCode = 1;
} finally {
// Cleanup
log('Cleaning up...');
if (nodeClient && nodeClient.pool) {
// Close pool connections
if (typeof nodeClient.pool.close === 'function') {
nodeClient.pool.close(nodeClient.relays);
}
}
if (browser) await browser.close();
if (httpServer) httpServer.kill();
if (relayServer) await relayServer.close();
process.exit(exitCode);
}
await new Promise(r => setTimeout(r, 200));
}
return false;
}
// --- Main Test Flow ---
async function run() {
let relayServer = null;
let httpServer = null;
let client = null;
let success = true;
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
});
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
};
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);
}
}
run();
runSmokeTest();
+246 -247
View File
@@ -1,9 +1,48 @@
#!/usr/bin/env node
// scripts/agent/validate-events.mjs
import WebSocket from 'ws';
import { webcrypto } from 'node:crypto';
// Polyfill WebSocket
if (typeof globalThis.WebSocket === 'undefined') {
globalThis.WebSocket = WebSocket;
}
// Polyfill window and self
if (typeof globalThis.window === 'undefined') {
globalThis.window = globalThis;
}
if (typeof globalThis.self === 'undefined') {
globalThis.self = globalThis;
}
// Polyfill crypto
if (typeof globalThis.crypto === 'undefined') {
globalThis.crypto = webcrypto;
}
// Polyfill localStorage
if (typeof globalThis.localStorage === 'undefined') {
const storage = new Map();
globalThis.localStorage = {
getItem: (k) => storage.get(k) || null,
setItem: (k, v) => storage.set(String(k), String(v)),
removeItem: (k) => storage.delete(k),
clear: () => storage.clear(),
key: (i) => Array.from(storage.keys())[i] || null,
get length() { return storage.size; }
};
}
if (typeof globalThis.window.localStorage === 'undefined') {
globalThis.window.localStorage = globalThis.localStorage;
}
// Mock navigator
if (typeof globalThis.navigator === 'undefined') {
globalThis.navigator = { userAgent: 'node' };
}
import { execSync } from "child_process";
import {
NOTE_TYPES,
validateEventStructure,
buildVideoPostEvent,
buildHttpAuthEvent,
buildReportEvent,
@@ -28,352 +67,312 @@ import {
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent,
} from "../../js/nostrEventSchemas.js";
validateEventStructure,
NOTE_TYPES,
ADMIN_LIST_IDENTIFIERS
} from '../../js/nostrEventSchemas.js';
import { buildNip71VideoEvent } from '../../js/nostr/nip71.js';
const PUBKEY = "0000000000000000000000000000000000000000000000000000000000000001";
const CREATED_AT = 1700000000;
// Note: Most builders are defined in js/nostrEventSchemas.js and are already instrumented
// with validation checks (validateEventAgainstSchema) when running in Dev Mode.
// buildNip71VideoEvent was instrumented in js/nostr/nip71.js as part of this work.
// This script verifies that all builders produce valid events according to the schema.
const IGNORED_BUILDERS = new Set([
"buildShareUrlFromEventId", // Method in js/app.js, not an event builder
"buildShareUrlFromEvent", // Substring match or alias
"buildListEvent", // Local helper in js/adminListStore.js wrapping buildAdminListEvent
"buildProfileFromEvent", // Local helper in js/services/authService.js
]);
const KNOWN_BUILDERS = {
buildVideoPostEvent: {
builder: buildVideoPostEvent,
const testCases = [
{
name: 'Video Post',
type: NOTE_TYPES.VIDEO_POST,
builder: buildVideoPostEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
dTagValue: "test-video-id",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
dTagValue: 'test-video',
content: {
version: 3,
title: "Test Video",
videoRootId: "test-video-id",
infoHash: "0123456789abcdef0123456789abcdef01234567",
},
title: 'Test Video',
videoRootId: 'test-root',
}
}
},
buildHttpAuthEvent: {
builder: buildHttpAuthEvent,
{
name: 'HTTP Auth',
type: NOTE_TYPES.HTTP_AUTH,
builder: buildHttpAuthEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
url: "https://example.com/auth",
method: "GET",
payload: "hash",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
url: 'https://example.com',
method: 'GET'
}
},
buildReportEvent: {
builder: buildReportEvent,
{
name: 'Report',
type: NOTE_TYPES.REPORT,
builder: buildReportEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
eventId: "e".repeat(64),
reportType: "nudity",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
eventId: '0000000000000000000000000000000000000000000000000000000000000002',
reportType: 'nudity'
}
},
buildVideoMirrorEvent: {
builder: buildVideoMirrorEvent,
{
name: 'Video Mirror',
type: NOTE_TYPES.VIDEO_MIRROR,
builder: buildVideoMirrorEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
tags: [["url", "https://example.com/video.mp4"], ["m", "video/mp4"]],
content: "Mirroring video",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
content: 'Alt text'
}
},
buildRepostEvent: {
builder: buildRepostEvent,
{
name: 'Repost',
type: NOTE_TYPES.REPOST,
builder: buildRepostEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
eventId: "e".repeat(64),
eventRelay: "wss://relay.example.com",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
eventId: '0000000000000000000000000000000000000000000000000000000000000002',
targetKind: 1
}
},
buildShareEvent: {
builder: buildShareEvent,
{
name: 'Share',
type: NOTE_TYPES.SHARE,
builder: buildShareEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
video: { id: "e".repeat(64), pubkey: PUBKEY },
relays: ["wss://relay.example.com"],
content: "Check this out",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
content: 'Check this out',
video: { id: '0000000000000000000000000000000000000000000000000000000000000002' }
}
},
buildRelayListEvent: {
builder: buildRelayListEvent,
{
name: 'Relay List',
type: NOTE_TYPES.RELAY_LIST,
builder: buildRelayListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
relays: [{ url: "wss://relay.example.com", read: true, write: true }],
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
relays: ['wss://relay.example.com']
}
},
buildDmRelayListEvent: {
builder: buildDmRelayListEvent,
{
name: 'DM Relay List',
type: NOTE_TYPES.DM_RELAY_LIST,
builder: buildDmRelayListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
relays: ["wss://relay.example.com"],
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
relays: ['wss://relay.example.com']
}
},
buildProfileMetadataEvent: {
builder: buildProfileMetadataEvent,
{
name: 'Profile Metadata',
type: NOTE_TYPES.PROFILE_METADATA,
builder: buildProfileMetadataEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
metadata: { name: "Test User", about: "Testing" },
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
metadata: { name: 'Alice' }
}
},
buildMuteListEvent: {
builder: buildMuteListEvent,
{
name: 'Mute List',
type: NOTE_TYPES.MUTE_LIST,
builder: buildMuteListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
pTags: [PUBKEY],
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
pTags: ['0000000000000000000000000000000000000000000000000000000000000002']
}
},
buildDeletionEvent: {
builder: buildDeletionEvent,
{
name: 'Deletion',
type: NOTE_TYPES.DELETION,
builder: buildDeletionEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
eventIds: ["e".repeat(64)],
reason: "mistake",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
eventIds: ['0000000000000000000000000000000000000000000000000000000000000002']
}
},
buildLegacyDirectMessageEvent: {
builder: buildLegacyDirectMessageEvent,
{
name: 'Legacy DM',
type: NOTE_TYPES.LEGACY_DM,
builder: buildLegacyDirectMessageEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
ciphertext: "encrypted",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
ciphertext: 'base64ciphertext'
}
},
buildDmAttachmentEvent: {
builder: buildDmAttachmentEvent,
{
name: 'DM Attachment',
type: NOTE_TYPES.DM_ATTACHMENT,
builder: buildDmAttachmentEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
attachment: { url: "https://example.com/file", x: "hash", type: "image/jpeg" },
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
attachment: { url: 'https://example.com/file.jpg', x: 'hash' }
}
},
buildDmReadReceiptEvent: {
builder: buildDmReadReceiptEvent,
{
name: 'DM Read Receipt',
type: NOTE_TYPES.DM_READ_RECEIPT,
builder: buildDmReadReceiptEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
eventId: "e".repeat(64),
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
eventId: '0000000000000000000000000000000000000000000000000000000000000002'
}
},
buildDmTypingIndicatorEvent: {
builder: buildDmTypingIndicatorEvent,
{
name: 'DM Typing Indicator',
type: NOTE_TYPES.DM_TYPING,
builder: buildDmTypingIndicatorEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
expiresAt: CREATED_AT + 60,
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
expiresAt: 1234567900
}
},
buildViewEvent: {
builder: buildViewEvent,
{
name: 'View Event',
type: NOTE_TYPES.VIEW_EVENT,
builder: buildViewEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
pointerValue: "e".repeat(64),
pointerTag: ["e", "e".repeat(64)],
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
pointerValue: '0000000000000000000000000000000000000000000000000000000000000002'
}
},
buildZapRequestEvent: {
builder: buildZapRequestEvent,
{
name: 'Zap Request',
type: NOTE_TYPES.ZAP_REQUEST,
builder: buildZapRequestEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
amountSats: 100,
relays: ["wss://relay.example.com"],
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
amountSats: 100
}
},
buildReactionEvent: {
builder: buildReactionEvent,
{
name: 'Reaction',
type: NOTE_TYPES.VIDEO_REACTION,
builder: buildReactionEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
pointerValue: "e".repeat(64),
targetPointer: { type: "e", value: "e".repeat(64) },
content: "+",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
pointerValue: '0000000000000000000000000000000000000000000000000000000000000002',
content: '+'
}
},
buildCommentEvent: {
builder: buildCommentEvent,
{
name: 'Comment',
type: NOTE_TYPES.VIDEO_COMMENT,
builder: buildCommentEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
videoEventId: "e".repeat(64),
content: "Nice video",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
videoEventId: '0000000000000000000000000000000000000000000000000000000000000002',
content: 'Nice video'
}
},
buildWatchHistoryEvent: {
builder: buildWatchHistoryEvent,
{
name: 'Watch History',
type: NOTE_TYPES.WATCH_HISTORY,
builder: buildWatchHistoryEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
monthIdentifier: "2023-01",
content: { version: 2, month: "2023-01", items: [] },
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
monthIdentifier: 'watch-history:2025-01',
content: { version: 2, month: '2025-01', items: [] }
}
},
buildSubscriptionListEvent: {
builder: buildSubscriptionListEvent,
{
name: 'Subscription List',
type: NOTE_TYPES.SUBSCRIPTION_LIST,
builder: buildSubscriptionListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
content: "encrypted-json",
encryption: "nip44_v2",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
content: 'encrypted-payload'
}
},
buildBlockListEvent: {
builder: buildBlockListEvent,
{
name: 'Block List',
type: NOTE_TYPES.USER_BLOCK_LIST,
builder: buildBlockListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
content: "encrypted-json",
encryption: "nip44_v2",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
content: 'encrypted-payload'
}
},
buildHashtagPreferenceEvent: {
builder: buildHashtagPreferenceEvent,
{
name: 'Hashtag Preferences',
type: NOTE_TYPES.HASHTAG_PREFERENCES,
builder: buildHashtagPreferenceEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
content: "encrypted-json",
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
content: JSON.stringify({ version: 1, interests: [], disinterests: [] })
}
},
buildAdminListEvent: {
builder: buildAdminListEvent,
{
name: 'Admin List (Moderation)',
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
// Special handling for args
args: ["moderation", {
pubkey: PUBKEY,
created_at: CREATED_AT,
hexPubkeys: [PUBKEY],
}]
builder: (params) => buildAdminListEvent(ADMIN_LIST_IDENTIFIERS.moderation, params),
params: {
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
hexPubkeys: []
}
},
};
function getUsedBuilders() {
console.log("Scanning repo for event builder usage...");
try {
const output = execSync('grep -r "build[A-Za-z]\\+Event" js/', { encoding: 'utf-8' });
const lines = output.split('\n');
const usedBuilders = new Set();
const regex = /(build[A-Za-z]+Event)/g;
lines.forEach(line => {
// Skip the schemas file itself
if (line.includes('js/nostrEventSchemas.js')) return;
const matches = line.match(regex);
if (matches) {
matches.forEach(match => usedBuilders.add(match));
{
name: 'NIP-71 Video',
type: NOTE_TYPES.NIP71_VIDEO,
builder: buildNip71VideoEvent,
params: {
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
title: 'Test NIP-71',
metadata: { kind: 21, title: 'Test NIP-71' },
pointerIdentifiers: { videoRootId: 'test-root' }
}
});
return usedBuilders;
} catch (error) {
// Grep returns status 1 if no matches found, which might throw an error in execSync
// depending on the version/platform, or just empty output.
console.error("Grep failed or found nothing:", error.message);
return new Set();
}
}
];
async function run() {
const usedBuilders = getUsedBuilders();
console.log(`Found ${usedBuilders.size} unique builder(s) used in the codebase.`);
// also check if we missed any exported builder in our KNOWN_BUILDERS list
// by assuming KNOWN_BUILDERS contains all we want to support.
let failureCount = 0;
const missingTests = [];
usedBuilders.forEach(builderName => {
if (!KNOWN_BUILDERS[builderName] && !IGNORED_BUILDERS.has(builderName)) {
missingTests.push(builderName);
}
});
if (missingTests.length > 0) {
console.warn("\n[WARNING] The following builders are used in the repo but have no test case in this script:");
missingTests.forEach(name => console.warn(` - ${name}`));
// We don't fail the build for this yet, but we warn.
// Or maybe we should fail? The prompt said "Check runtime event construction...".
// Let's count it as a "gap" but allow passing if validation of known ones works.
}
console.log("\nValidating builders...");
for (const [name, config] of Object.entries(KNOWN_BUILDERS)) {
// If we want to be strict, we could check if it is used. But validation is good regardless.
const isUsed = usedBuilders.has(name);
const usageLabel = isUsed ? "(USED)" : "(UNUSED)";
let failed = false;
for (const testCase of testCases) {
try {
let event;
if (config.args) {
event = config.builder(...config.args);
} else {
event = config.builder(config.params);
}
const { valid, errors } = validateEventStructure(config.type, event);
if (valid) {
console.log(`[PASS] ${name} ${usageLabel}`);
} else {
console.error(`[FAIL] ${name} ${usageLabel}`);
errors.forEach((err) => console.error(` - ${err}`));
failureCount++;
}
} catch (e) {
console.error(`[ERROR] ${name} ${usageLabel} threw an exception:`, e);
failureCount++;
const event = testCase.builder(testCase.params);
if (!event) {
console.error(`[FAIL] ${testCase.name}: Builder returned null`);
failed = true;
continue;
}
const { valid, errors } = validateEventStructure(testCase.type, event);
if (!valid) {
console.error(`[FAIL] ${testCase.name}:`);
errors.forEach(err => console.error(` - ${err}`));
failed = true;
} else {
console.log(`[PASS] ${testCase.name}`);
}
} catch (err) {
console.error(`[FAIL] ${testCase.name} threw error:`, err);
failed = true;
}
}
if (failureCount > 0) {
console.error(`\nValidation failed with ${failureCount} errors.`);
process.exit(1);
} else {
console.log("\nAll known builders validated successfully.");
process.exit(0);
}
}
run();
if (failed) {
process.exit(1);
}
+92 -24
View File
@@ -26,9 +26,8 @@ function runCommand(command, args = [], options = {}) {
return result;
}
function getChangedFiles() {
function getChangedFiles(baseRef) {
try {
const baseRef = process.env.GITHUB_BASE_REF || 'main';
const baseUrl = process.env.GITHUB_BASE_REPO_URL;
let remote = 'origin';
@@ -64,7 +63,6 @@ function getChangedFiles() {
console.error('Error getting changed files:', error.stderr || error.message);
// Fallback: try 2-dot diff if 3-dot failed (e.g. shallow clone issues)
try {
const baseRef = process.env.GITHUB_BASE_REF || 'main';
const remote = process.env.GITHUB_BASE_REPO_URL ? 'upstream' : 'origin';
const cmd = `git diff --name-only ${remote}/${baseRef} HEAD`;
console.log(`Retrying with 2-dot diff: ${cmd}`);
@@ -77,8 +75,7 @@ function getChangedFiles() {
}
}
function checkReleaseChannel(changedFiles) {
const baseRef = process.env.GITHUB_BASE_REF || 'main';
function checkReleaseChannel(changedFiles, baseRef) {
const warnings = [];
if (baseRef === 'main') {
@@ -100,13 +97,11 @@ function checkReleaseChannel(changedFiles) {
return warnings;
}
async function main() {
try {
console.log('Starting Automated PR Review...');
let commentBody = '## 🤖 Automated PR Review\n\n';
let hasFailures = false;
async function reviewPR(prNumber, baseRef) {
console.log(`Starting Automated PR Review for PR #${prNumber} (base: ${baseRef})...`);
let commentBody = '## 🤖 Automated PR Review\n\n';
let hasFailures = false;
let hasFormatChanges = false;
let microFixesStatus = '';
// 1. Install Dependencies
if (process.env.CI || process.argv.includes('--force-ci')) {
@@ -149,13 +144,13 @@ async function main() {
}
// 5. Security/Protocol Review Flags
const changedFiles = getChangedFiles();
const changedFiles = getChangedFiles(baseRef);
const sensitiveChanges = changedFiles.filter(file =>
SENSITIVE_PATHS.some(pattern => pattern.test(file))
);
// Release Channel Check
const channelWarnings = checkReleaseChannel(changedFiles);
const channelWarnings = checkReleaseChannel(changedFiles, baseRef);
if (channelWarnings.length > 0) {
commentBody += '### 🚀 Release Channel Checks\n\n';
channelWarnings.forEach(w => commentBody += `${w}\n\n`);
@@ -182,7 +177,7 @@ async function main() {
commentBody += '\n';
// 6. Post Comment (if in CI)
if (process.env.GITHUB_TOKEN && process.env.PR_NUMBER) {
if (process.env.GITHUB_TOKEN && prNumber) {
// Verify gh CLI is available
console.log('Verifying GitHub CLI...');
runCommand('gh', ['--version']);
@@ -194,7 +189,7 @@ async function main() {
execSync('git config user.name "bitvid-agent"');
execSync('git config user.email "[email protected]"');
execSync('git add .');
execSync('git commit -m "fix(ai): formatting (agent)"');
execSync('git commit -m "fix(ai): formatting (agent) suggested"');
execSync('git push');
commentBody += '> **✅ Micro-fixes applied:** Formatting fixes have been committed to this branch.\n\n';
@@ -204,13 +199,11 @@ async function main() {
// Fallback: Create a new branch and open a PR
try {
console.log('Attempting to create a follow-up PR with fixes...');
const prNumber = process.env.PR_NUMBER;
const fixBranchName = `agent-fix/pr-${prNumber}-${Date.now()}`;
// Push the current HEAD (with fixes) to a new branch on origin
execSync(`git push origin HEAD:refs/heads/${fixBranchName}`);
const baseRef = process.env.GITHUB_BASE_REF || 'main';
const prTitle = `fix(ai): formatting fixes for PR #${prNumber}`;
const prBody = `This PR applies automated formatting fixes for PR #${prNumber}.`;
@@ -231,9 +224,9 @@ async function main() {
}
}
console.log('Posting comment to PR #' + process.env.PR_NUMBER);
console.log('Posting comment to PR #' + prNumber);
try {
const gh = spawnSync('gh', ['pr', 'comment', process.env.PR_NUMBER, '-F', '-'], {
const gh = spawnSync('gh', ['pr', 'comment', prNumber, '-F', '-'], {
input: commentBody,
stdio: ['pipe', 'inherit', 'inherit']
});
@@ -247,17 +240,92 @@ async function main() {
}
// Always print the report to stdout so it's visible in logs (especially if posting fails or for debugging)
// Always print the report to stdout
console.log('---------------------------------------------------');
console.log('PR Review Report:');
console.log(`PR Review Report for PR #${prNumber}:`);
console.log(commentBody);
console.log('---------------------------------------------------');
if (hasFailures) {
process.exit(1);
if (hasFailures) {
throw new Error('Lint or Test failures detected.');
}
}
async function main() {
try {
const isAll = process.argv.includes('--all');
if (isAll) {
console.log('Fetching all open PRs...');
// Get list of open PRs
const listCmd = ['pr', 'list', '--state', 'open', '--json', 'number,baseRefName,headRefName'];
const result = spawnSync('gh', listCmd, { encoding: 'utf-8' });
if (result.error || result.status !== 0) {
throw new Error(`Failed to list PRs: ${result.stderr || result.error?.message}`);
}
const prs = JSON.parse(result.stdout);
console.log(`Found ${prs.length} open PRs.`);
let hasGlobalFailures = false;
// Save current branch to restore later
let originalBranch;
try {
originalBranch = execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
} catch (e) {
// Ignore
}
for (const pr of prs) {
try {
console.log(`\n=== Processing PR #${pr.number} ===`);
// Checkout PR
runCommand('gh', ['pr', 'checkout', pr.number]);
// Run Review
await reviewPR(pr.number, pr.baseRefName);
} catch (e) {
console.error(`❌ Failed to process PR #${pr.number}:`, e.message);
hasGlobalFailures = true;
// Continue to next PR
} finally {
// Clean up or reset if needed?
// Since we use gh pr checkout, we are on a different branch.
// Next iteration will checkout another branch.
}
}
if (originalBranch) {
console.log(`Restoring original branch: ${originalBranch}`);
try {
execSync(`git checkout ${originalBranch}`);
} catch(e) {
console.error('Failed to restore original branch');
}
}
if (hasGlobalFailures) {
process.exit(1);
}
} else {
const prNumber = process.env.PR_NUMBER;
const baseRef = process.env.GITHUB_BASE_REF || 'main';
if (prNumber) {
await reviewPR(prNumber, baseRef);
} else {
// Local run or manual invocation without specific PR env vars
console.log('No PR_NUMBER found and no --all flag. Running local review against ' + baseRef);
await reviewPR('local', baseRef);
}
}
} catch (error) {
console.error('Automated PR Review failed:', error);
console.error('Automated PR Review failed:', error.message);
process.exit(1);
}
}
+2 -1
View File
@@ -38,7 +38,8 @@ function copyFile(src, dest) {
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
} else {
console.warn(`Warning: Source file ${src} does not exist.`);
console.error(`Error: Source file ${src} does not exist.`);
process.exit(1);
}
}
+3
View File
@@ -4,6 +4,9 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
// Ensure NODE_ENV is set to "test" so that nostrToolsBootstrap.js uses the mock toolkit
process.env.NODE_ENV = "test";
const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
const testsDir = path.join(rootDir, "tests");
const testFiles = [];