test: add fleet control-plane Playwright e2e coverage

New e2e/fleet.spec.ts with a method- and URL-aware /api/fleet/** mock that
holds mutable state so operator actions and budget toggles reflect in
follow-up GETs. Covers: fleet overview (factory cards + recent jobs), jobs
table + stage filter, job detail requeue (stage building->queued) with the
SSE-driven Live badge, and budget pause/resume. All 4 specs green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Saravanakumar D 2026-05-30 18:44:53 -07:00
parent ea42602407
commit d780739cbe
2 changed files with 241 additions and 2 deletions

View File

@ -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<Job> & { 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<void> {
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();
});
});

View File

@ -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.