From 28af2910de19d5ba509e16e375123802f7197150 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:10:26 +0000 Subject: [PATCH 01/37] chore(code-health): remove unused WATCH_HISTORY_LIST_IDENTIFIER import from nostrEventSchemas.js Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostrEventSchemas.js | 1 - 1 file changed, 1 deletion(-) diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 1cd60a93..c2004dc7 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -6,7 +6,6 @@ import { ADMIN_COMMUNITY_BLACKLIST_PREFIX, isDevMode, WATCH_HISTORY_KIND, - WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_VERSION_TAG_VALUE, } from "./config.js"; import { From aefd6308a249e6b32ea9db2da3cf6f8cc19f3324 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:11:47 +0000 Subject: [PATCH 02/37] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20DM=20d?= =?UTF-8?q?ecrypt=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added unit tests for `summarizeDmEventForLog` covering null/invalid inputs, valid events, and edge cases. - Added unit tests for `sanitizeDecryptError` covering null/invalid inputs, string errors, and error objects. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/nostr/dmDecryptDiagnostics.test.mjs | 149 ++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/nostr/dmDecryptDiagnostics.test.mjs diff --git a/tests/nostr/dmDecryptDiagnostics.test.mjs b/tests/nostr/dmDecryptDiagnostics.test.mjs new file mode 100644 index 00000000..f9c2c745 --- /dev/null +++ b/tests/nostr/dmDecryptDiagnostics.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { test, describe, it } from "node:test"; +import { summarizeDmEventForLog, sanitizeDecryptError } from "../../js/nostr/dmDecryptDiagnostics.js"; + +describe("dmDecryptDiagnostics", () => { + describe("summarizeDmEventForLog", () => { + it("should return a default object when event is null or undefined", () => { + const expected = { + kind: null, + createdAt: null, + hasContent: false, + contentLength: 0, + tagCount: 0, + }; + assert.deepEqual(summarizeDmEventForLog(null), expected); + assert.deepEqual(summarizeDmEventForLog(undefined), expected); + }); + + it("should return a default object when event is not an object", () => { + const expected = { + kind: null, + createdAt: null, + hasContent: false, + contentLength: 0, + tagCount: 0, + }; + assert.deepEqual(summarizeDmEventForLog("invalid"), expected); + assert.deepEqual(summarizeDmEventForLog(123), expected); + }); + + it("should summarize a valid event correctly", () => { + const event = { + kind: 4, + created_at: 1678901234.567, + content: "test content", + tags: [["p", "pubkey"], ["e", "eventid"]], + }; + const expected = { + kind: 4, + createdAt: 1678901234, // Math.floor applied + hasContent: true, + contentLength: 12, + tagCount: 2, + }; + assert.deepEqual(summarizeDmEventForLog(event), expected); + }); + + it("should handle non-finite created_at", () => { + const event = { + kind: 4, + created_at: NaN, + content: "test", + tags: [], + }; + const summary = summarizeDmEventForLog(event); + assert.strictEqual(summary.createdAt, null); + }); + + it("should handle non-finite kind", () => { + const event = { + kind: Infinity, + created_at: 1000, + content: "test", + tags: [], + }; + const summary = summarizeDmEventForLog(event); + assert.strictEqual(summary.kind, null); + }); + + it("should handle missing or invalid content", () => { + const event1 = { kind: 4, created_at: 1000, tags: [] }; + const summary1 = summarizeDmEventForLog(event1); + assert.strictEqual(summary1.hasContent, false); + assert.strictEqual(summary1.contentLength, 0); + + const event2 = { kind: 4, created_at: 1000, content: 123, tags: [] }; + const summary2 = summarizeDmEventForLog(event2); + assert.strictEqual(summary2.hasContent, false); + assert.strictEqual(summary2.contentLength, 0); + }); + + it("should handle missing or invalid tags", () => { + const event1 = { kind: 4, created_at: 1000, content: "test" }; + const summary1 = summarizeDmEventForLog(event1); + assert.strictEqual(summary1.tagCount, 0); + + const event2 = { kind: 4, created_at: 1000, content: "test", tags: "invalid" }; + const summary2 = summarizeDmEventForLog(event2); + assert.strictEqual(summary2.tagCount, 0); + }); + }); + + describe("sanitizeDecryptError", () => { + it("should return null when error is null or undefined", () => { + assert.strictEqual(sanitizeDecryptError(null), null); + assert.strictEqual(sanitizeDecryptError(undefined), null); + }); + + it("should handle string errors", () => { + const error = "Something went wrong"; + const expected = { + name: "", + code: "", + message: "Something went wrong", + }; + assert.deepEqual(sanitizeDecryptError(error), expected); + }); + + it("should handle Error objects with standard properties", () => { + const error = { + name: "Error", + code: "ERR_CODE", + message: "Error message", + }; + const expected = { + name: "Error", + code: "ERR_CODE", + message: "Error message", + }; + assert.deepEqual(sanitizeDecryptError(error), expected); + }); + + it("should handle Error objects with missing properties", () => { + const error = { + message: "Just a message", + }; + const expected = { + name: "", + code: "", + message: "Just a message", + }; + assert.deepEqual(sanitizeDecryptError(error), expected); + }); + + it("should handle Error objects with non-string properties", () => { + const error = { + name: 123, + code: null, + message: ["not a string"], + }; + const expected = { + name: "", + code: "", + message: "", + }; + assert.deepEqual(sanitizeDecryptError(error), expected); + }); + }); +}); From e79998f7b567bbd6f209b70eee8162dac942cafd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:12:11 +0000 Subject: [PATCH 03/37] test: add unit tests for comment avatar resolution Added a new test file `tests/unit/comment-avatar.test.mjs` to comprehensively test the `commentAvatar.js` utility. The tests cover: - `normalizeCommentAvatarKey`: Verifies input sanitization and normalization. - `resolveCommentAvatarAsset`: Verifies avatar resolution logic including defaults, failure handling, and caching strategies. - `registerCommentAvatarFailure`: Verifies failure registration and cache invalidation. This ensures robust handling of user avatars in comments, including edge cases like missing or failed images and cache persistence. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/unit/comment-avatar.test.mjs | 176 +++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/unit/comment-avatar.test.mjs diff --git a/tests/unit/comment-avatar.test.mjs b/tests/unit/comment-avatar.test.mjs new file mode 100644 index 00000000..6ff592c9 --- /dev/null +++ b/tests/unit/comment-avatar.test.mjs @@ -0,0 +1,176 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + normalizeCommentAvatarKey, + resolveCommentAvatarAsset, + registerCommentAvatarFailure, +} from "../../js/ui/components/video-modal/utils/commentAvatar.js"; + +test("normalizeCommentAvatarKey handles inputs correctly", () => { + // Non-string inputs + assert.equal(normalizeCommentAvatarKey(null), ""); + assert.equal(normalizeCommentAvatarKey(undefined), ""); + assert.equal(normalizeCommentAvatarKey(123), ""); + assert.equal(normalizeCommentAvatarKey({}), ""); + + // Whitespace + assert.equal(normalizeCommentAvatarKey(" ABC "), "abc"); + assert.equal(normalizeCommentAvatarKey("\tXYZ\n"), "xyz"); + + // Empty string + assert.equal(normalizeCommentAvatarKey(""), ""); + assert.equal(normalizeCommentAvatarKey(" "), ""); + + // Lowercase + assert.equal(normalizeCommentAvatarKey("HeLLo"), "hello"); +}); + +test("resolveCommentAvatarAsset resolves avatars correctly", () => { + const defaultAvatar = "default.png"; + const pubkey = "pk1"; + const sanitizedPicture = "https://example.com/avatar.png"; + + // Scenario 1: Basic resolution (no cache, no failures) + let result = resolveCommentAvatarAsset({ + cache: new Map(), + failures: new Set(), + defaultAvatar, + pubkey, + sanitizedPicture, + }); + assert.deepEqual(result, { url: sanitizedPicture, source: sanitizedPicture }); + + // Scenario 2: Missing picture + result = resolveCommentAvatarAsset({ + cache: new Map(), + failures: new Set(), + defaultAvatar, + pubkey, + sanitizedPicture: null, + }); + assert.deepEqual(result, { url: defaultAvatar, source: "" }); + + // Scenario 3: Known failure + const failures = new Set([sanitizedPicture]); + result = resolveCommentAvatarAsset({ + cache: new Map(), + failures, + defaultAvatar, + pubkey, + sanitizedPicture, + }); + assert.deepEqual(result, { url: defaultAvatar, source: "" }); + + // Scenario 4: Cache hit (matching source) + const cache = new Map(); + cache.set(pubkey, { url: sanitizedPicture, source: sanitizedPicture }); + result = resolveCommentAvatarAsset({ + cache, + failures: new Set(), + defaultAvatar, + pubkey, + sanitizedPicture, + }); + assert.deepEqual(result, { url: sanitizedPicture, source: sanitizedPicture }); + + // Scenario 5: Cache hit (source mismatch - new avatar) + const newPicture = "https://example.com/new.png"; + result = resolveCommentAvatarAsset({ + cache, // cache has old picture + failures: new Set(), + defaultAvatar, + pubkey, + sanitizedPicture: newPicture, + }); + assert.deepEqual(result, { url: newPicture, source: newPicture }); + // Verify cache update + assert.deepEqual(cache.get(pubkey), { url: newPicture, source: newPicture }); + + // Scenario 6: Cache hit (source mismatch - removed avatar) + // Note: Implementation preserves cached avatar if input is empty (persistence) + result = resolveCommentAvatarAsset({ + cache, // cache has new picture + failures: new Set(), + defaultAvatar, + pubkey, + sanitizedPicture: "", // avatar removed or missing + }); + assert.deepEqual(result, { url: newPicture, source: newPicture }); + // Verify cache remains unchanged + assert.deepEqual(cache.get(pubkey), { url: newPicture, source: newPicture }); + + // Scenario 7: Cache hit (cached failure) + cache.set(pubkey, { url: defaultAvatar, source: "" }); + result = resolveCommentAvatarAsset({ + cache, + failures: new Set(), + defaultAvatar, + pubkey, + sanitizedPicture: "", // no new picture provided + }); + assert.deepEqual(result, { url: defaultAvatar, source: "" }); +}); + +test("registerCommentAvatarFailure registers failures correctly", () => { + const defaultAvatar = "default.png"; + const sourceUrl = "https://example.com/bad.png"; + + // Scenario 1: Basic failure registration + const failures = new Set(); + const cache = new Map(); + registerCommentAvatarFailure({ + cache, + failures, + defaultAvatar, + sourceUrl, + }); + assert.ok(failures.has(sourceUrl)); + + // Scenario 2: Cache invalidation on failure + cache.set("pk1", { url: sourceUrl, source: sourceUrl }); + cache.set("pk2", { + url: "https://example.com/good.png", + source: "https://example.com/good.png", + }); + + registerCommentAvatarFailure({ + cache, + failures, + defaultAvatar, + sourceUrl, + }); + + // Should update pk1 to default + assert.deepEqual(cache.get("pk1"), { url: defaultAvatar, source: "" }); + // Should keep pk2 + assert.deepEqual(cache.get("pk2"), { + url: "https://example.com/good.png", + source: "https://example.com/good.png", + }); + + // Scenario 3: Ignore invalid inputs + const failures2 = new Set(); + registerCommentAvatarFailure({ + cache: new Map(), + failures: failures2, + defaultAvatar, + sourceUrl: "", + }); + assert.equal(failures2.size, 0); + + registerCommentAvatarFailure({ + cache: new Map(), + failures: failures2, + defaultAvatar, + sourceUrl: defaultAvatar, + }); + assert.equal(failures2.size, 0); + + registerCommentAvatarFailure({ + cache: new Map(), + failures: failures2, + defaultAvatar, + sourceUrl: null, + }); + assert.equal(failures2.size, 0); +}); From 53d7bc7a59ce6037f61ac2e48e281f8b2a68ed4f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:13:11 +0000 Subject: [PATCH 04/37] Refactor loadSavedProfilesFromStorage for better maintainability Extracted the complex parsing, validation, and migration logic for saved profiles into a new helper function `processSavedProfilesPayload`. This reduces the complexity of `loadSavedProfilesFromStorage` and improves readability. Functionality is preserved, including handling of legacy formats, NIP-07 auth types, and active profile normalization. Verified with targeted tests: - tests/state/cache-saved-profiles.test.mjs - tests/state/cache.test.mjs Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/state/cache.js | 248 ++++++++++++++++++++++++++-------------------- 1 file changed, 141 insertions(+), 107 deletions(-) diff --git a/js/state/cache.js b/js/state/cache.js index 8adf23da..03c51e7e 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -505,6 +505,133 @@ export function persistSavedProfiles({ persistActive = true } = {}) { writeSavedProfilesPayloadToStorage(payload); } +function processSavedProfilesPayload(parsed) { + const result = { + profiles: [], + activePubkey: null, + hasExplicitActiveProfile: false, + needsRewrite: false, + }; + + if ( + !parsed || + typeof parsed !== "object" || + parsed.version !== SAVED_PROFILES_STORAGE_VERSION + ) { + result.needsRewrite = true; + return result; + } + + const entries = Array.isArray(parsed.entries) ? parsed.entries : []; + const seenPubkeys = new Set(); + for (const candidate of entries) { + if (!candidate || typeof candidate !== "object") { + continue; + } + + const normalizedPubkey = normalizeHexPubkey(candidate.pubkey); + if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) { + result.needsRewrite = true; + continue; + } + + seenPubkeys.add(normalizedPubkey); + const rawAuthType = + typeof candidate.authType === "string" ? candidate.authType : null; + let storedAuthType = null; + if (rawAuthType) { + const trimmedAuthType = rawAuthType.trim(); + if (trimmedAuthType) { + storedAuthType = trimmedAuthType; + if (trimmedAuthType !== rawAuthType) { + result.needsRewrite = true; + } + } + } + + if (!storedAuthType) { + storedAuthType = "nip07"; + if (rawAuthType !== "nip07") { + result.needsRewrite = true; + } + } + const rawProviderId = + typeof candidate.providerId === "string" ? candidate.providerId : null; + let storedProviderId = null; + if (rawProviderId) { + const trimmedProviderId = rawProviderId.trim(); + if (trimmedProviderId) { + storedProviderId = trimmedProviderId; + if (trimmedProviderId !== rawProviderId) { + result.needsRewrite = true; + } + } + } + if (!storedProviderId && storedAuthType) { + storedProviderId = storedAuthType; + if (rawProviderId !== storedProviderId) { + result.needsRewrite = true; + } + } + const npub = + typeof candidate.npub === "string" && candidate.npub.trim() + ? candidate.npub.trim() + : null; + + const entry = { + pubkey: normalizedPubkey, + npub, + name: typeof candidate.name === "string" ? candidate.name : "", + picture: typeof candidate.picture === "string" ? candidate.picture : "", + authType: storedAuthType, + providerId: storedProviderId, + }; + + if ( + typeof candidate.npub === "string" && + candidate.npub.trim() !== candidate.npub + ) { + result.needsRewrite = true; + } + + if ( + entry.npub && + typeof candidate.npub !== "string" && + entry.npub !== candidate.npub + ) { + result.needsRewrite = true; + } + + result.profiles.push(entry); + } + + let hasActiveField = false; + let activeCandidate; + if (Object.prototype.hasOwnProperty.call(parsed, "activePubkey")) { + hasActiveField = true; + activeCandidate = parsed.activePubkey; + } else if (Object.prototype.hasOwnProperty.call(parsed, "activePubKey")) { + hasActiveField = true; + activeCandidate = parsed.activePubKey; + } + + if (hasActiveField) { + result.hasExplicitActiveProfile = true; + if (typeof activeCandidate === "string") { + const normalizedActive = normalizeHexPubkey(activeCandidate); + if (normalizedActive && seenPubkeys.has(normalizedActive)) { + result.activePubkey = normalizedActive; + } else if (activeCandidate) { + result.needsRewrite = true; + } + } else if (activeCandidate !== null && activeCandidate !== undefined) { + result.needsRewrite = true; + } + } + + return result; +} + export function loadSavedProfilesFromStorage() { savedProfiles = []; activeProfilePubkey = null; @@ -530,117 +657,24 @@ export function loadSavedProfilesFromStorage() { let needsRewrite = false; try { const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object" && parsed.version === SAVED_PROFILES_STORAGE_VERSION) { - const entries = Array.isArray(parsed.entries) ? parsed.entries : []; - const seenPubkeys = new Set(); - for (const candidate of entries) { - if (!candidate || typeof candidate !== "object") { - continue; - } - - const normalizedPubkey = normalizeHexPubkey(candidate.pubkey); - if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) { - needsRewrite = true; - continue; - } - - seenPubkeys.add(normalizedPubkey); - const rawAuthType = - typeof candidate.authType === "string" ? candidate.authType : null; - let storedAuthType = null; - if (rawAuthType) { - const trimmedAuthType = rawAuthType.trim(); - if (trimmedAuthType) { - storedAuthType = trimmedAuthType; - if (trimmedAuthType !== rawAuthType) { - needsRewrite = true; - } - } - } - - if (!storedAuthType) { - storedAuthType = "nip07"; - if (rawAuthType !== "nip07") { - needsRewrite = true; - } - } - const rawProviderId = - typeof candidate.providerId === "string" ? candidate.providerId : null; - let storedProviderId = null; - if (rawProviderId) { - const trimmedProviderId = rawProviderId.trim(); - if (trimmedProviderId) { - storedProviderId = trimmedProviderId; - if (trimmedProviderId !== rawProviderId) { - needsRewrite = true; - } - } - } - if (!storedProviderId && storedAuthType) { - storedProviderId = storedAuthType; - if (rawProviderId !== storedProviderId) { - needsRewrite = true; - } - } - const npub = - typeof candidate.npub === "string" && candidate.npub.trim() - ? candidate.npub.trim() - : null; - - const entry = { - pubkey: normalizedPubkey, - npub, - name: typeof candidate.name === "string" ? candidate.name : "", - picture: typeof candidate.picture === "string" ? candidate.picture : "", - authType: storedAuthType, - providerId: storedProviderId, - }; - - if ( - typeof candidate.npub === "string" && candidate.npub.trim() !== candidate.npub - ) { - needsRewrite = true; - } - - if (entry.npub && typeof candidate.npub !== "string" && entry.npub !== candidate.npub) { - needsRewrite = true; - } - - savedProfiles.push(entry); - } - - let hasActiveField = false; - let activeCandidate; - if (Object.prototype.hasOwnProperty.call(parsed, "activePubkey")) { - hasActiveField = true; - activeCandidate = parsed.activePubkey; - } else if (Object.prototype.hasOwnProperty.call(parsed, "activePubKey")) { - hasActiveField = true; - activeCandidate = parsed.activePubKey; - } - - if (hasActiveField) { - hadExplicitActiveProfile = true; - if (typeof activeCandidate === "string") { - const normalizedActive = normalizeHexPubkey(activeCandidate); - if (normalizedActive && seenPubkeys.has(normalizedActive)) { - activeProfilePubkey = normalizedActive; - } else if (activeCandidate) { - needsRewrite = true; - } - } else if (activeCandidate !== null && activeCandidate !== undefined) { - needsRewrite = true; - } - } - } else if (raw) { - needsRewrite = true; - } + const result = processSavedProfilesPayload(parsed); + savedProfiles = result.profiles; + activeProfilePubkey = result.activePubkey; + hadExplicitActiveProfile = result.hasExplicitActiveProfile; + needsRewrite = result.needsRewrite; } catch (error) { - userLogger.warn("[cache.loadSavedProfilesFromStorage] Failed to parse payload:", error); + userLogger.warn( + "[cache.loadSavedProfilesFromStorage] Failed to parse payload:", + error, + ); needsRewrite = true; } - if (!activeProfilePubkey && savedProfiles.length && !hadExplicitActiveProfile) { + if ( + !activeProfilePubkey && + savedProfiles.length && + !hadExplicitActiveProfile + ) { activeProfilePubkey = savedProfiles[0].pubkey; needsRewrite = true; } From 2f78bc6632e524938b01d9185444b29f26e14efb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:13:31 +0000 Subject: [PATCH 05/37] Fix Open Redirect and XSS via NIP-46 Authentication URL - Validated `pendingAuthUrl` protocol to be `http:` or `https:` in `js/ui/loginModalController.js`. - Logged warning if unsafe URL is encountered. Signed-off-by: Jules Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/ui/loginModalController.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/js/ui/loginModalController.js b/js/ui/loginModalController.js index bf8e512d..a739a047 100644 --- a/js/ui/loginModalController.js +++ b/js/ui/loginModalController.js @@ -1561,6 +1561,23 @@ export default class LoginModalController { if (!pendingAuthUrl) { return; } + + let isValid = false; + try { + const parsed = new URL(pendingAuthUrl); + isValid = parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch (error) { + isValid = false; + } + + if (!isValid) { + devLogger.warn( + "[LoginModalController] Blocked unsafe authentication URL:", + pendingAuthUrl, + ); + return; + } + if (this.window && typeof this.window.open === "function") { this.window.open(pendingAuthUrl, "_blank", "noopener,noreferrer"); } else if (this.document && this.document.location) { From 06865e5f46ab06b93a81f42ee046ef3e5cc224ff Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:14:18 +0000 Subject: [PATCH 06/37] test(utils): add unit tests for domUtils - Added `tests/utils/domUtils.test.mjs` to test `escapeHTML` and `removeTrackingScripts`. - Verified tests pass with `node:test` and JSDOM. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/utils/domUtils.test.mjs | 109 ++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/utils/domUtils.test.mjs diff --git a/tests/utils/domUtils.test.mjs b/tests/utils/domUtils.test.mjs new file mode 100644 index 00000000..c2d82bf4 --- /dev/null +++ b/tests/utils/domUtils.test.mjs @@ -0,0 +1,109 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import "../test-setup.mjs"; // Setup JSDOM +import { escapeHTML, removeTrackingScripts } from "../../js/utils/domUtils.js"; + +describe("domUtils", () => { + describe("escapeHTML", () => { + it("should return empty string for null or undefined", () => { + assert.equal(escapeHTML(null), ""); + assert.equal(escapeHTML(undefined), ""); + }); + + it("should return the string as is if no special characters are present", () => { + assert.equal(escapeHTML("hello world"), "hello world"); + assert.equal(escapeHTML("12345"), "12345"); + }); + + it("should escape special characters", () => { + assert.equal(escapeHTML("&"), "&"); + assert.equal(escapeHTML("<"), "<"); + assert.equal(escapeHTML(">"), ">"); + assert.equal(escapeHTML('"'), """); + assert.equal(escapeHTML("'"), "'"); + }); + + it("should escape multiple occurrences of special characters", () => { + assert.equal(escapeHTML(""), "<script>alert('xss')</script>"); + assert.equal(escapeHTML("foo & bar & baz"), "foo & bar & baz"); + }); + + it("should handle mixed content correctly", () => { + const input = '
It\'s me & you
'; + const expected = '<div class="test">It's me & you</div>'; + assert.equal(escapeHTML(input), expected); + }); + + it("should convert non-string inputs to string and escape", () => { + assert.equal(escapeHTML(123), "123"); + // This behavior depends on String(obj), but let's test a simple object if needed. + // Ideally escapeHTML expects a string, but the implementation does String(unsafe). + assert.equal(escapeHTML(true), "true"); + }); + }); + + describe("removeTrackingScripts", () => { + it("should do nothing if root is null or undefined", () => { + // Should not throw + removeTrackingScripts(null); + removeTrackingScripts(undefined); + }); + + it("should do nothing if root has no querySelectorAll", () => { + removeTrackingScripts({}); + }); + + it("should remove scripts matching the tracking pattern", () => { + const container = document.createElement("div"); + + const trackScript = document.createElement("script"); + trackScript.src = "https://example.com/tracking.js"; + container.appendChild(trackScript); + + const trackScript2 = document.createElement("script"); + trackScript2.src = "/tracking.js?id=123"; + container.appendChild(trackScript2); + + const safeScript = document.createElement("script"); + safeScript.src = "https://example.com/app.js"; + container.appendChild(safeScript); + + removeTrackingScripts(container); + + assert.equal(container.querySelectorAll("script").length, 1); + assert.equal(container.querySelector("script").src, "https://example.com/app.js"); + }); + + it("should not remove inline scripts (no src)", () => { + const container = document.createElement("div"); + const inlineScript = document.createElement("script"); + inlineScript.textContent = "console.log('hello')"; + container.appendChild(inlineScript); + + removeTrackingScripts(container); + + assert.equal(container.querySelectorAll("script").length, 1); + assert.equal(container.querySelector("script").textContent, "console.log('hello')"); + }); + + it("should remove scripts where src ends with tracking.js", () => { + const container = document.createElement("div"); + const script = document.createElement("script"); + script.src = "http://bad.com/tracking.js"; + container.appendChild(script); + + removeTrackingScripts(container); + assert.equal(container.querySelectorAll("script").length, 0); + }); + + it("should remove scripts where src contains /tracking.js", () => { + const container = document.createElement("div"); + const script = document.createElement("script"); + script.src = "http://bad.com/js/tracking.js?v=1"; + container.appendChild(script); + + removeTrackingScripts(container); + assert.equal(container.querySelectorAll("script").length, 0); + }); + }); +}); From 4d93ed25ce52ef96899d2db787c91efdce5653f3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:15:02 +0000 Subject: [PATCH 07/37] =?UTF-8?q?=F0=9F=A7=AA=20Add=20tests=20for=20video?= =?UTF-8?q?=20menu=20renderers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jules Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- .../ui/components/videoMenuRenderers.test.mjs | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/ui/components/videoMenuRenderers.test.mjs diff --git a/tests/ui/components/videoMenuRenderers.test.mjs b/tests/ui/components/videoMenuRenderers.test.mjs new file mode 100644 index 00000000..71c8f969 --- /dev/null +++ b/tests/ui/components/videoMenuRenderers.test.mjs @@ -0,0 +1,208 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { + createVideoMoreMenuPanel, + createVideoShareMenuPanel, + createChannelProfileMenuPanel, + createVideoSettingsMenuPanel, +} from '../../../js/ui/components/videoMenuRenderers.js'; + +function setupDom() { + const dom = new JSDOM('', { + url: 'https://example.com', + }); + return { window: dom.window, document: dom.window.document }; +} + +test('createVideoMoreMenuPanel - basic rendering', () => { + const { document } = setupDom(); + const video = { + id: 'event-123', + pubkey: 'pubkey-123', + title: 'Test Video', + description: 'Test Description', + thumbnail: 'thumbnail.jpg', + isPrivate: false, + kind: 30078, + }; + + const panel = createVideoMoreMenuPanel({ + document, + video, + context: 'card', + }); + + assert.ok(panel, 'Panel should be created'); + assert.equal(panel.dataset.menu, 'video-more'); + assert.equal(panel.dataset.menuContext, 'card'); + + // Check common actions + const openChannel = panel.querySelector('[data-action="open-channel"]'); + assert.ok(openChannel, 'Open channel button exists'); + assert.equal(openChannel.dataset.author, video.pubkey); + + const copyLink = panel.querySelector('[data-action="copy-link"]'); + assert.ok(copyLink, 'Copy link button exists'); + assert.equal(copyLink.dataset.eventId, video.id); + + // Check Boost heading + const headings = Array.from(panel.querySelectorAll('.menu__heading')); + assert.ok(headings.some(h => h.textContent === 'Boost on Nostr…'), 'Boost heading exists'); + + // Check Repost + const repost = panel.querySelector('[data-action="repost-event"]'); + assert.ok(repost, 'Repost button exists'); + assert.equal(repost.dataset.eventId, video.id); + + // Check Mute/Block/Report + assert.ok(panel.querySelector('[data-action="mute-author"]'), 'Mute button exists'); + assert.ok(panel.querySelector('[data-action="block-author"]'), 'Block button exists'); + assert.ok(panel.querySelector('[data-action="report"]'), 'Report button exists'); +}); + +test('createVideoMoreMenuPanel - pointer info rendering', () => { + const { document } = setupDom(); + const video = { id: 'v1', pubkey: 'p1' }; + const pointerInfo = { + key: 'history-key', + pointer: ['a', 'value', 'relay'], + }; + + const panel = createVideoMoreMenuPanel({ + document, + video, + pointerInfo, + }); + + const removeHistory = panel.querySelector('[data-action="remove-history"]'); + assert.ok(removeHistory, 'Remove from history button exists'); + assert.equal(removeHistory.dataset.pointerKey, 'history-key'); + assert.equal(removeHistory.dataset.pointerValue, 'value'); + assert.equal(removeHistory.dataset.pointerRelay, 'relay'); +}); + +test('createVideoMoreMenuPanel - mirror logic', () => { + const { document } = setupDom(); + const video = { id: 'v1', isPrivate: false }; + const playbackUrl = 'https://example.com/video.mp4'; + const playbackMagnet = 'magnet:?xt=urn:btih:123'; + + const panel = createVideoMoreMenuPanel({ + document, + video, + playbackUrl, + playbackMagnet, + }); + + const mirror = panel.querySelector('[data-action="mirror-video"]'); + assert.ok(mirror, 'Mirror button exists'); + assert.equal(mirror.dataset.url, playbackUrl); + assert.equal(mirror.dataset.magnet, playbackMagnet); + assert.equal(mirror.dataset.isPrivate, 'false'); +}); + +test('createVideoMoreMenuPanel - blacklist logic', () => { + const { document } = setupDom(); + const video = { id: 'v1', pubkey: 'p1' }; + + const panel = createVideoMoreMenuPanel({ + document, + video, + canManageBlacklist: true, + }); + + const blacklist = panel.querySelector('[data-action="blacklist-author"]'); + assert.ok(blacklist, 'Blacklist button exists'); + assert.equal(blacklist.dataset.variant, 'critical'); +}); + +test('createVideoShareMenuPanel - permission logic', () => { + const { document } = setupDom(); + const video = { id: 'v1' }; + + // Case 1: Not logged in + const panel1 = createVideoShareMenuPanel({ + document, + video, + isLoggedIn: false, + hasSigner: false, + }); + const nostrBtn1 = panel1.querySelector('[data-action="share-nostr"]'); + assert.ok(nostrBtn1.disabled, 'Share on Nostr should be disabled when not logged in'); + + // Case 2: Logged in but no signer + const panel2 = createVideoShareMenuPanel({ + document, + video, + isLoggedIn: true, + hasSigner: false, + }); + const nostrBtn2 = panel2.querySelector('[data-action="share-nostr"]'); + assert.ok(nostrBtn2.disabled, 'Share on Nostr should be disabled without signer'); + + // Case 3: Logged in with signer + const panel3 = createVideoShareMenuPanel({ + document, + video, + isLoggedIn: true, + hasSigner: true, + }); + const nostrBtn3 = panel3.querySelector('[data-action="share-nostr"]'); + assert.ok(!nostrBtn3.disabled, 'Share on Nostr should be enabled with signer'); +}); + +test('createVideoShareMenuPanel - magnet/cdn logic', () => { + const { document } = setupDom(); + const video = { id: 'v1' }; + + const panel = createVideoShareMenuPanel({ + document, + video, + hasMagnet: true, + hasCdn: false, + }); + + const magnetBtn = panel.querySelector('[data-action="copy-magnet"]'); + assert.ok(!magnetBtn.disabled, 'Magnet button should be enabled'); + + const cdnBtn = panel.querySelector('[data-action="copy-cdn"]'); + assert.ok(cdnBtn.disabled, 'CDN button should be disabled'); +}); + +test('createChannelProfileMenuPanel - basic actions', () => { + const { document } = setupDom(); + const panel = createChannelProfileMenuPanel({ + document, + context: 'profile', + }); + + assert.equal(panel.dataset.menu, 'channel-profile'); + assert.ok(panel.querySelector('[data-action="copy-npub"]'), 'Copy npub exists'); + assert.ok(panel.querySelector('[data-action="mute-author"]'), 'Mute author exists'); + assert.ok(panel.querySelector('[data-action="report"]'), 'Report exists'); +}); + +test('createVideoSettingsMenuPanel - capabilities', () => { + const { document } = setupDom(); + const video = { id: 'v1' }; + + // Case 1: Edit only + const panel1 = createVideoSettingsMenuPanel({ + document, + video, + capabilities: { canEdit: true }, + }); + assert.ok(panel1.querySelector('[data-action="edit"]'), 'Edit button exists'); + assert.ok(!panel1.querySelector('[data-action="revert"]'), 'Revert button absent'); + assert.ok(!panel1.querySelector('[data-action="delete"]'), 'Delete button absent'); + + // Case 2: Full capabilities + const panel2 = createVideoSettingsMenuPanel({ + document, + video, + capabilities: { canEdit: true, canRevert: true, canDelete: true }, + }); + assert.ok(panel2.querySelector('[data-action="revert"]'), 'Revert button exists'); + assert.ok(panel2.querySelector('[data-action="delete"]'), 'Delete button exists'); +}); From aae2ea7d16a311d9a95b3f1ae6e195edffe34454 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:15:13 +0000 Subject: [PATCH 08/37] perf: use batch fetch for profile refresh Replaces the sequential loop in `forceRefreshAllProfiles` with `batchFetchProfiles` to reduce network requests. Updates `batchFetchProfilesFromRelays` to support `forceRefresh`, ensuring fresh data is requested while maintaining optimistic UI updates. Adds unit tests for `forceRefresh` behavior. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/app.js | 7 +-- js/utils/profileBatchFetcher.js | 4 ++ tests/app-batch-fetch-profiles.test.mjs | 76 +++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/js/app.js b/js/app.js index e0c363fa..12920b3f 100644 --- a/js/app.js +++ b/js/app.js @@ -718,9 +718,7 @@ class Application { const uniqueAuthors = new Set(activeVideos.map((v) => v.pubkey)); // 3) For each author, fetchAndRenderProfile with forceRefresh = true - for (const authorPubkey of uniqueAuthors) { - this.authService.fetchAndRenderProfile(authorPubkey, true); - } + this.batchFetchProfiles(uniqueAuthors, { forceRefresh: true }); } getCurrentUserNpub() { @@ -2661,9 +2659,10 @@ class Application { return this.authService.fetchAndRenderProfile(pubkey, forceRefresh); } - async batchFetchProfiles(authorSet) { + async batchFetchProfiles(authorSet, { forceRefresh = false } = {}) { return batchFetchProfilesFromRelays({ authorSet, + forceRefresh, getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey), setProfileCacheEntry: (pubkey, profile) => this.setProfileCacheEntry(pubkey, profile), diff --git a/js/utils/profileBatchFetcher.js b/js/utils/profileBatchFetcher.js index 1c3b20e1..b8a0f98e 100644 --- a/js/utils/profileBatchFetcher.js +++ b/js/utils/profileBatchFetcher.js @@ -10,6 +10,7 @@ const DEFAULT_PROFILE_IMAGE = "assets/svg/default-profile.svg"; export async function batchFetchProfilesFromRelays({ authorSet, + forceRefresh = false, getProfileCacheEntry, setProfileCacheEntry, updateProfileInDOM, @@ -50,6 +51,9 @@ export async function batchFetchProfilesFromRelays({ const cacheEntry = getProfileCacheEntry(normalizedPubkey); if (cacheEntry) { updateProfileInDOM(normalizedPubkey, cacheEntry.profile); + if (forceRefresh) { + toFetch.push(normalizedPubkey); + } } else { toFetch.push(normalizedPubkey); } diff --git a/tests/app-batch-fetch-profiles.test.mjs b/tests/app-batch-fetch-profiles.test.mjs index 361a46f7..2e916946 100644 --- a/tests/app-batch-fetch-profiles.test.mjs +++ b/tests/app-batch-fetch-profiles.test.mjs @@ -133,3 +133,79 @@ test("batchFetchProfiles handles fast and failing relays", async () => { { pubkey: VALID_PUBKEY, profile: { name: "Fast", picture: "fast.png" } }, ]); }); + +test("batchFetchProfiles respects forceRefresh", async () => { + const cache = new Map(); + const updates = []; + const setCalls = []; + const authorSet = new Set([ + CACHED_PUBKEY, + ]); + + const getProfileCacheEntry = (pubkey) => { + if (pubkey === CACHED_PUBKEY) { + return { profile: { name: "Cached", picture: "cached.png" } }; + } + return null; + }; + + const setProfileCacheEntry = (pubkey, profile) => { + cache.set(pubkey, { profile }); + setCalls.push({ pubkey, profile }); + }; + + const updateProfileInDOM = (pubkey, profile) => { + updates.push({ pubkey, profile }); + }; + + const originalPool = nostrClient.pool; + const poolListCalls = []; + + nostrClient.pool = { + list: (relays, filters) => { + poolListCalls.push({ relays, filters }); + return Promise.resolve([ + createProfileEvent({ + pubkey: CACHED_PUBKEY, + createdAt: 300, + content: { name: "Refreshed", picture: "refreshed.png" }, + }), + ]); + }, + }; + + try { + await batchFetchProfilesFromRelays({ + authorSet, + forceRefresh: true, + getProfileCacheEntry, + setProfileCacheEntry, + updateProfileInDOM, + }); + } finally { + nostrClient.pool = originalPool; + } + + // Should fetch despite cache presence + assert.ok(poolListCalls.length > 0, "should query relays when forceRefresh is true"); + assert.deepEqual( + poolListCalls[0].filters[0].authors, + [CACHED_PUBKEY], + "should include cached pubkey in fetch list" + ); + + // Should still optimistic update from cache first + assert.deepEqual( + updates[0], + { pubkey: CACHED_PUBKEY, profile: { name: "Cached", picture: "cached.png" } }, + "cached profiles should hydrate immediately even with forceRefresh" + ); + + // Then update with fresh data + const lastUpdate = updates[updates.length - 1]; + assert.deepEqual( + lastUpdate, + { pubkey: CACHED_PUBKEY, profile: { name: "Refreshed", picture: "refreshed.png" } }, + "should update with refreshed data" + ); +}); From 8a9dcab4e1fee332c7f134aa4632dbff84d75419 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:15:48 +0000 Subject: [PATCH 09/37] fix(security): encrypt NIP-46 session keys in localStorage - Implement AES-GCM encryption for NIP-46 `clientPrivateKey` and `secret` using `js/nostr/sessionActor.js` primitives. - Update `writeStoredNip46Session` to accept an optional `passphrase` and encrypt sensitive fields before storage. - Update `readStoredNip46Session` to support encrypted session retrieval. - Add `decryptNip46Session` helper to unlock session keys with a user-provided passphrase. - Update `NostrClient` methods `connectRemoteSigner` and `useStoredRemoteSigner` to support the new encryption flow. - Ensure plain text keys are never persisted to `localStorage` (default safe behavior maintained). Signed-off-by: Jules Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostr/client.js | 101 +++++++++++++++++++++++++++++----------- js/nostr/nip46Client.js | 100 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 30 deletions(-) diff --git a/js/nostr/client.js b/js/nostr/client.js index 757eb994..a50eaabc 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -165,6 +165,7 @@ import { sanitizeRelayList, readStoredNip46Session, writeStoredNip46Session, + decryptNip46Session, clearStoredNip46Session, parseNip46ConnectionString, generateNip46Secret, @@ -1479,11 +1480,15 @@ export class NostrClient { const remotePubkey = stored.remotePubkey || ""; const userPubkey = stored.userPubkey || ""; - const canReconnect = Boolean(stored.clientPrivateKey && stored.secret); + const hasEncryptedSession = + typeof stored.encryptedSecrets === "string" && !!stored.encryptedSecrets; + const canReconnect = + Boolean(stored.clientPrivateKey && stored.secret) || hasEncryptedSession; return { hasSession: true, canReconnect, + hasEncryptedSession, remotePubkey, remoteNpub: encodeHexToNpub(remotePubkey), clientPublicKey: stored.clientPublicKey || "", @@ -2234,6 +2239,7 @@ export class NostrClient { onAuthUrl, onStatus, handshakeTimeoutMs, + passphrase, } = {}) { const parsed = parseNip46ConnectionString(connectionString); if (!parsed) { @@ -2552,19 +2558,26 @@ export class NostrClient { encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", permissions: permissions || null, }); - writeStoredNip46Session({ - version: 1, - clientPublicKey, - remotePubkey, - relays, - encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", - permissions, - metadata, - userPubkey, - lastConnectedAt: Date.now(), - }); + await writeStoredNip46Session( + { + version: 1, + clientPublicKey, + remotePubkey, + relays, + encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", + permissions, + metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey, + secret, + }, + passphrase, + ); } else { - devLogger.debug("[nostr] Clearing stored remote signer session per request"); + devLogger.debug( + "[nostr] Clearing stored remote signer session per request", + ); clearStoredNip46Session(); } @@ -2637,15 +2650,40 @@ export class NostrClient { options && typeof options === "object" ? options : {}; const silent = normalizedOptions.silent === true; const forgetOnError = normalizedOptions.forgetOnError === true; - const validator = typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null; + const passphrase = + typeof normalizedOptions.passphrase === "string" + ? normalizedOptions.passphrase + : null; + const validator = + typeof normalizedOptions.validator === "function" + ? normalizedOptions.validator + : null; - const stored = readStoredNip46Session(); + let stored = readStoredNip46Session(); if (!stored) { - const error = new Error("No remote signer session is stored on this device."); + const error = new Error( + "No remote signer session is stored on this device.", + ); error.code = "no-stored-session"; throw error; } + if (stored.encryptedSecrets) { + if (!passphrase) { + const error = new Error("Passphrase required to unlock session."); + error.code = "passphrase-required"; + throw error; + } + try { + stored = await decryptNip46Session(stored, passphrase); + } catch (decryptError) { + const error = new Error("Failed to decrypt stored session."); + error.code = "decrypt-failed"; + error.cause = decryptError; + throw error; + } + } + const relays = resolveNip46Relays(stored.relays, this.relays); devLogger.debug("[nostr] Reconnecting to stored remote signer", { @@ -2664,7 +2702,9 @@ export class NostrClient { }); if (!stored.clientPrivateKey || !stored.secret) { - const error = new Error("Stored remote signer credentials are unavailable."); + const error = new Error( + "Stored remote signer credentials are unavailable.", + ); error.code = "stored-session-missing-credentials"; throw error; } @@ -2708,17 +2748,22 @@ export class NostrClient { client.metadata = stored.metadata; const signer = await this.installNip46Client(client, { userPubkey }); - writeStoredNip46Session({ - version: 1, - clientPublicKey: stored.clientPublicKey, - remotePubkey: stored.remotePubkey, - relays: stored.relays, - encryption: client.encryptionAlgorithm || stored.encryption || "", - permissions: stored.permissions, - metadata: stored.metadata, - userPubkey, - lastConnectedAt: Date.now(), - }); + await writeStoredNip46Session( + { + version: 1, + clientPublicKey: stored.clientPublicKey, + remotePubkey: stored.remotePubkey, + relays: stored.relays, + encryption: client.encryptionAlgorithm || stored.encryption || "", + permissions: stored.permissions, + metadata: stored.metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey: stored.clientPrivateKey, + secret: stored.secret, + }, + passphrase, + ); devLogger.debug("[nostr] Stored remote signer session refreshed", { remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index cb3d021b..dc7910c5 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -14,6 +14,11 @@ */ import { isDevMode } from "../config.js"; +import { + encryptSessionPrivateKey, + decryptSessionPrivateKey, + normalizeStoredEncryptionMetadata, +} from "./sessionActor.js"; import { DEFAULT_RELAY_URLS, ensureNostrTools, @@ -223,6 +228,11 @@ export function sanitizeStoredNip46Session(candidate) { lastConnectedAt: Number.isFinite(candidate.lastConnectedAt) ? candidate.lastConnectedAt : Date.now(), + encryptedSecrets: + typeof candidate.encryptedSecrets === "string" + ? candidate.encryptedSecrets.trim() + : "", + keyEncryption: normalizeStoredEncryptionMetadata(candidate.keyEncryption), }; } @@ -251,7 +261,7 @@ export function readStoredNip46Session() { (typeof parsed?.clientPrivateKey === "string" || typeof parsed?.secret === "string") ) { - writeStoredNip46Session(sanitized); + writeStoredNip46SessionSync(sanitized); } return sanitized; } catch (error) { @@ -267,7 +277,7 @@ export function readStoredNip46Session() { } } -export function writeStoredNip46Session(payload) { +export function writeStoredNip46SessionSync(payload) { const storage = getNip46Storage(); if (!storage) { return; @@ -285,6 +295,8 @@ export function writeStoredNip46Session(payload) { metadata: payload.metadata, userPubkey: payload.userPubkey, lastConnectedAt: payload.lastConnectedAt, + encryptedSecrets: payload.encryptedSecrets, + keyEncryption: payload.keyEncryption, } : payload; const normalized = sanitizeStoredNip46Session(sanitizedInput); @@ -304,6 +316,90 @@ export function writeStoredNip46Session(payload) { } } +export async function writeStoredNip46Session(payload, passphrase) { + if (!payload || typeof payload !== "object") { + return; + } + + const update = { ...payload }; + + if ( + passphrase && + typeof passphrase === "string" && + passphrase.trim() && + typeof update.clientPrivateKey === "string" && + update.clientPrivateKey && + typeof update.secret === "string" && + update.secret + ) { + const secrets = JSON.stringify({ + clientPrivateKey: update.clientPrivateKey, + secret: update.secret, + }); + + try { + const encrypted = await encryptSessionPrivateKey( + secrets, + passphrase.trim(), + ); + update.encryptedSecrets = encrypted.ciphertext; + update.keyEncryption = { + salt: encrypted.salt, + iv: encrypted.iv, + iterations: encrypted.iterations, + hash: encrypted.hash, + algorithm: encrypted.algorithm, + version: encrypted.version, + }; + } catch (error) { + devLogger.warn("[nostr] Failed to encrypt remote signer session:", error); + // Fall through to write sanitized (non-sensitive) data only + } + } + + // Ensure plain text keys are never persisted + delete update.clientPrivateKey; + delete update.secret; + + writeStoredNip46SessionSync(update); +} + +export async function decryptNip46Session(session, passphrase) { + if (!session || typeof session !== "object") { + return null; + } + + if ( + !session.encryptedSecrets || + typeof session.encryptedSecrets !== "string" + ) { + return { ...session }; + } + + if (!passphrase || typeof passphrase !== "string" || !passphrase.trim()) { + throw new Error("Passphrase required to decrypt session."); + } + + const payload = { + privateKeyEncrypted: session.encryptedSecrets, + encryption: session.keyEncryption, + }; + + const decryptedJson = await decryptSessionPrivateKey(payload, passphrase); + let secrets = null; + try { + secrets = JSON.parse(decryptedJson); + } catch (error) { + throw new Error("Decrypted session secrets are malformed."); + } + + return { + ...session, + clientPrivateKey: secrets.clientPrivateKey, + secret: secrets.secret, + }; +} + export function clearStoredNip46Session() { const storage = getNip46Storage(); if (!storage) { From afd64ba1ce9f73e5c808c4de64a910b865b3fe79 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:15:56 +0000 Subject: [PATCH 10/37] Refactor feed loading logic into shared loadFeedVideos method - Introduce `loadFeedVideos(feedType, forceFetch)` in `js/app/feedCoordinator.js` to consolidate video loading logic. - Update `loadForYouVideos`, `loadKidsVideos`, `loadExploreVideos`, and `loadVideos` to use the shared method. - Parameterize feed-specific configuration (telemetry context, tags, loading messages, relay health checks) based on feed type. - Add `tests/app/feedCoordinator.test.mjs` to verify the new logic. Signed-off-by: Jules Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/app/feedCoordinator.js | 276 ++++++++++------------------- tests/app/feedCoordinator.test.mjs | 89 ++++++++++ 2 files changed, 183 insertions(+), 182 deletions(-) create mode 100644 tests/app/feedCoordinator.test.mjs diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index 06d6237d..8fa082e2 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -1085,6 +1085,96 @@ export function createFeedCoordinator(deps) { return true; }, + /** + * Unified loader for all feed types. + */ + async loadFeedVideos(feedType, forceFetch = false) { + devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")"); + this.setFeedTelemetryContext(feedType); + + let includeTags = false; + let loadingMessage = "Fetching videos\u2026"; + let refreshMethod = null; + let shouldCheckRelayHealth = false; + + switch (feedType) { + case "recent": + includeTags = true; + loadingMessage = "Fetching recent videos\u2026"; + refreshMethod = (opts) => this.refreshRecentFeed(opts); + shouldCheckRelayHealth = true; + break; + case FEED_TYPES.FOR_YOU: + includeTags = false; + loadingMessage = "Fetching for-you videos\u2026"; + refreshMethod = (opts) => this.refreshForYouFeed(opts); + shouldCheckRelayHealth = true; + break; + case FEED_TYPES.KIDS: + includeTags = true; + loadingMessage = "Fetching kids videos\u2026"; + refreshMethod = (opts) => this.refreshKidsFeed(opts); + shouldCheckRelayHealth = false; + break; + case FEED_TYPES.EXPLORE: + includeTags = false; + loadingMessage = "Fetching explore videos\u2026"; + refreshMethod = (opts) => this.refreshExploreFeed(opts); + shouldCheckRelayHealth = false; + break; + default: + devLogger.warn(`[Application] Unknown feed type for loadFeedVideos: ${feedType}`); + return; + } + + if (shouldCheckRelayHealth) { + this.checkRelayHealthWarning(); + } + + const container = this.mountVideoListView({ includeTags }); + const hasCachedVideos = + this.nostrService && + Array.isArray(this.nostrService.getFilteredActiveVideos()) && + this.nostrService.getFilteredActiveVideos().length > 0; + + if (!hasCachedVideos) { + if (this.videoListView && container) { + this.videoListView.showLoading(loadingMessage); + } else if (container) { + container.innerHTML = getSidebarLoadingMarkup(loadingMessage); + } + } + + let initialRefreshPromise = null; + + const videos = await this.nostrService.loadVideos({ + forceFetch, + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + onVideos: (payload, detail = {}) => { + const promise = refreshMethod({ + reason: detail?.reason, + fallbackVideos: payload, + }); + if (!initialRefreshPromise) { + initialRefreshPromise = promise; + } + }, + }); + + if (initialRefreshPromise) { + await initialRefreshPromise; + } else if (!Array.isArray(videos) || videos.length === 0) { + await refreshMethod({ reason: "initial", fallbackVideos: [] }); + } + + this.videoSubscription = this.nostrService.getVideoSubscription() || null; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + }, + /** * Subscribe to videos (older + new) and render them as they come in. */ @@ -1102,52 +1192,7 @@ export function createFeedCoordinator(deps) { this.lastLoadVideosTime = now; this.loadVideosPromise = (async () => { - devLogger.log("Starting loadVideos... (forceFetch =", forceFetch, ")"); - this.setFeedTelemetryContext("recent"); - this.checkRelayHealthWarning(); - - const container = this.mountVideoListView(); - const hasCachedVideos = - this.nostrService && - Array.isArray(this.nostrService.getFilteredActiveVideos()) && - this.nostrService.getFilteredActiveVideos().length > 0; - - if (!hasCachedVideos) { - if (this.videoListView && container) { - this.videoListView.showLoading("Fetching recent videos\u2026"); - } else if (container) { - container.innerHTML = getSidebarLoadingMarkup("Fetching recent videos\u2026"); - } - } - - let initialRefreshPromise = null; - - const videos = await this.nostrService.loadVideos({ - forceFetch, - blacklistedEventIds: this.blacklistedEventIds, - isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - onVideos: (payload, detail = {}) => { - const promise = this.refreshRecentFeed({ - reason: detail?.reason, - fallbackVideos: payload, - }); - if (!initialRefreshPromise) { - initialRefreshPromise = promise; - } - }, - }); - - if (initialRefreshPromise) { - await initialRefreshPromise; - } else if (!Array.isArray(videos) || videos.length === 0) { - await this.refreshRecentFeed({ reason: "initial", fallbackVideos: [] }); - } - - this.videoSubscription = this.nostrService.getVideoSubscription() || null; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } + await this.loadFeedVideos("recent", forceFetch); })(); try { @@ -1158,148 +1203,15 @@ export function createFeedCoordinator(deps) { }, async loadForYouVideos(forceFetch = false) { - devLogger.log("Starting loadForYouVideos... (forceFetch =", forceFetch, ")"); - this.setFeedTelemetryContext(FEED_TYPES.FOR_YOU); - this.checkRelayHealthWarning(); - - const container = this.mountVideoListView({ includeTags: false }); - const hasCachedVideos = - this.nostrService && - Array.isArray(this.nostrService.getFilteredActiveVideos()) && - this.nostrService.getFilteredActiveVideos().length > 0; - - if (!hasCachedVideos) { - if (this.videoListView && container) { - this.videoListView.showLoading("Fetching for-you videos\u2026"); - } else if (container) { - container.innerHTML = getSidebarLoadingMarkup("Fetching for-you videos\u2026"); - } - } - - let initialRefreshPromise = null; - - const videos = await this.nostrService.loadVideos({ - forceFetch, - blacklistedEventIds: this.blacklistedEventIds, - isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - onVideos: (payload, detail = {}) => { - const promise = this.refreshForYouFeed({ - reason: detail?.reason, - fallbackVideos: payload, - }); - if (!initialRefreshPromise) { - initialRefreshPromise = promise; - } - }, - }); - - if (initialRefreshPromise) { - await initialRefreshPromise; - } else if (!Array.isArray(videos) || videos.length === 0) { - await this.refreshForYouFeed({ reason: "initial", fallbackVideos: [] }); - } - - this.videoSubscription = this.nostrService.getVideoSubscription() || null; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } + return this.loadFeedVideos(FEED_TYPES.FOR_YOU, forceFetch); }, async loadKidsVideos(forceFetch = false) { - devLogger.log("Starting loadKidsVideos... (forceFetch =", forceFetch, ")"); - this.setFeedTelemetryContext(FEED_TYPES.KIDS); - - const container = this.mountVideoListView({ includeTags: true }); - const hasCachedVideos = - this.nostrService && - Array.isArray(this.nostrService.getFilteredActiveVideos()) && - this.nostrService.getFilteredActiveVideos().length > 0; - - if (!hasCachedVideos) { - if (this.videoListView && container) { - this.videoListView.showLoading("Fetching kids videos\u2026"); - } else if (container) { - container.innerHTML = getSidebarLoadingMarkup("Fetching kids videos\u2026"); - } - } - - let initialRefreshPromise = null; - - const videos = await this.nostrService.loadVideos({ - forceFetch, - blacklistedEventIds: this.blacklistedEventIds, - isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - onVideos: (payload, detail = {}) => { - const promise = this.refreshKidsFeed({ - reason: detail?.reason, - fallbackVideos: payload, - }); - if (!initialRefreshPromise) { - initialRefreshPromise = promise; - } - }, - }); - - if (initialRefreshPromise) { - await initialRefreshPromise; - } else if (!Array.isArray(videos) || videos.length === 0) { - await this.refreshKidsFeed({ reason: "initial", fallbackVideos: [] }); - } - - this.videoSubscription = this.nostrService.getVideoSubscription() || null; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } + return this.loadFeedVideos(FEED_TYPES.KIDS, forceFetch); }, async loadExploreVideos(forceFetch = false) { - devLogger.log("Starting loadExploreVideos... (forceFetch =", forceFetch, ")"); - this.setFeedTelemetryContext(FEED_TYPES.EXPLORE); - - const container = this.mountVideoListView({ includeTags: false }); - const hasCachedVideos = - this.nostrService && - Array.isArray(this.nostrService.getFilteredActiveVideos()) && - this.nostrService.getFilteredActiveVideos().length > 0; - - if (!hasCachedVideos) { - if (this.videoListView && container) { - this.videoListView.showLoading("Fetching explore videos\u2026"); - } else if (container) { - container.innerHTML = getSidebarLoadingMarkup("Fetching explore videos\u2026"); - } - } - - let initialRefreshPromise = null; - - const videos = await this.nostrService.loadVideos({ - forceFetch, - blacklistedEventIds: this.blacklistedEventIds, - isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - onVideos: (payload, detail = {}) => { - const promise = this.refreshExploreFeed({ - reason: detail?.reason, - fallbackVideos: payload, - }); - if (!initialRefreshPromise) { - initialRefreshPromise = promise; - } - }, - }); - - if (initialRefreshPromise) { - await initialRefreshPromise; - } else if (!Array.isArray(videos) || videos.length === 0) { - await this.refreshExploreFeed({ reason: "initial", fallbackVideos: [] }); - } - - this.videoSubscription = this.nostrService.getVideoSubscription() || null; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } + return this.loadFeedVideos(FEED_TYPES.EXPLORE, forceFetch); }, async loadOlderVideos(lastTimestamp) { diff --git a/tests/app/feedCoordinator.test.mjs b/tests/app/feedCoordinator.test.mjs new file mode 100644 index 00000000..80f93cfd --- /dev/null +++ b/tests/app/feedCoordinator.test.mjs @@ -0,0 +1,89 @@ +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'; + +test('createFeedCoordinator - loadForYouVideos', async (t) => { + const mockDeps = { + devLogger: { log: mock.fn(), warn: mock.fn() }, + userLogger: { warn: mock.fn() }, + nostrClient: {}, + getSidebarLoadingMarkup: mock.fn(() => '
Loading...
'), + watchHistoryService: {}, + subscriptions: {}, + createActiveNostrSource: mock.fn(), + createBlacklistFilterStage: mock.fn(), + createDisinterestFilterStage: mock.fn(), + createDedupeByRootStage: mock.fn(), + createExploreDiversitySorter: mock.fn(), + createExploreScorerStage: mock.fn(), + createKidsAudienceFilterStage: mock.fn(), + createKidsScorerStage: mock.fn(), + createKidsScoreSorter: mock.fn(), + createModerationStage: mock.fn(), + createResolvePostedAtStage: mock.fn(), + createTagPreferenceFilterStage: mock.fn(), + createWatchHistorySuppressionStage: mock.fn(), + createChronologicalSorter: mock.fn(), + createSubscriptionAuthorsSource: mock.fn(), + registerWatchHistoryFeed: mock.fn(), + }; + + const coordinator = createFeedCoordinator(mockDeps); + + // Mock application context + const app = { + ...coordinator, + nostrService: { + getFilteredActiveVideos: mock.fn(() => []), // Simulate no cached videos + loadVideos: mock.fn(async ({ onVideos }) => { + // Simulate fetch completing with videos + onVideos([], { reason: 'test-reason' }); + return []; + }), + getVideoSubscription: mock.fn(() => 'mock-subscription'), + getVideosMap: mock.fn(() => new Map()), + }, + videoListView: { + showLoading: mock.fn(), + state: {}, + }, + mountVideoListView: mock.fn(() => ({ innerHTML: '' })), + checkRelayHealthWarning: mock.fn(), + setFeedTelemetryContext: mock.fn(), + isAuthorBlocked: mock.fn(() => false), + refreshForYouFeed: mock.fn(async () => {}), + refreshRecentFeed: mock.fn(async () => {}), + refreshKidsFeed: mock.fn(async () => {}), + refreshExploreFeed: mock.fn(async () => {}), + blacklistedEventIds: new Set(), + }; + + // Bind methods to app context + const loadForYouVideos = app.loadForYouVideos.bind(app); + + await t.test('loadForYouVideos executes successfully', async () => { + await loadForYouVideos(false); + + // Verify devLogger was called + assert.strictEqual(mockDeps.devLogger.log.mock.callCount() > 0, true, 'devLogger.log should be called'); + + // Verify telemetry context set + assert.strictEqual(app.setFeedTelemetryContext.mock.calls[0].arguments[0], FEED_TYPES.FOR_YOU, 'Should set telemetry context to for-you'); + + // Verify mountVideoListView called with includeTags: false + assert.deepStrictEqual(app.mountVideoListView.mock.calls[0].arguments[0], { includeTags: false }, 'Should mount list view with includeTags: false'); + + // Verify checkRelayHealthWarning called + assert.strictEqual(app.checkRelayHealthWarning.mock.callCount(), 1, 'Should check relay health warning'); + + // Verify nostrService.loadVideos called + assert.strictEqual(app.nostrService.loadVideos.mock.callCount(), 1, 'Should call nostrService.loadVideos'); + + // Verify refreshForYouFeed called via callback + assert.strictEqual(app.refreshForYouFeed.mock.callCount(), 1, 'Should call refreshForYouFeed via callback'); + + // Verify subscription updated + assert.strictEqual(app.videoSubscription, 'mock-subscription', 'Should update videoSubscription'); + }); +}); From 1fa72709d95a573880bebb63b8e4c81c59b3cb0d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:18:40 +0000 Subject: [PATCH 11/37] test: add unit tests for zap helpers Add comprehensive unit tests for `js/ui/dm/zapHelpers.js` in `tests/ui/dm/zapHelpers.test.mjs`. - Covers `formatZapAmount` for standard and compact notation. - Covers `aggregateZapTotals` for aggregation logic and edge cases. - Covers `normalizeZapReceipt` for input normalization. - Ensures robust handling of invalid inputs (NaN, null, etc.). Signed-off-by: Jules Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/ui/dm/zapHelpers.test.mjs | 136 ++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/ui/dm/zapHelpers.test.mjs diff --git a/tests/ui/dm/zapHelpers.test.mjs b/tests/ui/dm/zapHelpers.test.mjs new file mode 100644 index 00000000..225143cb --- /dev/null +++ b/tests/ui/dm/zapHelpers.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { test, describe, it } from "node:test"; +import { + formatZapAmount, + aggregateZapTotals, + normalizeZapReceipt, +} from "../../../js/ui/dm/zapHelpers.js"; + +describe("formatZapAmount", () => { + it("formats standard amounts correctly", () => { + assert.equal(formatZapAmount(100), "100 sats"); + assert.equal(formatZapAmount(1000), "1,000 sats"); + assert.equal(formatZapAmount(1234567), "1,234,567 sats"); + }); + + it("formats compact amounts correctly", () => { + // With maximumFractionDigits: 0, compact notation rounds to nearest integer + assert.equal(formatZapAmount(100, { compact: true }), "100 sats"); + assert.equal(formatZapAmount(1000, { compact: true }), "1K sats"); + assert.equal(formatZapAmount(1200, { compact: true }), "1K sats"); + assert.equal(formatZapAmount(1500, { compact: true }), "2K sats"); + assert.equal(formatZapAmount(1234567, { compact: true }), "1M sats"); + }); + + it("handles zero and small numbers", () => { + assert.equal(formatZapAmount(0), "0 sats"); + // normalizeAmount uses Math.round + assert.equal(formatZapAmount(0.5), "1 sats"); + assert.equal(formatZapAmount(0.4), "0 sats"); + }); + + it("handles invalid inputs gracefully", () => { + assert.equal(formatZapAmount(NaN), "0 sats"); + assert.equal(formatZapAmount(null), "0 sats"); + assert.equal(formatZapAmount(undefined), "0 sats"); + assert.equal(formatZapAmount("invalid"), "0 sats"); + }); + + it("handles string number inputs", () => { + assert.equal(formatZapAmount("100"), "100 sats"); + }); +}); + +describe("aggregateZapTotals", () => { + it("aggregates totals correctly", () => { + const receipts = [ + { amountSats: 100, conversationId: "c1", profileId: "p1" }, + { amountSats: 200, conversationId: "c1", profileId: "p2" }, + { amountSats: 50, conversationId: "c2", profileId: "p1" }, + ]; + + const result = aggregateZapTotals(receipts); + + assert.equal(result.overallSats, 350); + assert.equal(result.totalsByConversation.get("c1"), 300); + assert.equal(result.totalsByConversation.get("c2"), 50); + assert.equal(result.totalsByProfile.get("p1"), 150); + assert.equal(result.totalsByProfile.get("p2"), 200); + }); + + it("handles empty input", () => { + const result = aggregateZapTotals([]); + assert.equal(result.overallSats, 0); + assert.equal(result.totalsByConversation.size, 0); + assert.equal(result.totalsByProfile.size, 0); + }); + + it("handles non-array input", () => { + const result = aggregateZapTotals(null); + assert.equal(result.overallSats, 0); + assert.equal(result.totalsByConversation.size, 0); + assert.equal(result.totalsByProfile.size, 0); + }); + + it("handles missing or invalid amounts", () => { + const receipts = [ + { amountSats: 100, conversationId: "c1" }, + { conversationId: "c1" }, // missing amount + { amountSats: "invalid", conversationId: "c1" }, + { amount: 50, conversationId: "c1" }, // fallback to amount property + ]; + + const result = aggregateZapTotals(receipts); + // 100 + 0 + 0 + 50 = 150 + assert.equal(result.overallSats, 150); + assert.equal(result.totalsByConversation.get("c1"), 150); + }); + + it("handles missing IDs", () => { + const receipts = [ + { amountSats: 100 }, + { amountSats: 200, conversationId: "c1" }, + { amountSats: 300, profileId: "p1" }, + ]; + + const result = aggregateZapTotals(receipts); + assert.equal(result.overallSats, 600); + assert.equal(result.totalsByConversation.get("c1"), 200); + assert.equal(result.totalsByConversation.size, 1); + assert.equal(result.totalsByProfile.get("p1"), 300); + assert.equal(result.totalsByProfile.size, 1); + }); +}); + +describe("normalizeZapReceipt", () => { + it("normalizes valid receipt", () => { + const receipt = { amountSats: 100, other: "field" }; + const normalized = normalizeZapReceipt(receipt); + assert.equal(normalized.amountSats, 100); + assert.equal(normalized.other, "field"); + }); + + it("normalizes receipt with amount fallback", () => { + const receipt = { amount: 100 }; + const normalized = normalizeZapReceipt(receipt); + assert.equal(normalized.amountSats, 100); + // It should preserve original fields + assert.equal(normalized.amount, 100); + }); + + it("normalizes empty receipt", () => { + const normalized = normalizeZapReceipt({}); + assert.equal(normalized.amountSats, 0); + }); + + it("normalizes undefined receipt", () => { + // The function default param is {} so calling with undefined works + const normalized = normalizeZapReceipt(undefined); + assert.equal(normalized.amountSats, 0); + }); + + it("normalizes invalid amount", () => { + const normalized = normalizeZapReceipt({ amountSats: "invalid" }); + assert.equal(normalized.amountSats, 0); + }); +}); From 99d217e778c26c146eb1581cb24b21f54614ebaa Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:20:39 +0000 Subject: [PATCH 12/37] perf: remove dead code in channel profile render loop Removes the call to `app.persistWatchHistoryMetadataForVideo` in `renderChannelVideosFromList` within `js/channelProfile.js`. This function was identified as a no-op (delegating to a deprecated empty method), adding unnecessary overhead to the rendering loop. Benchmark results (100M iterations): - With call: ~1278ms - Without call: ~88ms - Improvement: ~14x speedup in the tight loop simulation. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- benchmarks/render_loop_overhead_bench.js | 71 ++++++++++++++++++++++++ js/channelProfile.js | 6 -- 2 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 benchmarks/render_loop_overhead_bench.js diff --git a/benchmarks/render_loop_overhead_bench.js b/benchmarks/render_loop_overhead_bench.js new file mode 100644 index 00000000..e4c3e559 --- /dev/null +++ b/benchmarks/render_loop_overhead_bench.js @@ -0,0 +1,71 @@ +// benchmarks/render_loop_overhead_bench.js + +class WatchHistoryTelemetry { + persistMetadataForVideo(video, pointerInfo) { + // Deprecated - effectively a no-op in real code + } +} + +class ModalCoordinator { + constructor() { + this.watchHistoryTelemetry = new WatchHistoryTelemetry(); + } + persistWatchHistoryMetadataForVideo(video, pointerInfo) { + if (this.watchHistoryTelemetry) { + this.watchHistoryTelemetry.persistMetadataForVideo(video, pointerInfo); + return; + } + } +} + +class App { + constructor() { + this._modal = new ModalCoordinator(); + this._coordinatorsReady = true; // Simulating initialized state + } + + _initCoordinators() { + if (this._coordinatorsReady) return; + this._coordinatorsReady = true; + } + + persistWatchHistoryMetadataForVideo(...args) { + this._initCoordinators(); + return this._modal.persistWatchHistoryMetadataForVideo(...args); + } +} + +const app = new App(); +const video = { id: "test", title: "test" }; +const pointerInfo = { key: "test" }; + +const ITERATIONS = 100_000_000; + +console.log(`Benchmarking overhead for ${ITERATIONS.toLocaleString()} iterations...`); + +const startWithCall = performance.now(); +for (let i = 0; i < ITERATIONS; i++) { + if ( + pointerInfo && + typeof app?.persistWatchHistoryMetadataForVideo === "function" + ) { + app.persistWatchHistoryMetadataForVideo(video, pointerInfo); + } +} +const endWithCall = performance.now(); +const durationWithCall = endWithCall - startWithCall; + +const startWithoutCall = performance.now(); +for (let i = 0; i < ITERATIONS; i++) { + // Simulating the removal of the call + if (false) { // Condition becomes false or code is removed + // Removed + } +} +const endWithoutCall = performance.now(); +const durationWithoutCall = endWithoutCall - startWithoutCall; + +console.log(`With call: ${durationWithCall.toFixed(2)}ms`); +console.log(`Without call: ${durationWithoutCall.toFixed(2)}ms`); +console.log(`Improvement: ${(durationWithCall - durationWithoutCall).toFixed(2)}ms`); +console.log(`Relative improvement: ${(durationWithCall / durationWithoutCall).toFixed(2)}x`); diff --git a/js/channelProfile.js b/js/channelProfile.js index 83cf0913..e590072a 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -4690,12 +4690,6 @@ export async function renderChannelVideosFromList({ typeof app?.deriveVideoPointerInfo === "function" ? app.deriveVideoPointerInfo(video) : null; - if ( - pointerInfo && - typeof app?.persistWatchHistoryMetadataForVideo === "function" - ) { - app.persistWatchHistoryMetadataForVideo(video, pointerInfo); - } const shareUrl = typeof app?.buildShareUrlFromEventId === "function" From a892d78c4ea36700db5390ee055f49da7ef3c7f5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:25:08 +0000 Subject: [PATCH 13/37] chore: remove custom bech32 implementation in nip46Client Removes the custom `bech32Polymod` and related Bech32 decoding functions from `js/nostr/nip46Client.js` in favor of using `nostr-tools` library implementation via `nip19.decode`. This reduces code duplication and relies on the standard library for decoding npubs. Breaking Changes: - None. `decodeNpubToHex` now strictly requires `nostr-tools` to be available, which is consistent with the rest of the file's dependencies. Tests: - Verified with targeted unit tests (`tests/nostr/nip46Client.test.js`). - Verified with a reproduction script (`tests/repro_issue.mjs`) ensuring `decodeNpubToHex` correctly decodes valid npubs and handles invalid inputs. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostr/nip46Client.js | 124 ---------------------------------------- 1 file changed, 124 deletions(-) diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index cb3d021b..2ed2b4b5 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -317,125 +317,6 @@ export function clearStoredNip46Session() { } } -const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; -const BECH32_CHARSET_MAP = (() => { - const map = new Map(); - for (let index = 0; index < BECH32_CHARSET.length; index += 1) { - map.set(BECH32_CHARSET[index], index); - } - return map; -})(); - -function bech32Polymod(values) { - const GENERATORS = [ - 0x3b6a57b2, - 0x26508e6d, - 0x1ea119fa, - 0x3d4233dd, - 0x2a1462b3, - ]; - - let chk = 1; - for (const value of values) { - const top = chk >> 25; - chk = ((chk & 0x1ffffff) << 5) ^ value; - for (let i = 0; i < GENERATORS.length; i += 1) { - if ((top >> i) & 1) { - chk ^= GENERATORS[i]; - } - } - } - return chk; -} - -function bech32HrpExpand(hrp) { - const result = []; - for (let i = 0; i < hrp.length; i += 1) { - result.push(hrp.charCodeAt(i) >> 5); - } - result.push(0); - for (let i = 0; i < hrp.length; i += 1) { - result.push(hrp.charCodeAt(i) & 31); - } - return result; -} - -function bech32VerifyChecksum(hrp, data) { - return bech32Polymod([...bech32HrpExpand(hrp), ...data]) === 1; -} - -function convertBits(data, fromBits, toBits) { - let accumulator = 0; - let bits = 0; - const result = []; - const maxValue = (1 << toBits) - 1; - const maxAccumulator = (1 << (fromBits + toBits - 1)) - 1; - - for (const value of data) { - if (value < 0 || (value >> fromBits) !== 0) { - return null; - } - accumulator = ((accumulator << fromBits) | value) & maxAccumulator; - bits += fromBits; - while (bits >= toBits) { - bits -= toBits; - result.push((accumulator >> bits) & maxValue); - } - } - - if (bits > 0) { - result.push((accumulator << (toBits - bits)) & maxValue); - } - - return result; -} - -function decodeBech32Npub(value) { - if (typeof value !== "string") { - return ""; - } - - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - - const normalized = trimmed.toLowerCase(); - const separatorIndex = normalized.lastIndexOf("1"); - if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) { - return ""; - } - - const hrp = normalized.slice(0, separatorIndex); - if (hrp !== "npub") { - return ""; - } - - const dataPart = normalized.slice(separatorIndex + 1); - const values = []; - for (let i = 0; i < dataPart.length; i += 1) { - const mapped = BECH32_CHARSET_MAP.get(dataPart[i]); - if (typeof mapped !== "number") { - return ""; - } - values.push(mapped); - } - - if (values.length < 7 || !bech32VerifyChecksum(hrp, values)) { - return ""; - } - - const words = values.slice(0, -6); - const bytes = convertBits(words, 5, 8); - if (!bytes || bytes.length !== 32) { - return ""; - } - - return Array.from(bytes) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - export function decodeNpubToHex(npub) { if (typeof npub !== "string") { return ""; @@ -480,11 +361,6 @@ export function decodeNpubToHex(npub) { } } - const manualDecoded = decodeBech32Npub(trimmed); - if (manualDecoded) { - return manualDecoded; - } - if (isDevMode && warnableNpub) { userLogger.warn( `[nostr] Failed to decode npub: ${trimmed}`, From 273cbfd9ccef2356c9449c77e4ede4f33640d0cf Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:50:18 +0000 Subject: [PATCH 14/37] Refactor HashtagPreferencesService to use unified profile cache key Replaced the legacy "interests" string literal with NOTE_TYPES.HASHTAG_PREFERENCES in profileCache calls to unify key usage. Updated migration logic and comments. Verified that the underlying storage key remains consistent for backward compatibility. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/services/hashtagPreferencesService.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 438cd33a..50fca17e 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -352,18 +352,15 @@ class HashtagPreferencesService { eventId: this.eventId, createdAt: this.eventCreatedAt, }; - // Use the note type key directly, though "interests" maps to HASHTAG_PREFERENCES - // in profileCache. Using NOTE_TYPES.HASHTAG_PREFERENCES explicitly is safer if we import it, - // but relying on the mapped key "interests" is also consistent with current usage. - // However, the fix requires us to ensure we use the unified path. - // We'll stick to "interests" which maps to NOTE_TYPES.HASHTAG_PREFERENCES in profileCache.js - profileCache.set("interests", payload); + // Use the note type key directly. This maps to the same storage key as the legacy "interests" + // mapping in profileCache, ensuring backward compatibility while using the unified path. + profileCache.set(NOTE_TYPES.HASHTAG_PREFERENCES, payload); } loadFromCache(pubkey) { // profileCache internally uses activePubkey, but we should verify if it matches try { - let cached = profileCache.getProfileData(pubkey, "interests"); + let cached = profileCache.getProfileData(pubkey, NOTE_TYPES.HASHTAG_PREFERENCES); // Legacy fallback migration if (!cached && typeof localStorage !== "undefined") { @@ -372,7 +369,7 @@ class HashtagPreferencesService { if (legacy) { const parsed = JSON.parse(legacy); // Migrate to unified profile cache - profileCache.set("interests", parsed); + profileCache.set(NOTE_TYPES.HASHTAG_PREFERENCES, parsed); localStorage.removeItem(HASHTAG_IDENTIFIER); cached = parsed; devLogger.log(`${LOG_PREFIX} Migrated legacy preferences to profileCache.`); From 24402792a836144a25ef3a4e2d279a3eae3fd5c2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:51:11 +0000 Subject: [PATCH 15/37] test: add unit tests for exploreDataService.js - Add `tests/services/exploreDataService.test.mjs` to test `buildWatchHistoryTagCounts`. - Mock global `Worker` to simulate worker behavior. - Mock `console.warn` to verify error logging and suppression. - Use dynamic import to handle `devLogger` initialization dependent on global flags. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/services/exploreDataService.test.mjs | 167 +++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/services/exploreDataService.test.mjs diff --git a/tests/services/exploreDataService.test.mjs b/tests/services/exploreDataService.test.mjs new file mode 100644 index 00000000..ac9be04f --- /dev/null +++ b/tests/services/exploreDataService.test.mjs @@ -0,0 +1,167 @@ + +import { describe, it, before, beforeEach, afterEach, after, mock } from 'node:test'; +import assert from 'node:assert'; + +// Set Dev Mode Override to ensure logs are emitted +globalThis.__BITVID_DEV_MODE_OVERRIDE__ = true; + +// Mock Worker globally before importing the service +class MockWorker { + constructor(scriptURL) { + this.scriptURL = scriptURL; + this.listeners = {}; + MockWorker.instances.push(this); + } + + addEventListener(event, handler) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(handler); + } + + removeEventListener(event, handler) { + if (this.listeners[event]) { + this.listeners[event] = this.listeners[event].filter((h) => h !== handler); + } + } + + postMessage(data) { + // Simulate async processing + setImmediate(() => { + if (MockWorker.onPostMessage) { + MockWorker.onPostMessage(this, data); + } + }); + } + + // Helper to simulate incoming message from worker + emitMessage(data) { + if (this.listeners['message']) { + const event = { data }; + this.listeners['message'].forEach((handler) => handler(event)); + } + } +} + +MockWorker.instances = []; +MockWorker.onPostMessage = null; + +globalThis.Worker = MockWorker; + +describe('exploreDataService - buildWatchHistoryTagCounts', () => { + let buildWatchHistoryTagCounts; + let consoleWarnMock; + let initialCallCount = 0; + + before(async () => { + // Mock console.warn BEFORE import so logger.js captures the mock + // if console.warn is already mocked (e.g. by other tests? unlikely in isolated process), restore it first + if (console.warn.mock) console.warn.mock.restore(); + + consoleWarnMock = mock.method(console, 'warn', () => {}); + + // Dynamic import + const mod = await import('../../js/services/exploreDataService.js'); + buildWatchHistoryTagCounts = mod.buildWatchHistoryTagCounts; + }); + + after(() => { + if (consoleWarnMock) consoleWarnMock.mock.restore(); + }); + + beforeEach(() => { + MockWorker.instances = []; + MockWorker.onPostMessage = null; + initialCallCount = consoleWarnMock.mock.calls.length; + }); + + it('should return empty Map if watchHistoryService is missing', async () => { + const result = await buildWatchHistoryTagCounts({}); + assert.ok(result instanceof Map); + assert.strictEqual(result.size, 0); + }); + + it('should handle missing loadLatest method gracefully', async () => { + const watchHistoryService = {}; + const result = await buildWatchHistoryTagCounts({ watchHistoryService }); + assert.strictEqual(result.size, 0); + }); + + it('should handle loadLatest failure gracefully', async () => { + const watchHistoryService = { + loadLatest: mock.fn(async () => { + throw new Error('Load failed'); + }), + }; + + // Even if load fails, it should continue with empty items + MockWorker.onPostMessage = (worker, data) => { + assert.deepStrictEqual(data.payload.items, []); + worker.emitMessage({ id: data.id, result: new Map() }); + }; + + const result = await buildWatchHistoryTagCounts({ watchHistoryService }); + + assert.ok(result instanceof Map); + assert.strictEqual(result.size, 0); + assert.strictEqual(watchHistoryService.loadLatest.mock.callCount(), 1); + + // Verify warning was logged + const newCalls = consoleWarnMock.mock.calls.slice(initialCallCount); + const warning = newCalls.find(c => c.arguments[0] && String(c.arguments[0]).includes('Failed to load watch history entries')); + assert.ok(warning, 'Should log warning about load failure'); + }); + + it('should return counts from worker on success', async () => { + const watchHistoryService = { + loadLatest: mock.fn(async () => ['item1']), + }; + const nostrService = { + getVideosMap: mock.fn(() => new Map([['v1', { tags: ['t1'] }]])), + }; + + MockWorker.onPostMessage = (worker, data) => { + assert.strictEqual(data.type, 'CALC_HISTORY_COUNTS'); + assert.deepStrictEqual(data.payload.items, ['item1']); + + // Verify worker receives correct payload structure + assert.ok(data.payload.videosMap instanceof Map); + + // Simulate successful result + worker.emitMessage({ + id: data.id, + result: new Map([['t1', 5]]) + }); + }; + + const result = await buildWatchHistoryTagCounts({ + watchHistoryService, + nostrService, + actor: 'user1' + }); + + assert.strictEqual(result.get('t1'), 5); + }); + + it('should handle worker error gracefully', async () => { + const watchHistoryService = { + loadLatest: mock.fn(async () => ['item1']), + }; + + MockWorker.onPostMessage = (worker, data) => { + worker.emitMessage({ + id: data.id, + error: 'Worker exploded' + }); + }; + + const result = await buildWatchHistoryTagCounts({ watchHistoryService }); + assert.ok(result instanceof Map); + assert.strictEqual(result.size, 0); + + const newCalls = consoleWarnMock.mock.calls.slice(initialCallCount); + const warning = newCalls.find(c => c.arguments[0] && String(c.arguments[0]).includes('Worker failed to calculate history counts')); + assert.ok(warning, 'Should log warning about worker failure'); + }); +}); From 549b1848613d1917c85eafc0409b66ae09ac6eed Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:51:47 +0000 Subject: [PATCH 16/37] Add unit tests for count diagnostics module Added a new test file `tests/nostr/countDiagnostics.test.mjs` to cover: - `isVerboseDiagnosticsEnabled` configuration flag logic. - Logging behavior for count failures (relay, timeout, rebroadcast, view). - Throttling logic to ensure repeated warnings are suppressed. - Error suppression for connection failures. The tests use module reloading with cache busting to ensure isolation between test cases, as the module maintains internal state (`seenWarningKeys`). Mocks `console.warn` to verify logging output without polluting stdout. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/nostr/countDiagnostics.test.mjs | 159 ++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/nostr/countDiagnostics.test.mjs diff --git a/tests/nostr/countDiagnostics.test.mjs b/tests/nostr/countDiagnostics.test.mjs new file mode 100644 index 00000000..f8ec0b4f --- /dev/null +++ b/tests/nostr/countDiagnostics.test.mjs @@ -0,0 +1,159 @@ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import "../test-setup.mjs"; + +// Setup globals before importing any module +globalThis.__BITVID_DEV_MODE_OVERRIDE__ = true; +globalThis.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__ = false; + +// Mock console.warn BEFORE importing logger so consoleAdapter binds to our mock +let warnCalls = []; +const originalConsoleWarn = console.warn; +console.warn = (...args) => { + warnCalls.push(args); +}; + +// Force initial import to ensure logger binds to our mock +await import("../../js/nostr/countDiagnostics.js"); + +describe("countDiagnostics", () => { + let countDiagnosticsModule; + + after(() => { + console.warn = originalConsoleWarn; + }); + + beforeEach(async () => { + warnCalls = []; + window.__BITVID_VERBOSE_DEV_MODE__ = undefined; + + // Reload module to get fresh state (empty seenWarningKeys) + // Use unique timestamp to bust module cache + // Note: This relies on Node.js module caching behavior where query params create new module instances + countDiagnosticsModule = await import(`../../js/nostr/countDiagnostics.js?t=${Date.now()}-${Math.random()}`); + }); + + describe("isVerboseDiagnosticsEnabled", () => { + it("should return false by default (when override is false and window flag is undefined)", () => { + assert.equal(countDiagnosticsModule.isVerboseDiagnosticsEnabled(), false); + }); + + it("should return true when window.__BITVID_VERBOSE_DEV_MODE__ is true", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = true; + assert.equal(countDiagnosticsModule.isVerboseDiagnosticsEnabled(), true); + }); + + it("should return false when window.__BITVID_VERBOSE_DEV_MODE__ is false", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = false; + assert.equal(countDiagnosticsModule.isVerboseDiagnosticsEnabled(), false); + }); + + it("should fallback to config default if window flag is not boolean", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = "invalid"; + assert.equal(countDiagnosticsModule.isVerboseDiagnosticsEnabled(), false); + }); + }); + + describe("Logging Functions", () => { + it("should NOT log when verbose mode is disabled", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = false; + const { + logCountTimeoutCleanupFailure, + logRelayCountFailure, + logRebroadcastCountFailure, + logViewCountFailure + } = countDiagnosticsModule; + + logCountTimeoutCleanupFailure(new Error("test error")); + logRelayCountFailure("wss://relay1.com", new Error("test error")); + logRebroadcastCountFailure(new Error("test error")); + logViewCountFailure(new Error("test error")); + + assert.equal(warnCalls.length, 0, "Should not log when verbose mode is disabled"); + }); + + it("should log when verbose mode is enabled", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = true; + const { + logCountTimeoutCleanupFailure, + logRelayCountFailure, + logRebroadcastCountFailure, + logViewCountFailure + } = countDiagnosticsModule; + + // 1. logCountTimeoutCleanupFailure + logCountTimeoutCleanupFailure(new Error("cleanup error")); + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /cleanup failed/); + + // 2. logRelayCountFailure + logRelayCountFailure("wss://relay-enabled.com", new Error("relay error")); + assert.equal(warnCalls.length, 2); + assert.match(warnCalls[1][0], /request failed on wss:\/\/relay-enabled\.com/); + + // 3. logRebroadcastCountFailure + logRebroadcastCountFailure(new Error("rebroadcast error")); + assert.equal(warnCalls.length, 3); + assert.match(warnCalls[2][0], /rebroadcast failed/); + + // 4. logViewCountFailure + logViewCountFailure(new Error("view error")); + assert.equal(warnCalls.length, 4); + assert.match(warnCalls[3][0], /view request failed/); + }); + + it("should throttle repeated logs", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = true; + const { + logCountTimeoutCleanupFailure, + logRelayCountFailure, + logRebroadcastCountFailure, + logViewCountFailure + } = countDiagnosticsModule; + + // First calls should log + logCountTimeoutCleanupFailure(new Error("cleanup error")); + logRelayCountFailure("wss://relay-throttled.com", new Error("relay error")); + logRebroadcastCountFailure(new Error("rebroadcast error")); + logViewCountFailure(new Error("view error")); + + assert.equal(warnCalls.length, 4, "Initial calls should log"); + const initialLogs = [...warnCalls]; + warnCalls = []; // Clear log buffer + + // Repeated calls should throttle + logCountTimeoutCleanupFailure(new Error("cleanup error again")); + logRelayCountFailure("wss://relay-throttled.com", new Error("relay error again")); + logRebroadcastCountFailure(new Error("rebroadcast error again")); + logViewCountFailure(new Error("view error again")); + + assert.equal(warnCalls.length, 0, "Repeated calls should be throttled"); + }); + + it("should log distinct relay failures separately (different keys)", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = true; + const { logRelayCountFailure } = countDiagnosticsModule; + + logRelayCountFailure("wss://relay-1.com", new Error("relay error 1")); + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /wss:\/\/relay-1\.com/); + + warnCalls = []; + logRelayCountFailure("wss://relay-2.com", new Error("relay error 2")); + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /wss:\/\/relay-2\.com/); + }); + + it("should suppress 'Failed to connect to relay' errors", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = true; + const { logRelayCountFailure } = countDiagnosticsModule; + + logRelayCountFailure("wss://relay-fail.com", new Error("Failed to connect to relay wss://relay-fail.com")); + assert.equal(warnCalls.length, 0, "Should suppress connection errors"); + + // Also test with object that has message + logRelayCountFailure("wss://relay-fail-obj.com", { message: "Failed to connect to relay wss://relay-fail-obj.com" }); + assert.equal(warnCalls.length, 0, "Should suppress connection errors (object)"); + }); + }); +}); From 9571e3aedb65b5f63756ddca5aa2e1694a1f6c97 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:55:17 +0000 Subject: [PATCH 17/37] Fix crash in feed loading by delegating loadFeedVideos The `loadFeedVideos` method was missing from the `Application` class in `js/app.js`, but was being called by `loadVideos` inside the feed coordinator (which is bound to the application instance). This caused a `TypeError: this.loadFeedVideos is not a function` at runtime. This change adds the missing delegation method to `Application` in `js/app.js`, forwarding the call to `this._feed.loadFeedVideos`. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/app.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/app.js b/js/app.js index 12920b3f..1a89cd70 100644 --- a/js/app.js +++ b/js/app.js @@ -3969,6 +3969,11 @@ class Application { return this._feed.checkRelayHealthWarning(...args); } + async loadFeedVideos(...args) { + this._initCoordinators(); + return this._feed.loadFeedVideos(...args); + } + /** * Subscribe to videos (older + new) and render them as they come in. */ From ab75153e0bb87cec0a0c371f079a230d5cc01239 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:57:14 +0000 Subject: [PATCH 18/37] test: add tests for max listener diagnostics logic Exports `collectCandidateStrings` and `shouldSuppressWarning` from `js/nostr/maxListenerDiagnostics.js` to enable unit testing. Adds `tests/nostr/maxListenerDiagnostics.test.mjs` to test the warning suppression logic and verify the `process.emitWarning` patch side effect. Fixes missing `jsdom` dependency issue by running `npm install`. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostr/maxListenerDiagnostics.js | 4 +- tests/nostr/maxListenerDiagnostics.test.mjs | 114 ++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/nostr/maxListenerDiagnostics.test.mjs diff --git a/js/nostr/maxListenerDiagnostics.js b/js/nostr/maxListenerDiagnostics.js index 365fa34f..76793fcf 100644 --- a/js/nostr/maxListenerDiagnostics.js +++ b/js/nostr/maxListenerDiagnostics.js @@ -15,7 +15,7 @@ const MAX_LISTENER_SNIPPETS = [ const MAX_LISTENER_CODES = new Set(["MaxListenersExceededWarning"]); -function collectCandidateStrings(value) { +export function collectCandidateStrings(value) { const candidates = []; if (!value) { @@ -40,7 +40,7 @@ function collectCandidateStrings(value) { return candidates; } -function shouldSuppressWarning(...args) { +export function shouldSuppressWarning(...args) { if (isVerboseDiagnosticsEnabled()) { return false; } diff --git a/tests/nostr/maxListenerDiagnostics.test.mjs b/tests/nostr/maxListenerDiagnostics.test.mjs new file mode 100644 index 00000000..83688fb6 --- /dev/null +++ b/tests/nostr/maxListenerDiagnostics.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { test, describe, it, before, after, beforeEach } from "node:test"; +import { shouldSuppressWarning, collectCandidateStrings } from "../../js/nostr/maxListenerDiagnostics.js"; + +// Helper to control verbose mode mock +function setVerboseMode(enabled) { + if (!globalThis.window) { + globalThis.window = {}; + } + globalThis.window.__BITVID_VERBOSE_DEV_MODE__ = enabled; +} + +describe("maxListenerDiagnostics", () => { + let originalWindow; + + before(() => { + originalWindow = globalThis.window; + }); + + after(() => { + globalThis.window = originalWindow; + }); + + beforeEach(() => { + // Default to non-verbose for most tests + setVerboseMode(false); + }); + + describe("collectCandidateStrings", () => { + it("should return empty array for null/undefined/falsy", () => { + assert.deepEqual(collectCandidateStrings(null), []); + assert.deepEqual(collectCandidateStrings(undefined), []); + assert.deepEqual(collectCandidateStrings(false), []); + assert.deepEqual(collectCandidateStrings(0), []); + }); + + it("should return array with string for string input", () => { + assert.deepEqual(collectCandidateStrings("test warning"), ["test warning"]); + }); + + it("should extract relevant fields from object", () => { + const errorObj = { + name: "ErrorName", + message: "ErrorMessage", + code: "ErrorCode", + type: "ErrorType", + other: "OtherField" + }; + const result = collectCandidateStrings(errorObj); + assert.ok(result.includes("ErrorName")); + assert.ok(result.includes("ErrorMessage")); + assert.ok(result.includes("ErrorCode")); + assert.ok(result.includes("ErrorType")); + assert.ok(!result.includes("OtherField")); + assert.equal(result.length, 4); + }); + + it("should ignore non-string fields in object", () => { + const errorObj = { + message: 123, + code: null + }; + assert.deepEqual(collectCandidateStrings(errorObj), []); + }); + }); + + describe("shouldSuppressWarning", () => { + it("should NOT suppress anything if verbose mode is enabled", () => { + setVerboseMode(true); + assert.equal(shouldSuppressWarning("MaxListenersExceededWarning"), false); + assert.equal(shouldSuppressWarning({ code: "MaxListenersExceededWarning" }), false); + }); + + it("should suppress warning by code string", () => { + setVerboseMode(false); + assert.equal(shouldSuppressWarning("MaxListenersExceededWarning"), true); + }); + + it("should suppress warning by object code property", () => { + setVerboseMode(false); + assert.equal(shouldSuppressWarning({ code: "MaxListenersExceededWarning" }), true); + }); + + it("should suppress warning by message snippet", () => { + setVerboseMode(false); + assert.equal(shouldSuppressWarning("Possible EventEmitter memory leak detected. 11 listeners added."), true); + }); + + it("should suppress warning by object message property snippet", () => { + setVerboseMode(false); + assert.equal(shouldSuppressWarning({ message: "Possible EventEmitter memory leak detected" }), true); + }); + + it("should NOT suppress unrelated warnings", () => { + setVerboseMode(false); + assert.equal(shouldSuppressWarning("Some other warning"), false); + assert.equal(shouldSuppressWarning({ message: "Some other warning" }), false); + }); + + it("should handle multiple arguments", () => { + setVerboseMode(false); + // First arg unrelated, second arg matching + assert.equal(shouldSuppressWarning("unrelated", { code: "MaxListenersExceededWarning" }), true); + // Both unrelated + assert.equal(shouldSuppressWarning("unrelated", "also unrelated"), false); + }); + }); + + describe("process.emitWarning patch", () => { + it("should have patched process.emitWarning", () => { + assert.ok(process.emitWarning.__BITVID_MAX_LISTENER_PATCHED__, "process.emitWarning should be patched"); + }); + }); +}); From 01005eacb984d358c1c3569fbaa837cc50e663c4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:05:18 +0000 Subject: [PATCH 19/37] test(utils): add unit tests for videoPointer.js resolution logic Adds comprehensive unit tests for `resolveVideoPointer` in `js/utils/videoPointer.js`. The new tests cover: - Happy paths for address (a-tag) and event (e-tag) pointers. - Priority logic (dTag > videoRootId > fallbackEventId). - Input normalization (trimming, lowercasing). - Relay inclusion in pointer arrays. - Default kind handling (fallback to 30078). - Edge cases and invalid inputs returning null. This improves test coverage for core video pointer resolution utility. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/utils/videoPointer.test.mjs | 152 ++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/utils/videoPointer.test.mjs diff --git a/tests/utils/videoPointer.test.mjs b/tests/utils/videoPointer.test.mjs new file mode 100644 index 00000000..1ff75ba5 --- /dev/null +++ b/tests/utils/videoPointer.test.mjs @@ -0,0 +1,152 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveVideoPointer, DEFAULT_VIDEO_KIND } from "../../js/utils/videoPointer.js"; + +test("resolveVideoPointer returns address pointer with dTag", () => { + const result = resolveVideoPointer({ + kind: 30000, + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + dTag: "test-video", + }); + + assert.ok(result); + assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video"); + assert.deepEqual(result.pointer, [ + "a", + "30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video", + ]); + assert.equal(result.eventId, ""); +}); + +test("resolveVideoPointer returns address pointer with videoRootId", () => { + const result = resolveVideoPointer({ + kind: 30000, + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + videoRootId: "root-id", + }); + + assert.ok(result); + assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:root-id"); + assert.deepEqual(result.pointer, [ + "a", + "30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:root-id", + ]); + assert.equal(result.eventId, ""); +}); + +test("resolveVideoPointer returns event pointer with fallbackEventId", () => { + const eventId = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + const result = resolveVideoPointer({ + fallbackEventId: eventId, + }); + + assert.ok(result); + assert.equal(result.key, `e:${eventId}`); + assert.deepEqual(result.pointer, ["e", eventId]); + assert.equal(result.eventId, eventId); +}); + +test("resolveVideoPointer prioritizes dTag over videoRootId", () => { + const result = resolveVideoPointer({ + kind: 30000, + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + dTag: "priority-dtag", + videoRootId: "ignored-root", + }); + + assert.ok(result); + assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:priority-dtag"); +}); + +test("resolveVideoPointer prioritizes videoRootId over fallbackEventId", () => { + const result = resolveVideoPointer({ + kind: 30000, + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + videoRootId: "priority-root", + fallbackEventId: "ignored-fallback", + }); + + assert.ok(result); + assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:priority-root"); +}); + +test("resolveVideoPointer prioritizes dTag over fallbackEventId", () => { + const result = resolveVideoPointer({ + kind: 30000, + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + dTag: "priority-dtag", + fallbackEventId: "ignored-fallback", + }); + + assert.ok(result); + assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:priority-dtag"); +}); + +test("resolveVideoPointer includes relay in pointer", () => { + const result = resolveVideoPointer({ + kind: 30000, + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + dTag: "test-video", + relay: "wss://relay.example.com", + }); + + assert.ok(result); + // Note: pointerArrayToKey includes relay if present + assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video:wss://relay.example.com"); + assert.deepEqual(result.pointer, [ + "a", + "30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video", + "wss://relay.example.com", + ]); +}); + +test("resolveVideoPointer normalizes inputs", () => { + const result = resolveVideoPointer({ + kind: 30000, + pubkey: " ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234 ", + dTag: " test-video ", + relay: " wss://relay.example.com ", + }); + + assert.ok(result); + assert.equal(result.key, "a:30000:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234:test-video:wss://relay.example.com"); +}); + +test("resolveVideoPointer uses default kind when kind is missing or invalid", () => { + const pubkey = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; + + // Missing kind + let result = resolveVideoPointer({ + pubkey, + dTag: "test", + }); + assert.equal(result.key, `a:${DEFAULT_VIDEO_KIND}:${pubkey}:test`); + + // Invalid kind (string) + result = resolveVideoPointer({ + kind: "30000", + pubkey, + dTag: "test", + }); + assert.equal(result.key, `a:${DEFAULT_VIDEO_KIND}:${pubkey}:test`); + + // Invalid kind (NaN) + result = resolveVideoPointer({ + kind: NaN, + pubkey, + dTag: "test", + }); + assert.equal(result.key, `a:${DEFAULT_VIDEO_KIND}:${pubkey}:test`); +}); + +test("resolveVideoPointer returns null for invalid inputs", () => { + assert.equal(resolveVideoPointer(), null); + assert.equal(resolveVideoPointer({}), null); + + // Missing pubkey for address pointer + assert.equal(resolveVideoPointer({ dTag: "test" }), null); + assert.equal(resolveVideoPointer({ videoRootId: "test" }), null); + + // Missing identifier for address pointer + assert.equal(resolveVideoPointer({ pubkey: "abcd" }), null); +}); From 1cedccbdf365e3d43b53a549b5b19bbe05b35aca Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:09:19 +0000 Subject: [PATCH 20/37] Add tests for count diagnostics Added `tests/nostr/countDiagnostics.test.mjs` to test `js/nostr/countDiagnostics.js`. Covered `isVerboseDiagnosticsEnabled` logic, including `window` overrides and fallback. Covered `logRelayCountFailure` and other logging functions, verifying message content, throttling, and error suppression. Ensured that logging respects the verbose mode flag. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/nostr/countDiagnostics.test.mjs | 182 ++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/nostr/countDiagnostics.test.mjs diff --git a/tests/nostr/countDiagnostics.test.mjs b/tests/nostr/countDiagnostics.test.mjs new file mode 100644 index 00000000..644ea02f --- /dev/null +++ b/tests/nostr/countDiagnostics.test.mjs @@ -0,0 +1,182 @@ +import { describe, it, before, after, afterEach, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import "../test-setup.mjs"; + +describe("countDiagnostics", () => { + let countDiagnostics; + let warnCalls = []; + let originalConsoleWarn; + + before(async () => { + // Setup environment before importing modules + globalThis.__BITVID_DEV_MODE_OVERRIDE__ = true; + globalThis.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__ = true; + + // Mock console.warn + originalConsoleWarn = console.warn; + console.warn = (...args) => { + warnCalls.push(args); + // originalConsoleWarn(...args); // Uncomment to see logs + }; + + // Dynamic import to pick up the overrides + countDiagnostics = await import("../../js/nostr/countDiagnostics.js"); + }); + + after(() => { + if (originalConsoleWarn) { + console.warn = originalConsoleWarn; + } + }); + + afterEach(() => { + warnCalls = []; + // Reset window flags between tests if needed, though most tests set them explicitly + if (typeof window !== "undefined") { + delete window.__BITVID_VERBOSE_DEV_MODE__; + } + }); + + describe("isVerboseDiagnosticsEnabled", () => { + it("should return true by default (due to override)", () => { + assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), true); + }); + + it("should return false if window.__BITVID_VERBOSE_DEV_MODE__ is false", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = false; + assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), false); + }); + + it("should return true if window.__BITVID_VERBOSE_DEV_MODE__ is true", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = true; + assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), true); + }); + + it("should fall back to isVerboseDevMode if window flag is not boolean", () => { + window.__BITVID_VERBOSE_DEV_MODE__ = "invalid"; + assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), true); + + window.__BITVID_VERBOSE_DEV_MODE__ = 123; + assert.equal(countDiagnostics.isVerboseDiagnosticsEnabled(), true); + }); + }); + + describe("logRelayCountFailure", () => { + it("should log warning for new relay URL", () => { + const relayUrl = "wss://relay.example.com"; + const error = new Error("Connection failed"); + + countDiagnostics.logRelayCountFailure(relayUrl, error); + + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /COUNT request failed on wss:\/\/relay.example.com/); + assert.deepEqual(warnCalls[0][1], error); + }); + + it("should suppress 'Failed to connect to relay' errors", () => { + const relayUrl = "wss://relay.bad.com"; + const error = new Error("Failed to connect to relay wss://relay.bad.com"); + + countDiagnostics.logRelayCountFailure(relayUrl, error); + + assert.equal(warnCalls.length, 0); + }); + + it("should throttle duplicate warnings for the same relay", () => { + const relayUrl = "wss://relay.throttled.com"; + const error = new Error("Timeout"); + + // First call logs + countDiagnostics.logRelayCountFailure(relayUrl, error); + assert.equal(warnCalls.length, 1); + + // Second call suppresses + countDiagnostics.logRelayCountFailure(relayUrl, error); + assert.equal(warnCalls.length, 1); + }); + + it("should handle empty or non-string relay URLs", () => { + const error = new Error("Unknown error"); + + countDiagnostics.logRelayCountFailure(null, error); + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /COUNT request failed on \(unknown relay\)/); + + warnCalls = []; + // This call should be throttled because both null and "" map to "(unknown relay)" + countDiagnostics.logRelayCountFailure("", error); + assert.equal(warnCalls.length, 0); + }); + }); + + describe("Other Loggers", () => { + // These use fixed keys so we can only test them once for "logging" behavior per process/suite run + + it("should log timeout cleanup failure once", () => { + const error = new Error("Cleanup failed"); + countDiagnostics.logCountTimeoutCleanupFailure(error); + + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /COUNT timeout cleanup failed/); + + // Verify throttling immediately + countDiagnostics.logCountTimeoutCleanupFailure(error); + assert.equal(warnCalls.length, 1); + }); + + it("should log rebroadcast failure once", () => { + const error = new Error("Rebroadcast failed"); + countDiagnostics.logRebroadcastCountFailure(error); + + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /COUNT request for rebroadcast failed/); + + // Verify throttling + countDiagnostics.logRebroadcastCountFailure(error); + assert.equal(warnCalls.length, 1); + }); + + it("should log view count failure once", () => { + const error = new Error("View count failed"); + countDiagnostics.logViewCountFailure(error); + + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /COUNT view request failed/); + + // Verify throttling + countDiagnostics.logViewCountFailure(error); + assert.equal(warnCalls.length, 1); + }); + }); + + describe("Verbose Mode Disabled", () => { + beforeEach(() => { + window.__BITVID_VERBOSE_DEV_MODE__ = false; + }); + + it("should not log even for new keys when disabled", () => { + const relayUrl = "wss://relay.silent.com"; + const error = new Error("Silent error"); + + countDiagnostics.logRelayCountFailure(relayUrl, error); + assert.equal(warnCalls.length, 0); + }); + + it("should not consume throttle key when disabled", () => { + const relayUrl = "wss://relay.delayed.com"; + const error = new Error("Delayed error"); + + // Disabled: no log, key not added + countDiagnostics.logRelayCountFailure(relayUrl, error); + assert.equal(warnCalls.length, 0); + + // Enable verbose mode + window.__BITVID_VERBOSE_DEV_MODE__ = true; + + // Should log now because previous call didn't register key + countDiagnostics.logRelayCountFailure(relayUrl, error); + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /COUNT request failed on wss:\/\/relay.delayed.com/); + }); + }); +}); From a7d19fc1dbe5f6f60ca213aaad29ddd5d406a979 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:22:16 +0000 Subject: [PATCH 21/37] test: improve gridHealth prioritization testing - Extract `prioritizeEntries` logic to `js/gridHealthLogic.js` for testability - Add comprehensive unit tests in `tests/grid-health.test.mjs` - Improve `getIntersectionRect` to robustly handle invalid rects - Ensure 0x0 rects are filtered out explicitly Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/gridHealth.js | 139 +---------------------- js/gridHealthLogic.js | 134 ++++++++++++++++++++++ tests/grid-health.test.mjs | 221 +++++++++++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+), 134 deletions(-) create mode 100644 js/gridHealthLogic.js create mode 100644 tests/grid-health.test.mjs diff --git a/js/gridHealth.js b/js/gridHealth.js index c428d293..19669701 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -8,6 +8,11 @@ import { removeTorrentProbeFromStorage, writeTorrentProbeToStorage, } from "./utils/storage.js"; +import { + prioritizeEntries, + getViewportCenter, + PRIORITY_BASELINE, +} from "./gridHealthLogic.js"; const badgeUpdateListeners = new Set(); @@ -226,7 +231,6 @@ const PROBE_CACHE_TTL_MS = 5 * 60 * 1000; const PROBE_TIMEOUT_MS = 20000; const PROBE_CONCURRENCY = 96; const PROBE_POLL_COUNT = 3; -const PRIORITY_BASELINE = 1_000_000; class ProbeQueue { constructor(max = 2) { @@ -308,139 +312,6 @@ const probeQueue = new ProbeQueue(PROBE_CONCURRENCY); const probeCache = new Map(); const probeInflight = new Map(); -function getViewportCenter() { - if (typeof window === "undefined") { - return null; - } - const width = Number(window.innerWidth) || 0; - const height = Number(window.innerHeight) || 0; - if (width <= 0 && height <= 0) { - return null; - } - return { - x: width > 0 ? width / 2 : 0, - y: height > 0 ? height / 2 : 0, - }; -} - -function prioritizeEntries(entries, viewportCenter) { - if (!Array.isArray(entries) || entries.length === 0) { - return []; - } - const filtered = entries - .filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement) - .map((entry) => { - const rect = getIntersectionRect(entry); - if (!rect) { - return null; - } - const ratio = - typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; - const centerY = rect.top + rect.height / 2; - const verticalDistance = viewportCenter - ? Math.abs(centerY - viewportCenter.y) - : Number.POSITIVE_INFINITY; - return { entry, ratio, centerY, verticalDistance }; - }) - .filter(Boolean); - - if (filtered.length === 0) { - return []; - } - - if (!viewportCenter) { - return filtered - .sort((a, b) => { - if (b.ratio !== a.ratio) { - return b.ratio - a.ratio; - } - return a.centerY - b.centerY; - }) - .map((item, index) => ({ - entry: item.entry, - priority: PRIORITY_BASELINE - index, - })); - } - - const ordered = filtered - .slice() - .sort((a, b) => a.centerY - b.centerY); - - let centerIndex = 0; - let minDistance = Number.POSITIVE_INFINITY; - for (let i = 0; i < ordered.length; i += 1) { - const candidate = ordered[i]; - if (candidate.verticalDistance < minDistance) { - minDistance = candidate.verticalDistance; - centerIndex = i; - } - } - - const prioritized = []; - const pushCandidate = (candidate) => { - if (!candidate) { - return; - } - prioritized.push(candidate); - }; - - pushCandidate(ordered[centerIndex]); - - let left = centerIndex - 1; - let right = centerIndex + 1; - while (left >= 0 || right < ordered.length) { - const leftCandidate = left >= 0 ? ordered[left] : null; - const rightCandidate = right < ordered.length ? ordered[right] : null; - - if (leftCandidate && rightCandidate) { - const leftDistance = leftCandidate.verticalDistance; - const rightDistance = rightCandidate.verticalDistance; - const distanceDelta = Math.abs(leftDistance - rightDistance); - if (distanceDelta <= 0.5) { - if (rightCandidate.ratio > leftCandidate.ratio) { - pushCandidate(rightCandidate); - right += 1; - } else { - pushCandidate(leftCandidate); - left -= 1; - } - } else if (leftDistance < rightDistance) { - pushCandidate(leftCandidate); - left -= 1; - } else { - pushCandidate(rightCandidate); - right += 1; - } - } else if (rightCandidate) { - pushCandidate(rightCandidate); - right += 1; - } else if (leftCandidate) { - pushCandidate(leftCandidate); - left -= 1; - } - } - - return prioritized.map((candidate, index) => ({ - entry: candidate.entry, - priority: PRIORITY_BASELINE - index, - })); -} - -function getIntersectionRect(entry) { - if (!entry) { - return null; - } - const rect = entry.intersectionRect; - if (rect && rect.width > 0 && rect.height > 0) { - return rect; - } - const fallback = entry.boundingClientRect; - if (fallback && fallback.width > 0 && fallback.height > 0) { - return fallback; - } - return rect || fallback || null; -} - function setBadge(card, state, details) { if (!(card instanceof HTMLElement)) { return; diff --git a/js/gridHealthLogic.js b/js/gridHealthLogic.js new file mode 100644 index 00000000..7b7de952 --- /dev/null +++ b/js/gridHealthLogic.js @@ -0,0 +1,134 @@ +export const PRIORITY_BASELINE = 1_000_000; + +export function getViewportCenter() { + if (typeof window === "undefined") { + return null; + } + const width = Number(window.innerWidth) || 0; + const height = Number(window.innerHeight) || 0; + if (width <= 0 && height <= 0) { + return null; + } + return { + x: width > 0 ? width / 2 : 0, + y: height > 0 ? height / 2 : 0, + }; +} + +export function getIntersectionRect(entry) { + if (!entry) { + return null; + } + const rect = entry.intersectionRect; + if (rect && rect.width > 0 && rect.height > 0) { + return rect; + } + const fallback = entry.boundingClientRect; + if (fallback && fallback.width > 0 && fallback.height > 0) { + return fallback; + } + return null; +} + +export function prioritizeEntries(entries, viewportCenter) { + if (!Array.isArray(entries) || entries.length === 0) { + return []; + } + const filtered = entries + .filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement) + .map((entry) => { + const rect = getIntersectionRect(entry); + if (!rect) { + return null; + } + const ratio = + typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + const centerY = rect.top + rect.height / 2; + const verticalDistance = viewportCenter + ? Math.abs(centerY - viewportCenter.y) + : Number.POSITIVE_INFINITY; + return { entry, ratio, centerY, verticalDistance }; + }) + .filter(Boolean); + + if (filtered.length === 0) { + return []; + } + + if (!viewportCenter) { + return filtered + .sort((a, b) => { + if (b.ratio !== a.ratio) { + return b.ratio - a.ratio; + } + return a.centerY - b.centerY; + }) + .map((item, index) => ({ + entry: item.entry, + priority: PRIORITY_BASELINE - index, + })); + } + + const ordered = filtered + .slice() + .sort((a, b) => a.centerY - b.centerY); + + let centerIndex = 0; + let minDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < ordered.length; i += 1) { + const candidate = ordered[i]; + if (candidate.verticalDistance < minDistance) { + minDistance = candidate.verticalDistance; + centerIndex = i; + } + } + + const prioritized = []; + const pushCandidate = (candidate) => { + if (!candidate) { + return; + } + prioritized.push(candidate); + }; + + pushCandidate(ordered[centerIndex]); + + let left = centerIndex - 1; + let right = centerIndex + 1; + while (left >= 0 || right < ordered.length) { + const leftCandidate = left >= 0 ? ordered[left] : null; + const rightCandidate = right < ordered.length ? ordered[right] : null; + + if (leftCandidate && rightCandidate) { + const leftDistance = leftCandidate.verticalDistance; + const rightDistance = rightCandidate.verticalDistance; + const distanceDelta = Math.abs(leftDistance - rightDistance); + if (distanceDelta <= 0.5) { + if (rightCandidate.ratio > leftCandidate.ratio) { + pushCandidate(rightCandidate); + right += 1; + } else { + pushCandidate(leftCandidate); + left -= 1; + } + } else if (leftDistance < rightDistance) { + pushCandidate(leftCandidate); + left -= 1; + } else { + pushCandidate(rightCandidate); + right += 1; + } + } else if (rightCandidate) { + pushCandidate(rightCandidate); + right += 1; + } else if (leftCandidate) { + pushCandidate(leftCandidate); + left -= 1; + } + } + + return prioritized.map((candidate, index) => ({ + entry: candidate.entry, + priority: PRIORITY_BASELINE - index, + })); +} diff --git a/tests/grid-health.test.mjs b/tests/grid-health.test.mjs new file mode 100644 index 00000000..570fa1d2 --- /dev/null +++ b/tests/grid-health.test.mjs @@ -0,0 +1,221 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { prioritizeEntries } from "../js/gridHealthLogic.js"; + +function setupDom() { + const dom = new JSDOM(""); + globalThis.window = dom.window; + globalThis.HTMLElement = dom.window.HTMLElement; + return dom; +} + +function teardownDom(dom) { + dom.window.close(); + delete globalThis.window; + delete globalThis.HTMLElement; +} + +test("prioritizeEntries input validation", (t) => { + const dom = setupDom(); + t.after(() => teardownDom(dom)); + + assert.deepEqual(prioritizeEntries(null), [], "should return empty array for null entries"); + assert.deepEqual(prioritizeEntries(undefined), [], "should return empty array for undefined entries"); + assert.deepEqual(prioritizeEntries([]), [], "should return empty array for empty entries"); +}); + +test("prioritizeEntries filtering", (t) => { + const dom = setupDom(); + t.after(() => teardownDom(dom)); + + const entries = [ + { isIntersecting: false, target: dom.window.document.createElement("div") }, + { isIntersecting: true, target: {} }, // Not an HTMLElement + { isIntersecting: true, target: dom.window.document.createElement("div"), intersectionRect: { width: 0, height: 0 } }, // Invalid rect + ]; + + const result = prioritizeEntries(entries, { x: 500, y: 500 }); + assert.deepEqual(result, [], "should filter out invalid entries"); +}); + +test("prioritizeEntries without viewport center", (t) => { + const dom = setupDom(); + t.after(() => teardownDom(dom)); + + // prioritizeEntries sorts by ratio desc, then centerY asc + // Note: the implementation sorts by ratio desc (b.ratio - a.ratio), then a.centerY - b.centerY + + const entries = [ + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 100, height: 100, width: 100 }, // centerY = 150 + intersectionRatio: 0.5, + id: 1 + }, + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 200, height: 100, width: 100 }, // centerY = 250 + intersectionRatio: 0.8, + id: 2 + }, + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 0, height: 100, width: 100 }, // centerY = 50 + intersectionRatio: 0.5, + id: 3 + }, + ]; + + const result = prioritizeEntries(entries, null); + + assert.equal(result.length, 3); + // Highest ratio first (id: 2) + assert.equal(result[0].entry.id, 2); + + // Equal ratio (0.5), sort by centerY asc (id: 3 (50) < id: 1 (150)) + assert.equal(result[1].entry.id, 3); + assert.equal(result[2].entry.id, 1); +}); + +test("prioritizeEntries with viewport center", (t) => { + const dom = setupDom(); + t.after(() => teardownDom(dom)); + + const viewportCenter = { x: 500, y: 500 }; + + const entries = [ + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 450, height: 100, width: 100 }, // centerY = 500 (distance 0) + intersectionRatio: 1.0, + id: "center" + }, + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 350, height: 100, width: 100 }, // centerY = 400 (distance 100) + intersectionRatio: 1.0, + id: "above" + }, + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 550, height: 100, width: 100 }, // centerY = 600 (distance 100) + intersectionRatio: 1.0, + id: "below" + }, + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 0, height: 100, width: 100 }, // centerY = 50 (distance 450) + intersectionRatio: 0.5, + id: "far_above" + } + ]; + + const result = prioritizeEntries(entries, viewportCenter); + + assert.equal(result.length, 4); + + // The closest to center should be first + assert.equal(result[0].entry.id, "center"); + + // The logic for left/right (above/below) depends on implementation details + // but "above" and "below" are equidistant. + // The implementation sorts by centerY first: above (400), center (500), below (600), far_above(50) -> far_above, above, center, below + // centerIndex is found (min distance). + // Then it expands left/right. + + // Let's trace: + // Ordered by Y: far_above (50), above (400), center (500), below (600) + // minDistance is center (0). centerIndex = 2. + // result: [center] + // left = 1 (above), right = 3 (below) + // leftDist = 100, rightDist = 100. + // delta = 0 <= 0.5. + // rightRatio (1.0) > leftRatio (1.0) -> False. + // else -> push left (above), left-- + // result: [center, above] + // left = 0 (far_above), right = 3 (below) + // leftDist = 450, rightDist = 100. + // leftDist > rightDist -> push right (below), right++ + // result: [center, above, below] + // left = 0 (far_above), right = 4 (out) + // push left (far_above) + // result: [center, above, below, far_above] + + assert.equal(result[1].entry.id, "above"); + assert.equal(result[2].entry.id, "below"); + assert.equal(result[3].entry.id, "far_above"); +}); + +test("prioritizeEntries ratio prioritization when distances are similar", (t) => { + const dom = setupDom(); + t.after(() => teardownDom(dom)); + + const viewportCenter = { x: 500, y: 500 }; + + const entries = [ + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 450, height: 100, width: 100 }, // centerY = 500 (distance 0) + intersectionRatio: 1.0, + id: "center" + }, + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 350, height: 100, width: 100 }, // centerY = 400 (distance 100) + intersectionRatio: 0.5, + id: "above_low_ratio" + }, + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + intersectionRect: { top: 550, height: 100, width: 100 }, // centerY = 600 (distance 100) + intersectionRatio: 0.9, + id: "below_high_ratio" + } + ]; + + // Logic check: + // Ordered by Y: above_low_ratio (400), center (500), below_high_ratio (600) + // centerIndex = 1 (center) + // left = 0 (above), right = 2 (below) + // Distances: 100 vs 100. Delta = 0. + // rightRatio (0.9) > leftRatio (0.5) -> True. + // Push right (below) + + const result = prioritizeEntries(entries, viewportCenter); + + assert.equal(result[0].entry.id, "center"); + assert.equal(result[1].entry.id, "below_high_ratio"); + assert.equal(result[2].entry.id, "above_low_ratio"); +}); + +test("prioritizeEntries uses boundingClientRect fallback", (t) => { + const dom = setupDom(); + t.after(() => teardownDom(dom)); + + const viewportCenter = { x: 500, y: 500 }; + const entries = [ + { + isIntersecting: true, + target: dom.window.document.createElement("div"), + // No intersectionRect + boundingClientRect: { top: 450, height: 100, width: 100 }, + intersectionRatio: 1.0, + id: "fallback" + } + ]; + + const result = prioritizeEntries(entries, viewportCenter); + assert.equal(result.length, 1); + assert.equal(result[0].entry.id, "fallback"); +}); From 8c8b8fb29af6cb2f543f654cea704bc80f2e0e95 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:23:56 +0000 Subject: [PATCH 22/37] test: add tests for card source visibility logic - Added `tests/utils/cardSourceVisibility.test.mjs` to test `updateVideoCardSourceVisibility`. - Covered scenarios for owner visibility, healthy sources, pending sources, and failed sources. - Verified that card is hidden only when both sources (URL and Stream) are failed. - Ensured graceful handling of null/undefined inputs. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/utils/cardSourceVisibility.test.mjs | 144 ++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/utils/cardSourceVisibility.test.mjs diff --git a/tests/utils/cardSourceVisibility.test.mjs b/tests/utils/cardSourceVisibility.test.mjs new file mode 100644 index 00000000..09fd7f55 --- /dev/null +++ b/tests/utils/cardSourceVisibility.test.mjs @@ -0,0 +1,144 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import "../test-setup.mjs"; +import { updateVideoCardSourceVisibility } from "../../js/utils/cardSourceVisibility.js"; + +describe("cardSourceVisibility", () => { + let card; + + beforeEach(() => { + card = document.createElement("div"); + card.classList.add("card"); + // Default state: not hidden, no health state set (so pending) + card.hidden = false; + // Clear any previous attributes + delete card.dataset.urlHealthState; + delete card.dataset.streamHealthState; + delete card.dataset.ownerIsViewer; + delete card.dataset.sourceVisibility; + }); + + describe("updateVideoCardSourceVisibility", () => { + it("should handle null or undefined input gracefully", () => { + assert.doesNotThrow(() => updateVideoCardSourceVisibility(null)); + assert.doesNotThrow(() => updateVideoCardSourceVisibility(undefined)); + }); + + it("should resolve card from object with .card property", () => { + const wrapper = { card: card }; + card.dataset.urlHealthState = "failed"; + card.dataset.streamHealthState = "failed"; + + // Should hide because both failed + updateVideoCardSourceVisibility(wrapper); + assert.equal(card.hidden, true); + }); + + it("should always show card if owner is viewer", () => { + card.dataset.ownerIsViewer = "true"; + // Set to failed state which would normally hide it + card.dataset.urlHealthState = "failed"; + card.dataset.streamHealthState = "failed"; + card.hidden = true; // Start hidden to verify it unhides + + updateVideoCardSourceVisibility(card); + + assert.equal(card.hidden, false); + assert.equal(card.dataset.sourceVisibility, "visible"); + }); + + it("should show card if at least one source is healthy", () => { + // Case 1: URL healthy + card.dataset.urlHealthState = "healthy"; + card.dataset.streamHealthState = "failed"; + card.hidden = true; + + updateVideoCardSourceVisibility(card); + assert.equal(card.hidden, false); + assert.equal(card.dataset.sourceVisibility, "visible"); + + // Reset + card.hidden = true; + + // Case 2: Stream healthy + card.dataset.urlHealthState = "failed"; + card.dataset.streamHealthState = "healthy"; + + updateVideoCardSourceVisibility(card); + assert.equal(card.hidden, false); + assert.equal(card.dataset.sourceVisibility, "visible"); + }); + + it("should show card if at least one source is checking/pending", () => { + // Case 1: URL checking + card.dataset.urlHealthState = "checking"; + card.dataset.streamHealthState = "failed"; + card.hidden = true; + + updateVideoCardSourceVisibility(card); + assert.equal(card.hidden, false); + + // Reset + card.hidden = true; + + // Case 2: Stream checking + card.dataset.urlHealthState = "failed"; + card.dataset.streamHealthState = "checking"; + + updateVideoCardSourceVisibility(card); + assert.equal(card.hidden, false); + + // Reset + card.hidden = true; + + // Case 3: Empty state (pending) + delete card.dataset.urlHealthState; // undefined/empty means pending + card.dataset.streamHealthState = "failed"; + + updateVideoCardSourceVisibility(card); + assert.equal(card.hidden, false); + }); + + it("should hide card ONLY if both sources are failed (not healthy and not pending)", () => { + card.dataset.urlHealthState = "failed"; + card.dataset.streamHealthState = "failed"; + card.hidden = false; + + updateVideoCardSourceVisibility(card); + + assert.equal(card.hidden, true); + assert.equal(card.dataset.sourceVisibility, "hidden"); + }); + + it("should recover from hidden state when source becomes healthy", () => { + // First failed + card.dataset.urlHealthState = "failed"; + card.dataset.streamHealthState = "failed"; + updateVideoCardSourceVisibility(card); + assert.equal(card.hidden, true); + + // Then healthy + card.dataset.urlHealthState = "healthy"; + updateVideoCardSourceVisibility(card); + assert.equal(card.hidden, false); + assert.equal(card.dataset.sourceVisibility, "visible"); + }); + + it("should check closest .card if element is not .card itself", () => { + const container = document.createElement("div"); + container.classList.add("card"); + const inner = document.createElement("div"); + container.appendChild(inner); + // We need to attach to DOM or mock closest if not attached? + // JSDOM supports closest on detached elements too usually. + + // Fail state on container + container.dataset.urlHealthState = "failed"; + container.dataset.streamHealthState = "failed"; + + updateVideoCardSourceVisibility(inner); + + assert.equal(container.hidden, true); + }); + }); +}); From 1ac9fee2ed67f4db7a621e9633612f616cd37941 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:24:54 +0000 Subject: [PATCH 23/37] feat(test): Add DiscussionCountService error handling tests Add comprehensive tests for `DiscussionCountService` focusing on error handling, input validation, and caching. - Covers happy path, partial results, and unsupported relays. - Validates graceful degradation when `nostrClient` calls fail. - Ensures no regression in service functionality. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- .../services/discussionCountService.test.mjs | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 tests/services/discussionCountService.test.mjs diff --git a/tests/services/discussionCountService.test.mjs b/tests/services/discussionCountService.test.mjs new file mode 100644 index 00000000..278ad97d --- /dev/null +++ b/tests/services/discussionCountService.test.mjs @@ -0,0 +1,192 @@ +import { describe, test, mock, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createUiDom } from "../ui/helpers/jsdom-test-helpers.mjs"; +import DiscussionCountService from "../../js/services/discussionCountService.js"; + +describe("DiscussionCountService", () => { + let dom; + let document; + let window; + + before(() => { + dom = createUiDom(); + window = dom.window; + document = dom.document; + }); + + after(() => { + dom.cleanup(); + }); + + test("initialization sets defaults", () => { + const service = new DiscussionCountService(); + assert.equal(service.maxVideos, 24); + assert.ok(service.logger); + assert.equal(service.videoDiscussionCountCache.size, 0); + assert.equal(service.inFlightDiscussionCounts.size, 0); + }); + + test("refreshCounts returns early if videos array is empty or invalid", () => { + const service = new DiscussionCountService(); + // mock logger to ensure no warnings + const logger = { warn: mock.fn() }; + service.logger = logger; + + service.refreshCounts([], {}); + service.refreshCounts(null, {}); + + assert.equal(service.inFlightDiscussionCounts.size, 0); + assert.equal(logger.warn.mock.callCount(), 0); + }); + + test("refreshCounts returns early if dependencies are missing", () => { + const service = new DiscussionCountService(); + const video = { id: "video1" }; + + // Missing nostrClient + service.refreshCounts([video], { videoListRoot: document.createElement("div") }); + assert.equal(service.inFlightDiscussionCounts.size, 0); + + // Missing videoListRoot + service.refreshCounts([video], { nostrClient: {} }); + assert.equal(service.inFlightDiscussionCounts.size, 0); + }); + + test("refreshCounts handles happy path (fetches and updates DOM)", async () => { + const service = new DiscussionCountService(); + const video = { id: "video1", kind: 30078, pubkey: "pubkey1" }; + + const root = document.createElement("div"); + const container = document.createElement("div"); + container.dataset.discussionCount = "video1"; + const valueEl = document.createElement("span"); + valueEl.dataset.discussionCountValue = "true"; + container.appendChild(valueEl); + root.appendChild(container); + + const nostrClient = { + pool: {}, + countEventsAcrossRelays: mock.fn(async () => ({ + total: 42, + ok: true, + perRelay: [{ ok: true }] + })) + }; + + service.refreshCounts([video], { videoListRoot: root, nostrClient }); + + // Wait for promise resolution (microtasks) + await new Promise(resolve => process.nextTick(resolve)); + await new Promise(resolve => process.nextTick(resolve)); + + assert.equal(nostrClient.countEventsAcrossRelays.mock.callCount(), 1); + assert.equal(valueEl.textContent, "42"); + assert.equal(container.dataset.countState, "ready"); + assert.equal(service.videoDiscussionCountCache.get("video1"), 42); + }); + + test("refreshCounts uses cached count if available", async () => { + const service = new DiscussionCountService(); + const video = { id: "video1" }; + service.videoDiscussionCountCache.set("video1", 100); + + const root = document.createElement("div"); + const container = document.createElement("div"); + container.dataset.discussionCount = "video1"; + const valueEl = document.createElement("span"); + valueEl.dataset.discussionCountValue = "true"; + container.appendChild(valueEl); + root.appendChild(container); + + const nostrClient = { + pool: {}, + countEventsAcrossRelays: mock.fn() + }; + + service.refreshCounts([video], { videoListRoot: root, nostrClient }); + + assert.equal(nostrClient.countEventsAcrossRelays.mock.callCount(), 0); + assert.equal(valueEl.textContent, "100"); + }); + + test("refreshCounts handles API errors gracefully", async () => { + const logger = { warn: mock.fn() }; + const service = new DiscussionCountService({ logger }); + const video = { id: "video1" }; + + const root = document.createElement("div"); + const container = document.createElement("div"); + container.dataset.discussionCount = "video1"; + const valueEl = document.createElement("span"); + valueEl.dataset.discussionCountValue = "true"; + container.appendChild(valueEl); + root.appendChild(container); + + const error = new Error("Network error"); + const nostrClient = { + pool: {}, + countEventsAcrossRelays: mock.fn(async () => { throw error; }) + }; + + service.refreshCounts([video], { videoListRoot: root, nostrClient }); + + const requestPromise = service.inFlightDiscussionCounts.get("video1"); + await assert.rejects(requestPromise, error); + + assert.equal(logger.warn.mock.callCount(), 1); + assert.equal(container.dataset.countState, "error"); + assert.equal(valueEl.textContent, "—"); + }); + + test("refreshCounts handles partial results", async () => { + const service = new DiscussionCountService(); + const video = { id: "video1" }; + + const root = document.createElement("div"); + const container = document.createElement("div"); + container.dataset.discussionCount = "video1"; + const valueEl = document.createElement("span"); + valueEl.dataset.discussionCountValue = "true"; + container.appendChild(valueEl); + root.appendChild(container); + + const nostrClient = { + pool: {}, + countEventsAcrossRelays: mock.fn(async () => ({ total: 5, partial: true, ok: true, perRelay: [{ ok: true }] })) + }; + + service.refreshCounts([video], { videoListRoot: root, nostrClient }); + + await new Promise(resolve => process.nextTick(resolve)); + await new Promise(resolve => process.nextTick(resolve)); + + assert.equal(container.dataset.countState, "partial"); + assert.equal(valueEl.textContent, "5"); + }); + + test("refreshCounts handles unsupported relays (empty perRelay)", async () => { + const service = new DiscussionCountService(); + const video = { id: "video1" }; + + const root = document.createElement("div"); + const container = document.createElement("div"); + container.dataset.discussionCount = "video1"; + const valueEl = document.createElement("span"); + valueEl.dataset.discussionCountValue = "true"; + container.appendChild(valueEl); + root.appendChild(container); + + const nostrClient = { + pool: {}, + countEventsAcrossRelays: mock.fn(async () => ({ total: 0, perRelay: [] })) + }; + + service.refreshCounts([video], { videoListRoot: root, nostrClient }); + + await new Promise(resolve => process.nextTick(resolve)); + await new Promise(resolve => process.nextTick(resolve)); + + assert.equal(container.dataset.countState, "unsupported"); + assert.equal(valueEl.textContent, "—"); + }); +}); From 596da72cb17d1f85463a467f4967b4cacb395b0b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:25:42 +0000 Subject: [PATCH 24/37] test: improve relay health service error handling and robustness - Adds a default connection timeout of 5000ms to `RelayHealthService.checkRelay`. - Adds comprehensive tests for `RelayHealthService` covering: - Connection timeouts (mocked). - Failure thresholds for user logging. - Logging cooldowns (mocked time). - Relay `disconnect` and `error` event handling. - `nostrClient.markRelayUnreachable` integration. - Refactors tests to use manual mocking for `setTimeout` and `Date.now` for stability. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/services/relayHealthService.js | 12 +- tests/services/relay-health-service.test.mjs | 144 ++++++++++++++++++- 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/js/services/relayHealthService.js b/js/services/relayHealthService.js index 98eddea6..47ae5061 100644 --- a/js/services/relayHealthService.js +++ b/js/services/relayHealthService.js @@ -3,6 +3,7 @@ import { devLogger, userLogger } from "../utils/logger.js"; const TELEMETRY_STORAGE_KEY = "bitvid:relay-health-telemetry-opt-in"; const PERSISTENT_FAILURE_THRESHOLD = 3; const USER_LOG_COOLDOWN_MS = 5 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 5000; function resolveLogger(logger) { if (logger && logger.dev && logger.user) { @@ -202,7 +203,16 @@ class RelayHealthService { const start = nowMs(); try { - const relay = await this.nostrClient.pool.ensureRelay(relayUrl); + const relay = await Promise.race([ + this.nostrClient.pool.ensureRelay(relayUrl), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("relay-connection-timeout")), + DEFAULT_TIMEOUT_MS, + ), + ), + ]); + if (!relay) { throw new Error("relay-unavailable"); } diff --git a/tests/services/relay-health-service.test.mjs b/tests/services/relay-health-service.test.mjs index 1943423c..4d67dbcf 100644 --- a/tests/services/relay-health-service.test.mjs +++ b/tests/services/relay-health-service.test.mjs @@ -2,7 +2,7 @@ import "../test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; -import test from "node:test"; +import test, { mock } from "node:test"; import RelayHealthService from "../../js/services/relayHealthService.js"; // Mock SimpleEventEmitter for tests @@ -166,3 +166,145 @@ test("RelayHealthService: emits telemetry if opted in", async () => { assert.equal(telemetryPayload.relays.length, 1); assert.equal(telemetryPayload.relays[0].url, "wss://t1.com"); }); + +test("RelayHealthService: checkRelay times out after DEFAULT_TIMEOUT_MS", async () => { + const originalSetTimeout = globalThis.setTimeout; + let timeoutCallback; + + // Manually mock setTimeout to capture callback + globalThis.setTimeout = (cb, delay) => { + if (delay === 5000) { + timeoutCallback = cb; + return 123; // dummy timer id + } + return originalSetTimeout(cb, delay); + }; + + try { + const nostrClient = { + ensurePool: async () => {}, + pool: { + ensureRelay: () => new Promise(() => {}), // Never resolves + }, + }; + + const service = new RelayHealthService({ nostrClient }); + const checkPromise = service.checkRelay("wss://timeout.com"); + + // Wait for the promise race to register the timeout + await new Promise((resolve) => originalSetTimeout(resolve, 0)); + + // Trigger the timeout manually + if (timeoutCallback) { + timeoutCallback(); + } else { + throw new Error("setTimeout was not called with 5000ms delay"); + } + + await checkPromise; + + const state = service.ensureRelayState("wss://timeout.com"); + assert.equal(state.connected, false); + assert.equal(state.errorCount, 1); + assert.equal(state.consecutiveFailures, 1); + } finally { + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("RelayHealthService: failure threshold triggers user warning", () => { + const userLogger = { warn: mock.fn() }; + const devLogger = { warn: mock.fn() }; + const logger = { user: userLogger, dev: devLogger }; + + const service = new RelayHealthService({ logger }); + const url = "wss://fail.com"; + + // 1st failure + service.recordRelayFailure(url, new Error("fail 1")); + assert.equal(userLogger.warn.mock.callCount(), 0); + + // 2nd failure + service.recordRelayFailure(url, new Error("fail 2")); + assert.equal(userLogger.warn.mock.callCount(), 0); + + // 3rd failure (Threshold is 3) + service.recordRelayFailure(url, new Error("fail 3")); + assert.equal(userLogger.warn.mock.callCount(), 1); +}); + +test("RelayHealthService: user warning respects cooldown", () => { + const originalDateNow = Date.now; + let currentTime = 1000000; + Date.now = () => currentTime; + + try { + const userLogger = { warn: mock.fn() }; + const devLogger = { warn: mock.fn() }; + const logger = { user: userLogger, dev: devLogger }; + const service = new RelayHealthService({ logger }); + const url = "wss://cooldown.com"; + + // Trigger 3 failures to reach threshold and get first warning + service.recordRelayFailure(url, new Error("1")); + service.recordRelayFailure(url, new Error("2")); + service.recordRelayFailure(url, new Error("3")); + assert.equal(userLogger.warn.mock.callCount(), 1); + + // Advance 1 minute (less than 5 mins cooldown) + currentTime += 60 * 1000; + service.recordRelayFailure(url, new Error("4")); + assert.equal(userLogger.warn.mock.callCount(), 1); // Still 1 + + // Advance 5 minutes (more than cooldown) + currentTime += 5 * 60 * 1000; + service.recordRelayFailure(url, new Error("5")); + assert.equal(userLogger.warn.mock.callCount(), 2); // Now 2 + } finally { + Date.now = originalDateNow; + } +}); + +test("RelayHealthService: relay disconnect/error events trigger failure", () => { + const service = new RelayHealthService(); + const url = "wss://events.com"; + + // Mock relay + const relay = new SimpleEventEmitter(); + + // Attach listeners manually for this test case (usually done via checkRelay) + service.attachRelayListeners(url, relay); + + // Simulate connect + relay.emit("connect"); + assert.equal(service.ensureRelayState(url).connected, true); + + // Simulate disconnect + relay.emit("disconnect"); + const stateDisconnect = service.ensureRelayState(url); + assert.equal(stateDisconnect.connected, false); + assert.equal(stateDisconnect.errorCount, 1); + + // Simulate connect again + relay.emit("connect"); + assert.equal(service.ensureRelayState(url).connected, true); + + // Simulate error + relay.emit("error", new Error("boom")); + const stateError = service.ensureRelayState(url); + assert.equal(stateError.connected, false); + assert.equal(stateError.errorCount, 2); +}); + +test("RelayHealthService: integrates with nostrClient.markRelayUnreachable", () => { + const markRelayUnreachable = mock.fn(); + const nostrClient = { markRelayUnreachable }; + const service = new RelayHealthService({ nostrClient }); + + service.recordRelayFailure("wss://unreachable.com", new Error("fail")); + + assert.equal(markRelayUnreachable.mock.callCount(), 1); + const args = markRelayUnreachable.mock.calls[0].arguments; + assert.equal(args[0], "wss://unreachable.com"); + assert.equal(args[1], 60000); // Verify duration +}); From 75753b92fdcf2cedadc3bdd4ea49c3306e226d94 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:26:53 +0000 Subject: [PATCH 25/37] Refactor buildCommentEvent to use helper functions Extracted parameter normalization, context resolution, and tag building into dedicated helper functions (`normalizeCommentInputs`, `resolveCommentContext`, `buildCommentTags`) to improve readability and maintainability of `buildCommentEvent`. This simplifies the main function logic and makes it easier to test individual components in the future if exported. No functional changes intended. Verified with existing unit and integration tests. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostrEventSchemas.js | 137 +++++++++++++++++++++++++--------------- 1 file changed, 87 insertions(+), 50 deletions(-) diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index c2004dc7..adbe6b06 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -2106,57 +2106,46 @@ export function buildReactionEvent(params) { return event; } -export function buildCommentEvent(params) { - const { - pubkey, - created_at, - videoEventId = "", - videoEventRelay = "", - videoDefinitionAddress = "", - videoDefinitionRelay = "", - rootIdentifier = "", - rootIdentifierRelay = "", - parentCommentId = "", - parentCommentRelay = "", - threadParticipantPubkey = "", - threadParticipantRelay = "", - rootKind, - rootAuthorPubkey = "", - rootAuthorRelay = "", - parentKind, - parentAuthorPubkey = "", - parentAuthorRelay = "", - parentIdentifier = "", - parentIdentifierRelay = "", - additionalTags = [], - content = "", - } = params || {}; - const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT); - const tags = []; - +function normalizeCommentInputs(params) { const normalizeString = (value) => typeof value === "string" ? value.trim() : ""; - const normalizedVideoEventId = normalizeString(videoEventId); - const normalizedVideoEventRelay = normalizeString(videoEventRelay); - const normalizedVideoDefinitionAddress = normalizeString(videoDefinitionAddress); - const normalizedVideoDefinitionRelay = normalizeString(videoDefinitionRelay); - const normalizedRootIdentifier = normalizeString(rootIdentifier); - const normalizedRootIdentifierRelay = normalizeString(rootIdentifierRelay); - const normalizedParentCommentId = normalizeString(parentCommentId); - const normalizedParentCommentRelay = normalizeString(parentCommentRelay); - const normalizedParentIdentifier = normalizeString(parentIdentifier); - const normalizedParentIdentifierRelay = normalizeString(parentIdentifierRelay); - const normalizedThreadParticipantPubkey = normalizeString( - threadParticipantPubkey, - ); - const normalizedThreadParticipantRelay = normalizeString( - threadParticipantRelay, - ); + return { + normalizedVideoEventId: normalizeString(params.videoEventId), + normalizedVideoEventRelay: normalizeString(params.videoEventRelay), + normalizedVideoDefinitionAddress: normalizeString(params.videoDefinitionAddress), + normalizedVideoDefinitionRelay: normalizeString(params.videoDefinitionRelay), + normalizedRootIdentifier: normalizeString(params.rootIdentifier), + normalizedRootIdentifierRelay: normalizeString(params.rootIdentifierRelay), + normalizedParentCommentId: normalizeString(params.parentCommentId), + normalizedParentCommentRelay: normalizeString(params.parentCommentRelay), + normalizedThreadParticipantPubkey: normalizeString(params.threadParticipantPubkey), + normalizedThreadParticipantRelay: normalizeString(params.threadParticipantRelay), + normalizedParentIdentifier: normalizeString(params.parentIdentifier), + normalizedParentIdentifierRelay: normalizeString(params.parentIdentifierRelay), + resolvedRootKind: normalizeString(params.rootKind), + resolvedRootAuthorPubkey: normalizeString(params.rootAuthorPubkey), + resolvedRootAuthorRelay: normalizeString(params.rootAuthorRelay), + resolvedParentAuthorPubkey: normalizeString(params.parentAuthorPubkey), + resolvedParentAuthorRelay: normalizeString(params.parentAuthorRelay), + resolvedParentKind: normalizeString(params.parentKind), + }; +} - let resolvedRootKind = normalizeString(rootKind); - let resolvedRootAuthorPubkey = normalizeString(rootAuthorPubkey); - let resolvedRootAuthorRelay = normalizeString(rootAuthorRelay); +function resolveCommentContext(inputs, schema) { + let { + resolvedRootKind, + resolvedRootAuthorPubkey, + resolvedRootAuthorRelay, + resolvedParentAuthorPubkey, + resolvedParentAuthorRelay, + resolvedParentKind, + normalizedVideoDefinitionAddress, + normalizedVideoDefinitionRelay, + normalizedThreadParticipantPubkey, + normalizedThreadParticipantRelay, + normalizedParentCommentId, + } = inputs; if (normalizedVideoDefinitionAddress) { const definitionSegments = normalizedVideoDefinitionAddress.split(":"); @@ -2171,12 +2160,10 @@ export function buildCommentEvent(params) { } } - let resolvedParentAuthorPubkey = normalizeString(parentAuthorPubkey); if (!resolvedParentAuthorPubkey) { resolvedParentAuthorPubkey = normalizedThreadParticipantPubkey; } - let resolvedParentAuthorRelay = normalizeString(parentAuthorRelay); if (!resolvedParentAuthorRelay) { resolvedParentAuthorRelay = normalizedThreadParticipantRelay; } @@ -2195,7 +2182,6 @@ export function buildCommentEvent(params) { resolvedParentAuthorRelay = resolvedRootAuthorRelay; } - let resolvedParentKind = normalizeString(parentKind); if (!resolvedParentKind) { if (normalizedParentCommentId) { resolvedParentKind = String(schema?.kind ?? 1111); @@ -2208,6 +2194,41 @@ export function buildCommentEvent(params) { resolvedRootKind = resolvedParentKind; } + return { + resolvedRootKind, + resolvedRootAuthorPubkey, + resolvedRootAuthorRelay, + resolvedParentAuthorPubkey, + resolvedParentAuthorRelay, + resolvedParentKind, + }; +} + +function buildCommentTags(schema, inputs, context) { + const { + normalizedVideoEventId, + normalizedVideoEventRelay, + normalizedVideoDefinitionAddress, + normalizedVideoDefinitionRelay, + normalizedRootIdentifier, + normalizedRootIdentifierRelay, + normalizedParentCommentId, + normalizedParentCommentRelay, + normalizedParentIdentifier, + normalizedParentIdentifierRelay, + } = inputs; + + const { + resolvedRootKind, + resolvedRootAuthorPubkey, + resolvedRootAuthorRelay, + resolvedParentAuthorPubkey, + resolvedParentAuthorRelay, + resolvedParentKind, + } = context; + + const tags = []; + const rootDefinitionPointerTagName = schema?.rootDefinitionPointerTagName || "A"; const rootEventPointerTagName = schema?.rootEventPointerTagName || "E"; const rootIdentifierPointerTagName = schema?.rootIdentifierPointerTagName || "I"; @@ -2333,6 +2354,22 @@ export function buildCommentEvent(params) { } } + return tags; +} + +export function buildCommentEvent(params) { + const { + pubkey, + created_at, + additionalTags = [], + content = "", + } = params || {}; + const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT); + + const inputs = normalizeCommentInputs(params || {}); + const context = resolveCommentContext(inputs, schema); + const tags = buildCommentTags(schema, inputs, context); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); if (sanitizedAdditionalTags.length) { tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); From b2eedb46dfb95984eacc40d8e87e7a626b1ed06c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:27:41 +0000 Subject: [PATCH 26/37] Refactor WebTorrent handlers to reduce duplication - Merged `handleChromeTorrent` and `handleFirefoxTorrent` into `handleTorrentStream` with a `context` parameter. - Preserved browser-specific logic (CORS workaround for Chrome, highWaterMark for Firefox). - Added `tests/webtorrent-handlers.test.mjs` to cover the refactored logic. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/webtorrent.js | 123 +++++++++++---------------- tests/webtorrent-handlers.test.mjs | 128 +++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 75 deletions(-) create mode 100644 tests/webtorrent-handlers.test.mjs diff --git a/js/webtorrent.js b/js/webtorrent.js index df95e2dd..50715369 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -706,34 +706,44 @@ export class TorrentClient { }); } - // Handle Chrome-based browsers - handleChromeTorrent(torrent, videoElement, resolve, reject) { - // Prune demo web seeds/trackers that chronically trip Chromium CORS and - // deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround. - torrent.on("warning", (err) => { - if (err && typeof err.message === "string") { - if ( - err.message.includes("CORS") || - err.message.includes("Access-Control-Allow-Origin") - ) { - userLogger.warn( - "CORS warning detected. Attempting to remove the failing webseed/tracker." - ); - if (torrent._opts?.urlList?.length) { - torrent._opts.urlList = torrent._opts.urlList.filter((url) => { - return !url.includes("distribution.bbb3d.renderfarming.net"); - }); - userLogger.warn("Cleaned up webseeds =>", torrent._opts.urlList); - } - if (torrent._opts?.announce?.length) { - torrent._opts.announce = torrent._opts.announce.filter((url) => { - return !url.includes("fastcast.nz"); - }); - userLogger.warn("Cleaned up trackers =>", torrent._opts.announce); + handleTorrentStream( + torrent, + videoElement, + resolve, + reject, + context = "chrome" + ) { + const isChrome = context === "chrome"; + const isFirefox = context === "firefox"; + + // Chrome-specific: Prune demo web seeds/trackers that chronically trip Chromium CORS + // and deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround. + if (isChrome) { + torrent.on("warning", (err) => { + if (err && typeof err.message === "string") { + if ( + err.message.includes("CORS") || + err.message.includes("Access-Control-Allow-Origin") + ) { + userLogger.warn( + "CORS warning detected. Attempting to remove the failing webseed/tracker." + ); + if (torrent._opts?.urlList?.length) { + torrent._opts.urlList = torrent._opts.urlList.filter((url) => { + return !url.includes("distribution.bbb3d.renderfarming.net"); + }); + userLogger.warn("Cleaned up webseeds =>", torrent._opts.urlList); + } + if (torrent._opts?.announce?.length) { + torrent._opts.announce = torrent._opts.announce.filter((url) => { + return !url.includes("fastcast.nz"); + }); + userLogger.warn("Cleaned up trackers =>", torrent._opts.announce); + } } } - } - }); + }); + } const file = torrent.files.find((f) => /\.(mp4|webm|mkv)$/i.test(f.name)); if (!file) { @@ -744,18 +754,23 @@ export class TorrentClient { videoElement.crossOrigin = "anonymous"; videoElement.addEventListener("error", (e) => { - this.log("Video error:", e.target.error); + this.log(`Video error (${context} path):`, e.target.error); }); const tryStart = () => { - this.attemptAutoplay(videoElement, "chrome"); + this.attemptAutoplay(videoElement, context); }; videoElement.addEventListener("canplay", tryStart, { once: true }); videoElement.addEventListener("loadeddata", tryStart, { once: true }); try { - file.streamTo(videoElement); + const streamOptions = {}; + if (isFirefox) { + streamOptions.highWaterMark = 256 * 1024; + } + file.streamTo(videoElement, streamOptions); + // If the video is already ready (e.g. from cache or fast load), try starting immediately if (videoElement.readyState >= 3) { tryStart(); @@ -763,54 +778,12 @@ export class TorrentClient { this.currentTorrent = torrent; resolve(torrent); } catch (err) { - this.log("Streaming error (Chrome path):", err); + this.log(`Streaming error (${context} path):`, err); reject(err); } torrent.on("error", (err) => { - this.log("Torrent error (Chrome path):", err); - reject(err); - }); - } - - // Handle Firefox-based browsers - handleFirefoxTorrent(torrent, videoElement, resolve, reject) { - const file = torrent.files.find((f) => - /\.(mp4|webm|mkv)$/.test(f.name.toLowerCase()) - ); - if (!file) { - return reject(new Error("No compatible video file found in torrent")); - } - - // Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots). - videoElement.crossOrigin = "anonymous"; - - videoElement.addEventListener("error", (e) => { - this.log("Video error (Firefox path):", e.target.error); - }); - - const tryStart = () => { - this.attemptAutoplay(videoElement, "firefox"); - }; - - videoElement.addEventListener("canplay", tryStart, { once: true }); - videoElement.addEventListener("loadeddata", tryStart, { once: true }); - - try { - file.streamTo(videoElement, { highWaterMark: 256 * 1024 }); - // If the video is already ready (e.g. from cache or fast load), try starting immediately - if (videoElement.readyState >= 3) { - tryStart(); - } - this.currentTorrent = torrent; - resolve(torrent); - } catch (err) { - this.log("Streaming error (Firefox path):", err); - reject(err); - } - - torrent.on("error", (err) => { - this.log("Torrent error (Firefox path):", err); + this.log(`Torrent error (${context} path):`, err); reject(err); }); } @@ -932,14 +905,14 @@ export class TorrentClient { { ...chromeOptions, maxWebConns: 4 }, (torrent) => { this.log("Torrent added (Firefox path):", torrent.name); - this.handleFirefoxTorrent(torrent, videoElement, resolve, reject); + this.handleTorrentStream(torrent, videoElement, resolve, reject, "firefox"); } ); } else { this.log("Starting torrent download (Chrome path)"); this.client.add(magnetURI, chromeOptions, (torrent) => { this.log("Torrent added (Chrome path):", torrent.name); - this.handleChromeTorrent(torrent, videoElement, resolve, reject); + this.handleTorrentStream(torrent, videoElement, resolve, reject, "chrome"); }); } }); diff --git a/tests/webtorrent-handlers.test.mjs b/tests/webtorrent-handlers.test.mjs new file mode 100644 index 00000000..1673d268 --- /dev/null +++ b/tests/webtorrent-handlers.test.mjs @@ -0,0 +1,128 @@ + +import "./test-helpers/setup-localstorage.mjs"; +import { test, describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import EventEmitter from "events"; +import { TorrentClient } from "../js/webtorrent.js"; + +// Mock JSDOM-like environment if needed +if (!global.window) { + global.window = new EventEmitter(); + global.window.navigator = { userAgent: "Node" }; +} + +class MockFile { + constructor(name) { + this.name = name; + } + streamTo(elem, opts) { + elem.opts = opts; // Capture opts for verification + elem.src = "blob:mock-stream"; + // Simulate ready state progression + setTimeout(() => { + elem.readyState = 3; + elem.emit('canplay'); + elem.emit('loadeddata'); + }, 10); + } +} + +class MockTorrent extends EventEmitter { + constructor(magnet, opts) { + super(); + this.magnet = magnet; + this.files = [new MockFile("video.mp4")]; + this._opts = opts || {}; + } + destroy() {} +} + +class MockVideoElement extends EventEmitter { + constructor() { + super(); + this.playCalled = false; + this.crossOrigin = null; + this.readyState = 0; + this.muted = false; + this.error = null; + } + play() { + this.playCalled = true; + return Promise.resolve(); + } + addEventListener(event, handler, opts) { + this.on(event, handler); + } + removeEventListener(event, handler) { + this.off(event, handler); + } +} + +describe("TorrentClient Handlers", () => { + let client; + let videoElement; + let torrent; + + before(() => { + client = new TorrentClient(); + // Silence logs for tests + client.log = () => {}; + }); + + it("handleTorrentStream (chrome) should set up video correctly", async () => { + videoElement = new MockVideoElement(); + torrent = new MockTorrent("magnet:?xt=urn:btih:test"); + + await new Promise((resolve, reject) => { + client.handleTorrentStream(torrent, videoElement, resolve, reject, "chrome"); + }); + + // Wait for events to fire + await new Promise(r => setTimeout(r, 20)); + + assert.strictEqual(videoElement.crossOrigin, "anonymous", "crossOrigin should be anonymous"); + assert.strictEqual(videoElement.playCalled, true, "play() should be called"); + assert.ok(videoElement.src, "src should be set"); + assert.strictEqual(client.currentTorrent, torrent, "currentTorrent should be set"); + }); + + it("handleTorrentStream (firefox) should set up video correctly with highWaterMark", async () => { + videoElement = new MockVideoElement(); + torrent = new MockTorrent("magnet:?xt=urn:btih:test"); + + await new Promise((resolve, reject) => { + client.handleTorrentStream(torrent, videoElement, resolve, reject, "firefox"); + }); + + // Wait for events to fire + await new Promise(r => setTimeout(r, 20)); + + assert.strictEqual(videoElement.crossOrigin, "anonymous", "crossOrigin should be anonymous"); + assert.strictEqual(videoElement.playCalled, true, "play() should be called"); + assert.ok(videoElement.src, "src should be set"); + // Check options directly on videoElement which our mock sets + assert.ok(videoElement.opts, "opts should be set"); + assert.strictEqual(videoElement.opts.highWaterMark, 256 * 1024, "highWaterMark should be set for Firefox"); + assert.strictEqual(client.currentTorrent, torrent, "currentTorrent should be set"); + }); + + it("handleTorrentStream (chrome) should handle CORS warning logic", () => { + videoElement = new MockVideoElement(); + torrent = new MockTorrent("magnet:?xt=urn:btih:test", { + urlList: ["http://distribution.bbb3d.renderfarming.net/video.mp4", "http://good.com/video.mp4"], + announce: ["ws://fastcast.nz", "ws://good.com"] + }); + + // We don't await because we are testing the event listener setup which happens synchronously + // But we need a dummy resolve/reject + client.handleTorrentStream(torrent, videoElement, () => {}, () => {}, "chrome"); + + // Emit warning + torrent.emit('warning', { message: 'CORS policy' }); + + assert.strictEqual(torrent._opts.urlList.length, 1, "Should filter urlList"); + assert.strictEqual(torrent._opts.urlList[0], "http://good.com/video.mp4", "Should keep good url"); + assert.strictEqual(torrent._opts.announce.length, 1, "Should filter announce"); + assert.strictEqual(torrent._opts.announce[0], "ws://good.com", "Should keep good tracker"); + }); +}); From c32e95f3720a795f7a65b813ca2577f712f5f301 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:27:45 +0000 Subject: [PATCH 27/37] Refactor NIP-46 logging helpers to dedicated utility module Moved logging helpers (`summarizeHexForLog`, `summarizeSecretForLog`, etc.) from `js/nostr/nip46Client.js` to `js/nostr/nip46LoggingUtils.js` to reduce clutter and improve reuse. Updated `js/nostr/client.js` and `js/services/authProviders/nip46.js` to import from the new module, removing duplicate definitions in the latter. Verified with existing tests. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostr/client.js | 4 +- js/nostr/nip46Client.js | 164 ++--------------------------- js/nostr/nip46LoggingUtils.js | 158 +++++++++++++++++++++++++++ js/services/authProviders/nip46.js | 26 +---- 4 files changed, 175 insertions(+), 177 deletions(-) create mode 100644 js/nostr/nip46LoggingUtils.js diff --git a/js/nostr/client.js b/js/nostr/client.js index a50eaabc..9797993b 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -181,6 +181,8 @@ import { decodeNpubToHex, encodeHexToNpub, normalizeNostrPubkey, +} from "./nip46Client.js"; +import { summarizeHexForLog, summarizeSecretForLog, summarizeMetadataForLog, @@ -189,7 +191,7 @@ import { summarizeRpcParamsForLog, summarizeRpcResultForLog, summarizeRelayPublishResultsForLog, -} from "./nip46Client.js"; +} from "./nip46LoggingUtils.js"; import { profileCache } from "../state/profileCache.js"; import { createPrivateKeyCipherClosures } from "./signerHelpers.js"; import { diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index 701076e0..b3ab6d76 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -32,6 +32,16 @@ import { import { devLogger, userLogger } from "../utils/logger.js"; import { Nip46RequestQueue, NIP46_PRIORITY } from "./nip46Queue.js"; import { HEX64_REGEX } from "../utils/hex.js"; +import { + summarizeHexForLog, + summarizeSecretForLog, + summarizeMetadataForLog, + summarizeUrlForLog, + summarizePayloadPreviewForLog, + summarizeRpcParamsForLog, + summarizeRpcResultForLog, + summarizeRelayPublishResultsForLog, +} from "./nip46LoggingUtils.js"; export const NIP46_RPC_KIND = 24_133; const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; @@ -1175,160 +1185,6 @@ export async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciph throw failure; } -export function summarizeHexForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const normalized = value.trim().toLowerCase(); - if (normalized.length <= 12) { - return `${normalized} (len:${normalized.length})`; - } - return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`; -} - -export function summarizeSecretForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const trimmed = value.trim(); - const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`; - return `${visible} (len:${trimmed.length})`; -} - -export function summarizeMetadataForLog(metadata) { - if (!metadata || typeof metadata !== "object") { - return []; - } - return Object.keys(metadata).slice(0, 12); -} - -export function summarizeUrlForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const trimmed = value.trim(); - try { - const url = new URL(trimmed); - return { - origin: url.origin, - pathname: url.pathname, - hasQuery: Boolean(url.search), - hasHash: Boolean(url.hash), - length: trimmed.length, - }; - } catch (error) { - const length = trimmed.length; - if (length <= 64) { - return `${trimmed} (len:${length})`; - } - return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)} (len:${length})`; - } -} - -export function summarizePayloadPreviewForLog(value) { - if (typeof value !== "string") { - return { type: typeof value }; - } - - const trimmed = value.trim(); - if (!trimmed) { - return { type: "string", length: 0 }; - } - - return { - type: "string", - length: trimmed.length, - preview: trimmed.length <= 96 ? trimmed : `${trimmed.slice(0, 64)}…`, - }; -} - -export function summarizeRpcParamsForLog(method, params) { - if (!Array.isArray(params)) { - return []; - } - - return params.map((param, index) => { - if (typeof param === "string") { - if (method === "connect" && index === 1) { - return { index, secret: summarizeSecretForLog(param) }; - } - const trimmed = param.trim(); - if (!trimmed) { - return { index, type: "string", length: 0 }; - } - if (method === "sign_event") { - return { index, type: "string", length: trimmed.length }; - } - if (trimmed.length <= 64) { - return { index, value: trimmed, length: trimmed.length }; - } - return { - index, - type: "string", - length: trimmed.length, - preview: `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`, - }; - } - - if (param && typeof param === "object") { - return { - index, - type: Array.isArray(param) ? "array" : "object", - keys: Object.keys(param).slice(0, 6), - }; - } - - return { index, type: typeof param }; - }); -} - -export function summarizeRpcResultForLog(method, result) { - if (typeof result === "string") { - const trimmed = result.trim(); - if (!trimmed) { - return { type: "string", length: 0 }; - } - if (method === "connect") { - return { type: "string", length: trimmed.length, secret: summarizeSecretForLog(trimmed) }; - } - if (trimmed.length <= 96) { - return { type: "string", length: trimmed.length, value: trimmed }; - } - return { - type: "string", - length: trimmed.length, - preview: `${trimmed.slice(0, 48)}…${trimmed.slice(-12)}`, - }; - } - - if (!result) { - return { type: typeof result }; - } - - if (typeof result === "object") { - return { - type: Array.isArray(result) ? "array" : "object", - keys: Object.keys(result).slice(0, 6), - }; - } - - return { type: typeof result }; -} - -export function summarizeRelayPublishResultsForLog(results) { - if (!Array.isArray(results)) { - return []; - } - - return results.map((entry) => ({ - relay: entry?.relay || "", - success: Boolean(entry?.ok), - reason: entry?.error ? entry.error?.message || String(entry.error) : null, - })); -} export function createNip46RequestId() { try { diff --git a/js/nostr/nip46LoggingUtils.js b/js/nostr/nip46LoggingUtils.js new file mode 100644 index 00000000..e7973c14 --- /dev/null +++ b/js/nostr/nip46LoggingUtils.js @@ -0,0 +1,158 @@ +/** + * Logging utilities for NIP-46 (Nostr Connect) client and diagnostics. + */ + +export function summarizeHexForLog(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const normalized = value.trim().toLowerCase(); + if (normalized.length <= 12) { + return `${normalized} (len:${normalized.length})`; + } + return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`; +} + +export function summarizeSecretForLog(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const trimmed = value.trim(); + const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`; + return `${visible} (len:${trimmed.length})`; +} + +export function summarizeMetadataForLog(metadata) { + if (!metadata || typeof metadata !== "object") { + return []; + } + return Object.keys(metadata).slice(0, 12); +} + +export function summarizeUrlForLog(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + return { + origin: url.origin, + pathname: url.pathname, + hasQuery: Boolean(url.search), + hasHash: Boolean(url.hash), + length: trimmed.length, + }; + } catch (error) { + const length = trimmed.length; + if (length <= 64) { + return `${trimmed} (len:${length})`; + } + return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)} (len:${length})`; + } +} + +export function summarizePayloadPreviewForLog(value) { + if (typeof value !== "string") { + return { type: typeof value }; + } + + const trimmed = value.trim(); + if (!trimmed) { + return { type: "string", length: 0 }; + } + + return { + type: "string", + length: trimmed.length, + preview: trimmed.length <= 96 ? trimmed : `${trimmed.slice(0, 64)}…`, + }; +} + +export function summarizeRpcParamsForLog(method, params) { + if (!Array.isArray(params)) { + return []; + } + + return params.map((param, index) => { + if (typeof param === "string") { + if (method === "connect" && index === 1) { + return { index, secret: summarizeSecretForLog(param) }; + } + const trimmed = param.trim(); + if (!trimmed) { + return { index, type: "string", length: 0 }; + } + if (method === "sign_event") { + return { index, type: "string", length: trimmed.length }; + } + if (trimmed.length <= 64) { + return { index, value: trimmed, length: trimmed.length }; + } + return { + index, + type: "string", + length: trimmed.length, + preview: `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`, + }; + } + + if (param && typeof param === "object") { + return { + index, + type: Array.isArray(param) ? "array" : "object", + keys: Object.keys(param).slice(0, 6), + }; + } + + return { index, type: typeof param }; + }); +} + +export function summarizeRpcResultForLog(method, result) { + if (typeof result === "string") { + const trimmed = result.trim(); + if (!trimmed) { + return { type: "string", length: 0 }; + } + if (method === "connect") { + return { type: "string", length: trimmed.length, secret: summarizeSecretForLog(trimmed) }; + } + if (trimmed.length <= 96) { + return { type: "string", length: trimmed.length, value: trimmed }; + } + return { + type: "string", + length: trimmed.length, + preview: `${trimmed.slice(0, 48)}…${trimmed.slice(-12)}`, + }; + } + + if (!result) { + return { type: typeof result }; + } + + if (typeof result === "object") { + return { + type: Array.isArray(result) ? "array" : "object", + keys: Object.keys(result).slice(0, 6), + }; + } + + return { type: typeof result }; +} + +export function summarizeRelayPublishResultsForLog(results) { + if (!Array.isArray(results)) { + return []; + } + + return results.map((entry) => ({ + relay: entry?.relay || "", + success: Boolean(entry?.ok), + reason: entry?.error ? entry.error?.message || String(entry.error) : null, + })); +} diff --git a/js/services/authProviders/nip46.js b/js/services/authProviders/nip46.js index f6108a05..3e421c87 100644 --- a/js/services/authProviders/nip46.js +++ b/js/services/authProviders/nip46.js @@ -1,27 +1,9 @@ import { devLogger } from "../../utils/logger.js"; import { accessControl } from "../../accessControl.js"; - -function summarizeHexForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const normalized = value.trim().toLowerCase(); - if (normalized.length <= 12) { - return `${normalized} (len:${normalized.length})`; - } - return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`; -} - -function summarizeSecretForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const trimmed = value.trim(); - const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`; - return `${visible} (len:${trimmed.length})`; -} +import { + summarizeHexForLog, + summarizeSecretForLog, +} from "../../nostr/nip46LoggingUtils.js"; function sanitizeNip46UriForLog(value) { if (typeof value !== "string" || !value) { From 00207592310cb2ae0d7f9ecbaa1b04c8698355e9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:33:13 +0000 Subject: [PATCH 28/37] Add tests for NIP-07 signing adapter factory - Implements `createNip07SigningAdapter` in `js/auth/signingAdapter.js`. - Adds unit tests in `tests/auth/signingAdapter.test.mjs` verifying explicit extension handling and window fallback. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/auth/signingAdapter.js | 24 ++++++++ tests/auth/signingAdapter.test.mjs | 91 ++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 js/auth/signingAdapter.js create mode 100644 tests/auth/signingAdapter.test.mjs diff --git a/js/auth/signingAdapter.js b/js/auth/signingAdapter.js new file mode 100644 index 00000000..cc854512 --- /dev/null +++ b/js/auth/signingAdapter.js @@ -0,0 +1,24 @@ +export function createNip07SigningAdapter({ extension } = {}) { + const resolvedExtension = + extension || + (typeof window !== "undefined" && window?.nostr ? window.nostr : null); + + const getPubkey = async () => { + if (!resolvedExtension) { + throw new Error("NIP-07 extension unavailable."); + } + return resolvedExtension.getPublicKey(); + }; + + const signEvent = async (event) => { + if (!resolvedExtension) { + throw new Error("NIP-07 extension unavailable."); + } + return resolvedExtension.signEvent(event); + }; + + return { + getPubkey, + signEvent, + }; +} diff --git a/tests/auth/signingAdapter.test.mjs b/tests/auth/signingAdapter.test.mjs new file mode 100644 index 00000000..b946ae81 --- /dev/null +++ b/tests/auth/signingAdapter.test.mjs @@ -0,0 +1,91 @@ +import { test, describe, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import { createNip07SigningAdapter } from "../../js/auth/signingAdapter.js"; + +describe("createNip07SigningAdapter", () => { + let originalWindow; + + beforeEach(() => { + // Preserve original window if it exists (for safety, though usually undefined in node) + if (typeof globalThis.window !== "undefined") { + originalWindow = globalThis.window; + } + }); + + afterEach(() => { + // Restore window + if (originalWindow) { + globalThis.window = originalWindow; + } else { + delete globalThis.window; + } + }); + + test("uses explicit extension if provided", async () => { + const mockExtension = { + getPublicKey: mock.fn(async () => "explicit_pubkey"), + signEvent: mock.fn(async (event) => ({ ...event, sig: "signature" })), + }; + + const adapter = createNip07SigningAdapter({ extension: mockExtension }); + + const pubkey = await adapter.getPubkey(); + assert.strictEqual(pubkey, "explicit_pubkey"); + assert.strictEqual(mockExtension.getPublicKey.mock.calls.length, 1); + + const event = { id: "123" }; + const signed = await adapter.signEvent(event); + assert.strictEqual(signed.sig, "signature"); + assert.strictEqual(mockExtension.signEvent.mock.calls.length, 1); + assert.deepStrictEqual(mockExtension.signEvent.mock.calls[0].arguments[0], event); + }); + + test("falls back to window.nostr if no extension provided", async () => { + const mockWindowNostr = { + getPublicKey: mock.fn(async () => "window_pubkey"), + signEvent: mock.fn(async (event) => ({ ...event, sig: "window_sig" })), + }; + + globalThis.window = { nostr: mockWindowNostr }; + + const adapter = createNip07SigningAdapter(); + + const pubkey = await adapter.getPubkey(); + assert.strictEqual(pubkey, "window_pubkey"); + assert.strictEqual(mockWindowNostr.getPublicKey.mock.calls.length, 1); + }); + + test("throws error if no extension available", async () => { + globalThis.window = {}; // No nostr + + const adapter = createNip07SigningAdapter(); + + await assert.rejects( + async () => adapter.getPubkey(), + { message: "NIP-07 extension unavailable." } + ); + + await assert.rejects( + async () => adapter.signEvent({}), + { message: "NIP-07 extension unavailable." } + ); + }); + + test("prioritizes explicit extension over window.nostr", async () => { + const mockExplicit = { + getPublicKey: mock.fn(async () => "explicit"), + }; + const mockWindow = { + getPublicKey: mock.fn(async () => "window"), + }; + + globalThis.window = { nostr: mockWindow }; + + const adapter = createNip07SigningAdapter({ extension: mockExplicit }); + const pubkey = await adapter.getPubkey(); + + assert.strictEqual(pubkey, "explicit"); + assert.strictEqual(mockExplicit.getPublicKey.mock.calls.length, 1); + assert.strictEqual(mockWindow.getPublicKey.mock.calls.length, 0); + }); +}); From d4beed100a22678c5955d4fcbc5c0ce9a1dfe8f3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:33:27 +0000 Subject: [PATCH 29/37] Remove legacy moderation overrides support Removed the legacy support for version 1 moderation overrides in `js/state/cache.js`. This includes removing the `LEGACY_MODERATION_OVERRIDE_STORAGE_KEY`, the migration logic in `loadModerationOverridesFromStorage`, and the cleanup logic in `persistModerationOverridesToStorage`. Updated `tests/state/cache.test.mjs` to include tests verifying that legacy overrides are ignored and not migrated. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/state/cache.js | 21 +-------------- tests/state/cache.test.mjs | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/js/state/cache.js b/js/state/cache.js index 03c51e7e..b399633c 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -30,7 +30,6 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v2"; -const LEGACY_MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1"; const MODERATION_OVERRIDE_STORAGE_VERSION = 2; const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1"; const MODERATION_SETTINGS_STORAGE_VERSION = 3; @@ -1530,9 +1529,7 @@ export function loadModerationOverridesFromStorage() { return; } - const raw = - localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY) || - localStorage.getItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY); + const raw = localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY); if (!raw) { return; } @@ -1553,20 +1550,6 @@ export function loadModerationOverridesFromStorage() { } else { shouldRewrite = true; } - } else if (payload.version === 1) { - const legacyEntries = - payload.entries && typeof payload.entries === "object" - ? payload.entries - : {}; - Object.entries(legacyEntries).forEach(([eventId, entry]) => { - entries.push({ - eventId, - authorPubkey: "", - showAnyway: entry?.showAnyway === true, - updatedAt: entry?.updatedAt, - }); - }); - shouldRewrite = true; } else { return; } @@ -1633,7 +1616,6 @@ export function persistModerationOverridesToStorage() { if (entries.length === 0) { try { localStorage.removeItem(MODERATION_OVERRIDE_STORAGE_KEY); - localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY); } catch (error) { userLogger.warn( "[cache.persistModerationOverridesToStorage] Failed to clear overrides:", @@ -1654,7 +1636,6 @@ export function persistModerationOverridesToStorage() { MODERATION_OVERRIDE_STORAGE_KEY, JSON.stringify(payload), ); - localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY); } catch (error) { userLogger.warn( "[cache.persistModerationOverridesToStorage] Failed to persist overrides:", diff --git a/tests/state/cache.test.mjs b/tests/state/cache.test.mjs index c21a6684..78f7d3ea 100644 --- a/tests/state/cache.test.mjs +++ b/tests/state/cache.test.mjs @@ -12,6 +12,9 @@ import { getModerationSettings, setModerationSettings, urlHealthConstants, + loadModerationOverridesFromStorage, + getModerationOverridesList, + clearModerationOverride, } from "../../js/state/cache.js"; // Ensure localStorage is mocked @@ -102,4 +105,53 @@ test("js/state/cache.js", async (t) => { assert.strictEqual(updated.blurThreshold, 1); assert.strictEqual(updated.autoplayBlockThreshold, 2); }); + + await t.test("Legacy Moderation Overrides Support", async (t) => { + // Reset internal state by clearing overrides + const current = getModerationOverridesList(); + for (const entry of current) { + clearModerationOverride(entry, { persist: false }); + } + + const VALID_HEX_ID_1 = "0000000000000000000000000000000000000000000000000000000000000001"; + const VALID_HEX_ID_2 = "0000000000000000000000000000000000000000000000000000000000000002"; + + await t.test("ignores legacy v1 overrides", () => { + const legacyData = { + version: 1, + entries: { + [VALID_HEX_ID_1]: { showAnyway: true, updatedAt: 1234567890 }, + } + }; + localStorage.setItem("bitvid:moderationOverrides:v1", JSON.stringify(legacyData)); + + loadModerationOverridesFromStorage(); + + const overrides = getModerationOverridesList(); + assert.strictEqual(overrides.length, 0, "Should not load legacy overrides"); + + // Check if it migrated to v2 key + const v2Data = localStorage.getItem("bitvid:moderationOverrides:v2"); + assert.strictEqual(v2Data, null, "Should not migrate/persist v2 data"); + + // Check if legacy key is NOT removed + assert.ok(localStorage.getItem("bitvid:moderationOverrides:v1"), "Should not remove legacy key"); + }); + + await t.test("loads v2 overrides", () => { + const v2Data = { + version: 2, + entries: [ + { eventId: VALID_HEX_ID_2, authorPubkey: "", showAnyway: true, updatedAt: 1234567890 } + ] + }; + localStorage.setItem("bitvid:moderationOverrides:v2", JSON.stringify(v2Data)); + + loadModerationOverridesFromStorage(); + + const overrides = getModerationOverridesList(); + assert.strictEqual(overrides.length, 1); + assert.strictEqual(overrides[0].eventId, VALID_HEX_ID_2); + }); + }); }); From d99a5b1dca9468a52943403b2d1da19868434695 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:33:37 +0000 Subject: [PATCH 30/37] test: verify default client initialization and configuration Add `tests/nostr/defaultClient.test.mjs` to ensure that `nostrClient` is correctly exported as a singleton, initialized with `DEFAULT_RELAY_URLS`, and registered in the client registry. This test handles global mocks (`WebSocket`, `localStorage`) via dynamic imports to verify the module's top-level execution. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/nostr/defaultClient.test.mjs | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/nostr/defaultClient.test.mjs diff --git a/tests/nostr/defaultClient.test.mjs b/tests/nostr/defaultClient.test.mjs new file mode 100644 index 00000000..ae752adb --- /dev/null +++ b/tests/nostr/defaultClient.test.mjs @@ -0,0 +1,72 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import "../test-setup.mjs"; + +// Mock dependencies before importing the module under test +if (!globalThis.WebSocket) { + globalThis.WebSocket = class MockWebSocket { + send() {} + close() {} + }; +} + +if (!globalThis.localStorage) { + globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + }; +} + +// Set dev mode override +globalThis.__BITVID_DEV_MODE_OVERRIDE__ = true; + +// Mock console to suppress logs during tests +const originalConsole = { ...console }; +globalThis.console = { + ...console, + log: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, +}; + +// Import modules dynamically after setting up mocks +const { nostrClient } = await import("../../js/nostr/defaultClient.js"); +const { DEFAULT_RELAY_URLS } = await import("../../js/nostr/toolkit.js"); +const { getRegisteredNostrClient } = await import("../../js/nostrClientRegistry.js"); +const { NostrClient } = await import("../../js/nostr/client.js"); + +describe("Default NostrClient", () => { + after(() => { + // Restore console + globalThis.console = originalConsole; + // Clean up globals if desired, though process isolation makes this optional + delete globalThis.__BITVID_DEV_MODE_OVERRIDE__; + }); + it("should be an instance of NostrClient", () => { + assert.ok(nostrClient instanceof NostrClient); + }); + + it("should be configured with default relays", () => { + const expectedRelays = Array.from(DEFAULT_RELAY_URLS); + assert.deepEqual(nostrClient.relays, expectedRelays); + }); + + it("should be initialized with default read relays", () => { + const expectedRelays = Array.from(DEFAULT_RELAY_URLS); + assert.deepEqual(nostrClient.readRelays, expectedRelays); + }); + + it("should be initialized with default write relays", () => { + const expectedRelays = Array.from(DEFAULT_RELAY_URLS); + assert.deepEqual(nostrClient.writeRelays, expectedRelays); + }); + + it("should be registered as the default client", () => { + const registered = getRegisteredNostrClient(); + assert.strictEqual(registered, nostrClient); + }); +}); From 5cf2b42bfd0b1ed1d4e88c21477aa398c95c20c1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:34:36 +0000 Subject: [PATCH 31/37] Add unit tests for dynamicStyles.js Added a new test file `tests/designSystem/dynamicStyles.test.mjs` to cover `js/designSystem/dynamicStyles.js`. The tests cover: - Scope registration and ID generation. - Stylesheet management (adoptedStyleSheets vs