learning_ai_common_plat/dashboards/tracker-web/e2e/fleet.spec.ts
Saravanakumar D 0799f69c30 feat(fleet-web): harden budget bar, surface SSE polling, allow checkpoint in patchJob
- budget page: guard spend bar against missing/zero ceiling (no NaN width);
  show an explicit "no ceiling set" state. Add pure budgetUsagePct() helper.
- job detail: replace silent live/poll toggle with an explicit stream-mode
  badge (Live vs Polling) so operators see when SSE degrades to polling.
- fleet-client: extend patchJob to carry optional checkpoint/blockedReason
  matching the server PatchJobSchema; add FleetCheckpoint type.
- tests: unit cover budgetUsagePct + patchJob checkpoint forwarding; e2e
  asserts the polling indicator appears when the stream is unavailable.
- ci: add a Gitea Playwright e2e job that runs the fleet control-plane specs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 20:35:05 -07:00

351 lines
12 KiB
TypeScript

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;
reviewPolicy?: { requiredApprovals: number; reviewers: string[] };
reviewDecisions?: {
reviewer: string;
decision: 'approve' | 'reject';
at: string;
note?: string;
}[];
gate?: 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'),
reviewDecisions: [] as { reviewer: string; decision: 'approve' | 'reject'; at: string }[],
};
const jobView = () =>
makeJob({
id: 'job-1',
idempotencyKey: 'feat-x',
stage: state.jobStage,
...(state.jobStage === 'review'
? {
reviewPolicy: { requiredApprovals: 2, reviewers: ['admin@example.com', 'bob@x.com'] },
reviewDecisions: state.reviewDecisions,
}
: {}),
});
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 }),
});
}
// ── Review gate (multi-reviewer) ──
if (path.endsWith('/review/request') && method === 'POST') {
state.jobStage = 'review';
state.reviewDecisions = [];
return route.fulfill({ json: jobView() });
}
if (path.endsWith('/review') && method === 'POST') {
const payload = req.postDataJSON() as {
reviewer: string;
decision: 'approve' | 'reject';
};
if (payload.decision === 'reject') {
state.jobStage = 'queued';
return route.fulfill({ json: { ...jobView(), gate: 'rejected' } });
}
state.reviewDecisions = state.reviewDecisions
.filter(d => d.reviewer !== payload.reviewer)
.concat({ reviewer: payload.reviewer, decision: 'approve', at: ISO });
const approvals = new Set(
state.reviewDecisions.filter(d => d.decision === 'approve').map(d => d.reviewer)
).size;
const gate = approvals >= 2 ? 'approved' : 'pending';
if (gate === 'approved') state.jobStage = 'testing';
return route.fulfill({ json: { ...jobView(), gate } });
}
// ── 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: jobView() });
}
if (path.endsWith('/jobs') && method === 'GET') {
return route.fulfill({ json: { jobs: [jobView()] } });
}
if (path.endsWith('/factories')) return route.fulfill({ json: { factories: [FACTORY] } });
// ── Fleet metrics ──
if (path.endsWith('/metrics')) {
return route.fulfill({
json: {
productId: 'lysnrai',
generatedAt: ISO,
jobs: {
total: 1,
byStage: { queued: 1 },
queueDepth: 1,
blocked: 0,
active: 0,
oldestQueuedAgeMs: 1000,
},
factories: {
total: 1,
live: 1,
stale: 0,
byHealth: { ok: 1, degraded: 0, down: 0 },
seatsUsed: 1,
seatsTotal: 4,
utilizationPct: 25,
},
alerts: [
{ level: 'warning', code: 'queue_starvation', message: 'A job has waited too long.' },
],
},
});
}
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.getByTestId('fleet-metrics')).toBeVisible();
await expect(page.getByTestId('fleet-alerts')).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('surfaces the polling indicator when the live stream is unavailable', async ({ page }) => {
await authenticate(page);
await mockFleet(page, { jobStage: 'building' });
// Override just the SSE stream to fail → the client falls back to polling.
// Registered after mockFleet so it takes precedence over the catch-all.
await page.route('**/api/fleet/jobs/*/events/stream', (route: Route) =>
route.fulfill({ status: 500, body: 'stream down' })
);
await page.goto('/dashboard/fleet/jobs/job-1');
await expect(page.getByRole('heading', { name: 'feat-x' })).toBeVisible();
// The degraded transport must be visible to the operator, not silent.
await expect(page.getByTestId('polling-indicator')).toBeVisible();
await expect(page.getByTestId('live-indicator')).toHaveCount(0);
// Events still render via the polling fallback (GET /events).
await expect(page.getByText('submitted', { exact: true })).toBeVisible();
});
});
test.describe('Fleet — Review gate', () => {
test('routes a building job to review and approves through the gate', 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();
// Send the building job to review.
await page.getByRole('button', { name: 'Send this job to review' }).click();
await expect(page.getByTestId('review-gate')).toBeVisible();
await expect(page.getByTestId('review-progress')).toHaveText('0 / 2 approvals');
// First approval (admin@example.com) keeps the gate pending at 1/2.
await page.getByRole('button', { name: 'Approve this job' }).click();
await expect(page.getByTestId('review-progress')).toHaveText('1 / 2 approvals');
});
});
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();
});
});