Merge branch 'unstable' into docs-align-behavior-17496722204495467027

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-07 16:33:22 -05:00
commit 5335d134ed
21 files changed
+1037 -257

No files matched your search

+5
View File
@@ -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
+15 -4
View File
@@ -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
+20 -35
View File
@@ -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.
+4 -4
View File
@@ -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
}
}
}
+21 -21
View File
@@ -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",
@@ -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" ...
```
@@ -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(
'<!DOCTYPE html><html><body><div id="modalMount"></div></body></html>',
{
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');
});
@@ -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
```
@@ -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("<!DOCTYPE html><html><body></body></html>", {
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");
});
@@ -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
```
@@ -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,
},
});
@@ -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);
});
});
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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.
+2 -2
View File
@@ -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.
+26 -10
View File
@@ -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);
+13 -13
View File
@@ -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": {
+5 -2
View File
@@ -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",
+26
View File
@@ -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.
+129 -159
View File
@@ -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();
@@ -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: "[email protected]",
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, "[email protected]");
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);
});
});
});