Merge pull request #2297 from PR0M3TH3AN/claude/improve-playwright-testability-8mNO7

Add Playwright agent testing infrastructure with test harness and mock relay
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-10 00:29:35 -05:00
commit d58cff2eac
15 files changed
+972 -29

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
/**
* Agent testability E2E tests.
*
* These tests demonstrate that bitvid is fully testable by Playwright CLI agents.
* They exercise:
* 1. Test harness installation and relay override
* 2. Programmatic login (bypassing the UI)
* 3. Seeding test data into a local mock relay
* 4. Inspecting app state through the harness API
* 5. Key data-testid selectors for UI interaction
*/
import { test, expect } from "./helpers/bitvidTestFixture";
test.describe("Agent testability infrastructure", () => {
test("test harness installs when __test__=1 is set", async ({
page,
relay,
gotoApp,
}) => {
await gotoApp();
const harness = await page.evaluate(() => {
const h = (window as any).__bitvidTest__;
return h ? { version: h.version } : null;
});
expect(harness).not.toBeNull();
expect(harness!.version).toBe(1);
});
test("relay overrides are applied via query params", async ({
page,
relay,
gotoApp,
relayUrl,
}) => {
await gotoApp();
const state = await page.evaluate(() => {
return (window as any).__bitvidTest__.getAppState();
});
// Relay overrides should include our test relay
expect(state.relays).not.toBeNull();
if (state.relays) {
const allRelays = state.relays.all || [];
expect(allRelays).toContain(relayUrl);
}
});
test("programmatic login works via test harness", async ({
page,
relay,
gotoApp,
loginAs,
testPubkey,
}) => {
await gotoApp();
const pubkey = await loginAs(page);
expect(pubkey).toBe(testPubkey);
const state = await page.evaluate(() => {
return (window as any).__bitvidTest__.getAppState();
});
expect(state.isLoggedIn).toBe(true);
expect(state.activePubkey).toBe(testPubkey);
});
test("can seed events into mock relay and retrieve them", async ({
relay,
seedEvent,
clearRelay,
}) => {
await clearRelay();
await seedEvent({
title: "Test Video Alpha",
url: "https://example.com/alpha.mp4",
dTag: "alpha-001",
});
await seedEvent({
title: "Test Video Beta",
url: "https://example.com/beta.mp4",
magnet: "magnet:?xt=urn:btih:0000000000000000000000000000000000000000",
dTag: "beta-001",
});
// Verify via HTTP API
const resp = await fetch("http://127.0.0.1:8878/events");
const events = await resp.json();
expect(events.length).toBe(2);
});
test("relay health endpoint works", async ({ relay }) => {
const resp = await fetch("http://127.0.0.1:8878/health");
const health = await resp.json();
expect(health.ok).toBe(true);
expect(typeof health.eventCount).toBe("number");
expect(typeof health.connectionCount).toBe("number");
});
});
test.describe("Data-testid selectors are present", () => {
test("critical UI elements have data-testid attributes", async ({
page,
relay,
gotoApp,
}) => {
await gotoApp();
// Header controls
await expect(page.locator('[data-testid="login-button"]')).toBeVisible();
await expect(page.locator('[data-testid="search-input"]')).toBeVisible();
// Upload button is hidden until logged in — verify it exists in DOM
const uploadBtn = page.locator('[data-testid="upload-button"]');
await expect(uploadBtn).toBeAttached();
});
test("login modal has testid selectors", async ({
page,
relay,
gotoApp,
}) => {
await gotoApp();
// 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: 5000,
});
// Check provider buttons exist
const providerButtons = page.locator(
'[data-testid="login-provider-button"]',
);
// At least one provider button should be present
await expect(providerButtons.first()).toBeVisible({ timeout: 5000 });
});
});
test.describe("App state inspection", () => {
test("getAppState reports logged-out state by default", async ({
page,
relay,
gotoApp,
}) => {
await gotoApp();
const state = await page.evaluate(() => {
return (window as any).__bitvidTest__.getAppState();
});
expect(state.isLoggedIn).toBe(false);
expect(state.activePubkey).toBeNull();
});
test("logout clears active signer", async ({
page,
relay,
gotoApp,
loginAs,
}) => {
await gotoApp();
await loginAs(page);
let state = await page.evaluate(() => {
return (window as any).__bitvidTest__.getAppState();
});
expect(state.isLoggedIn).toBe(true);
await page.evaluate(() => {
(window as any).__bitvidTest__.logout();
});
state = await page.evaluate(() => {
return (window as any).__bitvidTest__.getAppState();
});
expect(state.isLoggedIn).toBe(false);
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* Playwright test fixture for bitvid agent testing.
*
* Provides:
* - Local mock relay with HTTP seeding API
* - Programmatic login via the test harness
* - Test data seeding helpers
* - App state inspection utilities
*
* Usage:
* import { test, expect } from './helpers/bitvidTestFixture';
*
* test('can publish a video', async ({ page, relay, seedEvent, loginAs }) => {
* await loginAs(page);
* // ... interact with the app
* });
*/
import { test as base, expect, type Page } from "@playwright/test";
import { startRelay } from "../../../scripts/agent/simple-relay.mjs";
import { finalizeEvent, getPublicKey } from "nostr-tools";
import { WebSocket } from "ws";
// Polyfill WebSocket for node environment
if (!global.WebSocket) {
(global as any).WebSocket = WebSocket;
}
// Fixed test key for deterministic testing.
// This is a throwaway key used only in test environments.
const TEST_PRIVATE_KEY_HEX =
"7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b";
const TEST_SK = Uint8Array.from(Buffer.from(TEST_PRIVATE_KEY_HEX, "hex"));
const TEST_PK = getPublicKey(TEST_SK);
const RELAY_PORT = 8877;
const RELAY_HTTP_PORT = 8878;
const RELAY_WS_URL = `ws://127.0.0.1:${RELAY_PORT}`;
const RELAY_HTTP_URL = `http://127.0.0.1:${RELAY_HTTP_PORT}`;
export interface TestVideoEvent {
title: string;
url?: string;
magnet?: string;
description?: string;
dTag?: string;
}
/**
* Create a signed Nostr video event (kind 30078) for test seeding.
*/
function createVideoEvent(video: TestVideoEvent) {
const dTag = video.dTag || `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contentObj: Record<string, unknown> = {
version: 3,
title: video.title,
videoRootId: dTag,
mode: "dev",
isPrivate: false,
deleted: false,
};
if (video.url) contentObj.url = video.url;
if (video.magnet) contentObj.magnet = video.magnet;
if (video.description) contentObj.description = video.description;
const tags: string[][] = [
["d", dTag],
["title", video.title],
];
if (video.url) tags.push(["url", video.url]);
return finalizeEvent(
{
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags,
content: JSON.stringify(contentObj),
pubkey: TEST_PK,
},
TEST_SK,
);
}
/**
* Seed an event into the relay via HTTP API.
*/
async function seedEventViaHttp(event: any) {
const resp = await fetch(`${RELAY_HTTP_URL}/seed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(event),
});
if (!resp.ok) {
throw new Error(`Failed to seed event: ${resp.status} ${await resp.text()}`);
}
return resp.json();
}
/**
* Clear all events from the relay.
*/
async function clearRelayEvents() {
const resp = await fetch(`${RELAY_HTTP_URL}/events`, { method: "DELETE" });
if (!resp.ok) {
throw new Error(`Failed to clear events: ${resp.status}`);
}
}
/**
* Navigate to bitvid with test mode and relay overrides enabled.
*/
async function gotoWithTestMode(page: Page, path = "/") {
const separator = path.includes("?") ? "&" : "?";
const testUrl = `${path}${separator}__test__=1&__testRelays__=${encodeURIComponent(RELAY_WS_URL)}`;
// Set up localStorage before navigation
await page.addInitScript(() => {
localStorage.setItem("hasSeenDisclaimer", "true");
localStorage.setItem("__bitvidTestMode__", "1");
});
await page.goto(testUrl);
// Wait for the test harness to be available
await page.waitForFunction(
() => typeof (window as any).__bitvidTest__ === "object",
{ timeout: 15000 },
);
}
/**
* Login programmatically via the test harness.
*/
async function loginWithTestKey(page: Page) {
const pubkey = await page.evaluate(async (hexKey) => {
const harness = (window as any).__bitvidTest__;
if (!harness) throw new Error("Test harness not installed");
return harness.loginWithNsec(hexKey);
}, TEST_PRIVATE_KEY_HEX);
return pubkey;
}
// ------------------------------------------------------------------
// Fixture type definitions
// ------------------------------------------------------------------
type BitvidFixtures = {
relay: Awaited<ReturnType<typeof startRelay>>;
seedEvent: (video: TestVideoEvent) => Promise<any>;
seedRawEvent: (event: any) => Promise<any>;
clearRelay: () => Promise<void>;
gotoApp: (path?: string) => Promise<void>;
loginAs: (page: Page) => Promise<string>;
testPubkey: string;
testPrivateKey: string;
relayUrl: string;
};
// ------------------------------------------------------------------
// Shared relay fixture (one relay per worker)
// ------------------------------------------------------------------
export const test = base.extend<BitvidFixtures>({
relay: [
async ({}, use) => {
const relayInstance = startRelay(RELAY_PORT, { httpPort: RELAY_HTTP_PORT });
await use(relayInstance);
await relayInstance.close();
},
{ scope: "test" },
],
seedEvent: async ({ relay }, use) => {
await use(async (video: TestVideoEvent) => {
const event = createVideoEvent(video);
return seedEventViaHttp(event);
});
},
seedRawEvent: async ({ relay }, use) => {
await use(async (event: any) => {
return seedEventViaHttp(event);
});
},
clearRelay: async ({ relay }, use) => {
await use(async () => {
await clearRelayEvents();
});
},
gotoApp: async ({ page }, use) => {
await use(async (path = "/") => {
await gotoWithTestMode(page, path);
});
},
loginAs: async ({}, use) => {
await use(async (page: Page) => {
return loginWithTestKey(page);
});
},
testPubkey: TEST_PK,
testPrivateKey: TEST_PRIVATE_KEY_HEX,
relayUrl: RELAY_WS_URL,
});
export { expect };