mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge branch 'unstable' into ai/tests-nostr-state-20250522-1996916389475353061
This commit is contained in:
25 files changed
+880
-648
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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"**.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user