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:
parent
ea42602407
commit
d780739cbe
238
dashboards/tracker-web/e2e/fleet.spec.ts
Normal file
238
dashboards/tracker-web/e2e/fleet.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user