diff --git a/dashboards/tracker-web/e2e/fleet.spec.ts b/dashboards/tracker-web/e2e/fleet.spec.ts new file mode 100644 index 00000000..31fb09e8 --- /dev/null +++ b/dashboards/tracker-web/e2e/fleet.spec.ts @@ -0,0 +1,238 @@ +import { test, expect, type Page, type Route } from '@playwright/test'; + +/** + * E2E tests for the Fleet (Agent Gigafactory) control-plane dashboard. + * + * Deterministic: every backend call is mocked at the Next.js proxy boundary + * (`/api/fleet/**`, `/api/auth/**`). No live platform-service is required. The + * mock dispatcher is method- and URL-aware and holds a little mutable state so + * operator actions (requeue) and budget pause/resume reflect in follow-up GETs. + */ + +const PRODUCT = 'tracker-e2e'; +const ISO = '2025-01-01T00:00:00.000Z'; + +type Job = { + id: string; + productId: string; + stage: string; + idempotencyKey: string; + bodyMd: string; + priority: string; + priorityOrder: number; + capabilities: string[]; + kind: string; + attempts: number; + leaseEpoch: number; + createdAt: string; + updatedAt: string; +}; + +function makeJob(partial: Partial & { id: string; idempotencyKey: string }): Job { + return { + productId: PRODUCT, + stage: 'building', + bodyMd: '# task', + priority: 'high', + priorityOrder: 1, + capabilities: ['build'], + kind: 'task', + attempts: 1, + leaseEpoch: 2, + createdAt: ISO, + updatedAt: ISO, + ...partial, + }; +} + +const FACTORY = { + id: 'f1', + productId: PRODUCT, + factoryId: 'factory-alpha', + capabilities: ['build', 'test'], + health: 'ok' as const, + load: 1, + seatLimit: 4, + lastHeartbeatAt: ISO, +}; + +/** Authenticate the dashboard: seed the token + selected product, mock /me. */ +async function authenticate(page: Page): Promise { + await page.addInitScript( + ([product]) => { + localStorage.setItem('tracker_token', 'fake-e2e-token'); + localStorage.setItem('tracker_selected_product', product); + }, + [PRODUCT] + ); + await page.route('**/api/auth/me', (route: Route) => + route.fulfill({ + json: { id: 'u1', email: 'admin@example.com', role: 'admin', displayName: 'Admin' }, + }) + ); +} + +/** + * Install the fleet API mock. Returns the mutable state so a test can inspect or + * assert on the latest values the dispatcher served. + */ +async function mockFleet( + page: Page, + opts?: { jobStage?: string; budgetStatus?: 'active' | 'paused' } +): Promise<{ state: { jobStage: string; budgetStatus: 'active' | 'paused' } }> { + const state = { + jobStage: opts?.jobStage ?? 'building', + budgetStatus: opts?.budgetStatus ?? ('active' as 'active' | 'paused'), + }; + + const budget = () => ({ + id: PRODUCT, + productId: PRODUCT, + ceilingUsd: 100, + window: 'monthly', + spentUsd: 25, + status: state.budgetStatus, + updatedAt: ISO, + }); + + await page.route('**/api/fleet/**', (route: Route) => { + const req = route.request(); + const url = new URL(req.url()); + const path = url.pathname; // e.g. /api/fleet/jobs/job-1/events + const method = req.method(); + + // ── Live event stream (SSE) ── + if (path.includes('/events/stream')) { + const evt = { + id: 'job-1:evt:0', + jobId: 'job-1', + seq: 0, + type: 'submitted', + at: ISO, + data: {}, + }; + return route.fulfill({ + status: 200, + headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, + body: `retry: 3000\n\nid: 0\nevent: fleet-event\ndata: ${JSON.stringify(evt)}\n\n`, + }); + } + if (path.endsWith('/events')) { + return route.fulfill({ + json: { + events: [ + { id: 'job-1:evt:0', jobId: 'job-1', seq: 0, type: 'submitted', at: ISO, data: {} }, + ], + }, + }); + } + if (path.endsWith('/runs')) return route.fulfill({ json: { runs: [] } }); + if (path.endsWith('/artifacts')) return route.fulfill({ json: { artifacts: [] } }); + // DAG + explain: degrade to 404 so the page renders without those panels. + // Body omits `error` so the client throws "HTTP 404" (apiFetchOptional → null). + if (path.endsWith('/dag') || path.endsWith('/explain')) { + return route.fulfill({ status: 404, json: {} }); + } + + // ── Operator actions (requeue / cancel / reject) ── + const actionMatch = path.match(/\/jobs\/[^/]+\/actions\/(\w+)$/); + if (actionMatch && method === 'POST') { + const action = actionMatch[1]; + state.jobStage = + action === 'requeue' ? 'queued' : action === 'reject' ? 'dead_letter' : 'failed'; + return route.fulfill({ + json: makeJob({ id: 'job-1', idempotencyKey: 'feat-x', stage: state.jobStage }), + }); + } + + // ── Budgets ── + if (path.endsWith('/burndown')) return route.fulfill({ status: 404, json: {} }); + if (path.endsWith('/pause') && method === 'POST') { + state.budgetStatus = 'paused'; + return route.fulfill({ json: budget() }); + } + if (path.endsWith('/resume') && method === 'POST') { + state.budgetStatus = 'active'; + return route.fulfill({ json: budget() }); + } + if (path.match(/\/budgets\/[^/]+$/)) return route.fulfill({ json: budget() }); + + // ── Jobs ── + if (path.match(/\/jobs\/[^/]+$/) && method === 'GET') { + return route.fulfill({ + json: makeJob({ id: 'job-1', idempotencyKey: 'feat-x', stage: state.jobStage }), + }); + } + if (path.endsWith('/jobs') && method === 'GET') { + return route.fulfill({ + json: { jobs: [makeJob({ id: 'job-1', idempotencyKey: 'feat-x', stage: state.jobStage })] }, + }); + } + if (path.endsWith('/factories')) return route.fulfill({ json: { factories: [FACTORY] } }); + + return route.fulfill({ json: {} }); + }); + + return { state }; +} + +test.describe('Fleet — Overview', () => { + test('renders factory cards and the recent-jobs table', async ({ page }) => { + await authenticate(page); + await mockFleet(page); + await page.goto('/dashboard/fleet'); + + await expect(page.getByRole('heading', { name: 'Fleet Control Plane' })).toBeVisible(); + await expect(page.getByLabel('Factory factory-alpha')).toBeVisible(); + await expect(page.getByRole('table', { name: 'Recent fleet jobs' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'feat-x' })).toBeVisible(); + }); +}); + +test.describe('Fleet — Jobs table', () => { + test('lists jobs and exposes the stage filter', async ({ page }) => { + await authenticate(page); + await mockFleet(page); + await page.goto('/dashboard/fleet/jobs'); + + await expect(page.getByRole('heading', { name: 'Fleet Jobs' })).toBeVisible(); + await expect(page.getByRole('table', { name: 'Fleet jobs table' })).toBeVisible(); + await expect(page.getByLabel('Filter by stage')).toBeVisible(); + await expect(page.getByRole('link', { name: 'View job feat-x' })).toBeVisible(); + }); +}); + +test.describe('Fleet — Job detail', () => { + test('requeues a job and reflects the new stage; shows the live badge', async ({ page }) => { + await authenticate(page); + await mockFleet(page, { jobStage: 'building' }); + await page.goto('/dashboard/fleet/jobs/job-1'); + + await expect(page.getByRole('heading', { name: 'feat-x' })).toBeVisible(); + // SSE snapshot delivers an event → the Live indicator should appear. + await expect(page.getByTestId('live-indicator')).toBeVisible(); + // Stage card starts at 'building'. + await expect(page.getByText('building', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Requeue this job' }).click(); + // After requeue the coordinator returns stage 'queued', mirrored on refresh. + await expect(page.getByText('queued', { exact: true }).first()).toBeVisible(); + }); +}); + +test.describe('Fleet — Budget', () => { + test('pauses and resumes the budget', async ({ page }) => { + await authenticate(page); + await mockFleet(page, { budgetStatus: 'active' }); + await page.goto('/dashboard/fleet/budget'); + + await expect(page.getByRole('heading', { name: 'Fleet Budget' })).toBeVisible(); + await expect(page.getByText('active', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Pause budget' }).click(); + await expect(page.getByText('paused', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Resume budget' }).click(); + await expect(page.getByText('active', { exact: true })).toBeVisible(); + }); +}); diff --git a/docs/TASKS_TO_COMPLETE.md b/docs/TASKS_TO_COMPLETE.md index 7617a3f4..fb754974 100644 --- a/docs/TASKS_TO_COMPLETE.md +++ b/docs/TASKS_TO_COMPLETE.md @@ -50,9 +50,10 @@ - Acceptance criteria: new events appear without refresh; reconnect + fallback work. - Verification command: `pnpm --filter @lysnrai/platform-service test` -- [ ] **Fleet Playwright e2e** +- [x] **Fleet Playwright e2e** - Priority: P2 (Phase-3 exit gate) - - Current status: none for fleet pages + - Current status: ✅ DONE — `e2e/fleet.spec.ts`, 4 specs (overview, jobs table, job-detail requeue + + live badge, budget pause/resume) against a method/URL-aware mocked `/api/fleet/**`; all green - Files involved: `dashboards/tracker-web/e2e/fleet.spec.ts` - Implementation plan: cover fleet map render, jobs table, job detail action, budget pause/resume against a mocked fleet API.