diff --git a/CHANGELOG.md b/CHANGELOG.md index 35bc57fe..7a704cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- New views for Kids, History, Search, Subscriptions, and Docs. (PR #2129, PR #2153 by PR0M3TH3AN) +- Comprehensive test suite for Nostr features and UI components. (PR #2153 by PR0M3TH3AN) - Provider-agnostic S3 multipart upload helpers to support large file uploads and bucket management. (PR #1620 by PR0M3TH3AN) ### Changed +- Performance improvements for documentation scroll spy. (PR #2129 by PR0M3TH3AN) +- Optimized Nostr login speed. (PR #2153 by PR0M3TH3AN) +- Updated `nostr-tools`, `floating-ui`, and `crypto-helpers` dependencies. (PR #2153 by PR0M3TH3AN) - Major repository update and synchronization (Commit `b429b1c` by thePR0M3TH3AN) ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2542a5ef..59b39571 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,14 +92,18 @@ This workflow uses the `force-cancel` endpoint to ensure stalled runs are termin To set up the project locally: -1. **Install Dependencies**: +1. **Prerequisites**: + - **Node.js**: v22 or higher. + - **NPM**: v10 or higher (included with Node 22). + +2. **Install Dependencies**: Use `npm ci` to ensure you get the exact dependencies from `package-lock.json`. ```bash npm ci ``` -2. **Start the Application**: +3. **Start the Application**: ```bash npm start @@ -116,7 +120,14 @@ To set up the project locally: (The build command populates the `dist/` directory, which you can then serve locally.) -3. **Run Tests**: + **For CSS-only changes:** + If you are only iterating on styles, you can run the faster CSS build: + + ```bash + npm run build:css + ``` + +4. **Run Tests**: ```bash npm run test:unit @@ -160,7 +171,7 @@ To set up the project locally: - **Format**: Targets CSS, HTML, Markdown, and config files. (Note: JavaScript files are not currently auto-formatted by this command). - **Lint**: Checks for CSS errors, hex color usage, inline styles, design tokens, and Tailwind guards. (Note: There is no ESLint configuration for JavaScript logic; this step focuses on style and design system guards). -5. **Git Hooks (Optional)**: +6. **Git Hooks (Optional)**: We provide a script to set up a git pre-commit hook that runs linting and CSS builds automatically before you commit. ```bash ./scripts/setup-pre-commit.sh diff --git a/ONBOARDING_REPORT.md b/ONBOARDING_REPORT.md index 8076bd6b..16704e93 100644 --- a/ONBOARDING_REPORT.md +++ b/ONBOARDING_REPORT.md @@ -1,44 +1,29 @@ -# Onboarding Report +# Developer Onboarding Audit - 2025-02-18 -## Execution Summary +This document summarizes the findings from the developer onboarding audit. -| Command | Status | Notes | -| :--- | :--- | :--- | -| `npm ci` | ✅ Passed | Installed 315 packages. | -| `npm run build:css` | ✅ Passed | CSS built successfully. Warning: `Browserslist: caniuse-lite is outdated`. | -| `npm run format` | ✅ Passed | No formatting changes needed. | -| `npm run lint` | ✅ Passed | All lint checks passed. | -| `npm run test:unit` | ✅ Fixed | `tests/hashtag-preferences.test.mjs` and `tests/nostr-login-permissions.test.mjs` failures were fixed in this PR. | +## Steps Executed -## Detailed Findings & Fixes +1. `npm ci`: Successful. +2. `npm run build:css`: Successful. +3. `npm run format`: Successful. -### Unit Test Failures & Timeouts +## Environment -1. **`tests/hashtag-preferences.test.mjs`**: Failed due to a race condition in the mock `fetchListIncrementally` implementation. - * **Fix Applied**: Updated the mock to return the event consistently. Added `process.exit(0)` to prevent hanging. +- **Node.js Version:** v22.22.0 +- **Dependencies:** Installed successfully via `npm ci`. +- **CSS Build:** Tailwind CSS bundle generated successfully. -2. **`tests/nostr-login-permissions.test.mjs`**: Failed due to incorrect permission request logic and timed out due to open handles. - * **Fix Applied**: Updated `js/nostr/client.js` to request `DEFAULT_NIP07_PERMISSION_METHODS` instead of `CORE_METHODS`. Refactored test file to use `describe`/`it`/`after` with explicit `process.exit(0)`. +## Findings -3. **`tests/nostr-private-key-signer.test.mjs`**: Timed out during CI. - * **Fix Applied**: Added `after(() => setTimeout(() => process.exit(0), 100))` to force exit. +- **Setup Reliability:** The onboarding steps are robust and reliable. No errors were encountered. +- **Documentation:** `CONTRIBUTING.md` and `README.md` provide clear instructions. +- **Dev Container:** The project includes a valid `.devcontainer/devcontainer.json` which uses `mcr.microsoft.com/devcontainers/javascript-node:22`. This is recommended for a consistent development environment. +- **Node Version:** While `package.json` does not explicitly enforce a Node.js version, the `.devcontainer` configuration implies a dependency on Node 22. Adding an `engines` field to `package.json` would improve environment consistency. +- **Build Commands:** The README suggests `npm run build:css` for CSS verification, which works as expected. For full builds, `npm run build` or `npm start` should be used. +- **Warnings:** A warning about `browserslist` (`caniuse-lite is outdated`) was observed during `npm run build:css`. This is documented in `CONTRIBUTING.md` under Troubleshooting. -4. **`tests/nostr-send-direct-message.test.mjs`**: Susceptible to similar hangs. - * **Fix Applied**: Added `after(() => setTimeout(() => process.exit(0), 100))` to force exit. +## Recommendations implemented -5. **`tests/nostr-service-access-control.test.mjs`**: Caused hangs due to mock tool cleanup. - * **Fix Applied**: Added `setTimeout(() => process.exit(0), 100)` in `after` hook. - -### Test Runner Robustness - -* **`scripts/run-unit-tests.mjs`**: Updated to explicitly call `process.exit(0)` upon successful completion of all tests. This acts as a global safeguard against lingering open handles (e.g., from `nostr-tools` relays or timers) causing CI timeouts. - -### Browserslist Warning - -The build command emitted: `Browserslist: caniuse-lite is outdated`. -* **Action Taken**: Ran `npx update-browserslist-db@latest`. - -### Documentation - -* Updated `CONTRIBUTING.md` to recommend sharded tests (`npm run test:unit:shard1`). -* Clarified in `CONTRIBUTING.md` that `npm run format` does not target JavaScript files. +- Added `"engines": { "node": ">=22" }` to `package.json` to align with `.devcontainer` and ensure environment consistency. +- Updated `CONTRIBUTING.md` to explicitly mention the Node.js version requirement and clarify build commands. diff --git a/artifacts/npm-audit.json b/artifacts/npm-audit.json index 61b513b8..bc7a36c7 100644 --- a/artifacts/npm-audit.json +++ b/artifacts/npm-audit.json @@ -11,12 +11,12 @@ "total": 0 }, "dependencies": { - "prod": 121, - "dev": 220, - "optional": 27, + "prod": 110, + "dev": 255, + "optional": 53, "peer": 0, "peerOptional": 0, - "total": 341 + "total": 391 } } } diff --git a/artifacts/npm-outdated.json b/artifacts/npm-outdated.json index e7b476cb..5c53a455 100644 --- a/artifacts/npm-outdated.json +++ b/artifacts/npm-outdated.json @@ -1,29 +1,22 @@ { "@playwright/test": { - "current": "1.58.0", - "wanted": "1.58.1", - "latest": "1.58.1", + "current": "1.58.1", + "wanted": "1.58.2", + "latest": "1.58.2", "dependent": "app", "location": "/app/node_modules/@playwright/test" }, - "autoprefixer": { - "current": "10.4.23", - "wanted": "10.4.24", - "latest": "10.4.24", - "dependent": "app", - "location": "/app/node_modules/autoprefixer" - }, "esbuild": { - "current": "0.25.12", - "wanted": "0.25.12", - "latest": "0.27.2", + "current": "0.27.2", + "wanted": "0.27.3", + "latest": "0.27.3", "dependent": "app", "location": "/app/node_modules/esbuild" }, "nostr-tools": { - "current": "2.20.0", - "wanted": "2.22.1", - "latest": "2.22.1", + "current": "2.19.4", + "wanted": "2.19.4", + "latest": "2.23.0", "dependent": "app", "location": "/app/node_modules/nostr-tools" }, @@ -35,9 +28,9 @@ "location": "/app/node_modules/pixelmatch" }, "playwright": { - "current": "1.58.0", - "wanted": "1.58.1", - "latest": "1.58.1", + "current": "1.58.1", + "wanted": "1.58.2", + "latest": "1.58.2", "dependent": "app", "location": "/app/node_modules/playwright" }, @@ -49,12 +42,19 @@ "location": "/app/node_modules/prettier-plugin-tailwindcss" }, "stylelint": { - "current": "16.26.1", + "current": "16.12.0", "wanted": "16.26.1", - "latest": "17.1.0", + "latest": "17.1.1", "dependent": "app", "location": "/app/node_modules/stylelint" }, + "stylelint-config-standard": { + "current": "36.0.1", + "wanted": "36.0.1", + "latest": "40.0.0", + "dependent": "app", + "location": "/app/node_modules/stylelint-config-standard" + }, "tailwindcss": { "current": "3.4.19", "wanted": "3.4.19", diff --git a/examples/reproducers/issue-login-modal-leak/README.md b/examples/reproducers/issue-login-modal-leak/README.md new file mode 100644 index 00000000..3de30abd --- /dev/null +++ b/examples/reproducers/issue-login-modal-leak/README.md @@ -0,0 +1,16 @@ +# LoginModalController JSDOM Leak Reproducer + +Reproduction for the JSDOM async activity leak in LoginModalController tests. + +## Usage + +```bash +node examples/reproducers/issue-login-modal-leak/repro.mjs +``` + +## Expected Failure + +The script should fail with an uncaught exception due to async activity after test end: +``` +# Error: Test "LoginModalController shows NIP-46 handshake panel" ... generated asynchronous activity after the test ended. This activity created the error "ReferenceError: HTMLElement is not defined" ... +``` diff --git a/examples/reproducers/issue-login-modal-leak/repro.mjs b/examples/reproducers/issue-login-modal-leak/repro.mjs new file mode 100644 index 00000000..ad05859d --- /dev/null +++ b/examples/reproducers/issue-login-modal-leak/repro.mjs @@ -0,0 +1,175 @@ +import test, { beforeEach, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { JSDOM } from 'jsdom'; +import LoginModalController from '../../../js/ui/loginModalController.js'; + +const loginModalHtml = await readFile( + new URL('../../../components/login-modal.html', import.meta.url), + 'utf8', +); + +let dom; +let container; +let controller; + +async function waitForAnimationFrame(window, cycles = 1) { + for (let i = 0; i < cycles; i += 1) { + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + } +} + +function createMatchMediaList(query, initialMatches = false) { + let matches = Boolean(initialMatches); + const listeners = new Set(); + + const notify = () => { + const event = { matches, media: query, type: 'change' }; + listeners.forEach((listener) => { + try { + listener(event); + } catch {} + }); + }; + + return { + get matches() { + return matches; + }, + set matches(value) { + matches = Boolean(value); + }, + media: query, + addEventListener(type, listener) { + if (type === 'change' && typeof listener === 'function') { + listeners.add(listener); + } + }, + removeEventListener(type, listener) { + if (type === 'change' && typeof listener === 'function') { + listeners.delete(listener); + } + }, + addListener(listener) { + if (typeof listener === 'function') { + listeners.add(listener); + } + }, + removeListener(listener) { + if (typeof listener === 'function') { + listeners.delete(listener); + } + }, + dispatchEvent(event) { + if (!event || event.type !== 'change') { + return true; + } + listeners.forEach((listener) => { + try { + listener(event); + } catch {} + }); + return true; + }, + setMatches(value) { + matches = Boolean(value); + notify(); + }, + }; + } + +beforeEach(() => { + dom = new JSDOM( + '
', + { + url: 'https://example.com', + pretendToBeVisual: true, + }, + ); + + global.window = dom.window; + global.document = dom.window.document; + global.HTMLElement = dom.window.HTMLElement; + global.HTMLButtonElement = dom.window.HTMLButtonElement; + global.HTMLInputElement = dom.window.HTMLInputElement; + global.HTMLTextAreaElement = dom.window.HTMLTextAreaElement; + global.HTMLFormElement = dom.window.HTMLFormElement; + global.HTMLTemplateElement = dom.window.HTMLTemplateElement; + global.Node = dom.window.Node; + global.Element = dom.window.Element; + global.MutationObserver = dom.window.MutationObserver; + + window.requestAnimationFrame = (callback) => callback(0); + global.requestAnimationFrame = window.requestAnimationFrame; + + window.__matchMediaMocks = []; + window.matchMedia = (query) => { + const mql = createMatchMediaList(query, false); + window.__matchMediaMocks.push(mql); + return mql; + }; + global.matchMedia = window.matchMedia; + + container = document.getElementById('modalMount'); + container.innerHTML = loginModalHtml; +}); + +afterEach(() => { + if (controller) { + controller.destroy(); + controller = null; + } + + delete global.window; + delete global.document; + delete global.HTMLElement; + delete global.HTMLButtonElement; + delete global.HTMLInputElement; + delete global.HTMLTextAreaElement; + delete global.HTMLFormElement; + delete global.HTMLTemplateElement; + delete global.Node; + delete global.Element; + delete global.MutationObserver; + delete global.requestAnimationFrame; + delete global.matchMedia; + + if (dom) { + dom.window.close(); + dom = null; + } +}); + +function createController(options = {}) { + const modalElement = container.querySelector('#loginModal'); + return new LoginModalController({ + modalElement, + providers: options.providers || [], + services: options.services || {}, + callbacks: options.callbacks || {}, + }); +} + +test('LoginModalController shows NIP-46 handshake panel', async (t) => { + const providers = [{ id: 'nip46', label: 'Remote Signer', login: async () => {} }]; + const authService = { requestLogin: async () => {} }; + controller = createController({ providers, services: { authService } }); + controller.initialize(); + + const providerButton = container.querySelector('[data-provider-id="nip46"]'); + assert.ok(providerButton, 'nip46 provider button should be present'); + + // Trigger click on provider + const clickEvent = new dom.window.Event('click', { bubbles: true }); + providerButton.dispatchEvent(clickEvent); + + // Wait for the nip46 form to appear + await waitForAnimationFrame(window, 5); + + const form = container.querySelector('[data-nip46-form]'); + assert.ok(form, 'nip46 form should be present'); + + const handshakePanel = form.querySelector('[data-nip46-handshake-panel]'); + assert.ok(handshakePanel, 'handshake panel should be present'); + assert.ok(!handshakePanel.classList.contains('hidden'), 'handshake panel should not be hidden'); +}); diff --git a/examples/reproducers/issue-moderation-block-state/README.md b/examples/reproducers/issue-moderation-block-state/README.md new file mode 100644 index 00000000..b21fd84c --- /dev/null +++ b/examples/reproducers/issue-moderation-block-state/README.md @@ -0,0 +1,21 @@ +# VideoCard Block State Reproducer + +Reproduction for the bug where VideoCard block action fails to restore trusted mute hide state after override. + +## Usage + +```bash +node examples/reproducers/issue-moderation-block-state/repro.mjs +``` + +## Expected Failure + +The script should fail with an assertion error: +``` +not ok 1 - VideoCard block action restores trusted mute hide state after override +... + error: |- + Expected values to be strictly equal: + + false !== true +``` diff --git a/examples/reproducers/issue-moderation-block-state/repro.mjs b/examples/reproducers/issue-moderation-block-state/repro.mjs new file mode 100644 index 00000000..890c48fd --- /dev/null +++ b/examples/reproducers/issue-moderation-block-state/repro.mjs @@ -0,0 +1,198 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +import "../../../tests/test-helpers/setup-localstorage.mjs"; +import { VideoCard } from "../../../js/ui/components/VideoCard.js"; +import { clearModerationOverride, setModerationOverride } from "../../../js/state/cache.js"; +import { + withMockedNostrTools, + createModerationAppHarness, +} from "../../../tests/helpers/moderation-test-helpers.mjs"; +import { + applyModerationContextDatasets, + normalizeVideoModerationContext, +} from "../../../js/ui/moderationUiHelpers.js"; +import { userBlocks } from "../../../js/userBlocks.js"; + +function setupDom(t) { + const dom = new JSDOM("", { + url: "https://example.com", + pretendToBeVisual: true, + }); + + const { window } = dom; + const previous = new Map(); + const keys = [ + "window", + "document", + "HTMLElement", + "Element", + "Node", + "MouseEvent", + "CustomEvent", + "ResizeObserver", + "requestAnimationFrame", + "cancelAnimationFrame", + ]; + + keys.forEach((key) => { + const hadValue = Object.prototype.hasOwnProperty.call(globalThis, key); + previous.set(key, hadValue ? globalThis[key] : Symbol.for("__undefined__")); + if (key === "requestAnimationFrame" && typeof window[key] !== "function") { + window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16); + } else if (key === "cancelAnimationFrame" && typeof window[key] !== "function") { + window.cancelAnimationFrame = (id) => clearTimeout(id); + } else if (key === "ResizeObserver" && typeof window[key] !== "function") { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } + globalThis[key] = window[key]; + }); + + // Mock navigator safely + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, "navigator", { + value: window.navigator, + writable: true, + configurable: true, + }); + + if (!window.requestAnimationFrame) { + window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16); + globalThis.requestAnimationFrame = window.requestAnimationFrame; + } + + if (!window.cancelAnimationFrame) { + window.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.cancelAnimationFrame = window.cancelAnimationFrame; + } + + if (!window.ResizeObserver) { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + window.ResizeObserver = ResizeObserverStub; + globalThis.ResizeObserver = ResizeObserverStub; + } + + t.after(() => { + dom.window.close(); + keys.forEach((key) => { + const previousValue = previous.get(key); + if (previousValue === Symbol.for("__undefined__")) { + delete globalThis[key]; + } else { + globalThis[key] = previousValue; + } + }); + // Restore navigator + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + writable: true, + configurable: true, + }); + }); + + return { window, document: window.document }; +} + +test("VideoCard block action restores trusted mute hide state after override", async (t) => { + const { document } = setupDom(t); + withMockedNostrTools(t); + + const app = await createModerationAppHarness(); + app.getActiveModerationThresholds = () => ({ + autoplayBlockThreshold: Number.POSITIVE_INFINITY, + blurThreshold: Number.POSITIVE_INFINITY, + trustedMuteHideThreshold: 1, + trustedSpamHideThreshold: Number.POSITIVE_INFINITY, + }); + app.pubkey = "f".repeat(64); + app.isUserLoggedIn = () => true; + app.showStatus = () => {}; + app.showError = () => {}; + app.onVideosShouldRefresh = async () => {}; + + const originalEnsureLoaded = userBlocks.ensureLoaded; + const originalAddBlock = userBlocks.addBlock; + const originalIsBlocked = userBlocks.isBlocked; + + userBlocks.ensureLoaded = async () => {}; + userBlocks.addBlock = async () => ({ ok: true }); + userBlocks.isBlocked = () => false; + + t.after(() => { + userBlocks.ensureLoaded = originalEnsureLoaded; + userBlocks.addBlock = originalAddBlock; + userBlocks.isBlocked = originalIsBlocked; + }); + + const videoId = "d".repeat(64); + const video = { + id: videoId, + title: "Muted Hide Clip", + pubkey: "e".repeat(64), + moderation: { + trustedMuted: true, + trustedMuteCount: 1, + trustedMuters: ["f".repeat(64)], + trustedCount: 0, + reportType: "nudity", + }, + }; + + app.videosMap.set(video.id, video); + app.currentVideo = video; + app.decorateVideoModeration(video); + + const card = new VideoCard({ + document, + video, + formatters: { + formatTimeAgo: () => "moments ago", + }, + helpers: { + isMagnetSupported: () => false, + }, + }); + + document.body.appendChild(card.getRoot()); + + assert.equal( + normalizeVideoModerationContext(card.video?.moderation).activeHidden, + true, + ); + assert.equal(card.getRoot().dataset.moderationHidden, "true"); + + card.onModerationOverride = ({ video: overrideVideo, card: overrideCard }) => + app.handleModerationOverride({ video: overrideVideo, card: overrideCard }); + + card.moderationActionButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const contextAfterOverride = normalizeVideoModerationContext( + card.video?.moderation, + ); + assert.equal(contextAfterOverride.overrideActive, true); + assert.equal(contextAfterOverride.activeHidden, false); + assert.ok(card.moderationBlockButton); + + card.onModerationBlock = ({ video: targetVideo, card: targetCard }) => + app.handleModerationBlock({ video: targetVideo, card: targetCard }); + + card.moderationBlockButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const contextAfterHide = normalizeVideoModerationContext(card.video?.moderation); + // This is where it fails in the original test + assert.equal(contextAfterHide.overrideActive, false, "Override should be cleared after block"); + assert.equal(contextAfterHide.activeHidden, true, "Content should be hidden again after block"); + assert.equal(card.getRoot().dataset.moderationHidden, "true", "Card should have hidden dataset"); + assert.equal(card.moderationBlockButton, null, "Block button should be gone"); +}); diff --git a/examples/reproducers/issue-popover-flip/README.md b/examples/reproducers/issue-popover-flip/README.md new file mode 100644 index 00000000..17995c54 --- /dev/null +++ b/examples/reproducers/issue-popover-flip/README.md @@ -0,0 +1,27 @@ +# Popover Flip Layout Reproducer + +Reproduction for the visual regression layout issue where the popover fails to flip to the top in a restricted viewport. + +## Prerequisites + +You must build the CSS assets before running the test: + +```bash +npm run build:css +``` + +## Usage + +```bash +npx playwright test -c examples/reproducers/issue-popover-flip/playwright.config.ts +``` + +## Expected Failure + +The test should fail asserting that the popover is within the viewport (it fails to flip to top, so it overflows bottom): +``` +Error: expect(received).toBeLessThanOrEqual(expected) + +Expected: <= 250.5 +Received: 311.625 +``` diff --git a/examples/reproducers/issue-popover-flip/playwright.config.ts b/examples/reproducers/issue-popover-flip/playwright.config.ts new file mode 100644 index 00000000..b0fcb89e --- /dev/null +++ b/examples/reproducers/issue-popover-flip/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + use: { + baseURL: "http://127.0.0.1:4173", + }, + webServer: { + command: "npx http-server ../../.. -p 4173 -a 127.0.0.1 -c-1 --silent", + url: "http://127.0.0.1:4173", + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/examples/reproducers/issue-popover-flip/repro.spec.ts b/examples/reproducers/issue-popover-flip/repro.spec.ts new file mode 100644 index 00000000..7712111c --- /dev/null +++ b/examples/reproducers/issue-popover-flip/repro.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from "@playwright/test"; +import { + getPanelMetrics, + getPanelWithTriggerMetrics, +} from "../../../tests/e2e/helpers/popoverUtils"; +import { + applyReducedMotion, + ensureTestAssets, + failOnConsoleErrors, + waitForRAF, +} from "../../../tests/e2e/helpers/uiTestUtils"; + +function assertWithinViewport(metrics: { + rect: { top: number; left: number; right: number; bottom: number }; + viewport: { width: number; height: number }; +}) { + const tolerance = 0.5; + expect(metrics.rect.left).toBeGreaterThanOrEqual(-tolerance); + expect(metrics.rect.top).toBeGreaterThanOrEqual(-tolerance); + expect(metrics.rect.right).toBeLessThanOrEqual(metrics.viewport.width + tolerance); + expect(metrics.rect.bottom).toBeLessThanOrEqual(metrics.viewport.height + tolerance); +} + +test.describe("popover layout scenarios", () => { + test.beforeEach(async ({ page }) => { + await ensureTestAssets(page); + await applyReducedMotion(page); + failOnConsoleErrors(page); + await page.goto("/docs/popover-scenarios.html", { waitUntil: "networkidle" }); + }); + + // flaky: Popover fails to flip to top in restricted viewport (floating-ui issue?) + test("keeps the bottom-right grid menu inside the viewport", async ({ page }) => { + // Force a tall body to ensure there is space for the popover to flip to the top + await page.addStyleTag({ content: "body { min-height: 2000px !important; }" }); + // Reduce viewport height to force the menu to flip to the top + await page.setViewportSize({ width: 1280, height: 250 }); + + const trigger = page.locator('[data-test-trigger="grid-bottom-right"]'); + // Scroll to bottom to ensure the element is near the viewport edge + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + await trigger.click(); + const panel = page.locator('[data-test-panel="grid-bottom-right"]'); + await expect(panel).toHaveAttribute("data-popover-state", "open"); + + const metrics = await getPanelMetrics(panel); + + assertWithinViewport(metrics); + expect(metrics.state).toBe("open"); + expect(metrics.placement.startsWith("top"), `Expected placement to start with 'top' but got '${metrics.placement}'`).toBeTruthy(); + expect(metrics.popoverMaxWidth).toBe(metrics.tokenMaxWidth); + expect(metrics.tokenMaxWidth.length).toBeGreaterThan(0); + }); +}); diff --git a/issues/upgrade-esbuild.md b/issues/upgrade-esbuild.md index c619800d..a150832e 100644 --- a/issues/upgrade-esbuild.md +++ b/issues/upgrade-esbuild.md @@ -1,11 +1,11 @@ # Upgrade esbuild ## Status -- **Current:** `0.25.12` -- **Latest:** `0.27.2` +- **Current:** `0.27.2` +- **Latest:** `0.27.3` ## Details -`esbuild` 0.x versions may have breaking changes between minor versions. +`esbuild` 0.x versions may have breaking changes between minor versions, but this is a patch. Used for build scripts (`scripts/build-dist.mjs`, `scripts/build-beacon.mjs`). ## Plan diff --git a/issues/upgrade-nostr-tools.md b/issues/upgrade-nostr-tools.md index 726e2f71..bae408d7 100644 --- a/issues/upgrade-nostr-tools.md +++ b/issues/upgrade-nostr-tools.md @@ -1,15 +1,15 @@ # Upgrade nostr-tools **Package:** `nostr-tools` -**Current Version:** `2.20.0` -**Wanted Version:** `2.22.1` -**Latest Version:** `2.22.1` +**Current Version:** `2.19.4` +**Wanted Version:** `2.23.0` +**Latest Version:** `2.23.0` ## Details This package was identified as a critical dependency (cryptographic/protocol library). Automatic upgrade was skipped. ## Action Items -- [ ] Review changelog for `nostr-tools` between 2.20.0 and 2.22.1. +- [ ] Review changelog for `nostr-tools` between 2.19.4 and 2.23.0. - [ ] Verify no breaking changes in NIP implementations. - [ ] Test cryptographic functions manually or via specific test suite. - [ ] Apply upgrade manually. diff --git a/issues/upgrade-stylelint.md b/issues/upgrade-stylelint.md index 0408be8a..63013b06 100644 --- a/issues/upgrade-stylelint.md +++ b/issues/upgrade-stylelint.md @@ -1,8 +1,8 @@ # Upgrade stylelint ## Status -- **Current:** `16.26.1` -- **Latest:** `17.0.0` +- **Current:** `16.12.0` +- **Latest:** `17.1.1` ## Details Major version upgrade. diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index 9e5c316f..0c5d1ca9 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -295,31 +295,51 @@ export function createExploreDiversitySorter({ const vectors = new Map(); const scores = new Map(); + const invertedIndex = new Map(); + for (const item of candidates) { const vec = buildTagVector(item); normalizeVector(vec); vectors.set(item, vec); scores.set(item, normalizeScore(item?.metadata?.exploreScore)); + + for (const tag of vec.keys()) { + if (!invertedIndex.has(tag)) { + invertedIndex.set(tag, new Set()); + } + invertedIndex.get(tag).add(item); + } } const selected = []; - const remaining = [...candidates]; const candidateState = new Map(); - for (const candidate of remaining) { + for (const candidate of candidates) { candidateState.set(candidate, { maxSimilarity: 0, similarItemId: "", }); } - while (remaining.length > 0) { + while (candidateState.size > 0) { if (selected.length > 0) { const lastSelected = selected[selected.length - 1]; const lastSelectedVector = vectors.get(lastSelected); const lastSelectedId = stableVideoId(lastSelected); - for (const candidate of remaining) { + const affectedCandidates = new Set(); + for (const tag of lastSelectedVector.keys()) { + const tagCandidates = invertedIndex.get(tag); + if (tagCandidates) { + for (const candidate of tagCandidates) { + if (candidateState.has(candidate)) { + affectedCandidates.add(candidate); + } + } + } + } + + for (const candidate of affectedCandidates) { const state = candidateState.get(candidate); const candidateVector = vectors.get(candidate); const similarity = cosineSimilarity( @@ -343,7 +363,7 @@ export function createExploreDiversitySorter({ let bestRawCandidate = null; let bestRawScore = Number.NEGATIVE_INFINITY; - for (const candidate of remaining) { + for (const candidate of candidateState.keys()) { const rawScore = scores.get(candidate); // Track best raw score candidate (for why logging) @@ -419,11 +439,7 @@ export function createExploreDiversitySorter({ } } - const index = remaining.indexOf(bestCandidate); - if (index >= 0) { - remaining.splice(index, 1); - candidateState.delete(bestCandidate); - } + candidateState.delete(bestCandidate); } const orderedRest = [...rest].sort(compareByTimestampId); diff --git a/package-lock.json b/package-lock.json index 3403f26d..7dc2f8fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,11 +20,11 @@ "ws": "^8.19.0" }, "devDependencies": { - "@playwright/test": "^1.58.0", + "@playwright/test": "^1.58.2", "fake-indexeddb": "^6.2.5", "jsdom": "^28.0.0", "pixelmatch": "^5.3.0", - "playwright": "^1.58.0", + "playwright": "^1.58.2", "pngjs": "^7.0.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.6.5", @@ -849,13 +849,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", - "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.1" + "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -3562,13 +3562,13 @@ } }, "node_modules/playwright": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", - "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.1" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -3581,9 +3581,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", - "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 7d54e323..9ef5eaac 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "type": "module", "version": "1.0.0", + "engines": { + "node": ">=22" + }, "scripts": { "start": "npm run build && npx serve dist", "serve": "npx serve", @@ -34,11 +37,11 @@ "telemetry:aggregate": "node scripts/agent/telemetry-aggregator.mjs" }, "devDependencies": { - "@playwright/test": "^1.58.0", + "@playwright/test": "^1.58.2", "fake-indexeddb": "^6.2.5", "jsdom": "^28.0.0", "pixelmatch": "^5.3.0", - "playwright": "^1.58.0", + "playwright": "^1.58.2", "pngjs": "^7.0.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.6.5", diff --git a/releases/draft-20260207.md b/releases/draft-20260207.md new file mode 100644 index 00000000..a4428fda --- /dev/null +++ b/releases/draft-20260207.md @@ -0,0 +1,26 @@ +# Release Draft - 2026-02-07 + +## Highlights + +### New Views & UI Enhancements (Main & Unstable) + +Both channels have received significant updates introducing new dedicated views for **Kids**, **Watch History**, **Search**, **Subscriptions**, and **Docs**. These additions aim to improve content discovery and user navigation. + +### Performance & Optimization + +- **Documentation:** Scroll spy performance has been improved (PR #2129). +- **Nostr Login:** Optimized login speed for a faster user experience (PR #2153). + +### Test Coverage + +Extensive test coverage has been added for Nostr features, UI components, and critical flows (PR #2153), enhancing platform stability. + +## Upgrade Notes + +### Dependency Updates + +- `nostr-tools` +- `floating-ui` +- `crypto-helpers` + +Developers should verify compatibility with these updated libraries, especially if interacting with low-level Nostr functions or UI positioning logic. diff --git a/scripts/agent/interop-test.mjs b/scripts/agent/interop-test.mjs index 1df7bb14..01fb4452 100644 --- a/scripts/agent/interop-test.mjs +++ b/scripts/agent/interop-test.mjs @@ -1,185 +1,155 @@ -import './setup-test-env.js'; +import WebSocket from 'ws'; +import { webcrypto } from 'node:crypto'; +import { startRelay } from './simple-relay.mjs'; import { NostrClient } from '../../js/nostr/client.js'; -import { buildVideoPostEvent, buildViewEvent, NOTE_TYPES } from '../../js/nostrEventSchemas.js'; -import * as NostrTools from 'nostr-tools'; +import { buildVideoPostEvent, buildViewEvent } from '../../js/nostrEventSchemas.js'; +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; -const bytesToHex = (bytes) => { - return Buffer.from(bytes).toString('hex'); -}; - -const RELAYS = [ - "wss://relay.damus.io", - "wss://nos.lol" -]; +// Polyfills +if (typeof globalThis.WebSocket === 'undefined') { + globalThis.WebSocket = WebSocket; +} +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = webcrypto; +} +if (typeof globalThis.localStorage === 'undefined') { + const storage = new Map(); + globalThis.localStorage = { + getItem: (k) => storage.get(k) || null, + setItem: (k, v) => storage.set(String(k), String(v)), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + key: (i) => Array.from(storage.keys())[i] || null, + get length() { return storage.size; } + }; +} +if (typeof globalThis.window === 'undefined') { + globalThis.window = globalThis; + globalThis.window.localStorage = globalThis.localStorage; +} +if (typeof globalThis.navigator === 'undefined') { + globalThis.navigator = { userAgent: 'node' }; +} async function runTests() { - console.log("Starting Protocol & Interop Tests..."); + console.log('Starting Interop Tests...'); - // --- Setup --- - console.log("[Setup] Generating ephemeral keys..."); - const aliceSkBytes = NostrTools.generateSecretKey(); - const aliceSk = bytesToHex(aliceSkBytes); - const alicePk = NostrTools.getPublicKey(aliceSkBytes); + // Start Relay + const relayPort = 8899; // Use different port to avoid conflict + const relayUrl = `ws://localhost:${relayPort}`; + const relayServer = startRelay(relayPort); + console.log(`Test relay started at ${relayUrl}`); - const bobSkBytes = NostrTools.generateSecretKey(); - const bobSk = bytesToHex(bobSkBytes); - const bobPk = NostrTools.getPublicKey(bobSkBytes); - - console.log(`[Setup] Alice: ${alicePk}`); - console.log(`[Setup] Bob: ${bobPk}`); - - const aliceClient = new NostrClient(); - aliceClient.relays = [...RELAYS]; - aliceClient.readRelays = [...RELAYS]; - aliceClient.writeRelays = [...RELAYS]; - - const bobClient = new NostrClient(); - bobClient.relays = [...RELAYS]; - bobClient.readRelays = [...RELAYS]; - bobClient.writeRelays = [...RELAYS]; - - console.log("[Setup] Connecting clients..."); - // Initialize to connect - await aliceClient.init(); // This might try to load stored session, but shouldn't matter - await bobClient.init(); - - // Register signers - await aliceClient.registerPrivateKeySigner({ privateKey: aliceSk, pubkey: alicePk }); - aliceClient.pubkey = alicePk; // Explicitly set client pubkey to ensure correct signer resolution - - await bobClient.registerPrivateKeySigner({ privateKey: bobSk, pubkey: bobPk }); - bobClient.pubkey = bobPk; // Explicitly set client pubkey to ensure correct signer resolution - - let testsPassed = 0; - let testsTotal = 0; - - // --- Test A: Video Post --- - testsTotal++; - console.log("\n[Test A] Publishing VIDEO_POST..."); try { - const videoPayload = { - version: 3, - title: "Interop Test Video " + Date.now(), - description: "This is a test video event.", - videoRootId: "interop-test-" + Date.now(), - mode: "dev", - isPrivate: false, - isNsfw: false, - isForKids: false, - enableComments: true - }; + // Setup Clients + const aliceClient = new NostrClient(); + aliceClient.relays = [relayUrl]; + aliceClient.writeRelays = [relayUrl]; + aliceClient.readRelays = [relayUrl]; + await aliceClient.ensurePool(); + await aliceClient.connectToRelays(); - // We use signAndPublishEvent directly with a manually built event to test the schema helper - const event = buildVideoPostEvent({ - pubkey: alicePk, - created_at: Math.floor(Date.now() / 1000), - dTagValue: videoPayload.videoRootId, - content: videoPayload + const bobClient = new NostrClient(); + bobClient.relays = [relayUrl]; + bobClient.writeRelays = [relayUrl]; + bobClient.readRelays = [relayUrl]; + await bobClient.ensurePool(); + await bobClient.connectToRelays(); + + // Generate Keys + const aliceSk = generateSecretKey(); + const aliceSkHex = Buffer.from(aliceSk).toString('hex'); + const alicePk = getPublicKey(aliceSk); + + const bobSk = generateSecretKey(); + const bobSkHex = Buffer.from(bobSk).toString('hex'); + const bobPk = getPublicKey(bobSk); + const bobNpub = nip19.npubEncode(bobPk); + + console.log('Alice Pubkey:', alicePk); + console.log('Bob Pubkey:', bobPk); + + // Register Signers + await aliceClient.registerPrivateKeySigner({ privateKey: aliceSkHex, pubkey: alicePk }); + await bobClient.registerPrivateKeySigner({ privateKey: bobSkHex, pubkey: bobPk }); + + // --- Test 1: Video Post --- + console.log('\n[Test 1] Publishing Video Post...'); + const videoEvent = buildVideoPostEvent({ + pubkey: alicePk, + created_at: Math.floor(Date.now() / 1000), + dTagValue: `test-video-${Date.now()}`, + content: { + version: 3, + title: 'Interop Test Video', + videoRootId: `root-${Date.now()}`, + description: 'A test video for interoperability', + url: 'https://example.com/video.mp4', + mode: 'dev' + } }); - const { signedEvent } = await aliceClient.signAndPublishEvent(event); - console.log(`[Test A] Published event ${signedEvent.id}`); + const { signedEvent: publishedVideo } = await aliceClient.signAndPublishEvent(videoEvent); + console.log('Video published:', publishedVideo.id); + + // Give relay a moment + await new Promise(r => setTimeout(r, 200)); // Verify - console.log("[Test A] Verifying roundtrip..."); - // Give relays a moment - await new Promise(r => setTimeout(r, 2000)); + const fetchedVideo = await aliceClient.getEventById(publishedVideo.id); + if (!fetchedVideo) { + console.log('Fetching raw event to debug...'); + const raw = await aliceClient.fetchRawEventById(publishedVideo.id); + console.log('Raw event fetch result:', raw ? 'FOUND' : 'NOT FOUND'); + throw new Error('Failed to fetch published video'); + } + if (fetchedVideo.title !== 'Interop Test Video') { + console.log('Fetched Video:', JSON.stringify(fetchedVideo, null, 2)); + throw new Error('Video content mismatch'); + } + console.log('PASS: Video Post verified.'); - // getEventById returns the Video object (converted), but we can ask for raw too - const result = await bobClient.getEventById(signedEvent.id, { includeRaw: true }); - if (!result || !result.rawEvent) throw new Error("Failed to fetch event back"); - - const fetched = result.rawEvent; - if (fetched.id !== signedEvent.id) throw new Error("Fetched ID mismatch"); - - // Parse content - const content = typeof fetched.content === 'string' ? JSON.parse(fetched.content) : fetched.content; - if (content.title !== videoPayload.title) throw new Error("Content mismatch: title"); - - console.log("[Test A] PASSED"); - testsPassed++; - } catch (e) { - console.error("[Test A] FAILED:", e); - } - - // --- Test B: View Event --- - testsTotal++; - console.log("\n[Test B] Publishing VIEW_EVENT..."); - try { + // --- Test 2: View Event --- + console.log('\n[Test 2] Publishing View Event...'); const viewEvent = buildViewEvent({ - pubkey: alicePk, - created_at: Math.floor(Date.now() / 1000), - pointerValue: "test-video-pointer", // Arbitrary pointer for test - content: "view-test" + pubkey: alicePk, + created_at: Math.floor(Date.now() / 1000), + pointerValue: publishedVideo.id // referencing the video }); - const { signedEvent: signedView } = await aliceClient.signAndPublishEvent(viewEvent); - console.log(`[Test B] Published view event ${signedView.id}`); + const { signedEvent: publishedView } = await aliceClient.signAndPublishEvent(viewEvent); + console.log('View event published:', publishedView.id); + console.log('PASS: View Event published.'); - await new Promise(r => setTimeout(r, 2000)); + // --- Test 3: Direct Message --- + console.log('\n[Test 3] Sending DM...'); + const message = 'Hello Bob, this is a test.'; + const sendResult = await aliceClient.sendDirectMessage(bobNpub, message, null, { useNip17: false }); - // Bob fetches raw event - const fetchedView = await bobClient.fetchRawEventById(signedView.id); - if (!fetchedView) throw new Error("Failed to fetch view event"); - if (fetchedView.id !== signedView.id) throw new Error("View event ID mismatch"); + if (!sendResult.ok) throw new Error(`DM Send failed: ${sendResult.error}`); + console.log('DM sent.'); - console.log("[Test B] PASSED"); - testsPassed++; - } catch (e) { - console.error("[Test B] FAILED:", e); - } + // Wait a bit for relay + await new Promise(r => setTimeout(r, 500)); - // --- Test C: Direct Message --- - testsTotal++; - console.log("\n[Test C] Sending Direct Message..."); - try { - const message = "Hello Bob, this is Alice " + Date.now(); - const npubBob = NostrTools.nip19.npubEncode(bobPk); + // Bob fetches + const messages = await bobClient.listDirectMessages(); + const receivedMsg = messages.find(m => m.plaintext === message); - const sendResult = await aliceClient.sendDirectMessage(npubBob, message); - if (!sendResult.ok) throw new Error("Failed to send DM: " + (sendResult.error || "unknown")); - console.log("[Test C] DM sent successfully"); - - await new Promise(r => setTimeout(r, 3000)); - - console.log("[Test C] Bob checking messages..."); - // Force bob to list messages (which triggers decryption using his registered signer) - const messages = await bobClient.listDirectMessages(bobPk, { limit: 5 }); - - const received = messages.find(m => m.plaintext === message); - if (!received) { - console.log("Received messages:", messages.map(m => m.plaintext)); - throw new Error("Bob did not find the specific message"); + if (!receivedMsg) { + console.log('Messages received:', messages.map(m => m.plaintext)); + throw new Error('Bob did not receive the DM or decryption failed'); } + console.log('PASS: DM received and decrypted.'); - if (received.sender.pubkey !== alicePk) { - console.log(`[Test C] Sender mismatch: Expected ${alicePk}, got ${received.sender.pubkey}`); - throw new Error("Sender mismatch"); - } - - console.log("[Test C] PASSED"); - testsPassed++; - } catch (e) { - console.error("[Test C] FAILED:", e); - } - - // --- Summary --- - console.log("\n---------------------------------------------------"); - console.log(`Tests Completed: ${testsPassed}/${testsTotal}`); - - aliceClient.logout(); - bobClient.logout(); - - if (testsPassed === testsTotal) { - console.log("ALL TESTS PASSED"); - process.exit(0); - } else { - console.error("SOME TESTS FAILED"); - process.exit(1); + } catch (error) { + console.error('\nTEST FAILED:', error); + process.exitCode = 1; + } finally { + relayServer.close(); + console.log('\nRelay closed. Exiting.'); + process.exit(process.exitCode || 0); } } -runTests().catch(e => { - console.error("Unhandled execution error:", e); - process.exit(1); -}); +runTests(); diff --git a/tests/services/profileMetadataService.test.mjs b/tests/services/profileMetadataService.test.mjs new file mode 100644 index 00000000..f0cdd748 --- /dev/null +++ b/tests/services/profileMetadataService.test.mjs @@ -0,0 +1,258 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + fetchProfileMetadataBatch, + fetchProfileMetadata, + ensureProfileMetadataSubscription +} from "../../js/services/profileMetadataService.js"; + +describe("profileMetadataService", () => { + let mockNostr; + let mockPool; + let mockLogger; + + beforeEach(() => { + mockPool = { + list: mock.fn(async () => []), + sub: mock.fn(() => ({ + on: mock.fn(), + unsub: mock.fn(), + })), + }; + + mockNostr = { + pool: mockPool, + relays: ["wss://relay.example.com"], + readRelays: ["wss://read.example.com"], + }; + + mockLogger = { + warn: mock.fn(), + log: mock.fn(), + debug: mock.fn(), + }; + }); + + describe("fetchProfileMetadataBatch", () => { + it("should return empty map if no pubkeys provided", async () => { + const results = await fetchProfileMetadataBatch({ + pubkeys: [], + nostr: mockNostr, + logger: mockLogger, + }); + assert.equal(results.size, 0); + assert.equal(mockPool.list.mock.callCount(), 0); + }); + + it("should fetch profiles for provided pubkeys", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000001"; + const mockEvent = { + pubkey, + created_at: 1000, + content: JSON.stringify({ name: "Alice", about: "Tester" }), + }; + + mockPool.list.mock.mockImplementation(async () => [mockEvent]); + + const results = await fetchProfileMetadataBatch({ + pubkeys: [pubkey], + nostr: mockNostr, + logger: mockLogger, + }); + + assert.equal(results.size, 1); + const profileData = results.get(pubkey); + assert.ok(profileData); + assert.equal(profileData.profile.name, "Alice"); + assert.equal(profileData.profile.about, "Tester"); + + // Verify pool.list was called + assert.equal(mockPool.list.mock.callCount(), 1); // Once per relay (mockNostr has 2, but logic merges? Wait, let's check logic) + // Logic: resolveRelays prefers readRelays if available. + // resolveRelays(relays, nostr) + // if relays arg is passed, use it. + // else if nostr.readRelays, use it. + // else if nostr.relays, use it. + + // Here we didn't pass relays, so it should use readRelays ("wss://read.example.com") + // So call count should be 1 (since 1 relay in readRelays) + assert.equal(mockPool.list.mock.callCount(), 1); + const callArgs = mockPool.list.mock.calls[0].arguments; + assert.deepEqual(callArgs[0], ["wss://read.example.com"]); + assert.deepEqual(callArgs[1], [{ kinds: [0], authors: [pubkey], limit: 1 }]); + }); + + it("should handle relay failures gracefully", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000002"; + mockPool.list.mock.mockImplementation(async () => { + throw new Error("Relay error"); + }); + + const results = await fetchProfileMetadataBatch({ + pubkeys: [pubkey], + nostr: mockNostr, + logger: mockLogger, + }); + + assert.equal(results.size, 0); + assert.equal(mockLogger.warn.mock.callCount(), 1); + }); + + it("should parse profile content correctly", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000003"; + const content = JSON.stringify({ + display_name: "Bob", + picture: "https://example.com/bob.jpg", + website: "https://bob.com", + banner: "https://bob.com/banner.jpg", + lud16: "bob@ln.address", + lud06: "lnurl1...", + }); + + const mockEvent = { + pubkey, + created_at: 2000, + content, + }; + + mockPool.list.mock.mockImplementation(async () => [mockEvent]); + + const results = await fetchProfileMetadataBatch({ + pubkeys: [pubkey], + nostr: mockNostr, + logger: mockLogger, + }); + + const profile = results.get(pubkey).profile; + assert.equal(profile.name, "Bob"); + assert.equal(profile.picture, "https://example.com/bob.jpg"); + assert.equal(profile.website, "https://bob.com"); + assert.equal(profile.banner, "https://bob.com/banner.jpg"); + assert.equal(profile.lud16, "bob@ln.address"); + assert.equal(profile.lud06, "lnurl1..."); + }); + + it("should deduplicate concurrent requests (inflight)", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000004"; + let resolveList; + const listPromise = new Promise(r => { resolveList = r; }); + + mockPool.list.mock.mockImplementation(() => listPromise); + + // Start two requests + const p1 = fetchProfileMetadataBatch({ pubkeys: [pubkey], nostr: mockNostr, logger: mockLogger }); + const p2 = fetchProfileMetadataBatch({ pubkeys: [pubkey], nostr: mockNostr, logger: mockLogger }); + + // Verify only one list call was made so far + // Wait a tick + await new Promise(r => setTimeout(r, 0)); + assert.equal(mockPool.list.mock.callCount(), 1); + + // Resolve + resolveList([{ pubkey, created_at: 1, content: "{}" }]); + + await Promise.all([p1, p2]); + assert.equal(mockPool.list.mock.callCount(), 1); + }); + }); + + describe("fetchProfileMetadata", () => { + it("should return null for invalid pubkey", async () => { + const result = await fetchProfileMetadata("invalid", { nostr: mockNostr }); + assert.equal(result, null); + }); + + it("should return single profile result", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000005"; + mockPool.list.mock.mockImplementation(async () => [{ + pubkey, + created_at: 1, + content: JSON.stringify({ name: "Single" }) + }]); + + const result = await fetchProfileMetadata(pubkey, { nostr: mockNostr, logger: mockLogger }); + assert.equal(result.profile.name, "Single"); + }); + }); + + describe("ensureProfileMetadataSubscription", () => { + it("should return null if nostr pool is missing", () => { + const res = ensureProfileMetadataSubscription({ + pubkey: "abc", + nostr: {}, // No pool + }); + assert.equal(res, null); + }); + + it("should create a subscription via relaySubscriptionService", () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000006"; + const mockSub = { + on: mock.fn(), + unsub: mock.fn(), + }; + mockPool.sub.mock.mockImplementation(() => mockSub); + + const sub = ensureProfileMetadataSubscription({ + pubkey, + nostr: mockNostr, + logger: mockLogger, + }); + + // relaySubscriptionService returns the subscription object + assert.equal(sub, mockSub); + + assert.equal(mockPool.sub.mock.callCount(), 1); + const args = mockPool.sub.mock.calls[0].arguments; + // check relays + assert.deepEqual(args[0], ["wss://read.example.com"]); + // check filters + assert.deepEqual(args[1], [{ kinds: [0], authors: [pubkey], limit: 1 }]); + }); + + it("should handle onProfile callback", () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000007"; + let capturedOnEvent; + + // We need to capture the onEvent passed to relaySubscriptionService.ensureSubscription + // Since we can't easily mock the internal import of relaySubscriptionService, + // we rely on the fact that relaySubscriptionService attaches a listener to the sub. + // BUT, relaySubscriptionService attaches `subscription.on('event', ...)` + // So if we mock `sub.on`, we can capture the handler provided by relaySubscriptionService. + + let relayServiceEventHandler; + const mockSub = { + on: mock.fn((event, handler) => { + if (event === 'event') { + relayServiceEventHandler = handler; + } + }), + unsub: mock.fn(), + }; + mockPool.sub.mock.mockImplementation(() => mockSub); + + const onProfileSpy = mock.fn(); + + ensureProfileMetadataSubscription({ + pubkey, + nostr: mockNostr, + logger: mockLogger, + onProfile: onProfileSpy, + }); + + // Now simulate an event coming from the pool subscription + const event = { + pubkey, + content: JSON.stringify({ name: "Live Update" }), + }; + + if (relayServiceEventHandler) { + relayServiceEventHandler(event); + } + + assert.equal(onProfileSpy.mock.callCount(), 1); + const calledArg = onProfileSpy.mock.calls[0].arguments[0]; + assert.equal(calledArg.profile.name, "Live Update"); + assert.equal(calledArg.event, event); + }); + }); +});