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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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"); + }); + }); +});