diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd5baa1..70b6771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,22 @@ jobs: - name: Build run: npm run build + + webui-e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run Playwright E2E in Docker Compose + run: docker compose --profile e2e run --rm playwright-run + + - name: Upload Playwright artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-e2e-report + path: | + e2e/playwright-report + e2e/test-results + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 57a9f9d..ac619ad 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ node_modules/ capabilities/*/ui/dist/ modes/*/ui/dist/ agent-webui/dist/ +e2e/node_modules/ +e2e/playwright-report/ +e2e/test-results/ # Docker volumes (if running outside compose) data/ diff --git a/README.md b/README.md index 30f4037..5b6c26a 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,38 @@ Default endpoints: docker compose up -d kernel ``` +## Tests + +### Kernel BVT + +```bash +docker compose run --rm kernel-test sh -c "mkdir -p test-results && pytest -v tests -m bvt --junitxml=test-results/unit-pytest.xml" +``` + +### WebUI build + +```bash +cd agent-webui +npm ci --include=dev --no-audit --no-fund +npm run build +``` + +### Browser E2E UI mode + +```bash +docker compose --profile e2e up playwright +``` + +Then open `http://localhost:9323` to run and inspect tests in Playwright UI mode. The Playwright container runs against the Compose E2E WebUI service and stubs kernel API responses in the browser, so these smoke tests do not require Ollama responses or worker completion. + +### Browser E2E headless mode + +```bash +docker compose --profile e2e run --rm playwright-run +``` + +Headless mode is the same path used by CI. + ## API Snapshot Current kernel endpoints include: diff --git a/agent-webui/src/app/components/input-area.tsx b/agent-webui/src/app/components/input-area.tsx index 3c2c45f..9ec8610 100644 --- a/agent-webui/src/app/components/input-area.tsx +++ b/agent-webui/src/app/components/input-area.tsx @@ -82,7 +82,7 @@ export function InputArea({ textareaRef.current.style.height = 'auto'; textareaRef.current.focus(); } - }, [resetSignal, getUsage]); + }, [resetSignal]); const handleSend = () => { const validationError = validateMessage ? validateMessage(message) : null; diff --git a/agent-webui/vite.config.ts b/agent-webui/vite.config.ts index 4ccceb0..3df1e1f 100644 --- a/agent-webui/vite.config.ts +++ b/agent-webui/vite.config.ts @@ -16,6 +16,9 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + server: { + allowedHosts: ['webui-e2e'], + }, // File types to support raw imports. Never add .css, .tsx, or .ts files to this. assetsInclude: ['**/*.svg', '**/*.csv'], diff --git a/docker-compose.yml b/docker-compose.yml index e8c2d0c..2b8dbda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,6 +85,57 @@ services: - ./agent-webui:/app - webui_node_modules:/app/node_modules + playwright: + image: mcr.microsoft.com/playwright:v1.55.0-noble + working_dir: /e2e + profiles: + - e2e + depends_on: + webui-e2e: + condition: service_healthy + environment: + - PLAYWRIGHT_BASE_URL=http://webui-e2e:3000 + ports: + - "${HOST_PORT_PLAYWRIGHT_UI:-9323}:9323" + command: sh -c "npm install --no-audit --no-fund --package-lock=false && npx playwright test --ui --ui-host=0.0.0.0 --ui-port=9323" + volumes: + - ./e2e:/e2e + - e2e_node_modules:/e2e/node_modules + + playwright-run: + image: mcr.microsoft.com/playwright:v1.55.0-noble + working_dir: /e2e + profiles: + - e2e + depends_on: + webui-e2e: + condition: service_healthy + environment: + - PLAYWRIGHT_BASE_URL=http://webui-e2e:3000 + command: sh -c "npm install --no-audit --no-fund --package-lock=false && npm test" + volumes: + - ./e2e:/e2e + - e2e_node_modules:/e2e/node_modules + + webui-e2e: + image: node:20-alpine + working_dir: /app + command: sh -c "npm ci --include=dev --no-audit --no-fund && npm run dev:docker" + profiles: + - e2e + environment: + - CHOKIDAR_USEPOLLING=true + - VITE_API_URL=http://localhost:5501 + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 5s + volumes: + - ./agent-webui:/app + - webui_e2e_node_modules:/app/node_modules + # ollama: # # Uncomment to run Ollama as a bundled container. # # By default, Ollama is expected to run natively on the host (better @@ -98,5 +149,7 @@ services: # - ollama_data:/root/.ollama volumes: + e2e_node_modules: webui_node_modules: + webui_e2e_node_modules: # ollama_data: diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..53f0900 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "aigentos-e2e", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.55.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..475906e --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + expect: { + timeout: 10_000, + }, + fullyParallel: true, + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5500", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + outputDir: "test-results", +}); diff --git a/e2e/tests/app.spec.ts b/e2e/tests/app.spec.ts new file mode 100644 index 0000000..cf0606e --- /dev/null +++ b/e2e/tests/app.spec.ts @@ -0,0 +1,182 @@ +import { expect, Page, test } from "@playwright/test"; + +const now = "2026-05-08T12:00:00.000Z"; + +async function stubKernel(page: Page) { + const conversations = new Map(); + + await page.route("**/*", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + + if (request.resourceType() === "document" || url.port === "3000") { + return route.fallback(); + } + + const json = (body: unknown, status = 200) => + route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); + + if (path === "/health") { + return json({ + status: "ok", + version: "0.3.0-e2e", + tenant_id: "default", + model: "e2e-model", + ollama_base_url: "http://ollama:11434", + embedding_base_url: "http://ollama:11434", + is_warm: true, + }); + } + + if (path === "/api/llm/warmup") { + return json({ ok: true, status: "already_warmed", latency_ms: 1, model: "e2e-model", warmed_at: now }); + } + + if (path === "/api/conversations" && request.method() === "GET") { + return json([...conversations.values()]); + } + + if (path === "/api/conversations" && request.method() === "POST") { + const id = "conv-e2e"; + conversations.set(id, { + id, + title: "E2E chat", + last_message: "No messages yet", + updated_at: now, + message_count: 0, + }); + return json({ id, title: "E2E chat", updated_at: now, messages: [] }, 201); + } + + if (path === "/api/chat" && request.method() === "POST") { + const payload = request.postDataJSON() as { message: string; conversation_id: string }; + conversations.set(payload.conversation_id, { + id: payload.conversation_id, + title: "E2E chat", + last_message: payload.message, + updated_at: now, + message_count: 1, + }); + return json({ conversation_id: payload.conversation_id, event_id: "event-e2e-user", accepted_at: now }, 202); + } + + if (path.match(/^\/api\/conversations\/[^/]+\/events$/)) { + return json({ + id: "conv-e2e", + title: "E2E chat", + updated_at: now, + events: [], + background_updates: [], + }); + } + + if (path.match(/^\/api\/conversations\/[^/]+\/stream$/)) { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: "", + }); + } + + if (path === "/api/performance/recent") return json([]); + if (path === "/api/performance/summary") { + return json({ + exchange_count: 0, + latency_min_ms: 0, + latency_max_ms: 0, + latency_avg_ms: 0, + tokens_day: { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, exchange_count: 0, avg_tokens_per_exchange: 0 }, + tokens_week: { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, exchange_count: 0, avg_tokens_per_exchange: 0 }, + tokens_month: { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, exchange_count: 0, avg_tokens_per_exchange: 0 }, + tokens_all_time: { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, exchange_count: 0, avg_tokens_per_exchange: 0 }, + }); + } + + if (path === "/api/prompts/system") { + return json({ agent_id: "atlas", prompt: "You are Atlas.", component_count: 1, profile_name: "Default", is_custom: false }); + } + if (path === "/api/prompts/components" || path === "/api/prompts/orchestrator") return json([]); + if (path === "/api/prompts/profiles") return json([{ id: "default", name: "Default", is_active: true, is_default: true }]); + if (path === "/api/prompts/context-settings") { + return json({ + max_context_tokens: 4096, + max_response_tokens: 1024, + compact_trigger_pct: 0.9, + compact_instructions: "", + memory_enabled: true, + updated_at: now, + }); + } + + if (path === "/api/debug/logs") return json([]); + if (path === "/api/memory/chunks") return json({ memory_enabled: true, chunks: [] }); + if (path === "/api/imports") return json([]); + if (path === "/api/mcp/servers") return json([]); + + if (path === "/api/admin/export") return json({ version: "0.3.0-e2e", model: "e2e-model", ollama_base_url: "mock", data: {} }); + if (path === "/api/admin/delete-all-data") return json({ ok: true, deleted_at: now }); + if (path === "/api/baseline/start") return json({ job_id: "baseline-e2e", status: "queued" }, 202); + if (path === "/api/baseline/status/baseline-e2e") { + return json({ + job_id: "baseline-e2e", + status: "completed", + model: "e2e-model", + total_calls: 0, + completed_calls: 0, + current_step: null, + started_at: now, + updated_at: now, + completed_at: now, + duration_ms: 0, + events: [], + error: null, + result: { model: "e2e-model", mode: "direct_model", started_at: now, completed_at: now, duration_ms: 0, total_calls: 0, categories: [] }, + }); + } + + return route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ detail: `Unhandled E2E route: ${path}` }) }); + }); +} + +test.beforeEach(async ({ page }) => { + await stubKernel(page); +}); + +test("loads the chat shell and settings status", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Atlas" })).toBeVisible(); + await expect(page.getByText("No conversations yet")).toBeVisible(); + await expect(page.getByText("Model Ready (e2e-model)")).toBeVisible(); + + await page.getByRole("button", { name: "Settings" }).click(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + await expect(page.getByText("Connected + Ready")).toBeVisible(); + await expect(page.getByText("v0.3.0-e2e", { exact: true })).toBeVisible(); +}); + +test("creates a conversation and queues a chat turn", async ({ page }) => { + await page.goto("/"); + + await page.getByPlaceholder("Message your agent...").fill("Hello from Playwright"); + await page.getByRole("button", { name: "Send message" }).click(); + + await expect(page.getByText("Hello from Playwright")).toBeVisible(); + await expect(page.getByText("Queued for worker...")).toBeVisible(); + await expect(page.getByText("E2E chat")).toBeVisible(); +}); + +test("opens the dashboard orchestration view", async ({ page }) => { + await page.goto("/"); + + await page.getByRole("button", { name: "Dashboard" }).click(); + + await expect(page.getByRole("heading", { name: "Orchestration", exact: true })).toBeVisible(); + await expect(page.getByText("Behavior Layers")).toBeVisible(); + await expect(page.getByText("v0.3.0-e2e", { exact: true })).toBeVisible(); +});