diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de5db72f..39db0efd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,14 @@ jobs: name: Plugins uses: ./.github/workflows/plugins.yml + e2e: + name: E2E + uses: ./.github/workflows/e2e.yml + all-checks: name: All Checks Passed runs-on: ubuntu-22.04 - needs: [skit, ui, plugins] + needs: [skit, ui, plugins, e2e] steps: - name: All checks passed run: echo "All CI checks passed successfully!" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..50b587b8 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +name: E2E Tests + +on: + workflow_call: + +env: + CARGO_TERM_COLOR: always + +jobs: + e2e: + name: Playwright E2E + runs-on: ubuntu-22.04 + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: false + + - uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Build UI + working-directory: ./ui + run: | + bun install --frozen-lockfile + bun run build + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.92.0" + + - uses: Swatinem/rust-cache@v2 + + - name: Build skit (debug) + run: cargo build -p streamkit-server --bin skit + + - name: Install E2E dependencies + working-directory: ./e2e + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: ./e2e + run: bunx playwright install chromium --with-deps + + - name: Lint E2E (typecheck + prettier) + working-directory: ./e2e + run: bun run lint + + - name: Run E2E tests + working-directory: ./e2e + run: bun run test + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: e2e/playwright-report/ + retention-days: 7 + + - name: Upload test artifacts + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-results + path: e2e/test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 6d25445f..5d4126e2 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,8 @@ models /examples/plugins/*/build/ demo/ + +# E2E test artifacts +e2e/test-results +e2e/playwright-report +e2e/.playwright diff --git a/e2e/.prettierignore b/e2e/.prettierignore new file mode 100644 index 00000000..27f2d32f --- /dev/null +++ b/e2e/.prettierignore @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +node_modules +bun.lock +.playwright +playwright-report +test-results + diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..5211d2b3 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,133 @@ + + + +# StreamKit E2E Tests + +End-to-end tests for StreamKit using Playwright. + +## Prerequisites + +- Bun 1.3.5+ +- Rust 1.92.0+ (for building skit) +- Built UI (`cd ui && bun install && bun run build` or `just build-ui`) +- Built skit binary (`cargo build -p streamkit-server --bin skit`) + +## Quick Start + +```bash +# Install dependencies and Playwright browsers +just install-e2e +just install-playwright + +# Run tests (automatically starts server) +just e2e + +# Or run directly from e2e directory +cd e2e +bun install +bunx playwright install chromium +bun run test +``` + +## Running Against External Server + +If you already have a StreamKit server running: + +```bash +E2E_BASE_URL=http://localhost:4545 bun run test:only + +# Or via justfile +just e2e-external http://localhost:4545 +``` + +## Running Against Vite Dev Server + +To test against the Vite development server (useful for debugging UI changes): + +```bash +# Terminal 1: Start skit backend +cargo run -p streamkit-server --bin skit -- serve + +# Terminal 2: Start Vite dev server +cd ui && bun run dev + +# Terminal 3: Run E2E tests against Vite +just e2e-external http://localhost:3045 +``` + +The Vite dev server proxies `/api/*` and `/healthz` requests to the skit backend +(default `127.0.0.1:4545`). This is primarily for Playwright’s direct API calls when +`E2E_BASE_URL` points at the Vite server; the UI itself still talks directly to the backend +in development (via `import.meta.env.VITE_API_BASE`). + +Both servers must be running for tests to pass. + +## Test Structure + +- `tests/design.spec.ts` - Design view tests (canvas, samples, YAML editor) +- `tests/monitor.spec.ts` - Monitor view tests (session lifecycle) + +## Server Harness + +When `E2E_BASE_URL` is not set, the test harness (`src/harness/run.ts`): + +1. Finds a free port +2. Starts `target/debug/skit serve` with `SK_SERVER__ADDRESS=127.0.0.1:` +3. Polls `/healthz` until server is ready (30s timeout) +4. Runs all Playwright tests +5. Stops the server + +Environment variables set by harness: + +- `SK_SERVER__ADDRESS` - Bind address +- `SK_LOG__FILE_ENABLE=false` - Disable file logging +- `RUST_LOG=warn` - Reduce log noise + +## Scripts + +| Script | Description | +| --------------------- | -------------------------------------------- | +| `bun run test` | Run tests with auto server management | +| `bun run test:only` | Run tests directly (requires `E2E_BASE_URL`) | +| `bun run test:headed` | Run tests with visible browser | +| `bun run test:ui` | Run tests with Playwright UI | +| `bun run report` | Show HTML test report | + +## Debugging + +```bash +# Run with debug mode (shows server output) +DEBUG=1 bun run test + +# Run single test file +bun run test -- tests/design.spec.ts + +# Run with trace viewer on failure +bun run test -- --trace on + +# Run specific test by name +bun run test -- -g "loads with all main panes" +``` + +## CI + +Tests run automatically in CI via `.github/workflows/e2e.yml`. +On failure, `playwright-report/` and `test-results/` are uploaded as artifacts. + +## Adding New Tests + +1. Create a new spec file in `tests/` directory +2. Use `data-testid` attributes for stable element selection +3. Prefer role/name selectors for accessible elements +4. Avoid arbitrary waits; use Playwright's built-in assertions + +Example: + +```typescript +import { test, expect } from '@playwright/test'; + +test('my new test', async ({ page }) => { + await page.goto('/my-route'); + await expect(page.getByTestId('my-element')).toBeVisible(); +}); +``` diff --git a/e2e/bun.lock b/e2e/bun.lock new file mode 100644 index 00000000..04cfe6ab --- /dev/null +++ b/e2e/bun.lock @@ -0,0 +1,29 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "streamkit-e2e", + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.0.0", + "typescript": "~5.9.3", + }, + }, + }, + "packages": { + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + + "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..a9cfb1f8 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,22 @@ +{ + "name": "streamkit-e2e", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "format": "../ui/node_modules/.bin/prettier --write . --config ../ui/.prettierrc.json --ignore-path ./.prettierignore", + "format:check": "../ui/node_modules/.bin/prettier --check . --config ../ui/.prettierrc.json --ignore-path ./.prettierignore", + "lint": "bun run typecheck && bun run format:check", + "test": "bun run src/harness/run.ts", + "test:only": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "bun run src/harness/run.ts -- --headed", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.0.0", + "typescript": "~5.9.3" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..a107c694 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import { defineConfig, devices } from '@playwright/test'; + +// E2E_BASE_URL is set by the harness runner (run.ts) or passed externally +const baseURL = process.env.E2E_BASE_URL; + +if (!baseURL) { + throw new Error( + 'E2E_BASE_URL environment variable is required. ' + + 'Run tests via "bun run test" to auto-start the server, ' + + 'or set E2E_BASE_URL manually for an external server.' + ); +} + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, // Run tests serially for shared server + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, // Single worker for shared server state + reporter: process.env.CI ? [['html'], ['github']] : [['html']], + timeout: 30000, + expect: { + timeout: 10000, + }, + + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/e2e/src/harness/health.ts b/e2e/src/harness/health.ts new file mode 100644 index 00000000..2014b9ba --- /dev/null +++ b/e2e/src/harness/health.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +/** + * Wait for the server to become healthy by polling the /healthz endpoint. + */ +export async function waitForHealth( + baseUrl: string, + timeoutMs: number = 30000, + intervalMs: number = 500 +): Promise { + const deadline = Date.now() + timeoutMs; + const healthUrl = `${baseUrl}/healthz`; + + while (Date.now() < deadline) { + try { + const response = await fetch(healthUrl); + if (response.ok) { + const data = (await response.json()) as { status?: string }; + if (data.status === 'ok') { + return; + } + } + } catch { + // Server not ready yet, continue polling + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`Server health check timed out after ${timeoutMs}ms`); +} diff --git a/e2e/src/harness/port.ts b/e2e/src/harness/port.ts new file mode 100644 index 00000000..6c811fe8 --- /dev/null +++ b/e2e/src/harness/port.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import * as net from 'net'; + +/** + * Find a free port on the local machine by binding to port 0. + */ +export async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address && typeof address !== 'string') { + const port = address.port; + server.close(() => resolve(port)); + } else { + reject(new Error('Failed to get port from server address')); + } + }); + }); +} diff --git a/e2e/src/harness/run.ts b/e2e/src/harness/run.ts new file mode 100644 index 00000000..6cdf066f --- /dev/null +++ b/e2e/src/harness/run.ts @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +/** + * E2E Test Runner + * + * This script handles server lifecycle for E2E tests: + * - If E2E_BASE_URL is set, runs playwright directly against that server + * - Otherwise, starts a local skit server, waits for health, runs tests, then stops + */ + +import { spawn, type ChildProcess } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { findFreePort } from './port'; +import { waitForHealth } from './health'; + +const ROOT_DIR = path.resolve(import.meta.dirname, '../../..'); +const MAX_LOG_BYTES = 256 * 1024; + +interface ServerInfo { + process: ChildProcess; + baseUrl: string; + port: number; + stdout: string; + stderr: string; +} + +function appendBounded(buffer: string, chunk: string): string { + const next = buffer + chunk; + if (next.length <= MAX_LOG_BYTES) { + return next; + } + return next.slice(next.length - MAX_LOG_BYTES); +} + +async function startServer(): Promise { + const port = await findFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + + // Check if UI is built + const uiDistPath = path.join(ROOT_DIR, 'ui/dist/index.html'); + if (!fs.existsSync(uiDistPath)) { + throw new Error( + 'UI not built. Run "cd ui && bun install && bun run build" or "just build-ui" first.' + ); + } + + // Check if skit binary exists + const skitPath = path.join(ROOT_DIR, 'target/debug/skit'); + if (!fs.existsSync(skitPath)) { + throw new Error( + 'skit binary not found. Run "cargo build -p streamkit-server --bin skit" first.' + ); + } + + console.log(`Starting skit server on port ${port}...`); + + const serverProcess = spawn(skitPath, ['serve'], { + cwd: ROOT_DIR, + env: { + ...process.env, + SK_SERVER__ADDRESS: `127.0.0.1:${port}`, + SK_LOG__FILE_ENABLE: 'false', // Avoid writing skit.log + RUST_LOG: 'warn', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + // Log server output for debugging + serverProcess.stdout?.on('data', (data: Buffer) => { + const text = data.toString(); + stdout = appendBounded(stdout, text); + if (process.env.DEBUG) console.log(`[skit stdout] ${text}`); + }); + + serverProcess.stderr?.on('data', (data: Buffer) => { + const text = data.toString(); + stderr = appendBounded(stderr, text); + if (process.env.DEBUG) console.error(`[skit stderr] ${text}`); + }); + + serverProcess.on('error', (err) => { + console.error('Failed to start server:', err); + }); + + try { + let onExit: ((code: number | null, signal: NodeJS.Signals | null) => void) | null = null; + const exitedEarly = new Promise((_, reject) => { + onExit = (code, signal) => { + reject( + new Error( + `skit exited before becoming healthy (code=${code ?? 'null'}, signal=${signal ?? 'null'})` + ) + ); + }; + serverProcess.once('exit', onExit); + }); + + await Promise.race([waitForHealth(baseUrl), exitedEarly]); + if (onExit) { + serverProcess.off('exit', onExit); + } + exitedEarly.catch(() => undefined); + console.log(`Server ready at ${baseUrl}`); + } catch (error) { + if (!process.env.DEBUG) { + const trimmedStdout = stdout.trim(); + const trimmedStderr = stderr.trim(); + if (trimmedStdout) console.log(`\n[skit stdout]\n${trimmedStdout}\n`); + if (trimmedStderr) console.error(`\n[skit stderr]\n${trimmedStderr}\n`); + } + await stopServer({ process: serverProcess, baseUrl, port, stdout, stderr }); + throw error; + } + + return { process: serverProcess, baseUrl, port, stdout, stderr }; +} + +function stopServer(serverInfo: ServerInfo): Promise { + return new Promise((resolve) => { + if (serverInfo.process.killed || serverInfo.process.exitCode !== null) { + resolve(); + return; + } + + console.log('Stopping skit server...'); + + const onExit = () => { + console.log('Server stopped.'); + resolve(); + }; + + serverInfo.process.once('exit', onExit); + serverInfo.process.kill('SIGTERM'); + + setTimeout(() => { + if (serverInfo.process.exitCode !== null) { + return; + } + console.log('Force killing server...'); + serverInfo.process.kill('SIGKILL'); + setTimeout(() => { + if (serverInfo.process.exitCode === null) { + console.warn('Server did not exit after SIGKILL; continuing anyway.'); + } + resolve(); + }, 2000); + }, 5000); + }); +} + +async function runPlaywright(baseUrl: string, extraArgs: string[]): Promise { + return new Promise((resolve) => { + const args = ['playwright', 'test', ...extraArgs]; + console.log(`Running: bunx ${args.join(' ')}`); + + const playwright = spawn('bunx', args, { + cwd: path.resolve(import.meta.dirname, '../..'), + env: { + ...process.env, + E2E_BASE_URL: baseUrl, + }, + stdio: 'inherit', + }); + + playwright.on('error', (err) => { + console.error('Failed to run playwright:', err); + resolve(1); + }); + + playwright.on('exit', (code) => { + resolve(code ?? 1); + }); + }); +} + +async function main(): Promise { + // Get extra args to pass to playwright (everything after --) + const args = process.argv.slice(2); + const dashDashIndex = args.indexOf('--'); + const playwrightArgs = dashDashIndex >= 0 ? args.slice(dashDashIndex + 1) : args; + + // Check if E2E_BASE_URL is already set (external server) + const existingBaseUrl = process.env.E2E_BASE_URL; + if (existingBaseUrl) { + console.log(`Using external server at ${existingBaseUrl}`); + const exitCode = await runPlaywright(existingBaseUrl, playwrightArgs); + process.exit(exitCode); + } + + // Start local server + let serverInfo: ServerInfo | null = null; + let exitCode = 1; + + try { + serverInfo = await startServer(); + exitCode = await runPlaywright(serverInfo.baseUrl, playwrightArgs); + } catch (error) { + console.error('Error:', error); + exitCode = 1; + } finally { + if (serverInfo) { + await stopServer(serverInfo); + } + } + + process.exit(exitCode); +} + +main(); diff --git a/e2e/tests/design.spec.ts b/e2e/tests/design.spec.ts new file mode 100644 index 00000000..e45dd9a6 --- /dev/null +++ b/e2e/tests/design.spec.ts @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import { test, expect } from '@playwright/test'; + +test.describe('Design View', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/design'); + // Wait for the design view to load + await expect(page.getByTestId('design-view')).toBeVisible(); + }); + + test('loads with all main panes visible', async ({ page }) => { + // Left pane (Control Pane / Library) + await expect(page.getByTestId('control-pane')).toBeVisible(); + + // Center pane (Flow Canvas) + await expect(page.getByTestId('flow-canvas')).toBeVisible(); + + // Right pane (YAML Pane) + await expect(page.getByTestId('yaml-pane')).toBeVisible(); + }); + + test('sample pipelines pane shows samples', async ({ page }) => { + // Click on Samples tab in the control pane + await page.getByTestId('samples-tab').click(); + + // Wait for samples pane to be visible + await expect(page.getByTestId('samples-pane')).toBeVisible(); + + // Verify at least one sample card is visible (system samples should always exist) + await expect(page.getByTestId('sample-card').first()).toBeVisible({ + timeout: 10000, + }); + }); + + test('loading a oneshot sample populates the canvas and YAML editor', async ({ page }) => { + // Ensure we're in oneshot mode (default is dynamic) + await page.getByRole('button', { name: /Oneshot/i }).click(); + + // Open samples pane + await page.getByTestId('samples-tab').click(); + await expect(page.getByTestId('samples-pane')).toBeVisible(); + + // Wait for samples list to populate and find "Volume Boost" sample + const volumeBoostSample = page.getByTestId('sample-card').filter({ hasText: 'Volume Boost' }); + await expect(volumeBoostSample).toBeVisible({ timeout: 10000 }); + + // Click the card's load button (avoid clicking the container div) + await volumeBoostSample.getByRole('button').click(); + + // Handle confirmation modal if canvas has content + const confirmModal = page.getByTestId('confirm-modal'); + if (await confirmModal.isVisible({ timeout: 2000 }).catch(() => false)) { + // Click the confirm/load button (scope to modal) + await confirmModal.getByRole('button', { name: /Load Sample|Load|Confirm/i }).click(); + } + + // Verify nodes appear on canvas (React Flow renders nodes with this class) + await expect(page.locator('.react-flow__node').first()).toBeVisible({ + timeout: 10000, + }); + + // Verify YAML pane shows the loaded pipeline + const yamlPane = page.getByTestId('yaml-pane'); + await expect(yamlPane).toBeVisible(); + + // Check that YAML contains expected mode indicator + // The CodeMirror editor renders content in .cm-content + await expect(page.locator('.cm-content')).toContainText('mode: oneshot'); + }); +}); diff --git a/e2e/tests/monitor.spec.ts b/e2e/tests/monitor.spec.ts new file mode 100644 index 00000000..92b45a19 --- /dev/null +++ b/e2e/tests/monitor.spec.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import { test, expect, request } from '@playwright/test'; + +test.describe('Monitor View - Session Lifecycle', () => { + // Unique session name for this test run + const testSessionName = `e2e-test-session-${Date.now()}`; + let sessionId: string | null = null; + + // Minimal dynamic session YAML that doesn't require MoQ/plugins + // Uses core::file_reader (source with no inputs) → core::sink + const minimalPipelineYaml = `mode: dynamic +steps: + - kind: core::file_reader + params: + path: samples/audio/system/ehren-paper_lights-96.opus + - kind: core::sink +`; + + test.beforeEach(async ({ page }) => { + await page.goto('/monitor'); + await expect(page.getByTestId('monitor-view')).toBeVisible(); + }); + + test('creates session via API, verifies it appears in UI, then deletes it', async ({ + page, + baseURL, + }) => { + const apiContext = await request.newContext({ baseURL: baseURL! }); + + try { + // Step 1: Create session via API + const createResponse = await apiContext.post('/api/v1/sessions', { + data: { + name: testSessionName, + yaml: minimalPipelineYaml, + }, + }); + const responseText = await createResponse.text(); + expect(createResponse.ok(), `Create session failed: ${responseText}`).toBeTruthy(); + const createData = JSON.parse(responseText) as { session_id: string }; + sessionId = createData.session_id; + expect(sessionId).toBeTruthy(); + + // Step 2: Refresh the page to see the new session + await page.reload(); + await expect(page.getByTestId('monitor-view')).toBeVisible(); + + // Wait for sessions list to load + await expect(page.getByTestId('sessions-list')).toBeVisible({ timeout: 10000 }); + + // Find our session in the list by name + const sessionItem = page.getByTestId('session-item').filter({ hasText: testSessionName }); + await expect(sessionItem).toBeVisible({ timeout: 10000 }); + + // Step 3: Delete the session via UI + await sessionItem.hover(); + const deleteButton = sessionItem.getByTestId('session-delete-btn'); + await expect(deleteButton).toBeVisible(); + await deleteButton.click(); + + // Confirm deletion in modal (scope to modal) + const confirmModal = page.getByTestId('confirm-modal'); + await expect(confirmModal).toBeVisible(); + await confirmModal.getByRole('button', { name: /Confirm|Delete/i }).click(); + + // Verify session is removed from the list + await expect(sessionItem).toHaveCount(0, { timeout: 10000 }); + + // Mark as cleaned up so afterEach doesn't try to delete again + sessionId = null; + } finally { + await apiContext.dispose(); + } + }); + + test.afterEach(async ({ baseURL }) => { + // Cleanup: ensure session is deleted even if test fails + if (sessionId) { + try { + const apiContext = await request.newContext({ baseURL: baseURL! }); + await apiContext.delete(`/api/v1/sessions/${sessionId}`); + await apiContext.dispose(); + } catch { + // Ignore cleanup errors + } + sessionId = null; + } + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..8b409659 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "playwright.config.ts"] +} diff --git a/justfile b/justfile index 1284fe84..fb2e42ec 100644 --- a/justfile +++ b/justfile @@ -935,3 +935,44 @@ show-versions: @echo "Binaries:" @grep '^version' apps/skit/Cargo.toml | head -1 | awk '{print " streamkit-server (skit): " $$3}' @grep '^version' apps/skit-cli/Cargo.toml | head -1 | awk '{print " streamkit-client (skit-cli): " $$3}' + +# --- E2E Tests --- + +# Install E2E test dependencies +[working-directory: 'e2e'] +install-e2e: + @echo "Installing E2E dependencies..." + @bun install + +# Lint E2E (TypeScript + formatting) +lint-e2e: install-e2e + @echo "Linting E2E..." + @cd e2e && bun run lint + +# Install Playwright browsers +[working-directory: 'e2e'] +install-playwright: install-e2e + @echo "Installing Playwright browsers..." + @bunx playwright install chromium + +# Run E2E tests (builds UI and skit if needed) +e2e: build-ui install-e2e + @echo "Building skit (debug)..." + @cargo build -p streamkit-server --bin skit + @echo "Running E2E tests..." + @cd e2e && bun run test + +# Run E2E tests with headed browser +e2e-headed: build-ui install-e2e + @cargo build -p streamkit-server --bin skit + @cd e2e && bun run test:headed + +# Run E2E against external server +e2e-external url: + @echo "Running E2E tests against {{url}}..." + @cd e2e && E2E_BASE_URL={{url}} bun run test:only + +# Show E2E test report +[working-directory: 'e2e'] +e2e-report: + @bunx playwright show-report diff --git a/ui/src/components/ConfirmModal.tsx b/ui/src/components/ConfirmModal.tsx index 5458082c..c4baf80c 100644 --- a/ui/src/components/ConfirmModal.tsx +++ b/ui/src/components/ConfirmModal.tsx @@ -43,7 +43,7 @@ const ConfirmModal: React.FC = ({ !open && onCancel()}> - + {title} diff --git a/ui/src/components/FlowCanvas.tsx b/ui/src/components/FlowCanvas.tsx index 4b75282e..8036409c 100644 --- a/ui/src/components/FlowCanvas.tsx +++ b/ui/src/components/FlowCanvas.tsx @@ -148,7 +148,7 @@ export const FlowCanvas = = Record +
> nodes={nodes} edges={edges} diff --git a/ui/src/panes/ControlPane.tsx b/ui/src/panes/ControlPane.tsx index e26b96eb..43e8d0a5 100644 --- a/ui/src/panes/ControlPane.tsx +++ b/ui/src/panes/ControlPane.tsx @@ -790,12 +790,14 @@ const ControlPane: React.FC = ({ return ( <> - + Nodes Assets - Samples + + Samples + {isAdmin() && Plugins} {nodeLibraryContent} diff --git a/ui/src/panes/SamplePipelinesPane.tsx b/ui/src/panes/SamplePipelinesPane.tsx index 8fabdbf8..3386f5f2 100644 --- a/ui/src/panes/SamplePipelinesPane.tsx +++ b/ui/src/panes/SamplePipelinesPane.tsx @@ -411,7 +411,7 @@ const SamplePipelinesPane = forwardRef !s.is_system); return ( - + Samples & Fragments Click to load samples or drag fragments to canvas @@ -431,7 +431,7 @@ const SamplePipelinesPane = forwardRef System Samples {systemSamples.map((sample) => ( - + handleLoadSample(sample)}> {sample.name} @@ -464,7 +464,7 @@ const SamplePipelinesPane = forwardRef User Samples {userSamples.map((sample) => ( - + handleLoadSample(sample)}> {sample.name} {sample.description && ( diff --git a/ui/src/panes/YamlPane.tsx b/ui/src/panes/YamlPane.tsx index 562794f7..aca61fec 100644 --- a/ui/src/panes/YamlPane.tsx +++ b/ui/src/panes/YamlPane.tsx @@ -356,7 +356,7 @@ const YamlPane: React.FC = ({ }; return ( - + Pipeline YAML diff --git a/ui/src/views/DesignView.tsx b/ui/src/views/DesignView.tsx index c94e631a..9696a287 100644 --- a/ui/src/views/DesignView.tsx +++ b/ui/src/views/DesignView.tsx @@ -1437,7 +1437,7 @@ const DesignViewContent: React.FC = () => { ); return ( - + = React.memo( ); return ( - + @@ -602,6 +602,7 @@ const SessionItem: React.FC = React.memo( className="session-delete-button" onClick={handleDelete} aria-label="Delete session" + data-testid="session-delete-btn" > 🗑️ @@ -1117,7 +1118,7 @@ const LeftPanel = React.memo( - + {isLoadingSessions ? ( Loading sessions... ) : sessions.length === 0 ? ( @@ -3014,7 +3015,7 @@ const MonitorViewContent: React.FC = () => { ); return ( -
+
{ }, server: { port: 3045, + proxy: { + // Proxy API requests to skit backend (enables E2E tests against dev server) + '/api': { + target: `http://${apiUrl}`, + changeOrigin: true, + }, + '/healthz': { + target: `http://${apiUrl}`, + changeOrigin: true, + }, + }, }, optimizeDeps: { exclude: ['@moq/hang'],