improved playright integration

This commit is contained in:
thePR0M3TH3AN committed 2026-02-21 20:45:47 -05:00
1 parent 20314a5b5c
commit 0cf1dc0f55
17 files changed
+1165 -57

No files matched your search

+48
View File
@@ -352,6 +352,54 @@ jobs:
- name: Run DM unit tests
run: npm run test:dm:unit
e2e-tests:
runs-on: ubuntu-latest
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
node-version: [22]
project: [e2e, e2e-firefox]
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache npm directory
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-npm-
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ matrix.node-version }}-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-modules-
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Build app
run: npm run build
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox
- name: Run deterministic E2E suite (${{ matrix.project }})
run: npx playwright test tests/e2e --project=${{ matrix.project }}
dm-integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
+18
View File
@@ -366,6 +366,11 @@ Or set `localStorage.__bitvidTestRelays__` to a JSON array of relay URLs.
| `waitForSelector(sel, ms)` | `Promise<true>` | Wait for a DOM element |
| `getRelayHealth()` | `{ relays, unreachable, backoff }` | Relay connection status |
| `applyRelayOverrides(urls)` | `boolean` | Redirect relay connections |
| `setTestRelays(urls)` | `{ ok, relays }` | Align relayManager + nostrClient to a deterministic relay set |
| `setSignerDecryptBehavior(mode, opts?)` | `{ ok, mode, ... }` | Force decrypt behavior (`passthrough`, `timeout`, `error`, `delay`) for signer-based list sync tests |
| `getListSyncEvents()` | `Array<{ source, at, detail }>` | Read captured sync timeline (`bitvid:auth-loading-state` + `userBlocks` status) |
| `clearListSyncEvents()` | `void` | Reset captured sync events between assertions |
| `waitForListSyncEvent(criteria, ms?)` | `Promise<Event>` | Wait for sync milestones (`status`, `reason`, `source`) |
| `nostrClient` | `NostrClient` | Direct access for advanced use |
### Mock Relay (`scripts/agent/simple-relay.mjs`)
@@ -406,6 +411,9 @@ test("agent can seed and view videos", async ({ page, gotoApp, loginAs, seedEven
| `clearRelay()` | `() => Promise` | Wipe all relay events |
| `gotoApp(path?)` | `(string?) => Promise` | Navigate with test mode + relay overrides |
| `loginAs(page)` | `(Page) => Promise<string>` | Login with deterministic test key |
| `setTestRelays(page, relays)` | `(Page, string[]) => Promise<{ok, relays}>` | Force relay alignment through harness + relayManager |
| `setDecryptBehavior(page, mode, opts?)` | `(Page, mode, opts?) => Promise` | Force signer decrypt behavior (`passthrough`, `timeout`, `error`, `delay`) |
| `startDiagnostics(page, opts?)` | `(Page, opts?) => Promise<{stop()}>` | Capture console/page errors and list-sync telemetry for assertions |
| `testPubkey` | `string` | Hex pubkey of the test key |
| `relayUrl` | `string` | WebSocket URL of the mock relay |
@@ -418,6 +426,8 @@ Use these for stable element targeting:
| `[data-testid="login-button"]` | Header login button |
| `[data-testid="upload-button"]` | Header upload button (hidden until logged in) |
| `[data-testid="profile-button"]` | Header profile button (hidden until logged in) |
| `[data-testid="profile-permission-prompt"]` | Profile list-sync permission CTA container |
| `[data-testid="profile-permission-prompt-button"]` | Profile list-sync permission/retry CTA button |
| `[data-testid="search-input"]` | Header search field |
| `[data-testid="login-modal"]` | Login modal container |
| `[data-testid="login-provider-button"]` | Login provider option buttons |
@@ -428,6 +438,12 @@ Use these for stable element targeting:
| `[data-testid="upload-url"]` | Video URL input |
| `[data-testid="upload-magnet"]` | Magnet link input |
| `[data-testid="upload-submit"]` | Publish button |
| `[data-testid="hashtags-sync-status"]` | Profile hashtag sync status message |
| `[data-testid="subscriptions-sync-status"]` | Profile subscriptions sync status message |
| `[data-testid="blocked-refresh-button"]` | Profile blocked creators refresh button |
| `[data-testid="blocked-sync-status"]` | Profile blocked creators sync status message |
| `[data-testid="blocked-list"]` | Blocked creators list container |
| `[data-testid="blocked-empty-state"]` | Blocked creators empty state |
| `[data-testid="video-modal"]` | Video player modal |
| `[data-testid="video-card"]` | Individual video card in the feed |
| `[data-testid="video-list"]` | Video feed grid container |
@@ -440,6 +456,8 @@ Use these for stable element targeting:
| `scripts/agent/simple-relay.mjs` | Mock relay with HTTP seeding API |
| `tests/e2e/helpers/bitvidTestFixture.ts` | Reusable Playwright fixture |
| `tests/e2e/agent-testability.spec.ts` | Infrastructure validation tests |
| `scripts/playwright/run-extension-persistent.mjs` | Dedicated persistent-context launcher for extension smoke tests |
| `docs/testing/playwright-integration-recommendations-2026-02-22.md` | Playwright/extension integration recommendations and follow-ups |
---
+12 -2
View File
@@ -213,7 +213,13 @@ For detailed architecture and system documentation, see the [Documentation Index
- **Run load tests**: `npm run test:load`
- **Run DM unit tests**: `npm run test:dm:unit`
- **Run DM integration tests**: `npm run test:dm:integration`
- **Run headless E2E tests**: `npm run test:e2e`
- **Run headless E2E tests (Chromium + Firefox)**: `npm run test:e2e`
- **Run E2E tests (Chromium only)**: `npm run test:e2e:chromium`
- **Run E2E tests (Firefox only)**: `npm run test:e2e:firefox`
- **Run deterministic E2E matrix**: `npm run test:e2e:deterministic`
- **Run NIP-07 extension smoke (opt-in)**: `PLAYWRIGHT_EXTENSION_PATH=/abs/path/to/extension npm run test:e2e:extension`
- Uses persistent-context launcher: `scripts/playwright/run-extension-persistent.mjs`
- Optional profile dir: `PLAYWRIGHT_EXTENSION_USER_DATA_DIR=/abs/path/to/profile`
- **Run visual regression tests**: `npm run test:visual`
- **Update visual baselines**: `npm run test:visual:update`
- **Run design system audit**: `npm run audit` (Generates remediation report)
@@ -572,7 +578,11 @@ Before pushing, run `npm run build` locally so the Tailwind bundle regenerates.
- **Direct Messages**: Run `npm run test:dm:unit` and `npm run test:dm:integration` for DM flows.
- **End-to-End**: Run `npm run test:e2e` for headless Playwright journeys.
- **End-to-End**:
- `npm run test:e2e` runs deterministic Playwright journeys on Chromium + Firefox.
- `npm run test:e2e:chromium` / `npm run test:e2e:firefox` runs a single browser target.
- `npm run test:e2e:extension` runs the opt-in Chromium extension suite (set `PLAYWRIGHT_EXTENSION_PATH` and optionally `PLAYWRIGHT_ENABLE_EXTENSION_E2E=1`).
- Recommendations/roadmap: `docs/testing/playwright-integration-recommendations-2026-02-22.md`
### Manual QA checklist
+13 -1
View File
@@ -383,16 +383,19 @@
>
<div
id="profilePermissionPrompt"
data-testid="profile-permission-prompt"
class="card hidden flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
role="status"
aria-live="polite"
>
<p
id="profilePermissionPromptMessage"
data-testid="profile-permission-prompt-message"
class="text-sm text-muted"
></p>
<button
id="profilePermissionPromptButton"
data-testid="profile-permission-prompt-button"
type="button"
class="btn focus-ring"
>
@@ -927,6 +930,7 @@
</p>
<p
id="profileHashtagStatus"
data-testid="hashtags-sync-status"
class="text-sm text-muted"
role="status"
aria-live="polite"
@@ -1053,6 +1057,7 @@
</p>
<p
id="subscriptionsStatus"
data-testid="subscriptions-sync-status"
class="text-sm text-muted"
role="status"
aria-live="polite"
@@ -1339,6 +1344,7 @@
<h3 class="text-base font-semibold text-text">Blocked creators</h3>
<button
id="blockedRefreshBtn"
data-testid="blocked-refresh-button"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
@@ -1369,11 +1375,17 @@
</div>
<div
id="blockedEmpty"
data-testid="blocked-empty-state"
class="card border border-dashed border-border/60 p-6 text-center text-sm text-muted"
>
You haven’t blocked any creators yet.
</div>
<ul id="blockedList" class="grid-stack" data-orientation="vertical"></ul>
<ul
id="blockedList"
data-testid="blocked-list"
class="grid-stack"
data-orientation="vertical"
></ul>
</section>
<section
id="profilePaneMessages"
@@ -0,0 +1,63 @@
# Playwright Integration Recommendations (2026-02-22)
This document captures practical follow-ups for improving bitvid's end-to-end and extension integration testing.
## What is now in place
1. Deterministic E2E matrix projects:
- Chromium (`e2e`)
- Firefox (`e2e-firefox`)
2. Optional extension project:
- `e2e-nip07-extension` (opt-in via `PLAYWRIGHT_ENABLE_EXTENSION_E2E=1` and `PLAYWRIGHT_EXTENSION_PATH`)
3. Persistent-context extension launcher:
- Script: `scripts/playwright/run-extension-persistent.mjs`
- Command: `npm run test:e2e:extension`
- Purpose: run extension tests in Chromium with a real persistent profile context.
4. Test harness upgrades:
- `setTestRelays(urls)` aligns `relayManager` and `nostrClient`.
- `setSignerDecryptBehavior(mode, opts)` forces decrypt behavior for deterministic list-sync tests.
- `getListSyncEvents()`, `clearListSyncEvents()`, `waitForListSyncEvent()` capture and assert sync lifecycle.
5. Stable sync/test selectors:
- Permission prompt, hashtag/subscription statuses, blocked-list status/list/empty state.
## Recommended next steps
1. Add CI extension smoke lane (manual workflow dispatch only)
- Keep extension smoke out of default PR path.
- Add a separate workflow that runs only when maintainers provide:
- `PLAYWRIGHT_EXTENSION_PATH` artifact or unpacked extension directory
- any extension secrets/profile setup needed by the signer.
2. Add extension artifact capture on failure
- Always upload:
- `playwright-report`
- video/trace/screenshot for `tests/e2e-extension/**`
- JSON summary of `window.nostr` capabilities.
3. Add capability contract test
- Add a single test that asserts minimum NIP-07 surface before running deeper flows:
- `getPublicKey`
- `signEvent`
- at least one decrypt path (`nip04` or `nip44`).
4. Add "auth ready" test harness API
- Add a harness method that waits for authenticated app-ready state (`header UI + signer + relay ready`) so tests stop duplicating custom waits.
5. Add deterministic clock/random seed helper in test mode
- Expose optional fixed clock/random behavior to reduce cross-run variance in event ordering and timeout-sensitive tests.
6. Add a small extension compatibility matrix note
- Track known-good extension versions for:
- Alby/Nostr Connect style extensions
- Chromium version pinned in CI.
## Operational guardrails
1. Keep deterministic tests (mock signer + mock relay) as the default required PR gate.
2. Keep extension tests opt-in until flake rate is low and extension lifecycle setup is automated.
3. Do not treat extension `content.js` runtime noise as app regression unless bitvid telemetry/events also fail in the same window.
+304 -9
View File
@@ -12,6 +12,8 @@
// await page.evaluate(() => window.__bitvidTest__.getAppState());
import { nostrClient } from "./nostrClientFacade.js";
import { relayManager } from "./relayManager.js";
import { userBlocks, USER_BLOCK_EVENTS } from "./userBlocks.js";
import {
registerSigner,
setActiveSigner,
@@ -21,7 +23,238 @@ import {
import { devLogger } from "./utils/logger.js";
import { STANDARD_TIMEOUT_MS } from "./constants.js";
const HARNESS_VERSION = 1;
const HARNESS_VERSION = 2;
const MAX_SYNC_EVENT_BUFFER = 300;
const SYNC_EVENT_POLL_INTERVAL_MS = 50;
const listSyncEvents = [];
let listSyncCaptureInstalled = false;
let removeUserBlockStatusListener = null;
let removeAuthLoadingListener = null;
const signerDecryptOriginals = new WeakMap();
let activeDecryptBehavior = {
mode: "passthrough",
delayMs: 0,
errorMessage: "",
};
function normalizeRelayList(relayUrls) {
if (!Array.isArray(relayUrls)) {
return [];
}
const seen = new Set();
const normalized = [];
for (const item of relayUrls) {
const value = typeof item === "string" ? item.trim() : "";
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
normalized.push(value);
}
return normalized;
}
function pushListSyncEvent(source, detail = {}) {
const event = {
source: typeof source === "string" ? source : "unknown",
at: Date.now(),
detail: detail && typeof detail === "object" ? detail : { value: detail },
};
listSyncEvents.push(event);
if (listSyncEvents.length > MAX_SYNC_EVENT_BUFFER) {
listSyncEvents.splice(0, listSyncEvents.length - MAX_SYNC_EVENT_BUFFER);
}
}
function installListSyncCapture() {
if (listSyncCaptureInstalled || typeof window === "undefined") {
return;
}
removeAuthLoadingListener = (event) => {
pushListSyncEvent("auth-loading-state", event?.detail || {});
};
window.addEventListener("bitvid:auth-loading-state", removeAuthLoadingListener);
if (userBlocks && typeof userBlocks.on === "function") {
removeUserBlockStatusListener = userBlocks.on(
USER_BLOCK_EVENTS.STATUS,
(detail) => {
pushListSyncEvent("user-blocks-status", detail || {});
},
);
}
listSyncCaptureInstalled = true;
}
function clearListSyncEvents() {
listSyncEvents.length = 0;
}
function getListSyncEvents() {
return listSyncEvents.map((entry) => ({
source: entry.source,
at: entry.at,
detail: entry.detail,
}));
}
function matchesListSyncCriteria(event, criteria = {}) {
if (!event || typeof event !== "object") {
return false;
}
const expectedSource =
typeof criteria.source === "string" ? criteria.source.trim() : "";
if (expectedSource && event.source !== expectedSource) {
return false;
}
const expectedStatus =
typeof criteria.status === "string" ? criteria.status.trim() : "";
if (expectedStatus && event?.detail?.status !== expectedStatus) {
return false;
}
const expectedReason =
typeof criteria.reason === "string" ? criteria.reason.trim() : "";
if (expectedReason && event?.detail?.reason !== expectedReason) {
return false;
}
return true;
}
function waitForListSyncEvent(criteria = {}, timeoutMs = STANDARD_TIMEOUT_MS) {
const timeout = Number.isFinite(timeoutMs)
? Math.max(0, Math.floor(timeoutMs))
: STANDARD_TIMEOUT_MS;
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
for (const event of listSyncEvents) {
if (matchesListSyncCriteria(event, criteria)) {
resolve(event);
return;
}
}
if (Date.now() - startedAt >= timeout) {
reject(
new Error(
`[testHarness] Timed out waiting for list sync event: ${JSON.stringify(criteria)}`,
),
);
return;
}
setTimeout(check, SYNC_EVENT_POLL_INTERVAL_MS);
};
check();
});
}
function normalizeDecryptBehavior(mode, options = {}) {
const normalizedMode =
typeof mode === "string" ? mode.trim().toLowerCase() : "passthrough";
const allowed = new Set(["passthrough", "timeout", "error", "delay"]);
const safeMode = allowed.has(normalizedMode) ? normalizedMode : "passthrough";
const delayMs = Number.isFinite(options?.delayMs)
? Math.max(0, Math.floor(options.delayMs))
: 0;
const errorMessage =
typeof options?.errorMessage === "string" && options.errorMessage.trim()
? options.errorMessage.trim()
: "test-harness decrypt failure";
return { mode: safeMode, delayMs, errorMessage };
}
function ensureSignerDecryptOriginals(signer) {
if (!signer || typeof signer !== "object") {
return null;
}
if (!signerDecryptOriginals.has(signer)) {
signerDecryptOriginals.set(signer, {
nip04Decrypt: signer.nip04Decrypt,
nip44Decrypt: signer.nip44Decrypt,
});
}
return signerDecryptOriginals.get(signer);
}
function buildDecryptWrapper(originalDecrypt, behavior, methodName) {
if (typeof originalDecrypt !== "function") {
return originalDecrypt;
}
if (behavior.mode === "timeout") {
return () => new Promise(() => {});
}
if (behavior.mode === "error") {
return async () => {
const error = new Error(behavior.errorMessage);
error.code = "test-harness-decrypt-error";
error.method = methodName;
throw error;
};
}
if (behavior.mode === "delay") {
return async (...args) => {
if (behavior.delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, behavior.delayMs));
}
return originalDecrypt(...args);
};
}
return originalDecrypt;
}
function setSignerDecryptBehavior(mode = "passthrough", options = {}) {
const behavior = normalizeDecryptBehavior(mode, options);
activeDecryptBehavior = behavior;
const signer = getActiveSigner();
if (!signer || typeof signer !== "object") {
return {
ok: false,
mode: behavior.mode,
reason: "no-active-signer",
};
}
const originals = ensureSignerDecryptOriginals(signer);
if (!originals) {
return {
ok: false,
mode: behavior.mode,
reason: "invalid-signer",
};
}
signer.nip04Decrypt = buildDecryptWrapper(
originals.nip04Decrypt,
behavior,
"nip04Decrypt",
);
signer.nip44Decrypt = buildDecryptWrapper(
originals.nip44Decrypt,
behavior,
"nip44Decrypt",
);
return {
ok: true,
mode: behavior.mode,
delayMs: behavior.delayMs,
};
}
/**
* Check if test mode is enabled via URL param or localStorage.
@@ -77,14 +310,15 @@ export function getTestRelayOverrides() {
* instead of production relays.
*/
function applyRelayOverrides(relayUrls) {
if (!relayUrls || !relayUrls.length) return false;
const normalizedRelays = normalizeRelayList(relayUrls);
if (!normalizedRelays.length) return false;
try {
if (typeof nostrClient.applyRelayPreferences === "function") {
nostrClient.applyRelayPreferences({
all: relayUrls,
read: relayUrls,
write: relayUrls,
all: normalizedRelays,
read: normalizedRelays,
write: normalizedRelays,
});
return true;
}
@@ -92,9 +326,9 @@ function applyRelayOverrides(relayUrls) {
// Fallback: directly set relay arrays if available
if (nostrClient.connectionManager) {
const cm = nostrClient.connectionManager;
cm.relays = [...relayUrls];
if (Array.isArray(cm.readRelays)) cm.readRelays = [...relayUrls];
if (Array.isArray(cm.writeRelays)) cm.writeRelays = [...relayUrls];
cm.relays = [...normalizedRelays];
if (Array.isArray(cm.readRelays)) cm.readRelays = [...normalizedRelays];
if (Array.isArray(cm.writeRelays)) cm.writeRelays = [...normalizedRelays];
return true;
}
} catch (err) {
@@ -104,6 +338,47 @@ function applyRelayOverrides(relayUrls) {
return false;
}
function setTestRelays(relayUrls, options = {}) {
const normalizedRelays = normalizeRelayList(relayUrls);
if (!normalizedRelays.length) {
return { ok: false, reason: "invalid-relays" };
}
const persist = options?.persist !== false;
let applied = false;
try {
if (relayManager && typeof relayManager.setEntries === "function") {
relayManager.setEntries(
normalizedRelays.map((url) => ({ url, mode: "both" })),
{ allowEmpty: false, updateClient: true },
);
applied = true;
}
} catch (error) {
devLogger.warn("[testHarness] relayManager.setEntries failed:", error);
}
if (!applied) {
applied = applyRelayOverrides(normalizedRelays);
} else {
applyRelayOverrides(normalizedRelays);
}
if (persist) {
try {
localStorage.setItem("__bitvidTestRelays__", JSON.stringify(normalizedRelays));
} catch (_error) {
// ignore storage failures in restricted contexts
}
}
return {
ok: applied,
relays: normalizedRelays,
};
}
/**
* Login programmatically with a hex private key. Bypasses the login modal.
* Returns the hex pubkey on success.
@@ -130,6 +405,10 @@ async function loginWithNsec(hexPrivateKey) {
persist: false,
});
if (activeDecryptBehavior.mode !== "passthrough") {
setSignerDecryptBehavior(activeDecryptBehavior.mode, activeDecryptBehavior);
}
return pubkey;
}
@@ -161,6 +440,15 @@ function getAppState() {
write: [...(nostrClient.connectionManager.writeRelays || [])],
}
: null,
relayManager: relayManager
? {
all: relayManager.getAllRelayUrls?.() || [],
read: relayManager.getReadRelayUrls?.() || [],
write: relayManager.getWriteRelayUrls?.() || [],
lastLoadSource: relayManager.lastLoadSource || null,
}
: null,
decryptBehavior: { ...activeDecryptBehavior },
};
}
@@ -257,9 +545,11 @@ export function installTestHarness() {
const testRelays = getTestRelayOverrides();
if (testRelays) {
applyRelayOverrides(testRelays);
setTestRelays(testRelays, { persist: false });
}
installListSyncCapture();
window.__bitvidTest__ = Object.freeze({
version: HARNESS_VERSION,
loginWithNsec,
@@ -270,6 +560,11 @@ export function installTestHarness() {
waitForSelector,
getRelayHealth,
applyRelayOverrides,
setTestRelays,
setSignerDecryptBehavior,
getListSyncEvents,
clearListSyncEvents,
waitForListSyncEvent,
// Expose nostrClient for advanced test scenarios
get nostrClient() {
@@ -100,12 +100,16 @@ export class ProfileBlockListController {
'[data-role="blocked-list-status"]',
);
if (existing instanceof HTMLElement) {
if (!existing.dataset.testid) {
existing.dataset.testid = "blocked-sync-status";
}
this.blockListStatus = existing;
return existing;
}
const status = document.createElement("div");
status.dataset.role = "blocked-list-status";
status.dataset.testid = "blocked-sync-status";
status.className = "mt-4 flex items-center gap-3 text-sm text-muted hidden";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
+30 -24
View File
@@ -89,31 +89,31 @@ const FAST_BLOCKLIST_RELAY_LIMIT = 3;
const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
const MAX_BACKGROUND_BLOCKLIST_RELAY_LIMIT = 24;
// PERF: Reduced from 20s/15s to 10s/8s — the signer is now guaranteed to be
// ready before list loading starts (authSessionCoordinator waits for the
// permission pre-grant), so decryption should succeed quickly.
const DECRYPT_TIMEOUT_MS = STANDARD_TIMEOUT_MS;
// Keep interactive decrypt timeout above extension retry timeout window.
const DECRYPT_TIMEOUT_MS = Math.max(STANDARD_TIMEOUT_MS, 15000);
const BACKGROUND_DECRYPT_TIMEOUT_MS = 8000;
// PERF: Reduced from 10s to 3s for faster recovery during login.
const DECRYPT_RETRY_DELAY_MS = 3000;
const MAX_DECRYPT_RETRY_DELAY_MS = 30000;
function computeRetryDelay(baseDelayMs, attempt) {
const normalizedAttempt =
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
const normalizedAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
const multiplier = 2 ** normalizedAttempt;
const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier));
return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay);
}
function sanitizeRelayList(candidate) {
return Array.isArray(candidate)
? candidate
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean)
: [];
if (!Array.isArray(candidate)) return [];
const seen = new Set();
const relays = [];
for (const value of candidate) {
const normalized = typeof value === "string" ? value.trim() : "";
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
relays.push(normalized);
}
return relays;
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
@@ -1526,17 +1526,23 @@ class UserBlockListManager {
return;
}
if (decryptionError.code === "user-blocklist-decrypt-timeout") {
const signerStatus =
decryptionError.signerStatus || resolveSignerStatus();
// If decrypt times out, restore the previous state to avoid wiping the user's blocks.
// We then schedule a background retry.
this.blockedPubkeys = previousState.blockedPubkeys;
this._privateBlocks = previousState.privateBlocks;
this._publicMutes = previousState.publicMutes;
this.blockEventId = previousState.blockEventId;
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
this.loaded = true; // Mark as loaded so UI renders, even if stale
const signerStatus = decryptionError.signerStatus || resolveSignerStatus();
const recoveredPrivateBlocks = new Set(previousState.privateBlocks);
const recoveredPublicMutes = new Set([
...previousState.publicMutes,
...extractPubkeysFromTags(newestStandard?.tags, normalized, {
limit: MAX_BLOCKLIST_ENTRIES,
}),
]);
this.blockEventId = newestOverall?.id || previousState.blockEventId;
this.blockEventCreatedAt =
Number.isFinite(newestCreatedAt) ? newestCreatedAt : previousState.blockEventCreatedAt;
applySets(recoveredPrivateBlocks, recoveredPublicMutes, {
source,
reason: "decrypt-timeout-partial",
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
});
this.loaded = true;
emitStatus({
status: "stale",
+5 -1
View File
@@ -29,7 +29,11 @@
"check:css": "npm run build:css",
"test:visual": "npm run build && npx playwright test",
"test:visual:update": "npm run build && cross-env UPDATE_VISUAL_BASELINES=1 npx playwright test",
"test:e2e": "npm run build && npx playwright test tests/e2e",
"test:e2e": "npm run build && npx playwright test tests/e2e --project=e2e --project=e2e-firefox",
"test:e2e:chromium": "npm run build && npx playwright test tests/e2e --project=e2e",
"test:e2e:firefox": "npm run build && npx playwright test tests/e2e --project=e2e-firefox",
"test:e2e:deterministic": "npm run build && npx playwright test tests/e2e --project=e2e --project=e2e-firefox",
"test:e2e:extension": "npm run build && node scripts/playwright/run-extension-persistent.mjs",
"test:load": "node scripts/agent/load-test.mjs",
"test:smoke": "node scripts/agent/smoke-test.mjs",
"test:dm:unit": "node scripts/run-targeted-tests.mjs tests/dm-db.test.mjs tests/dm-decryptor.test.mjs tests/dm-normalization.test.mjs tests/nostr-send-direct-message.test.mjs",
+42 -15
View File
@@ -1,8 +1,49 @@
import { defineConfig } from "@playwright/test";
import path from "node:path";
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}`;
const ENABLE_EXTENSION_E2E = process.env.PLAYWRIGHT_ENABLE_EXTENSION_E2E === "1";
const EXTENSION_PATH = process.env.PLAYWRIGHT_EXTENSION_PATH
? path.resolve(process.env.PLAYWRIGHT_EXTENSION_PATH)
: "";
const projects = [
{
name: "visual",
testMatch: "visual/**/*.spec.ts",
use: {
screenshot: "only-on-failure",
trace: "retain-on-failure",
video: "retain-on-failure",
},
},
{
name: "e2e",
testMatch: "e2e/**/*.spec.ts",
use: {
browserName: "chromium",
},
},
{
name: "e2e-firefox",
testMatch: "e2e/**/*.spec.ts",
use: {
browserName: "firefox",
},
},
];
if (ENABLE_EXTENSION_E2E && EXTENSION_PATH) {
projects.push({
name: "e2e-nip07-extension",
testMatch: "e2e-extension/**/*.spec.ts",
use: {
browserName: "chromium",
},
});
}
export default defineConfig({
testDir: "./tests",
@@ -16,21 +57,7 @@ export default defineConfig({
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
},
projects: [
{
name: "visual",
testMatch: "visual/**/*.spec.ts",
use: {
screenshot: "only-on-failure",
trace: "retain-on-failure",
video: "retain-on-failure",
},
},
{
name: "e2e",
testMatch: "e2e/**/*.spec.ts",
},
],
projects,
webServer: {
command: `npx http-server . -p ${PORT} -a ${HOST} -c-1 --silent`,
url: `${BASE_URL}/docs/kitchen-sink.html`,
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
function resolveEnvPath(value) {
if (typeof value !== "string" || !value.trim()) {
return "";
}
return path.resolve(value.trim());
}
const extensionPath = resolveEnvPath(process.env.PLAYWRIGHT_EXTENSION_PATH || "");
if (!extensionPath) {
console.error(
"PLAYWRIGHT_EXTENSION_PATH is required. Example:\n" +
"PLAYWRIGHT_EXTENSION_PATH=/abs/path/to/extension npm run test:e2e:extension",
);
process.exit(1);
}
const extraArgs = process.argv.slice(2);
const testArgs = [
"playwright",
"test",
"tests/e2e-extension",
"--project=e2e-nip07-extension",
...extraArgs,
];
const child = spawn("npx", testArgs, {
stdio: "inherit",
shell: false,
env: {
...process.env,
PLAYWRIGHT_ENABLE_EXTENSION_E2E: "1",
PLAYWRIGHT_EXTENSION_PATH: extensionPath,
},
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
@@ -0,0 +1,88 @@
import { test as base, expect, chromium, type BrowserContext, type Page } from "@playwright/test";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
type PersistentExtensionFixtures = {
persistentContext: BrowserContext;
extensionPath: string;
extensionUserDataDir: string;
};
export const test = base.extend<PersistentExtensionFixtures>({
extensionPath: [
async ({}, use) => {
const rawPath = process.env.PLAYWRIGHT_EXTENSION_PATH || "";
const resolved = rawPath ? path.resolve(rawPath) : "";
if (!resolved) {
throw new Error(
"PLAYWRIGHT_EXTENSION_PATH is required for extension E2E tests.",
);
}
await use(resolved);
},
{ scope: "worker" },
],
extensionUserDataDir: [
async ({}, use, testInfo) => {
const provided = process.env.PLAYWRIGHT_EXTENSION_USER_DATA_DIR || "";
if (provided.trim()) {
await use(path.resolve(provided.trim()));
return;
}
const dir = await fs.mkdtemp(
path.join(
os.tmpdir(),
`bitvid-playwright-extension-${testInfo.workerIndex}-`,
),
);
try {
await use(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
},
{ scope: "worker" },
],
persistentContext: [
async ({ extensionPath, extensionUserDataDir }, use) => {
const context = await chromium.launchPersistentContext(
extensionUserDataDir,
{
headless: false,
viewport: { width: 1280, height: 720 },
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
},
);
try {
await use(context);
} finally {
await context.close();
}
},
{ scope: "worker" },
],
context: async ({ persistentContext }, use) => {
await use(persistentContext);
},
page: async ({ persistentContext, baseURL }, use) => {
const existing = persistentContext.pages()[0] || null;
const page = existing || (await persistentContext.newPage());
if (baseURL) {
await page.goto(baseURL);
}
await use(page);
},
});
export { expect };
@@ -0,0 +1,34 @@
import { test, expect } from "./helpers/persistentExtensionFixture";
test.describe("NIP-07 extension smoke", () => {
test("extension injects a NIP-07 API surface", async ({ page }) => {
await page.goto("/");
await page.waitForFunction(
() =>
typeof (window as any).nostr === "object" &&
(typeof (window as any).nostr.getPublicKey === "function" ||
typeof (window as any).nostr.nip04?.decrypt === "function" ||
typeof (window as any).nostr.nip44?.decrypt === "function"),
{ timeout: 15_000 },
);
const capabilities = await page.evaluate(() => {
const api = (window as any).nostr;
return {
hasGetPublicKey: typeof api?.getPublicKey === "function",
hasSignEvent: typeof api?.signEvent === "function",
hasNip04Decrypt: typeof api?.nip04?.decrypt === "function",
hasNip44Decrypt:
typeof api?.nip44?.decrypt === "function" ||
typeof api?.nip44?.v2?.decrypt === "function",
};
});
expect(
capabilities.hasGetPublicKey ||
capabilities.hasNip04Decrypt ||
capabilities.hasNip44Decrypt,
).toBe(true);
});
});
+61 -5
View File
@@ -26,7 +26,7 @@ test.describe("Agent testability infrastructure", () => {
});
expect(harness).not.toBeNull();
expect(harness!.version).toBe(1);
expect(harness!.version).toBe(2);
});
test("relay overrides are applied via query params", async ({
@@ -49,6 +49,29 @@ test.describe("Agent testability infrastructure", () => {
}
});
test("setTestRelays aligns relayManager and nostrClient", async ({
page,
gotoApp,
relayUrl,
}) => {
await gotoApp();
const result = await page.evaluate((url) => {
const harness = (window as any).__bitvidTest__;
if (!harness || typeof harness.setTestRelays !== "function") {
throw new Error("setTestRelays missing");
}
return harness.setTestRelays([url]);
}, relayUrl);
expect(result.ok).toBe(true);
const state = await page.evaluate(() => {
return (window as any).__bitvidTest__.getAppState();
});
expect(state.relays.read).toEqual([relayUrl]);
expect(state.relayManager.read).toEqual([relayUrl]);
});
test("programmatic login works via test harness", async ({
page,
relay,
@@ -133,10 +156,15 @@ test.describe("Data-testid selectors are present", () => {
// Open the login modal
await page.locator('[data-testid="login-button"]').click();
// Wait for modal to be visible
await expect(page.locator('[data-testid="login-modal"]')).toBeVisible({
timeout: 15000,
});
// Wait for modal to transition into an open, visible state.
await page.waitForFunction(() => {
const modal = document.querySelector('[data-testid="login-modal"]');
if (!(modal instanceof HTMLElement)) {
return false;
}
const openAttr = modal.getAttribute("data-open");
return openAttr === "true" && !modal.classList.contains("hidden");
}, { timeout: 15000 });
// Check provider buttons exist
const providerButtons = page.locator(
@@ -186,4 +214,32 @@ test.describe("App state inspection", () => {
});
expect(state.isLoggedIn).toBe(false);
});
test("list sync event capture APIs are available", async ({
page,
gotoApp,
}) => {
await gotoApp();
const result = await page.evaluate(() => {
const harness = (window as any).__bitvidTest__;
if (!harness) {
throw new Error("Harness missing");
}
harness.clearListSyncEvents();
window.dispatchEvent(
new CustomEvent("bitvid:auth-loading-state", {
detail: { lists: "loading", listsDetail: { ready: false } },
}),
);
const events = harness.getListSyncEvents();
return {
count: events.length,
firstSource: events[0]?.source || "",
};
});
expect(result.count).toBeGreaterThan(0);
expect(result.firstSource).toBe("auth-loading-state");
});
});
+102
View File
@@ -149,6 +149,22 @@ type BitvidFixtures = {
clearRelay: () => Promise<void>;
gotoApp: (path?: string) => Promise<void>;
loginAs: (page: Page) => Promise<string>;
setTestRelays: (page: Page, relays: string[]) => Promise<any>;
setDecryptBehavior: (
page: Page,
mode: "passthrough" | "timeout" | "error" | "delay",
options?: { delayMs?: number; errorMessage?: string },
) => Promise<any>;
startDiagnostics: (
page: Page,
options?: { captureSyncEvents?: boolean },
) => Promise<{
stop: () => Promise<{
console: Array<{ type: string; text: string }>;
pageErrors: string[];
syncEvents: any[];
}>;
}>;
testPubkey: string;
testPrivateKey: string;
relayUrl: string;
@@ -207,6 +223,92 @@ export const test = base.extend<BitvidFixtures>({
});
},
setTestRelays: async ({}, use) => {
await use(async (page: Page, relays: string[]) => {
return page.evaluate((urls) => {
const harness = (window as any).__bitvidTest__;
if (!harness || typeof harness.setTestRelays !== "function") {
throw new Error("setTestRelays is not available in test harness");
}
return harness.setTestRelays(urls);
}, relays);
});
},
setDecryptBehavior: async ({}, use) => {
await use(
async (
page: Page,
mode: "passthrough" | "timeout" | "error" | "delay",
options = {},
) => {
return page.evaluate(
({ nextMode, nextOptions }) => {
const harness = (window as any).__bitvidTest__;
if (!harness || typeof harness.setSignerDecryptBehavior !== "function") {
throw new Error(
"setSignerDecryptBehavior is not available in test harness",
);
}
return harness.setSignerDecryptBehavior(nextMode, nextOptions);
},
{ nextMode: mode, nextOptions: options },
);
},
);
},
startDiagnostics: async ({}, use) => {
await use(async (page: Page, options = {}) => {
const consoleEntries: Array<{ type: string; text: string }> = [];
const pageErrors: string[] = [];
const captureSyncEvents = options.captureSyncEvents !== false;
const onConsole = (message: any) => {
consoleEntries.push({
type: message.type(),
text: message.text(),
});
};
const onPageError = (error: Error) => {
pageErrors.push(error?.message || String(error));
};
page.on("console", onConsole);
page.on("pageerror", onPageError);
if (captureSyncEvents) {
await page.evaluate(() => {
const harness = (window as any).__bitvidTest__;
if (harness && typeof harness.clearListSyncEvents === "function") {
harness.clearListSyncEvents();
}
});
}
return {
stop: async () => {
page.off("console", onConsole);
page.off("pageerror", onPageError);
const syncEvents = captureSyncEvents
? await page.evaluate(() => {
const harness = (window as any).__bitvidTest__;
if (harness && typeof harness.getListSyncEvents === "function") {
return harness.getListSyncEvents();
}
return [];
})
: [];
return {
console: consoleEntries,
pageErrors,
syncEvents,
};
},
};
});
},
testPubkey: TEST_PK,
testPrivateKey: TEST_PRIVATE_KEY_HEX,
relayUrl: async ({ relay }, use) => {
@@ -0,0 +1,185 @@
import { test, expect } from "./helpers/bitvidTestFixture";
import { finalizeEvent, getPublicKey } from "nostr-tools";
function makeHex(char: string) {
return char.repeat(64);
}
test.describe("Profile list sync diagnostics", () => {
test("captures console + page errors during login/profile sync baseline", async ({
page,
gotoApp,
loginAs,
testPubkey,
relayUrl,
setTestRelays,
startDiagnostics,
}) => {
const diagnostics = await startDiagnostics(page);
await gotoApp();
await loginAs(page);
await setTestRelays(page, [relayUrl]);
await page.waitForFunction(
() => Boolean((window as any).__bitvidTest__?.getAppState?.()?.isLoggedIn),
{ timeout: 15_000 },
);
const appState = await page.evaluate(() => {
return (window as any).__bitvidTest__.getAppState();
});
expect(appState.isLoggedIn).toBe(true);
const syncState = await page.evaluate(async (activePubkey) => {
const module = await import("/js/userBlocks.js");
const manager = module.userBlocks;
const statuses: any[] = [];
const unsubscribe = manager.on("status", (detail: any) => {
statuses.push(detail);
});
try {
await manager.loadBlocks(activePubkey, {
allowPermissionPrompt: false,
decryptTimeoutMs: 250,
});
} finally {
if (typeof unsubscribe === "function") unsubscribe();
}
return {
statuses,
blockedPubkeys: manager.getBlockedPubkeys(),
};
}, testPubkey);
const diagnosticsResult = await diagnostics.stop();
const appErrorLogs = diagnosticsResult.console.filter(
(entry) =>
entry.type === "error" ||
entry.text.includes("[UserBlockList]") ||
entry.text.includes("profile list") ||
entry.text.includes("blocked creators"),
);
expect(Array.isArray(syncState.statuses)).toBe(true);
expect(Array.isArray(syncState.blockedPubkeys)).toBe(true);
// Diagnostic output is intentionally logged for local triage.
console.log(
JSON.stringify(
{
phase: "baseline",
appState,
consoleCount: diagnosticsResult.console.length,
pageErrorCount: diagnosticsResult.pageErrors.length,
appErrorLogs,
syncEventsTail: diagnosticsResult.syncEvents.slice(-8),
statusTail: syncState.statuses.slice(-8),
blockedPubkeys: syncState.blockedPubkeys,
},
null,
2,
),
);
});
test("forced decrypt-timeout still ingests public mute tags", async ({
page,
gotoApp,
loginAs,
seedRawEvent,
testPubkey,
testPrivateKey,
relayUrl,
setTestRelays,
setDecryptBehavior,
startDiagnostics,
}) => {
const publicBlocked = makeHex("a");
const privateKeyBytes = Uint8Array.from(Buffer.from(testPrivateKey, "hex"));
const derivedPubkey = getPublicKey(privateKeyBytes);
const staleEvent = finalizeEvent(
{
kind: 10000,
created_at: Math.floor(Date.now() / 1000),
tags: [
["encrypted", "nip44"],
["p", publicBlocked],
],
content: "ciphertext-timeout",
},
privateKeyBytes,
);
await seedRawEvent(staleEvent);
const diagnostics = await startDiagnostics(page);
await gotoApp();
await loginAs(page);
await setTestRelays(page, [relayUrl]);
await page.waitForFunction(
() => Boolean((window as any).__bitvidTest__?.getAppState?.()?.isLoggedIn),
{ timeout: 15_000 },
);
let result: any = null;
try {
await setDecryptBehavior(page, "timeout");
result = await page.evaluate(
async ({ activePubkey, blockedPubkey }) => {
const blocksModule = await import("/js/userBlocks.js");
const manager = blocksModule.userBlocks;
const statuses: any[] = [];
const unsubscribe = manager.on("status", (detail: any) => {
statuses.push(detail);
});
try {
await manager.loadBlocks(activePubkey, {
allowPermissionPrompt: false,
decryptTimeoutMs: 80,
});
} finally {
if (typeof unsubscribe === "function") unsubscribe();
}
return {
statuses,
blockedPubkeys: manager.getBlockedPubkeys(),
containsPublicMute: manager.getBlockedPubkeys().includes(blockedPubkey),
};
},
{ activePubkey: testPubkey, blockedPubkey: publicBlocked },
);
expect(testPubkey).toBe(derivedPubkey);
expect(result.containsPublicMute).toBe(true);
expect(
result.statuses.some(
(entry: any) =>
entry?.status === "stale" && entry?.reason === "decrypt-timeout",
),
).toBe(true);
} finally {
await setDecryptBehavior(page, "passthrough");
}
const diagnosticsResult = await diagnostics.stop();
console.log(
JSON.stringify(
{
phase: "forced-timeout",
consoleCount: diagnosticsResult.console.length,
pageErrorCount: diagnosticsResult.pageErrors.length,
syncEventsTail: diagnosticsResult.syncEvents.slice(-10),
statusTail: result?.statuses?.slice?.(-10) || [],
blockedPubkeys: result?.blockedPubkeys || [],
},
null,
2,
),
);
});
});
+107
View File
@@ -1469,3 +1469,110 @@ await (async () => {
window.nostr = originalNostr;
}
})();
await (async () => {
// Test: decrypt timeout should still apply public p-tags while preserving
// existing private blocks.
const actor = "6".repeat(64);
const existingPrivateBlocked = "7".repeat(64);
const publicBlocked = "8".repeat(64);
const UserBlockListManager = userBlocks.constructor;
const manager = new UserBlockListManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalSigner = getActiveSigner();
const originalRelayEntries = relayManager.getEntries();
const relayUrls = ["wss://decrypt-timeout-partial.example"];
relayManager.setEntries(
relayUrls.map((url) => ({ url, mode: "both" })),
{ allowEmpty: false, updateClient: false },
);
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
nostrClient.pool = {
list: async () => [
{
id: "event-timeout-partial",
kind: 10000,
created_at: 21_000,
pubkey: actor,
content: "ciphertext-timeout",
tags: [
["encrypted", "nip44"],
["p", publicBlocked],
],
},
],
};
setActiveSigner({
getPublicKey: async () => actor,
nip44Decrypt: async () => new Promise(() => {}),
});
manager._privateBlocks = new Set([existingPrivateBlocked]);
manager._publicMutes = new Set();
manager.blockedPubkeys = new Set([existingPrivateBlocked]);
manager.blockEventId = "event-previous";
manager.blockEventCreatedAt = 20_000;
manager.loaded = true;
const statusEvents = [];
const unsubscribe = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
statusEvents.push(detail);
});
try {
await manager.loadBlocks(actor, {
allowPermissionPrompt: false,
decryptTimeoutMs: 30,
});
const blocked = manager.getBlockedPubkeys();
assert.equal(
blocked.includes(existingPrivateBlocked),
true,
"timeout fallback should preserve previously decrypted private blocks",
);
assert.equal(
blocked.includes(publicBlocked),
true,
"timeout fallback should still ingest public mute p-tags",
);
assert.equal(
manager.blockEventId,
"event-timeout-partial",
"timeout fallback should keep metadata anchored to the newest relay event",
);
assert(
statusEvents.some(
(detail) =>
detail?.status === "stale" && detail?.reason === "decrypt-timeout",
),
"timeout fallback should emit stale decrypt-timeout status",
);
} finally {
unsubscribe?.();
relayManager.setEntries(
originalRelayEntries,
{ allowEmpty: false, updateClient: false },
);
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
clearActiveSigner();
if (originalSigner) {
setActiveSigner(originalSigner);
}
manager.reset();
}
})();