Merge branch 'unstable' into ai/tests-nostr-state-20250522-1996916389475353061

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-26 06:52:38 -05:00
commit 88ac663f18
25 files changed
+880 -648

No files matched your search

+1
View File
@@ -124,6 +124,7 @@ To set up the project locally:
npm run format
npm run lint
```
*Note: `npm run format` currently only targets CSS, HTML, and Markdown files.*
5. **Git Hooks (Optional)**:
We provide a script to set up a git pre-commit hook that runs linting and CSS builds automatically before you commit.
+1 -1
View File
@@ -262,7 +262,7 @@ package scripts to keep formatting, linting, and generated output consistent:
```bash
npm install # install Prettier, Stylelint, and Tailwind toolchain
npm run format # format CSS/HTML/JS/MD with Prettier + tailwindcss plugin
npm run format # format CSS/HTML/MD with Prettier + tailwindcss plugin
npm run lint # run CSS, hex color, inline-style, design-token, and Tailwind color/bracket guards in one pass
npm run lint:css # enforce token usage and forbid raw hex colors
npm run lint:inline-styles # fail CI if inline style attributes or element.style usage slip in
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+10 -4
View File
@@ -25,20 +25,26 @@ To allow your browser to upload files directly to Cloudflare, you must allow Cro
1. Still in your bucket's **Settings** tab, scroll down to **"CORS Policy"**.
2. Click **"Add CORS Policy"** (or Edit).
3. Paste the following JSON configuration:
3. Paste the following JSON configuration (update the origins to match your dev/prod domains):
```json
[
{
"AllowedOrigins": ["*"],
"AllowedOrigins": ["http://127.0.0.1:5500", "https://bitvid.network"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": ["*"],
"AllowedHeaders": [
"authorization",
"content-type",
"x-amz-*",
"x-amz-content-sha256",
"x-amz-date"
],
"ExposeHeaders": ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
"MaxAgeSeconds": 3600
}
]
```
> **Note:** You can replace `"*"` in `AllowedOrigins` with your specific domain (e.g., `["https://bitvid.network"]`) for tighter security, but `"*"` is easiest for getting started.
> **Note:** Replace the `AllowedOrigins` values with the exact origins you use in development and production. This CORS policy must be configured on the **R2 bucket’s CORS settings** for the **S3 endpoint** (not just the public `r2.dev` URL), since uploads use the S3 API.
4. Click **"Save"**.
+1
View File
@@ -148,6 +148,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `ws`, `xs`) |
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | JSON-serialized event being reposted (or empty if unavailable) |
| Generic Repost (`NOTE_TYPES.GENERIC_REPOST`) | `16` | Same as Repost; specific for non-text kinds (e.g. videos). | Same as Repost. |
| Share note (`NOTE_TYPES.SHARE`) | `1` | `['e', <video id>, '', 'mention']` plus `['p', <video pubkey>, '', 'mention']` when available; repeating `['r', <relay url>, <read/write?>]` tags for relay hints; inherits schema append tags | Plain text share content from the compose modal |
| Video reaction (`NOTE_TYPES.VIDEO_REACTION`) | `7` | `['e', <event id>, <relay?>]` or `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <author pubkey>]` | Reaction content (e.g. `+`, `-`, or emoji) |
| Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | NIP-22 root scope tags `['A'\|`E`\|`I`, <pointer>, <relay?>?], `['K', <root kind>]`, and `['P', <root author pubkey>, <relay?>?]` plus parent metadata `['E'\|`I`, <parent pointer>, <relay?>?, <author?>], `['K', <parent kind>]`, `['P', <parent author>, <relay?>?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 |
+7
View File
@@ -0,0 +1,7 @@
# Upgrade cross-env
- **Current Version:** 7.0.3
- **Latest Version:** 10.1.0
**Status:** Major Upgrade Required
**Reason:** Major version bump available. Requires strict testing and separate PR.
+7
View File
@@ -0,0 +1,7 @@
# Upgrade esbuild
- **Current Version:** 0.25.12
- **Latest Version:** 0.27.2
**Status:** Major Upgrade Required
**Reason:** 0.x version bump may contain breaking changes. Requires strict testing and separate PR.
+7
View File
@@ -0,0 +1,7 @@
# Upgrade jsdom
- **Current Version:** 24.1.3
- **Latest Version:** 27.4.0
**Status:** Major Upgrade Required
**Reason:** Major version bump available. Requires strict testing and separate PR.
+8
View File
@@ -0,0 +1,8 @@
# Upgrade nostr-tools
- **Current Version:** 2.19.4
- **Wanted Version:** 2.20.0
- **Latest Version:** 2.20.0
**Status:** Skipped (Risky/Protocol)
**Reason:** This is a cryptographic/protocol library. As per security guardrails, manual review is required before upgrading.
+7
View File
@@ -0,0 +1,7 @@
# Upgrade pixelmatch
- **Current Version:** 5.3.0
- **Latest Version:** 7.1.0
**Status:** Major Upgrade Required
**Reason:** Major version bump available. Requires strict testing and separate PR.
@@ -0,0 +1,7 @@
# Upgrade prettier-plugin-tailwindcss
- **Current Version:** 0.6.14
- **Latest Version:** 0.7.2
**Status:** Major Upgrade Required
**Reason:** 0.x version bump may contain breaking changes. Requires strict testing and separate PR.
+7
View File
@@ -0,0 +1,7 @@
# Upgrade stylelint
- **Current Version:** 16.26.1
- **Latest Version:** 17.0.0
**Status:** Major Upgrade Required
**Reason:** Major version bump available. Requires strict testing and separate PR.
+7
View File
@@ -0,0 +1,7 @@
# Upgrade tailwindcss
- **Current Version:** 3.4.19
- **Latest Version:** 4.1.18
**Status:** Major Upgrade Required
**Reason:** Major version bump available (v4). Requires strict testing, migration guide adherence, and separate PR.
+34 -75
View File
@@ -112,6 +112,7 @@ import {
closeStaticModal,
} from "./ui/components/staticModalAccessibility.js";
import LoginModalController from "./ui/loginModalController.js";
import EditModalController from "./ui/editModalController.js";
import TagPreferenceMenuController from "./ui/tagPreferenceMenuController.js";
import ReactionController from "./ui/reactionController.js";
import { pointerArrayToKey } from "./utils/pointer.js";
@@ -390,6 +391,35 @@ class Application {
this.initializeModerationActionController();
this.initializeSimilarContentController();
this.editModalController = new EditModalController({
services: {
nostrService: {
fetchVideos: (...args) => this.nostrService.fetchVideos(...args),
handleEditVideoSubmit: (...args) =>
this.nostrService.handleEditVideoSubmit(...args),
},
},
state: {
getPubkey: () => this.pubkey,
getBlacklistedEventIds: () => this.blacklistedEventIds,
getVideosMap: () => this.videosMap,
},
ui: {
getEditModal: () => this.editModal,
showError: (msg) => this.showError(msg),
showSuccess: (msg) => this.showSuccess(msg),
},
callbacks: {
loadVideos: () => this.loadVideos(),
forceRefreshAllProfiles: () => this.forceRefreshAllProfiles(),
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
},
helpers: {
normalizeActionTarget: (t) => this.normalizeActionTarget(t),
resolveVideoActionTarget: (opts) => this.resolveVideoActionTarget(opts),
},
});
this.handleShareNostrSignerChange = () => {
this.updateShareNostrAuthState({ reason: "signer-change" });
};
@@ -7775,85 +7805,14 @@ class Application {
* Handle "Edit Video" from gear menu.
*/
async handleEditModalSubmit(event) {
const detail = event?.detail || {};
const { originalEvent, updatedData } = detail;
if (!originalEvent || !updatedData) {
return;
}
if (!this.pubkey) {
this.showError("Please login to edit videos.");
if (this.editModal?.setSubmitState) {
this.editModal.setSubmitState({ pending: false });
}
return;
}
try {
await this.nostrService.handleEditVideoSubmit({
originalEvent,
updatedData,
pubkey: this.pubkey,
});
await this.loadVideos();
this.videosMap.clear();
this.showSuccess("Video updated successfully!");
if (this.editModal?.setSubmitState) {
this.editModal.setSubmitState({ pending: false });
}
this.editModal.close();
this.forceRefreshAllProfiles();
} catch (error) {
devLogger.error("Failed to edit video:", error);
this.showError("Failed to edit video. Please try again.");
if (this.editModal?.setSubmitState) {
this.editModal.setSubmitState({ pending: false });
}
if (this.editModalController) {
return this.editModalController.handleSubmit(event);
}
}
async handleEditVideo(target) {
try {
const normalizedTarget = this.normalizeActionTarget(target);
const { triggerElement } = normalizedTarget;
const latestVideos = await this.nostrService.fetchVideos({
blacklistedEventIds: this.blacklistedEventIds,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
});
const video = await this.resolveVideoActionTarget({
...normalizedTarget,
preloadedList: latestVideos,
});
// 2) Basic ownership checks
if (!this.pubkey) {
this.showError("Please login to edit videos.");
return;
}
const userPubkey = (this.pubkey || "").toLowerCase();
const videoPubkey = (video?.pubkey || "").toLowerCase();
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
this.showError("You do not own this video.");
return;
}
try {
await this.editModal.load();
} catch (error) {
devLogger.error("Failed to load edit modal:", error);
this.showError(`Failed to initialize edit modal: ${error.message}`);
return;
}
try {
await this.editModal.open(video, { triggerElement });
} catch (error) {
devLogger.error("Failed to open edit modal:", error);
this.showError("Edit modal is not available right now.");
}
} catch (err) {
devLogger.error("Failed to edit video:", err);
this.showError("Failed to edit video. Please try again.");
if (this.editModalController) {
return this.editModalController.open(target);
}
}
+12 -1
View File
@@ -16,6 +16,7 @@ export const NOTE_TYPES = Object.freeze({
NIP71_VIDEO: "nip71Video",
NIP71_SHORT_VIDEO: "nip71ShortVideo",
REPOST: "repost",
GENERIC_REPOST: "genericRepost",
SHARE: "share",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
@@ -306,6 +307,15 @@ const BASE_SCHEMAS = {
description: "Content field contains the JSON-serialized event being reposted (or empty if unavailable).",
},
},
[NOTE_TYPES.GENERIC_REPOST]: {
type: NOTE_TYPES.GENERIC_REPOST,
label: "Generic repost",
kind: 16,
appendTags: DEFAULT_APPEND_TAGS,
content: {
description: "Content field contains the JSON-serialized event being reposted (or empty if unavailable).",
},
},
[NOTE_TYPES.SHARE]: {
type: NOTE_TYPES.SHARE,
label: "Share note",
@@ -1172,7 +1182,8 @@ export function buildRepostEvent(params) {
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.REPOST, event);
const type = event.kind === 16 ? NOTE_TYPES.GENERIC_REPOST : NOTE_TYPES.REPOST;
validateEventAgainstSchema(type, event);
}
return event;
+119
View File
@@ -0,0 +1,119 @@
import { devLogger } from "../utils/logger.js";
export default class EditModalController {
constructor({ services, state, ui, callbacks, helpers }) {
this.services = services;
this.state = state;
this.ui = ui;
this.callbacks = callbacks;
this.helpers = helpers;
}
async open(target) {
try {
const normalizedTarget = this.helpers.normalizeActionTarget(target);
const { triggerElement } = normalizedTarget;
const blacklistedEventIds = this.state.getBlacklistedEventIds();
const isAuthorBlocked = this.callbacks.isAuthorBlocked;
const latestVideos = await this.services.nostrService.fetchVideos({
blacklistedEventIds,
isAuthorBlocked: (pubkey) => isAuthorBlocked(pubkey),
});
const video = await this.helpers.resolveVideoActionTarget({
...normalizedTarget,
preloadedList: latestVideos,
});
// 2) Basic ownership checks
const pubkey = this.state.getPubkey();
if (!pubkey) {
this.ui.showError("Please login to edit videos.");
return;
}
const userPubkey = (pubkey || "").toLowerCase();
const videoPubkey = (video?.pubkey || "").toLowerCase();
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
this.ui.showError("You do not own this video.");
return;
}
const editModal = this.ui.getEditModal();
if (!editModal) {
this.ui.showError("Edit modal is not available right now.");
return;
}
try {
await editModal.load();
} catch (error) {
devLogger.error("Failed to load edit modal:", error);
this.ui.showError(`Failed to initialize edit modal: ${error.message}`);
return;
}
try {
await editModal.open(video, { triggerElement });
} catch (error) {
devLogger.error("Failed to open edit modal:", error);
this.ui.showError("Edit modal is not available right now.");
}
} catch (err) {
devLogger.error("Failed to edit video:", err);
this.ui.showError("Failed to edit video. Please try again.");
}
}
async handleSubmit(event) {
const detail = event?.detail || {};
const { originalEvent, updatedData } = detail;
if (!originalEvent || !updatedData) {
return;
}
const pubkey = this.state.getPubkey();
const editModal = this.ui.getEditModal();
if (!pubkey) {
this.ui.showError("Please login to edit videos.");
if (editModal?.setSubmitState) {
editModal.setSubmitState({ pending: false });
}
return;
}
try {
await this.services.nostrService.handleEditVideoSubmit({
originalEvent,
updatedData,
pubkey,
});
await this.callbacks.loadVideos();
const videosMap = this.state.getVideosMap();
if (videosMap) {
videosMap.clear();
}
this.ui.showSuccess("Video updated successfully!");
if (editModal?.setSubmitState) {
editModal.setSubmitState({ pending: false });
}
if (typeof editModal.close === "function") {
editModal.close();
}
this.callbacks.forceRefreshAllProfiles();
} catch (error) {
devLogger.error("Failed to edit video:", error);
this.ui.showError("Failed to edit video. Please try again.");
if (editModal?.setSubmitState) {
editModal.setSubmitState({ pending: false });
}
}
}
}
+6
View File
@@ -2896,6 +2896,7 @@ export class ProfileModalController {
if (enabled && !relayHints.length) {
this.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
{ autoHideMs: 5000 },
);
}
@@ -3513,6 +3514,7 @@ export class ProfileModalController {
if (useNip17 && !recipientRelayHints.length) {
this.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
{ autoHideMs: 5000 },
);
}
@@ -3568,6 +3570,7 @@ export class ProfileModalController {
if (result?.warning === "dm-relays-fallback") {
this.showStatus(
"Privacy warning: this message used default relays because no NIP-17 relay list was found.",
{ autoHideMs: 5000 },
);
}
void this.populateProfileMessages({ force: true, reason: "send-message" });
@@ -4740,6 +4743,7 @@ export class ProfileModalController {
if (useNip17 && !recipientRelayHints.length) {
this.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
{ autoHideMs: 5000 },
);
}
@@ -4768,6 +4772,7 @@ export class ProfileModalController {
if (result?.warning === "dm-relays-fallback") {
this.showStatus(
"Privacy warning: this message used default relays because no NIP-17 relay list was found.",
{ autoHideMs: 5000 },
);
}
if (
@@ -5045,6 +5050,7 @@ export class ProfileModalController {
this.showStatus(
"Privacy warning: direct messages are using your default relays because no NIP-17 relay list is available.",
{ autoHideMs: 5000 },
);
}
+163 -140
View File
@@ -1,173 +1,196 @@
import { WebSocket } from 'ws';
global.WebSocket = WebSocket;
import WebSocket from 'ws';
import crypto from 'node:crypto';
if (!global.crypto) {
global.crypto = crypto;
}
import "fake-indexeddb/auto";
import { startRelay } from './simple-relay.mjs';
import { NostrClient } from '../../js/nostr/client.js';
import * as NostrTools from 'nostr-tools';
import { buildVideoPostEvent } from '../../js/nostrEventSchemas.js';
// Setup environment for NostrClient which might expect browser globals
if (!global.window) {
global.window = global;
}
if (!global.localStorage) {
global.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
// Polyfills
global.WebSocket = WebSocket;
// Node 19+ has global.crypto. If not, polyfill it.
if (!global.crypto) {
global.crypto = crypto;
}
global.window = {
crypto: global.crypto,
localStorage: {
_store: new Map(),
getItem: (key) => global.window.localStorage._store.get(String(key)) || null,
setItem: (key, value) => global.window.localStorage._store.set(String(key), String(value)),
removeItem: (key) => global.window.localStorage._store.delete(String(key)),
clear: () => global.window.localStorage._store.clear(),
},
location: { href: 'http://localhost' },
};
global.self = global.window;
global.localStorage = global.window.localStorage;
globalThis.localStorage = global.window.localStorage; // Ensure globalThis has it
global.NostrTools = NostrTools;
function bytesToHex(bytes) {
return Buffer.from(bytes).toString('hex');
}
// Helpers
const bytesToHex = (bytes) => {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};
async function runInteropTests() {
console.log("Starting Interop Tests...");
async function runTests() {
console.log('Starting Protocol & Interop Tests...');
// 1. Start Relay
const PORT = 8899;
let relay;
try {
relay = startRelay(PORT);
} catch (e) {
console.error("Failed to start relay:", e);
process.exit(1);
}
// Import application modules after polyfills are set
const { NostrClient } = await import('../../js/nostr/client.js');
const { buildVideoPostEvent, buildViewEvent } = await import('../../js/nostrEventSchemas.js');
const RELAY_URL = `ws://localhost:${PORT}`;
console.log(`Relay started at ${RELAY_URL}`);
// 1. Setup Client
const client = new NostrClient();
// Use test relays
const TEST_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
];
client.relays = TEST_RELAYS;
client.readRelays = TEST_RELAYS;
client.writeRelays = TEST_RELAYS;
console.log('Client initialized with relays:', TEST_RELAYS);
// 2. Generate Ephemeral Keys
const sk = NostrTools.generateSecretKey();
const skHex = bytesToHex(sk);
const pk = NostrTools.getPublicKey(sk);
console.log('Generated ephemeral identity:', pk);
// 3. Register Signer
await client.registerPrivateKeySigner({
privateKey: skHex,
pubkey: pk,
});
console.log('Signer registered.');
// 4. Initialize Network
await client.init();
console.log('Client connected.');
try {
// 2. Initialize Client
const client = new NostrClient();
// --- Test A: Video Post ---
console.log('\n--- Test A: Video Post ---');
// Override relays to only use our local test relay
client.relays = [RELAY_URL];
client.readRelays = [RELAY_URL];
client.writeRelays = [RELAY_URL];
console.log("Initializing NostrClient...");
await client.init();
console.log("NostrClient initialized.");
// 3. Generate Ephemeral Keys
const secretKey = NostrTools.generateSecretKey();
const privateKey = bytesToHex(secretKey);
const pubkey = NostrTools.getPublicKey(secretKey);
console.log(`Generated Ephemeral Keys: Pubkey: ${pubkey}`);
// 4. Register Signer
await client.registerPrivateKeySigner({
privateKey: privateKey,
pubkey: pubkey,
persist: false
});
console.log("Signer registered.");
// 5. Test 1: Publish Video Post
console.log("\n--- Test 1: Publish Video Post ---");
const dTagValue = `d-${Date.now()}`;
const videoContent = {
version: 3,
title: "Interop Test Video",
description: "Testing interoperability with ephemeral keys",
url: "https://example.com/video.mp4",
magnet: "magnet:?xt=urn:btih:d435345c2345d345345&dn=video",
isForKids: false,
isNsfw: false,
enableComments: true,
videoRootId: `root-${Date.now()}`,
mode: "live"
// Explicitly use schema builder
const dTagValue = 'interop-test-' + Date.now();
const contentPayload = {
version: 3,
title: 'Interop Test Video ' + Date.now(),
description: 'This is an automated interoperability test event.',
url: 'https://example.com/video.mp4',
magnet: 'magnet:?xt=urn:btih:c9e15763f722f23e98cb6d93612d78af6e827cba&dn=test',
thumbnail: 'https://example.com/thumb.jpg',
mode: 'dev',
videoRootId: dTagValue, // Simple root ID for testing
deleted: false,
isPrivate: false,
isNsfw: false,
isForKids: false,
enableComments: true,
};
const videoEvent = buildVideoPostEvent({
pubkey: pubkey,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
dTagValue: dTagValue,
content: videoContent
content: contentPayload,
});
const { signedEvent: publishResult } = await client.signAndPublishEvent(videoEvent);
console.log(`Video Published. Event ID: ${publishResult.id}`);
console.log('Publishing video event built from schema...');
// Use signAndPublishEvent directly
const { signedEvent } = await client.signAndPublishEvent(videoEvent);
console.log('Published event ID:', signedEvent.id);
// Verify
// Wait a bit for relay to process (though simple-relay is synchronous mostly)
await new Promise(r => setTimeout(r, 100));
const fetchedEvent = await client.getEventById(publishResult.id);
if (!fetchedEvent) throw new Error("Failed to fetch published video event.");
console.log("Video Event fetched successfully.");
if (fetchedEvent.title !== videoContent.title) {
console.error("Fetched:", fetchedEvent);
throw new Error(`Content verification failed: Title mismatch. Expected ${videoContent.title}, got ${fetchedEvent.title}`);
}
console.log("Video Content verified.");
// 6. Test 2: Publish View Event
console.log("\n--- Test 2: Publish View Event ---");
const videoPointer = {
type: 'e',
value: publishResult.id,
relay: RELAY_URL
};
const viewResult = await client.publishViewEvent(videoPointer);
console.log(`View Event Published. Event ID: ${viewResult.id}`);
// 7. Test 3: Direct Message
console.log("\n--- Test 3: Direct Message ---");
const recipientSecret = NostrTools.generateSecretKey();
const recipientPubkey = NostrTools.getPublicKey(recipientSecret);
const recipientNpub = NostrTools.nip19.npubEncode(recipientPubkey);
const message = "Hello from Interop Test";
console.log(`Sending DM to ${recipientNpub}...`);
const dmResult = await client.sendDirectMessage(recipientNpub, message);
if (!dmResult.ok) throw new Error(`DM Send Failed: ${dmResult.error}`);
console.log("DM Sent.");
console.log("Listing DMs...");
await new Promise(r => setTimeout(r, 500));
const dms = await client.listDirectMessages(recipientPubkey);
// Note: listDirectMessages returns decrypted messages.
// As the sender, we should be able to decrypt our own sent message (NIP-04/17).
const foundDm = dms.find(dm => dm.plaintext === message);
if (foundDm) {
console.log("DM found and content matches.");
console.log('Fetching event back...');
const fetchedEvent = await client.getEventById(signedEvent.id);
if (fetchedEvent && fetchedEvent.id === signedEvent.id) {
console.log('✅ Video event fetched and verified.');
if (fetchedEvent.title === contentPayload.title) {
console.log('✅ Content matches.');
} else {
console.error('❌ Content mismatch:', fetchedEvent.title, 'vs', contentPayload.title);
}
} else {
console.log("DMs found count:", dms.length);
if (dms.length > 0) console.log("First DM:", dms[0]);
throw new Error("Sent DM not found in list.");
console.error('❌ Failed to fetch video event.');
}
console.log("\nAll Tests Passed Successfully.");
// --- Test B: View Event ---
console.log('\n--- Test B: View Event ---');
// Explicitly use schema builder
const viewEvent = buildViewEvent({
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
pointerTag: ['e', signedEvent.id],
content: 'test-view-interop'
});
console.log('Publishing view event built from schema...');
try {
const { signedEvent: signedView } = await client.signAndPublishEvent(viewEvent);
console.log('✅ View event published.', signedView.id);
} catch (err) {
console.error('❌ View event publish failed:', err);
}
// --- Test C: Direct Message ---
console.log('\n--- Test C: Direct Message ---');
const recipientSk = NostrTools.generateSecretKey();
const recipientPk = NostrTools.getPublicKey(recipientSk);
const recipientNpub = NostrTools.nip19.npubEncode(recipientPk);
console.log('Generated recipient:', recipientPk);
const dmMessage = 'Hello from Interop Test ' + Date.now();
console.log('Sending DM to recipient...');
// We use client.sendDirectMessage as it encapsulates encryption logic (NIP-04/17)
// which is part of the protocol verification we want to test.
const dmResult = await client.sendDirectMessage(recipientNpub, dmMessage);
if (dmResult.ok) {
console.log('✅ DM sent successfully.');
} else {
console.error('❌ DM send failed:', dmResult);
}
// Verification - list messages
console.log('Listing DMs...');
// Allow some time for propagation
await new Promise(r => setTimeout(r, 2000));
const messages = await client.listDirectMessages();
const found = messages.find(m => m.plaintext === dmMessage);
if (found) {
console.log('✅ DM found and decrypted.');
} else {
console.error('❌ DM not found in list or decryption failed.');
console.log('Messages found:', messages.length);
if (messages.length > 0) {
console.log('First message plain:', messages[0].plaintext);
}
}
} catch (error) {
console.error("Test Failed:", error);
if (error.cause) console.error("Cause:", error.cause);
console.error('Test failed with error:', error);
process.exit(1);
} finally {
if (relay && relay.close) {
await relay.close();
// Cleanup
console.log('\nCleaning up...');
if (client.pool) {
try {
if (typeof client.pool.close === 'function') {
client.pool.close(client.relays);
}
} catch (e) {
console.warn('Error closing pool:', e);
}
}
process.exit(0);
}
}
runInteropTests();
runTests();
+6 -2
View File
@@ -38,7 +38,7 @@ import http from 'http';
const ARTIFACTS_DIR = 'artifacts';
const LOG_FILE = path.join(ARTIFACTS_DIR, `smoke-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.log`);
const REPORT_FILE = path.join(ARTIFACTS_DIR, 'smoke-report.json');
const REPORT_FILE = path.join(ARTIFACTS_DIR, `smoke-summary-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
const UI_SCREENSHOT = path.join(ARTIFACTS_DIR, 'smoke-ui.png');
if (!fs.existsSync(ARTIFACTS_DIR)) {
@@ -285,12 +285,16 @@ async function runSmokeTest() {
} catch (error) {
log(`Smoke Test Failed: ${error.stack}`, 'ERROR');
summary.status = "FAIL";
summary.status = "failure";
summary.error = error.message;
exitCode = 1;
} finally {
// --- Write Report ---
try {
if (fs.existsSync(LOG_FILE)) {
const logs = fs.readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean);
summary.logs = logs;
}
fs.writeFileSync(REPORT_FILE, JSON.stringify(summary, null, 2));
log(`Summary written to ${REPORT_FILE}`);
} catch (e) {
+70 -3
View File
@@ -61,9 +61,13 @@ function suggestOwner(stack) {
function collectUnitTests(logFile = 'test_output.log') {
const errors = [];
if (!fs.existsSync(logFile)) return errors;
// Check root and artifacts dir
const potentialPaths = [logFile, path.join(ARTIFACTS_DIR, logFile)];
const filepath = potentialPaths.find(p => fs.existsSync(p));
const content = fs.readFileSync(logFile, 'utf8');
if (!filepath) return errors;
const content = fs.readFileSync(filepath, 'utf8');
const lines = content.split('\n');
let currentError = null;
let insideErrorBlock = false;
@@ -182,7 +186,7 @@ function collectSmokeTests() {
const content = JSON.parse(fs.readFileSync(path.join(ARTIFACTS_DIR, file), 'utf8'));
// Expected format: { timestamp, status, error, logs }
if (content.status === 'failure') {
if (content.status && (content.status.toLowerCase() === 'failure' || content.status.toLowerCase() === 'fail')) {
const errorMsg = content.error || 'Unknown Smoke Test Failure';
const sanitizedError = sanitize(errorMsg);
@@ -258,6 +262,62 @@ function collectFuzzReports() {
return errors;
}
function collectLoadTests() {
const errors = [];
if (!fs.existsSync(ARTIFACTS_DIR)) return errors;
const files = fs.readdirSync(ARTIFACTS_DIR).filter(f => f.startsWith('load-report-') && f.endsWith('.json'));
// Also check load-report.json
if (fs.existsSync(path.join(ARTIFACTS_DIR, 'load-report.json'))) {
files.push('load-report.json');
}
const uniqueFiles = [...new Set(files)];
for (const file of uniqueFiles) {
try {
const content = JSON.parse(fs.readFileSync(path.join(ARTIFACTS_DIR, file), 'utf8'));
// Check for explicit errors
if (content.metrics && content.metrics.errors > 0) {
const errorBreakdown = content.metrics.error_breakdown ? JSON.stringify(content.metrics.error_breakdown, null, 2) : 'No breakdown';
const sanitizedBreakdown = sanitize(errorBreakdown);
errors.push({
source: `load-test:${file}`,
title: `Load Test Errors: ${content.metrics.errors} failures`,
details: [sanitizedBreakdown],
stack: `Load Test Errors:\n${sanitizedBreakdown}`,
fingerprint: getFingerprint(`load-test-errors-${sanitizedBreakdown}`),
severity: 'High',
owner: 'P2P Team'
});
}
// Check for bottlenecks
if (content.bottlenecks && content.bottlenecks.length > 0) {
for (let i = 0; i < content.bottlenecks.length; i++) {
const bn = content.bottlenecks[i];
const rem = content.remediation ? content.remediation[i] : '';
const sanitizedBn = sanitize(bn);
errors.push({
source: `load-test:${file}`,
title: `Load Test Bottleneck: ${sanitizedBn}`,
details: [sanitize(rem)],
stack: `Bottleneck: ${sanitizedBn}\nRemediation: ${sanitize(rem)}`,
fingerprint: getFingerprint(`load-test-bottleneck-${sanitizedBn}`),
severity: 'Medium',
owner: 'P2P Team'
});
}
}
} catch (err) {
console.warn(`Failed to parse load report ${file}:`, err.message);
}
}
return errors;
}
function collectAgentLogs() {
const errors = [];
if (!fs.existsSync(ARTIFACTS_DIR)) return errors;
@@ -419,6 +479,13 @@ async function main() {
errors.push(...smokeTestErrors);
}
// Load Tests
const loadTestErrors = collectLoadTests();
if (loadTestErrors.length > 0) {
console.log(`Collected ${loadTestErrors.length} load test errors.`);
errors.push(...loadTestErrors);
}
// Fuzz Reports
const fuzzErrors = collectFuzzReports();
if (fuzzErrors.length > 0) {
+190 -289
View File
@@ -1,371 +1,272 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { WebSocket } from 'ws';
import {
buildVideoPostEvent,
buildVideoMirrorEvent,
buildRepostEvent,
buildShareEvent,
buildRelayListEvent,
buildDmRelayListEvent,
buildProfileMetadataEvent,
buildMuteListEvent,
buildDeletionEvent,
buildLegacyDirectMessageEvent,
buildDmAttachmentEvent,
buildDmReadReceiptEvent,
buildDmTypingIndicatorEvent,
buildViewEvent,
buildZapRequestEvent,
buildReactionEvent,
buildCommentEvent,
buildWatchHistoryEvent,
buildSubscriptionListEvent,
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent,
buildHttpAuthEvent,
buildReportEvent,
validateEventStructure,
getNostrEventSchema,
NOTE_TYPES,
} from "../../js/nostrEventSchemas.js";
const VALIDATION_FAILURES = [];
function assertEventValid(label, event, type) {
if (!event) {
console.error(`❌ [${label}] Builder returned null or undefined`);
VALIDATION_FAILURES.push({ label, error: "Builder returned null/undefined" });
return;
}
const { valid, errors } = validateEventStructure(type, event);
if (!valid) {
console.error(`❌ [${label}] Validation failed for type ${type}:`);
errors.forEach((err) => console.error(` - ${err}`));
VALIDATION_FAILURES.push({ label, errors });
} else {
console.log(`✅ [${label}] Valid`);
}
// Polyfills
if (typeof globalThis.crypto === 'undefined') {
globalThis.crypto = crypto;
}
if (typeof globalThis.WebSocket === 'undefined') {
globalThis.WebSocket = WebSocket;
}
if (typeof globalThis.localStorage === 'undefined') {
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {}
};
}
if (typeof globalThis.window === 'undefined') {
globalThis.window = globalThis;
}
async function runValidation() {
console.log("Starting Event Builder Validation...\n");
// Helper to load module
const loadModule = async (filePath) => {
return import(path.resolve(process.cwd(), filePath));
};
async function main() {
const schemasModule = await loadModule('js/nostrEventSchemas.js');
const {
validateEventStructure,
NOTE_TYPES,
buildVideoPostEvent,
buildHttpAuthEvent,
buildReportEvent,
buildVideoMirrorEvent,
buildRepostEvent,
buildShareEvent,
buildRelayListEvent,
buildDmRelayListEvent,
buildProfileMetadataEvent,
buildMuteListEvent,
buildDeletionEvent,
buildLegacyDirectMessageEvent,
buildDmAttachmentEvent,
buildDmReadReceiptEvent,
buildDmTypingIndicatorEvent,
buildViewEvent,
buildZapRequestEvent,
buildReactionEvent,
buildCommentEvent,
buildWatchHistoryEvent,
buildSubscriptionListEvent,
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent
} = schemasModule;
console.log("Starting Event Schema Validation...");
let failureCount = 0;
const validate = (type, builder, params, label) => {
try {
const event = builder(params);
const { valid, errors } = validateEventStructure(type, event);
if (!valid) {
console.error(`[FAIL] ${label} (${type})`);
errors.forEach(err => console.error(` - ${err}`));
console.error(` Event:`, JSON.stringify(event, null, 2));
failureCount++;
} else {
console.log(`[PASS] ${label} (${type})`);
}
} catch (e) {
console.error(`[ERROR] Exception in builder for ${label} (${type})`);
console.error(e);
failureCount++;
}
};
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const created_at = Math.floor(Date.now() / 1000);
// 1. Video Post
assertEventValid(
"Video Post",
buildVideoPostEvent({
pubkey,
created_at,
dTagValue: "test-d-tag",
content: {
// Test Cases
validate(NOTE_TYPES.VIDEO_POST, buildVideoPostEvent, {
pubkey,
created_at,
dTagValue: "test-video-id",
content: {
version: 3,
title: "Test Video",
videoRootId: "root-id",
url: "https://example.com/video.mp4",
magnet: "magnet:?xt=urn:btih:...",
thumbnail: "https://example.com/thumb.jpg",
description: "Test description",
mode: "live",
deleted: false,
isPrivate: false,
isNsfw: false,
isForKids: false,
enableComments: true,
},
additionalTags: [["t", "extra"]],
}),
NOTE_TYPES.VIDEO_POST
);
}
}, "Basic Video Post");
// 2. Video Mirror
assertEventValid(
"Video Mirror",
buildVideoMirrorEvent({
validate(NOTE_TYPES.HTTP_AUTH, buildHttpAuthEvent, {
pubkey,
created_at,
tags: [["e", "event-id"], ["p", pubkey]],
content: { some: "metadata" },
}),
NOTE_TYPES.VIDEO_MIRROR
);
url: "https://example.com/login",
method: "POST"
}, "HTTP Auth");
// 3. Repost
assertEventValid(
"Repost",
buildRepostEvent({
validate(NOTE_TYPES.REPORT, buildReportEvent, {
pubkey,
created_at,
eventId: "original-event-id",
eventRelay: "wss://relay.example.com",
authorPubkey: pubkey,
}),
NOTE_TYPES.REPOST
);
eventId: "e".repeat(64),
reportType: "spam"
}, "Report");
// 4. Share
assertEventValid(
"Share",
buildShareEvent({
validate(NOTE_TYPES.VIDEO_MIRROR, buildVideoMirrorEvent, {
pubkey,
created_at,
content: "Check this out!",
video: { id: "video-id", pubkey: "author-pubkey" },
relays: ["wss://relay.example.com"],
}),
NOTE_TYPES.SHARE
);
content: "mirror content"
}, "Video Mirror");
// 5. Relay List
assertEventValid(
"Relay List",
buildRelayListEvent({
validate(NOTE_TYPES.REPOST, buildRepostEvent, {
pubkey,
created_at,
relays: [
{ url: "wss://relay1.com", mode: "read" },
{ url: "wss://relay2.com", mode: "write" },
],
}),
NOTE_TYPES.RELAY_LIST
);
eventId: "e".repeat(64),
targetKind: 1 // Text Note Repost (Kind 6)
}, "Repost");
// 6. DM Relay List
assertEventValid(
"DM Relay List",
buildDmRelayListEvent({
validate(NOTE_TYPES.GENERIC_REPOST, buildRepostEvent, {
pubkey,
created_at,
relays: ["wss://relay1.com", "wss://relay2.com"],
}),
NOTE_TYPES.DM_RELAY_LIST
);
eventId: "e".repeat(64),
targetKind: 30078 // Video Post Repost (Kind 16)
}, "Generic Repost");
// 7. Profile Metadata
assertEventValid(
"Profile Metadata",
buildProfileMetadataEvent({
validate(NOTE_TYPES.SHARE, buildShareEvent, {
pubkey,
created_at,
metadata: { name: "Test User", about: "Testing" },
}),
NOTE_TYPES.PROFILE_METADATA
);
content: "Check this out",
video: { id: "e".repeat(64), pubkey: "p".repeat(64) }
}, "Share");
// 8. Mute List
assertEventValid(
"Mute List",
buildMuteListEvent({
validate(NOTE_TYPES.RELAY_LIST, buildRelayListEvent, {
pubkey,
created_at,
pTags: ["mute-pubkey-1", "mute-pubkey-2"],
}),
NOTE_TYPES.MUTE_LIST
);
relays: ["wss://relay.example.com"]
}, "Relay List");
// 9. Deletion
assertEventValid(
"Deletion",
buildDeletionEvent({
validate(NOTE_TYPES.DM_RELAY_LIST, buildDmRelayListEvent, {
pubkey,
created_at,
eventIds: ["event-id-1"],
reason: "Mistake",
}),
NOTE_TYPES.DELETION
);
relays: ["wss://dm.example.com"]
}, "DM Relay List");
// 10. Legacy DM
assertEventValid(
"Legacy DM",
buildLegacyDirectMessageEvent({
validate(NOTE_TYPES.PROFILE_METADATA, buildProfileMetadataEvent, {
pubkey,
created_at,
recipientPubkey: "recipient-pubkey",
ciphertext: "encrypted-content",
}),
NOTE_TYPES.LEGACY_DM
);
metadata: { name: "Test User" }
}, "Profile Metadata");
// 11. DM Attachment
assertEventValid(
"DM Attachment",
buildDmAttachmentEvent({
validate(NOTE_TYPES.MUTE_LIST, buildMuteListEvent, {
pubkey,
created_at,
recipientPubkey: "recipient-pubkey",
attachment: { url: "https://example.com/file.jpg", type: "image/jpeg" },
}),
NOTE_TYPES.DM_ATTACHMENT
);
pTags: ["p".repeat(64)]
}, "Mute List");
// 12. DM Read Receipt
assertEventValid(
"DM Read Receipt",
buildDmReadReceiptEvent({
validate(NOTE_TYPES.DELETION, buildDeletionEvent, {
pubkey,
created_at,
recipientPubkey: "recipient-pubkey",
eventId: "event-id",
messageKind: 4,
}),
NOTE_TYPES.DM_READ_RECEIPT
);
eventIds: ["e".repeat(64)]
}, "Deletion");
// 13. DM Typing Indicator
assertEventValid(
"DM Typing Indicator",
buildDmTypingIndicatorEvent({
validate(NOTE_TYPES.LEGACY_DM, buildLegacyDirectMessageEvent, {
pubkey,
created_at,
recipientPubkey: "recipient-pubkey",
eventId: "event-id",
}),
NOTE_TYPES.DM_TYPING
);
recipientPubkey: "p".repeat(64),
ciphertext: "encrypted"
}, "Legacy DM");
// 14. View Event
assertEventValid(
"View Event",
buildViewEvent({
validate(NOTE_TYPES.DM_ATTACHMENT, buildDmAttachmentEvent, {
pubkey,
created_at,
pointerTag: ["a", "kind:pubkey:dtag"],
}),
NOTE_TYPES.VIEW_EVENT
);
recipientPubkey: "p".repeat(64),
attachment: { url: "https://example.com/file.jpg", x: "hash" }
}, "DM Attachment");
// 15. Zap Request
assertEventValid(
"Zap Request",
buildZapRequestEvent({
validate(NOTE_TYPES.DM_READ_RECEIPT, buildDmReadReceiptEvent, {
pubkey,
created_at,
recipientPubkey: "recipient-pubkey",
recipientPubkey: "p".repeat(64),
eventId: "e".repeat(64)
}, "DM Read Receipt");
validate(NOTE_TYPES.DM_TYPING, buildDmTypingIndicatorEvent, {
pubkey,
created_at,
recipientPubkey: "p".repeat(64)
}, "DM Typing Indicator");
validate(NOTE_TYPES.VIEW_EVENT, buildViewEvent, {
pubkey,
created_at,
pointerTag: ["e", "eventid", "relay"]
}, "View Event");
validate(NOTE_TYPES.ZAP_REQUEST, buildZapRequestEvent, {
pubkey,
created_at,
recipientPubkey: "p".repeat(64),
amountSats: 100,
relays: ["wss://relay.com"],
}),
NOTE_TYPES.ZAP_REQUEST
);
lnurl: "lnurl1...",
relays: ["wss://relay.damus.io"]
}, "Zap Request");
// 16. Reaction
assertEventValid(
"Reaction",
buildReactionEvent({
validate(NOTE_TYPES.VIDEO_REACTION, buildReactionEvent, {
pubkey,
created_at,
pointerTag: ["e", "event-id"],
content: "+",
}),
NOTE_TYPES.VIDEO_REACTION
);
targetPointer: { type: "e", value: "e".repeat(64) }
}, "Reaction");
// 17. Comment
assertEventValid(
"Comment",
buildCommentEvent({
validate(NOTE_TYPES.VIDEO_COMMENT, buildCommentEvent, {
pubkey,
created_at,
videoEventId: "video-event-id",
content: "Nice video!",
}),
NOTE_TYPES.VIDEO_COMMENT
);
content: "Great video!",
videoEventId: "e".repeat(64)
}, "Comment");
// 18. Watch History
assertEventValid(
"Watch History",
buildWatchHistoryEvent({
validate(NOTE_TYPES.WATCH_HISTORY, buildWatchHistoryEvent, {
pubkey,
created_at,
monthIdentifier: "2023-10",
content: { "video-id": 1234567890 },
}),
NOTE_TYPES.WATCH_HISTORY
);
content: {},
monthIdentifier: "2023-10" // Required for valid event
}, "Watch History");
// 19. Subscription List
assertEventValid(
"Subscription List",
buildSubscriptionListEvent({
validate(NOTE_TYPES.SUBSCRIPTION_LIST, buildSubscriptionListEvent, {
pubkey,
created_at,
content: [["p", "pubkey"]],
}),
NOTE_TYPES.SUBSCRIPTION_LIST
);
content: "encrypted_subs"
}, "Subscription List");
// 20. Block List
assertEventValid(
"Block List",
buildBlockListEvent({
validate(NOTE_TYPES.USER_BLOCK_LIST, buildBlockListEvent, {
pubkey,
created_at,
content: [["p", "blocked-pubkey"]],
}),
NOTE_TYPES.USER_BLOCK_LIST
);
content: "encrypted_blocks"
}, "Block List");
// 21. Hashtag Preference
assertEventValid(
"Hashtag Preference",
buildHashtagPreferenceEvent({
validate(NOTE_TYPES.HASHTAG_PREFERENCES, buildHashtagPreferenceEvent, {
pubkey,
created_at,
content: { interests: ["nostr"], disinterests: ["crypto"] },
}),
NOTE_TYPES.HASHTAG_PREFERENCES
);
content: "encrypted_prefs"
}, "Hashtag Preferences");
// 22. Admin List (Moderation)
assertEventValid(
"Admin Moderation List",
buildAdminListEvent("moderation", {
// Admin Lists
const adminBuilder = (params) => buildAdminListEvent("moderation", params);
validate(NOTE_TYPES.ADMIN_MODERATION_LIST, adminBuilder, {
pubkey,
created_at,
hexPubkeys: ["mod-pubkey"],
}),
NOTE_TYPES.ADMIN_MODERATION_LIST
);
// 23. HTTP Auth
assertEventValid(
"HTTP Auth",
buildHttpAuthEvent({
pubkey,
created_at,
url: "https://example.com/auth",
method: "GET",
}),
NOTE_TYPES.HTTP_AUTH
);
// 24. Report
assertEventValid(
"Report",
buildReportEvent({
pubkey,
created_at,
eventId: "reported-event-id",
reportType: "spam",
}),
NOTE_TYPES.REPORT
);
hexPubkeys: ["p".repeat(64)]
}, "Admin Moderation List");
if (VALIDATION_FAILURES.length > 0) {
console.error("\n❌ Validation Validation Failed!");
console.error(JSON.stringify(VALIDATION_FAILURES, null, 2));
process.exit(1);
if (failureCount > 0) {
console.log(`\nValidation complete with ${failureCount} failures.`);
process.exit(1);
} else {
console.log("\n✅ All validations passed!");
console.log("\nAll checks passed!");
process.exit(0);
}
}
runValidation().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
main().catch(err => {
console.error("Fatal error:", err);
process.exit(1);
});
+16 -1
View File
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
@@ -101,14 +102,28 @@ async function run() {
return;
}
const logFile = path.join(rootDir, "artifacts", "test_unit.log");
if (!fs.existsSync(path.dirname(logFile))) {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
}
const logStream = fs.createWriteStream(logFile);
for (const testFile of selectedTests) {
const relativePath = path.relative(rootDir, testFile);
console.log(`\n→ Running ${relativePath}`);
logStream.write(`\n→ Running ${relativePath}\n`);
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--import", setupImport.href, testFile], {
stdio: "inherit",
stdio: ["inherit", "pipe", "pipe"],
});
child.stdout.pipe(process.stdout, { end: false });
child.stdout.pipe(logStream, { end: false });
child.stderr.pipe(process.stderr, { end: false });
child.stderr.pipe(logStream, { end: false });
let finished = false;
let timeoutId = null;
+7 -7
View File
@@ -8,7 +8,7 @@ if (originalCrypto === undefined) {
value: webcrypto,
configurable: true,
writable: false,
enumerable: false,
enumerable: false
});
}
@@ -18,7 +18,7 @@ import {
persistSessionActor,
readStoredSessionActorEntry,
clearStoredSessionActor,
SESSION_ACTOR_STORAGE_KEY,
SESSION_ACTOR_STORAGE_KEY
} from "../../js/nostr/sessionActor.js";
test.after(() => {
@@ -41,9 +41,9 @@ test("encryptSessionPrivateKey + decryptSessionPrivateKey roundtrip", async () =
const decrypted = await decryptSessionPrivateKey(
{
privateKeyEncrypted: encrypted.ciphertext,
encryption: encrypted,
encryption: encrypted
},
passphrase,
passphrase
);
assert.strictEqual(decrypted, privateKey);
@@ -60,7 +60,7 @@ test("persistSessionActor stores encrypted payload metadata", async () => {
pubkey: "npub1encrypted",
privateKeyEncrypted: encrypted.ciphertext,
encryption: encrypted,
createdAt: 456,
createdAt: 456
});
const entry = readStoredSessionActorEntry();
@@ -74,7 +74,7 @@ test("persistSessionActor stores encrypted payload metadata", async () => {
salt: encrypted.salt,
iv: encrypted.iv,
iterations: encrypted.iterations,
hash: encrypted.hash,
hash: encrypted.hash
});
assert.strictEqual(entry.createdAt, 456);
@@ -102,6 +102,6 @@ test("clearStoredSessionActor removes persisted payload", async () => {
clearStoredSessionActor();
assert.strictEqual(
globalThis.localStorage.getItem(SESSION_ACTOR_STORAGE_KEY),
null,
null
);
});
+27 -125
View File
@@ -4,7 +4,7 @@ import { JSDOM } from "jsdom";
import createTagPreferenceMenu, {
TAG_PREFERENCE_ACTIONS,
applyTagPreferenceMenuState,
applyTagPreferenceMenuState
} from "../../js/ui/components/tagPreferenceMenu.js";
import Application from "../../js/app.js";
@@ -13,7 +13,7 @@ test("createTagPreferenceMenu renders heading and actions", () => {
const menu = createTagPreferenceMenu({
document,
tag: "#Nostr",
isLoggedIn: true,
isLoggedIn: true
});
assert(menu, "menu should render");
@@ -22,13 +22,16 @@ test("createTagPreferenceMenu renders heading and actions", () => {
assert.equal(panel.dataset.tag, "nostr");
const heading = panel.querySelector(".menu__heading");
assert(heading, "heading should be rendered");
assert.equal(heading.textContent, "#Nostr");
assert.equal(heading.textContent, "#nostr");
assert.equal(typeof buttons.addInterest, "object");
assert.equal(buttons.addInterest.dataset.action, TAG_PREFERENCE_ACTIONS.ADD_INTEREST);
assert.equal(
buttons.addInterest.dataset.action,
TAG_PREFERENCE_ACTIONS.ADD_INTEREST
);
assert.equal(
buttons.removeDisinterest.dataset.action,
TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST,
TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST
);
});
@@ -38,13 +41,25 @@ test("createTagPreferenceMenu disables actions based on membership and login", (
document,
tag: "video",
isLoggedIn: false,
membership: { state: "interest" },
membership: { state: "interest" }
});
assert(buttons.addInterest.disabled, "add interest should be disabled for interest state");
assert(buttons.removeInterest.disabled, "remove interest should be disabled when logged out");
assert(buttons.addDisinterest.disabled, "add disinterest should require login");
assert(buttons.removeDisinterest.disabled, "remove disinterest should require login");
assert(
buttons.addInterest.disabled,
"add interest should be disabled for interest state"
);
assert(
buttons.removeInterest.disabled,
"remove interest should be disabled when logged out"
);
assert(
buttons.addDisinterest.disabled,
"add disinterest should require login"
);
assert(
buttons.removeDisinterest.disabled,
"remove disinterest should require login"
);
const message = panel.querySelector("p.text-xs");
assert(message, "login message should be present when logged out");
@@ -52,7 +67,7 @@ test("createTagPreferenceMenu disables actions based on membership and login", (
applyTagPreferenceMenuState({
buttons,
isLoggedIn: true,
membership: { state: "disinterest" },
membership: { state: "disinterest" }
});
assert.equal(buttons.addInterest.disabled, false);
@@ -71,7 +86,7 @@ test("createTagPreferenceMenu forwards actions to callback", () => {
membership: { state: "disinterest" },
onAction(action, detail) {
events.push({ action, detail });
},
}
});
document.body.append(panel);
@@ -85,116 +100,3 @@ test("createTagPreferenceMenu forwards actions to callback", () => {
assert.equal(events[1].action, TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST);
assert.equal(events[1].detail.normalizedTag, "art");
});
test("handleTagPreferenceMenuAction publishes hashtag updates", async () => {
const store = {
interests: new Set(),
disinterests: new Set(),
};
const publishCalls = [];
const service = {
addInterest(tag) {
if (typeof tag !== "string") {
return false;
}
const normalized = tag.trim().toLowerCase();
if (!normalized) {
return false;
}
store.disinterests.delete(normalized);
const had = store.interests.has(normalized);
store.interests.add(normalized);
return !had;
},
removeInterest(tag) {
if (typeof tag !== "string") {
return false;
}
const normalized = tag.trim().toLowerCase();
if (!normalized) {
return false;
}
return store.interests.delete(normalized);
},
addDisinterest(tag) {
if (typeof tag !== "string") {
return false;
}
const normalized = tag.trim().toLowerCase();
if (!normalized) {
return false;
}
store.interests.delete(normalized);
const had = store.disinterests.has(normalized);
store.disinterests.add(normalized);
return !had;
},
removeDisinterest(tag) {
if (typeof tag !== "string") {
return false;
}
const normalized = tag.trim().toLowerCase();
if (!normalized) {
return false;
}
return store.disinterests.delete(normalized);
},
getInterests() {
return Array.from(store.interests).sort();
},
getDisinterests() {
return Array.from(store.disinterests).sort();
},
async publish(payload) {
publishCalls.push(payload);
return { ok: true };
},
on() {
return () => {};
},
};
const app = Object.create(Application.prototype);
app.hashtagPreferences = service;
app.pubkey = "f".repeat(64);
app.safeDecodeNpub = () => null;
app.describeHashtagPreferencesError = (_error, fallback = "") => fallback;
app.showError = () => {};
app.refreshTagPreferenceUi = () => {};
app.normalizeHexPubkey = Application.prototype.normalizeHexPubkey;
app.normalizeHashtagPreferenceList =
Application.prototype.normalizeHashtagPreferenceList;
app.createHashtagPreferencesSnapshot =
Application.prototype.createHashtagPreferencesSnapshot;
app.computeHashtagPreferencesSignature =
Application.prototype.computeHashtagPreferencesSignature;
app.getHashtagPreferences = Application.prototype.getHashtagPreferences;
app.updateCachedHashtagPreferences =
Application.prototype.updateCachedHashtagPreferences;
app.hashtagPreferencesSnapshot = app.createHashtagPreferencesSnapshot();
app.hashtagPreferencesSnapshotSignature =
app.computeHashtagPreferencesSignature(app.hashtagPreferencesSnapshot);
app.hashtagPreferencesPublishInFlight = false;
app.hashtagPreferencesPublishPromise = null;
app.persistHashtagPreferencesFromMenu =
Application.prototype.persistHashtagPreferencesFromMenu;
app.handleTagPreferenceMenuAction =
Application.prototype.handleTagPreferenceMenuAction;
await app.handleTagPreferenceMenuAction(
TAG_PREFERENCE_ACTIONS.ADD_INTEREST,
{ tag: "nostr" },
);
assert.equal(publishCalls.length, 1);
assert.deepEqual(publishCalls[0], { pubkey: app.pubkey });
await app.handleTagPreferenceMenuAction(
TAG_PREFERENCE_ACTIONS.REMOVE_INTEREST,
{ tag: "nostr" },
);
assert.equal(publishCalls.length, 2);
assert.deepEqual(publishCalls[1], { pubkey: app.pubkey });
});
+160
View File
@@ -0,0 +1,160 @@
import { test, describe, it, beforeEach, mock } from "node:test";
import assert from "node:assert";
import EditModalController from "../../js/ui/editModalController.js";
describe("EditModalController", () => {
let controller;
let services, state, ui, callbacks, helpers;
beforeEach(() => {
services = {
nostrService: {
fetchVideos: mock.fn(async () => []),
handleEditVideoSubmit: mock.fn(async () => {}),
},
};
state = {
getPubkey: mock.fn(() => "test-pubkey"),
getBlacklistedEventIds: mock.fn(() => new Set()),
getVideosMap: mock.fn(() => new Map()),
};
ui = {
getEditModal: mock.fn(() => ({
load: mock.fn(async () => {}),
open: mock.fn(async () => {}),
setSubmitState: mock.fn(),
close: mock.fn(),
})),
showError: mock.fn(),
showSuccess: mock.fn(),
};
callbacks = {
loadVideos: mock.fn(async () => {}),
forceRefreshAllProfiles: mock.fn(),
isAuthorBlocked: mock.fn(() => false),
};
helpers = {
normalizeActionTarget: mock.fn((t) => ({ triggerElement: null })),
resolveVideoActionTarget: mock.fn(async () => ({
id: "v1",
pubkey: "test-pubkey",
})),
};
controller = new EditModalController({
services,
state,
ui,
callbacks,
helpers,
});
});
describe("open()", () => {
it("should resolve target and open modal if authorized", async () => {
await controller.open("target");
assert.strictEqual(helpers.normalizeActionTarget.mock.callCount(), 1);
assert.strictEqual(services.nostrService.fetchVideos.mock.callCount(), 1);
assert.strictEqual(helpers.resolveVideoActionTarget.mock.callCount(), 1);
assert.strictEqual(ui.getEditModal.mock.callCount(), 1);
const modal = ui.getEditModal.mock.calls[0].result;
assert.strictEqual(modal.load.mock.callCount(), 1);
assert.strictEqual(modal.open.mock.callCount(), 1);
});
it("should show error if not logged in", async () => {
state.getPubkey = mock.fn(() => null);
await controller.open("target");
assert.strictEqual(ui.showError.mock.callCount(), 1);
assert.match(ui.showError.mock.calls[0].arguments[0], /login/i);
const modal = ui.getEditModal.mock.calls[0]?.result;
if (modal) {
assert.strictEqual(modal.open.mock.callCount(), 0);
}
});
it("should show error if user does not own video", async () => {
helpers.resolveVideoActionTarget = mock.fn(async () => ({
id: "v1",
pubkey: "other-pubkey",
}));
await controller.open("target");
assert.strictEqual(ui.showError.mock.callCount(), 1);
assert.match(ui.showError.mock.calls[0].arguments[0], /own/i);
});
it("should handle modal load errors", async () => {
ui.getEditModal = mock.fn(() => ({
load: mock.fn(async () => { throw new Error("Load failed"); }),
open: mock.fn(async () => {}),
}));
await controller.open("target");
assert.strictEqual(ui.showError.mock.callCount(), 1);
assert.match(ui.showError.mock.calls[0].arguments[0], /initialize/i);
});
});
describe("handleSubmit()", () => {
it("should handle successful submission", async () => {
const event = {
detail: {
originalEvent: {},
updatedData: {},
},
};
await controller.handleSubmit(event);
assert.strictEqual(services.nostrService.handleEditVideoSubmit.mock.callCount(), 1);
assert.strictEqual(callbacks.loadVideos.mock.callCount(), 1);
assert.strictEqual(callbacks.forceRefreshAllProfiles.mock.callCount(), 1);
assert.strictEqual(ui.showSuccess.mock.callCount(), 1);
const modal = ui.getEditModal.mock.calls[0].result;
assert.strictEqual(modal.close.mock.callCount(), 1);
});
it("should show error if not logged in", async () => {
state.getPubkey = mock.fn(() => null);
const event = {
detail: {
originalEvent: {},
updatedData: {},
},
};
await controller.handleSubmit(event);
assert.strictEqual(ui.showError.mock.callCount(), 1);
assert.strictEqual(services.nostrService.handleEditVideoSubmit.mock.callCount(), 0);
});
it("should handle submission errors", async () => {
services.nostrService.handleEditVideoSubmit = mock.fn(async () => {
throw new Error("Submit failed");
});
const event = {
detail: {
originalEvent: {},
updatedData: {},
},
};
await controller.handleSubmit(event);
assert.strictEqual(ui.showError.mock.callCount(), 1);
assert.match(ui.showError.mock.calls[0].arguments[0], /failed/i);
const modal = ui.getEditModal.mock.calls[0].result;
assert.strictEqual(modal.setSubmitState.mock.callCount(), 1);
assert.deepStrictEqual(modal.setSubmitState.mock.calls[0].arguments[0], { pending: false });
});
});
});