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

@@ -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();
}
})();