mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
improved playright integration
This commit is contained in:
1 parent
20314a5b5c
commit
0cf1dc0f55
17 files changed
+1165
-57
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user