From e2baa19aa11beb11d0b5cd8625c917e268bfed91 Mon Sep 17 00:00:00 2001 From: DavisVT Date: Sun, 28 Jun 2026 05:38:21 +0100 Subject: [PATCH] feat: add Playwright E2E tests for WebSocket event flow Configure GitHub Actions to run Playwright on PRs to main, add an end-to-end test that injects a mock Soroban RPC event through the WebSocket server, and verify translated text appears in the dashboard DOM. Co-authored-by: Cursor --- .github/workflows/playwright.yml | 90 +++++++++++++++++++++++++ components/dashboard/EventFeedTable.tsx | 1 + e2e/helpers/inject-event.ts | 23 +++++++ e2e/websocket-event-flow.spec.ts | 57 ++++++++++++++++ lib/hooks/useLiveFeed.ts | 2 +- lib/stellar/indexer.ts | 6 +- package-lock.json | 64 ++++++++++++++++++ package.json | 4 ++ playwright.config.ts | 41 +++++++++++ server.ts | 59 +++++++++++++--- 10 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 e2e/helpers/inject-event.ts create mode 100644 e2e/websocket-event-flow.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..8425a38 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,90 @@ +name: Playwright E2E Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + e2e-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: openaudit + POSTGRES_PASSWORD: openaudit + POSTGRES_DB: openaudit + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Setup database + env: + DATABASE_URL: postgresql://openaudit:openaudit@localhost:5432/openaudit + run: | + npx prisma generate + npx prisma migrate deploy --schema=prisma/schema.prisma || true + + - name: Build application + run: npm run build + env: + DATABASE_URL: postgresql://openaudit:openaudit@localhost:5432/openaudit + NEXT_PUBLIC_NETWORK: testnet + + - name: Run Playwright tests + run: npx playwright test + env: + DATABASE_URL: postgresql://openaudit:openaudit@localhost:5432/openaudit + NEXT_PUBLIC_NETWORK: testnet + E2E_TEST_MODE: "true" + CI: true + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + - name: Upload test traces + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/components/dashboard/EventFeedTable.tsx b/components/dashboard/EventFeedTable.tsx index 33669b1..54ab447 100644 --- a/components/dashboard/EventFeedTable.tsx +++ b/components/dashboard/EventFeedTable.tsx @@ -203,6 +203,7 @@ export function EventFeedTable({ return ( { + const response = await fetch(`${BASE_URL}/e2e/inject-event`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(rawEvent), + }); + + if (!response.ok) { + throw new Error(`Failed to inject event: ${response.status} ${await response.text()}`); + } + + return (await response.json()) as TranslatedEvent; +} diff --git a/e2e/websocket-event-flow.spec.ts b/e2e/websocket-event-flow.spec.ts new file mode 100644 index 0000000..a488d84 --- /dev/null +++ b/e2e/websocket-event-flow.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from "@playwright/test"; +import type { RawEvent } from "../lib/translator/types"; +import { injectMockSorobanEvent } from "./helpers/inject-event"; + +/** + * E2E: Soroban RPC event → translation → WebSocket → translated text in DOM + */ + +const MOCK_SOROBAN_RPC_EVENT: RawEvent = { + id: `e2e-${Date.now()}-0`, + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + topics: [ + "0x0000000000000000000000000000000000000000000000000000000074726e73", + "0x00000012000000000000000085a825af25ab38c944150cc569311cf76c80b8b521297c049c5c53204cd43e38", + "0x000000120000000000000000fa6798a578d9f9f012f70a00cae3d6b15a7ada4518f98ad68c0cab21d16a0f5d", + ], + data: "0x00000000000000000000000000000000000000000005F5E100", + ledger: 52_341_001, + timestamp: Math.floor(Date.now() / 1000), + txHash: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", +}; + +test.describe("WebSocket Event Flow", () => { + test("displays translated event in DOM after WebSocket broadcast", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const wsPromise = page.waitForEvent("websocket"); + await page.getByRole("button", { name: /live feed/i }).click(); + const ws = await wsPromise; + await ws.waitForEvent("framesreceived"); + + const translated = await injectMockSorobanEvent(MOCK_SOROBAN_RPC_EVENT); + + expect(translated.status).toBe("translated"); + expect(translated.description).toContain("USDC"); + + const eventRow = page.locator("[data-testid='event-row']").first(); + await expect(eventRow).toBeVisible({ timeout: 10_000 }); + await expect(eventRow.getByText(translated.description!)).toBeVisible(); + await expect(eventRow.getByText("Transfer")).toBeVisible(); + }); + + test("shows live indicator when WebSocket is connected", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const liveButton = page.getByRole("button", { name: /live feed/i }); + await expect(liveButton).toBeVisible(); + await liveButton.click(); + + await expect(page.getByRole("button", { name: /stop live/i })).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator(".animate-pulse")).toBeVisible(); + }); +}); diff --git a/lib/hooks/useLiveFeed.ts b/lib/hooks/useLiveFeed.ts index f390500..0f73353 100644 --- a/lib/hooks/useLiveFeed.ts +++ b/lib/hooks/useLiveFeed.ts @@ -131,7 +131,7 @@ export function useLiveFeed(onEvent: (event: TranslatedEvent) => void): LiveFeed setNewEventIds((prev) => new Set(prev).add(event.raw.id)); // Remove the highlight badge after the animation completes (600 ms). - setTimeout(() => { + const timeoutId = setTimeout(() => { setNewEventIds((prev) => { const next = new Set(prev); next.delete(event.raw.id); diff --git a/lib/stellar/indexer.ts b/lib/stellar/indexer.ts index a1f18b0..020a281 100644 --- a/lib/stellar/indexer.ts +++ b/lib/stellar/indexer.ts @@ -499,6 +499,10 @@ export interface StreamingIndexerOptions { * Called on the consumer thread — safe to perform async work. */ onDag?: (dag: ExecutionDag) => void | Promise; + /** Optional durable state store for resuming from the last processed ledger. */ + stateStore?: IngestionStateStore; + /** Number of ledgers to look back on cold start. */ + coldStartLookbackLedgers?: number; } /** @@ -556,7 +560,7 @@ export function startHorizonStreamingIndexer(options: StreamingIndexerOptions): stop: () => void; getMetrics: () => IngestionPoolMetrics; } { - const { networkConfig, contractIds, onEvent, onError, workerCount, maxQueueSize, onDag } = options; + const { networkConfig, contractIds, onEvent, onError, workerCount, maxQueueSize, onDag, stateStore, coldStartLookbackLedgers } = options; const server = new Horizon.Server(networkConfig.horizonUrl); let isRunning = true; diff --git a/package-lock.json b/package-lock.json index 2c624ba..7d8955b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "ws": "^8.21.0" }, "devDependencies": { + "@playwright/test": "^1.51.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/bull": "^4.10.4", @@ -2158,6 +2159,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -10648,6 +10665,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", diff --git a/package.json b/package.json index 61d91fd..7d44544 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,9 @@ "test:wasm:watch": "vitest lib/wasm-sandbox", "test:wasm:manual": "node scripts/test-wasm-sandbox.js", "test:wasm:benchmark": "node scripts/test-wasm-sandbox.js benchmark", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", "wasm:build-examples": "cd lib/wasm-sandbox/examples/rust && ./build-all.sh", "build:cli": "tsc cli/open-audit-cli.ts --outDir dist --module commonjs --target es2020 --moduleResolution node --esModuleInterop --resolveJsonModule --skipLibCheck", "cli": "node dist/cli/open-audit-cli.js", @@ -95,6 +98,7 @@ "ws": "^8.21.0" }, "devDependencies": { + "@playwright/test": "^1.51.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/bull": "^4.10.4", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b3a5334 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright E2E test configuration for Open-Audit + * + * Tests the core data flow: Soroban RPC event → WebSocket → Translated text in DOM + */ +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [["html"], ["github"]] : "html", + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npx ts-node --project tsconfig.server.json server.ts", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + stdout: "pipe", + stderr: "pipe", + env: { + E2E_TEST_MODE: "true", + NEXT_PUBLIC_NETWORK: "testnet", + DATABASE_URL: + process.env.DATABASE_URL ?? + "postgresql://openaudit:openaudit@localhost:5432/openaudit", + }, + }, +}); diff --git a/server.ts b/server.ts index bcc318e..0c62a1f 100644 --- a/server.ts +++ b/server.ts @@ -42,11 +42,14 @@ import next from "next"; import { WebSocketServer, WebSocket } from "ws"; import { MOCK_RAW_EVENTS } from "./lib/mock-data"; import { translateEvent } from "./lib/translator/registry"; +import type { RawEvent } from "./lib/translator/types"; import { processEventForIpfs } from "./lib/ipfs/offloader"; -import { createFileIngestionStateStore, startResilientEventIngestion } from "./lib/stellar/indexer"; +import { createFileIngestionStateStore } from "./lib/stellar/ingestion-state"; +import { startResilientEventIngestion } from "./lib/stellar/indexer"; import { getNetworkConfig } from "./lib/stellar/client"; import { captureExceptionSync, eventsIngestedTotal, metricsHandler, recordTranslationDuration, startTelemetry } from "./lib/telemetry"; import { startRetentionScheduler } from "./lib/retention/scheduler"; +import { schedulePruner } from "./lib/retention/pruner"; const dev = process.env.NODE_ENV !== "production"; const port = parseInt(process.env.PORT ?? "3000", 10); @@ -83,12 +86,48 @@ const handle = app.getRequestHandler(); app.prepare().then(async () => { await startTelemetry(); startRetentionScheduler(); + + let broadcast: (data: unknown) => void = () => {}; + const httpServer = createServer((req, res) => { res.setHeader( "Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss://* https://horizon-testnet.stellar.org https://soroban-testnet.stellar.org https://horizon.stellar.org https://mainnet.stellar.validationcloud.io; img-src 'self' data:; font-src 'self' data:;" ); const parsedUrl = parse(req.url ?? "/", true); + + if (process.env.E2E_TEST_MODE === "true" && parsedUrl.pathname === "/e2e/inject-event") { + if (req.method !== "POST") { + res.statusCode = 405; + res.end("Method Not Allowed"); + return; + } + + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + void (async () => { + try { + const rawEvent = JSON.parse(Buffer.concat(chunks).toString("utf8")) as RawEvent; + const processed = await processEventForIpfs(rawEvent); + rawEvent.data = processed.data; + rawEvent.topics = processed.topics; + const translated = recordTranslationDuration(rawEvent.contractId, () => + translateEvent(rawEvent) + ); + broadcast(translated); + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(translated)); + } catch (error) { + res.statusCode = 400; + res.end(error instanceof Error ? error.message : String(error)); + } + })(); + }); + return; + } + handle(req, res, parsedUrl); }); @@ -121,21 +160,22 @@ app.prepare().then(async () => { }); /** Broadcast a JSON payload to every connected client. */ - function broadcast(data: unknown): void { + broadcast = (data: unknown): void => { const message = JSON.stringify(data); wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message); } }); - } + }; - // Start the real-time streaming indexer - const stateStore = createFileIngestionStateStore( - process.env.INGESTION_STATE_FILE ?? ".open-audit/ingestion-state.json" - ); + if (process.env.E2E_TEST_MODE !== "true") { + // Start the real-time streaming indexer + const stateStore = createFileIngestionStateStore( + process.env.INGESTION_STATE_FILE ?? ".open-audit/ingestion-state.json" + ); - const indexer = startResilientEventIngestion({ + startResilientEventIngestion({ networkConfig: getNetworkConfig(), stateStore, coldStartLookbackLedgers: Number(process.env.INGESTION_COLD_START_LOOKBACK_LEDGERS ?? "100"), @@ -175,7 +215,8 @@ app.prepare().then(async () => { captureExceptionSync(err, { context: { operation: "resilientStreamingIndexer" } }); console.error("[Indexer] Streaming error:", err); }, - }); + }); + } // Start the retention pruner cron (no-op if RETENTION_ENABLED=false) schedulePruner();