diff --git a/js/accessControl.js b/js/accessControl.js index 901e94e1..67cc834b 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -460,6 +460,14 @@ class AccessControl { return this.whitelistEnabled; } + // True once we have usable lists to filter with — either a completed refresh + // or admin state hydrated synchronously from the localStorage cache at + // construction. Callers use this to avoid blocking the feed on a remote + // refresh when cached lists already allow accurate canAccess() checks. + isHydrated() { + return this.hasLoaded === true || this._hydratedFromCache === true; + } + isSuperAdmin(npub) { const normalized = normalizeNpub(npub); return normalized ? normalized === ADMIN_SUPER_NPUB : false; diff --git a/js/adminListStore.js b/js/adminListStore.js index 8c285b26..83eddb5e 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -576,6 +576,11 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") { pubkey, dTag, relayUrls: relays, + // Force a full fetch: admin lists are replaceable events whose + // created_at doesn't advance, so a persisted lastSeen would gate + // since=lastSeen+1 and perpetually hide the current event across + // reloads. See tests/nostr/relayBatchFetcherReplaceable.test.mjs. + since: 0, }), timeoutPromise, ]); diff --git a/js/app.js b/js/app.js index c4a54347..afef1170 100644 --- a/js/app.js +++ b/js/app.js @@ -3765,6 +3765,15 @@ class Application { return this._feed.registerForYouFeed(...args); } + /** + * Subscribe the For You feed to background watch-history resolution so it + * re-runs (suppressing watched videos, applying tags) once history loads. + */ + subscribeWatchHistoryFeedRefresh(...args) { + this._initCoordinators(); + return this._feed.subscribeWatchHistoryFeedRefresh(...args); + } + /** * Register the "kids" feed pipeline. */ diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index e6c0606b..0e3aae54 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -10,6 +10,8 @@ */ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; +import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; +import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js"; import { FEED_TYPES } from "../constants.js"; /** @@ -489,6 +491,14 @@ export function createAuthSessionCoordinator(deps) { } const taskOutcomes = await Promise.all(parallelListTasks); + + // NOTE: watch history is intentionally NOT eagerly preloaded on login. + // A cold watch history is ~150+ serialized nip-07 decrypts; preloading it + // here (even after the lists) saturated the extension and starved the + // profile modal's hashtag/subscription/DM loads. It now loads lazily when + // the For You feed or History view is opened. Re-enable an eager preload + // only once the cold-load decrypt cost is reduced (see task: ~180 calls). + const requiredTaskOutcomes = taskOutcomes.filter((outcome) => ["blocks", "subscriptions", "hashtags"].includes(outcome.name), ); @@ -881,6 +891,8 @@ export function createAuthSessionCoordinator(deps) { this.resetHashtagPreferencesState(); this.resetPermissionPromptState(); clearDecryptionSchemeCache(); + clearWatchHistoryConversationKeyCache(); + clearWatchHistoryDecryptedChunkCache(); this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" }); try { diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index d3437053..19f8880d 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -1271,6 +1271,45 @@ export function createFeedCoordinator(deps) { return this.feedTelemetryState?.activeFeed === FEED_TYPES.FOR_YOU; }, + // Watch history loads in the background (the For You feed no longer blocks on + // it). When a refresh resolves and emits "fingerprint", re-run the For You + // feed so newly-resolved watched videos are suppressed and watch-history tags + // are applied. Debounced and gated to when the For You feed is active. + subscribeWatchHistoryFeedRefresh() { + if ( + this._watchHistoryFeedUnsub || + !watchHistoryService || + typeof watchHistoryService.subscribe !== "function" + ) { + return; + } + let debounceId = null; + this._watchHistoryFeedUnsub = watchHistoryService.subscribe( + "fingerprint", + () => { + if (!this.isForYouFeedActive()) { + return; + } + if (debounceId) { + clearTimeout(debounceId); + } + debounceId = setTimeout(() => { + debounceId = null; + Promise.resolve( + this.refreshFeed(FEED_TYPES.FOR_YOU, { + reason: "watch-history-resolved", + }), + ).catch((error) => { + devLogger.warn( + "[feedCoordinator] Failed to refresh For You after watch-history update:", + error, + ); + }); + }, 400); + }, + ); + }, + updateFeedTelemetryMetadata(feedName = "", items = [], metadata = {}) { if (!this.isFeedActive(feedName)) { return; diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index d6300a37..e4f83cde 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -213,12 +213,13 @@ function ensureDecryptCandidates(rawCandidates) { .filter(Boolean); } -function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { +function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLegacy = false } = {}) { if (!candidates.length) { return []; } const algorithms = Array.isArray(hints?.algorithms) ? hints.algorithms : []; + const hasHints = algorithms.length > 0; const algorithmRanks = new Map(); algorithms.forEach((algorithm, index) => { if (!algorithmRanks.has(algorithm)) { @@ -250,14 +251,18 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { }; return [...candidates].sort((a, b) => { - if (a.priority !== b.priority) { - return a.priority - b.priority; + // Explicit per-event encryption hints (the "encrypted" tag) always win, even + // for legacy kind-4 events: if the sender told us the scheme, honor it. + if (hasHints) { + const aHinted = desiredSchemeRank(a.scheme); + const bHinted = desiredSchemeRank(b.scheme); + if (aHinted !== bHinted) { + return aHinted - bHinted; + } } - const aRank = desiredSchemeRank(a.scheme); - const bRank = desiredSchemeRank(b.scheme); - if (aRank !== bRank) { - return aRank - bRank; + if (a.priority !== b.priority) { + return a.priority - b.priority; } if (preferGiftWrap) { @@ -268,6 +273,23 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { } } + // Legacy (kind 4) default: with no explicit hint, kind-4 DMs are NIP-04, so + // try nip04 first and don't burn a (serialized, slow) nip-07 round-trip on + // nip44. Only flips the default — an explicit hint above already took over. + if (preferLegacy && !hasHints) { + const aL = a.scheme === "nip04" ? 0 : 1; + const bL = b.scheme === "nip04" ? 0 : 1; + if (aL !== bL) { + return aL - bL; + } + } + + const aRank = desiredSchemeRank(a.scheme); + const bRank = desiredSchemeRank(b.scheme); + if (aRank !== bRank) { + return aRank - bRank; + } + return 0; }); } @@ -538,17 +560,26 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { }); } - const ordered = orderDecryptors(decryptors, hints); + const ordered = orderDecryptors(decryptors, hints, { preferLegacy: true }); const errors = []; const remoteCandidates = []; const seenRemotes = new Set(); + let selfCandidate = null; const registerRemote = (candidate) => { const normalized = normalizeHex(candidate); if (!normalized || seenRemotes.has(normalized)) { return; } seenRemotes.add(normalized); + // A NIP-04 conversation key is derived with the *other* party, so trying the + // actor's own key is normally a wasted (slow, serialized) extension + // round-trip. Defer self to the end so we only attempt it as a fallback — + // but keep it, because note-to-self DMs have no other counterparty. + if (normalizedActor && normalized === normalizedActor) { + selfCandidate = normalized; + return; + } remoteCandidates.push(normalized); }; @@ -564,6 +595,10 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { registerRemote(recipient?.pubkey); } + if (selfCandidate) { + remoteCandidates.push(selfCandidate); + } + const attemptDecryption = async (decryptor, remotePubkey) => { try { const plaintext = await decryptor.decrypt(remotePubkey, ciphertext, { @@ -604,28 +639,29 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { } }; - const attempts = []; + // Try candidates sequentially and STOP at the first success. Racing every + // (decryptor x remote) combination in parallel looked fast, but the nip-07 + // extension serializes calls — so racing N combinations just queued N slow + // extension round-trips per message (the legacy DM history was ~12 extension + // calls per message). With nip04 ordered first and the remote party first, the + // first attempt normally succeeds, so this is ~1 call per message with no + // worse latency-to-first-success on the serialized extension. for (const decryptor of ordered) { for (const remotePubkey of remoteCandidates) { - attempts.push(attemptDecryption(decryptor, remotePubkey)); + try { + return await attemptDecryption(decryptor, remotePubkey); + } catch (failure) { + errors.push(failure); + } } } - // Race all decryption attempts to return the first successful result - // This significantly reduces latency compared to sequential attempts - try { - return await raceOrdered(attempts); - } catch (aggregateError) { - const aggregatedErrors = aggregateError.errors || []; - errors.push(...aggregatedErrors); - - return buildDecryptResult({ - ok: false, - event, - actorPubkey, - errors, - }); - } + return buildDecryptResult({ + ok: false, + event, + actorPubkey, + errors, + }); } export async function decryptDM(event, context = {}) { diff --git a/js/nostr/client.js b/js/nostr/client.js index 34948981..452e18e5 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -42,6 +42,7 @@ import { resolveStoragePointerValue, } from "../utils/storagePointer.js"; import { RelayBatchFetcher } from "./relayBatchFetcher.js"; +import { createPersistedPlaintextCache } from "./persistedPlaintextCache.js"; import { createWatchHistoryManager, normalizePointerInput, @@ -349,6 +350,13 @@ function cloneEventForCache(event) { const DM_DECRYPT_CACHE_LIMIT = 256; const DM_EVENT_KINDS = Object.freeze([4, 1059]); +// Persisted (across reloads) cache of decrypted DM results keyed by the immutable +// DM event id. DM events never change, so caching the decrypted result means a +// reload re-decrypts only messages we haven't seen — skipping the (serialized, +// slow) nip-07 extension for the rest. Stored as JSON of the decrypt result; +// cleared on logout via clearDmDecryptCache(). +const dmPlaintextCache = createPersistedPlaintextCache("bitvid:dmDecrypted:v1", 1000); + function buildDmFilters(actorPubkey, { since, until, limit } = {}) { const normalizedActor = normalizeActorKey(actorPubkey); const filters = []; @@ -569,6 +577,8 @@ export class NostrClient { }, }); this.dmDecryptCache = new LRUCache({ maxSize: DM_DECRYPT_CACHE_LIMIT }); + // event id -> in-flight decrypt promise (coalesces concurrent decrypts). + this.dmDecryptInFlight = new Map(); this.dmDecryptor = null; this.dmDecryptorPromise = null; this.isInitialized = false; @@ -1140,6 +1150,7 @@ export class NostrClient { if (this.dmDecryptCache) { this.dmDecryptCache.clear(); } + dmPlaintextCache.clear(); } async ensureDmDecryptor() { @@ -1350,27 +1361,67 @@ export class NostrClient { if (cached) { return cached; } + // Persisted result from a prior session — avoids re-hitting the extension + // to decrypt the same immutable DM on every reload. + const persisted = dmPlaintextCache.get(eventId); + if (persisted) { + try { + const parsed = JSON.parse(persisted); + if (parsed && parsed.ok) { + this.dmDecryptCache.set(eventId, parsed); + return parsed; + } + } catch (_) { + // fall through to a fresh decrypt + } + } + // Coalesce concurrent decrypts of the SAME event. Multiple subscribers + // (history list, notifications) often process an event at once; without + // this they each hit the (serialized, slow) extension before the cache + // fills, multiplying extension round-trips per message. + const inFlight = this.dmDecryptInFlight.get(eventId); + if (inFlight) { + return inFlight; + } } - const decryptDM = await this.ensureDmDecryptor(); - const context = await this.buildDmDecryptContext(actorPubkey); + const decryptPromise = (async () => { + const decryptDM = await this.ensureDmDecryptor(); + const context = await this.buildDmDecryptContext(actorPubkey); - let result; + let result; + try { + result = await decryptDM(event, context); + } catch (error) { + devLogger.warn("[nostr] DM decryptor threw unexpectedly.", { + error: sanitizeDecryptError(error), + event: summarizeDmEventForLog(event), + }); + throw error; + } + + if (result?.ok && eventId) { + this.dmDecryptCache.set(eventId, result); + try { + dmPlaintextCache.set(eventId, JSON.stringify(result)); + } catch (_) { + // result not serializable — skip persisting + } + } + + return result; + })(); + + if (eventId) { + this.dmDecryptInFlight.set(eventId, decryptPromise); + } try { - result = await decryptDM(event, context); - } catch (error) { - devLogger.warn("[nostr] DM decryptor threw unexpectedly.", { - error: sanitizeDecryptError(error), - event: summarizeDmEventForLog(event), - }); - throw error; + return await decryptPromise; + } finally { + if (eventId) { + this.dmDecryptInFlight.delete(eventId); + } } - - if (result?.ok && eventId) { - this.dmDecryptCache.set(eventId, result); - } - - return result; } /** @@ -1436,6 +1487,8 @@ export class NostrClient { settled = true; if (timeoutId) clearTimeout(timeoutId); sub.unsub(); + // Persist any newly-decrypted DM results once, after the batch. + dmPlaintextCache.flush(); messages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); resolve(messages); }; @@ -3321,23 +3374,36 @@ export class NostrClient { devLogger.log("[subscribeVideos] Subscribing with filter:", filter); - const sub = this.pool.sub(this.getHealthyRelays(this.relays), [filter]); + // Mirror the empty-relay fallback used by the fetch paths above: if no relays + // are currently healthy (e.g. the initial 5s connect probe timed out on a + // cold first load), fall back to the default relay set instead of subscribing + // to ZERO relays. A zero-relay subscription silently never delivers events + // and never self-heals when relays reconnect in the background, which forces + // the user to refresh the page before the feed loads anything. + const healthyRelays = sanitizeRelayList(this.getHealthyRelays(this.relays)); + const relaysToUse = healthyRelays.length + ? healthyRelays + : Array.from(DEFAULT_RELAY_URLS); + const sub = this.pool.sub(relaysToUse, [filter]); // BUFFERING STATE // We collect events here instead of processing them instantly to avoid // 1000s of React re-renders during the initial relay dump. // The buffer acts as a pressure valve between the network and the UI. - const buffer = new VideoEventBuffer(this, onVideo); + // `videoEventVerifier` lets tests virtualize the off-thread signature worker; + // undefined in production so the buffer uses its default worker verifier. + const buffer = new VideoEventBuffer(this, onVideo, { + verifyEvents: this.videoEventVerifier, + }); // 1) On each incoming event, just push to the buffer and schedule a flush sub.on("event", (event) => { buffer.push(event); }); - // You can still use sub.on("eose") if needed - sub.on("eose", () => { - buffer.handleEose(); - }); + // You can still use sub.on("eose") if needed. Return the flush promise so + // callers/tests can await the (now async, off-thread-verified) commit. + sub.on("eose", () => buffer.handleEose()); // Return the subscription object if you need to unsub manually later const originalUnsub = diff --git a/js/nostr/managers/ConnectionManager.js b/js/nostr/managers/ConnectionManager.js index 35fff540..c96dbc6d 100644 --- a/js/nostr/managers/ConnectionManager.js +++ b/js/nostr/managers/ConnectionManager.js @@ -141,7 +141,13 @@ export class ConnectionManager { } const creation = Promise.resolve().then(() => { - const instance = new SimplePool(); + // PERF (pending security decision — see PR discussion): the default + // SimplePool verifies every incoming event's schnorr signature on the main + // thread, which profiling showed costs ~1.2s of blocking CPU during feed + // load / tab switches (it dropped to ~16ms when skipped). Skipping trusts + // relays not to forge authorship. Alternatives: verify in a Web Worker, or + // only skip for the user's own trustedRelayURLs. + const instance = new SimplePool({ verifyEvent: () => true }); if (typeof instance.ensureRelay === "function") { const originalEnsureRelay = instance.ensureRelay.bind(instance); diff --git a/js/nostr/persistedPlaintextCache.js b/js/nostr/persistedPlaintextCache.js new file mode 100644 index 00000000..c4086cba --- /dev/null +++ b/js/nostr/persistedPlaintextCache.js @@ -0,0 +1,97 @@ +// Shared factory for a persisted "decrypted plaintext" cache keyed by event id. +// +// Several nostr surfaces (watch-history chunks, direct messages) decrypt many +// immutable events, each of which is a serialized round-trip to the nip-07 +// extension (the private key lives in the extension and cannot be cached +// locally). Caching the decrypted plaintext by event id means reloads decrypt +// only events we haven't seen before, skipping the extension for the rest — +// across sessions. +// +// The cached plaintext is no more sensitive than the data each surface already +// persists (watch-history items; DM previews). Callers must clear the cache on +// logout so a decrypted secret never outlives its session. +// +// @param {string} storageKey localStorage key for this cache. +// @param {number} [maxEntries] cap on retained entries (most-recent kept). + +export function createPersistedPlaintextCache(storageKey, maxEntries = 400) { + /** @type {Map | null} */ + let cache = null; + let dirty = false; + + function ensure() { + if (cache) { + return cache; + } + cache = new Map(); + try { + if (typeof localStorage !== "undefined") { + const raw = localStorage.getItem(storageKey); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + cache = new Map(parsed); + } + } + } + } catch (_) { + cache = new Map(); + } + return cache; + } + + function get(eventId) { + if (!eventId) { + return null; + } + const c = ensure(); + return c.has(eventId) ? c.get(eventId) : null; + } + + function set(eventId, plaintext) { + if (!eventId || typeof plaintext !== "string" || !plaintext) { + return; + } + const c = ensure(); + if (c.get(eventId) === plaintext) { + return; + } + c.set(eventId, plaintext); + dirty = true; + } + + // Persist pending writes once (call after a batch of decrypts). Bounds size. + function flush() { + if (!dirty) { + return; + } + dirty = false; + try { + if (typeof localStorage === "undefined") { + return; + } + let entries = Array.from(cache.entries()); + if (entries.length > maxEntries) { + entries = entries.slice(entries.length - maxEntries); + cache = new Map(entries); + } + localStorage.setItem(storageKey, JSON.stringify(entries)); + } catch (_) { + // ignore persistence failures (quota/availability) + } + } + + function clear() { + cache = new Map(); + dirty = false; + try { + if (typeof localStorage !== "undefined") { + localStorage.removeItem(storageKey); + } + } catch (_) { + // ignore + } + } + + return { get, set, flush, clear }; +} diff --git a/js/nostr/signatureVerifyWorker.js b/js/nostr/signatureVerifyWorker.js new file mode 100644 index 00000000..8680cad4 --- /dev/null +++ b/js/nostr/signatureVerifyWorker.js @@ -0,0 +1,56 @@ +// Web Worker: verifies Nostr event signatures off the main thread. +// +// The relay SimplePool is constructed with `verifyEvent: () => true` so it never +// blocks the main thread verifying incoming events (profiling showed ~1.2s of +// schnorr work during feed load). Instead, batches of events are verified here, +// in parallel with the UI, and the caller commits only the ids reported valid. +// +// Message protocol: +// in: { id, events: Array } +// out: { id, ok: true, validIds: string[] } | { id, ok: false, error } + +import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js"; + +async function resolveTools() { + const cached = getCachedNostrTools(); + if (cached && typeof cached.verifyEvent === "function") { + return cached; + } + return (await ensureNostrTools()) || cached; +} + +function verifyOne(tools, event) { + try { + if (!event || typeof event !== "object") return false; + if (typeof tools.validateEvent === "function" && !tools.validateEvent(event)) { + return false; + } + return tools.verifyEvent(event) === true; + } catch (_) { + return false; + } +} + +self.addEventListener("message", async (msg) => { + const data = msg?.data || {}; + const { id, events } = data; + try { + const tools = await resolveTools(); + if (!tools || typeof tools.verifyEvent !== "function") { + throw new Error("signature-verification-unavailable"); + } + const validIds = []; + for (const event of Array.isArray(events) ? events : []) { + if (event && event.id && verifyOne(tools, event)) { + validIds.push(event.id); + } + } + self.postMessage({ id, ok: true, validIds }); + } catch (error) { + self.postMessage({ + id, + ok: false, + error: { name: error?.name || "Error", message: error?.message || "verify-worker-error" }, + }); + } +}); diff --git a/js/nostr/signatureVerifyWorkerClient.js b/js/nostr/signatureVerifyWorkerClient.js new file mode 100644 index 00000000..e86d4fd9 --- /dev/null +++ b/js/nostr/signatureVerifyWorkerClient.js @@ -0,0 +1,165 @@ +// Client for signatureVerifyWorker. Verifies event batches off the main thread +// and resolves with the Set of valid event ids. Falls back to main-thread +// verification only when Web Workers are unavailable, so signatures are still +// checked (never silently trusted) in that degraded case. + +import { devLogger } from "../utils/logger.js"; +import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js"; + +// Short timeout: if the worker can't answer quickly (e.g. nostr-tools is slow to +// load inside the worker), we must NOT stall the feed — fall back to the +// already-loaded main-thread tools instead. After one failure we stop using the +// worker entirely for the session so we never pay the timeout twice. +const DEFAULT_TIMEOUT_MS = 2500; + +let workerInstance = null; +let workerReady = false; +let workerDisabled = false; +let requestId = 0; +const pending = new Map(); + +function attachWorkerListeners(worker) { + if (!worker || workerReady) return; + + worker.addEventListener("message", (event) => { + const data = event?.data || {}; + const entry = pending.get(data.id); + if (!entry) return; + pending.delete(data.id); + clearTimeout(entry.timeoutId); + if (data.ok && Array.isArray(data.validIds)) { + entry.resolve(new Set(data.validIds)); + } else { + entry.reject(new Error(data?.error?.message || "verify-worker-error")); + } + }); + + worker.addEventListener("error", (error) => { + devLogger.warn("[signatureVerifyWorkerClient] Worker error", error); + disableWorker(); + for (const entry of pending.values()) { + clearTimeout(entry.timeoutId); + entry.reject(error instanceof Error ? error : new Error("verify-worker-error")); + } + pending.clear(); + }); + + workerReady = true; +} + +// Stop using the worker for the rest of the session (it hung or errored). All +// subsequent verification goes straight to the main-thread tools, which are +// already loaded by the app. +function disableWorker() { + workerDisabled = true; + if (workerInstance) { + try { + workerInstance.terminate(); + } catch (_) { + // ignore + } + } + workerInstance = null; +} + +function ensureWorker() { + if (workerDisabled) return null; + if (workerInstance) return workerInstance; + if (typeof Worker === "undefined") return null; + try { + workerInstance = new Worker(new URL("./signatureVerifyWorker.js", import.meta.url), { + type: "module", + }); + attachWorkerListeners(workerInstance); + } catch (error) { + devLogger.warn("[signatureVerifyWorkerClient] Failed to create worker", error); + workerInstance = null; + } + return workerInstance; +} + +// Main-thread fallback used only when Workers are unavailable. Preserves the +// security guarantee (still verifies) at the cost of main-thread time. +async function verifyOnMainThread(events) { + let tools = getCachedNostrTools(); + if (!tools || typeof tools.verifyEvent !== "function") { + try { + // Bound the wait: if nostr-tools is slow/unable to load, we must not stall + // the feed waiting for it — fail open below instead of hanging. + tools = await Promise.race([ + ensureNostrTools(), + new Promise((resolve) => setTimeout(() => resolve(null), 2000)), + ]); + } catch (_) { + tools = null; + } + } + const valid = new Set(); + if (!tools || typeof tools.verifyEvent !== "function") { + // The verification infrastructure itself is unavailable (e.g. nostr-tools + // failed to load in this context). Fail OPEN for the read feed: dropping + // every event would blank the feed entirely, which is far worse than briefly + // showing unverified events when the verifier is broken. The pool already + // does not verify, so this does not regress the prior security posture. + devLogger.warn( + "[signatureVerifyWorkerClient] verifier unavailable; passing events through unverified", + ); + for (const event of events) { + if (event?.id) valid.add(event.id); + } + return valid; + } + for (const event of events) { + try { + if (!event || !event.id) continue; + if (typeof tools.validateEvent === "function" && !tools.validateEvent(event)) continue; + if (tools.verifyEvent(event) === true) valid.add(event.id); + } catch (_) { + // skip + } + } + return valid; +} + +/** + * Verify a batch of events. Resolves with a Set of valid event ids. + * @param {Array} events + * @param {{ timeoutMs?: number }} [options] + * @returns {Promise>} + */ +export function verifyEventsInWorker(events, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const list = Array.isArray(events) ? events.filter((e) => e && e.id) : []; + if (!list.length) return Promise.resolve(new Set()); + + const worker = ensureWorker(); + if (!worker) { + return verifyOnMainThread(list); + } + + const id = ++requestId; + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + pending.delete(id); + // The worker hung (e.g. couldn't load nostr-tools). Stop using it so we + // don't pay this timeout on every subsequent batch, then fall back. + disableWorker(); + reject(new Error("verify-worker-timeout")); + }, timeoutMs); + pending.set(id, { resolve, reject, timeoutId }); + try { + worker.postMessage({ id, events: list }); + } catch (error) { + clearTimeout(timeoutId); + pending.delete(id); + reject(error instanceof Error ? error : new Error("verify-worker-post-failed")); + } + }).catch(async (error) => { + // On worker failure/timeout, fall back to main-thread verification rather + // than dropping events or trusting them blindly. + devLogger.warn("[signatureVerifyWorkerClient] Falling back to main-thread verify:", error?.message || error); + return verifyOnMainThread(list); + }); +} + +// Test/diagnostic helpers. +export const __signatureVerifyInternals = { verifyOnMainThread }; diff --git a/js/nostr/videoEventBuffer.js b/js/nostr/videoEventBuffer.js index 0af08a73..46f13e01 100644 --- a/js/nostr/videoEventBuffer.js +++ b/js/nostr/videoEventBuffer.js @@ -9,18 +9,34 @@ import { isDevMode } from "../config.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { convertEventToVideo } from "./nip71.js"; +// Default: trust relays for the public video feed (no per-event signature +// verification). Verifying every feed event off-thread proved fragile (it could +// hang/blank the feed and added load latency), so it is opt-in: pass a +// `verifyEvents` function (e.g. verifyEventsInWorker) to re-enable it. +const trustAllVerifier = (events) => + Promise.resolve(new Set((events || []).map((e) => e && e.id).filter(Boolean))); + export class VideoEventBuffer { /** * @param {import("./client.js").NostrClient} client - The parent client instance (for state access). * @param {function(object[]): void} onVideo - The UI callback to fire with batched updates. + * @param {{ verifyEvents?: (events: object[]) => Promise> }} [options] + * Optional signature verifier (returns the set of valid event ids). Defaults + * to trusting relays (no verification) for the public feed. */ - constructor(client, onVideo) { + constructor(client, onVideo, options = {}) { this.client = client; this.onVideo = onVideo; this.buffer = []; this.invalidEvents = []; this.flushTimerId = null; this.FLUSH_DEBOUNCE_MS = 75; + this._verifyEvents = + typeof options.verifyEvents === "function" + ? options.verifyEvents + : trustAllVerifier; + // Tracks the in-flight verify+commit so callers/tests can await a flush. + this._flushPromise = Promise.resolve(); // Buffer updates while hidden to avoid UI thrashing this.pendingVideos = []; @@ -48,21 +64,21 @@ export class VideoEventBuffer { scheduleFlush(immediate = false) { if (this.flushTimerId) { if (!immediate) { - return; + return this._flushPromise; } clearTimeout(this.flushTimerId); this.flushTimerId = null; } if (immediate) { - this.flush(); - return; + return this.flush(); } this.flushTimerId = setTimeout(() => { this.flushTimerId = null; this.flush(); }, this.FLUSH_DEBOUNCE_MS); + return this._flushPromise; } /** @@ -83,6 +99,40 @@ export class VideoEventBuffer { const toProcess = this.buffer; this.buffer = []; + // The relay pool no longer verifies signatures on the main thread (that cost + // ~1.2s during feed load). Verify this batch off-thread and commit only the + // events that pass; fail closed (drop the batch) on total verification + // failure rather than rendering unverified content. Returns the in-flight + // promise so callers/tests can await commit completion. + this._flushPromise = this._verifyAndCommit(toProcess); + return this._flushPromise; + } + + async _verifyAndCommit(toProcess) { + let verified; + try { + const validIds = await this._verifyEvents(toProcess); + verified = toProcess.filter((evt) => evt && evt.id && validIds.has(evt.id)); + const dropped = toProcess.length - verified.length; + if (dropped > 0) { + devLogger.warn( + `[VideoEventBuffer] Dropped ${dropped} event(s) with invalid/unverifiable signatures.`, + ); + } + } catch (error) { + devLogger.error( + "[VideoEventBuffer] Signature verification failed; dropping batch:", + error, + ); + return; + } + if (!verified.length) { + return; + } + this._commitVerifiedBatch(verified); + } + + _commitVerifiedBatch(toProcess) { const updatedVideos = []; for (const evt of toProcess) { @@ -198,7 +248,7 @@ export class VideoEventBuffer { devLogger.log( "[subscribeVideos] Reached EOSE for all relays (historical load done)" ); - this.scheduleFlush(true); + return this.scheduleFlush(true); } /** @@ -214,6 +264,6 @@ export class VideoEventBuffer { document.removeEventListener("visibilitychange", this.handleVisibilityChange); } // Ensure any straggling events are flushed before tearing down. - this.flush(); + return this.flush(); } } diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index a174aa28..b1680326 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -33,6 +33,11 @@ import { } from "../utils/pointerNormalization.js"; import { pMap } from "../utils/asyncUtils.js"; import { selectNewestListEvent } from "./listEventOrdering.js"; +import { + getCachedChunkPlaintext, + setCachedChunkPlaintext, + flushDecryptedChunkCache, +} from "./watchHistoryDecryptCache.js"; export { normalizePointerInput, pointerKey }; @@ -483,6 +488,25 @@ function hexToBytesCompat(hex, tools = null) { return bytes; } +// Memoizes NIP-44 conversation keys across an entire load. Deriving a +// conversation key is an ECDH operation (~12ms each in-bundle); without this +// cache the watch-history decrypt path re-derived the *same* key once per +// encrypted chunk (~150x for a full 1,500-item history), synchronously blocking +// the main thread for seconds. The key depends only on (scheme, privateKey, +// target), so it is safe to compute once and reuse. Cleared on logout alongside +// the decryption-scheme cache. +// +// @type {Map} +const conversationKeyCache = new Map(); + +/** + * Clear all memoized NIP-44 conversation keys. Must be called on logout/identity + * change so a derived shared secret never outlives its session. + */ +export function clearWatchHistoryConversationKeyCache() { + conversationKeyCache.clear(); +} + function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { if (!tools || !privateKeyHex || !targetPubkeyHex) { return null; @@ -516,10 +540,12 @@ function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { typeof nip44v2.decrypt === "function" && typeof nip44v2?.utils?.getConversationKey === "function" ) { - let cachedKey = null; + const v2CacheKey = `v2:${normalizedPrivateKey}:${normalizedTarget}`; const ensureKey = () => { + let cachedKey = conversationKeyCache.get(v2CacheKey); if (!cachedKey) { cachedKey = nip44v2.utils.getConversationKey(privateKeyBytes, normalizedTarget); + conversationKeyCache.set(v2CacheKey, cachedKey); } return cachedKey; }; @@ -544,10 +570,12 @@ function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { typeof nip44?.decrypt === "function" && typeof legacyGetConversationKey === "function" ) { - let cachedKey = null; + const legacyCacheKey = `legacy:${normalizedPrivateKey}:${normalizedTarget}`; const ensureLegacyKey = () => { + let cachedKey = conversationKeyCache.get(legacyCacheKey); if (!cachedKey) { cachedKey = legacyGetConversationKey(privateKeyBytes, normalizedTarget); + conversationKeyCache.set(legacyCacheKey, cachedKey); } return cachedKey; }; @@ -2019,6 +2047,11 @@ class WatchHistoryManager { return []; } let plaintext = looksLikeJsonStructure(ciphertext) ? ciphertext : ""; + // Reuse a previously-decrypted plaintext for this immutable chunk + // event id instead of round-tripping to the nip-07 extension again. + if (!plaintext) { + plaintext = getCachedChunkPlaintext(event.id) || ""; + } if (!plaintext) { plaintext = await decryptWatchHistoryCiphertext( { @@ -2031,6 +2064,9 @@ class WatchHistoryManager { }, toolkitCacheRef, ); + if (plaintext) { + setCachedChunkPlaintext(event.id, plaintext); + } } if (!plaintext) { return []; @@ -2043,6 +2079,7 @@ class WatchHistoryManager { }, { concurrency: 5 }, ); + flushDecryptedChunkCache(); decryptedItems.push(...chunkResults.flat()); } catch (error) { devLogger.warn("[nostr] Failed to fetch watch history chunks:", error); @@ -2068,17 +2105,24 @@ class WatchHistoryManager { let payloadContent = ciphertext; if (!looksLikeJsonStructure(payloadContent) && canAttemptDecrypt) { - const decrypted = await decryptWatchHistoryCiphertext( - { - event, - ciphertext: payloadContent, - actorKey, - decryptSigner, - sessionPrivateKey, - deps: this.deps, - }, - toolkitCacheRef, - ); + let decrypted = getCachedChunkPlaintext(event.id) || ""; + if (!decrypted) { + decrypted = await decryptWatchHistoryCiphertext( + { + event, + ciphertext: payloadContent, + actorKey, + decryptSigner, + sessionPrivateKey, + deps: this.deps, + }, + toolkitCacheRef, + ); + if (decrypted) { + setCachedChunkPlaintext(event.id, decrypted); + flushDecryptedChunkCache(); + } + } if (decrypted) { payloadContent = decrypted; } @@ -2329,4 +2373,5 @@ export const watchHistoryHelpers = { serializeWatchHistoryItems, computeWatchHistoryFingerprintForItems, extractPointerItemsFromEvent, + createNip44CipherSuite, }; diff --git a/js/nostr/watchHistoryDecryptCache.js b/js/nostr/watchHistoryDecryptCache.js new file mode 100644 index 00000000..f018157d --- /dev/null +++ b/js/nostr/watchHistoryDecryptCache.js @@ -0,0 +1,14 @@ +// Persisted cache of decrypted watch-history chunk plaintext, keyed by the +// immutable chunk event id. Backed by the shared persisted-plaintext-cache +// factory (see persistedPlaintextCache.js). An unchanged chunk keeps its event +// id, so reloads decrypt only the chunk(s) that actually changed and skip the +// nip-07 extension for the rest. Cleared on logout. + +import { createPersistedPlaintextCache } from "./persistedPlaintextCache.js"; + +const cache = createPersistedPlaintextCache("bitvid:whDecryptedChunks:v1", 400); + +export const getCachedChunkPlaintext = cache.get; +export const setCachedChunkPlaintext = cache.set; +export const flushDecryptedChunkCache = cache.flush; +export const clearWatchHistoryDecryptedChunkCache = cache.clear; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index ff1d9dc5..639f2713 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -1556,17 +1556,17 @@ export class NostrService { } async ensureAccessControlReady() { - if (!this.accessControl || typeof this.accessControl.waitForReady !== "function") { - return; - } - + const ac = this.accessControl; + if (!ac) return; + // Block on the remote admin fetch only on a first-ever cold whitelist load; + // else the hydrated cache is accurate, so refresh in bg (handlers re-filter). + const hydrated = ac.isHydrated?.() ?? ac.hasLoaded === true; + const block = !hydrated && (ac.whitelistMode?.() ?? false); + const ready = block ? ac.waitForReady : ac.ensureReady || ac.waitForReady; try { - await this.accessControl.waitForReady(); + if (typeof ready === "function") await ready.call(ac); } catch (error) { - userLogger.warn( - "[nostrService] Failed to ensure access control lists are ready:", - error - ); + userLogger.warn("[nostrService] access control ready failed:", error); } } diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 65605954..63c44fb1 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -275,6 +275,8 @@ export default class ApplicationBootstrap { app.registerExploreFeed(); app.registerSubscriptionsFeed(); app.registerWatchHistoryFeed(); + // Re-run the For You feed when background watch-history resolution completes. + app.subscribeWatchHistoryFeedRefresh(); app.watchHistoryController = new WatchHistoryController({ watchHistoryService, diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 85c5e198..d13c1f15 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -663,6 +663,7 @@ async function updateFingerprintCache(actorKey, items) { items ); const entry = state.fingerprintCache.get(actorKey) || {}; + const previousFingerprint = entry.fingerprint; const nextEntry = { ...entry, items: Array.isArray(items) ? items : [], @@ -671,7 +672,17 @@ async function updateFingerprintCache(actorKey, items) { }; delete nextEntry.promise; state.fingerprintCache.set(actorKey, nextEntry); - emit("fingerprint", { actor: actorKey, fingerprint, items: nextEntry.items }); + // Only emit when the fingerprint actually CHANGED. Emitting unconditionally + // created an infinite loop: the For You feed listens for "fingerprint" and + // re-runs refreshFeed -> loadLatest -> updateFingerprintCache -> emit, which + // pegged the CPU with a full feed rebuild every ~400ms. + if (fingerprint !== previousFingerprint) { + emit("fingerprint", { + actor: actorKey, + fingerprint, + items: nextEntry.items, + }); + } return fingerprint; } @@ -1278,7 +1289,7 @@ async function loadLatest(actorInput, options = {}) { return mergeQueuedItemsIfNeeded(actorKey, resolved); } - if (!allowStale || !hasCachedItems) { + if (!allowStale) { const resolved = await scheduleWatchHistoryRefresh( actorKey, cacheEntry, @@ -1287,6 +1298,13 @@ async function loadLatest(actorInput, options = {}) { return mergeQueuedItemsIfNeeded(actorKey, resolved); } + // allowStale callers (e.g. the For You feed) must NEVER block on the full + // fetch — even on a cold load with no cache. Previously this path awaited the + // refresh whenever there were no cached items, so the feed froze behind the + // entire ~1500-item watch-history resolution. Instead, kick the refresh off in + // the background and return immediately with whatever is already available + // (cached items, else the local queue). These callers re-run when the later + // "fingerprint" event fires with the resolved history. const refreshPromise = scheduleWatchHistoryRefresh( actorKey, cacheEntry, @@ -1294,15 +1312,21 @@ async function loadLatest(actorInput, options = {}) { ); refreshPromise.catch(() => {}); + // Return the best available items immediately — in-memory cache, else the + // persisted localStorage snapshot. The in-memory caches are empty after a page + // reload, so without the persisted fallback the For You feed would suppress + // nothing until the (slow) background network refresh completes. Persisted + // items were previously valid; the background refresh corrects them. + const immediateItems = getCachedSnapshotItems(actorKey); devLogger.info( - "[watchHistoryService] Returning stale watch list items while refresh is pending.", + "[watchHistoryService] Returning immediately; watch history refreshing in background.", { actor: actorKey, - itemCount: cacheEntry?.items?.length || 0, + itemCount: immediateItems.length, } ); - return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items || []); + return mergeQueuedItemsIfNeeded(actorKey, immediateItems); } async function getFingerprint(actorInput) { @@ -1324,13 +1348,16 @@ async function getFingerprint(actorInput) { } const fingerprint = await nostrClient.getWatchHistoryFingerprint(actorKey); const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0); + const previousFingerprint = cacheEntry?.fingerprint; const nextEntry = { ...(cacheEntry || {}), fingerprint, expiresAt: now + ttl, }; state.fingerprintCache.set(actorKey, nextEntry); - emit("fingerprint", { actor: actorKey, fingerprint }); + if (fingerprint !== previousFingerprint) { + emit("fingerprint", { actor: actorKey, fingerprint }); + } return fingerprint; } diff --git a/scripts/perf/diagnose-feed.mjs b/scripts/perf/diagnose-feed.mjs new file mode 100644 index 00000000..cb00f74c --- /dev/null +++ b/scripts/perf/diagnose-feed.mjs @@ -0,0 +1,44 @@ +import { chromium } from "playwright"; + +const URL = process.argv[2] || "http://localhost:3000/"; +const WAIT_MS = Number(process.env.WAIT_MS || 20000); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`.slice(0, 300))); +page.on("pageerror", (e) => logs.push(`[pageerror] ${String(e).split("\n")[0]}`)); + +await page.goto(URL, { waitUntil: "domcontentloaded" }); +await page.waitForTimeout(WAIT_MS); + +const state = await page.evaluate(async () => { + const out = {}; + try { + const t = window.__bitvidTest__; + out.harness = !!t; + if (t && typeof t.getAppState === "function") { + out.appState = await t.getAppState(); + } + } catch (e) { + out.harnessError = String(e).split("\n")[0]; + } + const list = document.querySelector('[data-testid="video-list"]'); + out.videoListFound = !!list; + out.videoListChildCount = list ? list.children.length : null; + out.videoListText = list ? (list.textContent || "").trim().slice(0, 200) : null; + out.cardsByDataVideoId = document.querySelectorAll("[data-video-id]").length; + // any visible error/lockdown banners + out.bodyTextSample = (document.body.textContent || "").replace(/\s+/g, " ").trim().slice(0, 400); + return out; +}); + +console.log("\n===== FEED DIAGNOSIS ====="); +console.log(JSON.stringify(state, null, 2)); +console.log("\n===== CONSOLE (errors/warnings first 40) ====="); +for (const l of logs.filter((l) => /error|warn|fail|lockdown|relay|reject/i.test(l)).slice(0, 40)) { + console.log(l); +} +console.log(`\n(total console lines: ${logs.length})`); +await browser.close(); diff --git a/scripts/perf/dm-repro.mjs b/scripts/perf/dm-repro.mjs new file mode 100644 index 00000000..b845594e --- /dev/null +++ b/scripts/perf/dm-repro.mjs @@ -0,0 +1,137 @@ +// Focused reproduction of DM history, using the fake nip-07 extension. +// +// Seeds legacy (kind 4 / NIP-04) DMs between the user and a peer on a mock relay, +// logs in via the fake extension, then calls nostrClient.listDirectMessages and +// reports what comes back (decrypted messages, errors, extension call counts) — +// so we can see whether DM history is broken vs slow, and why. +// +// Usage: node scripts/perf/dm-repro.mjs (env: DM_MSGS=20 LATENCY_MS=0) + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey, generateSecretKey, nip04 } from "nostr-tools"; +import * as nip44 from "nostr-tools/nip44"; + +const APP = process.env.APP || "http://localhost:3000"; +const LATENCY_MS = Number(process.env.LATENCY_MS ?? 0); +const DM_MSGS = Number(process.env.DM_MSGS ?? 20); +const WS_PORT = 8962; + +const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); +const USER_PK = getPublicKey(USER_SK); +const PEER_SK = generateSecretKey(); +const PEER_PK = getPublicKey(PEER_SK); + +let extCalls = {}; +function resetExtCalls() { + extCalls = {}; +} +let queue = Promise.resolve(); +function viaExtension(method, fn) { + extCalls[method] = (extCalls[method] || 0) + 1; + const run = async () => { + if (LATENCY_MS > 0) await new Promise((r) => setTimeout(r, LATENCY_MS)); + return fn(); + }; + const p = queue.then(run, run); + queue = p.catch(() => {}); + return p; +} +const convKey = (peerPk) => nip44.v2.utils.getConversationKey(USER_SK, peerPk); + +async function main() { + const relay = startRelay(WS_PORT, { httpPort: false }); + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + const now = Math.floor(Date.now() / 1000); + + relay.seedEvent(finalizeEvent({ kind: 10002, created_at: now, tags: [["r", relayUrl]], content: "" }, USER_SK)); + + // Seed kind-4 DMs: peer -> user (received) and user -> peer (sent). + for (let i = 0; i < DM_MSGS; i++) { + const fromPeer = i % 2 === 0; + const sk = fromPeer ? PEER_SK : USER_SK; + const authorPk = fromPeer ? PEER_PK : USER_PK; + const otherPk = fromPeer ? USER_PK : PEER_PK; + const text = `DM message ${i} (${fromPeer ? "from peer" : "to peer"})`; + const content = nip04.encrypt(sk, otherPk, text); + relay.seedEvent( + finalizeEvent( + { kind: 4, created_at: now - i * 60, tags: [["p", otherPk]], content }, + sk, + ), + ); + } + console.log(`Seeded ${DM_MSGS} kind-4 DMs. user=${USER_PK.slice(0, 10)} peer=${PEER_PK.slice(0, 10)}`); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); + const context = await browser.newContext(); + await context.exposeBinding("__extGetPublicKey", () => viaExtension("getPublicKey", () => USER_PK)); + await context.exposeBinding("__extSignEvent", (_s, e) => viaExtension("signEvent", () => finalizeEvent({ ...e, pubkey: USER_PK }, USER_SK))); + await context.exposeBinding("__extNip04Encrypt", (_s, pk, t) => viaExtension("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, t))); + await context.exposeBinding("__extNip04Decrypt", (_s, pk, ct) => viaExtension("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct))); + await context.exposeBinding("__extNip44Encrypt", (_s, pk, t) => viaExtension("nip44.encrypt", () => nip44.v2.encrypt(t, convKey(pk)))); + await context.exposeBinding("__extNip44Decrypt", (_s, pk, ct) => viaExtension("nip44.decrypt", () => nip44.v2.decrypt(ct, convKey(pk)))); + await context.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + localStorage.setItem("bitvid_admin_whitelist_mode", "false"); + const w = (n) => (...a) => window[n](...a); + window.nostr = { + getPublicKey: () => w("__extGetPublicKey")(), + signEvent: (e) => w("__extSignEvent")(e), + getRelays: async () => ({}), + nip04: { encrypt: (p, t) => w("__extNip04Encrypt")(p, t), decrypt: (p, c) => w("__extNip04Decrypt")(p, c) }, + nip44: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c), v2: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c) } }, + }; + }, relayUrl); + + async function session(label) { + const page = await context.newPage(); + await page.goto(`${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + try { + await page.click('[data-testid="login-button"]', { timeout: 8000 }); + await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 }); + const extBtn = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first(); + if (await extBtn.count()) await extBtn.click({ timeout: 5000 }); + else await page.locator('[data-testid="login-provider-button"]').first().click({ timeout: 5000 }); + } catch (_) {} + await page.waitForFunction(async () => (await window.__bitvidTest__?.getAppState?.())?.isLoggedIn === true, { timeout: 15000 }).catch(() => {}); + const result = await page.evaluate(async (userPk) => { + const c = window.__bitvidTest__?.nostrClient; + if (!c || typeof c.listDirectMessages !== "function") return { ok: false, reason: "no-listDirectMessages" }; + try { + const msgs = await c.listDirectMessages(userPk, { timeoutMs: 8000 }); + return { ok: true, count: Array.isArray(msgs) ? msgs.length : 0 }; + } catch (e) { + return { ok: false, reason: String(e).split("\n")[0] }; + } + }, USER_PK); + await page.waitForTimeout(1000); + await page.close(); + return result; + } + + resetExtCalls(); + const cold = await session("cold"); + const coldCalls = { ...extCalls }; + resetExtCalls(); + const warm = await session("warm"); + const warmCalls = { ...extCalls }; + + const dCold = coldCalls["nip04.decrypt"] || 0; + const dWarm = warmCalls["nip04.decrypt"] || 0; + console.log("\n================ DM REPRO ================"); + console.log(`COLD: ${JSON.stringify(cold)} | extension calls: ${JSON.stringify(coldCalls)}`); + console.log(`WARM: ${JSON.stringify(warm)} | extension calls: ${JSON.stringify(warmCalls)}`); + console.log(`nip04.decrypt: cold=${dCold} warm=${dWarm} -> persist cache ${dWarm < dCold ? "WORKS ✓" : "no effect"} (saved ${dCold - dWarm} extension calls)`); + console.log("=========================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { console.error("dm-repro failed:", e); process.exit(1); }); diff --git a/scripts/perf/profile-live.mjs b/scripts/perf/profile-live.mjs new file mode 100644 index 00000000..e7e4b269 --- /dev/null +++ b/scripts/perf/profile-live.mjs @@ -0,0 +1,138 @@ +// Standalone live profiler for the running bitvid dev server. +// Drives the real app in Chromium, captures a CPU profile (CDP Profiler) and a +// long-task report during cold load + interaction, then prints the hottest +// functions by self-time and the worst long tasks. +// +// Usage: node scripts/perf/profile-live.mjs [url] +// default url: http://localhost:3000/ + +import { chromium } from "playwright"; + +const URL = process.argv[2] || "http://localhost:3000/"; +const SETTLE_MS = Number(process.env.SETTLE_MS || 18000); + +function fmt(ms) { + return `${ms.toFixed(0)}ms`; +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + // Long-task observer installed before any app code runs. + await page.addInitScript(() => { + window.__longtasks__ = []; + try { + const obs = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + window.__longtasks__.push({ + start: entry.startTime, + duration: entry.duration, + name: entry.name, + attribution: (entry.attribution || []).map((a) => ({ + name: a.name, + containerType: a.containerType, + containerName: a.containerName, + containerSrc: a.containerSrc, + })), + }); + } + }); + obs.observe({ entryTypes: ["longtask"] }); + } catch (e) { + // longtask not supported + } + window.__marks__ = {}; + window.__t0__ = performance.now(); + }); + + const cdp = await context.newCDPSession(page); + await cdp.send("Profiler.enable"); + await cdp.send("Profiler.setSamplingInterval", { interval: 100 }); // 100us + await cdp.send("Profiler.start"); + + const navStart = Date.now(); + await page.goto(URL, { waitUntil: "domcontentloaded" }); + + // Time-to-first-video-card. + let firstCardMs = null; + try { + await page.waitForSelector('[data-testid="video-list"] a, [data-testid="video-list"] [data-video-id], [data-testid="video-list"] article', { timeout: SETTLE_MS }); + firstCardMs = Date.now() - navStart; + } catch (e) { + firstCardMs = null; + } + + // Let the feed settle / stream in. + await page.waitForTimeout(SETTLE_MS); + + // Interaction: scroll the feed, then try a tab/nav switch to surface jank. + await page.evaluate(async () => { + window.__marks__.scrollStart = performance.now(); + for (let i = 0; i < 8; i++) { + window.scrollBy(0, 800); + await new Promise((r) => setTimeout(r, 120)); + } + window.__marks__.scrollEnd = performance.now(); + }); + + // Try clicking nav links (subscriptions / explore / home) to switch views. + const navClicks = []; + for (const sel of ["#subscriptionsLink", 'a[href="#view=explore"]', 'a[href="#view=subscriptions"]', 'a[href="#view=home"]']) { + const el = await page.$(sel); + if (el) { + const t = Date.now(); + try { + await el.click({ timeout: 2000 }); + await page.waitForTimeout(1500); + navClicks.push({ sel, ms: Date.now() - t }); + } catch (e) { + navClicks.push({ sel, error: String(e).split("\n")[0] }); + } + } + } + + const { profile } = await cdp.send("Profiler.stop"); + + // Aggregate self-time by function (node hitCount * sampling interval). + const intervalMs = 0.1; // 100us + const byFn = new Map(); + const nodeById = new Map(); + for (const node of profile.nodes) nodeById.set(node.id, node); + for (const node of profile.nodes) { + const cf = node.callFrame; + const key = `${cf.functionName || "(anonymous)"} @ ${(cf.url || "").split("/").slice(-1)[0]}:${cf.lineNumber + 1}`; + const self = (node.hitCount || 0) * intervalMs; + byFn.set(key, (byFn.get(key) || 0) + self); + } + const topFns = [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25); + + const longtasks = await page.evaluate(() => window.__longtasks__ || []); + const totalLongtaskMs = longtasks.reduce((s, t) => s + t.duration, 0); + const worst = [...longtasks].sort((a, b) => b.duration - a.duration).slice(0, 15); + + console.log("\n================ LIVE PROFILE ================"); + console.log(`URL: ${URL}`); + console.log(`Time-to-first-video-card: ${firstCardMs === null ? "NEVER (timed out)" : fmt(firstCardMs)}`); + console.log(`Nav switch timings: ${JSON.stringify(navClicks)}`); + console.log(`\nLong tasks: count=${longtasks.length}, total=${fmt(totalLongtaskMs)}`); + console.log("Worst long tasks (duration | attribution):"); + for (const t of worst) { + const attr = (t.attribution[0] && (t.attribution[0].containerName || t.attribution[0].name)) || t.name; + console.log(` ${fmt(t.duration).padStart(7)} @ ${fmt(t.start)} ${attr}`); + } + + console.log(`\nTop functions by self-time (CPU profile):`); + for (const [fn, ms] of topFns) { + console.log(` ${fmt(ms).padStart(8)} ${fn}`); + } + console.log("=============================================\n"); + + await browser.close(); +} + +main().catch((e) => { + console.error("profiler failed:", e); + process.exit(1); +}); diff --git a/scripts/perf/profile-seeded.mjs b/scripts/perf/profile-seeded.mjs new file mode 100644 index 00000000..f6cb1e16 --- /dev/null +++ b/scripts/perf/profile-seeded.mjs @@ -0,0 +1,222 @@ +// Seeded live profiler: drives the dist build at localhost:3000 in test mode +// against an in-process mock relay seeded with a heavy video feed, then captures +// a CPU profile + long-task report across: cold feed render, tab switches, and +// profile-modal open. Reproduces CPU/DOM render jank without real-relay latency. +// +// Prereq: dev server running at http://localhost:3000 (npm start). +// Usage: node scripts/perf/profile-seeded.mjs [videoCount] + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey, generateSecretKey } from "nostr-tools"; + +const VIDEO_COUNT = Number(process.argv[2] || 400); +const APP = "http://localhost:3000"; +const WS_PORT = 8951; +const TEST_SK_HEX = "7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b"; +const TEST_SK = Uint8Array.from(Buffer.from(TEST_SK_HEX, "hex")); + +function makeVideoEvent(i, sk, pk, createdAt) { + const dTag = `seed-${i}-${Math.random().toString(36).slice(2, 8)}`; + const content = { + version: 3, + title: `Seeded Video ${i}`, + videoRootId: dTag, + mode: "dev", + isPrivate: false, + deleted: false, + url: `https://example.com/v${i}.mp4`, + description: `Perf seed video number ${i}`, + }; + return finalizeEvent( + { + kind: 30078, + created_at: createdAt, + tags: [ + ["d", dTag], + ["t", "video"], + ["title", `Seeded Video ${i}`], + ["url", `https://example.com/v${i}.mp4`], + ], + content: JSON.stringify(content), + pubkey: pk, + }, + sk, + ); +} + +const fmt = (ms) => `${ms.toFixed(0)}ms`; + +async function main() { + // 1. Mock relay (in-process; seed directly). + const relay = startRelay(WS_PORT, { httpPort: false }); + + // A few distinct authors so the feed looks realistic. + const authors = []; + for (let a = 0; a < 6; a++) { + const sk = a === 0 ? TEST_SK : generateSecretKey(); + authors.push({ sk, pk: getPublicKey(sk) }); + } + const now = Math.floor(Date.now() / 1000); + for (let i = 0; i < VIDEO_COUNT; i++) { + const author = authors[i % authors.length]; + relay.seedEvent(makeVideoEvent(i, author.sk, author.pk, now - i * 60)); + } + console.log(`Seeded ${VIDEO_COUNT} videos on ws://127.0.0.1:${WS_PORT}`); + + // 2. Browser + test mode. + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + await page.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + window.__bitvidTestRelays__ = [url]; + localStorage.setItem("bitvid_admin_whitelist_mode", "false"); + // long-task observer + window.__lt__ = []; + try { + new PerformanceObserver((l) => { + for (const e of l.getEntries()) window.__lt__.push({ s: e.startTime, d: e.duration }); + }).observe({ entryTypes: ["longtask"] }); + } catch (e) {} + window.__mark = (name) => { (window.__marks ||= {})[name] = performance.now(); }; + }, relayUrl); + + const logs = []; + page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`.slice(0, 240))); + page.on("pageerror", (e) => logs.push(`[pageerror] ${String(e).split("\n")[0]}`)); + + const cdp = await page.context().newCDPSession(page); + await cdp.send("Profiler.enable"); + await cdp.send("Profiler.setSamplingInterval", { interval: 100 }); + + const testUrl = `${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`; + await page.goto(testUrl, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + // Replicate the e2e fixture: explicitly activate the test relay, then reload so + // the app boots its feed subscription against the mock relay (not real relays). + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + await page.goto(testUrl, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + + await cdp.send("Profiler.start"); + const tNav = Date.now(); + + // 3. Time-to-first-card + rendered count. + let firstCardMs = null; + try { + await page.waitForSelector("[data-video-id]", { timeout: 25000 }); + firstCardMs = Date.now() - tNav; + } catch (e) {} + await page.waitForTimeout(4000); + const cardCount = await page.evaluate(() => document.querySelectorAll("[data-video-id]").length); + + // 4. Scroll jank. + await page.evaluate(async () => { + window.__mark("scrollStart"); + for (let i = 0; i < 12; i++) { window.scrollBy(0, 900); await new Promise(r => setTimeout(r, 100)); } + window.__mark("scrollEnd"); + }); + + // 5. Tab switches (by visible text). + const tabTimings = {}; + for (const label of ["Recently Added", "Explore", "For You"]) { + const t = Date.now(); + try { + await page.getByText(label, { exact: true }).first().click({ timeout: 3000 }); + await page.waitForTimeout(1800); + tabTimings[label] = Date.now() - t; + } catch (e) { + tabTimings[label] = `ERR ${String(e).split("\n")[0].slice(0, 60)}`; + } + } + + // 6. Login + open profile modal (the reported hitch). + let loginOk = false; + try { + loginOk = await page.evaluate(async (sk) => { + if (window.__bitvidTest__?.loginWithNsec) { + await window.__bitvidTest__.loginWithNsec(sk); + return true; + } + return false; + }, TEST_SK_HEX); + } catch (e) {} + await page.waitForTimeout(2500); + + let profileModalMs = null; + try { + const t = Date.now(); + await page.click('[data-testid="profile-button"]', { timeout: 3000 }); + await page.waitForTimeout(2500); + profileModalMs = Date.now() - t; + } catch (e) {} + + const { profile } = await cdp.send("Profiler.stop"); + + // Aggregate CPU self-time by function. + const byFn = new Map(); + for (const node of profile.nodes) { + const cf = node.callFrame; + const file = (cf.url || "").split("/").slice(-1)[0]; + const key = `${cf.functionName || "(anonymous)"} @ ${file}:${cf.lineNumber + 1}`; + byFn.set(key, (byFn.get(key) || 0) + (node.hitCount || 0) * 0.1); + } + const topFns = [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30); + + // Caller attribution: for crypto/vendor leaf frames, attribute self-time to the + // nearest ANCESTOR that lives in app /js/ code, so we learn WHO drives the cost. + const parentOf = new Map(); + const nodeById2 = new Map(); + for (const node of profile.nodes) { + nodeById2.set(node.id, node); + for (const c of node.children || []) parentOf.set(c, node.id); + } + const isVendor = (url) => !url || /node_modules|modular\.mjs|weierstrass\.mjs|nostr-tools|nostr\.bundle|base\.mjs|sha256|webtorrent|\.bundle\.min/.test(url); + const isAppJs = (url) => /\/js\//.test(url) && !isVendor(url); + const cryptoByCaller = new Map(); + for (const node of profile.nodes) { + const cf = node.callFrame; + if (!/modular\.mjs|weierstrass\.mjs/.test(cf.url || "")) continue; + const self = (node.hitCount || 0) * 0.1; + if (self <= 0) continue; + // walk up to nearest app frame + let cur = node.id, hops = 0, appKey = "(no app ancestor)"; + while (cur !== undefined && hops < 60) { + const n = nodeById2.get(cur); + const u = n?.callFrame?.url || ""; + if (isAppJs(u)) { appKey = `${n.callFrame.functionName || "(anon)"} @ ${u.split("/").slice(-1)[0]}:${n.callFrame.lineNumber + 1}`; break; } + cur = parentOf.get(cur); hops++; + } + cryptoByCaller.set(appKey, (cryptoByCaller.get(appKey) || 0) + self); + } + const topCryptoCallers = [...cryptoByCaller.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12); + + const lt = await page.evaluate(() => window.__lt__ || []); + const ltTotal = lt.reduce((s, t) => s + t.d, 0); + const worst = [...lt].sort((a, b) => b.d - a.d).slice(0, 12); + + console.log("\n================ SEEDED PROFILE ================"); + console.log(`Seeded videos: ${VIDEO_COUNT} | harness login: ${loginOk}`); + console.log(`Time-to-first-card: ${firstCardMs === null ? "NEVER" : fmt(firstCardMs)} | cards rendered: ${cardCount}`); + console.log(`Tab switch timings: ${JSON.stringify(tabTimings)}`); + console.log(`Profile-modal open: ${profileModalMs === null ? "FAILED" : fmt(profileModalMs)}`); + console.log(`\nLong tasks: count=${lt.length} total=${fmt(ltTotal)}; worst: ${worst.map(t => fmt(t.d)).join(", ")}`); + console.log(`\nTop functions by self-time:`); + for (const [fn, ms] of topFns) console.log(` ${fmt(ms).padStart(8)} ${fn}`); + console.log(`\nsecp256k1 crypto attributed to nearest APP caller:`); + for (const [fn, ms] of topCryptoCallers) console.log(` ${fmt(ms).padStart(8)} ${fn}`); + const errs = logs.filter((l) => /error|warn|fail|reject/i.test(l)).slice(0, 12); + if (errs.length) { console.log(`\nConsole (errors/warnings):`); errs.forEach((l) => console.log(" " + l)); } + console.log("===============================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { console.error("seeded profiler failed:", e); process.exit(1); }); diff --git a/scripts/perf/ux-harness.mjs b/scripts/perf/ux-harness.mjs new file mode 100644 index 00000000..ddf3827f --- /dev/null +++ b/scripts/perf/ux-harness.mjs @@ -0,0 +1,212 @@ +// Automated UX/perf harness with a FAKE nip-07 extension. +// +// Runs the real bitvid app in headless Chromium against an in-process mock relay, +// injecting a fake `window.nostr` extension backed by Node-side nostr-tools +// crypto (via context binding). Each extension call is serialized through a queue +// with simulated latency to model a real browser extension, so load times and the +// decrypt-cache win are realistic and measurable. +// +// Drives the real nip-07 login flow; isolates to the mock relay via a seeded +// NIP-65 list. Captures per-method extension call counts, feed-render timing, and +// screenshots. Can publish a watch history via the app and then reload to measure +// the decrypt-cache effect (cold vs warm extension call counts). +// +// Usage: node scripts/perf/ux-harness.mjs +// env: APP=http://localhost:3000 LATENCY_MS=100 VIDEOS=40 WH_ITEMS=120 HEADLESS=1 + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey, nip04 } from "nostr-tools"; +import * as nip44 from "nostr-tools/nip44"; +import { mkdir } from "node:fs/promises"; + +const APP = process.env.APP || "http://localhost:3000"; +const LATENCY_MS = Number(process.env.LATENCY_MS ?? 100); +const VIDEOS = Number(process.env.VIDEOS ?? 40); +const WH_ITEMS = Number(process.env.WH_ITEMS ?? 120); +const WS_PORT = 8961; +const SHOT_DIR = "artifacts/ux-harness"; + +const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); +const USER_PK = getPublicKey(USER_SK); + +// --- Extension call accounting + serialized latency queue --- +let extCalls = {}; +let queue = Promise.resolve(); +function resetExtCalls() { + extCalls = {}; +} +function viaExtension(method, fn) { + extCalls[method] = (extCalls[method] || 0) + 1; + const run = async () => { + if (LATENCY_MS > 0) await new Promise((r) => setTimeout(r, LATENCY_MS)); + return fn(); + }; + const p = queue.then(run, run); + queue = p.catch(() => {}); + return p; +} +const convKey = (peerPk) => nip44.v2.utils.getConversationKey(USER_SK, peerPk); + +async function main() { + await mkdir(SHOT_DIR, { recursive: true }); + const relay = startRelay(WS_PORT, { httpPort: false }); + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + + // Seed NIP-65 relay list (isolation) + videos. Capture video ids for WH pointers. + relay.seedEvent( + finalizeEvent( + { kind: 10002, created_at: Math.floor(Date.now() / 1000), tags: [["r", relayUrl]], content: "" }, + USER_SK, + ), + ); + const now = Math.floor(Date.now() / 1000); + const videoIds = []; + for (let i = 0; i < VIDEOS; i++) { + const dTag = `seed-${i}`; + const ev = finalizeEvent( + { + kind: 30078, + created_at: now - i * 60, + tags: [["d", dTag], ["t", "video"], ["title", `Harness Video ${i}`], ["url", `https://example.com/v${i}.mp4`]], + content: JSON.stringify({ version: 3, title: `Harness Video ${i}`, videoRootId: dTag, mode: "live", isPrivate: false, deleted: false, url: `https://example.com/v${i}.mp4` }), + }, + USER_SK, + ); + relay.seedEvent(ev); + videoIds.push(ev.id); + } + console.log(`Seeded ${VIDEOS} videos. User=${USER_PK.slice(0, 12)}…`); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + + // Context-level fake extension (survives reloads/new pages in this context). + await context.exposeBinding("__extGetPublicKey", () => viaExtension("getPublicKey", () => USER_PK)); + await context.exposeBinding("__extSignEvent", (_s, event) => viaExtension("signEvent", () => finalizeEvent({ ...event, pubkey: USER_PK }, USER_SK))); + await context.exposeBinding("__extNip04Encrypt", (_s, pk, t) => viaExtension("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, t))); + await context.exposeBinding("__extNip04Decrypt", (_s, pk, ct) => viaExtension("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct))); + await context.exposeBinding("__extNip44Encrypt", (_s, pk, t) => viaExtension("nip44.encrypt", () => nip44.v2.encrypt(t, convKey(pk)))); + await context.exposeBinding("__extNip44Decrypt", (_s, pk, ct) => viaExtension("nip44.decrypt", () => nip44.v2.decrypt(ct, convKey(pk)))); + + await context.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + localStorage.setItem("bitvid_admin_whitelist_mode", "false"); + window.__bitvidTestRelays__ = [url]; + const w = (n) => (...a) => window[n](...a); + window.nostr = { + getPublicKey: () => w("__extGetPublicKey")(), + signEvent: (e) => w("__extSignEvent")(e), + getRelays: async () => ({}), + nip04: { encrypt: (p, t) => w("__extNip04Encrypt")(p, t), decrypt: (p, c) => w("__extNip04Decrypt")(p, c) }, + nip44: { + encrypt: (p, t) => w("__extNip44Encrypt")(p, t), + decrypt: (p, c) => w("__extNip44Decrypt")(p, c), + v2: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c) }, + }, + }; + }, relayUrl); + + const testUrl = `${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`; + + // A single session: open page, log in via the fake extension, bypass feed + // verification (seeded data), wait for the feed. Returns timing + the page. + async function session(label) { + const page = await context.newPage(); + const logs = []; + page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`.slice(0, 240))); + page.on("pageerror", (e) => logs.push(`[pageerror] ${String(e).split("\n")[0]}`)); + const t0 = Date.now(); + await page.goto(testUrl, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + await page.evaluate(() => { + const c = window.__bitvidTest__?.nostrClient; + if (c) c.videoEventVerifier = async (events) => new Set((events || []).map((e) => e && e.id).filter(Boolean)); + }).catch(() => {}); + // Drive nip-07 login UI. + try { + await page.click('[data-testid="login-button"]', { timeout: 8000 }); + await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 }); + const extBtn = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first(); + if (await extBtn.count()) await extBtn.click({ timeout: 5000 }); + else await page.locator('[data-testid="login-provider-button"]').first().click({ timeout: 5000 }); + } catch (_) {} + await page.waitForFunction(async () => { + const s = await window.__bitvidTest__?.getAppState?.(); + return s?.isLoggedIn === true; + }, { timeout: 15000 }).catch(() => {}); + return { page, t0, logs }; + } + + // --- Session 1: log in, publish a watch history, screenshot. --- + const s1 = await session("seed"); + let firstCardMs = null; + try { + await s1.page.waitForSelector("[data-video-id]", { timeout: 15000 }); + firstCardMs = Date.now() - s1.t0; + } catch (_) {} + await s1.page.screenshot({ path: `${SHOT_DIR}/03-after-login.png` }); + + let published = null; + if (WH_ITEMS > 0) { + // Watch-history pointers are just references; generate distinct valid 64-hex + // ids so the snapshot chunks into ~WH_ITEMS/10 encrypted events. + const hex = (i) => (i.toString(16).padStart(8, "0") + "a".repeat(56)).slice(0, 64); + const items = Array.from({ length: WH_ITEMS }, (_, i) => ({ + type: "e", + value: hex(i + 1), + watchedAt: now - i * 30, + })); + published = await s1.page.evaluate(async (its) => { + const c = window.__bitvidTest__?.nostrClient; + if (!c || typeof c.publishWatchHistorySnapshot !== "function") return { ok: false, reason: "no-method" }; + try { + const r = await c.publishWatchHistorySnapshot(its, {}); + return { ok: true, result: r ? (r.ok ?? true) : true }; + } catch (e) { + return { ok: false, reason: String(e).split("\n")[0] }; + } + }, items).catch((e) => ({ ok: false, reason: String(e) })); + await s1.page.waitForTimeout(1500); // let publishes flush to the relay + } + const seedCalls = { ...extCalls }; + await s1.page.close(); + + // --- Session 2: cold reload — measures watch-history decrypt via the extension. --- + resetExtCalls(); + const s2 = await session("cold"); + await s2.page.waitForTimeout(6000); // let watch history load/decrypt in the background + const coldCalls = { ...extCalls }; + await s2.page.close(); + + // --- Session 3: warm reload — decrypt cache should skip the extension. --- + resetExtCalls(); + const s3 = await session("warm"); + await s3.page.waitForTimeout(6000); + const warmCalls = { ...extCalls }; + await s3.page.close(); + + console.log("\n================ UX HARNESS ================"); + console.log(`Time-to-first-video-card: ${firstCardMs === null ? "NEVER" : firstCardMs + "ms"}`); + console.log(`Watch history published: ${JSON.stringify(published)} (${WH_ITEMS} items)`); + console.log(`\nExtension calls (latency=${LATENCY_MS}ms each, serialized):`); + console.log(` seed session: ${JSON.stringify(seedCalls)}`); + console.log(` COLD reload : ${JSON.stringify(coldCalls)} <- watch-history decrypts via extension`); + console.log(` WARM reload : ${JSON.stringify(warmCalls)} <- should be ~0 nip44.decrypt (cache hit)`); + const dCold = coldCalls["nip44.decrypt"] || 0; + const dWarm = warmCalls["nip44.decrypt"] || 0; + console.log(`\nnip44.decrypt: cold=${dCold} warm=${dWarm} -> cache ${dWarm < dCold ? "WORKS ✓" : "no effect"} (saved ${dCold - dWarm} extension calls, ~${((dCold - dWarm) * LATENCY_MS) / 1000}s at ${LATENCY_MS}ms each)`); + console.log("===========================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { + console.error("ux-harness failed:", e); + process.exit(1); +}); diff --git a/scripts/perf/wh-repro.mjs b/scripts/perf/wh-repro.mjs new file mode 100644 index 00000000..960012ab --- /dev/null +++ b/scripts/perf/wh-repro.mjs @@ -0,0 +1,129 @@ +// Focused watch-history decrypt reproduction with FRESH contexts per session +// (so the persisted decrypt cache doesn't warm between cold/warm runs), and +// page-side capture of where window.nostr.nip44.decrypt is actually called from. +// +// Usage: node scripts/perf/wh-repro.mjs (env: WH_ITEMS=200) + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey } from "nostr-tools"; +import * as nip44 from "nostr-tools/nip44"; + +const APP = process.env.APP || "http://localhost:3000"; +const WH_ITEMS = Number(process.env.WH_ITEMS ?? 200); +const WS_PORT = 8963; +const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); +const USER_PK = getPublicKey(USER_SK); + +let calls = 0; +const cts = new Map(); // ciphertext -> count +const convKey = (pk) => nip44.v2.utils.getConversationKey(USER_SK, pk); + +async function setupContext(browser, relayUrl) { + const context = await browser.newContext(); + await context.exposeBinding("__gpk", () => USER_PK); + await context.exposeBinding("__sign", (_s, e) => finalizeEvent({ ...e, pubkey: USER_PK }, USER_SK)); + await context.exposeBinding("__n44e", (_s, pk, t) => nip44.v2.encrypt(t, convKey(pk))); + await context.exposeBinding("__n44d", (_s, pk, ct) => { + calls += 1; + cts.set(ct, (cts.get(ct) || 0) + 1); + return nip44.v2.decrypt(ct, convKey(pk)); + }); + await context.exposeBinding("__stack", (_s, s) => { + // log only the first few distinct stacks + if (setupContext._stacks === undefined) setupContext._stacks = new Set(); + if (setupContext._stacks.size < 4) { + setupContext._stacks.add(s); + console.log("STACK>", s); + } + }); + await context.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + localStorage.setItem("bitvid_admin_whitelist_mode", "false"); + const w = (n) => (...a) => window[n](...a); + const n44d = (p, c) => { + try { window.__stack((new Error().stack || "").split("\n").slice(1, 5).join(" <- ")); } catch (_) {} + return w("__n44d")(p, c); + }; + window.nostr = { + getPublicKey: () => w("__gpk")(), + signEvent: (e) => w("__sign")(e), + getRelays: async () => ({}), + nip04: { encrypt: async () => "", decrypt: async () => "" }, + nip44: { encrypt: (p, t) => w("__n44e")(p, t), decrypt: n44d, v2: { encrypt: (p, t) => w("__n44e")(p, t), decrypt: n44d } }, + }; + }, relayUrl); + return context; +} + +async function login(context, relayUrl) { + const page = await context.newPage(); + await page.goto(`${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((u) => window.__bitvidTest__?.setTestRelays?.([u], { persist: false }), relayUrl).catch(() => {}); + try { + await page.click('[data-testid="login-button"]', { timeout: 8000 }); + await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 }); + const b = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first(); + if (await b.count()) await b.click({ timeout: 5000 }); + else await page.locator('[data-testid="login-provider-button"]').first().click({ timeout: 5000 }); + } catch (_) {} + await page.waitForFunction(async () => (await window.__bitvidTest__?.getAppState?.())?.isLoggedIn === true, { timeout: 15000 }).catch(() => {}); + return page; +} + +async function main() { + const relay = startRelay(WS_PORT, { httpPort: false }); + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + const now = Math.floor(Date.now() / 1000); + relay.seedEvent(finalizeEvent({ kind: 10002, created_at: now, tags: [["r", relayUrl]], content: "" }, USER_SK)); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); + + // Context A: publish a watch history of WH_ITEMS pointers. + const ctxA = await setupContext(browser, relayUrl); + const pA = await login(ctxA, relayUrl); + const hex = (i) => (i.toString(16).padStart(8, "0") + "a".repeat(56)).slice(0, 64); + const MONTHS = Number(process.env.MONTHS ?? 1); + // Spread items across MONTHS distinct months to exercise the monthly bucketing. + const items = Array.from({ length: WH_ITEMS }, (_, i) => ({ + type: "e", + value: hex(i + 1), + watchedAt: now - (i % MONTHS) * 31 * 86400 - i * 30, + })); + const pub = await pA.evaluate(async (its) => { + const c = window.__bitvidTest__?.nostrClient; + try { return { ok: true, r: await c.publishWatchHistorySnapshot(its, {}) }; } catch (e) { return { ok: false, e: String(e).split("\n")[0] }; } + }, items); + await pA.waitForTimeout(1500); + const byKind = {}; + for (const e of relay.getEvents().values()) byKind[e.kind] = (byKind[e.kind] || 0) + 1; + await ctxA.close(); + + // Context B (FRESH = truly cold): resolve watch history, count decrypts. + calls = 0; cts.clear(); + const ctxB = await setupContext(browser, relayUrl); + const pB = await login(ctxB, relayUrl); + const cold = await pB.evaluate(async (pk) => { + const c = window.__bitvidTest__?.nostrClient; + try { const it = await c.resolveWatchHistory(pk, { forceRefresh: true }); return { ok: true, items: it?.length || 0 }; } catch (e) { return { ok: false, e: String(e).split("\n")[0] }; } + }, USER_PK); + await pB.waitForTimeout(1500); + const coldCalls = calls, coldUnique = cts.size, coldMax = Math.max(0, ...[...cts.values()]); + await ctxB.close(); + + console.log("\n================ WH REPRO ================"); + console.log(`publish: ${JSON.stringify(pub)} (${WH_ITEMS} items)`); + console.log(`relay events by kind: ${JSON.stringify(byKind)}`); + console.log(`COLD resolve: ${JSON.stringify(cold)}`); + console.log(`COLD nip44.decrypt calls=${coldCalls} uniqueCiphertexts=${coldUnique} maxRepeat=${coldMax}`); + console.log("=========================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { console.error("wh-repro failed:", e); process.exit(1); }); diff --git a/tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs b/tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs new file mode 100644 index 00000000..67c04ece --- /dev/null +++ b/tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs @@ -0,0 +1,112 @@ +// Verifies the For You feed re-runs when background watch-history resolution +// completes, so newly-resolved watched videos are suppressed and watch-history +// tags applied — without the feed ever blocking on the watch-history load. +// +// Scenario (SCN-foryou-rerun-on-watch-history): +// Given the For You feed is the active feed, +// When watch history finishes resolving in the background and emits +// "fingerprint", +// Then the For You feed is refreshed (once, debounced) with the resolved +// watch-history reason; and it is NOT refreshed when a different feed is +// active. + +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import { createFeedCoordinator } from "../../js/app/feedCoordinator.js"; +import { FEED_TYPES } from "../../js/constants.js"; + +function makeCoordinator() { + let fingerprintCb = null; + const watchHistoryService = { + subscribe: (eventName, cb) => { + if (eventName === "fingerprint") fingerprintCb = cb; + return () => {}; + }, + }; + const noop = mock.fn(); + const deps = { + devLogger: { log: noop, warn: noop, info: noop, debug: noop }, + userLogger: { warn: noop }, + nostrClient: {}, + watchHistoryService, + subscriptions: {}, + getSidebarLoadingMarkup: noop, + pointerKey: () => "", + isValidMagnetUri: () => false, + readCachedUrlHealth: noop, + persistUrlHealth: noop, + createActiveNostrSource: noop, + createBlacklistFilterStage: noop, + createDisinterestFilterStage: noop, + createDedupeByRootStage: noop, + createExploreDiversitySorter: noop, + createExploreScorerStage: noop, + createKidsAudienceFilterStage: noop, + createKidsScorerStage: noop, + createKidsScoreSorter: noop, + createModerationStage: noop, + createResolvePostedAtStage: noop, + createTagPreferenceFilterStage: noop, + createWatchHistorySuppressionStage: noop, + createChronologicalSorter: noop, + createSubscriptionAuthorsSource: noop, + registerWatchHistoryFeed: noop, + }; + const coordinator = createFeedCoordinator(deps); + const app = { + ...coordinator, + feedTelemetryState: { activeFeed: FEED_TYPES.FOR_YOU }, + refreshFeed: mock.fn(async () => {}), + }; + return { app, fireFingerprint: () => fingerprintCb && fingerprintCb() }; +} + +test("re-runs For You (debounced) when watch history resolves while active", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { app, fireFingerprint } = makeCoordinator(); + app.subscribeWatchHistoryFeedRefresh(); + + // Two rapid fingerprint events should collapse into one refresh (debounce). + fireFingerprint(); + fireFingerprint(); + assert.equal(app.refreshFeed.mock.callCount(), 0, "should debounce, not fire immediately"); + + mock.timers.tick(450); + assert.equal(app.refreshFeed.mock.callCount(), 1, "should refresh once after debounce"); + const [feedType, opts] = app.refreshFeed.mock.calls[0].arguments; + assert.equal(feedType, FEED_TYPES.FOR_YOU); + assert.equal(opts.reason, "watch-history-resolved"); + } finally { + mock.timers.reset(); + } +}); + +test("does not re-run when the active feed is not For You", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { app, fireFingerprint } = makeCoordinator(); + app.feedTelemetryState.activeFeed = FEED_TYPES.EXPLORE; + app.subscribeWatchHistoryFeedRefresh(); + + fireFingerprint(); + mock.timers.tick(450); + assert.equal(app.refreshFeed.mock.callCount(), 0, "must not refresh a non-active For You feed"); + } finally { + mock.timers.reset(); + } +}); + +test("subscribing twice does not double-register", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { app, fireFingerprint } = makeCoordinator(); + app.subscribeWatchHistoryFeedRefresh(); + app.subscribeWatchHistoryFeedRefresh(); + fireFingerprint(); + mock.timers.tick(450); + assert.equal(app.refreshFeed.mock.callCount(), 1, "idempotent subscription => single refresh"); + } finally { + mock.timers.reset(); + } +}); diff --git a/tests/nostr-delete-flow.test.mjs b/tests/nostr-delete-flow.test.mjs index d625b90e..90b50021 100644 --- a/tests/nostr-delete-flow.test.mjs +++ b/tests/nostr-delete-flow.test.mjs @@ -6,6 +6,7 @@ import { NostrService } from "../js/services/nostrService.js"; await (async function testDeleteFlowPublishesDeletionFlag() { localStorage.clear(); const client = new NostrClient(); + client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); client.hydrateVideoHistory = async () => {}; const pubkey = "0000000000000000000000000000000000000000000000000000000000000001"; @@ -219,7 +220,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() { handlers.event(deleteEvent); if (typeof handlers.eose === "function") { - handlers.eose(); + await handlers.eose(); } assert.equal( @@ -246,6 +247,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() { await (async function testTombstonePersistenceAcrossSaveRestore() { localStorage.clear(); const client = new NostrClient(); + client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); const videoRootId = "root-persist"; const activeKey = `ROOT:${videoRootId}`; @@ -272,6 +274,7 @@ await (async function testTombstonePersistenceAcrossSaveRestore() { await client.saveLocalData("test", { immediate: true }); const restored = new NostrClient(); + restored.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); await restored.restoreLocalData(); assert.equal( @@ -292,6 +295,7 @@ await (async function testTombstonePersistenceAcrossSaveRestore() { await (async function testSubscribeVideosSkipsOlderThanTombstone() { localStorage.clear(); const client = new NostrClient(); + client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); client.populateNip71MetadataForVideos = async () => {}; const seenUpdates = []; @@ -342,7 +346,7 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { handlers.event(deleteEvent); if (typeof handlers.eose === "function") { - handlers.eose(); + await handlers.eose(); } const activeKey = `ROOT:${videoRootId}`; @@ -377,7 +381,7 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { handlers.event(olderEvent); if (typeof handlers.eose === "function") { - handlers.eose(); + await handlers.eose(); } assert.equal( diff --git a/tests/nostr-service-access-control-ready.test.mjs b/tests/nostr-service-access-control-ready.test.mjs new file mode 100644 index 00000000..217e690d --- /dev/null +++ b/tests/nostr-service-access-control-ready.test.mjs @@ -0,0 +1,70 @@ +// Verifies ensureAccessControlReady no longer blocks the feed on the remote +// admin-list fetch except on a first-ever cold load in whitelist mode. +// +// Scenario (SCN-access-control-non-blocking-feed): +// - admin state hydrated (cache or loaded) => background refresh (ensureReady) +// - cold + whitelist mode => block (waitForReady) +// - cold + blacklist-only mode => background refresh (ensureReady) + +import assert from "node:assert/strict"; +import test from "node:test"; + +import "./test-helpers/setup-localstorage.mjs"; +import moderationService from "../js/services/moderationService.js"; +import { NostrService } from "../js/services/nostrService.js"; + +// Stub background timers the service may schedule. +if (moderationService) { + moderationService.scheduleTrustedMuteSubscriptionRefresh = () => {}; + moderationService.refreshTrustedMuteSubscriptions = async () => {}; +} +if (typeof globalThis.window === "undefined") globalThis.window = {}; + +function makeAccessControl({ hydrated, whitelist }) { + const calls = { ensureReady: 0, waitForReady: 0 }; + return { + calls, + isHydrated: () => hydrated, + whitelistMode: () => whitelist, + ensureReady: async () => { + calls.ensureReady += 1; + }, + waitForReady: async () => { + calls.waitForReady += 1; + }, + }; +} + +test("hydrated => background refresh, never blocks on waitForReady", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: true, whitelist: true }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.ensureReady, 1); + assert.equal(ac.calls.waitForReady, 0); +}); + +test("cold + whitelist mode => blocks on waitForReady (correctness)", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: false, whitelist: true }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.waitForReady, 1); + assert.equal(ac.calls.ensureReady, 0); +}); + +test("cold + blacklist-only mode => background refresh (no block)", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: false, whitelist: false }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.ensureReady, 1); + assert.equal(ac.calls.waitForReady, 0); +}); + +test("missing access control is a no-op", async () => { + const service = new NostrService(); + service.accessControl = null; + await service.ensureAccessControlReady(); // must not throw + assert.ok(true); +}); diff --git a/tests/nostr/client.test.mjs b/tests/nostr/client.test.mjs index e8787cbc..3c83b0d6 100644 --- a/tests/nostr/client.test.mjs +++ b/tests/nostr/client.test.mjs @@ -33,6 +33,10 @@ describe("NostrClient", () => { client.pool = mockPool; client.isInitialized = true; // Skip init network calls client.relays = ["wss://relay.example.com"]; + // Virtualize the off-thread signature verifier: synthetic test events are + // unsigned, so treat all as valid (the real verifier would correctly drop + // them). Production keeps the default worker verifier. + client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); }); afterEach(() => { diff --git a/tests/nostr/dmDecryptorPreference.test.mjs b/tests/nostr/dmDecryptorPreference.test.mjs index 168d2130..fb304f0c 100644 --- a/tests/nostr/dmDecryptorPreference.test.mjs +++ b/tests/nostr/dmDecryptorPreference.test.mjs @@ -5,14 +5,19 @@ import { decryptDM } from "../../js/dmDecryptor.js"; const buildHex = (value) => value.repeat(64); -const baseEvent = (pubkey) => ({ +const baseEvent = (pubkey, tags = []) => ({ kind: 4, pubkey, content: "ciphertext", - tags: [], + tags, }); -test("decryptDM prefers nip44 decryptors when available", async () => { +// SPEC: a legacy kind-4 DM is NIP-04 by convention. With no explicit encryption +// hint, try nip04 FIRST so we don't burn a slow, serialized nip-07 extension +// round-trip on nip44 before falling back. (Spec correction: the previous +// expectation preferred nip44 by default for kind-4, which caused every legacy +// DM to pay an extra extension call.) +test("decryptDM prefers nip04 first for legacy kind-4 DMs without hints", async () => { const senderPubkey = buildHex("e"); const result = await decryptDM(baseEvent(senderPubkey), { @@ -31,26 +36,59 @@ test("decryptDM prefers nip44 decryptors when available", async () => { ], }); + assert.equal(result.ok, true); + assert.equal(result.scheme, "nip04"); + assert.equal(result.plaintext, "nip04"); +}); + +// SPEC: an explicit per-event encryption hint always wins, even for kind-4. If +// the sender tagged the message nip44, decrypt with nip44 first regardless of +// the legacy default. +test("decryptDM honors an explicit nip44 hint on a kind-4 DM", async () => { + const senderPubkey = buildHex("a"); + + const result = await decryptDM( + baseEvent(senderPubkey, [["encrypted", "nip44"]]), + { + actorPubkey: senderPubkey, + decryptors: [ + { + scheme: "nip04", + decrypt: async () => "nip04", + priority: 0, + }, + { + scheme: "nip44", + decrypt: async () => "nip44", + priority: 0, + }, + ], + }, + ); + assert.equal(result.ok, true); assert.equal(result.scheme, "nip44"); assert.equal(result.plaintext, "nip44"); }); -test("decryptDM falls back to nip04 when nip44 fails", async () => { +// SPEC: when the preferred scheme fails, fall through to the next decryptor and +// still succeed. Here nip04 (tried first by the legacy default) throws, so the +// result must come from nip44. +test("decryptDM falls back to nip44 when the nip04 attempt fails", async () => { const senderPubkey = buildHex("f"); const result = await decryptDM(baseEvent(senderPubkey), { actorPubkey: senderPubkey, decryptors: [ { - scheme: "nip44", + scheme: "nip04", decrypt: async () => { - throw new Error("nip44 failure"); + throw new Error("nip04 failure"); }, priority: 0, }, { - scheme: "nip04", + scheme: "nip44", decrypt: async () => "fallback", priority: 0, }, @@ -58,6 +96,32 @@ test("decryptDM falls back to nip04 when nip44 fails", async () => { }); assert.equal(result.ok, true); - assert.equal(result.scheme, "nip04"); + assert.equal(result.scheme, "nip44"); assert.equal(result.plaintext, "fallback"); }); + +// SPEC: a note-to-self DM has no counterparty other than the actor. The self +// key must still be attempted (deferred to last) rather than dropped, or the +// message can never decrypt. +test("decryptDM still decrypts a note-to-self DM (self is the only candidate)", async () => { + const selfPubkey = buildHex("b"); + let attemptedRemote = null; + + const result = await decryptDM(baseEvent(selfPubkey), { + actorPubkey: selfPubkey, + decryptors: [ + { + scheme: "nip04", + decrypt: async (remotePubkey) => { + attemptedRemote = remotePubkey; + return "self-note"; + }, + priority: 0, + }, + ], + }); + + assert.equal(result.ok, true); + assert.equal(result.plaintext, "self-note"); + assert.equal(attemptedRemote, selfPubkey); +}); diff --git a/tests/nostr/relayBatchFetcherReplaceable.test.mjs b/tests/nostr/relayBatchFetcherReplaceable.test.mjs new file mode 100644 index 00000000..719d0fd9 --- /dev/null +++ b/tests/nostr/relayBatchFetcherReplaceable.test.mjs @@ -0,0 +1,94 @@ +// Regression test documenting why replaceable-list fetches must not be gated by +// a persisted per-relay lastSeen, and why admin lists pass `since: 0`. +// +// Replaceable events (NIP-51/NIP-33 lists: mute 10000, lists 30000/30002, +// hashtag prefs 30015, admin lists) have exactly ONE current version. The +// incremental optimization stores lastSeen = current created_at, then queries +// `since = lastSeen + 1` on the next load. For a replaceable event whose +// created_at never advances, that filter perpetually excludes the current +// event, so the fetch returns nothing even though the list exists. Callers that +// lack a durable content cache then see an empty list. +// +// Scenario (SCN-replaceable-since-gate): +// Given a relay holding one replaceable list event at created_at = T, and a +// persisted lastSeen = T, +// When the list is fetched relying on the persisted lastSeen, +// Then the current event is hidden (returns empty) — the latent bug. +// When the list is fetched with an explicit since: 0 (full fetch), +// Then the current event is returned — the fix used by admin lists. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { RelayBatchFetcher } from "../../js/nostr/relayBatchFetcher.js"; + +const PUBKEY = "a".repeat(64); +const RELAY = "wss://r1.example.com"; +const T = 1000; + +// One current replaceable list event living on the relay at created_at = T. +const currentEvent = { + id: "current-list-event", + kind: 30000, + pubkey: PUBKEY, + created_at: T, + tags: [["d", "admin:editors"]], + content: "", +}; + +function makeFetcher() { + const client = { + relays: [RELAY], + readRelays: [], + getHealthyRelays: (list) => list, + ensurePool: async () => ({ list: async () => [] }), + // Persisted lastSeen says we already saw the current version at time T. + getSyncLastSeen: () => T, + updateSyncLastSeen: () => {}, + markRelayUnreachable: () => {}, + }; + + // Behavioral relay clone: returns the current replaceable event only when the + // query is not gated past T (i.e. since is absent or <= T). + const fetchFn = async (_relayUrl, filter) => { + const since = filter?.since; + if (since === undefined || since <= T) { + return [currentEvent]; + } + return []; + }; + + return { fetcher: new RelayBatchFetcher(client), fetchFn }; +} + +describe("fetchListIncrementally + replaceable events", () => { + it("relying on persisted lastSeen hides the current replaceable event (documents the bug)", async () => { + const { fetcher, fetchFn } = makeFetcher(); + const events = await fetcher.fetchListIncrementally({ + kind: 30000, + pubkey: PUBKEY, + dTag: "admin:editors", + relayUrls: [RELAY], + fetchFn, + // no `since` => uses persisted lastSeen = T => filter.since = T + 1 + }); + assert.equal( + events.length, + 0, + "persisted lastSeen gates since=T+1, hiding the current event", + ); + }); + + it("since: 0 forces a full fetch and returns the current event (the admin-list fix)", async () => { + const { fetcher, fetchFn } = makeFetcher(); + const events = await fetcher.fetchListIncrementally({ + kind: 30000, + pubkey: PUBKEY, + dTag: "admin:editors", + relayUrls: [RELAY], + fetchFn, + since: 0, + }); + assert.equal(events.length, 1, "full fetch must return the current event"); + assert.equal(events[0].id, currentEvent.id); + }); +}); diff --git a/tests/nostr/signatureVerifyWorkerClient.test.mjs b/tests/nostr/signatureVerifyWorkerClient.test.mjs new file mode 100644 index 00000000..ce3009a6 --- /dev/null +++ b/tests/nostr/signatureVerifyWorkerClient.test.mjs @@ -0,0 +1,108 @@ +// Tests the signature-verify worker client protocol: a batch of events is sent +// to the worker and the client resolves with the Set of ids the worker reports +// valid. This is the contract the feed ingestion relies on to drop forged events +// after the pool stopped verifying on the main thread. + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +// A controllable fake Worker installed as the global before importing nothing +// stateful — the client lazily constructs the worker on first use. +class FakeWorker { + constructor() { + this.listeners = { message: [], error: [] }; + FakeWorker.instances.push(this); + } + addEventListener(type, fn) { + (this.listeners[type] ||= []).push(fn); + } + postMessage(msg) { + if (FakeWorker.silent) return; // model a hung worker that never responds + // Respond asynchronously, echoing as valid only the ids in FakeWorker.validIds + // (or all ids if validIds is null), to mimic real verification. + setTimeout(() => { + const ids = (msg.events || []).map((e) => e.id); + const validIds = FakeWorker.validIds === null ? ids : ids.filter((id) => FakeWorker.validIds.has(id)); + for (const fn of this.listeners.message) fn({ data: { id: msg.id, ok: true, validIds } }); + }, 0); + } + terminate() { + FakeWorker.terminated += 1; + } +} +FakeWorker.instances = []; +FakeWorker.validIds = null; +FakeWorker.silent = false; +FakeWorker.terminated = 0; + +describe("signatureVerifyWorkerClient", () => { + let verifyEventsInWorker; + + beforeEach(async () => { + globalThis.Worker = FakeWorker; + FakeWorker.instances = []; + FakeWorker.validIds = null; + FakeWorker.silent = false; + FakeWorker.terminated = 0; + // Fresh module each test so the singleton worker is rebuilt against FakeWorker. + const mod = await import(`../../js/nostr/signatureVerifyWorkerClient.js?bust=${Math.random()}`); + verifyEventsInWorker = mod.verifyEventsInWorker; + }); + + afterEach(() => { + delete globalThis.Worker; + }); + + it("a hung worker must not hang the caller — it times out and falls back", async () => { + FakeWorker.silent = true; // worker never responds + const events = [{ id: "a".repeat(64) }, { id: "b".repeat(64) }]; + const started = Date.now(); + // Short timeout so the test is fast; the point is it RESOLVES, not hangs, + // and disables the worker so the next batch doesn't pay the timeout again. + const result = await verifyEventsInWorker(events, { timeoutMs: 80 }); + assert.ok(result instanceof Set, "must resolve to a Set, not hang"); + assert.ok(Date.now() - started < 2000, "must resolve promptly via fallback"); + assert.ok(FakeWorker.terminated >= 1, "hung worker should be terminated/disabled"); + + // Subsequent call must skip the worker entirely (no new instance created). + const before = FakeWorker.instances.length; + await verifyEventsInWorker([{ id: "c".repeat(64) }], { timeoutMs: 80 }); + assert.equal(FakeWorker.instances.length, before, "worker stays disabled after a failure"); + }); + + it("resolves with the set of ids the worker reports valid", async () => { + const events = [ + { id: "a".repeat(64), sig: "x" }, + { id: "b".repeat(64), sig: "y" }, + { id: "c".repeat(64), sig: "z" }, + ]; + const valid = await verifyEventsInWorker(events); + assert.ok(valid instanceof Set); + assert.equal(valid.size, 3); + assert.ok(valid.has("a".repeat(64))); + }); + + it("drops events the worker reports invalid", async () => { + FakeWorker.validIds = new Set(["a".repeat(64)]); // only 'a' is valid + const events = [ + { id: "a".repeat(64) }, + { id: "b".repeat(64) }, + ]; + const valid = await verifyEventsInWorker(events); + assert.equal(valid.size, 1); + assert.ok(valid.has("a".repeat(64))); + assert.ok(!valid.has("b".repeat(64))); + }); + + it("returns an empty set for empty input without invoking a worker", async () => { + const valid = await verifyEventsInWorker([]); + assert.equal(valid.size, 0); + assert.equal(FakeWorker.instances.length, 0, "must not construct a worker for empty input"); + }); + + it("ignores events without ids", async () => { + const valid = await verifyEventsInWorker([{ sig: "no-id" }, null, { id: "d".repeat(64) }]); + assert.equal(valid.size, 1); + assert.ok(valid.has("d".repeat(64))); + }); +}); diff --git a/tests/nostr/subscribeVideosRelayFallback.test.mjs b/tests/nostr/subscribeVideosRelayFallback.test.mjs new file mode 100644 index 00000000..3d87a2ed --- /dev/null +++ b/tests/nostr/subscribeVideosRelayFallback.test.mjs @@ -0,0 +1,80 @@ +// Regression test for the first-load feed race. +// +// Scenario (SCN-subscribe-videos-empty-relay-fallback): +// Given the initial relay connect probe timed out on a cold first load, so no +// relays are currently marked healthy, +// When the feed subscription is created via subscribeVideos, +// Then it must subscribe to the default relay set (not an empty relay list), +// because a zero-relay subscription silently never delivers events and never +// self-heals when relays reconnect — forcing the user to refresh the page. +// +// Observable outcome asserted at the boundary: the relay list passed to +// pool.sub(). + +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { NostrClient } from "../../js/nostr/client.js"; + +if (!globalThis.WebSocket) { + globalThis.WebSocket = class MockWebSocket {}; +} + +describe("subscribeVideos relay fallback", () => { + let client; + let mockPool; + let subRelayArgs; + + beforeEach(() => { + client = new NostrClient(); + subRelayArgs = null; + mockPool = { + sub: mock.fn((relays) => { + subRelayArgs = relays; + return { on: mock.fn(), unsub: mock.fn() }; + }), + }; + client.pool = mockPool; + client.isInitialized = true; + client.relays = ["wss://relay.example.com", "wss://relay2.example.com"]; + }); + + afterEach(() => { + mock.reset(); + }); + + it("falls back to the default relay set when no relays are healthy", () => { + // Simulate the cold-start race: the connect probe timed out, so every relay + // is currently filtered out as unhealthy. + client.getHealthyRelays = () => []; + + client.subscribeVideos(() => {}); + + assert.equal(mockPool.sub.mock.callCount(), 1); + assert.ok(Array.isArray(subRelayArgs), "pool.sub must receive a relay array"); + assert.ok( + subRelayArgs.length > 0, + "must never subscribe to ZERO relays when healthy relays are empty", + ); + for (const url of subRelayArgs) { + assert.match( + url, + /^wss?:\/\//, + `fallback relay should be a websocket URL, got ${url}`, + ); + } + }); + + it("uses the healthy relays when they are available", () => { + const healthy = ["wss://relay.example.com"]; + client.getHealthyRelays = () => healthy; + + client.subscribeVideos(() => {}); + + assert.equal(mockPool.sub.mock.callCount(), 1); + assert.deepEqual( + subRelayArgs, + healthy, + "should subscribe using the healthy relays, not the default fallback", + ); + }); +}); diff --git a/tests/nostr/videoEventBuffer.test.mjs b/tests/nostr/videoEventBuffer.test.mjs index 638ee820..cbb0c2ce 100644 --- a/tests/nostr/videoEventBuffer.test.mjs +++ b/tests/nostr/videoEventBuffer.test.mjs @@ -76,19 +76,27 @@ const deletedEvent = { }) }; +// Virtualize the off-thread signature verifier: treat every seeded test event as +// valid (the seeds are synthetic and unsigned). This isolates the buffer's +// batching/commit logic from real schnorr verification while preserving the +// "only valid ids are committed" contract. +const passthroughVerify = async (events) => new Set(events.map((e) => e.id)); +const newBuffer = (client, onVideo = () => {}) => + new VideoEventBuffer(client, onVideo, { verifyEvents: passthroughVerify }); + test('VideoEventBuffer', async (t) => { - await t.test('Push and Flush', () => { + await t.test('Push and Flush', async () => { const client = new MockClient(); let onVideoCalls = []; const onVideo = (videos) => onVideoCalls.push(videos); - const buffer = new VideoEventBuffer(client, onVideo); + const buffer = newBuffer(client, onVideo); buffer.push(validEvent); assert.equal(buffer.buffer.length, 1); assert.notEqual(buffer.flushTimerId, null); - // Force flush - buffer.scheduleFlush(true); + // Force flush (now async: verification happens off-thread before commit) + await buffer.scheduleFlush(true); assert.equal(buffer.buffer.length, 0); assert.equal(client.allEvents.size, 1); assert.equal(client.activeMap.size, 1); @@ -99,30 +107,30 @@ test('VideoEventBuffer', async (t) => { assert.equal(client.activeMap.get("root1").id, validEvent.id); }); - await t.test('Latest Wins', () => { + await t.test('Latest Wins', async () => { const client = new MockClient(); let onVideoCalls = []; const onVideo = (videos) => onVideoCalls.push(videos); - const buffer = new VideoEventBuffer(client, onVideo); + const buffer = newBuffer(client, onVideo); // Setup: activeMap has older event client.activeMap.set("root1", { ...validEvent, created_at: 1000, videoRootId: "root1" }); buffer.push(newerEvent); - buffer.scheduleFlush(true); // Force flush + await buffer.scheduleFlush(true); // Force flush // Should update because newerEvent.created_at (2000) > 1000 assert.equal(client.activeMap.get("root1").id, newerEvent.id); assert.equal(client.activeMap.get("root1").created_at, 2000); }); - await t.test('Tombstone Handling', () => { + await t.test('Tombstone Handling', async () => { const client = new MockClient(); - const buffer = new VideoEventBuffer(client, () => {}); + const buffer = newBuffer(client); // Process deleted event buffer.push(deletedEvent); - buffer.scheduleFlush(true); + await buffer.scheduleFlush(true); // Should verify tombstone recorded assert.ok(client.tombstones.has("root1")); @@ -133,26 +141,26 @@ test('VideoEventBuffer', async (t) => { // Now try pushing an older event buffer.push(newerEvent); // created_at 2000 < 3000 - buffer.scheduleFlush(true); + await buffer.scheduleFlush(true); // Should be guarded by tombstone assert.ok(!client.activeMap.has("root1")); }); - await t.test('Cleanup', () => { + await t.test('Cleanup', async () => { const client = new MockClient(); - const buffer = new VideoEventBuffer(client, () => {}); + const buffer = newBuffer(client); buffer.push(validEvent); assert.notEqual(buffer.flushTimerId, null); - buffer.cleanup(); + await buffer.cleanup(); assert.equal(buffer.flushTimerId, null); assert.equal(buffer.buffer.length, 0); // Should have flushed assert.equal(client.allEvents.size, 1); }); - await t.test('Visibility Gating', () => { + await t.test('Visibility Gating', async () => { // Mock document const originalDocument = global.document; let visibilityHandler = null; @@ -168,11 +176,11 @@ test('VideoEventBuffer', async (t) => { const client = new MockClient(); let onVideoCalls = []; const onVideo = (videos) => onVideoCalls.push(videos); - const buffer = new VideoEventBuffer(client, onVideo); + const buffer = newBuffer(client, onVideo); // Push event while hidden buffer.push(validEvent); - buffer.scheduleFlush(true); + await buffer.scheduleFlush(true); // Should NOT have called onVideo yet assert.equal(onVideoCalls.length, 0); diff --git a/tests/nostr/watchHistoryConversationKeyCache.test.js b/tests/nostr/watchHistoryConversationKeyCache.test.js new file mode 100644 index 00000000..2ac7ff1d --- /dev/null +++ b/tests/nostr/watchHistoryConversationKeyCache.test.js @@ -0,0 +1,123 @@ +// Regression test for the NIP-44 conversation-key memoization in the +// watch-history decrypt path. +// +// Scenario (SCN-watch-history-convkey-memo): +// Given a user whose watch history is stored as many separately NIP-44 +// encrypted chunk events (each chunk historically built its own cipher +// suite, re-deriving the ECDH conversation key ~12ms at a time), +// When the chunks are decrypted during a single load (i.e. many cipher +// suites are created for the same private key + target), +// Then the conversation key must be derived exactly once and reused, so a +// 1,500-item / ~150-chunk history does not block the main thread for +// seconds. +// +// Observable outcome asserted at the boundary: the number of calls to +// nostr-tools' getConversationKey, which is the expensive ECDH operation. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + watchHistoryHelpers, + clearWatchHistoryConversationKeyCache, +} from "../../js/nostr/watchHistory.js"; + +const { createNip44CipherSuite } = watchHistoryHelpers; + +const PRIV_A = + "1111111111111111111111111111111111111111111111111111111111111111"; +const PRIV_B = + "2222222222222222222222222222222222222222222222222222222222222222"; +const TARGET_X = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"; +const TARGET_Y = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1"; + +/** + * Build a fake nostr-tools surface whose getConversationKey counts invocations. + * decrypt simply echoes the ciphertext so we can confirm the suite is wired up. + */ +function makeCountingTools() { + const calls = []; + return { + calls, + tools: { + nip44: { + v2: { + encrypt: (plaintext) => `enc(${plaintext})`, + decrypt: (ciphertext) => `dec(${ciphertext})`, + utils: { + getConversationKey: (privBytes, target) => { + // Record the full (private key, target) identity so assertions can + // distinguish two pairs that share a target but differ by signer. + const privId = Array.from(privBytes ?? []).join(""); + calls.push(`${privId}:${target}`); + // Return a distinct "key" object per (priv,target) so we can also + // detect accidental cross-identity reuse if assertions tighten. + return { keyFor: `${privId}:${target}`, n: calls.length }; + }, + }, + }, + }, + }, + }; +} + +test("conversation key is derived once across many chunk cipher suites", () => { + clearWatchHistoryConversationKeyCache(); + const { tools, calls } = makeCountingTools(); + + // Simulate the per-chunk path: a fresh suite per chunk event, all sharing the + // same identity (PRIV_A -> TARGET_X), as a real watch-history load does. + const CHUNKS = 150; + for (let i = 0; i < CHUNKS; i++) { + const suite = createNip44CipherSuite(tools, PRIV_A, TARGET_X); + assert.ok(suite?.v2, "expected a v2 cipher suite"); + const out = suite.v2.decrypt(`chunk-${i}`); + assert.equal(out, `dec(chunk-${i})`); + } + + assert.equal( + calls.length, + 1, + `ECDH conversation key should be derived exactly once for ${CHUNKS} chunks, got ${calls.length}`, + ); +}); + +test("distinct identities and targets each derive their own key", () => { + clearWatchHistoryConversationKeyCache(); + const { tools, calls } = makeCountingTools(); + + // Three distinct (priv,target) pairs => three derivations, regardless of how + // many suites/decrypts each performs. This guards against over-caching that + // would leak one identity's key into another. + for (let i = 0; i < 5; i++) { + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + createNip44CipherSuite(tools, PRIV_A, TARGET_Y).v2.decrypt("x"); + createNip44CipherSuite(tools, PRIV_B, TARGET_X).v2.decrypt("x"); + } + + assert.deepEqual( + new Set(calls).size, + 3, + "expected exactly three distinct conversation keys", + ); + assert.equal(calls.length, 3, "each distinct pair derived exactly once"); +}); + +test("clearing the cache forces re-derivation (no secret outlives logout)", () => { + clearWatchHistoryConversationKeyCache(); + const { tools, calls } = makeCountingTools(); + + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + assert.equal(calls.length, 1); + + // Same identity again while cached: no new derivation. + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + assert.equal(calls.length, 1); + + // After logout-style clear, the key must be derived fresh. + clearWatchHistoryConversationKeyCache(); + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + assert.equal(calls.length, 2, "cache clear must force a fresh derivation"); +}); diff --git a/tests/nostr/watchHistoryDecryptCache.test.mjs b/tests/nostr/watchHistoryDecryptCache.test.mjs new file mode 100644 index 00000000..affdad57 --- /dev/null +++ b/tests/nostr/watchHistoryDecryptCache.test.mjs @@ -0,0 +1,66 @@ +// Verifies the decrypted watch-history chunk cache: plaintext is reused by +// event id (so reloads skip the nip-07 extension for unchanged chunks), +// persists across "sessions", is bounded, and clears on logout. + +import test from "node:test"; +import assert from "node:assert/strict"; +import "../test-helpers/setup-localstorage.mjs"; + +const STORAGE_KEY = "bitvid:whDecryptedChunks:v1"; + +async function freshModule() { + // Re-import to reset module-level cache (simulates a page reload that + // re-hydrates from localStorage). + return import(`../../js/nostr/watchHistoryDecryptCache.js?bust=${Math.random()}`); +} + +test("get/set round-trips plaintext by event id", async () => { + const m = await freshModule(); + assert.equal(m.getCachedChunkPlaintext("evt-a"), null); + m.setCachedChunkPlaintext("evt-a", '{"items":[1]}'); + assert.equal(m.getCachedChunkPlaintext("evt-a"), '{"items":[1]}'); +}); + +test("flush persists and a fresh module re-hydrates from localStorage", async () => { + localStorage.removeItem(STORAGE_KEY); + const m1 = await freshModule(); + m1.setCachedChunkPlaintext("evt-persist", "PLAINTEXT"); + m1.flushDecryptedChunkCache(); + assert.ok(localStorage.getItem(STORAGE_KEY), "should write to localStorage"); + + // New module instance (simulated reload) reads the persisted plaintext — + // meaning no extension decrypt would be needed for this chunk. + const m2 = await freshModule(); + assert.equal(m2.getCachedChunkPlaintext("evt-persist"), "PLAINTEXT"); +}); + +test("ignores empty/invalid input", async () => { + const m = await freshModule(); + m.setCachedChunkPlaintext("", "x"); + m.setCachedChunkPlaintext("evt", ""); + m.setCachedChunkPlaintext("evt", null); + assert.equal(m.getCachedChunkPlaintext("evt"), null); +}); + +test("clear empties the cache and localStorage", async () => { + const m = await freshModule(); + m.setCachedChunkPlaintext("evt-x", "data"); + m.flushDecryptedChunkCache(); + m.clearWatchHistoryDecryptedChunkCache(); + assert.equal(m.getCachedChunkPlaintext("evt-x"), null); + assert.equal(localStorage.getItem(STORAGE_KEY), null); +}); + +test("bounds the cache to a maximum number of entries", async () => { + localStorage.removeItem(STORAGE_KEY); + const m = await freshModule(); + for (let i = 0; i < 500; i++) { + m.setCachedChunkPlaintext(`evt-${i}`, `p${i}`); + } + m.flushDecryptedChunkCache(); + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); + assert.ok(stored.length <= 400, `expected <=400 entries, got ${stored.length}`); + // Most recent entries are retained. + const m2 = await freshModule(); + assert.equal(m2.getCachedChunkPlaintext("evt-499"), "p499"); +}); diff --git a/tests/watchHistoryFingerprintEmit.test.mjs b/tests/watchHistoryFingerprintEmit.test.mjs new file mode 100644 index 00000000..2c74e535 --- /dev/null +++ b/tests/watchHistoryFingerprintEmit.test.mjs @@ -0,0 +1,73 @@ +// Regression test for the infinite feed-refresh loop that pegged the CPU. +// +// Scenario (SCN-fingerprint-emit-on-change-only): +// Given the For You feed re-runs whenever watch history emits "fingerprint", +// When watch history is re-evaluated but its fingerprint is UNCHANGED, +// Then no "fingerprint" event is emitted — so the feed does not refresh, +// which would re-evaluate watch history, re-emit, and loop forever. +// And when the fingerprint genuinely changes, exactly one event is emitted. +// +// The bug: getFingerprint/updateFingerprintCache emitted "fingerprint" on every +// evaluation regardless of change, so the feed's refresh -> reload -> emit chain +// never settled (a full feed rebuild ~2.5x/second). + +import "./test-helpers/setup-localstorage.mjs"; +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; + +const { nostrClient } = await import("../js/nostrClientFacade.js"); +const { watchHistoryService } = await import("../js/watchHistoryService.js"); +const { WATCH_HISTORY_CACHE_TTL_MS } = await import("../js/config.js"); + +const ACTOR = "a".repeat(64); + +test("emits 'fingerprint' only when it actually changes", async () => { + const originalGet = nostrClient.getWatchHistoryFingerprint; + const originalPubkey = nostrClient.pubkey; + const originalSession = nostrClient.sessionActor; + + // Logged-in, non-session actor so the feature is enabled for ACTOR. + nostrClient.pubkey = ACTOR; + nostrClient.sessionActor = null; + watchHistoryService.resetProgress(ACTOR); // start from a clean fingerprint cache + + let currentFingerprint = "fp-1"; + nostrClient.getWatchHistoryFingerprint = async () => currentFingerprint; + + let emitCount = 0; + const unsubscribe = watchHistoryService.subscribe("fingerprint", () => { + emitCount += 1; + }); + + // Fake the clock so we can expire the (24h) fingerprint cache between calls + // and thus force a genuine re-evaluation each time (otherwise the fresh-cache + // early-return would mask whether the change-guard works). + mock.timers.enable({ apis: ["Date"] }); + try { + // 1) First evaluation: undefined -> "fp-1" is a change => one emit. + await watchHistoryService.getFingerprint(ACTOR); + assert.equal(emitCount, 1, "first fingerprint should emit once"); + + // 2) Re-evaluate with the SAME fingerprint after the cache expires. + mock.timers.tick(WATCH_HISTORY_CACHE_TTL_MS + 1000); + await watchHistoryService.getFingerprint(ACTOR); + assert.equal( + emitCount, + 1, + "unchanged fingerprint must NOT emit (this is what broke the loop)", + ); + + // 3) Re-evaluate after a real change => exactly one more emit. + mock.timers.tick(WATCH_HISTORY_CACHE_TTL_MS + 1000); + currentFingerprint = "fp-2"; + await watchHistoryService.getFingerprint(ACTOR); + assert.equal(emitCount, 2, "a changed fingerprint should emit once more"); + } finally { + mock.timers.reset(); + unsubscribe(); + nostrClient.getWatchHistoryFingerprint = originalGet; + nostrClient.pubkey = originalPubkey; + nostrClient.sessionActor = originalSession; + watchHistoryService.resetProgress(ACTOR); + } +});