From 2a7534a129200c25866f7bfa9ee06085edd05893 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 8 Jan 2026 16:31:07 +0100 Subject: [PATCH 1/9] [ci] e2e tests for web Signed-off-by: Peter Wielander --- .github/workflows/tests.yml | 93 +++++- packages/web-e2e/.gitignore | 12 + packages/web-e2e/README.md | 95 ++++++ packages/web-e2e/package.json | 20 ++ packages/web-e2e/playwright.config.ts | 51 ++++ packages/web-e2e/src/fixtures/index.ts | 176 +++++++++++ packages/web-e2e/src/fixtures/web-server.ts | 253 ++++++++++++++++ packages/web-e2e/src/web-ui.spec.ts | 309 ++++++++++++++++++++ packages/web-e2e/tsconfig.json | 16 + pnpm-lock.yaml | 131 ++++++--- 10 files changed, 1113 insertions(+), 43 deletions(-) create mode 100644 packages/web-e2e/.gitignore create mode 100644 packages/web-e2e/README.md create mode 100644 packages/web-e2e/package.json create mode 100644 packages/web-e2e/playwright.config.ts create mode 100644 packages/web-e2e/src/fixtures/index.ts create mode 100644 packages/web-e2e/src/fixtures/web-server.ts create mode 100644 packages/web-e2e/src/web-ui.spec.ts create mode 100644 packages/web-e2e/tsconfig.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cdd316003..02cc86e40 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -599,11 +599,94 @@ jobs: setup-command: ${{ matrix.world.setup-command }} secrets: inherit + # Web UI E2E Tests - runs after main e2e tests to verify the web UI displays workflow runs correctly + web-e2e: + name: Web UI E2E Tests (${{ matrix.backend }}) + runs-on: ubuntu-latest + needs: [e2e-vercel-prod, e2e-local-prod] + if: always() && !cancelled() + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + backend: [local, vercel] + + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup environment + uses: ./.github/actions/setup-workflow-dev + with: + build-packages: 'false' + + - name: Download E2E artifacts (for runId metadata) + uses: actions/download-artifact@v4 + with: + pattern: e2e-results-* + path: e2e-results + merge-multiple: true + + - name: Copy metadata files to expected location + run: | + # Copy metadata files from artifacts to root for the tests to find + find e2e-results -name "e2e-metadata-*.json" -exec cp {} . \; || true + ls -la e2e-metadata-*.json 2>/dev/null || echo "No metadata files found" + + - name: Build packages + run: pnpm turbo run build --filter='@workflow/web' --filter='@workflow/cli' + + - name: Install Playwright browsers + working-directory: packages/web-e2e + run: npx playwright install chromium --with-deps + + - name: Run Web E2E Tests (Local Backend) + if: matrix.backend == 'local' + working-directory: packages/web-e2e + run: pnpm test + env: + WORKFLOW_WEB_E2E_BACKEND: local + APP_NAME: nextjs-turbopack + + - name: Run Web E2E Tests (Vercel Backend) + if: matrix.backend == 'vercel' + working-directory: packages/web-e2e + run: pnpm test + env: + WORKFLOW_WEB_E2E_BACKEND: vercel + APP_NAME: nextjs-turbopack + WORKFLOW_VERCEL_AUTH_TOKEN: ${{ secrets.VERCEL_LABS_TOKEN }} + WORKFLOW_VERCEL_PROJECT: prj_yjkM7UdHliv8bfxZ1sMJQf1pMpdi + WORKFLOW_VERCEL_TEAM: vercel-labs + WORKFLOW_VERCEL_ENV: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: web-e2e-report-${{ matrix.backend }} + path: packages/web-e2e/playwright-report/ + retention-days: 7 + if-no-files-found: ignore + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: web-e2e-results-${{ matrix.backend }} + path: packages/web-e2e/test-results/ + retention-days: 7 + if-no-files-found: ignore + # Final job: Aggregate all E2E results and update PR comment summary: name: E2E Summary runs-on: ubuntu-latest - needs: [e2e-vercel-prod, e2e-local-dev, e2e-local-prod, e2e-local-postgres, e2e-windows, e2e-community] + needs: [e2e-vercel-prod, e2e-local-dev, e2e-local-prod, e2e-local-postgres, e2e-windows, e2e-community, web-e2e] if: always() && !cancelled() timeout-minutes: 10 @@ -634,6 +717,7 @@ jobs: POSTGRES_STATUS="${{ needs.e2e-local-postgres.result }}" WINDOWS_STATUS="${{ needs.e2e-windows.result }}" COMMUNITY_STATUS="${{ needs.e2e-community.result }}" + WEB_E2E_STATUS="${{ needs.web-e2e.result }}" echo "vercel=$VERCEL_STATUS" >> $GITHUB_OUTPUT echo "local-dev=$LOCAL_DEV_STATUS" >> $GITHUB_OUTPUT @@ -641,15 +725,17 @@ jobs: echo "postgres=$POSTGRES_STATUS" >> $GITHUB_OUTPUT echo "windows=$WINDOWS_STATUS" >> $GITHUB_OUTPUT echo "community=$COMMUNITY_STATUS" >> $GITHUB_OUTPUT + echo "web-e2e=$WEB_E2E_STATUS" >> $GITHUB_OUTPUT # Community world failures are warnings, not errors + # Web E2E failures are also warnings (non-blocking) since they test the UI, not core functionality if [[ "$VERCEL_STATUS" == "failure" || "$LOCAL_DEV_STATUS" == "failure" || "$LOCAL_PROD_STATUS" == "failure" || "$POSTGRES_STATUS" == "failure" || "$WINDOWS_STATUS" == "failure" ]]; then echo "has_failures=true" >> $GITHUB_OUTPUT else echo "has_failures=false" >> $GITHUB_OUTPUT fi - if [[ "$COMMUNITY_STATUS" == "failure" ]]; then + if [[ "$COMMUNITY_STATUS" == "failure" || "$WEB_E2E_STATUS" == "failure" ]]; then echo "has_warnings=true" >> $GITHUB_OUTPUT else echo "has_warnings=false" >> $GITHUB_OUTPUT @@ -689,8 +775,9 @@ jobs: message: | --- - ⚠️ **Community world tests failed** (non-blocking): + ⚠️ **Non-blocking test failures:** - Community Worlds: ${{ needs.e2e-community.result }} + - Web UI E2E: ${{ needs.web-e2e.result }} Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. diff --git a/packages/web-e2e/.gitignore b/packages/web-e2e/.gitignore new file mode 100644 index 000000000..d7315f632 --- /dev/null +++ b/packages/web-e2e/.gitignore @@ -0,0 +1,12 @@ +# Test results and artifacts +test-results/ +playwright-report/ +test-results.json + +# Node +node_modules/ +dist/ + +# Playwright +.playwright/ + diff --git a/packages/web-e2e/README.md b/packages/web-e2e/README.md new file mode 100644 index 000000000..57068f384 --- /dev/null +++ b/packages/web-e2e/README.md @@ -0,0 +1,95 @@ +# @workflow/web-e2e + +Playwright end-to-end tests for the Workflow Web UI. + +## Overview + +These tests verify that the Workflow Web UI correctly displays workflow runs, steps, hooks, and other data. They run after the main e2e tests and can use the runIds generated by those tests. + +## Test Coverage + +- **Runs List**: Tests the main runs table view, filters, and pagination +- **Run Detail View**: Tests the run detail page with trace, graph, and streams tabs +- **Hooks Tab**: Tests the hooks listing functionality +- **Workflows Tab**: Tests the workflows list (local backend only) +- **Navigation**: Tests navigating between views +- **Error Handling**: Tests graceful error handling for invalid data + +## Running Tests + +### Prerequisites + +1. Build the workspace packages: + ```bash + pnpm build + ``` + +2. (Optional) Run the main e2e tests first to generate runIds: + ```bash + APP_NAME=nextjs-turbopack DEPLOYMENT_URL=http://localhost:3000 pnpm test:e2e + ``` + +### Running Web E2E Tests + +```bash +# Run against local backend (default) +cd packages/web-e2e +pnpm test + +# Run against Vercel backend +WORKFLOW_WEB_E2E_BACKEND=vercel \ +WORKFLOW_VERCEL_AUTH_TOKEN= \ +WORKFLOW_VERCEL_PROJECT= \ +WORKFLOW_VERCEL_TEAM= \ +pnpm test + +# Run with headed browser for debugging +pnpm test:headed + +# Run with Playwright UI +pnpm test:ui +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `WORKFLOW_WEB_E2E_BACKEND` | Backend type: `local` or `vercel` | `local` | +| `WORKFLOW_WEB_PORT` | Port for the web UI server | `3456` | +| `APP_NAME` | Workbench app name (for local backend) | `nextjs-turbopack` | +| `WORKFLOW_VERCEL_AUTH_TOKEN` | Vercel auth token (for vercel backend) | - | +| `WORKFLOW_VERCEL_PROJECT` | Vercel project ID (for vercel backend) | - | +| `WORKFLOW_VERCEL_TEAM` | Vercel team slug (for vercel backend) | - | +| `WORKFLOW_VERCEL_ENV` | Vercel environment: `production` or `preview` | `preview` | + +## Test Fixtures + +The tests use custom Playwright fixtures that: + +1. **WebServer**: Manages the @workflow/web Next.js server lifecycle +2. **webPage**: A page pre-navigated to the web UI with correct config params +3. **e2eMetadata**: RunIds from previous e2e tests (if available) +4. **getRunId/getAnyRunId**: Helper functions to get runIds for testing + +## CI Integration + +These tests run as part of the GitHub Actions workflow after the main e2e tests complete. The workflow: + +1. Downloads e2e result artifacts (which include runId metadata) +2. Builds the web package +3. Runs Playwright tests against local and Vercel backends +4. Uploads test results and screenshots as artifacts + +## Development + +```bash +# Install dependencies +pnpm install + +# Run tests with debug output +DEBUG=pw:api pnpm test + +# View test report +pnpm report +``` + diff --git a/packages/web-e2e/package.json b/packages/web-e2e/package.json new file mode 100644 index 000000000..f4af9c609 --- /dev/null +++ b/packages/web-e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "@workflow/web-e2e", + "version": "0.0.1", + "private": true, + "description": "Playwright e2e tests for the Workflow Web UI", + "type": "module", + "scripts": { + "test": "playwright test", + "test:local": "WORKFLOW_WEB_E2E_BACKEND=local playwright test", + "test:vercel": "WORKFLOW_WEB_E2E_BACKEND=vercel playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/web-e2e/playwright.config.ts b/packages/web-e2e/playwright.config.ts new file mode 100644 index 000000000..bebb84c61 --- /dev/null +++ b/packages/web-e2e/playwright.config.ts @@ -0,0 +1,51 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for Workflow Web UI e2e tests. + * + * These tests run after the main e2e tests and verify that the web UI + * correctly displays workflow runs, steps, hooks, and other data. + */ +export default defineConfig({ + testDir: './src', + // Run tests in parallel when possible + fullyParallel: true, + // Fail the build on CI if you accidentally left test.only in the source code + forbidOnly: !!process.env.CI, + // Retry on CI only + retries: process.env.CI ? 2 : 0, + // Opt out of parallel tests on CI since we're spawning servers + workers: 1, + // Reporter to use + reporter: process.env.CI + ? [ + ['html', { open: 'never' }], + ['json', { outputFile: 'test-results.json' }], + ['list'], + ] + : [['html', { open: 'on-failure' }], ['list']], + // Shared settings for all the projects below + use: { + // Base URL to use in actions like `await page.goto('/')`. + baseURL: process.env.WORKFLOW_WEB_URL || 'http://localhost:3456', + // Collect trace when retrying the failed test + trace: 'on-first-retry', + // Take screenshot on failure + screenshot: 'only-on-failure', + }, + // Configure projects for major browsers + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + // Timeout for each test + timeout: 60_000, + // Timeout for expect assertions + expect: { + timeout: 10_000, + }, + // Output folder for test artifacts + outputDir: './test-results', +}); diff --git a/packages/web-e2e/src/fixtures/index.ts b/packages/web-e2e/src/fixtures/index.ts new file mode 100644 index 000000000..b66d37e8c --- /dev/null +++ b/packages/web-e2e/src/fixtures/index.ts @@ -0,0 +1,176 @@ +import { test as base, type Page } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + WebServer, + getDefaultConfig, + type WebServerConfig, +} from './web-server.js'; + +/** + * E2E metadata from the main e2e tests + */ +export interface E2EMetadata { + runIds: Array<{ + testName: string; + runId: string; + timestamp: string; + }>; + vercel?: { + projectSlug: string; + environment: string; + teamSlug: string; + }; +} + +/** + * Extended test fixtures for web e2e tests + */ +export interface WebE2EFixtures { + /** + * The web server instance + */ + webServer: WebServer; + + /** + * A page that's already navigated to the web UI with correct config params + */ + webPage: Page; + + /** + * E2E metadata with runIds from previous e2e tests (if available) + */ + e2eMetadata: E2EMetadata | null; + + /** + * Get a specific runId from the e2e metadata by test name + */ + getRunId: (testName: string) => string | null; + + /** + * Get the first available runId from the e2e metadata + */ + getAnyRunId: () => string | null; +} + +/** + * Worker-scoped fixtures (shared across all tests in a worker) + */ +export interface WebE2EWorkerFixtures { + /** + * Web server configuration + */ + webServerConfig: WebServerConfig; + + /** + * The shared web server instance (per worker) + */ + sharedWebServer: WebServer; +} + +/** + * Find e2e metadata files in the results directory + */ +function findE2EMetadata( + appName: string, + backend: 'local' | 'vercel' +): E2EMetadata | null { + const workspaceRoot = path.resolve(import.meta.dirname, '../../../../'); + + // Look for metadata files in various locations + const possiblePaths = [ + // CI artifacts location + path.join( + workspaceRoot, + 'e2e-results', + `e2e-metadata-${appName}-${backend}.json` + ), + // Root location (from test:e2e runs) + path.join(workspaceRoot, `e2e-metadata-${appName}-${backend}.json`), + // Local location for testing + path.join( + workspaceRoot, + 'packages', + 'web-e2e', + `e2e-metadata-${appName}-${backend}.json` + ), + ]; + + for (const metadataPath of possiblePaths) { + try { + if (fs.existsSync(metadataPath)) { + const content = fs.readFileSync(metadataPath, 'utf-8'); + return JSON.parse(content) as E2EMetadata; + } + } catch (error) { + console.warn( + `[WebE2E] Failed to read metadata from ${metadataPath}:`, + error + ); + } + } + + return null; +} + +/** + * Extended Playwright test with web e2e fixtures + */ +export const test = base.extend({ + // Worker-scoped: shared across all tests in a worker + webServerConfig: [ + async ({}, use) => { + const config = getDefaultConfig(); + await use(config); + }, + { scope: 'worker' }, + ], + + sharedWebServer: [ + async ({ webServerConfig }, use) => { + const server = new WebServer(webServerConfig); + await server.start(); + await use(server); + await server.stop(); + }, + { scope: 'worker' }, + ], + + // Test-scoped fixtures + webServer: async ({ sharedWebServer }, use) => { + await use(sharedWebServer); + }, + + webPage: async ({ page, webServer }, use) => { + // Navigate to the web UI with configuration params + const url = webServer.getUrl('/'); + await page.goto(url); + await use(page); + }, + + e2eMetadata: async ({ webServerConfig }, use) => { + const appName = process.env.APP_NAME || 'nextjs-turbopack'; + const metadata = findE2EMetadata(appName, webServerConfig.backend); + await use(metadata); + }, + + getRunId: async ({ e2eMetadata }, use) => { + const fn = (testName: string): string | null => { + if (!e2eMetadata?.runIds) return null; + const entry = e2eMetadata.runIds.find((r) => r.testName === testName); + return entry?.runId || null; + }; + await use(fn); + }, + + getAnyRunId: async ({ e2eMetadata }, use) => { + const fn = (): string | null => { + if (!e2eMetadata?.runIds || e2eMetadata.runIds.length === 0) return null; + return e2eMetadata.runIds[0].runId; + }; + await use(fn); + }, +}); + +export { expect } from '@playwright/test'; +export type { WebServer, WebServerConfig }; diff --git a/packages/web-e2e/src/fixtures/web-server.ts b/packages/web-e2e/src/fixtures/web-server.ts new file mode 100644 index 000000000..2a28a79c3 --- /dev/null +++ b/packages/web-e2e/src/fixtures/web-server.ts @@ -0,0 +1,253 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +/** + * Configuration for the web server + */ +export interface WebServerConfig { + /** + * Backend type: 'local' or 'vercel' + */ + backend: 'local' | 'vercel'; + + /** + * Port to run the web server on + */ + port: number; + + /** + * Vercel-specific configuration (required when backend is 'vercel') + */ + vercel?: { + authToken: string; + project: string; + team: string; + env?: 'production' | 'preview'; + }; + + /** + * Path to the workbench app directory (for local backend) + */ + appDir?: string; +} + +/** + * Web server manager for Playwright tests. + * Spawns the @workflow/web Next.js server and manages its lifecycle. + */ +export class WebServer { + private process: ChildProcess | null = null; + private config: WebServerConfig; + private started = false; + + constructor(config: WebServerConfig) { + this.config = config; + } + + /** + * Get the base URL for the web server + */ + get baseUrl(): string { + return `http://localhost:${this.config.port}`; + } + + /** + * Get query parameters for the web server based on configuration + */ + getQueryParams(): URLSearchParams { + const params = new URLSearchParams(); + params.set('resource', 'run'); + + if (this.config.backend === 'local') { + params.set('backend', 'local'); + if (this.config.appDir) { + // For local backend, set the data directory to the workbench app's .workflow folder + const dataDir = path.join(this.config.appDir, '.workflow'); + params.set('dataDir', dataDir); + } + } else if (this.config.backend === 'vercel' && this.config.vercel) { + params.set('backend', 'vercel'); + params.set('authToken', this.config.vercel.authToken); + params.set('project', this.config.vercel.project); + params.set('team', this.config.vercel.team); + if (this.config.vercel.env) { + params.set('env', this.config.vercel.env); + } + } + + return params; + } + + /** + * Get the full URL including query parameters + */ + getUrl(pathname = '/'): string { + const params = this.getQueryParams(); + const url = new URL(pathname, this.baseUrl); + // Merge query params + for (const [key, value] of params.entries()) { + url.searchParams.set(key, value); + } + return url.toString(); + } + + /** + * Start the web server + */ + async start(): Promise { + if (this.started) { + console.log('[WebServer] Already started'); + return; + } + + // Check if server is already running on the port + if (await this.isRunning()) { + console.log( + `[WebServer] Server already running on port ${this.config.port}` + ); + this.started = true; + return; + } + + // Find the @workflow/web package + const webPackagePath = this.findWebPackagePath(); + console.log(`[WebServer] Using web package at: ${webPackagePath}`); + + // Start the Next.js server + const shellCommand = `npx next start -p ${this.config.port}`; + console.log(`[WebServer] Starting: ${shellCommand}`); + + this.process = spawn(shellCommand, { + shell: true, + cwd: webPackagePath, + detached: false, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + // Don't pass workflow-specific env vars to the server + // They will be passed via query params + WORKFLOW_TARGET_WORLD: undefined, + WORKFLOW_VERCEL_ENV: undefined, + WORKFLOW_VERCEL_AUTH_TOKEN: undefined, + WORKFLOW_VERCEL_PROJECT: undefined, + WORKFLOW_VERCEL_TEAM: undefined, + WORKFLOW_LOCAL_DATA_DIR: undefined, + }, + }); + + // Log server output + this.process.stdout?.on('data', (data) => { + console.log(`[WebServer] ${data.toString().trim()}`); + }); + + this.process.stderr?.on('data', (data) => { + console.error(`[WebServer] ${data.toString().trim()}`); + }); + + this.process.on('error', (error) => { + console.error(`[WebServer] Process error: ${error}`); + }); + + this.process.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + console.log(`[WebServer] Exited with code ${code}`); + } + this.process = null; + }); + + // Wait for server to be ready + const maxRetries = 60; + const retryInterval = 1000; + + for (let i = 0; i < maxRetries; i++) { + await this.sleep(retryInterval); + if (await this.isRunning()) { + console.log(`[WebServer] Server ready on port ${this.config.port}`); + this.started = true; + return; + } + } + + throw new Error( + `[WebServer] Failed to start within ${maxRetries * retryInterval}ms` + ); + } + + /** + * Stop the web server + */ + async stop(): Promise { + if (this.process && !this.process.killed) { + console.log('[WebServer] Stopping server...'); + this.process.kill('SIGTERM'); + this.process = null; + } + this.started = false; + } + + /** + * Check if the server is running + */ + async isRunning(): Promise { + try { + const response = await fetch(this.baseUrl, { method: 'HEAD' }); + return response.ok; + } catch { + return false; + } + } + + /** + * Find the @workflow/web package path + */ + private findWebPackagePath(): string { + try { + // Try to resolve from the workspace root + const requireFromHere = createRequire(import.meta.url); + const packageJsonPath = requireFromHere.resolve( + '@workflow/web/package.json' + ); + return path.dirname(packageJsonPath); + } catch { + // Fallback to relative path from this package + return path.resolve(import.meta.dirname, '../../../web'); + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +/** + * Get default web server configuration from environment variables + */ +export function getDefaultConfig(): WebServerConfig { + const backend = + (process.env.WORKFLOW_WEB_E2E_BACKEND as 'local' | 'vercel') || 'local'; + const port = parseInt(process.env.WORKFLOW_WEB_PORT || '3456', 10); + + const config: WebServerConfig = { + backend, + port, + }; + + if (backend === 'local') { + // Get the app directory from APP_NAME + const appName = process.env.APP_NAME || 'nextjs-turbopack'; + const workspaceRoot = path.resolve(import.meta.dirname, '../../../../'); + config.appDir = path.join(workspaceRoot, 'workbench', appName); + } else if (backend === 'vercel') { + config.vercel = { + authToken: process.env.WORKFLOW_VERCEL_AUTH_TOKEN || '', + project: process.env.WORKFLOW_VERCEL_PROJECT || '', + team: process.env.WORKFLOW_VERCEL_TEAM || '', + env: + (process.env.WORKFLOW_VERCEL_ENV as 'production' | 'preview') || + 'preview', + }; + } + + return config; +} diff --git a/packages/web-e2e/src/web-ui.spec.ts b/packages/web-e2e/src/web-ui.spec.ts new file mode 100644 index 000000000..2816d9e0f --- /dev/null +++ b/packages/web-e2e/src/web-ui.spec.ts @@ -0,0 +1,309 @@ +import { test, expect } from './fixtures/index.js'; + +test.describe('Web UI - Runs List', () => { + test('should display the runs tab by default', async ({ webPage }) => { + // The runs tab should be active by default + const runsTab = webPage.getByRole('tab', { name: 'Runs' }); + await expect(runsTab).toBeVisible(); + await expect(runsTab).toHaveAttribute('data-state', 'active'); + }); + + test('should show the runs table or empty state', async ({ webPage }) => { + // Wait for the page to load + await webPage.waitForLoadState('networkidle'); + + // Should show either the runs table or an empty state + const table = webPage.getByRole('table'); + const emptyState = webPage.getByText('No workflow runs found'); + + // One of these should be visible + const tableVisible = await table.isVisible().catch(() => false); + const emptyVisible = await emptyState.isVisible().catch(() => false); + + expect(tableVisible || emptyVisible).toBe(true); + }); + + test('should display table headers when runs exist', async ({ + webPage, + e2eMetadata, + }) => { + // Skip if no runs from e2e tests + if (!e2eMetadata?.runIds?.length) { + test.skip(); + return; + } + + await webPage.waitForLoadState('networkidle'); + + // Wait a bit for data to load + await webPage.waitForTimeout(2000); + + // Check for table headers + const workflowHeader = webPage.getByRole('columnheader', { + name: 'Workflow', + }); + const runIdHeader = webPage.getByRole('columnheader', { name: 'Run ID' }); + const statusHeader = webPage.getByRole('columnheader', { name: 'Status' }); + + await expect(workflowHeader).toBeVisible(); + await expect(runIdHeader).toBeVisible(); + await expect(statusHeader).toBeVisible(); + }); + + test('should have filter controls', async ({ webPage }) => { + await webPage.waitForLoadState('networkidle'); + + // Should have workflow filter dropdown + const workflowFilter = webPage.getByRole('combobox').first(); + await expect(workflowFilter).toBeVisible(); + + // Should have refresh button + const refreshButton = webPage.getByRole('button', { name: /Refresh/i }); + await expect(refreshButton).toBeVisible(); + }); +}); + +test.describe('Web UI - Hooks Tab', () => { + test('should switch to hooks tab', async ({ webPage }) => { + const hooksTab = webPage.getByRole('tab', { name: 'Hooks' }); + await expect(hooksTab).toBeVisible(); + + await hooksTab.click(); + await expect(hooksTab).toHaveAttribute('data-state', 'active'); + }); + + test('should show hooks table or empty state', async ({ webPage }) => { + // Click on hooks tab + const hooksTab = webPage.getByRole('tab', { name: 'Hooks' }); + await hooksTab.click(); + + await webPage.waitForLoadState('networkidle'); + + // Should show either hooks table or empty state + const table = webPage.getByRole('table'); + const emptyState = webPage.getByText(/no.*hooks/i); + + const tableVisible = await table.isVisible().catch(() => false); + const emptyVisible = await emptyState.isVisible().catch(() => false); + + expect(tableVisible || emptyVisible).toBe(true); + }); +}); + +test.describe('Web UI - Workflows Tab (Local Only)', () => { + test('should show workflows tab for local backend', async ({ + webPage, + webServerConfig, + }) => { + // Skip if not local backend + if (webServerConfig.backend !== 'local') { + test.skip(); + return; + } + + const workflowsTab = webPage.getByRole('tab', { name: 'Workflows' }); + await expect(workflowsTab).toBeVisible(); + }); + + test('should switch to workflows tab and show content', async ({ + webPage, + webServerConfig, + }) => { + // Skip if not local backend + if (webServerConfig.backend !== 'local') { + test.skip(); + return; + } + + const workflowsTab = webPage.getByRole('tab', { name: 'Workflows' }); + await workflowsTab.click(); + await expect(workflowsTab).toHaveAttribute('data-state', 'active'); + + await webPage.waitForLoadState('networkidle'); + }); +}); + +test.describe('Web UI - Run Detail View', () => { + test('should navigate to run detail page', async ({ + webPage, + getAnyRunId, + e2eMetadata, + }) => { + // Skip if no run IDs available + const runId = getAnyRunId(); + if (!runId) { + test.skip(); + return; + } + + // Navigate to run detail page + const runDetailUrl = webPage.url().replace(/\?.*$/, '') + `run/${runId}`; + await webPage.goto(runDetailUrl); + + await webPage.waitForLoadState('networkidle'); + + // Should show breadcrumb back to runs + const breadcrumb = webPage.getByRole('link', { name: 'Runs' }); + await expect(breadcrumb).toBeVisible(); + }); + + test('should display run overview information', async ({ + webPage, + getAnyRunId, + }) => { + const runId = getAnyRunId(); + if (!runId) { + test.skip(); + return; + } + + // Navigate to run detail page + const baseUrl = webPage.url().split('?')[0]; + const runDetailUrl = `${baseUrl}run/${runId}`; + await webPage.goto(runDetailUrl); + + await webPage.waitForLoadState('networkidle'); + + // Wait for content to load + await webPage.waitForTimeout(2000); + + // Should show status section + const statusLabel = webPage.getByText('Status'); + await expect(statusLabel.first()).toBeVisible(); + + // Should show Run ID section + const runIdLabel = webPage.getByText('Run ID'); + await expect(runIdLabel.first()).toBeVisible(); + }); + + test('should display trace tab by default', async ({ + webPage, + getAnyRunId, + }) => { + const runId = getAnyRunId(); + if (!runId) { + test.skip(); + return; + } + + const baseUrl = webPage.url().split('?')[0]; + const runDetailUrl = `${baseUrl}run/${runId}`; + await webPage.goto(runDetailUrl); + + await webPage.waitForLoadState('networkidle'); + + // Trace tab should be active by default + const traceTab = webPage.getByRole('tab', { name: 'Trace' }); + await expect(traceTab).toBeVisible(); + await expect(traceTab).toHaveAttribute('data-state', 'active'); + }); + + test('should have streams tab', async ({ webPage, getAnyRunId }) => { + const runId = getAnyRunId(); + if (!runId) { + test.skip(); + return; + } + + const baseUrl = webPage.url().split('?')[0]; + const runDetailUrl = `${baseUrl}run/${runId}`; + await webPage.goto(runDetailUrl); + + await webPage.waitForLoadState('networkidle'); + + // Should have streams tab + const streamsTab = webPage.getByRole('tab', { name: 'Streams' }); + await expect(streamsTab).toBeVisible(); + }); + + test('should have graph tab for local backend', async ({ + webPage, + getAnyRunId, + webServerConfig, + }) => { + // Skip if not local backend + if (webServerConfig.backend !== 'local') { + test.skip(); + return; + } + + const runId = getAnyRunId(); + if (!runId) { + test.skip(); + return; + } + + const baseUrl = webPage.url().split('?')[0]; + const runDetailUrl = `${baseUrl}run/${runId}`; + await webPage.goto(runDetailUrl); + + await webPage.waitForLoadState('networkidle'); + + // Should have graph tab for local backend + const graphTab = webPage.getByRole('tab', { name: 'Graph' }); + await expect(graphTab).toBeVisible(); + }); +}); + +test.describe('Web UI - Navigation', () => { + test('should navigate from run list to run detail and back', async ({ + webPage, + e2eMetadata, + }) => { + // Skip if no runs from e2e tests + if (!e2eMetadata?.runIds?.length) { + test.skip(); + return; + } + + await webPage.waitForLoadState('networkidle'); + await webPage.waitForTimeout(2000); + + // Find and click on a run row (if table is visible) + const table = webPage.getByRole('table'); + const tableVisible = await table.isVisible().catch(() => false); + + if (!tableVisible) { + test.skip(); + return; + } + + // Click on first data row + const firstRow = webPage.getByRole('row').nth(1); + await firstRow.click(); + + // Should navigate to run detail page + await webPage.waitForLoadState('networkidle'); + await expect(webPage).toHaveURL(/\/run\//); + + // Click breadcrumb to go back + const breadcrumb = webPage.getByRole('link', { name: 'Runs' }); + await breadcrumb.click(); + + // Should be back on main page + await webPage.waitForLoadState('networkidle'); + await expect(webPage).not.toHaveURL(/\/run\//); + }); +}); + +test.describe('Web UI - Error Handling', () => { + test('should handle invalid run ID gracefully', async ({ webPage }) => { + // Navigate to a non-existent run + const baseUrl = webPage.url().split('?')[0]; + const invalidRunUrl = `${baseUrl}run/invalid_run_id_12345`; + await webPage.goto(invalidRunUrl); + + await webPage.waitForLoadState('networkidle'); + + // Should show some error state (alert or error message) + // The exact message may vary, but it shouldn't crash + const page = webPage; + + // Give it time to potentially load/fail + await page.waitForTimeout(3000); + + // Page should still be responsive + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); +}); diff --git a/packages/web-e2e/tsconfig.json b/packages/web-e2e/tsconfig.json new file mode 100644 index 000000000..5192350a3 --- /dev/null +++ b/packages/web-e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*", "playwright.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b86c1df3f..f41b22ce7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,13 +125,13 @@ importers: version: 19.1.9(@types/react@19.1.13) '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + version: 1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@vercel/edge-config': specifier: ^1.4.0 version: 1.4.0(@opentelemetry/api@1.9.0) '@vercel/speed-insights': specifier: 1.2.0 - version: 1.2.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + version: 1.2.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@workflow/ai': specifier: workspace:* version: link:../packages/ai @@ -191,16 +191,16 @@ importers: version: 5.1.0 fumadocs-core: specifier: ^16.0.11 - version: 16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + version: 16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) fumadocs-mdx: specifier: 13.0.8 - version: 13.0.8(fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 13.0.8(fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) fumadocs-typescript: specifier: ^4.0.13 - version: 4.0.13(@types/react@19.1.13)(fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0))(fumadocs-ui@16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13))(typescript@5.9.3) + version: 4.0.13(@types/react@19.1.13)(fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0))(fumadocs-ui@16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13))(typescript@5.9.3) fumadocs-ui: specifier: 16.0.11 - version: 16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13) + version: 16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13) github-slugger: specifier: ^2.0.0 version: 2.0.0 @@ -224,7 +224,7 @@ importers: version: 5.1.6 next: specifier: 16.0.10 - version: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -681,7 +681,7 @@ importers: version: link:../tsconfig next: specifier: 16.0.10 - version: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) packages/nitro: dependencies: @@ -880,10 +880,10 @@ importers: version: 12.9.3(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: 16.0.10 - version: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nuqs: specifier: ^2.2.5 - version: 2.8.3(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.10.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 2.8.3(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.10.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) devDependencies: '@biomejs/biome': specifier: 'catalog:' @@ -979,6 +979,18 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/web-e2e: + devDependencies: + '@playwright/test': + specifier: ^1.48.0 + version: 1.57.0 + '@types/node': + specifier: 'catalog:' + version: 22.19.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/web-shared: dependencies: '@tailwindcss/postcss': @@ -1289,7 +1301,7 @@ importers: version: 9.5.0(astro@5.16.3(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)) '@astrojs/vercel': specifier: ^9.0.0 - version: 9.0.2(@aws-sdk/credential-provider-web-identity@3.844.0)(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(astro@5.16.3(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + version: 9.0.2(f18808a7d1029db9fe7fc505cee8c92c) '@workflow/world-postgres': specifier: workspace:* version: link:../../packages/world-postgres @@ -1487,7 +1499,7 @@ importers: version: 0.0.4 next: specifier: 16.0.10 - version: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) openai: specifier: 6.9.1 version: 6.9.1(ws@8.18.3)(zod@4.1.11) @@ -1575,7 +1587,7 @@ importers: version: 0.0.4 next: specifier: 16.0.10 - version: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) openai: specifier: 6.9.1 version: 6.9.1(ws@8.18.3)(zod@4.1.11) @@ -4547,6 +4559,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.57.0': + resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -8797,6 +8814,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -11085,6 +11107,16 @@ packages: player.style@0.3.0: resolution: {integrity: sha512-ny1TbqA2ZsUd6jzN+F034+UMXVK7n5SrwepsrZ2gIqVz00Hn0ohCUbbUdst/2IOFCy0oiTbaOXkSFxRw1RmSlg==} + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -13707,10 +13739,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/vercel@9.0.2(@aws-sdk/credential-provider-web-identity@3.844.0)(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(astro@5.16.3(@netlify/blobs@9.1.2)(@types/node@24.6.2)(@vercel/blob@2.0.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@astrojs/vercel@9.0.2(f18808a7d1029db9fe7fc505cee8c92c)': dependencies: '@astrojs/internal-helpers': 0.7.5 - '@vercel/analytics': 1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + '@vercel/analytics': 1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@vercel/functions': 2.2.13(@aws-sdk/credential-provider-web-identity@3.844.0) '@vercel/nft': 0.30.4(rollup@4.53.2) '@vercel/routing-utils': 5.3.0 @@ -16522,6 +16554,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.57.0': + dependencies: + playwright: 1.57.0 + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.5': @@ -19337,19 +19373,19 @@ snapshots: unhead: 2.0.19 vue: 3.5.22(typescript@5.9.3) - '@vercel/analytics@1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@vercel/analytics@1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': optionalDependencies: '@sveltejs/kit': 2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) - next: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 svelte: 5.43.3 vue: 3.5.22(typescript@5.9.3) vue-router: 4.6.3(vue@3.5.22(typescript@5.9.3)) - '@vercel/analytics@1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@vercel/analytics@1.5.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': optionalDependencies: '@sveltejs/kit': 2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) - next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 svelte: 5.43.3 vue: 3.5.22(typescript@5.9.3) @@ -19477,10 +19513,10 @@ snapshots: optionalDependencies: ajv: 6.12.6 - '@vercel/speed-insights@1.2.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + '@vercel/speed-insights@1.2.0(@sveltejs/kit@2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(svelte@5.43.3)(vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': optionalDependencies: '@sveltejs/kit': 2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) - next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 svelte: 5.43.3 vue: 3.5.22(typescript@5.9.3) @@ -21951,10 +21987,13 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true - fumadocs-core@16.0.11(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0): + fumadocs-core@16.0.11(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0): dependencies: '@formatjs/intl-localematcher': 0.6.2 '@orama/orama': 3.1.16 @@ -21977,14 +22016,14 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 lucide-react: 0.544.0(react@19.2.0) - next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) react-router: 7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) transitivePeerDependencies: - supports-color - fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0): + fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0): dependencies: '@formatjs/intl-localematcher': 0.6.2 '@orama/orama': 3.1.16 @@ -22007,21 +22046,21 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 lucide-react: 0.544.0(react@19.2.0) - next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) react-router: 7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) transitivePeerDependencies: - supports-color - fumadocs-mdx@13.0.8(fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)): + fumadocs-mdx@13.0.8(fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.0.0 chokidar: 4.0.3 esbuild: 0.25.12 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + fumadocs-core: 16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) js-yaml: 4.1.0 lru-cache: 11.2.2 mdast-util-to-markdown: 2.1.2 @@ -22035,16 +22074,16 @@ snapshots: unist-util-visit: 5.0.0 zod: 4.1.12 optionalDependencies: - next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color - fumadocs-typescript@4.0.13(@types/react@19.1.13)(fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0))(fumadocs-ui@16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13))(typescript@5.9.3): + fumadocs-typescript@4.0.13(@types/react@19.1.13)(fumadocs-core@16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0))(fumadocs-ui@16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13))(typescript@5.9.3): dependencies: estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + fumadocs-core: 16.0.14(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) hast-util-to-estree: 3.1.3 hast-util-to-jsx-runtime: 2.3.6 remark: 15.0.1 @@ -22055,11 +22094,11 @@ snapshots: unist-util-visit: 5.0.0 optionalDependencies: '@types/react': 19.1.13 - fumadocs-ui: 16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13) + fumadocs-ui: 16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13) transitivePeerDependencies: - supports-color - fumadocs-ui@16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13): + fumadocs-ui@16.0.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13): dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -22072,7 +22111,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.1.13)(react@19.2.0) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) class-variance-authority: 0.7.1 - fumadocs-core: 16.0.11(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + fumadocs-core: 16.0.11(@types/react@19.1.13)(lucide-react@0.544.0(react@19.2.0))(next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router@7.10.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) lodash.merge: 4.6.2 next-themes: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) postcss-selector-parser: 7.1.0 @@ -22083,7 +22122,7 @@ snapshots: tailwind-merge: 3.4.0 optionalDependencies: '@types/react': 19.1.13 - next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) tailwindcss: 4.1.13 transitivePeerDependencies: - '@mixedbread/sdk' @@ -23914,7 +23953,7 @@ snapshots: transitivePeerDependencies: - supports-color - next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.0.10 '@swc/helpers': 0.5.15 @@ -23933,12 +23972,13 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.10 '@next/swc-win32-x64-msvc': 16.0.10 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.57.0 sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 16.0.10 '@swc/helpers': 0.5.15 @@ -23957,12 +23997,13 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.10 '@next/swc-win32-x64-msvc': 16.0.10 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.57.0 sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@next/env': 16.0.10 '@swc/helpers': 0.5.15 @@ -23981,12 +24022,13 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.10 '@next/swc-win32-x64-msvc': 16.0.10 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.57.0 sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.0.10 '@swc/helpers': 0.5.15 @@ -24005,6 +24047,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.10 '@next/swc-win32-x64-msvc': 16.0.10 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.57.0 sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' @@ -24389,12 +24432,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.3(next@16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.10.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): + nuqs@2.8.3(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.10.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): dependencies: '@standard-schema/spec': 1.0.0 react: 19.1.0 optionalDependencies: - next: 16.0.10(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-router: 7.10.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nuxt@4.0.0(@biomejs/biome@2.3.3)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@22.19.0)(@vercel/blob@2.0.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(@vue/compiler-sfc@3.5.22)(better-sqlite3@11.10.0)(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7))(eslint@9.38.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1): @@ -25210,6 +25253,14 @@ snapshots: transitivePeerDependencies: - react + playwright-core@1.57.0: {} + + playwright@1.57.0: + dependencies: + playwright-core: 1.57.0 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: From 4cb814b4dba7dd9272262317fe646e94db2189be Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 8 Jan 2026 16:41:01 +0100 Subject: [PATCH 2/9] fix Signed-off-by: Peter Wielander --- vitest.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 7d49659db..b8f42d6d1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { testTimeout: 60_000, + // Exclude web-e2e package - it uses Playwright, not vitest + exclude: ['**/node_modules/**', '**/dist/**', 'packages/web-e2e/**'], }, benchmark: { include: ['**/*.bench.ts'], From d930557e937f4f2e232e12204a8d54bd31013d92 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 8 Jan 2026 16:45:42 +0100 Subject: [PATCH 3/9] Images? Signed-off-by: Peter Wielander --- .github/scripts/post-web-e2e-screenshots.js | 242 ++++++++++++++++++ .github/workflows/tests.yml | 38 +++ packages/web-e2e/.gitignore | 1 + packages/web-e2e/src/screenshots.spec.ts | 166 ++++++++++++ .../web/src/components/settings-sidebar.tsx | 3 +- 5 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/post-web-e2e-screenshots.js create mode 100644 packages/web-e2e/src/screenshots.spec.ts diff --git a/.github/scripts/post-web-e2e-screenshots.js b/.github/scripts/post-web-e2e-screenshots.js new file mode 100644 index 000000000..7c828a49d --- /dev/null +++ b/.github/scripts/post-web-e2e-screenshots.js @@ -0,0 +1,242 @@ +#!/usr/bin/env node + +/** + * Post Web E2E screenshots to a PR comment. + * + * This script: + * 1. Finds all screenshot files in the specified directory + * 2. Uploads them to GitHub as issue attachments (which gives us CDN URLs) + * 3. Creates/updates a PR comment with the embedded images + * + * Usage: node post-web-e2e-screenshots.js + * + * Required environment variables: + * - GITHUB_TOKEN: GitHub token with repo permissions + * - GITHUB_REPOSITORY: owner/repo format + * - PR_NUMBER: Pull request number + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const COMMENT_MARKER = ''; + +async function main() { + const screenshotsDir = process.argv[2] || 'screenshots'; + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const prNumber = process.env.PR_NUMBER; + const runUrl = process.env.RUN_URL || ''; + + if (!token || !repository || !prNumber) { + console.log( + 'Missing required environment variables. Skipping screenshot posting.' + ); + console.log(` GITHUB_TOKEN: ${token ? 'set' : 'missing'}`); + console.log(` GITHUB_REPOSITORY: ${repository || 'missing'}`); + console.log(` PR_NUMBER: ${prNumber || 'missing'}`); + process.exit(0); + } + + const [owner, repo] = repository.split('/'); + + // Find all screenshot files + const screenshots = findScreenshots(screenshotsDir); + if (screenshots.length === 0) { + console.log(`No screenshots found in ${screenshotsDir}`); + process.exit(0); + } + + console.log(`Found ${screenshots.length} screenshots`); + + // Group screenshots by backend + const byBackend = groupByBackend(screenshots); + + // Generate markdown content + const markdown = generateMarkdown(byBackend, runUrl); + + // Find existing comment + const existingComment = await findExistingComment( + owner, + repo, + prNumber, + token + ); + + if (existingComment) { + // Update existing comment + await updateComment(owner, repo, existingComment.id, markdown, token); + console.log(`Updated existing comment: ${existingComment.html_url}`); + } else { + // Create new comment + const newComment = await createComment( + owner, + repo, + prNumber, + markdown, + token + ); + console.log(`Created new comment: ${newComment.html_url}`); + } +} + +function findScreenshots(dir) { + const screenshots = []; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + if ( + file.endsWith('.png') || + file.endsWith('.jpg') || + file.endsWith('.jpeg') + ) { + screenshots.push({ + name: file, + path: path.join(dir, file), + }); + } + } + } catch (error) { + console.warn(`Could not read screenshots directory: ${error.message}`); + } + return screenshots; +} + +function groupByBackend(screenshots) { + const groups = { local: [], vercel: [] }; + for (const screenshot of screenshots) { + if (screenshot.name.includes('-local')) { + groups.local.push(screenshot); + } else if (screenshot.name.includes('-vercel')) { + groups.vercel.push(screenshot); + } + } + return groups; +} + +function generateMarkdown(byBackend, runUrl) { + let md = `${COMMENT_MARKER}\n`; + md += `## 📸 Web UI Screenshots\n\n`; + md += `Screenshots captured from the Web UI E2E tests.\n\n`; + + // Note about artifacts since we can't embed images directly in comments without uploading + md += `> **Note:** Screenshots are available as artifacts in the workflow run.\n\n`; + + if (byBackend.local.length > 0) { + md += `### 💻 Local Backend\n\n`; + md += `| View | Screenshot |\n`; + md += `|:-----|:-----------|\n`; + for (const screenshot of byBackend.local) { + const viewName = formatViewName(screenshot.name); + md += `| ${viewName} | \`${screenshot.name}\` |\n`; + } + md += '\n'; + } + + if (byBackend.vercel.length > 0) { + md += `### ▲ Vercel Backend\n\n`; + md += `| View | Screenshot |\n`; + md += `|:-----|:-----------|\n`; + for (const screenshot of byBackend.vercel) { + const viewName = formatViewName(screenshot.name); + md += `| ${viewName} | \`${screenshot.name}\` |\n`; + } + md += '\n'; + } + + if (runUrl) { + md += `---\n`; + md += `📋 [View workflow run and download screenshots](${runUrl})\n`; + } + + return md; +} + +function formatViewName(filename) { + // runs-list-local.png -> Runs List + return filename + .replace(/-(local|vercel)\.png$/, '') + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +async function findExistingComment(owner, repo, prNumber, token) { + const comments = await githubRequest( + 'GET', + `/repos/${owner}/${repo}/issues/${prNumber}/comments`, + null, + token + ); + + for (const comment of comments) { + if (comment.body && comment.body.includes(COMMENT_MARKER)) { + return comment; + } + } + return null; +} + +async function createComment(owner, repo, prNumber, body, token) { + return githubRequest( + 'POST', + `/repos/${owner}/${repo}/issues/${prNumber}/comments`, + { body }, + token + ); +} + +async function updateComment(owner, repo, commentId, body, token) { + return githubRequest( + 'PATCH', + `/repos/${owner}/${repo}/issues/comments/${commentId}`, + { body }, + token + ); +} + +function githubRequest(method, path, data, token) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'api.github.com', + port: 443, + path, + method, + headers: { + 'User-Agent': 'web-e2e-screenshots', + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + }, + }; + + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(body)); + } catch { + resolve(body); + } + } else { + reject(new Error(`GitHub API error: ${res.statusCode} ${body}`)); + } + }); + }); + + req.on('error', reject); + + if (data) { + req.write(JSON.stringify(data)); + } + req.end(); + }); +} + +main().catch((error) => { + console.error('Error posting screenshots:', error); + process.exit(1); +}); diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 02cc86e40..670c35da0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -682,6 +682,44 @@ jobs: retention-days: 7 if-no-files-found: ignore + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: web-e2e-screenshots-${{ matrix.backend }} + path: packages/web-e2e/screenshots/ + retention-days: 7 + if-no-files-found: ignore + + # Post Web UI screenshots to PR comment + web-e2e-screenshots: + name: Post Web UI Screenshots + runs-on: ubuntu-latest + needs: [web-e2e] + if: always() && !cancelled() && github.event_name == 'pull_request' + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v4 + + - name: Download screenshots + uses: actions/download-artifact@v4 + with: + pattern: web-e2e-screenshots-* + path: screenshots + merge-multiple: true + + - name: List screenshots + run: find screenshots -type f -name "*.png" | sort || echo "No screenshots found" + + - name: Post screenshots to PR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node .github/scripts/post-web-e2e-screenshots.js screenshots + # Final job: Aggregate all E2E results and update PR comment summary: name: E2E Summary diff --git a/packages/web-e2e/.gitignore b/packages/web-e2e/.gitignore index d7315f632..813743b95 100644 --- a/packages/web-e2e/.gitignore +++ b/packages/web-e2e/.gitignore @@ -2,6 +2,7 @@ test-results/ playwright-report/ test-results.json +screenshots/ # Node node_modules/ diff --git a/packages/web-e2e/src/screenshots.spec.ts b/packages/web-e2e/src/screenshots.spec.ts new file mode 100644 index 000000000..c09677b39 --- /dev/null +++ b/packages/web-e2e/src/screenshots.spec.ts @@ -0,0 +1,166 @@ +import { test, expect } from './fixtures/index.js'; +import path from 'node:path'; +import fs from 'node:fs'; + +/** + * Screenshot tests for main views. + * These tests capture screenshots of the main UI views for visual verification in PR comments. + */ + +const SCREENSHOTS_DIR = 'screenshots'; + +// Ensure screenshots directory exists +function ensureScreenshotsDir() { + const dir = path.join(process.cwd(), SCREENSHOTS_DIR); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +test.describe('Screenshots - Main Views', () => { + test.beforeAll(() => { + ensureScreenshotsDir(); + }); + + test('capture runs list view', async ({ webPage, webServerConfig }) => { + await webPage.waitForLoadState('networkidle'); + // Wait a bit for any animations to settle + await webPage.waitForTimeout(1000); + + const screenshotPath = path.join( + SCREENSHOTS_DIR, + `runs-list-${webServerConfig.backend}.png` + ); + await webPage.screenshot({ + path: screenshotPath, + fullPage: false, + }); + + // Verify screenshot was created + expect(fs.existsSync(screenshotPath)).toBe(true); + }); + + test('capture hooks tab view', async ({ webPage, webServerConfig }) => { + // Click on hooks tab + const hooksTab = webPage.getByRole('tab', { name: 'Hooks' }); + await hooksTab.click(); + await webPage.waitForLoadState('networkidle'); + await webPage.waitForTimeout(1000); + + const screenshotPath = path.join( + SCREENSHOTS_DIR, + `hooks-tab-${webServerConfig.backend}.png` + ); + await webPage.screenshot({ + path: screenshotPath, + fullPage: false, + }); + + expect(fs.existsSync(screenshotPath)).toBe(true); + }); + + test('capture workflows tab view (local only)', async ({ + webPage, + webServerConfig, + }) => { + if (webServerConfig.backend !== 'local') { + test.skip(); + return; + } + + const workflowsTab = webPage.getByRole('tab', { name: 'Workflows' }); + await workflowsTab.click(); + await webPage.waitForLoadState('networkidle'); + await webPage.waitForTimeout(1000); + + const screenshotPath = path.join( + SCREENSHOTS_DIR, + `workflows-tab-${webServerConfig.backend}.png` + ); + await webPage.screenshot({ + path: screenshotPath, + fullPage: false, + }); + + expect(fs.existsSync(screenshotPath)).toBe(true); + }); + + test('capture run detail view', async ({ + webPage, + webServerConfig, + getAnyRunId, + }) => { + const runId = getAnyRunId(); + if (!runId) { + // If no runId available, take screenshot of empty state + const screenshotPath = path.join( + SCREENSHOTS_DIR, + `run-detail-empty-${webServerConfig.backend}.png` + ); + await webPage.screenshot({ + path: screenshotPath, + fullPage: false, + }); + expect(fs.existsSync(screenshotPath)).toBe(true); + return; + } + + // Navigate to run detail page + const baseUrl = webPage.url().split('?')[0]; + const runDetailUrl = `${baseUrl}run/${runId}`; + await webPage.goto(runDetailUrl); + await webPage.waitForLoadState('networkidle'); + await webPage.waitForTimeout(2000); // Extra time for trace viewer to load + + const screenshotPath = path.join( + SCREENSHOTS_DIR, + `run-detail-${webServerConfig.backend}.png` + ); + await webPage.screenshot({ + path: screenshotPath, + fullPage: false, + }); + + expect(fs.existsSync(screenshotPath)).toBe(true); + }); + + test('capture run detail graph tab (local only)', async ({ + webPage, + webServerConfig, + getAnyRunId, + }) => { + if (webServerConfig.backend !== 'local') { + test.skip(); + return; + } + + const runId = getAnyRunId(); + if (!runId) { + test.skip(); + return; + } + + // Navigate to run detail page + const baseUrl = webPage.url().split('?')[0]; + const runDetailUrl = `${baseUrl}run/${runId}`; + await webPage.goto(runDetailUrl); + await webPage.waitForLoadState('networkidle'); + + // Click on graph tab + const graphTab = webPage.getByRole('tab', { name: 'Graph' }); + await graphTab.click(); + await webPage.waitForTimeout(2000); // Extra time for graph to render + + const screenshotPath = path.join( + SCREENSHOTS_DIR, + `run-detail-graph-${webServerConfig.backend}.png` + ); + await webPage.screenshot({ + path: screenshotPath, + fullPage: false, + }); + + expect(fs.existsSync(screenshotPath)).toBe(true); + }); +}); diff --git a/packages/web/src/components/settings-sidebar.tsx b/packages/web/src/components/settings-sidebar.tsx index 02a714d75..2cb840c81 100644 --- a/packages/web/src/components/settings-sidebar.tsx +++ b/packages/web/src/components/settings-sidebar.tsx @@ -20,7 +20,7 @@ import { validateWorldConfig, type WorldConfig, } from '@/lib/config-world'; -import { useDataDirInfo, useWorldsAvailability } from '@/lib/hooks'; +import { useWorldsAvailability } from '@/lib/hooks'; interface SettingsSidebarProps { open?: boolean; @@ -43,7 +43,6 @@ export function SettingsSidebar({ const { data: worldsAvailability = [], isLoading: isLoadingWorlds } = useWorldsAvailability(); - const { data: dataDirInfo } = useDataDirInfo(localConfig.dataDir); const backend = localConfig.backend || 'local'; const isLocal = backend === 'local'; From c104d49c594aa98f68b9385e430973586ba762d1 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 8 Jan 2026 16:52:13 +0100 Subject: [PATCH 4/9] Fix for regular unit tests Signed-off-by: Peter Wielander --- .github/workflows/tests.yml | 2 +- packages/web-e2e/README.md | 3 ++- vitest.config.ts | 2 -- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 670c35da0..de1bc43ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -135,7 +135,7 @@ jobs: build-packages: 'false' - name: Run Unit Tests - run: pnpm test --filter='!./docs' + run: pnpm test --filter='!./docs' --filter='!./packages/web-e2e' build-error-messages: name: Node.js Module Build Errors Test diff --git a/packages/web-e2e/README.md b/packages/web-e2e/README.md index 57068f384..46bff449e 100644 --- a/packages/web-e2e/README.md +++ b/packages/web-e2e/README.md @@ -20,11 +20,13 @@ These tests verify that the Workflow Web UI correctly displays workflow runs, st ### Prerequisites 1. Build the workspace packages: + ```bash pnpm build ``` 2. (Optional) Run the main e2e tests first to generate runIds: + ```bash APP_NAME=nextjs-turbopack DEPLOYMENT_URL=http://localhost:3000 pnpm test:e2e ``` @@ -92,4 +94,3 @@ DEBUG=pw:api pnpm test # View test report pnpm report ``` - diff --git a/vitest.config.ts b/vitest.config.ts index b8f42d6d1..7d49659db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,8 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { testTimeout: 60_000, - // Exclude web-e2e package - it uses Playwright, not vitest - exclude: ['**/node_modules/**', '**/dist/**', 'packages/web-e2e/**'], }, benchmark: { include: ['**/*.bench.ts'], From 7534d07354eb4a8e95661ba9a870e1980058352f Mon Sep 17 00:00:00 2001 From: Vercel Date: Thu, 8 Jan 2026 16:06:28 +0000 Subject: [PATCH 5/9] Fix: The `isRunning()` method uses a HEAD request without timeout, which can cause indefinite hangs and is less reliable than GET with timeout --- packages/web-e2e/src/fixtures/web-server.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/web-e2e/src/fixtures/web-server.ts b/packages/web-e2e/src/fixtures/web-server.ts index 2a28a79c3..801bb6e8a 100644 --- a/packages/web-e2e/src/fixtures/web-server.ts +++ b/packages/web-e2e/src/fixtures/web-server.ts @@ -191,8 +191,18 @@ export class WebServer { */ async isRunning(): Promise { try { - const response = await fetch(this.baseUrl, { method: 'HEAD' }); - return response.ok; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(this.baseUrl, { + method: 'GET', + signal: controller.signal, + }); + return response.ok; + } finally { + clearTimeout(timeoutId); + } } catch { return false; } From bec280bd1a059de7ea88127fba189cb1c1172aaa Mon Sep 17 00:00:00 2001 From: Vercel Date: Thu, 8 Jan 2026 16:06:33 +0000 Subject: [PATCH 6/9] Fix: Environment variables set to undefined are not actually removed from child process environment --- packages/web-e2e/src/fixtures/web-server.ts | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/web-e2e/src/fixtures/web-server.ts b/packages/web-e2e/src/fixtures/web-server.ts index 801bb6e8a..2d3a1cf91 100644 --- a/packages/web-e2e/src/fixtures/web-server.ts +++ b/packages/web-e2e/src/fixtures/web-server.ts @@ -118,22 +118,22 @@ export class WebServer { const shellCommand = `npx next start -p ${this.config.port}`; console.log(`[WebServer] Starting: ${shellCommand}`); + // Create env object and properly remove workflow-specific vars + // (Setting to undefined doesn't actually delete the property) + const env = { ...process.env }; + delete env.WORKFLOW_TARGET_WORLD; + delete env.WORKFLOW_VERCEL_ENV; + delete env.WORKFLOW_VERCEL_AUTH_TOKEN; + delete env.WORKFLOW_VERCEL_PROJECT; + delete env.WORKFLOW_VERCEL_TEAM; + delete env.WORKFLOW_LOCAL_DATA_DIR; + this.process = spawn(shellCommand, { shell: true, cwd: webPackagePath, detached: false, stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - // Don't pass workflow-specific env vars to the server - // They will be passed via query params - WORKFLOW_TARGET_WORLD: undefined, - WORKFLOW_VERCEL_ENV: undefined, - WORKFLOW_VERCEL_AUTH_TOKEN: undefined, - WORKFLOW_VERCEL_PROJECT: undefined, - WORKFLOW_VERCEL_TEAM: undefined, - WORKFLOW_LOCAL_DATA_DIR: undefined, - }, + env, }); // Log server output From 8592ba43937522fab232d99ef0c7f9f4191c913b Mon Sep 17 00:00:00 2001 From: Vercel Date: Thu, 8 Jan 2026 16:06:39 +0000 Subject: [PATCH 7/9] Fix: URL construction using string concatenation is fragile and may fail when base URL lacks trailing slash --- packages/web-e2e/src/web-ui.spec.ts | 35 ++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/web-e2e/src/web-ui.spec.ts b/packages/web-e2e/src/web-ui.spec.ts index 2816d9e0f..b537d523b 100644 --- a/packages/web-e2e/src/web-ui.spec.ts +++ b/packages/web-e2e/src/web-ui.spec.ts @@ -137,7 +137,10 @@ test.describe('Web UI - Run Detail View', () => { } // Navigate to run detail page - const runDetailUrl = webPage.url().replace(/\?.*$/, '') + `run/${runId}`; + const pageUrl = new URL(webPage.url()); + pageUrl.pathname = `/run/${runId}`; + pageUrl.search = ''; + const runDetailUrl = pageUrl.toString(); await webPage.goto(runDetailUrl); await webPage.waitForLoadState('networkidle'); @@ -158,8 +161,10 @@ test.describe('Web UI - Run Detail View', () => { } // Navigate to run detail page - const baseUrl = webPage.url().split('?')[0]; - const runDetailUrl = `${baseUrl}run/${runId}`; + const pageUrl = new URL(webPage.url()); + pageUrl.pathname = `/run/${runId}`; + pageUrl.search = ''; + const runDetailUrl = pageUrl.toString(); await webPage.goto(runDetailUrl); await webPage.waitForLoadState('networkidle'); @@ -186,8 +191,10 @@ test.describe('Web UI - Run Detail View', () => { return; } - const baseUrl = webPage.url().split('?')[0]; - const runDetailUrl = `${baseUrl}run/${runId}`; + const pageUrl = new URL(webPage.url()); + pageUrl.pathname = `/run/${runId}`; + pageUrl.search = ''; + const runDetailUrl = pageUrl.toString(); await webPage.goto(runDetailUrl); await webPage.waitForLoadState('networkidle'); @@ -205,8 +212,10 @@ test.describe('Web UI - Run Detail View', () => { return; } - const baseUrl = webPage.url().split('?')[0]; - const runDetailUrl = `${baseUrl}run/${runId}`; + const pageUrl = new URL(webPage.url()); + pageUrl.pathname = `/run/${runId}`; + pageUrl.search = ''; + const runDetailUrl = pageUrl.toString(); await webPage.goto(runDetailUrl); await webPage.waitForLoadState('networkidle'); @@ -233,8 +242,10 @@ test.describe('Web UI - Run Detail View', () => { return; } - const baseUrl = webPage.url().split('?')[0]; - const runDetailUrl = `${baseUrl}run/${runId}`; + const pageUrl = new URL(webPage.url()); + pageUrl.pathname = `/run/${runId}`; + pageUrl.search = ''; + const runDetailUrl = pageUrl.toString(); await webPage.goto(runDetailUrl); await webPage.waitForLoadState('networkidle'); @@ -289,8 +300,10 @@ test.describe('Web UI - Navigation', () => { test.describe('Web UI - Error Handling', () => { test('should handle invalid run ID gracefully', async ({ webPage }) => { // Navigate to a non-existent run - const baseUrl = webPage.url().split('?')[0]; - const invalidRunUrl = `${baseUrl}run/invalid_run_id_12345`; + const pageUrl = new URL(webPage.url()); + pageUrl.pathname = '/run/invalid_run_id_12345'; + pageUrl.search = ''; + const invalidRunUrl = pageUrl.toString(); await webPage.goto(invalidRunUrl); await webPage.waitForLoadState('networkidle'); From f6b333e9ac35786dfb6cb3c63d86a961471963c3 Mon Sep 17 00:00:00 2001 From: Vercel Date: Thu, 8 Jan 2026 16:06:44 +0000 Subject: [PATCH 8/9] Fix: The webPage fixture navigates to the server without error handling, retry logic, or server health diagnostics, causing cryptic failures if the server becomes unavailable during navigation. --- packages/web-e2e/src/fixtures/index.ts | 44 ++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/web-e2e/src/fixtures/index.ts b/packages/web-e2e/src/fixtures/index.ts index b66d37e8c..823580bd0 100644 --- a/packages/web-e2e/src/fixtures/index.ts +++ b/packages/web-e2e/src/fixtures/index.ts @@ -144,8 +144,48 @@ export const test = base.extend({ webPage: async ({ page, webServer }, use) => { // Navigate to the web UI with configuration params const url = webServer.getUrl('/'); - await page.goto(url); - await use(page); + + // Try to navigate to the server with retry logic and better error handling + const maxRetries = 3; + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await page.goto(url, { waitUntil: 'networkidle' }); + await use(page); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Check if server is still running + const serverRunning = await webServer.isRunning(); + + if (attempt < maxRetries && serverRunning) { + // Server is running but navigation failed, try again + console.warn( + `[webPage] Navigation attempt ${attempt} failed: ${lastError.message}. ` + + `Retrying (${attempt}/${maxRetries})...` + ); + await new Promise(resolve => setTimeout(resolve, 1000)); + } else { + // Last attempt or server is down + if (!serverRunning) { + throw new Error( + `[webPage] Failed to navigate to ${url}: Server is not responding. ` + + `Original error: ${lastError.message}` + ); + } else { + throw new Error( + `[webPage] Failed to navigate to ${url} after ${maxRetries} attempts. ` + + `Last error: ${lastError.message}` + ); + } + } + } + } + + // Fallback (should not reach here, but for safety) + throw lastError || new Error('Failed to navigate to webPage'); }, e2eMetadata: async ({ webServerConfig }, use) => { From f53f9824c2abd7476fe1f19cfa8e9ed4d57cdb35 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 8 Jan 2026 17:37:50 +0100 Subject: [PATCH 9/9] fixes Signed-off-by: Peter Wielander --- packages/web-e2e/playwright.config.ts | 42 +++++- packages/web-e2e/src/fixtures/index.ts | 115 ++++----------- packages/web-e2e/src/fixtures/web-server.ts | 156 ++------------------ packages/web-e2e/src/web-ui.spec.ts | 3 +- packages/web-e2e/tsconfig.json | 3 +- 5 files changed, 82 insertions(+), 237 deletions(-) diff --git a/packages/web-e2e/playwright.config.ts b/packages/web-e2e/playwright.config.ts index bebb84c61..b0c3ade91 100644 --- a/packages/web-e2e/playwright.config.ts +++ b/packages/web-e2e/playwright.config.ts @@ -1,4 +1,23 @@ import { defineConfig, devices } from '@playwright/test'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const WEB_PORT = parseInt(process.env.WORKFLOW_WEB_PORT || '3456', 10); + +/** + * Find the @workflow/web package path + */ +function findWebPackagePath(): string { + try { + const requireFromHere = createRequire(import.meta.url); + const packageJsonPath = requireFromHere.resolve( + '@workflow/web/package.json' + ); + return path.dirname(packageJsonPath); + } catch { + return path.resolve(import.meta.dirname, '../web'); + } +} /** * Playwright configuration for Workflow Web UI e2e tests. @@ -27,7 +46,7 @@ export default defineConfig({ // Shared settings for all the projects below use: { // Base URL to use in actions like `await page.goto('/')`. - baseURL: process.env.WORKFLOW_WEB_URL || 'http://localhost:3456', + baseURL: `http://localhost:${WEB_PORT}`, // Collect trace when retrying the failed test trace: 'on-first-retry', // Take screenshot on failure @@ -48,4 +67,25 @@ export default defineConfig({ }, // Output folder for test artifacts outputDir: './test-results', + + // Use Playwright's built-in webServer config to manage the server lifecycle. + // This ensures proper startup/shutdown and handles port conflicts gracefully. + webServer: { + command: `npx next start -p ${WEB_PORT}`, + cwd: findWebPackagePath(), + url: `http://localhost:${WEB_PORT}`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + // Don't pass workflow-specific env vars to the server + // They will be passed via query params in tests + env: { + ...process.env, + WORKFLOW_TARGET_WORLD: '', + WORKFLOW_VERCEL_ENV: '', + WORKFLOW_VERCEL_AUTH_TOKEN: '', + WORKFLOW_VERCEL_PROJECT: '', + WORKFLOW_VERCEL_TEAM: '', + WORKFLOW_LOCAL_DATA_DIR: '', + }, + }, }); diff --git a/packages/web-e2e/src/fixtures/index.ts b/packages/web-e2e/src/fixtures/index.ts index 823580bd0..546ce1843 100644 --- a/packages/web-e2e/src/fixtures/index.ts +++ b/packages/web-e2e/src/fixtures/index.ts @@ -2,7 +2,7 @@ import { test as base, type Page } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; import { - WebServer, + WebUrlBuilder, getDefaultConfig, type WebServerConfig, } from './web-server.js'; @@ -28,9 +28,14 @@ export interface E2EMetadata { */ export interface WebE2EFixtures { /** - * The web server instance + * Configuration for the web server */ - webServer: WebServer; + webServerConfig: WebServerConfig; + + /** + * URL builder for creating URLs with correct query params + */ + urlBuilder: WebUrlBuilder; /** * A page that's already navigated to the web UI with correct config params @@ -53,21 +58,6 @@ export interface WebE2EFixtures { getAnyRunId: () => string | null; } -/** - * Worker-scoped fixtures (shared across all tests in a worker) - */ -export interface WebE2EWorkerFixtures { - /** - * Web server configuration - */ - webServerConfig: WebServerConfig; - - /** - * The shared web server instance (per worker) - */ - sharedWebServer: WebServer; -} - /** * Find e2e metadata files in the results directory */ @@ -116,84 +106,34 @@ function findE2EMetadata( /** * Extended Playwright test with web e2e fixtures */ -export const test = base.extend({ - // Worker-scoped: shared across all tests in a worker - webServerConfig: [ - async ({}, use) => { - const config = getDefaultConfig(); - await use(config); - }, - { scope: 'worker' }, - ], - - sharedWebServer: [ - async ({ webServerConfig }, use) => { - const server = new WebServer(webServerConfig); - await server.start(); - await use(server); - await server.stop(); - }, - { scope: 'worker' }, - ], - - // Test-scoped fixtures - webServer: async ({ sharedWebServer }, use) => { - await use(sharedWebServer); +export const test = base.extend({ + // Configuration (test-scoped but constant) + webServerConfig: async ({}, use) => { + const config = getDefaultConfig(); + await use(config); }, - webPage: async ({ page, webServer }, use) => { - // Navigate to the web UI with configuration params - const url = webServer.getUrl('/'); - - // Try to navigate to the server with retry logic and better error handling - const maxRetries = 3; - let lastError: Error | null = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - await page.goto(url, { waitUntil: 'networkidle' }); - await use(page); - return; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - - // Check if server is still running - const serverRunning = await webServer.isRunning(); - - if (attempt < maxRetries && serverRunning) { - // Server is running but navigation failed, try again - console.warn( - `[webPage] Navigation attempt ${attempt} failed: ${lastError.message}. ` + - `Retrying (${attempt}/${maxRetries})...` - ); - await new Promise(resolve => setTimeout(resolve, 1000)); - } else { - // Last attempt or server is down - if (!serverRunning) { - throw new Error( - `[webPage] Failed to navigate to ${url}: Server is not responding. ` + - `Original error: ${lastError.message}` - ); - } else { - throw new Error( - `[webPage] Failed to navigate to ${url} after ${maxRetries} attempts. ` + - `Last error: ${lastError.message}` - ); - } - } - } - } - - // Fallback (should not reach here, but for safety) - throw lastError || new Error('Failed to navigate to webPage'); + // URL builder + urlBuilder: async ({ webServerConfig }, use) => { + const builder = new WebUrlBuilder(webServerConfig); + await use(builder); + }, + + // Page pre-navigated to the web UI + webPage: async ({ page, urlBuilder }, use) => { + const url = urlBuilder.getUrl('/'); + await page.goto(url); + await use(page); }, + // E2E metadata from previous tests e2eMetadata: async ({ webServerConfig }, use) => { const appName = process.env.APP_NAME || 'nextjs-turbopack'; const metadata = findE2EMetadata(appName, webServerConfig.backend); await use(metadata); }, + // Helper to get runId by test name getRunId: async ({ e2eMetadata }, use) => { const fn = (testName: string): string | null => { if (!e2eMetadata?.runIds) return null; @@ -203,6 +143,7 @@ export const test = base.extend({ await use(fn); }, + // Helper to get any available runId getAnyRunId: async ({ e2eMetadata }, use) => { const fn = (): string | null => { if (!e2eMetadata?.runIds || e2eMetadata.runIds.length === 0) return null; @@ -213,4 +154,4 @@ export const test = base.extend({ }); export { expect } from '@playwright/test'; -export type { WebServer, WebServerConfig }; +export type { WebUrlBuilder, WebServerConfig }; diff --git a/packages/web-e2e/src/fixtures/web-server.ts b/packages/web-e2e/src/fixtures/web-server.ts index 2d3a1cf91..a8c96c426 100644 --- a/packages/web-e2e/src/fixtures/web-server.ts +++ b/packages/web-e2e/src/fixtures/web-server.ts @@ -1,5 +1,3 @@ -import { spawn, type ChildProcess } from 'node:child_process'; -import { createRequire } from 'node:module'; import path from 'node:path'; /** @@ -12,7 +10,7 @@ export interface WebServerConfig { backend: 'local' | 'vercel'; /** - * Port to run the web server on + * Port the web server runs on */ port: number; @@ -33,13 +31,11 @@ export interface WebServerConfig { } /** - * Web server manager for Playwright tests. - * Spawns the @workflow/web Next.js server and manages its lifecycle. + * URL builder for the web UI. + * The actual server lifecycle is managed by Playwright's webServer config. */ -export class WebServer { - private process: ChildProcess | null = null; +export class WebUrlBuilder { private config: WebServerConfig; - private started = false; constructor(config: WebServerConfig) { this.config = config; @@ -52,6 +48,13 @@ export class WebServer { return `http://localhost:${this.config.port}`; } + /** + * Get the backend type + */ + get backend(): 'local' | 'vercel' { + return this.config.backend; + } + /** * Get query parameters for the web server based on configuration */ @@ -91,143 +94,6 @@ export class WebServer { } return url.toString(); } - - /** - * Start the web server - */ - async start(): Promise { - if (this.started) { - console.log('[WebServer] Already started'); - return; - } - - // Check if server is already running on the port - if (await this.isRunning()) { - console.log( - `[WebServer] Server already running on port ${this.config.port}` - ); - this.started = true; - return; - } - - // Find the @workflow/web package - const webPackagePath = this.findWebPackagePath(); - console.log(`[WebServer] Using web package at: ${webPackagePath}`); - - // Start the Next.js server - const shellCommand = `npx next start -p ${this.config.port}`; - console.log(`[WebServer] Starting: ${shellCommand}`); - - // Create env object and properly remove workflow-specific vars - // (Setting to undefined doesn't actually delete the property) - const env = { ...process.env }; - delete env.WORKFLOW_TARGET_WORLD; - delete env.WORKFLOW_VERCEL_ENV; - delete env.WORKFLOW_VERCEL_AUTH_TOKEN; - delete env.WORKFLOW_VERCEL_PROJECT; - delete env.WORKFLOW_VERCEL_TEAM; - delete env.WORKFLOW_LOCAL_DATA_DIR; - - this.process = spawn(shellCommand, { - shell: true, - cwd: webPackagePath, - detached: false, - stdio: ['ignore', 'pipe', 'pipe'], - env, - }); - - // Log server output - this.process.stdout?.on('data', (data) => { - console.log(`[WebServer] ${data.toString().trim()}`); - }); - - this.process.stderr?.on('data', (data) => { - console.error(`[WebServer] ${data.toString().trim()}`); - }); - - this.process.on('error', (error) => { - console.error(`[WebServer] Process error: ${error}`); - }); - - this.process.on('exit', (code, signal) => { - if (code !== 0 && code !== null) { - console.log(`[WebServer] Exited with code ${code}`); - } - this.process = null; - }); - - // Wait for server to be ready - const maxRetries = 60; - const retryInterval = 1000; - - for (let i = 0; i < maxRetries; i++) { - await this.sleep(retryInterval); - if (await this.isRunning()) { - console.log(`[WebServer] Server ready on port ${this.config.port}`); - this.started = true; - return; - } - } - - throw new Error( - `[WebServer] Failed to start within ${maxRetries * retryInterval}ms` - ); - } - - /** - * Stop the web server - */ - async stop(): Promise { - if (this.process && !this.process.killed) { - console.log('[WebServer] Stopping server...'); - this.process.kill('SIGTERM'); - this.process = null; - } - this.started = false; - } - - /** - * Check if the server is running - */ - async isRunning(): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - try { - const response = await fetch(this.baseUrl, { - method: 'GET', - signal: controller.signal, - }); - return response.ok; - } finally { - clearTimeout(timeoutId); - } - } catch { - return false; - } - } - - /** - * Find the @workflow/web package path - */ - private findWebPackagePath(): string { - try { - // Try to resolve from the workspace root - const requireFromHere = createRequire(import.meta.url); - const packageJsonPath = requireFromHere.resolve( - '@workflow/web/package.json' - ); - return path.dirname(packageJsonPath); - } catch { - // Fallback to relative path from this package - return path.resolve(import.meta.dirname, '../../../web'); - } - } - - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } } /** diff --git a/packages/web-e2e/src/web-ui.spec.ts b/packages/web-e2e/src/web-ui.spec.ts index b537d523b..73d04bbe3 100644 --- a/packages/web-e2e/src/web-ui.spec.ts +++ b/packages/web-e2e/src/web-ui.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from './fixtures/index.js'; +import { expect, test } from './fixtures/index.js'; test.describe('Web UI - Runs List', () => { test('should display the runs tab by default', async ({ webPage }) => { @@ -127,7 +127,6 @@ test.describe('Web UI - Run Detail View', () => { test('should navigate to run detail page', async ({ webPage, getAnyRunId, - e2eMetadata, }) => { // Skip if no run IDs available const runId = getAnyRunId(); diff --git a/packages/web-e2e/tsconfig.json b/packages/web-e2e/tsconfig.json index 5192350a3..4d621a4f0 100644 --- a/packages/web-e2e/tsconfig.json +++ b/packages/web-e2e/tsconfig.json @@ -8,8 +8,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "outDir": "./dist", - "rootDir": "./src" + "noEmit": true }, "include": ["src/**/*", "playwright.config.ts"], "exclude": ["node_modules", "dist"]