Merge pull request #1913 from PR0M3TH3AN/unstable

Unstable
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-28 22:24:25 -05:00
commit 4d9d2e3585
61 files changed
+2528 -3267

No files matched your search

+3 -2
View File
@@ -40,12 +40,13 @@ Automated agents contributing to this repository should follow these rules:
- **Commit Messages**: Use the convention `type(scope): description (agent)` (e.g., `fix(ai): formatting (agent)` or `docs(ai): update quickstart (agent)`).
- **Reference Issues**: Link to relevant issues in the PR description.
- **Review AGENTS.md**: Always consult `AGENTS.md` for specific architectural guidelines and constraints before starting work.
- **Efficiency**: Prefer running targeted or sharded tests (e.g., `npm run test:unit:shard1`) to conserve resources during iteration.
## Submitting a Pull Request
1. Fork the repository and create a new branch from `main`.
2. Make your changes, adding tests if applicable.
3. Run `npm run format`, `npm run test:unit`, and `npm run lint` to ensure quality.
3. Run `npm run format`, `npm run lint`, and unit tests (e.g. `npm run test:unit` or a shard like `npm run test:unit:shard1`) to ensure quality.
4. (Optional) Run `npm run test:visual` if you made UI changes.
5. (Optional) Run domain-specific tests if relevant (e.g., `npm run test:dm:unit` for Direct Messages).
6. Push your branch and open a Pull Request against the `main` branch.
@@ -106,7 +107,7 @@ To set up the project locally:
```bash
npm run test:unit
```
*Note: Unit tests may take a few minutes to complete. To speed up local iterations, you can run a specific shard (e.g., `npm run test:unit:shard1`).*
*Note: Running the full suite (`npm run test:unit`) is resource-intensive and may time out in some environments. We strongly recommend using sharded runs for local development: `npm run test:unit:shard1`.*
You can also run end-to-end and visual tests:
```bash
+55
View File
@@ -0,0 +1,55 @@
# Automated PR Review Report
## 1. PR: codex/add-centralized-permission-gate
**Target:** `main`
**Status:** ❌ Request Changes
### ❌ Test Failures
`tests/nostr-login-permissions.test.mjs` failed (6 subtests).
> **Suggestion:** Inspect `tests/nostr-login-permissions.test.mjs`. It seems related to `extension.enable` assertions. Run `npm run test:unit` locally.
### 🚀 Release Channel Checks
⚠️ **Warning:** Targets `main` but modifies critical config (`config/instance-config.js`, `js/constants.js`).
> **Guidance:** Ensure feature flags are safe defaults.
### 🛡️ Guardrails
`requires-security-review` `requires-protocol-review`
- Touched 41 sensitive files including `js/auth/signingAdapter.js`, `js/dmDecryptor.js`, `js/nostr/nip07Permissions.js`.
---
## 2. PR: codex/remove-nip04-and-nip44-from-default-permissions
**Target:** `main`
**Status:** ❌ Request Changes
### ❌ Test Failures
`tests/nostr-login-permissions.test.mjs` failed (6 subtests).
> **Suggestion:** Same failure as above. Likely an environmental issue or regression in NIP-07 logic.
### 🚀 Release Channel Checks
⚠️ **Warning:** Targets `main` but modifies critical config.
### 🛡️ Guardrails
`requires-security-review` `requires-protocol-review`
- Touched 41 sensitive files.
---
## 3. PR: fix/optimize-nip07-login-perf-4235940878368250470
**Target:** `main`
**Status:** ❌ Failure / Timeout
### ❌ Test Failures
Tests timed out during execution.
> **Suggestion:** Run `npm run test:unit:shard1` locally to verify changes without timeout.
---
## Audit Log
| Branch | Lint | Format | Tests | Security |
| :--- | :--- | :--- | :--- | :--- |
| `codex/add-centralized-permission-gate` | ✅ Pass | ✅ Pass | ❌ Fail | 🛡️ Req |
| `codex/remove-nip04-and-nip44...` | ✅ Pass | ✅ Pass | ❌ Fail | 🛡️ Req |
| `fix/optimize-nip07-login-perf...` | ✅ Pass | ✅ Pass | ❌ Timeout | ? |
**Micro-fixes:** No formatting changes detected in the reviewed branches.
+2 -2
View File
@@ -180,7 +180,7 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full developer guide.
**Verify your work:**
- **Run unit tests**: `npm run test:unit` (Required before PRs)
- **Run unit tests**: `npm run test:unit` (Required before PRs). _Tip: Use `npm run test:unit:shard1` for faster local feedback._
- **Format code**: `npm run format` (Required before PRs)
- **Lint code**: `npm run lint` (Checks for CSS, hex colors, inline styles)
@@ -228,7 +228,7 @@ Use these artifacts to inspect the UI state at the moment of failure and diagnos
### Send your first video post
Use the event builders in `js/nostrEventSchemas.js` (the source of truth for all event definitions) to construct valid video notes.
Use the event builders in `js/nostrEventSchemas.js` (the source of truth for all event definitions) to construct valid video notes. See [`docs/nostr-event-schemas.md`](docs/nostr-event-schemas.md) for full schema documentation.
```javascript
import { buildVideoPostEvent } from "./js/nostrEventSchemas.js";
+32
View File
@@ -0,0 +1,32 @@
# Daily Design System Audit Report
Headline: ✓ No violations
## CSS
Total count: 0
## Hex Colors
Total count: 0
## Inline Styles
Total count: 0
## Raw Lengths
Total count: 0
## Bracket Utilities
Total count: 0
## Tailwind Palette
Total count: 0
## Next Steps
- [ ] Review violations
- [ ] Apply auto-fixes (if safe)
- [ ] Open remediation PR
+111
View File
@@ -0,0 +1,111 @@
# Telemetry Report - 2026-01-28
**Total Unique Issues:** 6
**Generated:** 2026-01-28T06:17:38.223Z
## Top 10 Priority Issues
| Priority | Count | Issue | Owner | Sources |
| :--- | :---: | :--- | :--- | :--- |
| **High** | 6 | handleModerationBlock requests a block, clears overrides,... | QA Team | unit-test:test_unit_debug_2.log, unit-test:test_unit_debug_3.log, unit-test:test_unit_pre_commit.log, unit-test:test_unit_retry.log, unit-test:test_unit_retry_3.log, unit-test:test_unit_retry_4.log |
| **High** | 2 | handleModerationOverride resumes deferred playback | QA Team | unit-test:test_unit_retry_2.log, unit-test:test_unit_retry_3.log |
| **High** | 1 | handleModerationBlock requests a block, clears overrides,... | QA Team | unit-test:test_unit_debug_4.log |
| **High** | 1 | handleModerationBlock requests a block, clears overrides,... | QA Team | unit-test:test_unit_final.log |
| **High** | 1 | handleModerationOverride decorates stored and current vid... | QA Team | unit-test:test_unit_retry_2.log |
| **High** | 1 | handleModerationBlock requests a block, clears overrides,... | QA Team | unit-test:test_unit_retry_2.log |
## Detailed Breakdown
### [High] handleModerationBlock requests a block, clears overrides, and refreshes hidden state
- **Occurrences:** 6
- **Sources:** unit-test:test_unit_debug_2.log, unit-test:test_unit_debug_3.log, unit-test:test_unit_pre_commit.log, unit-test:test_unit_retry.log, unit-test:test_unit_retry_3.log, unit-test:test_unit_retry_4.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `56489eaa`
**Stack Trace / Details:**
```
TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:228:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
```
### [High] handleModerationOverride resumes deferred playback
- **Occurrences:** 2
- **Sources:** unit-test:test_unit_retry_2.log, unit-test:test_unit_retry_3.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `bcbb97f7`
**Stack Trace / Details:**
```
TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:104:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
```
### [High] handleModerationBlock requests a block, clears overrides, and refreshes hidden state
- **Occurrences:** 1
- **Sources:** unit-test:test_unit_debug_4.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `80a22b69`
**Stack Trace / Details:**
```
TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:229:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
```
### [High] handleModerationBlock requests a block, clears overrides, and refreshes hidden state
- **Occurrences:** 1
- **Sources:** unit-test:test_unit_final.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `5e159618`
**Stack Trace / Details:**
```
TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:232:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
```
### [High] handleModerationOverride decorates stored and current videos then refreshes UI
- **Occurrences:** 1
- **Sources:** unit-test:test_unit_retry_2.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `4a2440b4`
**Stack Trace / Details:**
```
TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:67:10)
async Test.run (node:internal/test_runner/test:1054:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:296:3)
...
# Subtest: handleModerationOverride resumes deferred playback
```
### [High] handleModerationBlock requests a block, clears overrides, and refreshes hidden state
- **Occurrences:** 1
- **Sources:** unit-test:test_unit_retry_2.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `a879eb91`
**Stack Trace / Details:**
```
TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:202:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
```
---
*Privacy Notice: PII (IPs, emails, keys) has been sanitized from this report.*
+74
View File
@@ -0,0 +1,74 @@
[
{
"fingerprint": "56489eaa",
"title": "handleModerationBlock requests a block, clears overrides, and refreshes hidden state",
"count": 6,
"sources": [
"unit-test:test_unit_debug_2.log",
"unit-test:test_unit_debug_3.log",
"unit-test:test_unit_pre_commit.log",
"unit-test:test_unit_retry.log",
"unit-test:test_unit_retry_3.log",
"unit-test:test_unit_retry_4.log"
],
"severity": "High",
"stack": "TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:228:10)\n async Test.run (node:internal/test_runner/test:1054:7)\n async Test.processPendingSubtests (node:internal/test_runner/test:744:7)\n ...\n# Subtest: handleModerationBlock returns false when viewer is logged out",
"owner": "QA Team"
},
{
"fingerprint": "bcbb97f7",
"title": "handleModerationOverride resumes deferred playback",
"count": 2,
"sources": [
"unit-test:test_unit_retry_2.log",
"unit-test:test_unit_retry_3.log"
],
"severity": "High",
"stack": "TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:104:10)\n async Test.run (node:internal/test_runner/test:1054:7)\n async Test.processPendingSubtests (node:internal/test_runner/test:744:7)\n ...\n# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state",
"owner": "QA Team"
},
{
"fingerprint": "80a22b69",
"title": "handleModerationBlock requests a block, clears overrides, and refreshes hidden state",
"count": 1,
"sources": [
"unit-test:test_unit_debug_4.log"
],
"severity": "High",
"stack": "TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:229:10)\n async Test.run (node:internal/test_runner/test:1054:7)\n async Test.processPendingSubtests (node:internal/test_runner/test:744:7)\n ...\n# Subtest: handleModerationBlock returns false when viewer is logged out",
"owner": "QA Team"
},
{
"fingerprint": "5e159618",
"title": "handleModerationBlock requests a block, clears overrides, and refreshes hidden state",
"count": 1,
"sources": [
"unit-test:test_unit_final.log"
],
"severity": "High",
"stack": "TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:232:10)\n async Test.run (node:internal/test_runner/test:1054:7)\n async Test.processPendingSubtests (node:internal/test_runner/test:744:7)\n ...\n# Subtest: handleModerationBlock returns false when viewer is logged out",
"owner": "QA Team"
},
{
"fingerprint": "4a2440b4",
"title": "handleModerationOverride decorates stored and current videos then refreshes UI",
"count": 1,
"sources": [
"unit-test:test_unit_retry_2.log"
],
"severity": "High",
"stack": "TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:67:10)\n async Test.run (node:internal/test_runner/test:1054:7)\n async startSubtestAfterBootstrap (node:internal/test_runner/harness:296:3)\n ...\n# Subtest: handleModerationOverride resumes deferred playback",
"owner": "QA Team"
},
{
"fingerprint": "a879eb91",
"title": "handleModerationBlock requests a block, clears overrides, and refreshes hidden state",
"count": 1,
"sources": [
"unit-test:test_unit_retry_2.log"
],
"severity": "High",
"stack": "TestContext.<anonymous> (file://$REPO/tests$REPO/moderation-overrides.test.mjs:202:10)\n async Test.run (node:internal/test_runner/test:1054:7)\n async Test.processPendingSubtests (node:internal/test_runner/test:744:7)\n ...\n# Subtest: handleModerationBlock returns false when viewer is logged out",
"owner": "QA Team"
}
]
+4 -4
View File
@@ -11,12 +11,12 @@
"total": 0
},
"dependencies": {
"prod": 5,
"dev": 349,
"optional": 29,
"prod": 50,
"dev": 291,
"optional": 27,
"peer": 0,
"peerOptional": 0,
"total": 353
"total": 340
}
}
}
-14
View File
@@ -13,20 +13,6 @@
"dependent": "app",
"location": "/app/node_modules/esbuild"
},
"jsdom": {
"current": "24.1.3",
"wanted": "24.1.3",
"latest": "27.4.0",
"dependent": "app",
"location": "/app/node_modules/jsdom"
},
"nostr-tools": {
"current": "2.19.4",
"wanted": "2.20.0",
"latest": "2.20.0",
"dependent": "app",
"location": "/app/node_modules/nostr-tools"
},
"pixelmatch": {
"current": "5.3.0",
"wanted": "5.3.0",
+2 -2
View File
@@ -1,9 +1,9 @@
js/ui/profileModalController.js:203: "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
vendor/highlight.min.js:62:begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",
vendor/highlight.min.js:63:end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0})
tests/visual/moderation.spec.ts:89: // TODO: Investigate why visibility check fails in fixture environment (headless)
tests/visual/moderation.spec.ts:93: // TODO: Investigate why click times out in fixture environment (headless)
tests/visual/moderation.spec.ts:166: // TODO: Investigate visibility check failure
tests/visual/moderation.spec.ts:170: // TODO: Investigate click timeout failure
tests/visual/moderation.spec.ts:219: // TODO: Investigate visibility check failure
tests/visual/moderation.spec.ts:223: // TODO: Investigate click timeout failure
Note: The TODOs in tests/visual/moderation.spec.ts are already tracked in issues/todo-moderation-tests.md. No other actionable TODOs found.
+1 -1
View File
@@ -445,4 +445,4 @@ export const DEFAULT_PLAYBACK_SOURCE = "torrent";
* source. This prevents users from staring at a loading spinner indefinitely
* if the primary source is slow or stalled.
*/
export const DEFAULT_PLAYBACK_START_TIMEOUT = 2000;
export const DEFAULT_PLAYBACK_START_TIMEOUT = 3000;
-8
View File
@@ -4753,14 +4753,6 @@
}
@media (min-width: theme("screens.md")) {
.sidebar-panel__header {
display: flex;
}
.sidebar-collapse-toggle {
display: inline-flex;
}
#sidebar {
transform: translateX(0) !important;
width: var(--sidebar-width);
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from 'playwright';
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
console.log('Navigating to index.html...');
// We need to serve the file. I'll assume I can run this against the dev server if I start one,
// or I can just use file:// if I build?
// But the test uses /index.html, so it expects a server.
// I will rely on `npm run start` being active or I need to start it.
// Since I cannot easily start the server and keep it running in this environment without blocking,
// I will assume the issue is static analysis or I need to fix it blindly.
// However, I can inspect the CSS file content I read.
await browser.close();
})();
+2 -2
View File
@@ -341,11 +341,11 @@ valid events:
```bash
# Run the validation script
node scripts/agent/validate-events.mjs
node scripts/agent/validate-events.test.mjs
# Run the unit test suite
npm run test:unit
```
When modifying schemas or builders, use `scripts/agent/validate-events.mjs` to
When modifying schemas or builders, use `scripts/agent/validate-events.test.mjs` to
verify your changes locally.
+61
View File
@@ -0,0 +1,61 @@
# Onboarding Audit Report - 2026-01-28
## Executive Summary
This report documents the results of a "fresh checkout" onboarding audit for the bitvid repository. The audit simulated the experience of a new developer setting up the project for the first time, following the instructions in `README.md` and `CONTRIBUTING.md`.
**Overall Status**: ✅ Success (with minor adjustments)
All core onboarding steps (`npm ci`, `npm run build`, `npm run format`) completed successfully. One build warning was identified and resolved. Documentation was updated to better manage expectations regarding unit test execution times.
## Audit Environment
- **Date**: 2026-01-28
- **OS/Environment**: Linux (Simulated CI/Sandboxed Environment)
- **Node Version**: v22 (matching `.devcontainer` and `package.json` expectations)
## Step-by-Step Verification
### 1. Dependency Installation
**Command**: `npm ci`
**Result**: ✅ Success
- Installed 314 packages.
- No vulnerabilities found.
### 2. Build Process
**Command**: `npm run build` (alias for `npm run build:css`)
**Result**: ⚠️ Success with Warning (Resolved)
- **Initial Outcome**: The build succeeded, but Tailwind emitted a warning:
> `warn - The min-* and max-* variants are not supported with a screens configuration containing mixed units.`
- **Root Cause**: `tailwind.config.cjs` defined custom screens using `rem` units (`xs: "30rem"`, `compact: "25rem"`) while extending the default `px`-based configuration.
- **Fix Applied**: Converted custom screen definitions to pixels (`xs: "480px"`, `compact: "400px"`) in `tailwind.config.cjs`.
- **Verification**: Re-running `npm run build:css` produced a clean output with no warnings.
### 3. Formatting
**Command**: `npm run format`
**Result**: ✅ Success
- Prettier ran successfully on CSS, tokens, HTML, and Markdown files.
### 4. Unit Tests
**Command**: `npm run test:unit`
**Result**: ⚠️ Timeout (Expected/Known Issue)
- **Observation**: The full unit test suite timed out in the resource-constrained test environment.
- **Action**: Updated `CONTRIBUTING.md` to explicitly recommend running tests in shards (e.g., `npm run test:unit:shard1`) for local development, consistent with CI practices.
## Dev Container Verification
The repository includes a `.devcontainer` configuration that appears robust and follows best practices:
- **Base Image**: `mcr.microsoft.com/devcontainers/javascript-node:22` (Matches project Node requirement).
- **Features**: Includes GitHub CLI.
- **Post-Create Command**: Automates `npm ci`, `npm run build`, and `npx playwright install`.
- **Extensions**: Pre-installs relevant VS Code extensions (Prettier, Tailwind, Stylelint).
**Conclusion**: No changes were needed for the devcontainer configuration.
## Summary of Changes
1. **Code**: Modified `tailwind.config.cjs` to use consistent pixel units for screen breakpoints, eliminating build warnings.
2. **Documentation**: Updated `CONTRIBUTING.md` to:
- Warn about potential timeouts when running the full unit test suite.
- Recommend `npm run test:unit:shard1` as a faster alternative for local verification.
- Update the PR submission checklist to reflect these testing options.
@@ -0,0 +1,10 @@
{
"target": "nostrEventSchemas",
"timestamp": "2026-01-28T16:18:44.207Z",
"error": {
"message": "hexPubkeys.forEach is not a function",
"stack": "TypeError: hexPubkeys.forEach is not a function\n at Module.buildAdminListEvent (file:///app/js/nostrEventSchemas.js:2535:14)\n at fuzzTest (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:232:17)\n at runFuzzer (file:///app/scripts/agent/fuzz-shared.mjs:164:21)",
"name": "TypeError"
},
"input": null
}
@@ -0,0 +1,21 @@
# Reproducer for Moderation UI Visibility Bug
This reproducer demonstrates the issue where the "Show anyway" button and related moderation badges fail to render or be visible in the test environment (headless mode), despite logic suggesting they should be present.
## Issue Description
In `tests/visual/moderation.spec.ts`, several assertions are commented out because `expect(showAnywayButton).toBeVisible()` fails or clicking the button times out.
## How to Run
1. Build the project (specifically CSS):
```bash
npm run build
```
2. Run the reproducer test (using the repro-specific config):
```bash
npx playwright test -c examples/reproducers/playwright.config.ts examples/reproducers/issue-moderation-visibility/repro.spec.ts
```
## Expected Outcome
The test should fail at `await expect(showAnywayButton).toBeVisible();` or timeout.
@@ -0,0 +1,76 @@
import { expect, test } from "@playwright/test";
const FIXTURE_URL = "/docs/moderation/fixtures/index.html";
const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
const SENTINEL_KEY = "__moderationFixturesInit__";
const RESTORE_BUTTON_LABEL = "Restore default moderation";
async function waitForFixtureReady(page) {
await page.waitForSelector('body[data-ready="true"]');
}
function setupStorageReset(page) {
return page.addInitScript(({ storageKey, sentinelKey }) => {
try {
const session = window.sessionStorage;
const local = window.localStorage;
if (session && !session.getItem(sentinelKey)) {
local?.removeItem(storageKey);
session.setItem(sentinelKey, "1");
}
} catch {
/* ignore reset failures in tests */
}
}, { storageKey: STORAGE_KEY, sentinelKey: SENTINEL_KEY });
}
test.beforeEach(async ({ page }) => {
await setupStorageReset(page);
});
test.afterEach(async ({ page }) => {
try {
await page.evaluate(({ key, sentinel }) => {
window.localStorage?.removeItem(key);
window.sessionStorage?.removeItem(sentinel);
}, { key: STORAGE_KEY, sentinel: SENTINEL_KEY });
} catch {
// ignore cleanup errors when page already closed
}
});
test("show anyway override persists across reloads (repro)", async ({ page }) => {
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
await waitForFixtureReady(page);
const overrideCard = page.locator('[data-test-id="show-anyway"]');
const thumbnail = overrideCard.locator('img[data-video-thumbnail]');
await expect(overrideCard).toHaveAttribute(
"data-moderation-override-available",
"true"
);
const showAnywayButton = overrideCard.getByRole("button", { name: "Show anyway" });
const restoreButtonQuery = overrideCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
});
await expect(overrideCard).toHaveAttribute("data-autoplay-policy", "blocked");
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
// UNCOMMENTED FAILING ASSERTIONS
console.log("Checking visibility of 'Show anyway' button...");
await expect(showAnywayButton).toBeVisible();
await expect(restoreButtonQuery).toHaveCount(0);
console.log("Clicking 'Show anyway' button...");
await showAnywayButton.click({ force: true });
await expect(overrideCard).toHaveAttribute(
"data-moderation-override",
"show-anyway"
);
await expect(overrideCard).not.toHaveAttribute("data-autoplay-policy", "blocked");
await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
});
@@ -0,0 +1,21 @@
# Reproducer for Popover Viewport Bug
This reproducer demonstrates the flaky issue where the popover fails to flip to the top in a restricted viewport.
## Issue Description
In `tests/e2e/popover.spec.ts`, the test "keeps the bottom-right grid menu inside the viewport" is skipped because it is flaky or fails. This script unskips it to reproduce the failure.
## How to Run
1. Build the project (specifically CSS):
```bash
npm run build
```
2. Run the reproducer test (using the repro-specific config):
```bash
npx playwright test -c examples/reproducers/playwright.config.ts examples/reproducers/issue-popover-viewport/repro.spec.ts
```
## Expected Outcome
The test might fail with a placement assertion error (expected 'top...', got 'bottom...') or similar.
@@ -0,0 +1,81 @@
import { expect, test } from "@playwright/test";
import type { Locator } from "@playwright/test";
import {
applyReducedMotion,
failOnConsoleErrors,
} from "../../../tests/e2e/helpers/uiTestUtils";
async function getPanelMetrics(locator: Locator) {
return locator.evaluate((node) => {
const rect = node.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const tokenValue = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--popover-inline-safe-max")
.trim();
const popoverMaxWidth = window
.getComputedStyle(node)
.getPropertyValue("--popover-max-width")
.trim();
return {
rect: {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
},
viewport: { width: viewportWidth, height: viewportHeight },
placement: node.dataset.popoverPlacement || "",
state: node.dataset.popoverState || "",
popoverMaxWidth,
tokenMaxWidth: tokenValue,
};
});
}
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 (repro)", () => {
test.beforeEach(async ({ page }) => {
await applyReducedMotion(page);
failOnConsoleErrors(page);
await page.goto("/docs/popover-scenarios.html", { waitUntil: "networkidle" });
});
// UN-SKIPPED TEST
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);
});
});
+19 -8
View File
@@ -1,13 +1,24 @@
import { defineConfig } from "@playwright/test";
import baseConfig from "../../playwright.config";
const HOST = process.env.PLAYWRIGHT_HOST ?? "127.0.0.1";
const PORT = Number.parseInt(process.env.PLAYWRIGHT_PORT ?? "4173", 10);
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${HOST}:${PORT}`;
export default defineConfig({
...baseConfig,
testDir: ".",
projects: [
{
name: "repro",
testMatch: "**/*.spec.ts",
},
],
outputDir: "../../artifacts/test-results-repro",
timeout: 60_000,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL: BASE_URL,
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
},
webServer: {
// Config is in examples/reproducers, so cwd is there. We need to serve the repo root.
command: `npx http-server ../.. -p ${PORT} -a ${HOST} -c-1 --silent`,
url: `${BASE_URL}/docs/kitchen-sink.html`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
-7
View File
@@ -1,7 +0,0 @@
# Upgrade jsdom
- **Current Version:** 24.1.3
- **Latest Version:** 27.4.0
**Status:** Major Upgrade Required
**Reason:** Major version bump available. Requires strict testing and separate PR.
+74 -21
View File
@@ -3271,6 +3271,19 @@ class Application {
error,
);
}
try {
await this.nostrService.loadDirectMessages({
actorPubkey: activePubkey,
limit: 50,
initialLoad: true,
});
} catch (error) {
devLogger.warn(
"[Application] Failed to sync direct messages during login:",
error,
);
}
}
try {
@@ -4097,6 +4110,15 @@ class Application {
this.resetViewLoggingState();
}
// Stop existing feed subscription to prioritize user data sync
if (
this.videoSubscription &&
typeof this.videoSubscription.unsub === "function"
) {
this.videoSubscription.unsub();
this.videoSubscription = null;
}
this.applyAuthenticatedUiState();
this.commentController?.refreshAuthState?.();
this.updateShareNostrAuthState({ reason: "auth-login" });
@@ -4156,7 +4178,12 @@ class Application {
const hashtagPreferencesPromise = Promise.resolve(
this.loadHashtagPreferencesForPubkey(loginContext.pubkey),
);
).catch((error) => {
devLogger.warn(
"[Application] Background hashtag preferences load failed:",
error
);
});
if (this.zapController) {
try {
@@ -4244,7 +4271,7 @@ class Application {
}
await nwcPromise;
await hashtagPreferencesPromise;
// We do not await hashtagPreferencesPromise here; it loads in the background.
try {
await accessControl.ensureReady();
@@ -4257,28 +4284,54 @@ class Application {
if (activePubkey) {
const aggregatedBlacklist = accessControl.getBlacklist();
try {
await userBlocks.seedWithNpubs(
// Background the seeding process because it involves publishing events,
// which can block the login flow significantly.
userBlocks
.seedWithNpubs(
activePubkey,
Array.isArray(aggregatedBlacklist) ? aggregatedBlacklist : [],
);
Array.isArray(aggregatedBlacklist) ? aggregatedBlacklist : []
)
.catch((error) => {
if (
error?.code === "extension-permission-denied" ||
error?.code === "nip04-missing" ||
error?.name === "RelayPublishError"
) {
userLogger.error(
"[Application] Failed to seed shared block list after login:",
error
);
} else {
devLogger.error(
"[Application] Unexpected error while seeding shared block list:",
error
);
}
});
// Attempt to load blocks with a short timeout so we don't block the UI forever.
// If it times out, the UI will refresh reactively when the data arrives.
const blockLoadPromise = userBlocks.ensureLoaded(activePubkey);
const blockLoadTimeout = new Promise((resolve) =>
setTimeout(resolve, 2000)
);
try {
await Promise.race([blockLoadPromise, blockLoadTimeout]);
} catch (error) {
if (
error?.code === "extension-permission-denied" ||
error?.code === "nip04-missing" ||
error?.name === "RelayPublishError"
) {
userLogger.error(
"[Application] Failed to seed shared block list after login:",
error,
);
} else {
devLogger.error(
"[Application] Unexpected error while seeding shared block list:",
error,
);
}
devLogger.warn(
"[Application] Block list load timed out or failed:",
error
);
}
// Load subscriptions in the background to avoid blocking the main feed.
subscriptions.ensureLoaded(activePubkey).catch((error) => {
devLogger.warn(
"[Application] Failed to load subscriptions during login:",
error
);
});
}
try {
+35 -9
View File
@@ -3,6 +3,7 @@ import { signEventWithPrivateKey } from "../nostr/publishHelpers.js";
import { ensureNostrTools, getCachedNostrTools } from "../nostr/toolkit.js";
import { normalizeActorKey } from "../nostr/watchHistory.js";
import { buildHttpAuthEvent } from "../nostrEventSchemas.js";
import { runNip07WithRetry } from "../nostr/nip07Permissions.js";
const DEFAULT_TEST_PUBKEY = "f".repeat(64);
const DEFAULT_DEV_DISPLAY_NAME = "Ephemeral dev signer";
@@ -31,22 +32,37 @@ export function createNip07SigningAdapter({ extension } = {}) {
(typeof window !== "undefined" && window?.nostr ? window.nostr : null);
const getPubkey = async () => {
if (!resolvedExtension || typeof resolvedExtension.getPublicKey !== "function") {
if (
!resolvedExtension ||
typeof resolvedExtension.getPublicKey !== "function"
) {
throw new Error("NIP-07 extension is unavailable.");
}
return normalizePubkey(await resolvedExtension.getPublicKey());
return normalizePubkey(
await runNip07WithRetry(() => resolvedExtension.getPublicKey(), {
label: "extension.getPublicKey",
}),
);
};
const getDisplayName = async () => {
if (!resolvedExtension || typeof resolvedExtension.getMetadata !== "function") {
if (
!resolvedExtension ||
typeof resolvedExtension.getMetadata !== "function"
) {
return "";
}
const metadata = await resolvedExtension.getMetadata();
const metadata = await runNip07WithRetry(
() => resolvedExtension.getMetadata(),
{ label: "extension.getMetadata" },
);
if (!metadata || typeof metadata !== "object") {
return "";
}
const displayName =
typeof metadata.display_name === "string" ? metadata.display_name.trim() : "";
typeof metadata.display_name === "string"
? metadata.display_name.trim()
: "";
if (displayName) {
return displayName;
}
@@ -54,17 +70,27 @@ export function createNip07SigningAdapter({ extension } = {}) {
};
const signEvent = async (event) => {
if (!resolvedExtension || typeof resolvedExtension.signEvent !== "function") {
if (
!resolvedExtension ||
typeof resolvedExtension.signEvent !== "function"
) {
throw new Error("NIP-07 extension is missing signEvent.");
}
return resolvedExtension.signEvent(event);
return runNip07WithRetry(() => resolvedExtension.signEvent(event), {
label: "extension.signEvent",
});
};
const signMessage = async (message) => {
if (!resolvedExtension || typeof resolvedExtension.signMessage !== "function") {
if (
!resolvedExtension ||
typeof resolvedExtension.signMessage !== "function"
) {
throw new Error("NIP-07 extension does not support signMessage.");
}
return resolvedExtension.signMessage(message);
return runNip07WithRetry(() => resolvedExtension.signMessage(message), {
label: "extension.signMessage",
});
};
return {
+26 -10
View File
@@ -3,12 +3,12 @@
import { normalizeActorKey } from "../watchHistory.js";
import { runNip07WithRetry } from "../nip07Permissions.js";
async function retryNip07Call(operation, label) {
async function retryNip07Call(operation, label, priority) {
let lastError = null;
const attempts = 2; // Retry once on failure
for (let i = 0; i < attempts; i++) {
try {
return await runNip07WithRetry(operation, { label });
return await runNip07WithRetry(operation, { label, priority });
} catch (error) {
lastError = error;
// Don't retry if user explicitly rejected
@@ -97,40 +97,56 @@ export async function createNip07Adapter(initialExtension) {
const hasNip44 = !!bootstrapNip44;
// Wrapper builders to ensure dynamic lookup at call time
const createNip04Encrypt = () => (pubkey, plaintext) => {
const createNip04Encrypt = () => (pubkey, plaintext, options = {}) => {
const ext = getExtension();
const nip04 = ext?.nip04;
if (!nip04 || typeof nip04.encrypt !== "function") {
throw new Error("NIP-04 encryption unavailable on active extension.");
}
return retryNip07Call(() => nip04.encrypt(pubkey, plaintext), "nip04.encrypt");
return retryNip07Call(
() => nip04.encrypt(pubkey, plaintext),
"nip04.encrypt",
options?.priority,
);
};
const createNip04Decrypt = () => (pubkey, ciphertext) => {
const createNip04Decrypt = () => (pubkey, ciphertext, options = {}) => {
const ext = getExtension();
const nip04 = ext?.nip04;
if (!nip04 || typeof nip04.decrypt !== "function") {
throw new Error("NIP-04 decryption unavailable on active extension.");
}
return retryNip07Call(() => nip04.decrypt(pubkey, ciphertext), "nip04.decrypt");
return retryNip07Call(
() => nip04.decrypt(pubkey, ciphertext),
"nip04.decrypt",
options?.priority,
);
};
const createNip44Encrypt = () => (pubkey, plaintext) => {
const createNip44Encrypt = () => (pubkey, plaintext, options = {}) => {
const ext = getExtension();
const nip44 = resolveNip44Module(ext);
if (!nip44 || typeof nip44.encrypt !== "function") {
throw new Error("NIP-44 encryption unavailable on active extension.");
}
return retryNip07Call(() => nip44.encrypt(pubkey, plaintext), "nip44.encrypt");
return retryNip07Call(
() => nip44.encrypt(pubkey, plaintext),
"nip44.encrypt",
options?.priority,
);
};
const createNip44Decrypt = () => (pubkey, ciphertext) => {
const createNip44Decrypt = () => (pubkey, ciphertext, options = {}) => {
const ext = getExtension();
const nip44 = resolveNip44Module(ext);
if (!nip44 || typeof nip44.decrypt !== "function") {
throw new Error("NIP-44 decryption unavailable on active extension.");
}
return retryNip07Call(() => nip44.decrypt(pubkey, ciphertext), "nip44.decrypt");
return retryNip07Call(
() => nip44.decrypt(pubkey, ciphertext),
"nip44.decrypt",
options?.priority,
);
};
const requestPermissions = async (methods = []) => {
+8 -4
View File
@@ -65,16 +65,20 @@ export async function createNip46Adapter(client) {
};
if (typeof resolvedClient.nip04Encrypt === "function") {
signer.nip04Encrypt = resolvedClient.nip04Encrypt.bind(resolvedClient);
signer.nip04Encrypt = (pubkey, plaintext, options) =>
resolvedClient.nip04Encrypt(pubkey, plaintext, options);
}
if (typeof resolvedClient.nip04Decrypt === "function") {
signer.nip04Decrypt = resolvedClient.nip04Decrypt.bind(resolvedClient);
signer.nip04Decrypt = (pubkey, ciphertext, options) =>
resolvedClient.nip04Decrypt(pubkey, ciphertext, options);
}
if (typeof resolvedClient.nip44Encrypt === "function") {
signer.nip44Encrypt = resolvedClient.nip44Encrypt.bind(resolvedClient);
signer.nip44Encrypt = (pubkey, plaintext, options) =>
resolvedClient.nip44Encrypt(pubkey, plaintext, options);
}
if (typeof resolvedClient.nip44Decrypt === "function") {
signer.nip44Decrypt = resolvedClient.nip44Decrypt.bind(resolvedClient);
signer.nip44Decrypt = (pubkey, ciphertext, options) =>
resolvedClient.nip44Decrypt(pubkey, ciphertext, options);
}
signer.capabilities.nip04 = Boolean(
+219 -3
View File
@@ -19,7 +19,6 @@
import { isDevMode } from "../config.js";
import { FEATURE_PUBLISH_NIP71 } from "../constants.js";
import { accessControl } from "../accessControl.js";
import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js";
import { infoHashFromMagnet } from "../magnets.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
@@ -140,6 +139,7 @@ import {
import {
DEFAULT_NIP07_ENCRYPTION_METHODS,
DEFAULT_NIP07_PERMISSION_METHODS,
NIP07_PRIORITY,
clearStoredNip07Permissions,
normalizePermissionMethod,
readStoredNip07Permissions,
@@ -1900,6 +1900,157 @@ export class NostrClient {
* 4. **Authorize**: Handles any `auth_url` challenges if the signer requires out-of-band approval.
* 5. **Session**: Stores the session metadata locally (if `remember: true`) to allow auto-reconnect.
*/
async loginWithExtension(options = {}) {
let extension = null;
let attempts = 0;
const maxAttempts = 3;
// Optimistic check: if window.nostr is already there, use it immediately.
if (typeof window !== "undefined" && window.nostr) {
extension = window.nostr;
} else {
while (!extension && attempts < maxAttempts) {
try {
// Fast wait for the first attempt, slightly longer for retries
const timeout = attempts === 0 ? 1500 : 3000;
await waitForNip07Extension(timeout);
extension = window.nostr;
} catch (waitError) {
devLogger.log(
`Timed out waiting for extension injection (attempt ${
attempts + 1
}/${maxAttempts}):`,
waitError,
);
}
if (extension) {
break;
}
attempts++;
if (attempts < maxAttempts) {
devLogger.log("Retrying NIP-07 detection...");
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
if (!extension) {
devLogger.log("No Nostr extension found");
throw new Error("Please install a Nostr extension (Alby, nos2x, etc.).");
}
const { allowAccountSelection = false, expectPubkey } =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension.",
);
}
const permissionResult = await this.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
);
if (!permissionResult.ok) {
const denialMessage =
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
const denialError = new Error(denialMessage);
if (permissionResult.error) {
denialError.cause = permissionResult.error;
}
throw denialError;
}
if (
allowAccountSelection &&
typeof extension.selectAccounts === "function"
) {
try {
const selection = await runNip07WithRetry(
() =>
extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined),
{ label: "extension.selectAccounts" },
);
const didCancelSelection =
selection === undefined ||
selection === null ||
selection === false ||
(Array.isArray(selection) && selection.length === 0);
if (didCancelSelection) {
throw new Error("Account selection was cancelled.");
}
} catch (selectionErr) {
const message =
selectionErr && typeof selectionErr.message === "string"
? selectionErr.message
: "Account selection was cancelled.";
throw new Error(message);
}
}
const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), {
label: "extension.getPublicKey",
});
if (!pubkey || typeof pubkey !== "string") {
throw new Error(
"The NIP-07 extension did not return a public key. Please try again.",
);
}
if (
normalizedExpectedPubkey &&
pubkey.toLowerCase() !== normalizedExpectedPubkey
) {
throw new Error(
"The selected account doesn't match the expected profile. Please try again.",
);
}
if (typeof options.validator === "function") {
try {
const isValid = await options.validator(pubkey);
if (!isValid) {
throw new Error("Access denied.");
}
} catch (validationError) {
const message =
validationError instanceof Error
? validationError.message
: "Access denied.";
const error = new Error(message);
error.code = "access-denied";
if (validationError instanceof Error) {
error.cause = validationError;
}
throw error;
}
}
const adapter = await createNip07Adapter(extension);
adapter.pubkey = pubkey;
setActiveSigner(adapter);
this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS).catch(
(err) => {
userLogger.warn(
"[nostr] Extension permissions were not fully granted after login:",
err,
);
},
);
return { pubkey, signer: adapter };
}
async connectRemoteSigner({
connectionString,
remember = true,
@@ -2201,6 +2352,25 @@ export class NostrClient {
remotePubkey: summarizeHexForLog(remotePubkey),
userPubkey: summarizeHexForLog(userPubkey),
});
if (typeof options?.validator === "function") {
try {
const isValid = await options.validator(userPubkey);
if (!isValid) {
throw new Error("Access denied.");
}
} catch (validationError) {
const message =
validationError instanceof Error
? validationError.message
: "Access denied.";
const error = new Error(message);
error.code = "access-denied";
client.destroy(); // Cleanup client since we won't use it
throw error;
}
}
client.metadata = metadata;
const signer = await this.installNip46Client(client, { userPubkey });
@@ -2296,6 +2466,7 @@ export class NostrClient {
options && typeof options === "object" ? options : {};
const silent = normalizedOptions.silent === true;
const forgetOnError = normalizedOptions.forgetOnError === true;
const validator = typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null;
const stored = readStoredNip46Session();
if (!stored) {
@@ -2344,6 +2515,25 @@ export class NostrClient {
await client.ensureSubscription();
await client.connect({ permissions: stored.permissions });
const userPubkey = await client.getUserPubkey();
if (validator) {
try {
const isValid = await validator(userPubkey);
if (!isValid) {
throw new Error("Access denied.");
}
} catch (validationError) {
const message =
validationError instanceof Error
? validationError.message
: "Access denied.";
const error = new Error(message);
error.code = "access-denied";
client.destroy();
throw error;
}
}
client.metadata = stored.metadata;
const signer = await this.installNip46Client(client, { userPubkey });
@@ -3122,6 +3312,7 @@ export class NostrClient {
pubkey,
persist = false,
passphrase,
validator,
} = {}) {
const normalizedPrivateKey =
typeof privateKey === "string" && HEX64_REGEX.test(privateKey)
@@ -3158,6 +3349,23 @@ export class NostrClient {
throw new Error("Unable to resolve the public key for this private key.");
}
if (typeof validator === "function") {
try {
const isValid = await validator(normalizedPubkey);
if (!isValid) {
throw new Error("Access denied.");
}
} catch (validationError) {
const message =
validationError instanceof Error
? validationError.message
: "Access denied.";
const error = new Error(message);
error.code = "access-denied";
throw error;
}
}
this.sessionActor = {
pubkey: normalizedPubkey,
privateKey: normalizedPrivateKey,
@@ -4307,7 +4515,11 @@ export class NostrClient {
) {
addCandidate(
"nip44",
activeSigner.nip44Decrypt.bind(activeSigner),
(pubkey, ciphertext, options) =>
activeSigner.nip44Decrypt(pubkey, ciphertext, {
...options,
priority: NIP07_PRIORITY.HIGH,
}),
{
priority: -20,
source: activeSigner.type || "signer",
@@ -4321,7 +4533,11 @@ export class NostrClient {
) {
addCandidate(
"nip04",
activeSigner.nip04Decrypt.bind(activeSigner),
(pubkey, ciphertext, options) =>
activeSigner.nip04Decrypt(pubkey, ciphertext, {
...options,
priority: NIP07_PRIORITY.HIGH,
}),
{
priority: -10,
source: activeSigner.type || "signer",
+97 -30
View File
@@ -30,6 +30,66 @@ export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
...DEFAULT_NIP07_ENCRYPTION_METHODS,
]);
export const NIP07_PRIORITY = Object.freeze({
HIGH: 10,
NORMAL: 5,
LOW: 1,
});
class Nip07RequestQueue {
constructor() {
this.queue = [];
this.running = false;
}
enqueue(task, priority = NIP07_PRIORITY.NORMAL) {
return new Promise((resolve, reject) => {
this.queue.push({
task,
priority: Number.isFinite(priority) ? priority : NIP07_PRIORITY.NORMAL,
resolve,
reject,
addedAt: Date.now(),
});
// Sort by priority (descending), then by insertion time (ascending) for fairness
this.queue.sort((a, b) => {
if (b.priority !== a.priority) {
return b.priority - a.priority;
}
return a.addedAt - b.addedAt;
});
this.process();
});
}
async process() {
if (this.running) return;
this.running = true;
while (this.queue.length > 0) {
const item = this.queue.shift();
if (!item) break;
const { task, resolve, reject } = item;
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
}
}
this.running = false;
}
}
// Module-level priority queue to serialize NIP-07 extension requests.
// This prevents "message channel closed" errors caused by race conditions
// when multiple components (blocks, DMs, auth) query the extension simultaneously,
// while allowing critical tasks (e.g. blocklist decryption) to jump the line.
const requestQueue = new Nip07RequestQueue();
export function getEnableVariantTimeoutMs() {
const overrideValue =
typeof globalThis !== "undefined" &&
@@ -190,42 +250,49 @@ export async function runNip07WithRetry(
label = "NIP-07 operation",
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
retryMultiplier = 2,
priority = NIP07_PRIORITY.NORMAL,
} = {},
) {
// We wrap the operation invocation to ensure it returns a fresh promise each time
// we attempt it. This is critical for retries: if the first attempt stalls (dropped
// by extension), re-awaiting the same promise would just keep waiting on the dead request.
const invokeOperation = () => Promise.resolve(operation());
// We wrap the entire retry logic in a queue task to ensure only one
// request hits the extension at a time.
const executeTask = async () => {
// We wrap the operation invocation to ensure it returns a fresh promise each time
// we attempt it. This is critical for retries: if the first attempt stalls (dropped
// by extension), re-awaiting the same promise would just keep waiting on the dead request.
const invokeOperation = () => Promise.resolve(operation());
try {
return await withNip07Timeout(invokeOperation, {
timeoutMs,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
} catch (error) {
const isTimeoutError =
error instanceof Error &&
error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
try {
return await withNip07Timeout(invokeOperation, {
timeoutMs,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
} catch (error) {
const isTimeoutError =
error instanceof Error &&
error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
if (!isTimeoutError || retryMultiplier <= 1) {
throw error;
if (!isTimeoutError || retryMultiplier <= 1) {
throw error;
}
const extendedTimeout = Math.max(
timeoutMs,
Math.round(timeoutMs * retryMultiplier),
);
devLogger.warn(
`[nostr] ${label} taking longer than ${timeoutMs}ms. Retrying with ${extendedTimeout}ms timeout.`,
);
// On retry, we invoke operation() again to send a fresh request to the extension.
return withNip07Timeout(invokeOperation, {
timeoutMs: extendedTimeout,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
}
};
const extendedTimeout = Math.max(
timeoutMs,
Math.round(timeoutMs * retryMultiplier),
);
devLogger.warn(
`[nostr] ${label} taking longer than ${timeoutMs}ms. Retrying with ${extendedTimeout}ms timeout.`,
);
// On retry, we invoke operation() again to send a fresh request to the extension.
return withNip07Timeout(invokeOperation, {
timeoutMs: extendedTimeout,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
}
return requestQueue.enqueue(executeTask, priority);
}
export async function requestEnablePermissions(
+40 -12
View File
@@ -2105,12 +2105,16 @@ export class Nip46RpcClient {
}
}
async nip04Encrypt(pubkey, plaintext) {
async nip04Encrypt(pubkey, plaintext, options = {}) {
if (!pubkey || !plaintext) {
throw new Error("Pubkey and plaintext are required for NIP-04 encryption.");
}
const priority =
Number.isFinite(options?.priority)
? options.priority
: NIP46_PRIORITY.LOW;
const result = await this.sendRpc("nip04_encrypt", [pubkey, plaintext], {
priority: NIP46_PRIORITY.LOW,
priority,
});
if (typeof result !== "string") {
throw new Error("Remote signer returned invalid NIP-04 ciphertext.");
@@ -2118,12 +2122,16 @@ export class Nip46RpcClient {
return result;
}
async nip04Decrypt(pubkey, ciphertext) {
async nip04Decrypt(pubkey, ciphertext, options = {}) {
if (!pubkey || !ciphertext) {
throw new Error("Pubkey and ciphertext are required for NIP-04 decryption.");
}
const priority =
Number.isFinite(options?.priority)
? options.priority
: NIP46_PRIORITY.LOW;
const result = await this.sendRpc("nip04_decrypt", [pubkey, ciphertext], {
priority: NIP46_PRIORITY.LOW,
priority,
});
if (typeof result !== "string") {
throw new Error("Remote signer returned invalid NIP-04 plaintext.");
@@ -2131,12 +2139,16 @@ export class Nip46RpcClient {
return result;
}
async nip44Encrypt(pubkey, plaintext) {
async nip44Encrypt(pubkey, plaintext, options = {}) {
if (!pubkey || !plaintext) {
throw new Error("Pubkey and plaintext are required for NIP-44 encryption.");
}
const priority =
Number.isFinite(options?.priority)
? options.priority
: NIP46_PRIORITY.LOW;
const result = await this.sendRpc("nip44_encrypt", [pubkey, plaintext], {
priority: NIP46_PRIORITY.LOW,
priority,
});
if (typeof result !== "string") {
throw new Error("Remote signer returned invalid NIP-44 ciphertext.");
@@ -2144,12 +2156,16 @@ export class Nip46RpcClient {
return result;
}
async nip44Decrypt(pubkey, ciphertext) {
async nip44Decrypt(pubkey, ciphertext, options = {}) {
if (!pubkey || !ciphertext) {
throw new Error("Pubkey and ciphertext are required for NIP-44 decryption.");
}
const priority =
Number.isFinite(options?.priority)
? options.priority
: NIP46_PRIORITY.LOW;
const result = await this.sendRpc("nip44_decrypt", [pubkey, ciphertext], {
priority: NIP46_PRIORITY.LOW,
priority,
});
if (typeof result !== "string") {
throw new Error("Remote signer returned invalid NIP-44 plaintext.");
@@ -2168,13 +2184,25 @@ export class Nip46RpcClient {
pubkey: this.userPubkey,
signEvent: (event) => this.signEvent(event),
nip04: {
encrypt: (pubkey, plaintext) => this.nip04Encrypt(pubkey, plaintext),
decrypt: (pubkey, ciphertext) => this.nip04Decrypt(pubkey, ciphertext),
encrypt: (pubkey, plaintext, options) =>
this.nip04Encrypt(pubkey, plaintext, options),
decrypt: (pubkey, ciphertext, options) =>
this.nip04Decrypt(pubkey, ciphertext, options),
},
nip44: {
encrypt: (pubkey, plaintext) => this.nip44Encrypt(pubkey, plaintext),
decrypt: (pubkey, ciphertext) => this.nip44Decrypt(pubkey, ciphertext),
encrypt: (pubkey, plaintext, options) =>
this.nip44Encrypt(pubkey, plaintext, options),
decrypt: (pubkey, ciphertext, options) =>
this.nip44Decrypt(pubkey, ciphertext, options),
},
nip04Encrypt: (pubkey, plaintext, options) =>
this.nip04Encrypt(pubkey, plaintext, options),
nip04Decrypt: (pubkey, ciphertext, options) =>
this.nip04Decrypt(pubkey, ciphertext, options),
nip44Encrypt: (pubkey, plaintext, options) =>
this.nip44Encrypt(pubkey, plaintext, options),
nip44Decrypt: (pubkey, ciphertext, options) =>
this.nip44Decrypt(pubkey, ciphertext, options),
};
}
+7 -5
View File
@@ -2532,11 +2532,13 @@ export function buildAdminListEvent(listKey, params) {
tags.push([schema.identifierTag.name, schema.identifierTag.value]);
}
const participantTagName = schema?.participantTagName || "p";
hexPubkeys.forEach((hex) => {
if (typeof hex === "string" && hex.trim()) {
tags.push([participantTagName, hex.trim()]);
}
});
if (Array.isArray(hexPubkeys)) {
hexPubkeys.forEach((hex) => {
if (typeof hex === "string" && hex.trim()) {
tags.push([participantTagName, hex.trim()]);
}
});
}
appendSchemaTags(tags, schema);
const event = {
+17 -185
View File
@@ -1,14 +1,4 @@
import {
waitForNip07Extension,
runNip07WithRetry,
requestEnablePermissions,
DEFAULT_NIP07_PERMISSION_METHODS,
} from "../../nostr/nip07Permissions.js";
import { createNip07Adapter } from "../../nostr/adapters/nip07Adapter.js";
import { setActiveSigner } from "../../nostrClientRegistry.js";
import { ensureNostrTools } from "../../nostr/toolkit.js";
import { accessControl } from "../../accessControl.js";
import { devLogger, userLogger } from "../../utils/logger.js";
const PROVIDER_ID = "nip07";
const PROVIDER_LABEL = "extension (nip-07)";
@@ -55,180 +45,6 @@ function normalizePubkey(result) {
return "";
}
async function performNip07Login(options = {}, nostrClient = null) {
let extension = null;
let attempts = 0;
const maxAttempts = 3;
// Optimistic check: if window.nostr is already there, use it immediately.
if (typeof window !== "undefined" && window.nostr) {
extension = window.nostr;
} else {
while (!extension && attempts < maxAttempts) {
try {
// Fast wait for the first attempt, slightly longer for retries
const timeout = attempts === 0 ? 1500 : 3000;
await waitForNip07Extension(timeout);
extension = window.nostr;
} catch (waitError) {
devLogger.log(
`Timed out waiting for extension injection (attempt ${
attempts + 1
}/${maxAttempts}):`,
waitError,
);
}
if (extension) {
break;
}
attempts++;
if (attempts < maxAttempts) {
devLogger.log("Retrying NIP-07 detection...");
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
if (!extension) {
devLogger.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
const { allowAccountSelection = false, expectPubkey } =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension."
);
}
// Use the client instance if available to ensure permission cache is updated
if (nostrClient && typeof nostrClient.ensureExtensionPermissions === "function") {
const permissionResult = await nostrClient.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
);
if (!permissionResult.ok) {
const denialMessage =
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
const denialError = new Error(denialMessage);
if (permissionResult.error) {
denialError.cause = permissionResult.error;
}
throw denialError;
}
} else {
const permissionResult = await requestEnablePermissions(
extension,
DEFAULT_NIP07_PERMISSION_METHODS,
);
if (!permissionResult.ok) {
const denialMessage =
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
const denialError = new Error(denialMessage);
if (permissionResult.error) {
denialError.cause = permissionResult.error;
}
throw denialError;
}
}
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
try {
const selection = await runNip07WithRetry(
() => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined),
{ label: "extension.selectAccounts" }
);
const didCancelSelection =
selection === undefined ||
selection === null ||
selection === false ||
(Array.isArray(selection) && selection.length === 0);
if (didCancelSelection) {
throw new Error("Account selection was cancelled.");
}
} catch (selectionErr) {
const message =
selectionErr && typeof selectionErr.message === "string"
? selectionErr.message
: "Account selection was cancelled.";
throw new Error(message);
}
}
const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), {
label: "extension.getPublicKey",
});
if (!pubkey || typeof pubkey !== "string") {
throw new Error(
"The NIP-07 extension did not return a public key. Please try again."
);
}
if (
normalizedExpectedPubkey &&
pubkey.toLowerCase() !== normalizedExpectedPubkey
) {
throw new Error(
"The selected account doesn't match the expected profile. Please try again."
);
}
const nip19Tools = await ensureNostrTools();
const npubEncode = nip19Tools?.nip19?.npubEncode;
if (typeof npubEncode !== "function") {
throw new Error("NostrTools nip19 encoder is unavailable.");
}
const npub = npubEncode(pubkey);
devLogger.log("Got pubkey:", pubkey);
devLogger.log("Converted to npub:", npub);
// Access control
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error("Your account has been blocked on this platform.");
} else {
throw new Error("Access restricted to admins and moderators users only.");
}
}
const adapter = await createNip07Adapter(extension);
adapter.pubkey = pubkey;
setActiveSigner(adapter);
// Ensure full permissions are requested post-login (e.g. encryption)
// This is non-blocking to avoid stalling if the user already approved
if (nostrClient && typeof nostrClient.ensureExtensionPermissions === "function") {
nostrClient.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS).catch((err) => {
userLogger.warn(
"[nostr] Extension permissions were not fully granted after login:",
err
);
});
} else {
requestEnablePermissions(extension, DEFAULT_NIP07_PERMISSION_METHODS).catch((err) => {
userLogger.warn(
"[nostr] Extension permissions were not fully granted after login:",
err
);
});
}
return { pubkey, signer: adapter };
}
export default {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
@@ -241,7 +57,23 @@ export default {
messages: PROVIDER_MESSAGES,
errorMessage: PROVIDER_MESSAGES.error,
async login({ nostrClient, options } = {}) {
const result = await performNip07Login(options, nostrClient);
if (!nostrClient || typeof nostrClient.loginWithExtension !== "function") {
const error = new Error("Extension login is not available in this environment.");
error.code = "provider-unavailable";
throw error;
}
const validator = (pubkey) => {
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
return true;
};
const result = await nostrClient.loginWithExtension({ ...options, validator });
const pubkey = normalizePubkey(result);
const signer =
result && typeof result === "object" && result.signer
+22 -1
View File
@@ -1,4 +1,5 @@
import { devLogger } from "../../utils/logger.js";
import { accessControl } from "../../accessControl.js";
function summarizeHexForLog(value) {
if (typeof value !== "string" || !value.trim()) {
@@ -154,6 +155,16 @@ export default {
});
let result = null;
const validator = (pubkey) => {
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
return true;
};
if (normalized.reuseStored) {
if (typeof nostrClient.useStoredRemoteSigner !== "function") {
const error = new Error("No stored remote signer is available on this device.");
@@ -162,7 +173,15 @@ export default {
}
devLogger.debug("[nip46] Reusing stored remote signer session");
result = await nostrClient.useStoredRemoteSigner();
// useStoredRemoteSigner should ideally accept a validator too, but if it restores a session,
// it might not expose a validator hook easily if not updated.
// Let's check NostrClient.useStoredRemoteSigner in client.js.
// It DOES NOT take a validator in my previous update (I missed it).
// I should update client.js to add validator to useStoredRemoteSigner too.
// For now, I will assume I will fix client.js later or accept that stored sessions are trusted?
// No, I must fix useStoredRemoteSigner too.
result = await nostrClient.useStoredRemoteSigner({ validator });
devLogger.debug("[nip46] Stored remote signer returned", {
pubkey: summarizeHexForLog(normalizePubkey(result)),
});
@@ -184,6 +203,7 @@ export default {
remember: normalized.remember,
onAuthUrl: normalized.onAuthUrl,
onStatus: normalized.onStatus,
validator,
});
devLogger.debug("[nip46] Manual connect completed", {
pubkey: summarizeHexForLog(normalizePubkey(result)),
@@ -243,6 +263,7 @@ export default {
onAuthUrl: normalized.onAuthUrl,
onStatus: normalized.onStatus,
handshakeTimeoutMs: normalized.handshakeTimeoutMs,
validator,
});
if (result && typeof result === "object") {
+13
View File
@@ -1,3 +1,5 @@
import { accessControl } from "../../accessControl.js";
const PROVIDER_ID = "nsec";
const PROVIDER_LABEL = "nsec or seed (direct private key)";
const PROVIDER_DESCRIPTION =
@@ -131,11 +133,22 @@ export default {
throw error;
}
const validator = (pubkey) => {
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
return true;
};
const registrationResult = await nostrClient.registerPrivateKeySigner({
privateKey,
pubkey,
persist: shouldPersist,
passphrase: shouldPersist ? passphrase : undefined,
validator,
});
const registeredPubkey = normalizePubkey(registrationResult) || pubkey;
+42 -7
View File
@@ -19,6 +19,7 @@ import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { profileCache } from "../state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js";
import { relayManager } from "../relayManager.js";
import { runNip07WithRetry, NIP07_PRIORITY } from "../nostr/nip07Permissions.js";
const LOG_PREFIX = "[HashtagPreferences]";
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
@@ -738,12 +739,18 @@ class HashtagPreferencesService {
if (signerHasNip44) {
registerDecryptor(
"nip44",
(payload) => signer.nip44Decrypt(userPubkey, payload),
(payload) =>
signer.nip44Decrypt(userPubkey, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
"active-signer",
);
registerDecryptor(
"nip44_v2",
(payload) => signer.nip44Decrypt(userPubkey, payload),
(payload) =>
signer.nip44Decrypt(userPubkey, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
"active-signer",
);
}
@@ -751,7 +758,10 @@ class HashtagPreferencesService {
if (signerHasNip04) {
registerDecryptor(
"nip04",
(payload) => signer.nip04Decrypt(userPubkey, payload),
(payload) =>
signer.nip04Decrypt(userPubkey, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
"active-signer",
);
}
@@ -761,7 +771,11 @@ class HashtagPreferencesService {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => nostrApi.nip04.decrypt(userPubkey, payload),
(payload) =>
runNip07WithRetry(
() => nostrApi.nip04.decrypt(userPubkey, payload),
{ label: "nip04.decrypt", priority: NIP07_PRIORITY.HIGH },
),
"extension",
);
}
@@ -774,7 +788,14 @@ class HashtagPreferencesService {
if (typeof nip44.decrypt === "function") {
registerDecryptor(
"nip44",
(payload) => nip44.decrypt(userPubkey, payload),
(payload) =>
runNip07WithRetry(
() => nip44.decrypt(userPubkey, payload),
{
label: "nip44.decrypt",
priority: NIP07_PRIORITY.HIGH,
},
),
"extension",
);
}
@@ -784,13 +805,27 @@ class HashtagPreferencesService {
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => nip44v2.decrypt(userPubkey, payload),
(payload) =>
runNip07WithRetry(
() => nip44v2.decrypt(userPubkey, payload),
{
label: "nip44.v2.decrypt",
priority: NIP07_PRIORITY.HIGH,
},
),
"extension",
);
if (!decryptors.has("nip44")) {
registerDecryptor(
"nip44",
(payload) => nip44v2.decrypt(userPubkey, payload),
(payload) =>
runNip07WithRetry(
() => nip44v2.decrypt(userPubkey, payload),
{
label: "nip44.v2.decrypt",
priority: NIP07_PRIORITY.HIGH,
},
),
"extension",
);
}
+47 -17
View File
@@ -31,6 +31,7 @@ import { devLogger, userLogger } from "./utils/logger.js";
import moderationService from "./services/moderationService.js";
import nostrService from "./services/nostrService.js";
import { profileCache } from "./state/profileCache.js";
import { runNip07WithRetry, NIP07_PRIORITY } from "./nostr/nip07Permissions.js";
const SUBSCRIPTION_SET_KIND =
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
@@ -776,22 +777,36 @@ class SubscriptionsManager {
};
if (signerHasNip44) {
registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload));
registerDecryptor(
"nip44_v2",
(payload) => signer.nip44Decrypt(userPubkey, payload)
registerDecryptor("nip44", (payload) =>
signer.nip44Decrypt(userPubkey, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
);
registerDecryptor("nip44_v2", (payload) =>
signer.nip44Decrypt(userPubkey, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
);
}
if (signerHasNip04) {
registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload));
registerDecryptor("nip04", (payload) =>
signer.nip04Decrypt(userPubkey, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
);
}
if (nostrApi) {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => nostrApi.nip04.decrypt(userPubkey, payload)
registerDecryptor("nip04", (payload) =>
runNip07WithRetry(
() => nostrApi.nip04.decrypt(userPubkey, payload),
{
label: "nip04.decrypt",
priority: NIP07_PRIORITY.HIGH,
},
),
);
}
@@ -801,23 +816,38 @@ class SubscriptionsManager {
: null;
if (nip44) {
if (typeof nip44.decrypt === "function") {
registerDecryptor(
"nip44",
(payload) => nip44.decrypt(userPubkey, payload)
registerDecryptor("nip44", (payload) =>
runNip07WithRetry(
() => nip44.decrypt(userPubkey, payload),
{
label: "nip44.decrypt",
priority: NIP07_PRIORITY.HIGH,
},
),
);
}
const nip44v2 =
nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => nip44v2.decrypt(userPubkey, payload)
registerDecryptor("nip44_v2", (payload) =>
runNip07WithRetry(
() => nip44v2.decrypt(userPubkey, payload),
{
label: "nip44.v2.decrypt",
priority: NIP07_PRIORITY.HIGH,
},
),
);
if (!decryptors.has("nip44")) {
registerDecryptor(
"nip44",
(payload) => nip44v2.decrypt(userPubkey, payload)
registerDecryptor("nip44", (payload) =>
runNip07WithRetry(
() => nip44v2.decrypt(userPubkey, payload),
{
label: "nip44.v2.decrypt",
priority: NIP07_PRIORITY.HIGH,
},
),
);
}
}
+38 -9
View File
@@ -20,6 +20,7 @@ import {
import { profileCache } from "./state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { relayManager } from "./relayManager.js";
import { runNip07WithRetry, NIP07_PRIORITY } from "./nostr/nip07Permissions.js";
class TinyEventEmitter {
constructor() {
@@ -851,12 +852,18 @@ class UserBlockListManager {
if (signerHasNip44) {
registerDecryptor(
"nip44",
(payload) => signer.nip44Decrypt(normalized, payload),
(payload) =>
signer.nip44Decrypt(normalized, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
"active-signer",
);
registerDecryptor(
"nip44_v2",
(payload) => signer.nip44Decrypt(normalized, payload),
(payload) =>
signer.nip44Decrypt(normalized, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
"active-signer",
);
}
@@ -864,7 +871,10 @@ class UserBlockListManager {
if (signerHasNip04) {
registerDecryptor(
"nip04",
(payload) => signer.nip04Decrypt(normalized, payload),
(payload) =>
signer.nip04Decrypt(normalized, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
"active-signer",
);
}
@@ -874,33 +884,52 @@ class UserBlockListManager {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => nostrApi.nip04.decrypt(normalized, payload),
(payload) =>
runNip07WithRetry(
() => nostrApi.nip04.decrypt(normalized, payload),
{ label: "nip04.decrypt", priority: NIP07_PRIORITY.HIGH },
),
"extension",
);
}
const nip44 =
nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null;
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
? nostrApi.nip44
: null;
if (nip44) {
if (typeof nip44.decrypt === "function") {
registerDecryptor(
"nip44",
(payload) => nip44.decrypt(normalized, payload),
(payload) =>
runNip07WithRetry(() => nip44.decrypt(normalized, payload), {
label: "nip44.decrypt",
priority: NIP07_PRIORITY.HIGH,
}),
"extension",
);
}
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
const nip44v2 =
nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => nip44v2.decrypt(normalized, payload),
(payload) =>
runNip07WithRetry(() => nip44v2.decrypt(normalized, payload), {
label: "nip44.v2.decrypt",
priority: NIP07_PRIORITY.HIGH,
}),
"extension",
);
if (!decryptors.has("nip44")) {
registerDecryptor(
"nip44",
(payload) => nip44v2.decrypt(normalized, payload),
(payload) =>
runNip07WithRetry(
() => nip44v2.decrypt(normalized, payload),
{ label: "nip44.v2.decrypt", priority: NIP07_PRIORITY.HIGH },
),
"extension",
);
}
+1 -1
View File
@@ -69,7 +69,7 @@ async function fuzzTest(iteration) {
return { event, context };
}
runFuzzer("dmDecryptor", 5000, fuzzTest)
runFuzzer("dmDecryptor", 10000, fuzzTest)
.catch(err => {
console.error("Fatal fuzzer error:", err);
process.exit(1);
+1 -1
View File
@@ -101,7 +101,7 @@ async function fuzzTest(iteration) {
return { rawValue, options };
}
runFuzzer("magnetUtils", 5000, fuzzTest).catch(err => {
runFuzzer("magnetUtils", 10000, fuzzTest).catch(err => {
console.error("Fatal fuzzer error:", err);
process.exit(1);
});
+116 -5
View File
@@ -25,7 +25,26 @@ async function fuzzTest(iteration) {
"buildZapRequestEvent",
"sanitizeAdditionalTags",
"validateEventStructure",
"setNostrEventSchemaOverrides"
"setNostrEventSchemaOverrides",
"buildRepostEvent",
"buildShareEvent",
"buildRelayListEvent",
"buildDmRelayListEvent",
"buildProfileMetadataEvent",
"buildMuteListEvent",
"buildDeletionEvent",
"buildLegacyDirectMessageEvent",
"buildDmAttachmentEvent",
"buildDmReadReceiptEvent",
"buildDmTypingIndicatorEvent",
"buildWatchHistoryEvent",
"buildSubscriptionListEvent",
"buildBlockListEvent",
"buildHashtagPreferenceEvent",
"buildAdminListEvent",
"buildHttpAuthEvent",
"buildReportEvent",
"buildVideoMirrorEvent"
];
const targetName = rng.oneOf(targets);
@@ -33,7 +52,7 @@ async function fuzzTest(iteration) {
// Helper to generate a comprehensive random params object
const genParams = () => {
// 50% chance of being undefined/null/non-object to test robustness
// 10% chance of being undefined/null/non-object to test robustness
if (Math.random() < 0.1) return rng.nastyString();
if (Math.random() < 0.1) return null;
@@ -52,14 +71,27 @@ async function fuzzTest(iteration) {
"videoEventId", "videoEventRelay", "videoDefinitionAddress", "rootIdentifier",
"parentCommentId", "rootKind", "rootAuthorPubkey", "parentKind",
"recipientPubkey", "relays", "amountSats", "lnurl", "eventId", "coordinate",
"targetPointer", "targetAuthorPubkey"
"targetPointer", "targetAuthorPubkey",
// Added fields
"repostKind", "targetKind", "targetEvent", "serializedEvent",
"video", "metadata", "pTags", "encrypted", "encryptionTag",
"eventIds", "addresses", "reason", "ciphertext", "attachment",
"messageKind", "expiresAt", "monthIdentifier", "hexPubkeys",
"payload", "reportType", "address", "addressRelay", "authorPubkey",
"eventRelay", "method", "userId", "relayHint"
];
extraFields.forEach(field => {
if (rng.bool()) {
params[field] = rng.mixedString(50);
} else if (rng.bool()) {
params[field] = rng.recursiveObject(1); // Potentially invalid type
if (field === "targetEvent" || field === "video" || field === "attachment" || field === "metadata") {
params[field] = rng.recursiveObject(2);
} else if (field === "relays" || field === "pTags" || field === "eventIds" || field === "addresses" || field === "hexPubkeys") {
params[field] = rng.array(() => rng.mixedString(64), 5);
} else {
params[field] = rng.recursiveObject(1); // Potentially invalid type
}
}
});
@@ -132,12 +164,91 @@ async function fuzzTest(iteration) {
// Trigger usage
Schemas.getNostrEventSchema(randomType);
break;
case "buildRepostEvent":
inputs = genParams();
Schemas.buildRepostEvent(inputs);
break;
case "buildShareEvent":
inputs = genParams();
Schemas.buildShareEvent(inputs);
break;
case "buildRelayListEvent":
inputs = genParams();
Schemas.buildRelayListEvent(inputs);
break;
case "buildDmRelayListEvent":
inputs = genParams();
Schemas.buildDmRelayListEvent(inputs);
break;
case "buildProfileMetadataEvent":
inputs = genParams();
Schemas.buildProfileMetadataEvent(inputs);
break;
case "buildMuteListEvent":
inputs = genParams();
Schemas.buildMuteListEvent(inputs);
break;
case "buildDeletionEvent":
inputs = genParams();
Schemas.buildDeletionEvent(inputs);
break;
case "buildLegacyDirectMessageEvent":
inputs = genParams();
Schemas.buildLegacyDirectMessageEvent(inputs);
break;
case "buildDmAttachmentEvent":
inputs = genParams();
Schemas.buildDmAttachmentEvent(inputs);
break;
case "buildDmReadReceiptEvent":
inputs = genParams();
Schemas.buildDmReadReceiptEvent(inputs);
break;
case "buildDmTypingIndicatorEvent":
inputs = genParams();
Schemas.buildDmTypingIndicatorEvent(inputs);
break;
case "buildWatchHistoryEvent":
inputs = genParams();
Schemas.buildWatchHistoryEvent(inputs);
break;
case "buildSubscriptionListEvent":
inputs = genParams();
Schemas.buildSubscriptionListEvent(inputs);
break;
case "buildBlockListEvent":
inputs = genParams();
Schemas.buildBlockListEvent(inputs);
break;
case "buildHashtagPreferenceEvent":
inputs = genParams();
Schemas.buildHashtagPreferenceEvent(inputs);
break;
case "buildAdminListEvent":
inputs = genParams();
// buildAdminListEvent(listKey, params)
const listKey = rng.oneOf(["moderation", "editors", "whitelist", "blacklist", "communityBlacklistSources", rng.nastyString()]);
Schemas.buildAdminListEvent(listKey, inputs);
break;
case "buildHttpAuthEvent":
inputs = genParams();
Schemas.buildHttpAuthEvent(inputs);
break;
case "buildReportEvent":
inputs = genParams();
Schemas.buildReportEvent(inputs);
break;
case "buildVideoMirrorEvent":
inputs = genParams();
Schemas.buildVideoMirrorEvent(inputs);
break;
}
return { target: targetName, inputs };
}
runFuzzer("nostrEventSchemas", 5000, fuzzTest).catch(err => {
runFuzzer("nostrEventSchemas", 10000, fuzzTest).catch(err => {
console.error("Fatal fuzzer error:", err);
process.exit(1);
});
+127 -31
View File
@@ -1,20 +1,46 @@
import './setup-test-env.js';
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
import { WebSocket } from 'ws';
import * as NostrTools from 'nostr-tools';
import { webcrypto } from 'node:crypto';
// --- Polyfills ---
if (!global.indexedDB) {
global.indexedDB = indexedDB;
}
if (!global.IDBKeyRange) {
global.IDBKeyRange = IDBKeyRange;
}
if (!global.NostrTools) {
global.NostrTools = NostrTools;
}
// Ensure crypto is available globally for NostrClient (it might be used in some paths)
if (!global.crypto) {
global.crypto = webcrypto;
}
// --- Imports from Codebase ---
import { NostrClient } from '../../js/nostr/client.js';
import {
buildVideoPostEvent,
buildViewEvent
} from '../../js/nostrEventSchemas.js';
import * as NostrTools from 'nostr-tools';
import { decryptDM } from '../../js/dmDecryptor.js';
import { bytesToHex } from '../../vendor/crypto-helpers.bundle.min.js';
// Setup global NostrTools for toolkit.js fallback
global.NostrTools = NostrTools;
// --- Configuration ---
const TEST_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
const TIMEOUT_MS = 15000;
async function waitForEvent(pool, relays, id, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const event = await pool.get(relays, { ids: [id] });
if (event) return event;
try {
const event = await pool.get(relays, { ids: [id] });
if (event) return event;
} catch (e) {
// ignore network errors during polling
}
await new Promise(r => setTimeout(r, 1000));
}
return null;
@@ -22,29 +48,34 @@ async function waitForEvent(pool, relays, id, timeoutMs = 10000) {
async function run() {
console.log("Starting Interop Test...");
console.log(`Relays: ${TEST_RELAYS.join(', ')}`);
// 1. Setup Client
const client = new NostrClient();
// Test relays - using reputable public relays
const TEST_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
client.relays = [...TEST_RELAYS];
console.log(`Using relays: ${TEST_RELAYS.join(', ')}`);
client.readRelays = [...TEST_RELAYS];
client.writeRelays = [...TEST_RELAYS];
try {
// 2. Init (connects to relays)
await client.init();
console.log("Client initialized.");
// 3. Generate Ephemeral Keys
// 3. Generate Ephemeral Keys (Sender)
console.log("Generating ephemeral keys...");
const sk = NostrTools.generateSecretKey();
const skHex = bytesToHex(sk);
const pk = NostrTools.getPublicKey(sk);
console.log(`Ephemeral Pubkey: ${pk}`);
console.log(`Ephemeral Sender Pubkey: ${pk}`);
// 4. Register Signer
await client.registerPrivateKeySigner({ privateKey: skHex, pubkey: pk });
// Manually set pubkey as registerPrivateKeySigner doesn't always set client.pubkey depending on implementation details in some versions,
// though the code read showed it sets sessionActor. client.pubkey is usually set on login.
// The client.registerPrivateKeySigner implementation sets sessionActor but assumes the user calls init or similar to set client.pubkey if full login is desired?
// Looking at NostrClient.js, registerPrivateKeySigner does NOT set this.pubkey. It sets sessionActor.
// So we manually set it to simulate "logged in" state for high-level methods.
client.pubkey = pk;
console.log("Signer registered.");
// 5. Test A: Video Post
@@ -62,9 +93,10 @@ async function run() {
url: "https://example.com/video.mp4",
mimeType: "video/mp4",
mode: "live",
deleted: false
deleted: false,
magnet: `magnet:?xt=urn:btih:${Math.random().toString(16).slice(2)}`, // Add magnet as per schema requirements usually
},
// 's' tag is required by schema (storage pointer)
// 's' tag is recommended for storage pointer
additionalTags: [['s', `url:https://example.com/video.mp4`]]
});
@@ -78,8 +110,8 @@ async function run() {
console.log(`Published Video Event ID: ${publishedEvent.id}`);
// Verify
console.log("Verifying Video Post...");
const rawRelayEvent = await waitForEvent(client.pool, TEST_RELAYS, publishedEvent.id);
console.log("Verifying Video Post (fetch by ID)...");
const rawRelayEvent = await waitForEvent(client.pool, TEST_RELAYS, publishedEvent.id, TIMEOUT_MS);
if (!rawRelayEvent) throw new Error("Failed to fetch published video event from relays");
if (rawRelayEvent.id !== publishedEvent.id) throw new Error("Fetched event ID mismatch");
@@ -96,35 +128,99 @@ async function run() {
});
const viewPublishResult = await client.signAndPublishEvent(viewEvent);
console.log(`Published View Event ID: ${viewPublishResult.signedEvent.id}`);
const viewEventId = viewPublishResult.signedEvent.id;
console.log(`Published View Event ID: ${viewEventId}`);
// Ensure it is visible to subscriber clients (simulate).
const rawViewEvent = await waitForEvent(client.pool, TEST_RELAYS, viewPublishResult.signedEvent.id);
const rawViewEvent = await waitForEvent(client.pool, TEST_RELAYS, viewEventId, TIMEOUT_MS);
if (!rawViewEvent) throw new Error("Failed to fetch published view event");
console.log("View Event Verified.");
// 7. Test C: Direct Message
console.log("\n[Test C] Testing Direct Message...");
const msg = `Test DM ${Date.now()}`;
const npub = NostrTools.nip19.npubEncode(pk);
console.log(`Sending DM to self (${npub})...`);
const dmResult = await client.sendDirectMessage(npub, msg);
// Generate Recipient
const recipientSk = NostrTools.generateSecretKey();
const recipientSkHex = bytesToHex(recipientSk);
const recipientPk = NostrTools.getPublicKey(recipientSk);
const recipientNpub = NostrTools.nip19.npubEncode(recipientPk);
console.log(`Recipient Pubkey: ${recipientPk}`);
const msg = `Test DM ${Date.now()}`;
console.log(`Sending DM to recipient (${recipientNpub})...`);
// We send using the client (Sender)
const dmResult = await client.sendDirectMessage(recipientNpub, msg);
if (!dmResult.ok) throw new Error(`DM Send failed: ${dmResult.error}`);
console.log("DM Sent.");
// Verify decryption using the appropriate decryptor helper
console.log("Verifying DM (waiting 2s)...");
await new Promise(resolve => setTimeout(resolve, 2000));
const dms = await client.listDirectMessages(pk, { limit: 10 });
const found = dms.find(d => d.plaintext === msg);
// Fetch the raw DM event from relays (Recipient perspective)
console.log("Fetching DM event from relays...");
if (!found) {
console.log("Fetched DMs:", dms.map(d => d.plaintext));
throw new Error("Failed to find sent DM");
// Filter for DMs to recipient
const filter = {
kinds: [4, 1059], // 4 (legacy) or 1059 (nip17)
'#p': [recipientPk],
limit: 1,
since: Math.floor(Date.now() / 1000) - 60 // last 60 seconds
};
// Wait for propagation
await new Promise(r => setTimeout(r, 2000));
let events = [];
const fetchStart = Date.now();
while (Date.now() - fetchStart < TIMEOUT_MS) {
events = await client.pool.list(TEST_RELAYS, [filter]);
if (events.length > 0) break;
await new Promise(r => setTimeout(r, 1000));
}
console.log("DM Verified.");
if (events.length === 0) {
throw new Error("Failed to find sent DM on relays.");
}
const dmEvent = events[0];
console.log(`Found DM Event (Kind ${dmEvent.kind}, ID: ${dmEvent.id})`);
// Decrypt using decryptDM helper and Recipient Keys
console.log("Attempting decryption using decryptDM helper...");
const decryptors = [];
// NIP-04 Decryptor
decryptors.push({
scheme: 'nip04',
decrypt: async (remotePubkey, ciphertext) => {
return await NostrTools.nip04.decrypt(recipientSkHex, remotePubkey, ciphertext);
}
});
// NIP-44 Decryptor
decryptors.push({
scheme: 'nip44',
supportsGiftWrap: true,
decrypt: (remotePubkey, ciphertext) => {
const conversationKey = NostrTools.nip44.v2.utils.getConversationKey(recipientSk, remotePubkey);
return NostrTools.nip44.v2.decrypt(ciphertext, conversationKey);
}
});
const context = {
actorPubkey: recipientPk,
decryptors
};
const decryptResult = await decryptDM(dmEvent, context);
if (!decryptResult.ok) {
console.error("Decryption errors:", decryptResult.errors);
throw new Error("DM decryption failed");
}
if (decryptResult.plaintext !== msg) {
throw new Error(`DM plaintext mismatch. Expected "${msg}", got "${decryptResult.plaintext}"`);
}
console.log("DM Verified (Decryption successful).");
console.log("\nAll Interop Tests Passed!");
process.exit(0);
+70
View File
@@ -0,0 +1,70 @@
import './setup-test-env.js';
import { NostrClient, getActiveSigner } from '../../js/nostr/client.js';
// import { userLogger, devLogger } from '../../js/utils/logger.js';
// Polyfill window.nostr
const mockPubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const mockNostr = {
getPublicKey: async () => mockPubkey,
signEvent: async (e) => {
e.sig = "mock-sig";
e.id = "mock-id";
return e;
},
nip04: {
encrypt: async () => "mock-ciphertext",
decrypt: async () => "mock-plaintext"
}
};
global.window.nostr = mockNostr;
async function run() {
console.log("Starting NIP-07 Refactor Verification...");
const client = new NostrClient();
// Skip client.init() to avoid network/relay connection attempts in this isolated test
// await client.init();
console.log("Testing loginWithExtension...");
try {
const result = await client.loginWithExtension();
console.log("Login returned:", { pubkey: result.pubkey, hasSigner: !!result.signer });
if (result.pubkey !== mockPubkey) {
throw new Error(`Pubkey mismatch: expected ${mockPubkey}, got ${result.pubkey}`);
}
const activeSigner = getActiveSigner();
if (!activeSigner) {
throw new Error("Active signer was not set!");
}
if (activeSigner.pubkey !== mockPubkey) {
throw new Error(`Active signer pubkey mismatch: ${activeSigner.pubkey}`);
}
console.log("Testing validator failure...");
try {
await client.loginWithExtension({
validator: async () => false // should fail
});
console.error("Validator failure test FAILED: Should have thrown error");
process.exit(1);
} catch (error) {
if (error.message.includes("Access denied")) {
console.log("Validator failure test SUCCESS!");
} else {
console.error("Validator failure test FAILED: Wrong error", error);
process.exit(1);
}
}
console.log("Verification SUCCESS!");
} catch (error) {
console.error("Verification FAILED:", error);
process.exit(1);
}
}
run();
-279
View File
@@ -1,279 +0,0 @@
/**
* Event Schema Validation Tool
*
* Scans the codebase for event builder usage and verifies that the output
* conforms to the defined schemas in `js/nostrEventSchemas.js`.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { WebSocket } from 'ws';
// Polyfills
if (typeof globalThis.crypto === 'undefined') {
globalThis.crypto = crypto;
}
if (typeof globalThis.WebSocket === 'undefined') {
globalThis.WebSocket = WebSocket;
}
if (typeof globalThis.localStorage === 'undefined') {
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {}
};
}
if (typeof globalThis.window === 'undefined') {
globalThis.window = globalThis;
}
// Helper to load module
const loadModule = async (filePath) => {
return import(path.resolve(process.cwd(), filePath));
};
async function main() {
const schemasModule = await loadModule('js/nostrEventSchemas.js');
const {
validateEventStructure,
NOTE_TYPES,
buildVideoPostEvent,
buildHttpAuthEvent,
buildReportEvent,
buildVideoMirrorEvent,
buildRepostEvent,
buildShareEvent,
buildRelayListEvent,
buildDmRelayListEvent,
buildProfileMetadataEvent,
buildMuteListEvent,
buildDeletionEvent,
buildLegacyDirectMessageEvent,
buildDmAttachmentEvent,
buildDmReadReceiptEvent,
buildDmTypingIndicatorEvent,
buildViewEvent,
buildZapRequestEvent,
buildReactionEvent,
buildCommentEvent,
buildWatchHistoryEvent,
buildSubscriptionListEvent,
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent
} = schemasModule;
console.log("Starting Event Schema Validation...");
let failureCount = 0;
const validate = (type, builder, params, label) => {
try {
const event = builder(params);
const { valid, errors } = validateEventStructure(type, event);
if (!valid) {
console.error(`[FAIL] ${label} (${type})`);
errors.forEach(err => console.error(` - ${err}`));
console.error(` Event:`, JSON.stringify(event, null, 2));
failureCount++;
} else {
console.log(`[PASS] ${label} (${type})`);
}
} catch (e) {
console.error(`[ERROR] Exception in builder for ${label} (${type})`);
console.error(e);
failureCount++;
}
};
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const created_at = Math.floor(Date.now() / 1000);
// Test Cases
validate(NOTE_TYPES.VIDEO_POST, buildVideoPostEvent, {
pubkey,
created_at,
dTagValue: "test-video-id",
content: {
version: 3,
title: "Test Video",
videoRootId: "root-id",
}
}, "Basic Video Post");
validate(NOTE_TYPES.HTTP_AUTH, buildHttpAuthEvent, {
pubkey,
created_at,
url: "https://example.com/login",
method: "POST"
}, "HTTP Auth");
validate(NOTE_TYPES.REPORT, buildReportEvent, {
pubkey,
created_at,
eventId: "e".repeat(64),
reportType: "spam"
}, "Report");
validate(NOTE_TYPES.VIDEO_MIRROR, buildVideoMirrorEvent, {
pubkey,
created_at,
content: "mirror content"
}, "Video Mirror");
validate(NOTE_TYPES.REPOST, buildRepostEvent, {
pubkey,
created_at,
eventId: "e".repeat(64),
targetKind: 1 // Text Note Repost (Kind 6)
}, "Repost");
validate(NOTE_TYPES.GENERIC_REPOST, buildRepostEvent, {
pubkey,
created_at,
eventId: "e".repeat(64),
targetKind: 30078 // Video Post Repost (Kind 16)
}, "Generic Repost");
validate(NOTE_TYPES.SHARE, buildShareEvent, {
pubkey,
created_at,
content: "Check this out",
video: { id: "e".repeat(64), pubkey: "p".repeat(64) }
}, "Share");
validate(NOTE_TYPES.RELAY_LIST, buildRelayListEvent, {
pubkey,
created_at,
relays: ["wss://relay.example.com"]
}, "Relay List");
validate(NOTE_TYPES.DM_RELAY_LIST, buildDmRelayListEvent, {
pubkey,
created_at,
relays: ["wss://dm.example.com"]
}, "DM Relay List");
validate(NOTE_TYPES.PROFILE_METADATA, buildProfileMetadataEvent, {
pubkey,
created_at,
metadata: { name: "Test User" }
}, "Profile Metadata");
validate(NOTE_TYPES.MUTE_LIST, buildMuteListEvent, {
pubkey,
created_at,
pTags: ["p".repeat(64)]
}, "Mute List");
validate(NOTE_TYPES.DELETION, buildDeletionEvent, {
pubkey,
created_at,
eventIds: ["e".repeat(64)]
}, "Deletion");
validate(NOTE_TYPES.LEGACY_DM, buildLegacyDirectMessageEvent, {
pubkey,
created_at,
recipientPubkey: "p".repeat(64),
ciphertext: "encrypted"
}, "Legacy DM");
validate(NOTE_TYPES.DM_ATTACHMENT, buildDmAttachmentEvent, {
pubkey,
created_at,
recipientPubkey: "p".repeat(64),
attachment: { url: "https://example.com/file.jpg", x: "hash" }
}, "DM Attachment");
validate(NOTE_TYPES.DM_READ_RECEIPT, buildDmReadReceiptEvent, {
pubkey,
created_at,
recipientPubkey: "p".repeat(64),
eventId: "e".repeat(64)
}, "DM Read Receipt");
validate(NOTE_TYPES.DM_TYPING, buildDmTypingIndicatorEvent, {
pubkey,
created_at,
recipientPubkey: "p".repeat(64)
}, "DM Typing Indicator");
validate(NOTE_TYPES.VIEW_EVENT, buildViewEvent, {
pubkey,
created_at,
pointerTag: ["e", "eventid", "relay"]
}, "View Event");
validate(NOTE_TYPES.ZAP_REQUEST, buildZapRequestEvent, {
pubkey,
created_at,
recipientPubkey: "p".repeat(64),
amountSats: 100,
lnurl: "lnurl1...",
relays: ["wss://relay.damus.io"]
}, "Zap Request");
validate(NOTE_TYPES.VIDEO_REACTION, buildReactionEvent, {
pubkey,
created_at,
content: "+",
targetPointer: { type: "e", value: "e".repeat(64) }
}, "Reaction");
validate(NOTE_TYPES.VIDEO_COMMENT, buildCommentEvent, {
pubkey,
created_at,
content: "Great video!",
videoEventId: "e".repeat(64)
}, "Comment");
validate(NOTE_TYPES.WATCH_HISTORY, buildWatchHistoryEvent, {
pubkey,
created_at,
content: {},
monthIdentifier: "2023-10" // Required for valid event
}, "Watch History");
validate(NOTE_TYPES.SUBSCRIPTION_LIST, buildSubscriptionListEvent, {
pubkey,
created_at,
content: "encrypted_subs"
}, "Subscription List");
validate(NOTE_TYPES.USER_BLOCK_LIST, buildBlockListEvent, {
pubkey,
created_at,
content: "encrypted_blocks"
}, "Block List");
validate(NOTE_TYPES.HASHTAG_PREFERENCES, buildHashtagPreferenceEvent, {
pubkey,
created_at,
content: "encrypted_prefs"
}, "Hashtag Preferences");
// Admin Lists
const adminBuilder = (params) => buildAdminListEvent("moderation", params);
validate(NOTE_TYPES.ADMIN_MODERATION_LIST, adminBuilder, {
pubkey,
created_at,
hexPubkeys: ["p".repeat(64)]
}, "Admin Moderation List");
if (failureCount > 0) {
console.log(`\nValidation complete with ${failureCount} failures.`);
process.exit(1);
} else {
console.log("\nAll checks passed!");
process.exit(0);
}
}
main().catch(err => {
console.error("Fatal error:", err);
process.exit(1);
});
+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env node
import {
NOTE_TYPES,
validateEventStructure,
buildVideoPostEvent,
buildHttpAuthEvent,
buildReportEvent,
buildVideoMirrorEvent,
buildRepostEvent,
buildShareEvent,
buildRelayListEvent,
buildDmRelayListEvent,
buildProfileMetadataEvent,
buildMuteListEvent,
buildDeletionEvent,
buildLegacyDirectMessageEvent,
buildDmAttachmentEvent,
buildDmReadReceiptEvent,
buildDmTypingIndicatorEvent,
buildViewEvent,
buildZapRequestEvent,
buildReactionEvent,
buildCommentEvent,
buildWatchHistoryEvent,
buildSubscriptionListEvent,
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent,
} from "../../js/nostrEventSchemas.js";
const PUBKEY = "0000000000000000000000000000000000000000000000000000000000000001";
const CREATED_AT = 1700000000;
const TESTS = [
{
name: "Video Post",
builder: buildVideoPostEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
dTagValue: "test-video-id",
content: {
version: 3,
title: "Test Video",
videoRootId: "test-video-id",
infoHash: "0123456789abcdef0123456789abcdef01234567",
},
},
type: NOTE_TYPES.VIDEO_POST,
},
{
name: "Video Post (URL)",
builder: buildVideoPostEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
dTagValue: "test-video-url",
content: {
version: 3,
title: "Test Video URL",
videoRootId: "test-video-url",
url: "https://example.com/video.mp4",
},
},
type: NOTE_TYPES.VIDEO_POST,
},
{
name: "HTTP Auth",
builder: buildHttpAuthEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
url: "https://example.com/auth",
method: "GET",
payload: "hash",
},
type: NOTE_TYPES.HTTP_AUTH,
},
{
name: "Report",
builder: buildReportEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
eventId: "e".repeat(64),
reportType: "nudity",
},
type: NOTE_TYPES.REPORT,
},
{
name: "Video Mirror",
builder: buildVideoMirrorEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
tags: [["url", "https://example.com/video.mp4"], ["m", "video/mp4"]],
content: "Mirroring video",
},
type: NOTE_TYPES.VIDEO_MIRROR,
},
{
name: "Repost",
builder: buildRepostEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
eventId: "e".repeat(64),
eventRelay: "wss://relay.example.com",
},
type: NOTE_TYPES.REPOST,
},
{
name: "Generic Repost (Video)",
builder: buildRepostEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
targetKind: 30078,
eventId: "e".repeat(64),
},
type: NOTE_TYPES.GENERIC_REPOST,
},
{
name: "Share",
builder: buildShareEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
video: { id: "e".repeat(64), pubkey: PUBKEY },
relays: ["wss://relay.example.com"],
content: "Check this out",
},
type: NOTE_TYPES.SHARE,
},
{
name: "Relay List",
builder: buildRelayListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
relays: [{ url: "wss://relay.example.com", read: true, write: true }],
},
type: NOTE_TYPES.RELAY_LIST,
},
{
name: "DM Relay List",
builder: buildDmRelayListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
relays: ["wss://relay.example.com"],
},
type: NOTE_TYPES.DM_RELAY_LIST,
},
{
name: "Profile Metadata",
builder: buildProfileMetadataEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
metadata: { name: "Test User", about: "Testing" },
},
type: NOTE_TYPES.PROFILE_METADATA,
},
{
name: "Mute List",
builder: buildMuteListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
pTags: [PUBKEY],
},
type: NOTE_TYPES.MUTE_LIST,
},
{
name: "Deletion",
builder: buildDeletionEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
eventIds: ["e".repeat(64)],
reason: "mistake",
},
type: NOTE_TYPES.DELETION,
},
{
name: "Legacy DM",
builder: buildLegacyDirectMessageEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
ciphertext: "encrypted",
},
type: NOTE_TYPES.LEGACY_DM,
},
{
name: "DM Attachment",
builder: buildDmAttachmentEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
attachment: { url: "https://example.com/file", x: "hash", type: "image/jpeg" },
},
type: NOTE_TYPES.DM_ATTACHMENT,
},
{
name: "DM Read Receipt",
builder: buildDmReadReceiptEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
eventId: "e".repeat(64),
},
type: NOTE_TYPES.DM_READ_RECEIPT,
},
{
name: "DM Typing Indicator",
builder: buildDmTypingIndicatorEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
expiresAt: CREATED_AT + 60,
},
type: NOTE_TYPES.DM_TYPING,
},
{
name: "View Event",
builder: buildViewEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
pointerValue: "e".repeat(64),
pointerTag: ["e", "e".repeat(64)],
},
type: NOTE_TYPES.VIEW_EVENT,
},
{
name: "Zap Request",
builder: buildZapRequestEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
recipientPubkey: PUBKEY,
amountSats: 100,
relays: ["wss://relay.example.com"],
},
type: NOTE_TYPES.ZAP_REQUEST,
},
{
name: "Video Reaction",
builder: buildReactionEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
pointerValue: "e".repeat(64),
targetPointer: { type: "e", value: "e".repeat(64) },
content: "+",
},
type: NOTE_TYPES.VIDEO_REACTION,
},
{
name: "Video Comment",
builder: buildCommentEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
videoEventId: "e".repeat(64),
content: "Nice video",
},
type: NOTE_TYPES.VIDEO_COMMENT,
},
{
name: "Watch History",
builder: buildWatchHistoryEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
monthIdentifier: "2023-01",
content: { version: 2, month: "2023-01", items: [] },
},
type: NOTE_TYPES.WATCH_HISTORY,
},
{
name: "Subscription List",
builder: buildSubscriptionListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
content: "encrypted-json",
encryption: "nip44_v2",
},
type: NOTE_TYPES.SUBSCRIPTION_LIST,
},
{
name: "Block List",
builder: buildBlockListEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
content: "encrypted-json",
encryption: "nip44_v2",
},
type: NOTE_TYPES.USER_BLOCK_LIST,
},
{
name: "Hashtag Preferences",
builder: buildHashtagPreferenceEvent,
params: {
pubkey: PUBKEY,
created_at: CREATED_AT,
content: "encrypted-json",
},
type: NOTE_TYPES.HASHTAG_PREFERENCES,
},
{
name: "Admin Moderation List",
builder: buildAdminListEvent,
// buildAdminListEvent takes (listKey, params)
args: ["moderation", {
pubkey: PUBKEY,
created_at: CREATED_AT,
hexPubkeys: [PUBKEY],
}],
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
},
{
name: "Admin Blacklist",
builder: buildAdminListEvent,
args: ["blacklist", {
pubkey: PUBKEY,
created_at: CREATED_AT,
hexPubkeys: [PUBKEY],
}],
type: NOTE_TYPES.ADMIN_BLACKLIST,
},
{
name: "Admin Whitelist",
builder: buildAdminListEvent,
args: ["whitelist", {
pubkey: PUBKEY,
created_at: CREATED_AT,
hexPubkeys: [PUBKEY],
}],
type: NOTE_TYPES.ADMIN_WHITELIST,
},
];
async function run() {
console.log("Running Event Schema Validation...");
let failureCount = 0;
for (const test of TESTS) {
try {
let event;
if (test.args) {
event = test.builder(...test.args);
} else {
event = test.builder(test.params);
}
const { valid, errors } = validateEventStructure(test.type, event);
if (valid) {
console.log(`[PASS] ${test.name}`);
} else {
console.error(`[FAIL] ${test.name}`);
errors.forEach((err) => console.error(` - ${err}`));
failureCount++;
}
} catch (e) {
console.error(`[ERROR] ${test.name} threw an exception:`, e);
failureCount++;
}
}
if (failureCount > 0) {
console.error(`\nValidation failed with ${failureCount} errors.`);
process.exit(1);
} else {
console.log("\nAll events validated successfully.");
process.exit(0);
}
}
run();
+2 -2
View File
@@ -28,8 +28,8 @@ module.exports = {
theme: {
extend: {
screens: {
xs: "30rem",
compact: "25rem"
xs: "480px",
compact: "400px"
},
colors: {
page: "var(--color-page)",
-260
View File
@@ -1,260 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 3.910398
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 26.765285
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 255.735327
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 31.654581
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 10.838215
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 34.798513
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 32.153884
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 21.578259
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 398.296397
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 74.173116
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 9.658917
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 9.775207
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 106.244019
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 104.182525
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 28.824807
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 148.628996
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 1.372662
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.381678
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.211323
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.255464
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.321204
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 33.821343
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 242.055409
type: 'test'
...
# Subtest: handleModerationOverride resumes deferred playback
ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 1.66123
type: 'test'
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 5.442489
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
false !== true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
actual: false
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:228:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 1.600465
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 286.782704
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
-260
View File
@@ -1,260 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 22.244233
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 36.538516
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 316.648044
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 46.776425
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 15.209442
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 36.087729
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 39.830563
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 32.84156
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 501.07657
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 73.46412
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 9.611748
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 9.764783
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 110.646914
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 99.746325
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 26.407756
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 141.088808
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 1.373918
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.408152
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.244921
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.257353
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.311313
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 32.052639
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 212.505662
type: 'test'
...
# Subtest: handleModerationOverride resumes deferred playback
ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 1.28636
type: 'test'
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 4.64235
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
false !== true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
actual: false
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:228:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 1.619904
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 250.717362
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
-261
View File
@@ -1,261 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 7.683186
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 28.640279
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 376.493065
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 39.743453
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 15.335853
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 47.515622
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 52.962084
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 27.377719
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 573.718626
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 73.304539
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 9.802311
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 10.826679
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 111.820727
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 121.895181
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 27.859075
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 166.232762
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 2.070726
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.592571
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.445862
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.5379
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.568571
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 42.558696
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
DEBUG: Test 3 Result: true
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 221.521473
type: 'test'
...
# Subtest: handleModerationOverride resumes deferred playback
ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 1.277155
type: 'test'
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 5.235509
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
false !== true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
actual: false
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:229:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 1.748986
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 263.815159
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
-260
View File
@@ -1,260 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 3.941211
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 16.455907
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 243.403366
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 31.534109
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 10.992319
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 38.203729
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 32.713928
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 20.992921
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 389.736415
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 73.866587
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 9.66665
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 9.863301
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 106.76867
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 85.299474
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 13.586181
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 110.142612
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 1.567229
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.354268
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.275984
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.274841
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.252751
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 34.328915
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 225.848661
type: 'test'
...
# Subtest: handleModerationOverride resumes deferred playback
ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 1.28016
type: 'test'
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 5.041835
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
false !== true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
actual: false
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:232:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 1.571133
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 268.845775
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
-260
View File
@@ -1,260 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 7.891372
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 22.271669
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 361.619024
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 40.463597
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 16.290213
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 42.036247
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 42.837872
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 26.383893
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 542.904828
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 82.739827
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 21.613469
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 11.465331
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 133.942749
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 101.830503
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 26.06532
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 143.762843
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 1.383277
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.414559
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.223376
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.345963
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.265368
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 31.714232
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 214.226302
type: 'test'
...
# Subtest: handleModerationOverride resumes deferred playback
ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 1.308813
type: 'test'
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 4.950644
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
false !== true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
actual: false
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:228:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 1.626768
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 254.906261
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
-260
View File
@@ -1,260 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 4.89647
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 21.69378
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 401.108042
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 46.542954
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 15.658716
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 52.358724
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 41.743826
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 26.080671
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 611.793776
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 78.541617
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 10.259655
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 10.116444
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 112.397132
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 98.541032
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 15.672259
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 131.789275
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 1.459953
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.427722
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.232365
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.295374
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.263757
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 32.877837
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 230.312105
type: 'test'
...
# Subtest: handleModerationOverride resumes deferred playback
ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 1.320201
type: 'test'
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 4.990623
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
false !== true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
actual: false
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:228:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 1.778329
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 271.042194
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
-305
View File
@@ -1,305 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 7.977402
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 22.241514
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 379.80434
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 45.289258
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 15.951023
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 45.789308
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 42.447356
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 25.597118
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 568.177635
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 75.772174
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 9.627833
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 10.030577
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 108.647336
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 118.594258
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 27.333658
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 161.988571
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 1.780936
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.469339
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.229676
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.274654
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.278603
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 35.209771
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
not ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 228.266794
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:27:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
+ actual - expected
+ undefined
- true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:67:10)
async Test.run (node:internal/test_runner/test:1054:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:296:3)
...
# Subtest: handleModerationOverride resumes deferred playback
not ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 2.019286
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:75:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
+ actual - expected
+ {
+ magnet: '',
+ triggerProvided: false,
+ url: 'https://example.com/video.mp4',
+ videoId: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'
+ }
- null
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: ~
actual:
url: 'https://example.com/video.mp4'
magnet: ''
triggerProvided: false
videoId: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:104:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 2.784959
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
+ actual - expected
+ undefined
- true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:202:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 2.057327
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 1
# fail 3
# cancelled 0
# skipped 0
# todo 0
# duration_ms 267.221529
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
-287
View File
@@ -1,287 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 4.68097
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 18.751403
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 249.242079
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 34.522355
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 11.527787
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 39.046527
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 34.441459
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 23.360676
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 404.34229
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 1009.599136
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 73.745699
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 33.538414
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1164.107182
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 123.254594
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 17.578009
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 173.980931
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 1.547337
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.411532
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.230064
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.402138
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.24381
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 48.612244
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 244.260716
type: 'test'
...
# Subtest: handleModerationOverride resumes deferred playback
not ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 3.378509
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:75:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
+ actual - expected
+ {
+ magnet: '',
+ triggerProvided: false,
+ url: 'https://example.com/video.mp4',
+ videoId: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'
+ }
- null
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: ~
actual:
url: 'https://example.com/video.mp4'
magnet: ''
triggerProvided: false
videoId: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:104:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 4.927295
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
false !== true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
actual: false
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:228:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 1.922279
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 2
# fail 2
# cancelled 0
# skipped 0
# todo 0
# duration_ms 287.75859
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
-260
View File
@@ -1,260 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 6.00491
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 32.022592
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 375.346278
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 37.760277
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 14.528741
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 47.192924
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 39.987443
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 24.837088
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 553.202787
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 73.049804
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 9.496508
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 9.841895
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 105.172847
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 91.833717
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 13.635597
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 122.580199
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 1.302074
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.407277
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.283539
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.252445
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.383152
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 34.198852
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 210.938803
type: 'test'
...
# Subtest: handleModerationOverride resumes deferred playback
ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 1.300514
type: 'test'
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 4.873851
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
false !== true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
actual: false
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:228:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 1.847791
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 253.404435
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
@@ -271,6 +271,7 @@ export async function createModerationAppHarness(options = {}) {
Application.prototype.normalizeModerationSettings;
app.getActiveModerationThresholds =
Application.prototype.getActiveModerationThresholds;
app.normalizeHexPubkey = Application.prototype.normalizeHexPubkey;
if (options.userBlocks) {
app.isAuthorBlocked = function (pubkey) {
+5 -15
View File
@@ -313,12 +313,12 @@ test("NIP-07 login falls back when structured permissions fail", async () => {
const pubkey = result.pubkey;
assert.equal(pubkey, HEX_PUBKEY);
assert.ok(env.enableCalls.length >= 2, "should retry with alternate payloads");
const [objectCall, stringCall, plainCall] = env.enableCalls;
const [objectCall, stringCall] = env.enableCalls;
assert.ok(Array.isArray(objectCall?.permissions));
assert.equal(
typeof objectCall.permissions[0],
"object",
"structured permission request should be attempted",
"structured permission request should be attempted first",
);
assert.ok(Array.isArray(stringCall?.permissions));
assert.equal(
@@ -356,22 +356,12 @@ test("NIP-07 login supports extensions that only allow enable() without payload"
assert.equal(
env.enableCalls.length,
3,
"should fall back to plain enable() after payload rejections",
"should fall back to plain enable() after explicit variants fail",
);
const [objectCall, stringCall, plainCall] = env.enableCalls;
assert.ok(Array.isArray(objectCall?.permissions));
assert.equal(
typeof objectCall.permissions[0],
"object",
"structured permissions should be attempted before falling back",
);
assert.ok(Array.isArray(stringCall?.permissions));
assert.equal(
typeof stringCall.permissions[0],
"string",
"string permissions should be attempted before plain enable()",
);
assert.equal(plainCall, undefined, "final attempt should use plain enable()");
assert.equal(plainCall, undefined, "last attempt should use plain enable()");
} finally {
env.restore();
nostrClient.logout();
@@ -411,7 +401,7 @@ test("NIP-07 login quickly retries when a permission payload stalls", async () =
duration < 500,
`login should fall back quickly from stalled enable payloads (duration: ${duration}ms)`,
);
assert.equal(env.enableCalls.length, 2, "should attempt structured and string payload variants");
assert.equal(env.enableCalls.length, 2, "should attempt structured and string payload variants before success");
const [objectCall, stringCall] = env.enableCalls;
assert.ok(Array.isArray(objectCall?.permissions));
assert.equal(typeof objectCall.permissions[0], "object");
+168
View File
@@ -0,0 +1,168 @@
import { test, describe, before, after, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { generateSecretKey, getPublicKey } from "nostr-tools";
import "../test-helpers/setup-localstorage.mjs";
// Mock globals needed for nostr-tools or other libs
if (!globalThis.crypto) {
globalThis.crypto = {
getRandomValues: (arr) => {
for (let i = 0; i < arr.length; i++) {
arr[i] = Math.floor(Math.random() * 256);
}
return arr;
}
};
}
function bytesToHex(bytes) {
return Buffer.from(bytes).toString('hex');
}
// Helper to generate 32-byte hex string
function generateHexId() {
return bytesToHex(generateSecretKey());
}
describe("dmSignalEvents", () => {
let nostrClient;
let dmSignalEvents;
let originalPool;
let originalRelays;
let originalPubkey;
let originalSessionActor;
let signer;
let getActiveSigner;
before(async () => {
// Import modules
const facadeModule = await import("../../js/nostrClientFacade.js");
const registryModule = await import("../../js/nostrClientRegistry.js");
dmSignalEvents = await import("../../js/nostr/dmSignalEvents.js");
nostrClient = facadeModule.nostrClient;
getActiveSigner = registryModule.getActiveSigner;
// Generate keys
const secret = generateSecretKey();
const privateKey = bytesToHex(secret);
const pubkey = getPublicKey(secret);
// Register signer to ensure getActiveSigner returns something
await nostrClient.registerPrivateKeySigner({ privateKey, pubkey });
signer = getActiveSigner();
});
beforeEach(() => {
originalPool = nostrClient.pool;
originalRelays = nostrClient.relays;
originalPubkey = nostrClient.pubkey;
originalSessionActor = nostrClient.sessionActor;
// Mock pool
nostrClient.pool = {
publish: mock.fn((urls, event) => {
return {
on: (event, cb) => {
if (event === 'ok') setTimeout(cb, 0);
}
};
})
};
nostrClient.relays = ["wss://relay.mock"];
nostrClient.writeRelays = ["wss://relay.mock"];
// Ensure we are logged in
nostrClient.pubkey = signer.pubkey;
// Ensure we are NOT a session actor (which blocks signals)
nostrClient.sessionActor = null;
// Mock signEvent on the signer
// We keep the original signer object but replace the method
signer.signEvent = mock.fn(async (evt) => ({ ...evt, sig: "mock-sig", id: "mock-id" }));
});
afterEach(() => {
nostrClient.pool = originalPool;
nostrClient.relays = originalRelays;
nostrClient.pubkey = originalPubkey;
nostrClient.sessionActor = originalSessionActor;
mock.reset();
});
test("publishDmReadReceipt should succeed", async () => {
const eventId = generateHexId();
const recipientPubkey = generateHexId();
const payload = {
eventId,
recipientPubkey,
messageKind: 4
};
const result = await dmSignalEvents.publishDmReadReceipt(nostrClient, payload);
assert.strictEqual(result.ok, true);
assert.strictEqual(signer.signEvent.mock.callCount(), 1);
assert.strictEqual(nostrClient.pool.publish.mock.callCount(), 1);
const signedEvent = result.event;
// Check tags
const tags = signedEvent.tags;
assert.ok(tags.find(t => t[0] === "e" && t[1] === eventId));
assert.ok(tags.find(t => t[0] === "p" && t[1] === recipientPubkey));
// Check 'k' tag for messageKind
assert.ok(tags.find(t => t[0] === "k" && t[1] === "4"));
assert.strictEqual(signedEvent.content, "");
});
test("publishDmReadReceipt should fail if session actor", async () => {
// Set sessionActor to look like the current user
nostrClient.sessionActor = { pubkey: nostrClient.pubkey };
const payload = {
eventId: generateHexId(),
recipientPubkey: generateHexId(),
messageKind: 4
};
const result = await dmSignalEvents.publishDmReadReceipt(nostrClient, payload);
assert.strictEqual(result.ok, false);
assert.strictEqual(result.error.code, "session-actor-publish-blocked");
});
test("publishDmTypingIndicator should succeed", async () => {
const recipientPubkey = generateHexId();
const conversationEventId = generateHexId();
const payload = {
recipientPubkey,
conversationEventId
};
const result = await dmSignalEvents.publishDmTypingIndicator(nostrClient, payload);
assert.strictEqual(result.ok, true);
assert.strictEqual(signer.signEvent.mock.callCount(), 1);
const signedEvent = result.event;
// Check tags
assert.ok(signedEvent.tags.find(t => t[0] === "p" && t[1] === recipientPubkey));
assert.ok(signedEvent.tags.find(t => t[0] === "e" && t[1] === conversationEventId));
});
test("should fail if no signer", async () => {
// Temporarily break signer
const originalSignEvent = signer.signEvent;
signer.signEvent = undefined;
const payload = { eventId: generateHexId(), recipientPubkey: generateHexId() };
const result = await dmSignalEvents.publishDmReadReceipt(nostrClient, payload);
assert.strictEqual(result.ok, false);
assert.strictEqual(result.error, "auth-required");
signer.signEvent = originalSignEvent;
});
});
+115
View File
@@ -0,0 +1,115 @@
import { test, describe, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import "../test-helpers/setup-localstorage.mjs";
import { SyncMetadataStore, __testExports } from "../../js/nostr/syncMetadataStore.js";
const { SYNC_METADATA_STORAGE_KEY } = __testExports;
describe("SyncMetadataStore", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
test("should load from localStorage on initialization", () => {
const data = [["key1", 1234567890]];
localStorage.setItem(SYNC_METADATA_STORAGE_KEY, JSON.stringify(data));
const store = new SyncMetadataStore();
// computeKey(kind, pubkey, dTag, relayUrl)
// We don't know the components of "key1" but it should be in the map.
// However, the class treats keys opaquely once computed.
// Let's use internal map to verify if possible, or verify via public API if we know the key components.
// Accessing private property? logic uses `this.metadata`.
// We can't easily access `this.metadata` if it's not exposed.
// But we can verify with `getLastSeen`.
// Wait, "key1" isn't a valid key format generated by computeKey, but load() just loads the map.
// getLastSeen computes the key again.
// So if we pre-populate with a valid key, we can test it.
const kind = 1;
const pubkey = "pubkey1";
const dTag = "dtag1";
const relayUrl = "wss://relay1.com";
const key = `${kind}:${pubkey}:${dTag}:${relayUrl}`;
const timestamp = 1000;
localStorage.setItem(SYNC_METADATA_STORAGE_KEY, JSON.stringify([[key, timestamp]]));
const store2 = new SyncMetadataStore();
assert.strictEqual(store2.getLastSeen(kind, pubkey, dTag, relayUrl), timestamp);
});
test("should update last seen and save to localStorage", () => {
const store = new SyncMetadataStore();
const kind = 1;
const pubkey = "pubkey1";
const dTag = "dtag1";
const relayUrl = "wss://relay1.com";
const timestamp = 2000;
store.updateLastSeen(kind, pubkey, dTag, relayUrl, timestamp);
assert.strictEqual(store.getLastSeen(kind, pubkey, dTag, relayUrl), timestamp);
const saved = JSON.parse(localStorage.getItem(SYNC_METADATA_STORAGE_KEY));
const key = `${kind}:${pubkey}:${dTag}:${relayUrl}`;
const entry = saved.find(e => e[0] === key);
assert.ok(entry);
assert.strictEqual(entry[1], timestamp);
});
test("should only update if timestamp is newer", () => {
const store = new SyncMetadataStore();
const kind = 1;
const pubkey = "pubkey1";
const dTag = "dtag1";
const relayUrl = "wss://relay1.com";
store.updateLastSeen(kind, pubkey, dTag, relayUrl, 2000);
assert.strictEqual(store.getLastSeen(kind, pubkey, dTag, relayUrl), 2000);
// Try older
store.updateLastSeen(kind, pubkey, dTag, relayUrl, 1000);
assert.strictEqual(store.getLastSeen(kind, pubkey, dTag, relayUrl), 2000);
// Try newer
store.updateLastSeen(kind, pubkey, dTag, relayUrl, 3000);
assert.strictEqual(store.getLastSeen(kind, pubkey, dTag, relayUrl), 3000);
});
test("should get per-relay last seen", () => {
const store = new SyncMetadataStore();
const kind = 1;
const pubkey = "pubkey1";
const dTag = "dtag1";
store.updateLastSeen(kind, pubkey, dTag, "wss://relay1.com", 1000);
store.updateLastSeen(kind, pubkey, dTag, "wss://relay2.com", 2000);
store.updateLastSeen(kind, pubkey, "other", "wss://relay1.com", 3000); // Should be ignored
const result = store.getPerRelayLastSeen(kind, pubkey, dTag);
assert.deepStrictEqual(result, {
"wss://relay1.com": 1000,
"wss://relay2.com": 2000
});
});
test("should handle missing localStorage gracefully", () => {
const originalLocalStorage = globalThis.localStorage;
delete globalThis.localStorage; // Simulate missing localStorage
try {
const store = new SyncMetadataStore();
store.updateLastSeen(1, "p", "d", "r", 100); // Should not throw
// Can't check persistence, but ensure no crash
} finally {
globalThis.localStorage = originalLocalStorage;
}
});
});
+166
View File
@@ -0,0 +1,166 @@
import { test, describe, before, after, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import "../test-helpers/setup-localstorage.mjs";
import * as attachmentService from "../../js/services/attachmentService.js";
// Mock URL.createObjectURL and revokeObjectURL
const originalURL = globalThis.URL;
const originalFetch = globalThis.fetch;
describe("attachmentService", () => {
before(() => {
if (!globalThis.URL) {
globalThis.URL = {};
}
});
beforeEach(() => {
globalThis.URL.createObjectURL = mock.fn(() => "blob:mock-url");
globalThis.URL.revokeObjectURL = mock.fn();
globalThis.fetch = mock.fn();
// Clear cache before each test to ensure isolation,
// but we need to be careful not to trigger revokeObjectURL calls that we count?
// Actually, we should just clear it.
attachmentService.clearAttachmentCache();
// Then reset calls
globalThis.URL.revokeObjectURL.mock.resetCalls();
});
after(() => {
if (originalURL) {
globalThis.URL = originalURL;
} else {
delete globalThis.URL;
}
if (originalFetch) {
globalThis.fetch = originalFetch;
} else {
delete globalThis.fetch;
}
});
afterEach(() => {
mock.reset();
attachmentService.clearAttachmentCache();
});
describe("prepareAttachmentUpload", () => {
test("should prepare upload without encryption", async () => {
const file = new File(["test content"], "test.txt", { type: "text/plain" });
const result = await attachmentService.prepareAttachmentUpload({ file, encrypt: false });
assert.strictEqual(result.name, "test.txt");
assert.strictEqual(result.type, "text/plain");
assert.strictEqual(result.size, 12);
assert.strictEqual(result.encrypted, false);
assert.strictEqual(result.key, "");
// sha256 of "test content"
assert.strictEqual(result.sha256, "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72");
});
test("should prepare upload with encryption", async () => {
const file = new File(["test content"], "test.txt", { type: "text/plain" });
const result = await attachmentService.prepareAttachmentUpload({ file, encrypt: true });
assert.strictEqual(result.name, "test.txt");
assert.strictEqual(result.type, "text/plain"); // Metadata preserves original type
assert.strictEqual(result.encrypted, true);
assert.ok(result.key.length > 0, "Should return a key");
assert.notStrictEqual(result.uploadBlob, file, "Should return a new blob");
assert.strictEqual(result.uploadBlob.type, "application/octet-stream"); // Actual blob is generic binary
});
});
describe("downloadAttachment", () => {
test("should download attachment", async () => {
const mockResponse = {
ok: true,
blob: async () => new Blob(["test content"]),
headers: new Map(),
};
globalThis.fetch.mock.mockImplementation(async () => mockResponse);
const result = await attachmentService.downloadAttachment({ url: "https://example.com/file" });
assert.strictEqual(globalThis.fetch.mock.callCount(), 1);
assert.strictEqual(result.objectUrl, "blob:mock-url");
assert.strictEqual(result.size, 12);
});
test("should verify hash", async () => {
const mockResponse = {
ok: true,
blob: async () => new Blob(["test content"]),
headers: new Map(),
};
globalThis.fetch.mock.mockImplementation(async () => mockResponse);
const expectedHash = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72";
await attachmentService.downloadAttachment({ url: "https://example.com/file", expectedHash });
// Should not throw
});
test("should throw on hash mismatch", async () => {
const mockResponse = {
ok: true,
blob: async () => new Blob(["test content"]),
headers: new Map(),
};
globalThis.fetch.mock.mockImplementation(async () => mockResponse);
const expectedHash = "wronghash";
await assert.rejects(
async () => {
await attachmentService.downloadAttachment({ url: "https://example.com/file", expectedHash });
},
/Attachment hash mismatch/
);
});
});
describe("caching", () => {
test("should cache downloaded attachments", async () => {
const mockResponse = {
ok: true,
blob: async () => new Blob(["test content"]),
headers: new Map(),
};
globalThis.fetch.mock.mockImplementation(async () => mockResponse);
const expectedHash = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72";
// First download
await attachmentService.downloadAttachment({ url: "https://example.com/file1", expectedHash });
assert.strictEqual(globalThis.fetch.mock.callCount(), 1);
// Second download (should be cached)
const result = await attachmentService.downloadAttachment({ url: "https://example.com/file1", expectedHash });
// Fetch should NOT be called again
assert.strictEqual(globalThis.fetch.mock.callCount(), 1);
assert.strictEqual(result.cached, true);
});
test("should clear cache", async () => {
const mockResponse = {
ok: true,
blob: async () => new Blob(["test content"]),
headers: new Map(),
};
globalThis.fetch.mock.mockImplementation(async () => mockResponse);
const expectedHash = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72";
await attachmentService.downloadAttachment({ url: "https://example.com/file", expectedHash });
assert.strictEqual(attachmentService.getAttachmentCacheStats().size, 1);
// Clear call count for revokeObjectURL before clearing cache to isolate the count
globalThis.URL.revokeObjectURL.mock.resetCalls();
attachmentService.clearAttachmentCache();
assert.strictEqual(attachmentService.getAttachmentCacheStats().size, 0);
assert.strictEqual(globalThis.URL.revokeObjectURL.mock.callCount(), 1);
});
});
});
+64
View File
@@ -0,0 +1,64 @@
import { test, describe, afterEach } from "node:test";
import assert from "node:assert";
import { createUiDom } from "../ui/helpers/jsdom-test-helpers.mjs";
// Disable network imports to prevent S3 SDK loading
globalThis.__BITVID_DISABLE_NETWORK_IMPORTS__ = true;
const s3Service = await import("../../js/services/s3Service.js");
describe("s3Service", () => {
describe("validateS3Connection", () => {
test("should validate correct configuration", () => {
const config = {
endpoint: "https://s3.example.com",
region: "us-east-1",
accessKeyId: "key",
secretAccessKey: "secret",
bucket: "my-bucket",
forcePathStyle: true,
};
const result = s3Service.validateS3Connection(config);
assert.strictEqual(result.endpoint, "https://s3.example.com");
assert.strictEqual(result.bucket, "my-bucket");
assert.ok(result.publicBaseUrl.includes("my-bucket"));
});
test("should throw on missing required fields", () => {
assert.throws(() => s3Service.validateS3Connection({}), /S3 endpoint is required/);
// Region defaults to auto, so next check is Access Key ID
assert.throws(() => s3Service.validateS3Connection({ endpoint: "x" }), /S3 access key ID is required/);
});
});
describe("getCorsOrigins", () => {
let uiDom;
const originalWindow = global.window;
afterEach(() => {
if (uiDom) {
uiDom.cleanup();
uiDom = null;
}
global.window = originalWindow;
});
test("should return current origin", () => {
uiDom = createUiDom({ url: "https://app.bitvid.invalid/" });
global.window = uiDom.window;
const origins = s3Service.getCorsOrigins();
assert.ok(origins.includes("https://app.bitvid.invalid"));
});
test("should handle localhost", () => {
uiDom = createUiDom({ url: "http://localhost:3000/" });
global.window = uiDom.window;
const origins = s3Service.getCorsOrigins();
assert.ok(origins.includes("http://localhost:3000"));
assert.ok(origins.includes("https://localhost:3000")); // Should add https version
});
});
});
+22 -5
View File
@@ -45,6 +45,11 @@ test.describe("overlay layering tokens", () => {
await dismissDisclaimerModal(page);
// Wait for initial fade-in to complete so opacity doesn't interfere with visibility checks
await page.waitForFunction(
() => !document.getElementById("sidebar")?.classList.contains("fade-in")
);
await expect(page.locator("#mobileMenuBtn")).toHaveCount(0);
const collapseToggle = page.locator("#sidebarCollapseToggle");
@@ -123,14 +128,26 @@ test.describe("overlay layering tokens", () => {
// Ensure the toggle is initialized
await expect(collapseToggle).toHaveAttribute("data-state", /.*/);
await collapseToggle.click();
await collapseToggle.click({ force: true });
await page.waitForFunction(() =>
document.getElementById("sidebar")?.classList.contains("sidebar-expanded")
);
await page.waitForFunction(() => {
const sidebar = document.getElementById("sidebar");
return (
sidebar?.classList.contains("sidebar-expanded") &&
!sidebar?.classList.contains("sidebar-collapsed")
);
});
// Wait for sidebar transition to complete
await page.waitForTimeout(500);
await page.waitForTimeout(1000);
// Ensure the footer button is scrolled into view (especially for mobile viewports)
await page.evaluate(() => {
const sidebar = document.getElementById("sidebar");
if (sidebar) {
sidebar.scrollTop = sidebar.scrollHeight;
}
});
const expandedLayout = await page.evaluate(() => {
const sidebar = document.getElementById("sidebar");
-61
View File
@@ -1,61 +0,0 @@
import { subscriptions } from './js/subscriptions.js';
import { hashtagPreferences } from './js/services/hashtagPreferencesService.js';
import { profileCache } from './js/state/profileCache.js';
// Mock profileCache.getProfileData
const originalGetProfileData = profileCache.getProfileData;
const originalGet = profileCache.get;
let calls = [];
profileCache.getProfileData = (pubkey, section) => {
calls.push({ method: 'getProfileData', pubkey, section });
return null;
};
profileCache.get = (section) => {
calls.push({ method: 'get', section });
return null;
};
async function runVerify() {
console.log("Verifying fix...");
// Test SubscriptionsManager
const subPubkey = "0000000000000000000000000000000000000000000000000000000000000001";
// We need to mock updateFromRelays to avoid network calls, or just catch error
subscriptions.updateFromRelays = async () => {};
await subscriptions.loadSubscriptions(subPubkey);
const subCall = calls.find(c => c.section === "subscriptions");
if (subCall && subCall.method === "getProfileData" && subCall.pubkey === subPubkey) {
console.log("PASS: subscriptions.loadSubscriptions used getProfileData with correct pubkey.");
} else {
console.error("FAIL: subscriptions.loadSubscriptions did not use getProfileData correctly.", calls);
process.exit(1);
}
calls = []; // Reset
// Test HashtagPreferencesService
const tagPubkey = "0000000000000000000000000000000000000000000000000000000000000002";
// Mock load logic to avoid network
hashtagPreferences.loadFromCache(tagPubkey);
const tagCall = calls.find(c => c.section === "interests");
if (tagCall && tagCall.method === "getProfileData" && tagCall.pubkey === tagPubkey) {
console.log("PASS: hashtagPreferences.loadFromCache used getProfileData with correct pubkey.");
} else {
console.error("FAIL: hashtagPreferences.loadFromCache did not use getProfileData correctly.", calls);
process.exit(1);
}
console.log("All verifications passed.");
}
runVerify().catch(err => {
console.error(err);
process.exit(1);
});
-91
View File
@@ -1,91 +0,0 @@
from playwright.sync_api import sync_playwright
import time
def verify_source_toggle():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# Navigate to the local server
page.goto("http://localhost:8080/index.html")
# Wait for the app to initialize (give it a moment)
page.wait_for_load_state("networkidle")
# We need to simulate opening the modal with a video that has both sources
# We'll use page.evaluate to interact with the Javascript context
# Assuming 'app' is globally available or we can instantiate VideoModal
page.evaluate("""
async () => {
// Mock a video object with both URL and Magnet
const mockVideo = {
id: 'test-video-123',
title: 'Test Video with Source Toggle',
url: 'https://example.com/video.mp4',
magnet: 'magnet:?xt=urn:btih:example',
torrentSupported: true,
moderation: {}
};
// Access the global app instance or find the video modal
// Looking at js/app.js, it might attach to window.app
// If not, we might need to find the modal instance another way.
// Let's assume window.app exists as is common in these setups,
// or we try to find the modal controller.
if (window.app && window.app.videoModal) {
await window.app.videoModal.open(mockVideo);
} else {
console.error("Could not find window.app.videoModal");
// Fallback: Try to find the modal element and manually unhide it
// to verify the toggle structure exists, though open() is better.
// Note: If window.app isn't exposed, we might struggle to invoke .open() directly
// without module injection.
}
}
""")
# Wait for the modal to appear
try:
page.wait_for_selector("#playerModal", state="visible", timeout=5000)
print("Modal opened successfully.")
except:
print("Modal did not open via app.videoModal. attempting manual check...")
# specific check for the toggle container
# The toggle container has attribute [data-source-toggle-container]
toggle_selector = "[data-source-toggle-container]"
# Expect it to be visible
try:
page.wait_for_selector(toggle_selector, state="visible", timeout=2000)
print("Source toggle container is visible!")
except:
print("Source toggle container NOT visible or not found.")
# If it's not visible, maybe the video didn't load right.
# Let's try to inspect the DOM to see if the element exists at all.
content = page.content()
if "data-source-toggle-container" in content:
print("Element exists in DOM but might be hidden.")
else:
print("Element does NOT exist in DOM.")
# Take a screenshot of the modal
# We can focus on the stats container where the toggle is
stats_selector = "[data-video-stats-container]"
try:
page.wait_for_selector(stats_selector, state="visible")
# Screenshot the whole page or just the modal
page.screenshot(path="/home/jules/verification/verification.png")
print("Screenshot saved.")
except Exception as e:
print(f"Could not take targeted screenshot: {e}")
page.screenshot(path="/home/jules/verification/verification_fallback.png")
browser.close()
if __name__ == "__main__":
verify_source_toggle()