mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge branch 'unstable' into docs-align-behavior-17496722204495467027
This commit is contained in:
21 files changed
+1037
-257
No files matched your search
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Generated
+13
-13
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user