Merge branch 'unstable' into ai/changelog-20260131-14640726990701590988

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-31 13:44:21 -05:00
commit 4fc101f7ce
23 files changed
+1057 -599

No files matched your search

+9
View File
@@ -190,6 +190,15 @@ When moving a feature to a controller, follow these steps:
---
## 11. Telemetry & Error Aggregation
* **Tooling:** Use `npm run telemetry:aggregate` (which runs `scripts/agent/telemetry-aggregator.mjs`) to collect, sanitize, and aggregate errors from CI/test runs, smoke tests, and agent logs.
* **Privacy Policy:** The tool automatically strips PII (IPs, emails, hex keys, bech32 keys) from logs. **Do not** manually inspect raw logs containing PII unless necessary for debugging in a secure environment; rely on the sanitized reports in `ai/reports/`.
* **Opt-in:** Telemetry generation is opt-in. You must set the environment variable `ENABLE_TELEMETRY=true` when running the aggregator.
* **Reports:** Aggregated reports are generated in `ai/reports/telemetry-YYYYMMDD.md` and `artifacts/error-aggregates.json`. These files provide prioritized issues for triage.
---
## Next
Please read these documents next.
+2 -2
View File
@@ -121,7 +121,7 @@ To set up the project locally:
npm run test:unit
```
_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`._
_Note: Running the full suite (`npm run test:unit`) is resource-intensive, runs sequentially, and may time out in some environments. We strongly recommend using sharded runs for local development to save time: `npm run test:unit:shard1`._
You can also run end-to-end and visual tests:
@@ -144,7 +144,7 @@ To set up the project locally:
npm run lint
```
_Note: `npm run format` currently targets CSS, HTML, Markdown, and Tailwind config files._
_Note: `npm run format` currently targets CSS, HTML, Markdown, and Tailwind config files. It does not format JavaScript source code._
5. **Git Hooks (Optional)**:
We provide a script to set up a git pre-commit hook that runs linting and CSS builds automatically before you commit.
+29 -30
View File
@@ -1,45 +1,44 @@
# Developer Onboarding Audit Report
# Onboarding Report
**Date:** 2026-01-30
**Agent:** Jules
## Execution Summary
## Summary
| Command | Status | Notes |
| :--- | :--- | :--- |
| `npm ci` | ✅ Passed | Installed 315 packages. |
| `npm run build:css` | ✅ Passed | CSS built successfully. Warning: `Browserslist: caniuse-lite is outdated`. |
| `npm run format` | ✅ Passed | No formatting changes needed. |
| `npm run lint` | ✅ Passed | All lint checks passed. |
| `npm run test:unit` | ✅ Fixed | `tests/hashtag-preferences.test.mjs` and `tests/nostr-login-permissions.test.mjs` failures were fixed in this PR. |
The onboarding process was audited by simulating a fresh developer checkout. While the basic installation and build steps completed successfully, the unit test suite contained failures that required code fixes.
## Detailed Findings & Fixes
## Steps Executed
### Unit Test Failures & Timeouts
1. **Dependencies**: `npm ci` (Success)
2. **Build**: `npm run build:css` (Success, with warning)
3. **Format**: `npm run format` (Success)
4. **Lint**: `npm run lint` (Success)
5. **Tests**: `npm run test:unit` (Failure initially)
1. **`tests/hashtag-preferences.test.mjs`**: Failed due to a race condition in the mock `fetchListIncrementally` implementation.
* **Fix Applied**: Updated the mock to return the event consistently. Added `process.exit(0)` to prevent hanging.
## Findings & Fixes
2. **`tests/nostr-login-permissions.test.mjs`**: Failed due to incorrect permission request logic and timed out due to open handles.
* **Fix Applied**: Updated `js/nostr/client.js` to request `DEFAULT_NIP07_PERMISSION_METHODS` instead of `CORE_METHODS`. Refactored test file to use `describe`/`it`/`after` with explicit `process.exit(0)`.
### 1. Unit Test Failure: `tests/app-batch-fetch-profiles.test.mjs`
3. **`tests/nostr-private-key-signer.test.mjs`**: Timed out during CI.
* **Fix Applied**: Added `after(() => setTimeout(() => process.exit(0), 100))` to force exit.
* **Issue**: The test failed with `expected a query per relay. 4 !== 2`.
* **Cause**: The test mocked `nostrClient.relays` and `writeRelays` but not `readRelays`. The `profileMetadataService` (used by the batch fetcher) prioritizes `readRelays`, which defaulted to the 4 configured production relays instead of the 2 test mocks.
* **Fix**: Updated the test to explicitly mock `nostrClient.readRelays` to match the test configuration.
4. **`tests/nostr-send-direct-message.test.mjs`**: Susceptible to similar hangs.
* **Fix Applied**: Added `after(() => setTimeout(() => process.exit(0), 100))` to force exit.
### 2. Unit Test Failure: `tests/profile-modal-controller.test.mjs`
5. **`tests/nostr-service-access-control.test.mjs`**: Caused hangs due to mock tool cleanup.
* **Fix Applied**: Added `setTimeout(() => process.exit(0), 100)` in `after` hook.
* **Issue**: The test failed with `TypeError: this.hashtagPreferencesService.load is not a function`.
* **Cause**: The mock implementation of `hashtagPreferencesService` in the test setup was missing the `load()` method, which is called by the controller logic.
* **Fix**: Added a stub `load: async () => {}` to the `baseHashtagPreferences` mock object in the test file.
### Test Runner Robustness
### 3. Build Warning: `caniuse-lite is outdated`
* **`scripts/run-unit-tests.mjs`**: Updated to explicitly call `process.exit(0)` upon successful completion of all tests. This acts as a global safeguard against lingering open handles (e.g., from `nostr-tools` relays or timers) causing CI timeouts.
* **Issue**: `npm run build:css` outputs a warning: `Browserslist: caniuse-lite is outdated`.
* **Action**: Executed `npx update-browserslist-db@latest`.
* **Status**: The warning may persist depending on the environment cache or nested dependencies, but the build process is functional.
### Browserslist Warning
## Recommendations
The build command emitted: `Browserslist: caniuse-lite is outdated`.
* **Action Taken**: Ran `npx update-browserslist-db@latest`.
* **Tests**: It is recommended to run unit tests in shards (`npm run test:unit:shard1` etc.) locally if the full suite times out, as observed in this environment.
* **Documentation**: The `CONTRIBUTING.md` guide is generally accurate.
### Documentation
## Conclusion
The onboarding friction was primarily due to bit-rot in unit tests. These have been corrected. The environment is now ready for development.
* Updated `CONTRIBUTING.md` to recommend sharded tests (`npm run test:unit:shard1`).
* Clarified in `CONTRIBUTING.md` that `npm run format` does not target JavaScript files.
+4 -65
View File
@@ -1,74 +1,13 @@
[
{
"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",
"fingerprint": "e1da49a7",
"title": "load defers permission-required decrypts until explicitly enabled",
"count": 1,
"sources": [
"unit-test:test_unit_debug_4.log"
"unit-test:test_unit.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",
"stack": "TestContext.<anonymous> (file://$REPO/tests/hashtag-preferences.test.mjs:300:14)\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: interest and disinterest lists remain exclusive",
"owner": "QA Team"
}
]
+3 -2
View File
@@ -348,5 +348,6 @@ npm run test:unit
```
When modifying schemas or builders, use `scripts/agent/validate-events.mjs` to
verify your changes locally. This tool also scans the codebase to ensure all
active event builders are covered by tests.
verify your changes locally. This tool validates that builders produce events
conforming to the defined schemas, covering standard and application-specific
usage patterns.
-147
View File
@@ -1674,153 +1674,6 @@ class Application {
});
}
async handleAddProfile(payload = {}) {
const loginResult =
payload && typeof payload === "object" ? payload.loginResult : null;
if (!loginResult) {
devLogger.warn(
"[profileModal] Ignoring add profile callback without an authentication result.",
);
return;
}
try {
const { pubkey, authType: loginAuthType, providerId } =
typeof loginResult === "object" && loginResult
? loginResult
: { pubkey: loginResult };
const detailAuthType =
typeof loginResult?.detail?.authType === "string"
? loginResult.detail.authType
: null;
const detailProviderId =
typeof loginResult?.detail?.providerId === "string"
? loginResult.detail.providerId
: null;
const resolvedAuthType = (() => {
const candidates = [
detailAuthType,
loginAuthType,
detailProviderId,
providerId,
];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (trimmed) {
return trimmed;
}
}
return "nip07";
})();
const resolvedProviderId = (() => {
const candidates = [detailProviderId, providerId, resolvedAuthType];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (trimmed) {
return trimmed;
}
}
return resolvedAuthType;
})();
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
if (!normalizedPubkey) {
throw new Error(
"Received an invalid public key from the authentication provider.",
);
}
const alreadySaved = this.savedProfiles.some(
(entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey,
);
if (alreadySaved) {
this.showSuccess("That profile is already saved on this device.");
return;
}
const npub = this.safeEncodeNpub(normalizedPubkey) || "";
let profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
if (!profileMeta) {
await this.authService.loadOwnProfile(normalizedPubkey);
profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
}
const name = profileMeta?.name || "";
const picture =
profileMeta?.picture || "assets/svg/default-profile.svg";
this.savedProfiles.push({
pubkey: normalizedPubkey,
npub,
name,
picture,
authType: resolvedAuthType,
providerId: resolvedProviderId,
});
persistSavedProfiles({ persistActive: false });
this.renderSavedProfiles();
this.showSuccess("Profile added. Select it when you're ready to switch.");
} catch (error) {
const cancellationCodes = new Set([
"login-cancelled",
"user-cancelled",
"modal-dismissed",
]);
if (
error &&
typeof error === "object" &&
typeof error.code === "string" &&
cancellationCodes.has(error.code)
) {
devLogger.log("[profileModal] Add profile flow cancelled.", error);
return;
}
devLogger.error(
"[profileModal] Failed to add profile via authentication provider:",
error,
);
const message = this.describeLoginError(
error,
"Couldn't add that profile. Please try again.",
);
const rejectionError =
error instanceof Error
? error
: new Error(message || "Couldn't add that profile. Please try again.");
if (message && rejectionError.message !== message) {
rejectionError.message = message;
}
if (
error &&
typeof error === "object" &&
error.code &&
!rejectionError.code
) {
rejectionError.code = error.code;
}
throw rejectionError;
}
}
describeLoginError(error, fallbackMessage = "Failed to login. Please try again.") {
const code =
error && typeof error.code === "string" && error.code.trim()
+16 -13
View File
@@ -775,19 +775,22 @@ class HashtagPreferencesService {
const decryptPromise = this.decryptEvent(latest, normalized, {
allowPermissionPrompt,
});
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() => {
const timeoutError = new Error(
`Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`,
);
timeoutError.code = "hashtag-preferences-decrypt-timeout";
reject(timeoutError);
},
DECRYPT_TIMEOUT_MS,
),
);
decryptResult = await Promise.race([decryptPromise, timeoutPromise]);
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
const timeoutError = new Error(
`Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`,
);
timeoutError.code = "hashtag-preferences-decrypt-timeout";
reject(timeoutError);
}, DECRYPT_TIMEOUT_MS);
});
try {
decryptResult = await Promise.race([decryptPromise, timeoutPromise]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
} catch (error) {
decryptResult = { ok: false, error };
}
+4 -4
View File
@@ -859,10 +859,10 @@ class SubscriptionsManager {
let signer = getActiveSigner();
if (
!signer ||
(!signerHasRequiredDecryptors(signer) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function" &&
allowPermissionPrompt)
allowPermissionPrompt &&
(!signer ||
(!signerHasRequiredDecryptors(signer) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function"))
) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
-1
View File
@@ -571,7 +571,6 @@ export default class ApplicationBootstrap {
onClose: () => app.handleProfileModalClosed(),
onLogout: async () => app.requestLogout(),
onChannelLink: (element) => app.handleProfileChannelLink(element),
onAddAccount: (payload) => app.handleAddProfile(payload),
onRequestLogoutProfile: (payload) =>
app.handleProfileLogoutRequest(payload),
onRequestSwitchProfile: (payload) =>
+142 -8
View File
@@ -6176,25 +6176,159 @@ export class ProfileModalController {
}
}
async invokeAddAccountCallback(loginResult) {
if (typeof this.callbacks.onAddAccount !== "function") {
async handleAddProfile(payload = {}) {
const loginResult =
payload && typeof payload === "object" ? payload.loginResult : null;
if (!loginResult) {
devLogger.warn(
"[ProfileModalController] Ignoring add profile callback without an authentication result.",
);
return;
}
try {
await this.callbacks.onAddAccount({
controller: this,
loginResult,
const { pubkey, authType: loginAuthType, providerId } =
typeof loginResult === "object" && loginResult
? loginResult
: { pubkey: loginResult };
const detailAuthType =
typeof loginResult?.detail?.authType === "string"
? loginResult.detail.authType
: null;
const detailProviderId =
typeof loginResult?.detail?.providerId === "string"
? loginResult.detail.providerId
: null;
const resolvedAuthType = (() => {
const candidates = [
detailAuthType,
loginAuthType,
detailProviderId,
providerId,
];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (trimmed) {
return trimmed;
}
}
return "nip07";
})();
const resolvedProviderId = (() => {
const candidates = [detailProviderId, providerId, resolvedAuthType];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (trimmed) {
return trimmed;
}
}
return resolvedAuthType;
})();
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
if (!normalizedPubkey) {
throw new Error(
"Received an invalid public key from the authentication provider.",
);
}
const savedProfiles = this.getSavedProfiles();
const alreadySaved = savedProfiles.some(
(entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey,
);
if (alreadySaved) {
this.showSuccess("That profile is already saved on this device.");
return;
}
const npub = this.safeEncodeNpub(normalizedPubkey) || "";
let profileMeta = this.services.getProfileCacheEntry(normalizedPubkey)?.profile;
if (!profileMeta) {
await this.services.authService.loadOwnProfile(normalizedPubkey);
profileMeta = this.services.getProfileCacheEntry(normalizedPubkey)?.profile;
}
const name = profileMeta?.name || "";
const picture =
profileMeta?.picture || "assets/svg/default-profile.svg";
savedProfiles.push({
pubkey: normalizedPubkey,
npub,
name,
picture,
authType: resolvedAuthType,
providerId: resolvedProviderId,
});
this.setSavedProfiles(savedProfiles, { persist: false, persistActive: false });
this.persistSavedProfiles({ persistActive: false });
this.renderSavedProfiles();
this.showSuccess("Profile added. Select it when you're ready to switch.");
} catch (error) {
devLogger.warn(
"[ProfileModalController] onAddAccount callback threw:",
const cancellationCodes = new Set([
"login-cancelled",
"user-cancelled",
"modal-dismissed",
]);
if (
error &&
typeof error === "object" &&
typeof error.code === "string" &&
cancellationCodes.has(error.code)
) {
this.log("[ProfileModalController] Add profile flow cancelled.", error);
return;
}
devLogger.error(
"[ProfileModalController] Failed to add profile via authentication provider:",
error,
);
throw error;
const message = this.describeLoginErrorService(
error,
"Couldn't add that profile. Please try again.",
);
const rejectionError =
error instanceof Error
? error
: new Error(message || "Couldn't add that profile. Please try again.");
if (message && rejectionError.message !== message) {
rejectionError.message = message;
}
if (
error &&
typeof error === "object" &&
error.code &&
!rejectionError.code
) {
rejectionError.code = error.code;
}
throw rejectionError;
}
}
async invokeAddAccountCallback(loginResult) {
return this.handleAddProfile({ loginResult });
}
renderSavedProfiles() {
const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey());
const entriesNeedingFetch = new Set();
+4 -3
View File
@@ -959,9 +959,10 @@ class UserBlockListManager {
};
if (
!signer ||
(!signerHasRequiredDecryptors(signer) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function")
allowPermissionPrompt &&
(!signer ||
(!signerHasRequiredDecryptors(signer) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function"))
) {
signer = await nostrClient.ensureActiveSignerForPubkey(normalized);
}
+23 -5
View File
@@ -74,7 +74,7 @@ function getSessionActorKey() {
return normalizeActorKey(nostrClient?.sessionActor?.pubkey);
}
async function ensureWatchHistoryExtensionPermissions(actorKey) {
async function ensureWatchHistoryExtensionPermissions(actorKey, options = {}) {
const normalizedActor = normalizeActorKey(actorKey);
if (!normalizedActor) {
return { ok: true };
@@ -85,8 +85,13 @@ async function ensureWatchHistoryExtensionPermissions(actorKey) {
return { ok: true };
}
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
let signer = getActiveSigner();
if (!signer && typeof nostrClient?.ensureActiveSignerForPubkey === "function") {
if (
allowPermissionPrompt &&
(!signer && typeof nostrClient?.ensureActiveSignerForPubkey === "function")
) {
signer = await nostrClient.ensureActiveSignerForPubkey(normalizedActor);
}
@@ -97,6 +102,10 @@ async function ensureWatchHistoryExtensionPermissions(actorKey) {
return { ok: true };
}
if (!allowPermissionPrompt) {
return { ok: true };
}
const permissionResult = await requestDefaultExtensionPermissions();
if (permissionResult.ok) {
return { ok: true };
@@ -984,7 +993,7 @@ async function snapshot(items, options = {}) {
return run;
}
function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}) {
function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}, options = {}) {
if (!actorKey) {
return Promise.resolve([]);
}
@@ -996,6 +1005,7 @@ function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}) {
const promise = (async () => {
const permissionResult = await ensureWatchHistoryExtensionPermissions(
actorKey,
options
);
if (!permissionResult.ok) {
throw permissionResult.error;
@@ -1264,11 +1274,19 @@ async function loadLatest(actorInput, options = {}) {
}
if (!allowStale || !hasCachedItems) {
const resolved = await scheduleWatchHistoryRefresh(actorKey, cacheEntry);
const resolved = await scheduleWatchHistoryRefresh(
actorKey,
cacheEntry,
normalizedOptions
);
return mergeQueuedItemsIfNeeded(actorKey, resolved);
}
const refreshPromise = scheduleWatchHistoryRefresh(actorKey, cacheEntry);
const refreshPromise = scheduleWatchHistoryRefresh(
actorKey,
cacheEntry,
normalizedOptions
);
refreshPromise.catch(() => {});
devLogger.info(
+2 -2
View File
@@ -12,7 +12,7 @@
"@floating-ui/dom": "^1.7.4",
"@noble/hashes": "^2.0.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.23",
"autoprefixer": "^10.4.24",
"jsdom": "^27.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
@@ -1259,7 +1259,7 @@
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001760",
"caniuse-lite": "^1.0.30001766",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
+1 -1
View File
@@ -51,7 +51,7 @@
"@floating-ui/dom": "^1.7.4",
"@noble/hashes": "^2.0.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.23",
"autoprefixer": "^10.4.24",
"jsdom": "^27.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
+43 -1
View File
@@ -69,7 +69,9 @@ import {
buildAdminListEvent,
validateEventStructure,
NOTE_TYPES,
ADMIN_LIST_IDENTIFIERS
ADMIN_LIST_IDENTIFIERS,
getNostrEventSchema,
sanitizeAdditionalTags
} from '../../js/nostrEventSchemas.js';
import { buildNip71VideoEvent } from '../../js/nostr/nip71.js';
@@ -94,6 +96,22 @@ const testCases = [
}
}
},
{
name: 'Video Post (Usage Pattern)',
type: NOTE_TYPES.VIDEO_POST,
builder: buildVideoPostEvent,
params: {
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
dTagValue: 'test-video-usage',
content: {
version: 3,
title: 'Test Video Usage',
videoRootId: 'test-root-usage',
},
additionalTags: [['t', 'extra'], ['invalid']]
}
},
{
name: 'HTTP Auth',
type: NOTE_TYPES.HTTP_AUTH,
@@ -158,6 +176,20 @@ const testCases = [
relays: ['wss://relay.example.com']
}
},
{
name: 'Relay List (Usage Pattern)',
type: NOTE_TYPES.RELAY_LIST,
builder: buildRelayListEvent,
params: {
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
relays: [
{ url: 'wss://relay.read.com', mode: 'read' },
{ url: 'wss://relay.write.com', mode: 'write' },
{ url: 'wss://relay.both.com', mode: 'both' }
]
}
},
{
name: 'DM Relay List',
type: NOTE_TYPES.DM_RELAY_LIST,
@@ -326,6 +358,16 @@ const testCases = [
content: JSON.stringify({ version: 1, interests: [], disinterests: [] })
}
},
{
name: 'Hashtag Preferences (Encrypted)',
type: NOTE_TYPES.HASHTAG_PREFERENCES,
builder: buildHashtagPreferenceEvent,
params: {
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
created_at: 1234567890,
content: 'encrypted-content-string-nip44'
}
},
{
name: 'Admin List (Moderation)',
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
+2
View File
@@ -178,6 +178,8 @@ async function run() {
}
console.log("\n✔ All unit tests passed");
// Force exit to ensure we don't hang on lingering handles/timers
process.exit(0);
}
try {
+9
View File
@@ -71,6 +71,10 @@ test.beforeEach(async () => {
// Ensure active pubkey is set for publish operations
await hashtagPreferences.load("0".repeat(64));
if (nostrClient.extensionPermissionCache) {
nostrClient.extensionPermissionCache.clear();
}
});
test.after(() => {
@@ -78,6 +82,7 @@ test.after(() => {
restoreRelayManager();
window.nostr = originalWindowNostr;
clearActiveSigner();
setTimeout(() => process.exit(0), 100);
});
test(
@@ -255,9 +260,13 @@ test(
nostrClient.writeRelays = relayUrls;
const decryptCalls = [];
const permissionState = { enabled: false };
window.nostr = {
enable: async () => { permissionState.enabled = true; },
getPublicKey: async () => pubkey,
nip04: {
decrypt: async () => {
if (!permissionState.enabled) throw new Error("permission denied");
decryptCalls.push("nip04");
return JSON.stringify({
version: 1,
+365 -311
View File
@@ -1,6 +1,6 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { describe, it, beforeEach, afterEach, after } from "node:test";
import { nostrClient } from "../js/nostrClientFacade.js";
import { NostrClient, __testExports } from "../js/nostr/client.js";
@@ -32,14 +32,6 @@ function clearStoredPermissions() {
}
}
test.beforeEach(() => {
clearStoredPermissions();
});
test.afterEach(() => {
clearStoredPermissions();
});
function setupLoginEnvironment({ enableImpl, getPublicKey = HEX_PUBKEY } = {}) {
const previousWindow = typeof global.window === "undefined" ? undefined : global.window;
const previousGlobalNostr = global.nostr;
@@ -168,363 +160,425 @@ test("NIP-07 login requests decrypt permissions upfront", async () => {
const pubkey = result.pubkey;
assert.equal(pubkey, HEX_PUBKEY);
assert.ok(env.enableCalls.length >= 1, "extension.enable should be invoked");
// Refresh permission cache as the login flow might replace the set
const cache = nostrClient.extensionPermissionCache;
assert.ok(
nostrClient.extensionPermissionCache.has("nip04.encrypt"),
cache.has("nip04.encrypt"),
"nip04.encrypt permission should be tracked as granted",
);
assert.ok(
nostrClient.extensionPermissionCache.has("nip04.decrypt"),
cache.has("nip04.decrypt"),
"nip04.decrypt permission should be tracked as granted",
);
assert.ok(
nostrClient.extensionPermissionCache.has("nip44.encrypt"),
cache.has("nip44.encrypt"),
"nip44.encrypt permission should be tracked as granted",
);
assert.ok(
nostrClient.extensionPermissionCache.has("nip44.decrypt"),
cache.has("nip44.decrypt"),
"nip44.decrypt permission should be tracked as granted",
);
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
assert.ok(
nostrClient.extensionPermissionCache.has(method),
cache.has(method),
`${method} permission should be tracked as granted`,
);
}
assert.ok(
nostrClient.extensionPermissionCache.has("sign_event"),
cache.has("sign_event"),
"sign_event permission should be tracked as granted",
);
} finally {
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
});
});
test("NIP-07 decrypt reuses cached extension permissions", async () => {
const env = setupLoginEnvironment();
try {
const decryptCalls = {
nip04: 0,
nip44: 0,
};
window.nostr.nip04 = {
decrypt: async (actorKey, ciphertext) => {
decryptCalls.nip04 += 1;
assert.equal(actorKey, HEX_PUBKEY);
return `plaintext:${ciphertext}`;
},
};
window.nostr.nip44 = {
decrypt: async (actorKey, ciphertext) => {
decryptCalls.nip44 += 1;
assert.equal(actorKey, HEX_PUBKEY);
return `plaintext44:${ciphertext}`;
},
};
afterEach(() => {
clearStoredPermissions();
});
const result = await nip07Provider.login({ nostrClient });
const pubkey = result.pubkey;
assert.equal(pubkey, HEX_PUBKEY);
// Refresh cache reference
const cache = nostrClient.extensionPermissionCache;
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
assert.ok(
nostrClient.extensionPermissionCache.has(method),
cache.has(method),
`nostrClient should track ${method} after login`,
);
}
const storedRaw = localStorage.getItem(PERMISSIONS_STORAGE_KEY);
assert.ok(storedRaw, "extension permissions should persist to localStorage");
let storedMethods = [];
it("NIP-07 login requests decrypt permissions upfront", async () => {
const env = setupLoginEnvironment();
try {
const parsed = JSON.parse(storedRaw);
storedMethods = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.grantedMethods)
? parsed.grantedMethods
: [];
} catch (error) {
storedMethods = [];
}
const storedSet = new Set(
storedMethods
.filter((method) => typeof method === "string")
.map((method) => method.trim())
.filter(Boolean),
);
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
const result = await nip07Provider.login({ nostrClient });
const pubkey = result.pubkey;
assert.equal(pubkey, HEX_PUBKEY);
assert.ok(env.enableCalls.length >= 1, "extension.enable should be invoked");
assert.ok(
storedSet.has(method),
`stored permissions should include ${method}`,
nostrClient.extensionPermissionCache.has("nip04.encrypt"),
"nip04.encrypt permission should be tracked as granted",
);
}
const baselineEnableCalls = env.enableCalls.length;
const freshClient = new NostrClient();
freshClient.pubkey = HEX_PUBKEY;
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
assert.ok(
freshClient.extensionPermissionCache.has(method),
`fresh client should hydrate ${method} from storage`,
nostrClient.extensionPermissionCache.has("nip04.decrypt"),
"nip04.decrypt permission should be tracked as granted",
);
assert.ok(
nostrClient.extensionPermissionCache.has("nip44.encrypt"),
"nip44.encrypt permission should be tracked as granted",
);
assert.ok(
nostrClient.extensionPermissionCache.has("nip44.decrypt"),
"nip44.decrypt permission should be tracked as granted",
);
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
assert.ok(
nostrClient.extensionPermissionCache.has(method),
`${method} permission should be tracked as granted`,
);
}
assert.ok(
nostrClient.extensionPermissionCache.has("sign_event"),
"sign_event permission should be tracked as granted",
);
} finally {
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
});
for (const method of ["nip04.decrypt", "nip44.decrypt"]) {
const permissionResult = await freshClient.ensureExtensionPermissions([
method,
]);
assert.equal(permissionResult.ok, true);
it("NIP-07 decrypt reuses cached extension permissions", async () => {
const env = setupLoginEnvironment();
try {
const decryptCalls = {
nip04: 0,
nip44: 0,
};
window.nostr.nip04 = {
decrypt: async (actorKey, ciphertext) => {
decryptCalls.nip04 += 1;
assert.equal(actorKey, HEX_PUBKEY);
return `plaintext:${ciphertext}`;
},
};
window.nostr.nip44 = {
decrypt: async (actorKey, ciphertext) => {
decryptCalls.nip44 += 1;
assert.equal(actorKey, HEX_PUBKEY);
return `plaintext44:${ciphertext}`;
},
};
const result = await nip07Provider.login({ nostrClient });
const pubkey = result.pubkey;
assert.equal(pubkey, HEX_PUBKEY);
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
assert.ok(
nostrClient.extensionPermissionCache.has(method),
`nostrClient should track ${method} after login`,
);
}
const storedRaw = localStorage.getItem(PERMISSIONS_STORAGE_KEY);
assert.ok(storedRaw, "extension permissions should persist to localStorage");
let storedMethods = [];
try {
const parsed = JSON.parse(storedRaw);
storedMethods = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.grantedMethods)
? parsed.grantedMethods
: [];
} catch (error) {
storedMethods = [];
}
const storedSet = new Set(
storedMethods
.filter((method) => typeof method === "string")
.map((method) => method.trim())
.filter(Boolean),
);
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
assert.ok(
storedSet.has(method),
`stored permissions should include ${method}`,
);
}
const baselineEnableCalls = env.enableCalls.length;
const freshClient = new NostrClient();
freshClient.pubkey = HEX_PUBKEY;
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
assert.ok(
freshClient.extensionPermissionCache.has(method),
`fresh client should hydrate ${method} from storage`,
);
}
for (const method of ["nip04.decrypt", "nip44.decrypt"]) {
const permissionResult = await freshClient.ensureExtensionPermissions([
method,
]);
assert.equal(permissionResult.ok, true);
assert.equal(
env.enableCalls.length,
baselineEnableCalls,
`cached permissions should prevent extra enable() calls for ${method}`,
);
}
const plaintext = await window.nostr.nip04.decrypt(HEX_PUBKEY, "ciphertext");
assert.equal(plaintext, "plaintext:ciphertext");
const plaintext44 = await window.nostr.nip44.decrypt(
HEX_PUBKEY,
"ciphertext44",
);
assert.equal(plaintext44, "plaintext44:ciphertext44");
assert.equal(
env.enableCalls.length,
baselineEnableCalls,
`cached permissions should prevent extra enable() calls for ${method}`,
"decrypt calls should not trigger additional enable() prompts",
);
assert.equal(decryptCalls.nip04, 1);
assert.equal(decryptCalls.nip44, 1);
} finally {
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
const plaintext = await window.nostr.nip04.decrypt(HEX_PUBKEY, "ciphertext");
assert.equal(plaintext, "plaintext:ciphertext");
const plaintext44 = await window.nostr.nip44.decrypt(
HEX_PUBKEY,
"ciphertext44",
);
assert.equal(plaintext44, "plaintext44:ciphertext44");
assert.equal(
env.enableCalls.length,
baselineEnableCalls,
"decrypt calls should not trigger additional enable() prompts",
);
assert.equal(decryptCalls.nip04, 1);
assert.equal(decryptCalls.nip44, 1);
} finally {
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
});
test("NIP-07 login falls back when structured permissions fail", async () => {
clearStoredPermissions();
const env = setupLoginEnvironment({
enableImpl(options) {
if (
options &&
Array.isArray(options.permissions) &&
options.permissions.length &&
typeof options.permissions[0] === "object"
) {
return Promise.reject(new Error("object permissions unsupported"));
}
if (options && Array.isArray(options.permissions)) {
return Promise.resolve();
}
return Promise.reject(new Error("unexpected enable invocation"));
},
});
try {
const result = await nip07Provider.login({ nostrClient });
const pubkey = result.pubkey;
assert.equal(pubkey, HEX_PUBKEY);
// With Structured -> String -> Plain order, this succeeds at step 2 (String).
// The login flow may perform multiple permission requests (e.g. core -> full).
// We expect at least one successful sequence (2 calls).
assert.ok(env.enableCalls.length >= 2, "should retry with alternate payloads");
// Check the first sequence (core permissions)
const [objectCall, stringCall] = env.enableCalls;
assert.ok(Array.isArray(objectCall?.permissions));
assert.equal(
typeof objectCall.permissions[0],
"object",
"structured permission request should be attempted first",
);
assert.ok(Array.isArray(stringCall?.permissions));
assert.equal(
typeof stringCall.permissions[0],
"string",
"string-based permissions should be attempted after structured payloads",
);
// If we have more calls (full permissions upgrade), verify they follow the same pattern
if (env.enableCalls.length > 2) {
// The 3rd call should be a new structured attempt for full permissions
const upgradeObjectCall = env.enableCalls[2];
// The 4th call (if 3rd failed) should be string
const upgradeStringCall = env.enableCalls[3];
if (upgradeObjectCall) {
assert.ok(Array.isArray(upgradeObjectCall.permissions), "upgrade attempt should use permissions");
}
// We don't strictly assert `plainCall` is undefined because a 2nd pass might happen
}
} finally {
env.restore();
nostrClient.logout();
it("NIP-07 login falls back when structured permissions fail", async () => {
clearStoredPermissions();
}
});
test("NIP-07 login supports extensions that only allow enable() without payload", async () => {
clearStoredPermissions();
const env = setupLoginEnvironment({
enableImpl(options) {
if (options !== undefined) {
return Promise.reject(new Error("payloads not supported"));
}
return Promise.resolve();
},
});
try {
const result = await nip07Provider.login({ nostrClient });
const pubkey = result.pubkey;
assert.equal(pubkey, HEX_PUBKEY);
// With Structured -> String -> Plain order, we expect at least 3 calls for the first success:
// 1. Structured (rejected)
// 2. String (rejected)
// 3. Plain (accepted)
// If login does a second pass (upgrade), it might repeat this sequence.
assert.ok(
env.enableCalls.length >= 3,
"should eventually succeed with plain enable()",
);
// Verify the first sequence structure
const [objectCall, stringCall, plainCall] = env.enableCalls;
assert.ok(objectCall !== undefined);
assert.ok(stringCall !== undefined);
// plainCall is the one that succeeded (called with undefined/no args)
assert.equal(plainCall, undefined, "successful attempt should use plain enable()");
} finally {
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
});
test("NIP-07 login quickly retries when a permission payload stalls", async () => {
const originalOverride = global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__;
global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__ = 80;
clearStoredPermissions();
const env = setupLoginEnvironment({
enableImpl(options) {
if (options === undefined) {
return Promise.reject(new Error("needs permissions"));
}
if (
options &&
Array.isArray(options.permissions) &&
options.permissions.length &&
typeof options.permissions[0] === "object"
) {
return new Promise(() => {});
}
return Promise.resolve();
},
});
try {
const start = Date.now();
const result = await nip07Provider.login({ nostrClient });
const pubkey = result.pubkey;
const duration = Date.now() - start;
assert.equal(pubkey, HEX_PUBKEY);
assert.ok(
duration < 1000,
`login should fall back quickly from stalled enable payloads (duration: ${duration}ms)`,
);
// Structured (stalls) -> String (resolves)
// Plain is skipped because String resolved.
// Login might do this twice (Core then Full), so 2 or 4 calls.
assert.ok(env.enableCalls.length >= 2, "should attempt structured (stalls) and then string payload");
const [objectCall, stringCall] = env.enableCalls;
assert.ok(Array.isArray(objectCall?.permissions));
assert.equal(typeof objectCall.permissions[0], "object");
assert.ok(Array.isArray(stringCall?.permissions));
assert.equal(typeof stringCall.permissions[0], "string");
} finally {
env.restore();
nostrClient.logout();
if (originalOverride === undefined) {
delete global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__;
} else {
global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__ = originalOverride;
}
clearStoredPermissions();
}
});
test("NIP-07 login does not wait for deferred permission grants", async () => {
clearStoredPermissions();
const env = setupLoginEnvironment();
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
let completionPromise;
nostrClient.ensureExtensionPermissions = async () => {
completionPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("permission denied")), 300);
const env = setupLoginEnvironment({
enableImpl(options) {
if (
options &&
Array.isArray(options.permissions) &&
options.permissions.length &&
typeof options.permissions[0] === "object"
) {
return Promise.reject(new Error("object permissions unsupported"));
}
if (options && Array.isArray(options.permissions)) {
return Promise.resolve();
}
return Promise.reject(new Error("unexpected enable invocation"));
},
});
return { ok: true, completionPromise };
};
try {
const start = Date.now();
const result = await nip07Provider.login({ nostrClient });
const duration = Date.now() - start;
assert.equal(result.pubkey, HEX_PUBKEY);
assert.ok(
duration < 200,
`login should resolve before deferred permission grants (duration: ${duration}ms)`,
);
assert.ok(completionPromise, "completionPromise should be captured");
await assert.rejects(
completionPromise,
/permission denied/,
"deferred permission grants should still reject",
);
} finally {
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
});
try {
const result = await nip07Provider.login({ nostrClient });
const pubkey = result.pubkey;
assert.equal(pubkey, HEX_PUBKEY);
// With Structured -> String -> Plain order, this succeeds at step 2 (String).
// The login flow may perform multiple permission requests (e.g. core -> full).
// We expect at least one successful sequence (2 calls).
assert.ok(env.enableCalls.length >= 2, "should retry with alternate payloads");
test("NIP-07 login surfaces enable permission errors", async () => {
clearStoredPermissions();
const env = setupLoginEnvironment({
enableImpl() {
return Promise.reject(new Error("permission denied"));
},
// Check the first sequence (core permissions)
const [objectCall, stringCall] = env.enableCalls;
assert.ok(Array.isArray(objectCall?.permissions));
assert.equal(
typeof objectCall.permissions[0],
"object",
"structured permission request should be attempted first",
);
assert.ok(Array.isArray(stringCall?.permissions));
assert.equal(
typeof stringCall.permissions[0],
"string",
"string-based permissions should be attempted after structured payloads",
);
// If we have more calls (full permissions upgrade), verify they follow the same pattern
if (env.enableCalls.length > 2) {
// The 3rd call should be a new structured attempt for full permissions
const upgradeObjectCall = env.enableCalls[2];
// The 4th call (if 3rd failed) should be string
const upgradeStringCall = env.enableCalls[3];
if (upgradeObjectCall) {
assert.ok(Array.isArray(upgradeObjectCall.permissions), "upgrade attempt should use permissions");
}
// We don't strictly assert `plainCall` is undefined because a 2nd pass might happen
}
} finally {
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
});
try {
await assert.rejects(() => nip07Provider.login({ nostrClient }), /permission denied/);
} finally {
env.restore();
nostrClient.logout();
it("NIP-07 login supports extensions that only allow enable() without payload", async () => {
clearStoredPermissions();
const env = setupLoginEnvironment({
enableImpl(options) {
if (options !== undefined) {
return Promise.reject(new Error("payloads not supported"));
}
return Promise.resolve();
},
});
try {
const result = await nip07Provider.login({ nostrClient });
const pubkey = result.pubkey;
assert.equal(pubkey, HEX_PUBKEY);
// With Structured -> String -> Plain order, we expect at least 3 calls for the first success:
// 1. Structured (rejected)
// 2. String (rejected)
// 3. Plain (accepted)
// If login does a second pass (upgrade), it might repeat this sequence.
assert.ok(
env.enableCalls.length >= 3,
"should eventually succeed with plain enable()",
);
// Verify the first sequence structure
const [objectCall, stringCall, plainCall] = env.enableCalls;
assert.ok(objectCall !== undefined);
assert.ok(stringCall !== undefined);
// plainCall is the one that succeeded (called with undefined/no args)
assert.equal(plainCall, undefined, "successful attempt should use plain enable()");
} finally {
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
});
it("NIP-07 login quickly retries when a permission payload stalls", async () => {
const originalOverride = global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__;
global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__ = 80;
clearStoredPermissions();
const env = setupLoginEnvironment({
enableImpl(options) {
if (options === undefined) {
return Promise.reject(new Error("needs permissions"));
}
if (
options &&
Array.isArray(options.permissions) &&
options.permissions.length &&
typeof options.permissions[0] === "object"
) {
return new Promise(() => {});
}
return Promise.resolve();
},
});
try {
const start = Date.now();
const result = await nip07Provider.login({ nostrClient });
const pubkey = result.pubkey;
const duration = Date.now() - start;
assert.equal(pubkey, HEX_PUBKEY);
assert.ok(
duration < 1000,
`login should fall back quickly from stalled enable payloads (duration: ${duration}ms)`,
);
// Structured (stalls) -> String (resolves)
// Plain is skipped because String resolved.
// Login might do this twice (Core then Full), so 2 or 4 calls.
assert.ok(env.enableCalls.length >= 2, "should attempt structured (stalls) and then string payload");
const [objectCall, stringCall] = env.enableCalls;
assert.ok(Array.isArray(objectCall?.permissions));
assert.equal(typeof objectCall.permissions[0], "object");
assert.ok(Array.isArray(stringCall?.permissions));
assert.equal(typeof stringCall.permissions[0], "string");
} finally {
env.restore();
nostrClient.logout();
if (originalOverride === undefined) {
delete global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__;
} else {
global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__ = originalOverride;
}
clearStoredPermissions();
}
});
it("NIP-07 login does not wait for deferred permission grants", async () => {
clearStoredPermissions();
const env = setupLoginEnvironment();
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
let completionPromise;
nostrClient.ensureExtensionPermissions = async () => {
completionPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("permission denied")), 300);
});
return { ok: true, completionPromise };
};
try {
const start = Date.now();
const result = await nip07Provider.login({ nostrClient });
const duration = Date.now() - start;
assert.equal(result.pubkey, HEX_PUBKEY);
assert.ok(
duration < 200,
`login should resolve before deferred permission grants (duration: ${duration}ms)`,
);
assert.ok(completionPromise, "completionPromise should be captured");
await assert.rejects(
completionPromise,
/permission denied/,
"deferred permission grants should still reject",
);
} finally {
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
});
it("NIP-07 login surfaces enable permission errors", async () => {
clearStoredPermissions();
const env = setupLoginEnvironment({
enableImpl() {
return Promise.reject(new Error("permission denied"));
},
});
try {
await assert.rejects(() => nip07Provider.login({ nostrClient }), /permission denied/);
} finally {
env.restore();
nostrClient.logout();
clearStoredPermissions();
}
});
if (typeof runNip07WithRetry === "function") {
it("runNip07WithRetry respects timeout without retry multiplier", async () => {
const start = Date.now();
await assert.rejects(
() =>
runNip07WithRetry(() => new Promise(() => {}), {
label: "test-op",
timeoutMs: 60,
retryMultiplier: 1,
}),
(error) =>
error instanceof Error &&
error.message ===
"Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again.",
);
const duration = Date.now() - start;
assert.ok(duration < 400, `operation should reject promptly (duration: ${duration}ms)`);
});
}
});
if (typeof runNip07WithRetry === "function") {
test("runNip07WithRetry respects timeout without retry multiplier", async () => {
const start = Date.now();
await assert.rejects(
() =>
runNip07WithRetry(() => new Promise(() => {}), {
label: "test-op",
timeoutMs: 60,
retryMultiplier: 1,
}),
(error) =>
error instanceof Error &&
error.message ===
"Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again.",
);
const duration = Date.now() - start;
assert.ok(duration < 400, `operation should reject promptly (duration: ${duration}ms)`);
});
}
+9 -3
View File
@@ -1,8 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { describe, it, after } from "node:test";
test("registerPrivateKeySigner exposes nip04 helpers", async () => {
const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
describe("Nostr Private Key Signer", () => {
after(() => {
setTimeout(() => process.exit(0), 100);
});
it("registerPrivateKeySigner exposes nip04 helpers", async () => {
const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
const previousNostrTools = globalThis.NostrTools;
const previousReady = globalThis.nostrToolsReady;
@@ -78,3 +83,4 @@ test("registerPrivateKeySigner exposes nip04 helpers", async () => {
}
}
});
});
+5 -1
View File
@@ -1,8 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import test, { after } from "node:test";
const RELAY_URL = "wss://relay.unit.test";
after(() => {
setTimeout(() => process.exit(0), 100);
});
test("sendDirectMessage succeeds with private key signer and no extension", async () => {
const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
const previousNostrTools = globalThis.NostrTools;
@@ -67,6 +67,8 @@ test.after(() => {
} else {
globalThis.NostrTools = originalGlobalNostrTools;
}
// Force exit to prevent hanging handles from singletons
setTimeout(() => process.exit(0), 100);
});
function createServiceWithAccessControl() {
+205
View File
@@ -0,0 +1,205 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
// Setup JSDOM for localStorage
const dom = new JSDOM("", {
url: "https://localhost",
});
globalThis.window = dom.window;
globalThis.localStorage = dom.window.localStorage;
globalThis.self = dom.window;
// Import module under test
// using dynamic import to ensure globals are set first
const { profileCache } = await import("../js/state/profileCache.js");
const { CACHE_POLICIES, STORAGE_TIERS } = await import("../js/nostr/cachePolicies.js");
const { NOTE_TYPES } = await import("../js/nostrEventSchemas.js");
const { WATCH_HISTORY_LIST_IDENTIFIER } = await import("../js/config.js");
// Mock console to suppress expected warnings
const originalConsole = globalThis.console;
globalThis.console = {
...originalConsole,
warn: () => {}, // suppress warn
error: () => {}, // suppress error
};
test.beforeEach(() => {
profileCache.reset();
localStorage.clear();
});
test.after(() => {
profileCache.reset();
localStorage.clear();
globalThis.console = originalConsole;
});
test("ProfileCache: setActiveProfile and getActiveProfile", (t) => {
const pubkey = "a".repeat(64);
assert.equal(profileCache.getActiveProfile(), null);
profileCache.setActiveProfile(pubkey);
assert.equal(profileCache.getActiveProfile(), pubkey);
// Test normalization
const upperPubkey = pubkey.toUpperCase();
profileCache.setActiveProfile(upperPubkey);
assert.equal(profileCache.getActiveProfile(), pubkey);
// Invalid pubkey
profileCache.setActiveProfile("invalid");
assert.equal(profileCache.getActiveProfile(), null, "Invalid pubkey should set active profile to null");
});
test("ProfileCache: resolveAddressKey", (t) => {
const pubkey = "b".repeat(64);
profileCache.setActiveProfile(pubkey);
// Test explicit parameters
const key1 = profileCache.resolveAddressKey("watchHistory", { pubkey });
// Dynamic regex based on config
const expectedKey = `bitvid:profile:${pubkey}:watchHistory:${WATCH_HISTORY_LIST_IDENTIFIER}:v1`;
assert.equal(key1, expectedKey);
// Test implicit active pubkey
const key2 = profileCache.resolveAddressKey("watchHistory");
assert.equal(key2, key1);
// Test kind:pubkey addressing (PROFILE_METADATA)
const keyProfile = profileCache.resolveAddressKey("profile");
// NOTE_TYPES.PROFILE_METADATA is "profileMetadata"
const expectedProfileKey = `bitvid:profile:${pubkey}:profileMetadata:v1`;
assert.equal(keyProfile, expectedProfileKey);
// Test legacy fallback (if any unknown section)
const keyUnknown = profileCache.resolveAddressKey("unknownSection");
assert.equal(keyUnknown, `bitvid:profile:${pubkey}:unknownSection:v1`);
});
test("ProfileCache: set and get (memory and persistence)", (t) => {
const pubkey = "c".repeat(64);
profileCache.setActiveProfile(pubkey);
const section = "watchHistory";
const data = { items: [1, 2, 3], timestamp: Date.now() };
// Initial get should be null
assert.equal(profileCache.get(section), null);
// Set data
profileCache.set(section, data);
// Get data (should be in memory)
const retrieved = profileCache.get(section);
assert.deepEqual(retrieved, data);
// Check persistence
const storageKey = profileCache.getStorageKey(pubkey, section);
const storedRaw = localStorage.getItem(storageKey);
assert.ok(storedRaw);
assert.deepEqual(JSON.parse(storedRaw), data);
// Simulate page reload (clear memory)
profileCache.memoryCache.clear();
// Get data (should load from persistence)
const reloaded = profileCache.get(section);
assert.deepEqual(reloaded, data);
});
test("ProfileCache: setProfile normalization and storage", (t) => {
const pubkey = "d".repeat(64);
const profileInput = {
name: " Test User ",
about: " Just testing ",
picture: " https://example.com/pic.jpg ",
nip05: "[email protected]", // Should be ignored if not in allowed fields?
// implementation copies specific fields
lud16: " [email protected] ",
};
const entry = profileCache.setProfile(pubkey, profileInput);
assert.equal(entry.profile.name, "Test User");
assert.equal(entry.profile.about, "Just testing");
assert.equal(entry.profile.picture, "https://example.com/pic.jpg");
assert.equal(entry.profile.lud16, "[email protected]");
assert.ok(entry.timestamp);
// Verify persistence
const storageKey = profileCache.getStorageKey(pubkey, "profile");
const stored = JSON.parse(localStorage.getItem(storageKey));
assert.deepEqual(stored.profile, entry.profile);
});
test("ProfileCache: TTL expiration", (t) => {
const pubkey = "e".repeat(64);
profileCache.setActiveProfile(pubkey);
const section = "watchHistory";
const ttl = CACHE_POLICIES[NOTE_TYPES.WATCH_HISTORY].ttl;
// Create expired data
const expiredData = {
items: [],
timestamp: Date.now() - ttl - 1000 // Expired by 1 second
};
const storageKey = profileCache.getStorageKey(pubkey, section);
localStorage.setItem(storageKey, JSON.stringify(expiredData));
// Should return null and log info (logger mocked/ignored)
const result = profileCache.get(section);
assert.equal(result, null);
// Create valid data
const validData = {
items: [],
timestamp: Date.now() - ttl + 10000 // Valid
};
localStorage.setItem(storageKey, JSON.stringify(validData));
const resultValid = profileCache.get(section);
assert.deepEqual(resultValid, validData);
});
test("ProfileCache: clearMemoryCache and clearSignerRuntime", (t) => {
const pubkey = "f".repeat(64);
profileCache.setActiveProfile(pubkey);
const section = "subscriptions";
const data = { tags: [] };
profileCache.set(section, data);
assert.ok(profileCache.getMemoryData(section));
profileCache.clearSignerRuntime(pubkey);
assert.equal(profileCache.getMemoryData(section), undefined);
});
test("ProfileCache: listeners", (t) => {
const pubkey = "1".repeat(64); // Valid hex
let eventCount = 0;
let lastEvent = null;
const unsubscribe = profileCache.subscribe((event, detail) => {
eventCount++;
lastEvent = { event, detail };
});
profileCache.setActiveProfile(pubkey);
assert.equal(eventCount, 1);
assert.equal(lastEvent.event, "profileChanged");
assert.equal(lastEvent.detail.pubkey, pubkey);
profileCache.set("watchHistory", { data: 1 });
assert.equal(eventCount, 3); // update, partition-updated
unsubscribe();
profileCache.reset();
assert.equal(eventCount, 3); // Should not increase
});
@@ -0,0 +1,178 @@
import test, { beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import ProfileModalController from "../../js/ui/profileModalController.js";
const TEMPLATE_HTML = `
<div id="profileModal">
<div class="bv-modal__panel"></div>
<button id="profileAddAccountBtn" type="button"></button>
<div id="profileSwitcherList"></div>
<div id="profileModerationSettings">
<label>
<span class="text-xs"></span>
<input id="profileModerationMuteHideThreshold" type="number" />
</label>
</div>
</div>
`;
let dom;
let documentRef;
let windowRef;
let controller;
let savedProfiles;
let profileCache;
let persistedProfiles;
let loadProfileError = null;
beforeEach(async () => {
dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "https://example.com",
});
windowRef = dom.window;
documentRef = windowRef.document;
global.window = windowRef;
global.document = documentRef;
global.HTMLElement = windowRef.HTMLElement;
global.HTMLButtonElement = windowRef.HTMLButtonElement;
global.HTMLInputElement = windowRef.HTMLInputElement;
global.HTMLTextAreaElement = windowRef.HTMLTextAreaElement;
global.HTMLImageElement = windowRef.HTMLImageElement;
global.Event = windowRef.Event;
if (!global.localStorage) {
global.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {}
};
}
global.fetch = async (url) => {
return {
ok: true,
async text() {
return TEMPLATE_HTML;
},
};
};
savedProfiles = [];
persistedProfiles = null;
profileCache = new Map();
loadProfileError = null;
controller = new ProfileModalController({
modalContainer: documentRef.body,
removeTrackingScripts: () => {},
showSuccess: (msg) => { console.log("Success:", msg); },
showError: (msg) => { console.error("Error:", msg); },
services: {
normalizeHexPubkey: (k) => k,
safeEncodeNpub: (k) => `npub${k}`,
getProfileCacheEntry: (k) => ({ profile: profileCache.get(k) }),
authService: {
loadOwnProfile: async (k) => {
if (loadProfileError) throw loadProfileError;
if (!profileCache.has(k)) {
profileCache.set(k, { name: "Loaded User", picture: "pic.jpg" });
}
return profileCache.get(k);
}
},
persistSavedProfiles: (opts) => { persistedProfiles = [...savedProfiles]; },
describeLoginError: (err, fb) => err.message || fb,
// minimal mocks for other required services
relayManager: {},
userBlocks: {},
nostrClient: {},
accessControl: {},
getCurrentUserNpub: () => null,
moderationSettings: {},
watchHistoryService: {},
log: () => {},
closeAllMoreMenus: () => {},
},
state: {
getSavedProfiles: () => savedProfiles,
setSavedProfiles: (profiles) => { savedProfiles = profiles; return savedProfiles; },
persistSavedProfiles: (opts) => { persistedProfiles = [...savedProfiles]; },
getActivePubkey: () => null,
setActivePubkey: () => null,
},
});
await controller.load();
});
afterEach(() => {
if (dom) {
dom.window.close();
}
delete global.window;
delete global.document;
delete global.HTMLElement;
delete global.HTMLButtonElement;
delete global.HTMLInputElement;
delete global.HTMLTextAreaElement;
delete global.HTMLImageElement;
delete global.Event;
delete global.fetch;
});
test("handleAddProfile adds a new profile correctly", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const loginResult = {
pubkey,
authType: "nip07",
};
await controller.handleAddProfile({ loginResult });
assert.equal(savedProfiles.length, 1);
assert.equal(savedProfiles[0].pubkey, pubkey);
assert.equal(savedProfiles[0].name, "Loaded User"); // loaded via mock authService
assert.equal(savedProfiles[0].picture, "pic.jpg");
assert.deepEqual(persistedProfiles, savedProfiles);
});
test("handleAddProfile prevents duplicates", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
savedProfiles.push({ pubkey, name: "Existing" });
const loginResult = { pubkey, authType: "nip07" };
await controller.handleAddProfile({ loginResult });
assert.equal(savedProfiles.length, 1);
assert.equal(savedProfiles[0].name, "Existing");
});
test("handleAddProfile handles missing login result", async () => {
await controller.handleAddProfile({});
assert.equal(savedProfiles.length, 0);
});
test("handleAddProfile handles errors gracefully", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const loginResult = { pubkey };
loadProfileError = new Error("Network error loading profile");
await assert.rejects(
async () => {
await controller.handleAddProfile({ loginResult });
},
(err) => {
assert.equal(err.message, "Network error loading profile");
return true;
}
);
assert.equal(savedProfiles.length, 0);
});