mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 18:32:27 +00:00
Merge branch 'unstable' into ai/debug-frontend-20260124-13275558127831455913
This commit is contained in:
48 files changed
+2900
-1552
No files matched your search
@@ -0,0 +1,153 @@
|
||||
import './fuzz-setup.mjs';
|
||||
import * as dm from '../../js/dmDecryptor.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const REPRODUCER_DIR = 'examples/reproducers';
|
||||
const REPORT_FILE = 'artifacts/fuzz-report-dm.json';
|
||||
|
||||
const ITERATIONS = 5000;
|
||||
|
||||
// Verify export
|
||||
if (typeof dm.decryptDM !== 'function') {
|
||||
console.error("Error: decryptDM is not exported from js/dmDecryptor.js");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Random generators
|
||||
function getRandomInt(max) {
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
|
||||
function getRandomString(length = 100) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRandomHex(length = 64) {
|
||||
const chars = '0123456789abcdef';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRandomTags() {
|
||||
const numTags = getRandomInt(5);
|
||||
const tags = [];
|
||||
for(let i=0; i<numTags; i++) {
|
||||
const tagLen = getRandomInt(4) + 1;
|
||||
const tag = [];
|
||||
tag.push(Math.random() > 0.5 ? 'p' : (Math.random() > 0.5 ? 'encrypted' : getRandomString(5)));
|
||||
for(let j=1; j<tagLen; j++) {
|
||||
tag.push(Math.random() > 0.8 ? getRandomHex(64) : getRandomString(20));
|
||||
}
|
||||
tags.push(tag);
|
||||
}
|
||||
// inject malformed tags
|
||||
if (Math.random() < 0.1) tags.push("not-an-array");
|
||||
if (Math.random() < 0.1) tags.push([]);
|
||||
return tags;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`Starting fuzzing DM decryptor for ${ITERATIONS} iterations...`);
|
||||
|
||||
const report = {
|
||||
target: 'js/dmDecryptor.js',
|
||||
timestamp: new Date().toISOString(),
|
||||
iterations: ITERATIONS,
|
||||
crashes: [],
|
||||
};
|
||||
|
||||
const uniqueCrashes = new Set();
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const event = {
|
||||
kind: Math.random() > 0.5 ? 4 : 1059,
|
||||
pubkey: getRandomHex(64),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: getRandomTags(),
|
||||
content: getRandomString(50),
|
||||
};
|
||||
|
||||
// Malform event
|
||||
if (Math.random() < 0.1) event.kind = "string";
|
||||
if (Math.random() < 0.1) event.tags = null;
|
||||
if (Math.random() < 0.1) event.content = null;
|
||||
|
||||
const context = {
|
||||
actorPubkey: getRandomHex(64),
|
||||
decryptors: [
|
||||
{
|
||||
scheme: 'nip44',
|
||||
decrypt: async (pk, ciphertext) => {
|
||||
if (Math.random() < 0.1) throw new Error("Decrypt failed");
|
||||
if (Math.random() < 0.1) return null;
|
||||
return "decrypted-content";
|
||||
},
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
scheme: 'nip04',
|
||||
decrypt: async () => "legacy-decrypted",
|
||||
priority: 0
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Malform context
|
||||
if (Math.random() < 0.1) context.decryptors = null;
|
||||
if (Math.random() < 0.1) context.decryptors = [null, {}];
|
||||
|
||||
try {
|
||||
await dm.decryptDM(event, context);
|
||||
} catch (err) {
|
||||
const crashKey = `decryptDM:${err.message}`;
|
||||
if (uniqueCrashes.has(crashKey)) {
|
||||
continue;
|
||||
}
|
||||
uniqueCrashes.add(crashKey);
|
||||
|
||||
let safeInput = "Could not stringify input";
|
||||
try {
|
||||
safeInput = JSON.stringify({ event, context: { ...context, decryptors: 'mocked' } });
|
||||
} catch (e) {
|
||||
safeInput = "[Input causes JSON.stringify to throw]";
|
||||
}
|
||||
|
||||
const crashInfo = {
|
||||
function: 'decryptDM',
|
||||
input: safeInput,
|
||||
error: err.toString(),
|
||||
stack: err.stack,
|
||||
};
|
||||
|
||||
const hash = crypto.createHash('sha256').update(JSON.stringify(crashInfo)).digest('hex').slice(0, 8);
|
||||
const filename = `crash-dm-${hash}.json`;
|
||||
fs.writeFileSync(path.join(REPRODUCER_DIR, filename), JSON.stringify(crashInfo, null, 2));
|
||||
|
||||
report.crashes.push({
|
||||
file: filename,
|
||||
function: 'decryptDM',
|
||||
error: err.message
|
||||
});
|
||||
|
||||
console.error(`[CRASH] decryptDM failed! Saved to ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.dirname(REPORT_FILE))) {
|
||||
fs.mkdirSync(path.dirname(REPORT_FILE), { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
|
||||
console.log(`Fuzzing complete. Report saved to ${REPORT_FILE}. Crashes: ${report.crashes.length}`);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,120 @@
|
||||
import './fuzz-setup.mjs';
|
||||
import * as magnetUtils from '../../js/magnetUtils.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const REPRODUCER_DIR = 'examples/reproducers';
|
||||
const REPORT_FILE = 'artifacts/fuzz-report-magnet.json';
|
||||
|
||||
const ITERATIONS = 5000;
|
||||
|
||||
function getRandomString(length = 100) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}|:"<>?~`-=[]\;\',./% ';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRandomMagnet() {
|
||||
const hash = getRandomString(40).replace(/[^0-9a-f]/gi, 'a');
|
||||
return `magnet:?xt=urn:btih:${hash}&dn=${getRandomString(10)}&tr=${encodeURIComponent(getRandomString(20))}`;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`Starting fuzzing magnet utils for ${ITERATIONS} iterations...`);
|
||||
|
||||
const report = {
|
||||
target: 'js/magnetUtils.js',
|
||||
timestamp: new Date().toISOString(),
|
||||
iterations: ITERATIONS,
|
||||
crashes: [],
|
||||
};
|
||||
|
||||
const uniqueCrashes = new Set();
|
||||
|
||||
const targets = [
|
||||
{ name: 'normalizeAndAugmentMagnet', fn: magnetUtils.normalizeAndAugmentMagnet, args: 2 },
|
||||
{ name: 'safeDecodeMagnet', fn: magnetUtils.safeDecodeMagnet, args: 1 },
|
||||
];
|
||||
|
||||
for (const { name, fn, args } of targets) {
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
let input, options;
|
||||
|
||||
if (Math.random() < 0.3) {
|
||||
input = getRandomMagnet();
|
||||
} else if (Math.random() < 0.3) {
|
||||
input = getRandomString(getRandomInt(200));
|
||||
} else {
|
||||
input = null; // or undefined, or object
|
||||
if (Math.random() < 0.5) input = {};
|
||||
}
|
||||
|
||||
if (args > 1) {
|
||||
options = {
|
||||
webSeed: Math.random() < 0.5 ? getRandomString(20) : [getRandomString(20)],
|
||||
torrentUrl: getRandomString(20),
|
||||
extraTrackers: [getRandomString(20)],
|
||||
appProtocol: 'https:',
|
||||
};
|
||||
if (Math.random() < 0.1) options = null;
|
||||
if (Math.random() < 0.1) options = "string";
|
||||
}
|
||||
|
||||
try {
|
||||
if (args === 1) {
|
||||
fn(input);
|
||||
} else {
|
||||
fn(input, options);
|
||||
}
|
||||
} catch (err) {
|
||||
const crashKey = `${name}:${err.message}`;
|
||||
if (uniqueCrashes.has(crashKey)) {
|
||||
continue;
|
||||
}
|
||||
uniqueCrashes.add(crashKey);
|
||||
|
||||
let safeInput = "Could not stringify input";
|
||||
try {
|
||||
safeInput = JSON.stringify(args === 1 ? input : { input, options });
|
||||
} catch (e) {
|
||||
safeInput = "[Input causes JSON.stringify to throw]";
|
||||
}
|
||||
|
||||
const crashInfo = {
|
||||
function: name,
|
||||
input: safeInput,
|
||||
error: err.toString(),
|
||||
stack: err.stack,
|
||||
};
|
||||
|
||||
const hash = crypto.createHash('sha256').update(JSON.stringify(crashInfo)).digest('hex').slice(0, 8);
|
||||
const filename = `crash-magnet-${name}-${hash}.json`;
|
||||
fs.writeFileSync(path.join(REPRODUCER_DIR, filename), JSON.stringify(crashInfo, null, 2));
|
||||
|
||||
report.crashes.push({
|
||||
file: filename,
|
||||
function: name,
|
||||
error: err.message
|
||||
});
|
||||
|
||||
console.error(`[CRASH] ${name} failed! Saved to ${filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.dirname(REPORT_FILE))) {
|
||||
fs.mkdirSync(path.dirname(REPORT_FILE), { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
|
||||
console.log(`Fuzzing complete. Report saved to ${REPORT_FILE}. Crashes: ${report.crashes.length}`);
|
||||
}
|
||||
|
||||
function getRandomInt(max) {
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,217 @@
|
||||
import './fuzz-setup.mjs';
|
||||
import * as schemas from '../../js/nostrEventSchemas.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const REPRODUCER_DIR = 'examples/reproducers';
|
||||
const REPORT_FILE = 'artifacts/fuzz-report-schemas.json';
|
||||
|
||||
const ITERATIONS = 5000;
|
||||
|
||||
// Fuzzing inputs generator
|
||||
function getRandomInt(max) {
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
|
||||
function getRandomString(length = 100) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}|:"<>?~`-=[]\;\',./';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRandomUnicodeString(length = 100) {
|
||||
let result = '';
|
||||
// Include surrogates and other edge cases
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (Math.random() < 0.1) {
|
||||
// Surrogate pair halves
|
||||
result += String.fromCharCode(0xD800 + Math.random() * 0x800);
|
||||
} else {
|
||||
result += String.fromCharCode(Math.random() * 0xFFFF);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRandomValue(depth = 0) {
|
||||
if (depth > 3) return null;
|
||||
const type = Math.random();
|
||||
if (type < 0.1) return null;
|
||||
if (type < 0.2) return undefined;
|
||||
if (type < 0.3) return getRandomInt(100000);
|
||||
if (type < 0.5) return getRandomString(getRandomInt(50));
|
||||
if (type < 0.6) return getRandomUnicodeString(getRandomInt(50));
|
||||
if (type < 0.7) return true;
|
||||
if (type < 0.8) return false;
|
||||
if (type < 0.9) {
|
||||
const arr = [];
|
||||
const len = getRandomInt(5);
|
||||
for(let i=0; i<len; i++) arr.push(getRandomValue(depth + 1));
|
||||
return arr;
|
||||
}
|
||||
const obj = {};
|
||||
const len = getRandomInt(5);
|
||||
for(let i=0; i<len; i++) {
|
||||
obj[getRandomString(5)] = getRandomValue(depth + 1);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
const interestingValues = [
|
||||
null,
|
||||
undefined,
|
||||
0,
|
||||
1,
|
||||
-1,
|
||||
Infinity,
|
||||
-Infinity,
|
||||
NaN,
|
||||
"",
|
||||
" ",
|
||||
"\n",
|
||||
"\t",
|
||||
"{}",
|
||||
"[]",
|
||||
"true",
|
||||
"false",
|
||||
"null",
|
||||
"undefined",
|
||||
{ toString: () => { throw new Error('toString throw'); } },
|
||||
{ toJSON: () => { throw new Error('toJSON throw'); } },
|
||||
new Array(1000).fill('a').join(''), // large string
|
||||
"javascript:alert(1)",
|
||||
"__proto__",
|
||||
"constructor",
|
||||
];
|
||||
|
||||
function generateFuzzInput(paramsSchema) {
|
||||
// If we knew the schema of params, we could be smarter.
|
||||
// For now, generate a random object with random keys, plus keys that likely exist.
|
||||
const input = {};
|
||||
const commonKeys = [
|
||||
'pubkey', 'created_at', 'content', 'tags', 'additionalTags',
|
||||
'id', 'kind', 'dTagValue', 'eventId', 'eventRelay', 'address',
|
||||
'addressRelay', 'authorPubkey', 'repostKind', 'targetKind',
|
||||
'targetEvent', 'serializedEvent', 'video', 'relays', 'metadata',
|
||||
'pTags', 'eventIds', 'addresses', 'reason', 'recipientPubkey',
|
||||
'ciphertext', 'attachment', 'messageKind', 'expiresAt',
|
||||
'pointerValue', 'pointerTag', 'pointerTags', 'dedupeTag',
|
||||
'includeSessionTag', 'amountSats', 'lnurl', 'coordinate',
|
||||
'targetPointer', 'targetAuthorPubkey', 'videoEventId',
|
||||
'videoEventRelay', 'videoDefinitionAddress', 'rootIdentifier',
|
||||
'parentCommentId', 'threadParticipantPubkey', 'rootKind',
|
||||
'monthIdentifier', 'encryption', 'hexPubkeys', 'listKey'
|
||||
];
|
||||
|
||||
// Pick some common keys and assign random or interesting values
|
||||
const numKeys = getRandomInt(10);
|
||||
for (let i = 0; i < numKeys; i++) {
|
||||
const key = commonKeys[getRandomInt(commonKeys.length)];
|
||||
const useInteresting = Math.random() < 0.2;
|
||||
input[key] = useInteresting ? interestingValues[getRandomInt(interestingValues.length)] : getRandomValue();
|
||||
}
|
||||
|
||||
// Also add completely random keys
|
||||
const numRandomKeys = getRandomInt(3);
|
||||
for (let i = 0; i < numRandomKeys; i++) {
|
||||
input[getRandomString(5)] = getRandomValue();
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`Starting fuzzing schemas for ${ITERATIONS} iterations...`);
|
||||
|
||||
const functionsToFuzz = [
|
||||
{ name: 'buildVideoPostEvent', fn: schemas.buildVideoPostEvent },
|
||||
{ name: 'buildVideoMirrorEvent', fn: schemas.buildVideoMirrorEvent },
|
||||
{ name: 'buildRepostEvent', fn: schemas.buildRepostEvent },
|
||||
{ name: 'buildShareEvent', fn: schemas.buildShareEvent },
|
||||
{ name: 'buildRelayListEvent', fn: schemas.buildRelayListEvent },
|
||||
{ name: 'buildDmRelayListEvent', fn: schemas.buildDmRelayListEvent },
|
||||
{ name: 'buildProfileMetadataEvent', fn: schemas.buildProfileMetadataEvent },
|
||||
{ name: 'buildMuteListEvent', fn: schemas.buildMuteListEvent },
|
||||
{ name: 'buildDeletionEvent', fn: schemas.buildDeletionEvent },
|
||||
{ name: 'buildLegacyDirectMessageEvent', fn: schemas.buildLegacyDirectMessageEvent },
|
||||
{ name: 'buildDmAttachmentEvent', fn: schemas.buildDmAttachmentEvent },
|
||||
{ name: 'buildDmReadReceiptEvent', fn: schemas.buildDmReadReceiptEvent },
|
||||
{ name: 'buildDmTypingIndicatorEvent', fn: schemas.buildDmTypingIndicatorEvent },
|
||||
{ name: 'buildViewEvent', fn: schemas.buildViewEvent },
|
||||
{ name: 'buildZapRequestEvent', fn: schemas.buildZapRequestEvent },
|
||||
{ name: 'buildReactionEvent', fn: schemas.buildReactionEvent },
|
||||
{ name: 'buildCommentEvent', fn: schemas.buildCommentEvent },
|
||||
{ name: 'buildWatchHistoryEvent', fn: schemas.buildWatchHistoryEvent },
|
||||
{ name: 'buildSubscriptionListEvent', fn: schemas.buildSubscriptionListEvent },
|
||||
{ name: 'buildBlockListEvent', fn: schemas.buildBlockListEvent },
|
||||
{ name: 'buildHashtagPreferenceEvent', fn: schemas.buildHashtagPreferenceEvent },
|
||||
{ name: 'buildAdminListEvent', fn: (params) => schemas.buildAdminListEvent(params.listKey, params) }, // Wrapper since it takes 2 args
|
||||
];
|
||||
|
||||
const report = {
|
||||
target: 'js/nostrEventSchemas.js',
|
||||
timestamp: new Date().toISOString(),
|
||||
iterations: ITERATIONS,
|
||||
crashes: [],
|
||||
};
|
||||
|
||||
const uniqueCrashes = new Set();
|
||||
|
||||
for (const { name, fn } of functionsToFuzz) {
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const input = generateFuzzInput();
|
||||
try {
|
||||
const result = fn(input);
|
||||
// Optional: validate output structure
|
||||
// if (result) {
|
||||
// schemas.validateEventAgainstSchema(result.kind, result);
|
||||
// }
|
||||
} catch (err) {
|
||||
const crashKey = `${name}:${err.message}`;
|
||||
if (uniqueCrashes.has(crashKey)) {
|
||||
continue;
|
||||
}
|
||||
uniqueCrashes.add(crashKey);
|
||||
|
||||
let safeInput = "Could not stringify input";
|
||||
try {
|
||||
safeInput = JSON.stringify(input, (k, v) => v === undefined ? '__undefined__' : v);
|
||||
} catch (e) {
|
||||
safeInput = "[Input causes JSON.stringify to throw]";
|
||||
}
|
||||
|
||||
const crashInfo = {
|
||||
function: name,
|
||||
input: safeInput,
|
||||
error: err.toString(),
|
||||
stack: err.stack,
|
||||
};
|
||||
|
||||
// Save reproducer
|
||||
const hash = crypto.createHash('sha256').update(JSON.stringify(crashInfo)).digest('hex').slice(0, 8);
|
||||
const filename = `crash-schemas-${name}-${hash}.json`;
|
||||
fs.writeFileSync(path.join(REPRODUCER_DIR, filename), JSON.stringify(crashInfo, null, 2));
|
||||
|
||||
report.crashes.push({
|
||||
file: filename,
|
||||
function: name,
|
||||
error: err.message
|
||||
});
|
||||
|
||||
console.error(`[CRASH] ${name} failed! Saved to ${filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.dirname(REPORT_FILE))) {
|
||||
fs.mkdirSync(path.dirname(REPORT_FILE), { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
|
||||
console.log(`Fuzzing complete. Report saved to ${REPORT_FILE}. Crashes: ${report.crashes.length}`);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,55 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageStore = new Map();
|
||||
const localStorageMock = {
|
||||
getItem: (key) => localStorageStore.get(String(key)) ?? null,
|
||||
setItem: (key, value) => localStorageStore.set(String(key), String(value)),
|
||||
removeItem: (key) => localStorageStore.delete(String(key)),
|
||||
clear: () => localStorageStore.clear(),
|
||||
key: (index) => Array.from(localStorageStore.keys())[index] ?? null,
|
||||
get length() {
|
||||
return localStorageStore.size;
|
||||
},
|
||||
};
|
||||
|
||||
// Mock window
|
||||
const windowMock = {
|
||||
localStorage: localStorageMock,
|
||||
crypto: {
|
||||
getRandomValues: (buffer) => crypto.getRandomValues(buffer),
|
||||
subtle: crypto.webcrypto.subtle,
|
||||
},
|
||||
location: {
|
||||
protocol: 'https:',
|
||||
hostname: 'bitvid.network',
|
||||
href: 'https://bitvid.network/',
|
||||
},
|
||||
};
|
||||
|
||||
// Polyfill globals
|
||||
if (typeof globalThis.window === 'undefined') {
|
||||
globalThis.window = windowMock;
|
||||
}
|
||||
|
||||
if (typeof globalThis.localStorage === 'undefined') {
|
||||
globalThis.localStorage = localStorageMock;
|
||||
}
|
||||
|
||||
if (typeof globalThis.crypto === 'undefined') {
|
||||
globalThis.crypto = windowMock.crypto;
|
||||
}
|
||||
|
||||
if (typeof globalThis.WebSocket === 'undefined') {
|
||||
globalThis.WebSocket = class WebSocket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
}
|
||||
send() {}
|
||||
close() {}
|
||||
};
|
||||
}
|
||||
|
||||
// Mock bitvid-specific globals if needed (though config.js sets them on window)
|
||||
globalThis.bitvidNostrEventOverrides = {};
|
||||
+184
-195
@@ -1,202 +1,191 @@
|
||||
import './setup-test-env.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { NostrClient } from '../../js/nostr/client.js';
|
||||
import { buildVideoPostEvent } from '../../js/nostrEventSchemas.js';
|
||||
import { WebSocket } from 'ws';
|
||||
import * as NostrTools from 'nostr-tools';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Polyfill global for fallback if dynamic import fails in bootstrap (unlikely in Node but safe)
|
||||
global.NostrTools = NostrTools;
|
||||
|
||||
const ARTIFACTS_DIR = 'artifacts';
|
||||
if (!fs.existsSync(ARTIFACTS_DIR)) {
|
||||
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
|
||||
if (!global.WebSocket) {
|
||||
global.WebSocket = WebSocket;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('--- Starting Interop Test ---');
|
||||
|
||||
// 1. Start Local Relay
|
||||
console.log('[Setup] Spawning simple-relay.mjs...');
|
||||
const relayLog = fs.openSync(path.join(ARTIFACTS_DIR, 'interop-relay.log'), 'w');
|
||||
const relayProcess = spawn('node', ['scripts/agent/simple-relay.mjs'], {
|
||||
stdio: ['ignore', relayLog, relayLog],
|
||||
env: { ...process.env, PORT: '8008' }
|
||||
});
|
||||
|
||||
// Give relay time to boot
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
console.log('[Setup] Relay assumed running on ws://localhost:8008');
|
||||
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
// 2. Client Init (Sender - Identity A)
|
||||
console.log('[Client A] Initializing...');
|
||||
const clientA = new NostrClient();
|
||||
|
||||
// Override relays to local
|
||||
const testRelays = ['ws://localhost:8008'];
|
||||
clientA.relays = [...testRelays];
|
||||
clientA.readRelays = [...testRelays];
|
||||
clientA.writeRelays = [...testRelays];
|
||||
|
||||
await clientA.init();
|
||||
console.log('[Client A] Connected.');
|
||||
|
||||
// Generate keys
|
||||
const skA = NostrTools.generateSecretKey();
|
||||
const pkA = NostrTools.getPublicKey(skA);
|
||||
const hexSkA = NostrTools.utils.bytesToHex(skA);
|
||||
const npubA = nip19.npubEncode(pkA);
|
||||
|
||||
console.log(`[Client A] Identity: ${pkA} (${npubA})`);
|
||||
|
||||
// Register signer
|
||||
await clientA.registerPrivateKeySigner({ privateKey: hexSkA, pubkey: pkA });
|
||||
console.log('[Client A] Signer registered.');
|
||||
|
||||
// --- TEST 1: Video Post ---
|
||||
console.log('\n--- Test 1: Video Post ---');
|
||||
// Using buildVideoPostEvent directly to verify schema interop as requested
|
||||
const videoPayload = {
|
||||
version: 3,
|
||||
title: 'Interop Video Test',
|
||||
description: 'Testing video publish via NostrClient',
|
||||
magnet: 'magnet:?xt=urn:btih:c91104e1e82813136287e0767786431206d048d0&dn=test-video',
|
||||
mode: 'live',
|
||||
isPrivate: false,
|
||||
videoRootId: `test-root-${Date.now()}`,
|
||||
isNsfw: false,
|
||||
isForKids: false,
|
||||
url: '',
|
||||
thumbnail: '',
|
||||
deleted: false,
|
||||
enableComments: true
|
||||
// Mock localStorage to suppress config.js warnings
|
||||
if (!global.localStorage) {
|
||||
global.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
clear: () => {}
|
||||
};
|
||||
|
||||
console.log('[Test 1] Building and Publishing video...');
|
||||
const rawEvent = buildVideoPostEvent({
|
||||
pubkey: pkA,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: videoPayload,
|
||||
dTagValue: `interop-${Date.now()}`
|
||||
});
|
||||
|
||||
const { signedEvent: videoEvent } = await clientA.signAndPublishEvent(rawEvent);
|
||||
|
||||
if (!videoEvent || !videoEvent.id) {
|
||||
throw new Error('Video publish returned no event or ID');
|
||||
}
|
||||
console.log(`[Test 1] Video published. ID: ${videoEvent.id}`);
|
||||
|
||||
// Fetch back
|
||||
console.log('[Test 1] Fetching event back...');
|
||||
const fetchedEvent = await clientA.getEventById(videoEvent.id);
|
||||
if (!fetchedEvent) {
|
||||
throw new Error('Failed to fetch video event back');
|
||||
}
|
||||
|
||||
// Validate
|
||||
// getEventById returns the Video model (parsed object), not the raw event, unless includeRaw is true.
|
||||
// The Video model has 'title' at top level.
|
||||
if (fetchedEvent.title !== videoPayload.title) {
|
||||
throw new Error(`Title mismatch: expected ${videoPayload.title}, got ${fetchedEvent.title}`);
|
||||
}
|
||||
console.log('[Test 1] Verified: Content matches.');
|
||||
|
||||
|
||||
// --- TEST 2: View Event ---
|
||||
console.log('\n--- Test 2: View Event ---');
|
||||
// publishViewEvent expects a pointer object { type: 'e'|'a', value: hex|address } or a string.
|
||||
// We'll use the event ID ('e' tag) for this test.
|
||||
const viewPointer = {
|
||||
type: 'e',
|
||||
value: videoEvent.id
|
||||
};
|
||||
|
||||
console.log('[Test 2] Publishing view event...');
|
||||
await clientA.publishViewEvent(viewPointer);
|
||||
|
||||
console.log('[Test 2] Polling for view event...');
|
||||
// We poll because simple-relay.mjs does not support live subscriptions
|
||||
let receivedView = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const views = await clientA.listVideoViewEvents(viewPointer);
|
||||
// Find one signed by us
|
||||
receivedView = views.find(v => v.pubkey === pkA);
|
||||
if (receivedView) break;
|
||||
}
|
||||
|
||||
if (!receivedView) {
|
||||
throw new Error('View event not found after polling');
|
||||
}
|
||||
console.log(`[Test 2] View event verified: ${receivedView.id}`);
|
||||
|
||||
|
||||
// --- TEST 3: Encrypted DM ---
|
||||
console.log('\n--- Test 3: Direct Message ---');
|
||||
|
||||
// Client B (Recipient)
|
||||
console.log('[Client B] Initializing...');
|
||||
const clientB = new NostrClient();
|
||||
clientB.relays = [...testRelays];
|
||||
clientB.readRelays = [...testRelays];
|
||||
clientB.writeRelays = [...testRelays];
|
||||
await clientB.init();
|
||||
|
||||
const skB = NostrTools.generateSecretKey();
|
||||
const pkB = NostrTools.getPublicKey(skB);
|
||||
const hexSkB = NostrTools.utils.bytesToHex(skB);
|
||||
const npubB = nip19.npubEncode(pkB);
|
||||
console.log(`[Client B] Identity: ${pkB} (${npubB})`);
|
||||
|
||||
await clientB.registerPrivateKeySigner({ privateKey: hexSkB, pubkey: pkB });
|
||||
|
||||
const dmMessage = 'Hello from Client A to Client B ' + Date.now();
|
||||
|
||||
console.log(`[Test 3] Client A sending DM to ${npubB}...`);
|
||||
const sendResult = await clientA.sendDirectMessage(npubB, dmMessage);
|
||||
if (!sendResult.ok) {
|
||||
throw new Error(`Failed to send DM: ${sendResult.error}`);
|
||||
}
|
||||
console.log('[Test 3] DM Sent.');
|
||||
|
||||
// Wait a bit for relay
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
console.log('[Test 3] Client B listing messages...');
|
||||
// listDirectMessages uses decryptDirectMessageEvent internally which uses dmDecryptor.js
|
||||
// It requires actorPubkeyInput or uses internal session actor.
|
||||
// We verified registerPrivateKeySigner sets session actor.
|
||||
const messages = await clientB.listDirectMessages(pkB, { limit: 10 });
|
||||
|
||||
const found = messages.find(msg => msg.plaintext === dmMessage);
|
||||
if (!found) {
|
||||
console.log('Messages found:', messages.map(m => m.plaintext));
|
||||
throw new Error('Client B did not receive/decrypt the expected message.');
|
||||
}
|
||||
|
||||
console.log(`[Test 3] Verified DM content: "${found.plaintext}"`);
|
||||
console.log(`[Test 3] Encryption scheme: ${found.scheme}`);
|
||||
|
||||
|
||||
console.log('\n--- All Tests Passed ---');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n!!! TEST FAILED !!!');
|
||||
console.error(error);
|
||||
exitCode = 1;
|
||||
} finally {
|
||||
console.log('[Cleanup] Killing relay...');
|
||||
relayProcess.kill();
|
||||
fs.closeSync(relayLog);
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
run();
|
||||
// Setup basic logging
|
||||
const log = (msg) => console.log(`[TEST] ${msg}`);
|
||||
const err = (msg, e) => console.error(`[TEST] ${msg}`, e);
|
||||
|
||||
// Configuration
|
||||
const TEST_RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social"
|
||||
];
|
||||
|
||||
// Hex helper
|
||||
const toHex = (bytes) => Buffer.from(bytes).toString('hex');
|
||||
|
||||
// Polling helper
|
||||
async function waitForEvent(client, eventId, timeout = 15000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const event = await client.getEventById(eventId);
|
||||
if (event) return event;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
throw new Error(`Timeout waiting for event ${eventId}`);
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
try {
|
||||
// Dynamic imports to ensure mocks are applied before modules load
|
||||
const { NostrClient } = await import('../../js/nostr/defaultClient.js');
|
||||
const {
|
||||
buildVideoPostEvent,
|
||||
buildViewEvent,
|
||||
} = await import('../../js/nostrEventSchemas.js');
|
||||
|
||||
// Helper to gen keypair
|
||||
const genKeypair = () => {
|
||||
let skBytes = NostrTools.generateSecretKey();
|
||||
let sk = toHex(skBytes);
|
||||
let pk = NostrTools.getPublicKey(sk); // Assumes hex input works or throws, but we'll try standard
|
||||
// In v2, getPublicKey usually takes Uint8Array.
|
||||
// If previous run worked with hex string, good.
|
||||
// If not, we should pass skBytes.
|
||||
// NostrClient source suggests it handles string input via tools wrapper, but let's be safe.
|
||||
try {
|
||||
pk = NostrTools.getPublicKey(skBytes);
|
||||
} catch(e) {
|
||||
pk = NostrTools.getPublicKey(sk);
|
||||
}
|
||||
return { sk, pk };
|
||||
}
|
||||
|
||||
log("Generating ephemeral keys (Alice)...");
|
||||
const alice = genKeypair();
|
||||
log(`Alice Pubkey: ${alice.pk}`);
|
||||
|
||||
log("Generating ephemeral keys (Bob)...");
|
||||
const bob = genKeypair();
|
||||
log(`Bob Pubkey: ${bob.pk}`);
|
||||
|
||||
log("Initializing NostrClient...");
|
||||
const client = new NostrClient();
|
||||
client.relays = TEST_RELAYS;
|
||||
|
||||
await client.init();
|
||||
log("Client initialized.");
|
||||
|
||||
// Register signer as Alice
|
||||
await client.registerPrivateKeySigner({ privateKey: alice.sk, pubkey: alice.pk });
|
||||
log("Registered as Alice.");
|
||||
|
||||
// 1. Video Post
|
||||
log("--- Test Case 1: Video Post (Alice) ---");
|
||||
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const created_at = Math.floor(Date.now() / 1000);
|
||||
|
||||
const videoContent = {
|
||||
version: 3,
|
||||
title: "Interop Test Video",
|
||||
videoRootId: videoRootId,
|
||||
description: "Test video for interoperability check.",
|
||||
url: "https://example.com/video.mp4",
|
||||
mode: "live"
|
||||
};
|
||||
|
||||
const videoEvent = buildVideoPostEvent({
|
||||
pubkey: alice.pk,
|
||||
created_at,
|
||||
dTagValue,
|
||||
content: videoContent
|
||||
});
|
||||
|
||||
log("Publishing Video Post...");
|
||||
const { signedEvent: publishedVideo } = await client.signAndPublishEvent(videoEvent);
|
||||
if (!publishedVideo || !publishedVideo.id || !publishedVideo.sig) {
|
||||
throw new Error("Failed to sign/publish video event");
|
||||
}
|
||||
log(`Published Video Event ID: ${publishedVideo.id}`);
|
||||
|
||||
log("Fetching Video Event back...");
|
||||
const fetchedVideo = await waitForEvent(client, publishedVideo.id);
|
||||
if (fetchedVideo.id !== publishedVideo.id) {
|
||||
throw new Error(`Fetched event ID mismatch. Expected ${publishedVideo.id}, got ${fetchedVideo.id}`);
|
||||
}
|
||||
log("Verified Video Post roundtrip.");
|
||||
|
||||
// 2. View Event
|
||||
log("--- Test Case 2: View Event (Alice) ---");
|
||||
const viewEvent = buildViewEvent({
|
||||
pubkey: alice.pk,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pointerValue: publishedVideo.id,
|
||||
pointerTag: ["e", publishedVideo.id],
|
||||
content: "Interop View Test",
|
||||
dedupeTag: `view-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
});
|
||||
|
||||
log("Publishing View Event...");
|
||||
const { signedEvent: publishedView } = await client.signAndPublishEvent(viewEvent);
|
||||
if (!publishedView || !publishedView.id) {
|
||||
throw new Error("Failed to publish view event");
|
||||
}
|
||||
log(`Published View Event ID: ${publishedView.id}`);
|
||||
|
||||
log("Verifying View Event visibility...");
|
||||
await waitForEvent(client, publishedView.id);
|
||||
log("Verified View Event roundtrip.");
|
||||
|
||||
// 3. DM
|
||||
log("--- Test Case 3: Direct Message (Alice -> Bob) ---");
|
||||
const dmMessage = `Test DM ${Date.now()}`;
|
||||
const bobNpub = NostrTools.nip19.npubEncode(bob.pk);
|
||||
|
||||
log(`Sending DM from Alice to Bob (${bobNpub}): "${dmMessage}"`);
|
||||
const sendResult = await client.sendDirectMessage(bobNpub, dmMessage);
|
||||
if (!sendResult.ok) {
|
||||
throw new Error(`Failed to send DM: ${sendResult.error}`);
|
||||
}
|
||||
log("DM sent.");
|
||||
|
||||
log("Switching to Bob to receive...");
|
||||
await client.registerPrivateKeySigner({ privateKey: bob.sk, pubkey: bob.pk });
|
||||
log("Registered as Bob.");
|
||||
|
||||
log("Waiting for DM...");
|
||||
// Poll for DMs
|
||||
const start = Date.now();
|
||||
let foundDm = null;
|
||||
while (Date.now() - start < 20000) {
|
||||
const dms = await client.listDirectMessages();
|
||||
const match = dms.find(dm => dm.plaintext === dmMessage && dm.sender?.pubkey === alice.pk);
|
||||
if (match) {
|
||||
foundDm = match;
|
||||
break;
|
||||
}
|
||||
log(" DM not found yet, retrying...");
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
if (!foundDm) {
|
||||
throw new Error("Timeout: Bob could not find the sent DM.");
|
||||
}
|
||||
log(`Bob received and decrypted DM: "${foundDm.plaintext}" from Alice.`);
|
||||
|
||||
log("All tests passed!");
|
||||
process.exit(0);
|
||||
|
||||
} catch (e) {
|
||||
err("Test failed", e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
+144
-219
@@ -1,292 +1,217 @@
|
||||
/**
|
||||
* Agent Smoke Test
|
||||
* Verified flows: Login, Publish (Video), DM Decrypt (NIP-04/44)
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
import { startRelay } from './simple-relay.mjs';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT_DIR = path.resolve(__dirname, '../..');
|
||||
const ROOT_DIR = path.resolve(__dirname, '../../');
|
||||
const ARTIFACTS_DIR = path.join(ROOT_DIR, 'artifacts');
|
||||
|
||||
if (!fs.existsSync(ARTIFACTS_DIR)) {
|
||||
fs.mkdirSync(ARTIFACTS_DIR);
|
||||
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const DATE_STR = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const LOG_FILE = path.join(ARTIFACTS_DIR, `smoke-${DATE_STR}.log`);
|
||||
const TIMESTAMP = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const LOG_FILE = path.join(ARTIFACTS_DIR, `smoke-${TIMESTAMP}.log`);
|
||||
const SUMMARY_FILE = path.join(ARTIFACTS_DIR, `smoke-summary.json`);
|
||||
|
||||
function log(message) {
|
||||
const line = `[${new Date().toISOString()}] ${message}`;
|
||||
const timestamp = new Date().toISOString();
|
||||
const line = `[${timestamp}] ${message}`;
|
||||
console.log(line);
|
||||
fs.appendFileSync(LOG_FILE, line + '\n');
|
||||
}
|
||||
|
||||
async function startProcess(command, args, name) {
|
||||
log(`Starting ${name}...`);
|
||||
const proc = spawn(command, args, {
|
||||
async function runSmokeTest() {
|
||||
const startTime = new Date().toISOString();
|
||||
log('Starting smoke test...');
|
||||
|
||||
// 1. Start Relay
|
||||
log('Starting local relay on port 8888...');
|
||||
const relay = startRelay(8888);
|
||||
|
||||
// 2. Start Web Server
|
||||
log('Starting web server on port 8000...');
|
||||
const serverProcess = spawn('npx', ['serve', '-p', '8000'], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: 'pipe', // Capture output
|
||||
env: process.env
|
||||
stdio: 'ignore',
|
||||
shell: true
|
||||
});
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
log(`[${name}] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
log(`[${name} ERR] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
log(`[${name}] Failed to start: ${err.message}`);
|
||||
});
|
||||
|
||||
// Give it a moment to start
|
||||
// Give server time to boot
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
return proc;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let serverProcess;
|
||||
let relayProcess;
|
||||
let browser;
|
||||
let exitCode = 0;
|
||||
let testResult = { success: false, error: null };
|
||||
|
||||
try {
|
||||
// 1. Start Relay
|
||||
relayProcess = await startProcess('node', ['scripts/agent/simple-relay.mjs'], 'LocalRelay');
|
||||
|
||||
// 2. Start Web Server
|
||||
serverProcess = await startProcess('python3', ['-m', 'http.server', '8000'], 'WebServer');
|
||||
|
||||
log('Launching browser...');
|
||||
browser = await chromium.launch({ headless: true });
|
||||
// 3. Launch Playwright
|
||||
log('Launching headless browser...');
|
||||
browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
page.on('console', msg => {
|
||||
const type = msg.type();
|
||||
const text = msg.text();
|
||||
if (text.startsWith('[SmokeTest]')) {
|
||||
log(text);
|
||||
} else if (type === 'error') {
|
||||
log(`BROWSER ERROR: ${text}`);
|
||||
}
|
||||
});
|
||||
page.on('console', msg => log(`[BROWSER] ${msg.text()}`));
|
||||
page.on('pageerror', err => log(`[BROWSER ERROR] ${err.message}`));
|
||||
|
||||
page.on('pageerror', err => {
|
||||
log(`PAGE ERROR: ${err.message}`);
|
||||
});
|
||||
log('Navigating to http://localhost:8000...');
|
||||
await page.goto('http://localhost:8000');
|
||||
|
||||
log('Navigating to http://127.0.0.1:8000 ...');
|
||||
await page.goto('http://127.0.0.1:8000');
|
||||
|
||||
// Wait for App and NostrTools
|
||||
log('Waiting for app and NostrTools...');
|
||||
await page.waitForFunction(() => window.NostrTools && document.querySelector('#app'), { timeout: 15000 });
|
||||
log('App loaded and NostrTools available.');
|
||||
|
||||
// Inject test logic
|
||||
log('Running in-page smoke test logic...');
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const logs = [];
|
||||
function pageLog(m) {
|
||||
const msg = `[SmokeTest] ${m}`;
|
||||
console.log(msg);
|
||||
logs.push(msg);
|
||||
}
|
||||
log('Waiting for NostrTools...');
|
||||
await page.waitForFunction(() => window.NostrTools);
|
||||
|
||||
// 4. Inject Test Script
|
||||
log('Injecting test script...');
|
||||
testResult = await page.evaluate(async () => {
|
||||
try {
|
||||
const RELAY_URL = 'ws://localhost:8008';
|
||||
pageLog(`Test Relay: ${RELAY_URL}`);
|
||||
console.log('Test script started inside browser.');
|
||||
|
||||
// 1. Import Modules
|
||||
const { nostrClient } = await import('./js/nostrClientFacade.js');
|
||||
const { decryptDM } = await import('./js/dmDecryptor.js');
|
||||
const NostrTools = window.NostrTools;
|
||||
const { bytesToHex } = window.NostrTools.utils || window.NostrTools; // Handle v2 or fallback
|
||||
const { generateSecretKey, getPublicKey } = window.NostrTools;
|
||||
|
||||
if (!nostrClient) throw new Error('nostrClient import failed');
|
||||
if (!decryptDM) throw new Error('decryptDM import failed');
|
||||
// --- Setup ---
|
||||
console.log('Generating keys...');
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
const privateKey = typeof sk === 'string' ? sk : bytesToHex(sk); // Handle if generateSecretKey returns hex (older versions)
|
||||
const pubkey = pk;
|
||||
|
||||
// 2. Configure Client
|
||||
pageLog('Configuring nostrClient...');
|
||||
nostrClient.relays = [RELAY_URL];
|
||||
nostrClient.writeRelays = [RELAY_URL];
|
||||
nostrClient.readRelays = [RELAY_URL];
|
||||
console.log(`Test User Pubkey: ${pubkey}`);
|
||||
|
||||
// Force connection logic if needed, but adding to relays list usually triggers connection attempt in client
|
||||
// Check pool if available
|
||||
if (nostrClient.pool) {
|
||||
await nostrClient.pool.ensureRelay(RELAY_URL);
|
||||
}
|
||||
// Configure Client
|
||||
console.log('Configuring NostrClient...');
|
||||
nostrClient.relays = ['ws://localhost:8888'];
|
||||
nostrClient.readRelays = ['ws://localhost:8888'];
|
||||
nostrClient.writeRelays = ['ws://localhost:8888'];
|
||||
|
||||
// Wait for connection
|
||||
pageLog('Waiting for relay connection...');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
await nostrClient.init();
|
||||
|
||||
// 3. Generate Identity & Login
|
||||
pageLog('Generating ephemeral keys...');
|
||||
// Login
|
||||
console.log('Logging in with private key...');
|
||||
await nostrClient.registerPrivateKeySigner({ privateKey, pubkey });
|
||||
|
||||
// Helper since NostrTools.bytesToHex might be missing depending on version/bundle
|
||||
function toHex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const skBytes = NostrTools.generateSecretKey();
|
||||
const sk = toHex(skBytes);
|
||||
const pk = NostrTools.getPublicKey(skBytes);
|
||||
pageLog(`Generated Pubkey: ${pk}`);
|
||||
|
||||
await nostrClient.registerPrivateKeySigner({ privateKey: sk, pubkey: pk });
|
||||
pageLog('Logged in.');
|
||||
|
||||
// 4. Publish Video
|
||||
pageLog('Publishing VIDEO_POST...');
|
||||
const videoRootId = `smoke-${Date.now()}`;
|
||||
// --- Video Flow ---
|
||||
console.log('Starting Video Flow...');
|
||||
const videoTitle = `Smoke Test Video ${Date.now()}`;
|
||||
const videoPayload = {
|
||||
legacyFormData: {
|
||||
title: `Smoke Test Video ${Date.now()}`,
|
||||
description: 'Smoke test artifact',
|
||||
url: 'https://example.com/smoke.mp4',
|
||||
magnet: 'magnet:?xt=urn:btih:c9e15763f722f23e98ce29a8c0c4000000000000&dn=smoke',
|
||||
title: videoTitle,
|
||||
description: 'Smoke test description',
|
||||
url: 'https://example.com/video.mp4',
|
||||
magnet: 'magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&dn=video.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
isForKids: false,
|
||||
isNsfw: false,
|
||||
isPrivate: true
|
||||
},
|
||||
fileSha256: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
isNsfw: false
|
||||
}
|
||||
};
|
||||
|
||||
const pubEvent = await nostrClient.publishVideo(videoPayload, pk);
|
||||
if (!pubEvent || !pubEvent.id) throw new Error('Publish failed, no event returned');
|
||||
pageLog(`Video published: ${pubEvent.id}`);
|
||||
console.log('Publishing video...');
|
||||
const publishedEvent = await nostrClient.publishVideo(videoPayload, pubkey);
|
||||
console.log(`Published Video Event ID: ${publishedEvent.id}`);
|
||||
|
||||
// 5. Verify Video Readback
|
||||
pageLog('Verifying video readback...');
|
||||
await new Promise(r => setTimeout(r, 500)); // Propagate
|
||||
const fetched = await nostrClient.getEventById(pubEvent.id);
|
||||
if (!fetched || fetched.id !== pubEvent.id) throw new Error('Failed to fetch video event');
|
||||
pageLog('Video verified.');
|
||||
// Verify fetch
|
||||
console.log('Fetching videos to verify...');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// 6. DM Flow (Self-DM)
|
||||
pageLog('Testing DM flow (Self-Send)...');
|
||||
const message = `Smoke DM ${Date.now()}`;
|
||||
const npub = NostrTools.nip19.npubEncode(pk);
|
||||
const videos = await nostrClient.fetchVideos({ limit: 10 });
|
||||
const found = videos.find(v => v.id === publishedEvent.id);
|
||||
|
||||
const dmRes = await nostrClient.sendDirectMessage(npub, message);
|
||||
if (!dmRes.ok) throw new Error(`DM Send failed: ${dmRes.error}`);
|
||||
pageLog('DM sent.');
|
||||
|
||||
// 7. Fetch & Decrypt
|
||||
pageLog('Fetching DM...');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
// We use listDirectMessages which handles fetching and decrypting usually,
|
||||
// BUT the prompt asks to "verify decryption via js/dmDecryptor.js".
|
||||
// `nostrClient.listDirectMessages` might use `dmDecryptor` internally or have its own logic.
|
||||
// Let's manually fetch the raw event and decrypt it to be sure we are testing `dmDecryptor.js`.
|
||||
|
||||
const filter = {
|
||||
kinds: [4, 1059],
|
||||
'#p': [pk],
|
||||
limit: 10
|
||||
};
|
||||
const events = await nostrClient.pool.list([RELAY_URL], [filter]);
|
||||
const targetEvent = events.find(e => e.pubkey === pk); // Sent by self
|
||||
|
||||
if (!targetEvent) throw new Error('DM event not found on relay');
|
||||
pageLog(`Found DM event kind ${targetEvent.kind}`);
|
||||
|
||||
// Setup Decrypt Context
|
||||
const decryptors = [];
|
||||
|
||||
// NIP-44
|
||||
if (NostrTools.nip44) {
|
||||
decryptors.push({
|
||||
scheme: 'nip44',
|
||||
decrypt: (pk, ct) => NostrTools.nip44.decrypt(sk, pk, ct),
|
||||
priority: 1,
|
||||
supportsGiftWrap: true
|
||||
});
|
||||
if (!found) {
|
||||
throw new Error('Published video not found in fetch results.');
|
||||
}
|
||||
// NIP-04
|
||||
if (NostrTools.nip04) {
|
||||
decryptors.push({
|
||||
scheme: 'nip04',
|
||||
decrypt: async (pk, ct) => NostrTools.nip04.decrypt(sk, pk, ct),
|
||||
priority: 0
|
||||
});
|
||||
console.log('Video Flow Success: Video found.');
|
||||
|
||||
// --- DM Flow ---
|
||||
console.log('Starting DM Flow (Self-DM)...');
|
||||
const dmMessage = `Smoke Test DM ${Date.now()}`;
|
||||
|
||||
console.log('Sending DM...');
|
||||
const sendResult = await nostrClient.sendDirectMessage(window.NostrTools.nip19.npubEncode(pubkey), dmMessage);
|
||||
|
||||
if (!sendResult.ok) {
|
||||
throw new Error(`DM Send failed: ${sendResult.error}`);
|
||||
}
|
||||
console.log('DM Sent.');
|
||||
|
||||
// Verify Decryption explicitly using decryptDM
|
||||
console.log('Fetching raw DM event to verify decryption...');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// We need to fetch the raw event. nostrClient.pool.list
|
||||
const events = await nostrClient.pool.list(['ws://localhost:8888'], [
|
||||
{ kinds: [4, 1059], authors: [pubkey], limit: 10 } // Simplified filter for self-DM
|
||||
]);
|
||||
|
||||
// Find the event that corresponds to our message (we can't check content yet, so we try decrypting candidates)
|
||||
let foundAndDecrypted = false;
|
||||
const decryptContext = await nostrClient.buildDmDecryptContext(pubkey);
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
const result = await decryptDM(event, decryptContext);
|
||||
if (result.ok && result.plaintext === dmMessage) {
|
||||
foundAndDecrypted = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore decrypt errors for other events
|
||||
}
|
||||
}
|
||||
|
||||
const context = {
|
||||
actorPubkey: pk,
|
||||
decryptors
|
||||
};
|
||||
|
||||
pageLog('Decrypting...');
|
||||
const decrypted = await decryptDM(targetEvent, context);
|
||||
|
||||
if (!decrypted.ok) {
|
||||
console.error('Decryption errors:', decrypted.errors);
|
||||
throw new Error('Decryption failed');
|
||||
if (!foundAndDecrypted) {
|
||||
throw new Error('DM verification failed: Could not find or decrypt the sent message using decryptDM.');
|
||||
}
|
||||
|
||||
if (decrypted.plaintext !== message) {
|
||||
throw new Error(`Message mismatch: Got "${decrypted.plaintext}", expected "${message}"`);
|
||||
}
|
||||
console.log('DM Flow Success: Message decrypted and verified via decryptDM.');
|
||||
|
||||
pageLog('Decryption verified!');
|
||||
return { success: true };
|
||||
|
||||
return { success: true, logs };
|
||||
|
||||
} catch (e) {
|
||||
pageLog(`FATAL: ${e.message}`);
|
||||
return { success: false, error: e.message, stack: e.stack, logs };
|
||||
} catch (err) {
|
||||
console.error('In-browser test error:', err);
|
||||
return { success: false, error: err.toString(), stack: err.stack };
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
log('SMOKE TEST PASSED');
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
logs: result.logs
|
||||
};
|
||||
fs.writeFileSync(path.join(ARTIFACTS_DIR, `smoke-summary-${DATE_STR}.json`), JSON.stringify(summary, null, 2));
|
||||
if (testResult.success) {
|
||||
log('Test execution passed successfully.');
|
||||
} else {
|
||||
log('SMOKE TEST FAILED');
|
||||
log(`Error: ${result.error}`);
|
||||
log(`Test execution failed: ${testResult.error}`);
|
||||
if (testResult.stack) log(testResult.stack);
|
||||
|
||||
const screenshotPath = path.join(ARTIFACTS_DIR, `smoke-fail-${DATE_STR}.png`);
|
||||
const screenshotPath = path.join(ARTIFACTS_DIR, 'smoke-failure.png');
|
||||
await page.screenshot({ path: screenshotPath });
|
||||
log(`Screenshot saved: ${screenshotPath}`);
|
||||
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'failure',
|
||||
error: result.error,
|
||||
logs: result.logs
|
||||
};
|
||||
fs.writeFileSync(path.join(ARTIFACTS_DIR, `smoke-summary-${DATE_STR}.json`), JSON.stringify(summary, null, 2));
|
||||
exitCode = 1;
|
||||
log(`Screenshot saved to ${screenshotPath}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
log(`SCRIPT ERROR: ${err.message}`);
|
||||
log(`FATAL ERROR: ${err.message}`);
|
||||
console.error(err);
|
||||
exitCode = 1;
|
||||
testResult.error = err.message;
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
log('Cleaning up...');
|
||||
if (browser) await browser.close();
|
||||
if (serverProcess) serverProcess.kill();
|
||||
if (relayProcess) relayProcess.kill();
|
||||
}
|
||||
serverProcess.kill();
|
||||
relay.close();
|
||||
|
||||
process.exit(exitCode);
|
||||
// Write JSON summary
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
success: testResult.success === true,
|
||||
error: testResult.error || null,
|
||||
startTime,
|
||||
endTime: new Date().toISOString()
|
||||
};
|
||||
fs.writeFileSync(SUMMARY_FILE, JSON.stringify(summary, null, 2));
|
||||
log(`Summary written to ${SUMMARY_FILE}`);
|
||||
|
||||
log('Done.');
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
runSmokeTest();
|
||||
@@ -94,9 +94,6 @@ function collectUnitTests() {
|
||||
currentError = null;
|
||||
} else {
|
||||
// Capture detail lines
|
||||
// If line is empty or indented, it's likely part of the error
|
||||
// If it's unindented but not a TAP command, it might still be part of it (console logs)
|
||||
// We'll be greedy here.
|
||||
currentError.details.push(sanitize(line));
|
||||
}
|
||||
}
|
||||
@@ -175,28 +172,85 @@ function collectSmokeTests() {
|
||||
const errors = [];
|
||||
if (!fs.existsSync(ARTIFACTS_DIR)) return errors;
|
||||
|
||||
const files = fs.readdirSync(ARTIFACTS_DIR).filter(f => f.startsWith('smoke-report-') && f.endsWith('.json'));
|
||||
// Look for smoke-summary-*.json
|
||||
const files = fs.readdirSync(ARTIFACTS_DIR).filter(f => f.startsWith('smoke-summary-') && f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = JSON.parse(fs.readFileSync(path.join(ARTIFACTS_DIR, file), 'utf8'));
|
||||
if (content.stats && content.stats.failures > 0) {
|
||||
const failedSteps = content.stats.details.filter(d => !d.success);
|
||||
for (const step of failedSteps) {
|
||||
const sanitizedError = sanitize(step.error);
|
||||
errors.push({
|
||||
source: 'smoke-test',
|
||||
title: sanitize(`Smoke Test Failed: ${step.name}`),
|
||||
details: [sanitizedError],
|
||||
stack: sanitizedError,
|
||||
fingerprint: getFingerprint(sanitizedError + step.name),
|
||||
severity: 'Critical',
|
||||
owner: suggestOwner(sanitizedError) || 'QA Team'
|
||||
});
|
||||
}
|
||||
|
||||
// Expected format: { timestamp, status, error, logs }
|
||||
if (content.status === 'failure') {
|
||||
const errorMsg = content.error || 'Unknown Smoke Test Failure';
|
||||
const sanitizedError = sanitize(errorMsg);
|
||||
|
||||
// Combine relevant logs if available for context
|
||||
const logs = (content.logs || [])
|
||||
.map(l => sanitize(l))
|
||||
.join('\n')
|
||||
.substring(0, 2000); // Limit size
|
||||
|
||||
errors.push({
|
||||
source: 'smoke-test',
|
||||
title: `Smoke Test Failed: ${sanitizedError.split('\n')[0]}`,
|
||||
details: content.logs ? content.logs.map(sanitize) : [],
|
||||
stack: sanitizedError + (logs ? '\n\nContext Logs:\n' + logs : ''),
|
||||
fingerprint: getFingerprint(sanitizedError),
|
||||
severity: 'Critical',
|
||||
owner: suggestOwner(sanitizedError) || 'QA Team'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to parse smoke report ${file}:`, err.message);
|
||||
console.warn(`Failed to parse smoke summary ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function collectFuzzReports() {
|
||||
const errors = [];
|
||||
if (!fs.existsSync(ARTIFACTS_DIR)) return errors;
|
||||
|
||||
// Look for fuzz-report-*.json
|
||||
const files = fs.readdirSync(ARTIFACTS_DIR).filter(f => f.startsWith('fuzz-report-') && f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = JSON.parse(fs.readFileSync(path.join(ARTIFACTS_DIR, file), 'utf8'));
|
||||
|
||||
// Support both 'crashes' (fuzz-shared.mjs) and 'issues' (fuzz-lib.mjs)
|
||||
const crashes = content.crashes || content.issues || [];
|
||||
|
||||
for (const crash of crashes) {
|
||||
// Crash structure might vary slightly
|
||||
// fuzz-shared: { error: { message, stack }, reproducer }
|
||||
// fuzz-lib: { message, stack, hash, input }
|
||||
|
||||
let message = crash.message;
|
||||
let stack = crash.stack;
|
||||
|
||||
if (!message && crash.error) {
|
||||
message = crash.error.message;
|
||||
stack = crash.error.stack;
|
||||
}
|
||||
|
||||
if (!message) continue;
|
||||
|
||||
const sanitizedMessage = sanitize(message);
|
||||
const sanitizedStack = sanitize(stack || message);
|
||||
|
||||
errors.push({
|
||||
source: `fuzz-test:${content.target || 'unknown'}`,
|
||||
title: `Fuzz Crash: ${sanitizedMessage}`,
|
||||
details: [sanitizedStack],
|
||||
stack: sanitizedStack,
|
||||
fingerprint: getFingerprint(sanitizedStack),
|
||||
severity: 'High',
|
||||
owner: suggestOwner(sanitizedStack)
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to parse fuzz report ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
@@ -206,10 +260,13 @@ function collectAgentLogs() {
|
||||
const errors = [];
|
||||
if (!fs.existsSync(ARTIFACTS_DIR)) return errors;
|
||||
|
||||
// Look for any .log files in artifacts that aren't the main ones we already checked (if any)
|
||||
// Look for any .log files in artifacts
|
||||
const files = fs.readdirSync(ARTIFACTS_DIR).filter(f => f.endsWith('.log'));
|
||||
|
||||
for (const file of files) {
|
||||
// Avoid reparsing if we have specific logic (though generic is fine for now)
|
||||
if (file === 'test_output.log') continue;
|
||||
|
||||
errors.push(...collectGenericLogs(path.join(ARTIFACTS_DIR, file), `agent-log:${file}`));
|
||||
}
|
||||
return errors;
|
||||
@@ -239,8 +296,6 @@ function aggregate(errors) {
|
||||
grouped[key].sources.add(err.source);
|
||||
// Escalating severity if we see critical
|
||||
if (err.severity === 'Critical') grouped[key].severity = 'Critical';
|
||||
|
||||
// Update title if new one is longer/better (optional, keeping first for now)
|
||||
}
|
||||
|
||||
return Object.values(grouped).sort((a, b) => {
|
||||
@@ -326,7 +381,8 @@ function generateJSONArtifact(aggregates) {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
if (process.env.ENABLE_TELEMETRY !== 'true') {
|
||||
const enabled = process.env.ENABLE_TELEMETRY === 'true' || process.env.ENABLE_TELEMETRY === '1';
|
||||
if (!enabled) {
|
||||
console.log('Telemetry is opt-in. Set ENABLE_TELEMETRY=true to run.');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -354,6 +410,11 @@ async function main() {
|
||||
console.log(`Collected ${smokeTestErrors.length} smoke test errors.`);
|
||||
errors.push(...smokeTestErrors);
|
||||
|
||||
// Fuzz Reports
|
||||
const fuzzErrors = collectFuzzReports();
|
||||
console.log(`Collected ${fuzzErrors.length} fuzz reports.`);
|
||||
errors.push(...fuzzErrors);
|
||||
|
||||
// Agent Logs
|
||||
const agentErrors = collectAgentLogs();
|
||||
console.log(`Collected ${agentErrors.length} agent log errors.`);
|
||||
+330
-205
@@ -1,23 +1,5 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
||||
// Hook console.warn to capture validation warnings
|
||||
const originalWarn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => {
|
||||
// Capture only [schema] or [nostrEventSchemas] warnings
|
||||
const msg = args.join(' ');
|
||||
if (msg.includes('[schema]') || msg.includes('[nostrEventSchemas]')) {
|
||||
warnings.push(msg);
|
||||
}
|
||||
// We can still print to stdout if needed, but for validation we want to capture
|
||||
// originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
// Import schemas AFTER hooking console
|
||||
const schemas = await import('../../js/nostrEventSchemas.js');
|
||||
const {
|
||||
validateEventAgainstSchema,
|
||||
NOTE_TYPES,
|
||||
import {
|
||||
buildVideoPostEvent,
|
||||
buildVideoMirrorEvent,
|
||||
buildRepostEvent,
|
||||
@@ -39,202 +21,345 @@ const {
|
||||
buildSubscriptionListEvent,
|
||||
buildBlockListEvent,
|
||||
buildHashtagPreferenceEvent,
|
||||
buildAdminListEvent
|
||||
} = schemas;
|
||||
buildAdminListEvent,
|
||||
buildHttpAuthEvent,
|
||||
buildReportEvent,
|
||||
validateEventStructure,
|
||||
NOTE_TYPES
|
||||
} from "../../js/nostrEventSchemas.js";
|
||||
|
||||
console.log("Running Event Schema Validator...");
|
||||
const TEST_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const TEST_EVENT_ID = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const TEST_TIMESTAMP = 1700000000;
|
||||
|
||||
const failures = [];
|
||||
const testCases = [
|
||||
{
|
||||
name: "Video Post",
|
||||
builder: buildVideoPostEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
dTagValue: "test-video-d-tag",
|
||||
content: {
|
||||
version: 3,
|
||||
title: "Test Video",
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:example",
|
||||
thumbnail: "https://example.com/thumb.jpg",
|
||||
description: "A test video",
|
||||
mode: "live",
|
||||
videoRootId: "root-id-123",
|
||||
deleted: false,
|
||||
isPrivate: false,
|
||||
isNsfw: false,
|
||||
isForKids: true,
|
||||
enableComments: true,
|
||||
ws: "https://webseed.example.com",
|
||||
xs: "http://xseed.example.com"
|
||||
}
|
||||
},
|
||||
type: NOTE_TYPES.VIDEO_POST
|
||||
},
|
||||
{
|
||||
name: "Video Mirror",
|
||||
builder: buildVideoMirrorEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
tags: [["url", "https://example.com"], ["m", "video/mp4"]],
|
||||
content: "Mirroring video..."
|
||||
},
|
||||
type: NOTE_TYPES.VIDEO_MIRROR
|
||||
},
|
||||
{
|
||||
name: "Repost",
|
||||
builder: buildRepostEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
eventId: TEST_EVENT_ID,
|
||||
eventRelay: "wss://relay.example.com",
|
||||
authorPubkey: TEST_PUBKEY,
|
||||
targetEvent: { id: TEST_EVENT_ID, kind: 1, content: "test" }
|
||||
},
|
||||
type: NOTE_TYPES.REPOST
|
||||
},
|
||||
{
|
||||
name: "Share",
|
||||
builder: buildShareEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
content: "Check this out!",
|
||||
video: { id: TEST_EVENT_ID, pubkey: TEST_PUBKEY },
|
||||
relays: ["wss://relay.example.com"]
|
||||
},
|
||||
type: NOTE_TYPES.SHARE
|
||||
},
|
||||
{
|
||||
name: "Relay List",
|
||||
builder: buildRelayListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
relays: [
|
||||
{ url: "wss://read.example.com", mode: "read" },
|
||||
{ url: "wss://write.example.com", mode: "write" }
|
||||
]
|
||||
},
|
||||
type: NOTE_TYPES.RELAY_LIST
|
||||
},
|
||||
{
|
||||
name: "DM Relay List",
|
||||
builder: buildDmRelayListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
relays: ["wss://dm.example.com"]
|
||||
},
|
||||
type: NOTE_TYPES.DM_RELAY_LIST
|
||||
},
|
||||
{
|
||||
name: "Profile Metadata",
|
||||
builder: buildProfileMetadataEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
metadata: {
|
||||
name: "Alice",
|
||||
about: "Hello world",
|
||||
picture: "https://example.com/pic.jpg"
|
||||
}
|
||||
},
|
||||
type: NOTE_TYPES.PROFILE_METADATA
|
||||
},
|
||||
{
|
||||
name: "Mute List",
|
||||
builder: buildMuteListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
pTags: [TEST_PUBKEY]
|
||||
},
|
||||
type: NOTE_TYPES.MUTE_LIST
|
||||
},
|
||||
{
|
||||
name: "Deletion",
|
||||
builder: buildDeletionEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
eventIds: [TEST_EVENT_ID],
|
||||
reason: "Mistake"
|
||||
},
|
||||
type: NOTE_TYPES.DELETION
|
||||
},
|
||||
{
|
||||
name: "Legacy Direct Message",
|
||||
builder: buildLegacyDirectMessageEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
ciphertext: "secretstuff"
|
||||
},
|
||||
type: NOTE_TYPES.LEGACY_DM
|
||||
},
|
||||
{
|
||||
name: "DM Attachment",
|
||||
builder: buildDmAttachmentEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
attachment: {
|
||||
x: "hashhash",
|
||||
url: "https://example.com/file",
|
||||
name: "file.txt",
|
||||
type: "text/plain",
|
||||
size: 100
|
||||
}
|
||||
},
|
||||
type: NOTE_TYPES.DM_ATTACHMENT
|
||||
},
|
||||
{
|
||||
name: "DM Read Receipt",
|
||||
builder: buildDmReadReceiptEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
eventId: TEST_EVENT_ID
|
||||
},
|
||||
type: NOTE_TYPES.DM_READ_RECEIPT
|
||||
},
|
||||
{
|
||||
name: "DM Typing Indicator",
|
||||
builder: buildDmTypingIndicatorEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
eventId: TEST_EVENT_ID,
|
||||
expiresAt: TEST_TIMESTAMP + 60
|
||||
},
|
||||
type: NOTE_TYPES.DM_TYPING
|
||||
},
|
||||
{
|
||||
name: "View Event",
|
||||
builder: buildViewEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
pointerValue: TEST_EVENT_ID,
|
||||
dedupeTag: "dedupe-123"
|
||||
},
|
||||
type: NOTE_TYPES.VIEW_EVENT
|
||||
},
|
||||
{
|
||||
name: "Zap Request",
|
||||
builder: buildZapRequestEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
relays: ["wss://relay.example.com"],
|
||||
amountSats: 100,
|
||||
lnurl: "lnurl1...",
|
||||
eventId: TEST_EVENT_ID
|
||||
},
|
||||
type: NOTE_TYPES.ZAP_REQUEST
|
||||
},
|
||||
{
|
||||
name: "Reaction",
|
||||
builder: buildReactionEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
pointerValue: TEST_EVENT_ID,
|
||||
targetPointer: { type: "e", value: TEST_EVENT_ID, relay: "wss://relay.example.com" },
|
||||
content: "+"
|
||||
},
|
||||
type: NOTE_TYPES.VIDEO_REACTION
|
||||
},
|
||||
{
|
||||
name: "Comment",
|
||||
builder: buildCommentEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
videoEventId: TEST_EVENT_ID,
|
||||
content: "Nice video!"
|
||||
},
|
||||
type: NOTE_TYPES.VIDEO_COMMENT
|
||||
},
|
||||
{
|
||||
name: "Watch History",
|
||||
builder: buildWatchHistoryEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
monthIdentifier: "2023-11",
|
||||
content: { version: 2, month: "2023-11", items: [] }
|
||||
},
|
||||
type: NOTE_TYPES.WATCH_HISTORY
|
||||
},
|
||||
{
|
||||
name: "Subscription List",
|
||||
builder: buildSubscriptionListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
content: "ciphertext",
|
||||
encryption: "nip04"
|
||||
},
|
||||
type: NOTE_TYPES.SUBSCRIPTION_LIST
|
||||
},
|
||||
{
|
||||
name: "Block List",
|
||||
builder: buildBlockListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
content: "ciphertext",
|
||||
encryption: "nip04"
|
||||
},
|
||||
type: NOTE_TYPES.USER_BLOCK_LIST
|
||||
},
|
||||
{
|
||||
name: "Hashtag Preference",
|
||||
builder: buildHashtagPreferenceEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
content: "ciphertext"
|
||||
},
|
||||
type: NOTE_TYPES.HASHTAG_PREFERENCES
|
||||
},
|
||||
{
|
||||
name: "Admin Moderation List",
|
||||
builder: (params) => buildAdminListEvent("moderation", params),
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
hexPubkeys: [TEST_PUBKEY]
|
||||
},
|
||||
type: NOTE_TYPES.ADMIN_MODERATION_LIST
|
||||
},
|
||||
{
|
||||
name: "HTTP Auth",
|
||||
builder: buildHttpAuthEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
url: "https://auth.example.com",
|
||||
method: "POST",
|
||||
payload: "hash"
|
||||
},
|
||||
type: NOTE_TYPES.HTTP_AUTH
|
||||
},
|
||||
{
|
||||
name: "Report",
|
||||
builder: buildReportEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
eventId: TEST_EVENT_ID,
|
||||
userId: TEST_PUBKEY,
|
||||
reportType: "spam",
|
||||
relayHint: "wss://relay.example.com"
|
||||
},
|
||||
type: NOTE_TYPES.REPORT
|
||||
}
|
||||
];
|
||||
|
||||
function check(name, builder, params, expectedType) {
|
||||
warnings.length = 0; // Clear warnings
|
||||
let hasError = false;
|
||||
|
||||
console.log("Running Nostr Event Builder Validation...\n");
|
||||
|
||||
for (const testCase of testCases) {
|
||||
try {
|
||||
const event = builder(params);
|
||||
validateEventAgainstSchema(expectedType || event.kind, event); // Pass type if known, else relies on implementation or event.kind mapping
|
||||
const event = testCase.builder(testCase.params);
|
||||
const { valid, errors } = validateEventStructure(testCase.type, event);
|
||||
|
||||
// Some builders might not set the type explicitly in validate calls if we don't instrument them yet.
|
||||
// So we manually call validateEventAgainstSchema with the expected NOTE_TYPE.
|
||||
if (expectedType) {
|
||||
validateEventAgainstSchema(expectedType, event);
|
||||
if (valid) {
|
||||
console.log(`✅ ${testCase.name}`);
|
||||
} else {
|
||||
console.error(`❌ ${testCase.name} FAILED validation:`);
|
||||
errors.forEach(err => console.error(` - ${err}`));
|
||||
console.error(" Produced Event:", JSON.stringify(event, null, 2));
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
failures.push({ name, error: `Generated warnings: ${JSON.stringify(warnings)}` });
|
||||
}
|
||||
|
||||
// specific checks can go here
|
||||
if (!event.created_at) failures.push({ name, error: "Missing created_at" });
|
||||
if (!event.pubkey) failures.push({ name, error: "Missing pubkey" });
|
||||
|
||||
} catch (e) {
|
||||
failures.push({ name, error: `Threw exception: ${e.message}` });
|
||||
} catch (error) {
|
||||
console.error(`❌ ${testCase.name} CRASHED:`, error);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const created_at = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Test cases
|
||||
check("buildVideoPostEvent", buildVideoPostEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
dTagValue: "video-123",
|
||||
content: {
|
||||
version: 3,
|
||||
title: "Test Video",
|
||||
videoRootId: "root-123",
|
||||
url: "https://example.com/video.mp4"
|
||||
}
|
||||
}, NOTE_TYPES.VIDEO_POST);
|
||||
|
||||
check("buildVideoMirrorEvent", buildVideoMirrorEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
tags: [["t", "test"]],
|
||||
content: "Mirror description"
|
||||
}, NOTE_TYPES.VIDEO_MIRROR);
|
||||
|
||||
check("buildRepostEvent", buildRepostEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
eventId: "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
eventRelay: "wss://relay.example.com",
|
||||
serializedEvent: JSON.stringify({ id: "111", kind: 1 })
|
||||
}, NOTE_TYPES.REPOST);
|
||||
|
||||
check("buildShareEvent", buildShareEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
content: "Check this out",
|
||||
video: { id: "2222222222222222222222222222222222222222222222222222222222222222" }
|
||||
}, NOTE_TYPES.SHARE);
|
||||
|
||||
check("buildRelayListEvent", buildRelayListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
relays: ["wss://relay.example.com", { url: "wss://read.example.com", mode: "read" }]
|
||||
}, NOTE_TYPES.RELAY_LIST);
|
||||
|
||||
check("buildDmRelayListEvent", buildDmRelayListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
relays: ["wss://dm.example.com"]
|
||||
}, NOTE_TYPES.DM_RELAY_LIST);
|
||||
|
||||
check("buildProfileMetadataEvent", buildProfileMetadataEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
metadata: { name: "Test User", about: "Testing" }
|
||||
}, NOTE_TYPES.PROFILE_METADATA);
|
||||
|
||||
check("buildMuteListEvent", buildMuteListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
pTags: ["3333333333333333333333333333333333333333333333333333333333333333"]
|
||||
}, NOTE_TYPES.MUTE_LIST);
|
||||
|
||||
check("buildDeletionEvent", buildDeletionEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
eventIds: ["4444444444444444444444444444444444444444444444444444444444444444"],
|
||||
reason: "Mistake"
|
||||
}, NOTE_TYPES.DELETION);
|
||||
|
||||
check("buildLegacyDirectMessageEvent", buildLegacyDirectMessageEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "5555555555555555555555555555555555555555555555555555555555555555",
|
||||
ciphertext: "encryped_content"
|
||||
}, NOTE_TYPES.LEGACY_DM);
|
||||
|
||||
check("buildDmAttachmentEvent", buildDmAttachmentEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "6666666666666666666666666666666666666666666666666666666666666666",
|
||||
attachment: { url: "https://example.com/file.jpg", x: "hash" }
|
||||
}, NOTE_TYPES.DM_ATTACHMENT);
|
||||
|
||||
check("buildDmReadReceiptEvent", buildDmReadReceiptEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "7777777777777777777777777777777777777777777777777777777777777777",
|
||||
eventId: "8888888888888888888888888888888888888888888888888888888888888888"
|
||||
}, NOTE_TYPES.DM_READ_RECEIPT);
|
||||
|
||||
check("buildDmTypingIndicatorEvent", buildDmTypingIndicatorEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "9999999999999999999999999999999999999999999999999999999999999999"
|
||||
}, NOTE_TYPES.DM_TYPING);
|
||||
|
||||
check("buildViewEvent", buildViewEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
pointerValue: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
pointerTag: ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
|
||||
dedupeTag: "session-123"
|
||||
}, NOTE_TYPES.VIEW_EVENT);
|
||||
|
||||
check("buildZapRequestEvent", buildZapRequestEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
amountSats: 100,
|
||||
relays: ["wss://zap.relay.com"]
|
||||
}, NOTE_TYPES.ZAP_REQUEST);
|
||||
|
||||
check("buildReactionEvent", buildReactionEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
pointerTag: ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"],
|
||||
content: "+"
|
||||
}, NOTE_TYPES.VIDEO_REACTION);
|
||||
|
||||
check("buildCommentEvent", buildCommentEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
videoEventId: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
content: "Nice video!"
|
||||
}, NOTE_TYPES.VIDEO_COMMENT);
|
||||
|
||||
check("buildWatchHistoryEvent", buildWatchHistoryEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
monthIdentifier: "2023-10",
|
||||
content: JSON.stringify({ version: 2, month: "2023-10", items: [] })
|
||||
}, NOTE_TYPES.WATCH_HISTORY);
|
||||
|
||||
check("buildSubscriptionListEvent", buildSubscriptionListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
content: "encrypted_subs"
|
||||
}, NOTE_TYPES.SUBSCRIPTION_LIST);
|
||||
|
||||
check("buildBlockListEvent", buildBlockListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
content: "encrypted_blocks"
|
||||
}, NOTE_TYPES.USER_BLOCK_LIST);
|
||||
|
||||
check("buildHashtagPreferenceEvent", buildHashtagPreferenceEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
content: "encrypted_prefs"
|
||||
}, NOTE_TYPES.HASHTAG_PREFERENCES);
|
||||
|
||||
check("buildAdminListEvent (moderation)", (p) => buildAdminListEvent("moderation", p), {
|
||||
pubkey,
|
||||
created_at,
|
||||
hexPubkeys: ["eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"]
|
||||
}, NOTE_TYPES.ADMIN_MODERATION_LIST);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("\n❌ Validation Failed:");
|
||||
failures.forEach(f => {
|
||||
console.error(`- ${f.name}: ${f.error}`);
|
||||
});
|
||||
if (hasError) {
|
||||
console.log("\nValidation FAILED.");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("\n✅ All events validated successfully.");
|
||||
console.log("\nAll builders produced valid events.");
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import path from 'node:path';
|
||||
const SENSITIVE_PATHS = [
|
||||
/^js\/nostr\//,
|
||||
/^js\/storage\//,
|
||||
/^js\/dmDecryptor\.js$/
|
||||
/^js\/dmDecryptor\.js$/,
|
||||
/^js\/services\/storageService\.js$/,
|
||||
/^js\/services\/attachmentService\.js$/,
|
||||
/^js\/services\/hashtagPreferencesService\.js$/
|
||||
];
|
||||
|
||||
function runCommand(command, args = [], options = {}) {
|
||||
@@ -65,6 +68,22 @@ function getChangedFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
function checkReleaseChannel(changedFiles) {
|
||||
const baseRef = process.env.GITHUB_BASE_REF || 'main';
|
||||
const warnings = [];
|
||||
|
||||
if (baseRef === 'main') {
|
||||
const criticalFiles = ['js/constants.js', 'config/instance-config.js'];
|
||||
const modifiedCriticalFiles = changedFiles.filter(f => criticalFiles.includes(f));
|
||||
|
||||
if (modifiedCriticalFiles.length > 0) {
|
||||
warnings.push(`⚠️ **Release Channel Warning**: This PR targets \`main\` but modifies the following critical configuration files: ${modifiedCriticalFiles.map(f => `\`${f}\``).join(', ')}.`);
|
||||
warnings.push(`> **Guidance:** \`main\` is the production track. Ensure feature flags in \`js/constants.js\` are set to safe defaults (usually \`false\`) and \`config/instance-config.js\` has production-appropriate settings (e.g. \`IS_DEV_MODE\`).`);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting Automated PR Review...');
|
||||
let commentBody = '## 🤖 Automated PR Review\n\n';
|
||||
@@ -118,6 +137,13 @@ async function main() {
|
||||
SENSITIVE_PATHS.some(pattern => pattern.test(file))
|
||||
);
|
||||
|
||||
// Release Channel Check
|
||||
const channelWarnings = checkReleaseChannel(changedFiles);
|
||||
if (channelWarnings.length > 0) {
|
||||
commentBody += '### 🚀 Release Channel Checks\n\n';
|
||||
channelWarnings.forEach(w => commentBody += `${w}\n\n`);
|
||||
}
|
||||
|
||||
if (sensitiveChanges.length > 0) {
|
||||
commentBody += '### 🛡️ Guardrails\n\n';
|
||||
commentBody += 'This PR modifies sensitive files. **Security and Protocol Review Required.**\n';
|
||||
@@ -127,6 +153,17 @@ async function main() {
|
||||
commentBody += '\n</details>\n\n';
|
||||
}
|
||||
|
||||
// Audit Log
|
||||
commentBody += '### 📝 Audit Log\n\n';
|
||||
commentBody += '| Check | Status | Details |\n';
|
||||
commentBody += '| :--- | :--- | :--- |\n';
|
||||
commentBody += `| **Formatting** | ${hasFormatChanges ? '⚠️ Changes' : '✅ Pass'} | ${hasFormatChanges ? 'Fixes applied/suggested' : 'No issues'} |\n`;
|
||||
commentBody += `| **Lint** | ${lintRes.status === 0 ? '✅ Pass' : '⚠️ Warnings'} | ${lintRes.status === 0 ? '-' : 'See output above'} |\n`;
|
||||
commentBody += `| **Unit Tests** | ${testRes.status === 0 ? '✅ Pass' : '❌ Fail'} | ${testRes.status === 0 ? '-' : 'See failures above'} |\n`;
|
||||
commentBody += `| **Security** | ${sensitiveChanges.length > 0 ? '🛡️ Review Req' : '✅ Pass'} | ${sensitiveChanges.length > 0 ? `${sensitiveChanges.length} sensitive files` : 'No sensitive files'} |\n`;
|
||||
commentBody += `| **Release Channel** | ${channelWarnings.length > 0 ? '⚠️ Warning' : '✅ Pass'} | ${channelWarnings.length > 0 ? 'Critical config modified' : 'Safe'} |\n`;
|
||||
commentBody += '\n';
|
||||
|
||||
// 6. Post Comment (if in CI)
|
||||
if (process.env.GITHUB_TOKEN && process.env.PR_NUMBER) {
|
||||
|
||||
@@ -142,8 +179,35 @@ async function main() {
|
||||
|
||||
commentBody += '> **✅ Micro-fixes applied:** Formatting fixes have been committed to this branch.\n\n';
|
||||
} catch (e) {
|
||||
console.error('Failed to auto-commit/push:', e.message);
|
||||
commentBody += '> **ℹ️ Micro-fixes available:** Formatting issues were found, but I could not automatically commit the fixes (likely due to permissions on a fork). Please run `npm run format` locally and push.\n\n';
|
||||
console.error('Failed to push to PR branch:', e.message);
|
||||
|
||||
// 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}.`;
|
||||
|
||||
const ghCmd = ['pr', 'create', '--base', baseRef, '--head', fixBranchName, '--title', prTitle, '--body', prBody];
|
||||
const ghRes = spawnSync('gh', ghCmd, { encoding: 'utf-8' });
|
||||
|
||||
if (ghRes.status === 0) {
|
||||
const newPrUrl = ghRes.stdout.trim();
|
||||
commentBody += `> **ℹ️ Micro-fixes available:** I could not push to your branch, but I have created a follow-up PR with the fixes: ${newPrUrl}\n\n`;
|
||||
} else {
|
||||
throw new Error(`gh pr create failed: ${ghRes.stderr}`);
|
||||
}
|
||||
|
||||
} catch (fallbackError) {
|
||||
console.error('Failed to create follow-up PR:', fallbackError.message);
|
||||
commentBody += '> **ℹ️ Micro-fixes available:** Formatting issues were found. Please run `npm run format` locally and push.\n\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user