From 4133fc3d309f7a39f097f3a1851669639f2ed4d8 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 02:58:32 +0200 Subject: [PATCH 1/9] feat(slice-4e): wire admin Audit/Cost/Queue/Notifications to existing endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked for slice 4e to audit each Operations admin page against the server route surface and wire any missing reads. The endpoints (/api/audit, /api/cost/summary, /api/queue, /api/notifications) all existed already; this slice adds the client-side fetches with graceful fallback to the local fixtures so mock-data mode keeps working. Admin: - PageAudit fetches /api/audit (x-aqa-org). When the server responds, the page subtitle reads "N events · live from /api/audit" and the AuditChainViewer renders the live events (the fixture demoBad is suppressed in live mode to avoid mixing concerns). - PageQueue fetches /api/queue and replaces QUEUE_JOBS with the live payload (KPIs + table both refresh). - PageCost fetches /api/cost/summary with tenant headers (x-aqa-org, x-aqa-project) and uses the server's total_usd as the MTD figure when available. - PageNotifications fetches /api/notifications (x-aqa-org). E2E: 5 new tests in operations.e2e.ts asserting the request fires, the headers are set, and either the live count renders (audit) or the fixture sub-header survives (audit fallback). Tests: admin e2e 128/129 (+5, 1 skipped). Lint preexisting only. --- packages/admin/src/app.tsx | 123 +++++++++++++++++++--- packages/admin/test/e2e/operations.e2e.ts | 114 ++++++++++++++++++++ 2 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 packages/admin/test/e2e/operations.e2e.ts diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 803b0e6..b599206 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11169,6 +11169,32 @@ function PageReplay({ onNavigate }) { // ---------------- Audit log ---------------- function PageAudit({ onNavigate }) { + // v1.7 slice 4e — wire to /api/audit (audit:read). The server route + // returns chain-verified events; in mock mode we fall back to the + // hardcoded fixtures (AUDIT_EVENTS_GOOD / _BAD) so the demo stays + // usable when no server is reachable. The AuditChainViewer's + // schema is whatever the server returns plus its existing demo + // demoGood/demoBad shape — we pass the live events as `demoGood` + // and clear the bad demo to avoid mixing concerns. + const [liveEvents, setLiveEvents] = React.useState(null); + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(apiUrl('/api/audit'), { + headers: { 'x-aqa-org': 'padosoft' }, + }); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (Array.isArray(body?.events)) setLiveEvents(body.events); + } catch { + /* mock mode — leave the fixtures */ + } + })(); + return () => { + cancelled = true; + }; + }, []); return (
} - sub="Hash-chained, tamper-evident event log · verify in-browser with Web Crypto" + sub={ + liveEvents + ? `${liveEvents.length} events · live from /api/audit` + : 'Hash-chained, tamper-evident event log · verify in-browser with Web Crypto' + } actions={ <> - @@ -11192,14 +11222,41 @@ function PageAudit({ onNavigate }) { } /> - + 0 ? liveEvents : AUDIT_EVENTS_GOOD} + demoBad={liveEvents ? [] : AUDIT_EVENTS_BAD} + />
); } // ---------------- Cost ---------------- function PageCost({ onNavigate }) { - const mtd = COST_DAYS.reduce((a, d) => a + d.usd, 0); + // v1.7 slice 4e — wire to /api/cost/summary. The endpoint returns + // an aggregated summary (totals, breakdown by profile/model); the + // page's MTD curve and projection still draw from the local + // COST_DAYS fixture for now (no per-day endpoint yet). Pulling the + // summary lets the header KPIs reflect live state when available. + const [summary, setSummary] = React.useState(null); + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(apiUrl('/api/cost/summary'), { + headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'demo' }, + }); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (body?.summary) setSummary(body.summary); + } catch { + /* mock mode */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + const mtd = summary?.total_usd ?? COST_DAYS.reduce((a, d) => a + d.usd, 0); const dayCount = COST_DAYS.length; const avgDay = mtd / dayCount; const budget = 250; @@ -11429,11 +11486,32 @@ function PageCost({ onNavigate }) { // ---------------- Queue ---------------- function PageQueue({ onNavigate }) { - const pending = QUEUE_JOBS.filter((j) => !j.leased_by).length; - const inflight = QUEUE_JOBS.filter((j) => j.leased_by).length; - const oldestPending = QUEUE_JOBS.find((j) => !j.leased_by); + // v1.7 slice 4e — wire to /api/queue. Server returns the live jobs; + // the runners panel stays on the local RUNNERS fixture for v1.7 + // (no /api/runners endpoint yet). Falls back to QUEUE_JOBS on + // error so mock-data mode still renders the page. + const [jobs, setJobs] = React.useState(QUEUE_JOBS); + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(apiUrl('/api/queue')); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (Array.isArray(body?.jobs)) setJobs(body.jobs); + } catch { + /* mock mode — keep the fixture */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + const pending = jobs.filter((j) => !j.leased_by).length; + const inflight = jobs.filter((j) => j.leased_by).length; + const oldestPending = jobs.find((j) => !j.leased_by); const onlineRunners = RUNNERS.filter((r) => r.online).length; - const stuck = QUEUE_JOBS.find((j) => j.stuck); + const stuck = jobs.find((j) => j.stuck); return (
@@ -11487,7 +11565,7 @@ function PageQueue({ onNavigate }) { Stuck jobs
- {QUEUE_JOBS.filter((j) => j.stuck).length} + {jobs.filter((j) => j.stuck).length}
{stuck ? (
@@ -11522,7 +11600,7 @@ function PageQueue({ onNavigate }) { - {QUEUE_JOBS.map((j) => ( + {jobs.map((j) => ( @@ -11690,6 +11768,28 @@ function PageQueue({ onNavigate }) { // ---------------- Notifications ---------------- function PageNotifications({ onNavigate }) { const [filter, setFilter] = React.useState('all'); + // v1.7 slice 4e — wire to /api/notifications (with x-aqa-org). + // Fall back to the NOTIFICATIONS fixture if the server is + // unreachable so mock-data mode still renders. + const [items, setItems] = React.useState(NOTIFICATIONS); + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(apiUrl('/api/notifications'), { + headers: { 'x-aqa-org': 'padosoft' }, + }); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (Array.isArray(body?.notifications)) setItems(body.notifications); + } catch { + /* mock mode */ + } + })(); + return () => { + cancelled = true; + }; + }, []); const kinds = [ 'all', 'finding.critical', @@ -11699,8 +11799,7 @@ function PageNotifications({ onNavigate }) { 'pack.signed', 'audit.verified', ]; - const filtered = - filter === 'all' ? NOTIFICATIONS : NOTIFICATIONS.filter((n) => n.kind === filter); + const filtered = filter === 'all' ? items : items.filter((n) => n.kind === filter); return (
diff --git a/packages/admin/test/e2e/operations.e2e.ts b/packages/admin/test/e2e/operations.e2e.ts new file mode 100644 index 0000000..e3726c2 --- /dev/null +++ b/packages/admin/test/e2e/operations.e2e.ts @@ -0,0 +1,114 @@ +import { expect, test } from '@playwright/test'; + +/** + * v1.7 slice 4e — admin Operations pages (Audit, Cost, Queue, + * Notifications) now read their data from the existing server + * endpoints when reachable, with a graceful fall-back to local + * fixtures so mock-data mode still renders. + * + * These tests assert the WIRING (request fires + page renders); + * full content assertions stay on the fixture-mode rendering + * tests in smoke.e2e.ts. + */ + +async function gotoNav(page: import('@playwright/test').Page, label: string): Promise { + await page.goto('/'); + await expect(page.locator('.sidebar')).toBeVisible(); + await page + .locator('.nav-item', { hasText: new RegExp(`^${label}`, 'i') }) + .first() + .click(); +} + +test.describe('Operations pages wire-up', () => { + test('Audit page fetches /api/audit with x-aqa-org and renders live count', async ({ page }) => { + let captured: { url: string; org: string | null } | null = null; + await page.route('**/api/audit', async (route) => { + const req = route.request(); + captured = { url: req.url(), org: req.headers()['x-aqa-org'] ?? null }; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + events: [ + { schema_version: '1', ts: '2026-05-19T10:00:00Z', kind: 'run.started', payload: {} }, + { schema_version: '1', ts: '2026-05-19T10:05:00Z', kind: 'run.finished', payload: {} }, + ], + }), + }); + }); + await gotoNav(page, 'Audit log'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Audit log/i); + await expect(page.locator('text=2 events · live from /api/audit')).toBeVisible(); + expect(captured?.url).toMatch(/\/api\/audit$/); + expect(captured?.org).toBe('padosoft'); + }); + + test('Audit falls back to the fixture when the endpoint fails', async ({ page }) => { + await page.route('**/api/audit', (route) => route.abort('failed')); + await gotoNav(page, 'Audit log'); + // Fixture-mode sub-header (no "live from" claim). + await expect(page.locator('text=Hash-chained, tamper-evident')).toBeVisible(); + }); + + test('Queue page fetches /api/queue and renders the live jobs', async ({ page }) => { + let hit = false; + await page.route('**/api/queue', async (route) => { + hit = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + jobs: [ + { + id: 'job-live-1', + kind: 'aqa.run', + enqueued_at: '2026-05-19T11:00:00Z', + leased_by: null, + stuck: false, + }, + ], + }), + }); + }); + await gotoNav(page, 'Queue'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Queue/i); + expect(hit).toBe(true); + // Live job id appears in the table (last 12 chars). + await expect(page.locator('table.tbl', { hasText: 'job-live-1' })).toBeVisible(); + }); + + test('Notifications page fetches /api/notifications with x-aqa-org', async ({ page }) => { + let captured: { org: string | null } | null = null; + await page.route('**/api/notifications**', async (route) => { + captured = { org: route.request().headers()['x-aqa-org'] ?? null }; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notifications: [] }), + }); + }); + await gotoNav(page, 'Notifications'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Notifications/i); + expect(captured?.org).toBe('padosoft'); + }); + + test('Cost page fetches /api/cost/summary with tenant scope', async ({ page }) => { + let captured: { org: string | null; project: string | null } | null = null; + await page.route('**/api/cost/summary**', async (route) => { + captured = { + org: route.request().headers()['x-aqa-org'] ?? null, + project: route.request().headers()['x-aqa-project'] ?? null, + }; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ summary: { total_usd: 123.45, by_profile: [], by_model: [] } }), + }); + }); + await gotoNav(page, 'Cost'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Cost/i); + expect(captured?.org).toBe('padosoft'); + expect(captured?.project).toBe('demo'); + }); +}); From 8232abfa481e869d5d1080f0f5231695d4c9d304 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 03:07:39 +0200 Subject: [PATCH 2/9] review(slice-4e iter 1): normalize server payloads to UI shape + explicit MTD bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot/Codex iter-1 (5 P1+P2): - AUDIT: server @aqa/schemas Event uses { ts, actor: { type, id }, prev_hash }, but AuditChainViewer expects { at, actor: string, prev_hash }. Added a normalizer in the live-events branch so the viewer's actor.toLowerCase() / ev.at reads don't break. Also switched the "loaded" check from `liveEvents.length > 0` to `liveEvents !== null` so an empty live response is no longer silently masked by the fixture (while the header still claims "live"). - QUEUE: server EnqueuedJob is { id, enqueued_at, payload, status, leased_until }; UI expects { kind, leased_by, attempts, stuck, payload_summary }. Added a mapper: status === 'in_flight' → leased_by; expired leased_until → stuck; payload.kind → kind; truncated JSON.stringify(payload) → payload_summary. - NOTIFICATIONS: server Notification uses { summary, actor, read_by, … }; UI expects { title, body, unread }. Adapter derives `title` from summary (or a humanized kind), `unread` from `!read_by.includes(SELF)`, etc. - COST: server defaults to a rolling 30-day window but the page KPI reads "MTD spend"; pass explicit from/to aligned to the current calendar month so the total_usd matches the label. - Test gotoNav now escapes regex metacharacters in `label` to match smoke.e2e.ts (a nav like "Audit (admin)" would otherwise trip parens). - Cost e2e now asserts the request carries `from` (1st of current month) and `to` query params. Tests: operations e2e 5/5 green; admin e2e 128/129 (1 skipped); lint preexisting only. --- packages/admin/src/app.tsx | 92 ++++++++++++++++++----- packages/admin/test/e2e/operations.e2e.ts | 26 ++++++- 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index b599206..983b405 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11223,8 +11223,29 @@ function PageAudit({ onNavigate }) { } /> 0 ? liveEvents : AUDIT_EVENTS_GOOD} - demoBad={liveEvents ? [] : AUDIT_EVENTS_BAD} + // PR #39 Copilot iter 1: server Event has {ts, actor:{type,id}, + // prev_hash} but AuditChainViewer's demo shape is {at, actor: + // string, prev_hash}. Normalize the server response before + // handing it to the viewer; otherwise the viewer breaks on + // actor.toLowerCase() / reads ev.at. An empty live array is + // ALSO a valid loaded state (no fixture fallback) — use + // liveEvents !== null as the "loaded" signal. + demoGood={ + liveEvents !== null + ? liveEvents.map((ev) => ({ + at: ev.ts ?? ev.at ?? '', + actor: + typeof ev.actor === 'string' + ? ev.actor + : ev.actor?.id || ev.actor?.type || 'system', + kind: ev.kind ?? 'event', + payload: ev.payload ?? {}, + prev_hash: ev.prev_hash ?? '0'.repeat(64), + hash: ev.hash ?? '0'.repeat(64), + })) + : AUDIT_EVENTS_GOOD + } + demoBad={liveEvents !== null ? [] : AUDIT_EVENTS_BAD} />
); @@ -11232,17 +11253,21 @@ function PageAudit({ onNavigate }) { // ---------------- Cost ---------------- function PageCost({ onNavigate }) { - // v1.7 slice 4e — wire to /api/cost/summary. The endpoint returns - // an aggregated summary (totals, breakdown by profile/model); the - // page's MTD curve and projection still draw from the local - // COST_DAYS fixture for now (no per-day endpoint yet). Pulling the - // summary lets the header KPIs reflect live state when available. + // v1.7 slice 4e — wire to /api/cost/summary. The server default is + // a rolling 30-day window; the page's KPI labels say "MTD spend", + // so pass explicit `from`/`to` aligned to the current calendar + // month. PR #39 Copilot iter 1. const [summary, setSummary] = React.useState(null); React.useEffect(() => { let cancelled = false; (async () => { try { - const res = await fetch(apiUrl('/api/cost/summary'), { + const now = new Date(); + const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const from = monthStart.toISOString(); + const to = now.toISOString(); + const url = `${apiUrl('/api/cost/summary')}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; + const res = await fetch(url, { headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'demo' }, }); if (cancelled || !res.ok) return; @@ -11486,10 +11511,12 @@ function PageCost({ onNavigate }) { // ---------------- Queue ---------------- function PageQueue({ onNavigate }) { - // v1.7 slice 4e — wire to /api/queue. Server returns the live jobs; - // the runners panel stays on the local RUNNERS fixture for v1.7 - // (no /api/runners endpoint yet). Falls back to QUEUE_JOBS on - // error so mock-data mode still renders the page. + // v1.7 slice 4e — wire to /api/queue. Server returns EnqueuedJob + // ({id, enqueued_at, payload, status, leased_until}) but the page + // renders fixture fields (kind, leased_by, attempts, stuck, + // payload_summary). Normalize the server payload to the UI shape + // so KPIs and the table render correctly when live. PR #39 Copilot + // iter 1. const [jobs, setJobs] = React.useState(QUEUE_JOBS); React.useEffect(() => { let cancelled = false; @@ -11498,7 +11525,23 @@ function PageQueue({ onNavigate }) { const res = await fetch(apiUrl('/api/queue')); if (cancelled || !res.ok) return; const body = await res.json(); - if (Array.isArray(body?.jobs)) setJobs(body.jobs); + if (!Array.isArray(body?.jobs)) return; + const adapted = body.jobs.map((j) => ({ + id: j.id, + kind: (j.payload && typeof j.payload === 'object' ? j.payload.kind : null) ?? 'aqa.run', + enqueued_at: j.enqueued_at ?? new Date().toISOString(), + leased_by: j.status === 'in_flight' ? (j.leased_by ?? 'runner') : null, + attempts: j.attempts ?? 0, + stuck: + typeof j.leased_until === 'string' + ? new Date(j.leased_until).getTime() < Date.now() + : false, + payload_summary: + j.payload && typeof j.payload === 'object' + ? JSON.stringify(j.payload).slice(0, 80) + : '', + })); + setJobs(adapted); } catch { /* mock mode — keep the fixture */ } @@ -11768,9 +11811,12 @@ function PageQueue({ onNavigate }) { // ---------------- Notifications ---------------- function PageNotifications({ onNavigate }) { const [filter, setFilter] = React.useState('all'); - // v1.7 slice 4e — wire to /api/notifications (with x-aqa-org). - // Fall back to the NOTIFICATIONS fixture if the server is - // unreachable so mock-data mode still renders. + // v1.7 slice 4e — wire to /api/notifications. Server returns + // @aqa/schemas Notification ({id, kind, summary, actor, read_by, + // at, …}); UI expects fixture shape ({id, kind, title, body, + // unread, link, at}). Normalize so unread counts + rendering work + // against the live payload. PR #39 Copilot iter 1. + const SELF = 'usr_self'; const [items, setItems] = React.useState(NOTIFICATIONS); React.useEffect(() => { let cancelled = false; @@ -11781,7 +11827,19 @@ function PageNotifications({ onNavigate }) { }); if (cancelled || !res.ok) return; const body = await res.json(); - if (Array.isArray(body?.notifications)) setItems(body.notifications); + if (!Array.isArray(body?.notifications)) return; + const adapted = body.notifications.map((n) => ({ + id: n.id, + kind: n.kind ?? 'audit.verified', + // Title falls back to a humanized kind so we always render + // something even when the server omits `summary`. + title: n.summary ?? n.title ?? (n.kind ?? 'event').replace(/\./g, ' ').toUpperCase(), + body: n.body ?? '', + unread: Array.isArray(n.read_by) ? !n.read_by.includes(SELF) : (n.unread ?? false), + link: n.link ?? null, + at: n.at ?? n.ts ?? new Date().toISOString(), + })); + setItems(adapted); } catch { /* mock mode */ } diff --git a/packages/admin/test/e2e/operations.e2e.ts b/packages/admin/test/e2e/operations.e2e.ts index e3726c2..cc3226d 100644 --- a/packages/admin/test/e2e/operations.e2e.ts +++ b/packages/admin/test/e2e/operations.e2e.ts @@ -14,8 +14,11 @@ import { expect, test } from '@playwright/test'; async function gotoNav(page: import('@playwright/test').Page, label: string): Promise { await page.goto('/'); await expect(page.locator('.sidebar')).toBeVisible(); + // Escape regex metacharacters in `label` so a nav item like + // "Audit (admin)" doesn't trip the parens. Mirrors smoke.e2e.ts. + const safe = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); await page - .locator('.nav-item', { hasText: new RegExp(`^${label}`, 'i') }) + .locator('.nav-item', { hasText: new RegExp(`^${safe}`, 'i') }) .first() .click(); } @@ -93,12 +96,26 @@ test.describe('Operations pages wire-up', () => { expect(captured?.org).toBe('padosoft'); }); - test('Cost page fetches /api/cost/summary with tenant scope', async ({ page }) => { - let captured: { org: string | null; project: string | null } | null = null; + test('Cost page fetches /api/cost/summary with tenant scope + explicit MTD bounds', async ({ + page, + }) => { + // PR #39 Copilot iter 1: the request must carry explicit `from`/ + // `to` aligned to the current month so the server's total_usd + // matches the "MTD spend" KPI label (default window is rolling + // 30 days, which spans month boundaries). + let captured: { + org: string | null; + project: string | null; + from: string | null; + to: string | null; + } | null = null; await page.route('**/api/cost/summary**', async (route) => { + const url = new URL(route.request().url()); captured = { org: route.request().headers()['x-aqa-org'] ?? null, project: route.request().headers()['x-aqa-project'] ?? null, + from: url.searchParams.get('from'), + to: url.searchParams.get('to'), }; await route.fulfill({ status: 200, @@ -110,5 +127,8 @@ test.describe('Operations pages wire-up', () => { await expect(page.locator('h1, .page-title').first()).toContainText(/Cost/i); expect(captured?.org).toBe('padosoft'); expect(captured?.project).toBe('demo'); + // `from` is the first of the current month at 00:00 UTC. + expect(captured?.from).toMatch(/^\d{4}-\d{2}-01T00:00:00\.000Z$/); + expect(captured?.to).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); }); }); From 7b00e965e8c542aaa7b45ced1f8eadb2227fc606 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 03:17:33 +0200 Subject: [PATCH 3/9] review(slice-4e iter 2): project=gescat, SELF=SESSION_USER, schema-true test mocks, dynamic kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot iter-2 (8): - Cost summary header had `x-aqa-project: demo` hardcoded but the admin selected project is `gescat` (PROJECTS list / TopBar switcher). Fixed. - Notifications unread check compared `read_by` against a hardcoded 'usr_self' that doesn't exist. Switched to SESSION_USER.id (= 'usr_sara') so the unread state resolves correctly against live payloads. - Notifications filter `kinds` list omitted server kinds (audit.chain_broken, pack.install_failed, queue.stuck, user.invited, …). Derive the union dynamically from a static base + whatever's in items[] so live payloads stay filterable. - E2E route patterns: `**/api/audit**` (was `**/api/audit`) and `**/api/queue**` so the matchers stay resilient to query params. - Audit/Queue/Cost mock responses rewritten to the SERVER schema shapes (Event with seq/actor:{type,id}/prev_hash; EnqueuedJob with status/leased_until/payload; CostSummary with schema_version/org/project/from/to). Tests now exercise the PageAudit/PageQueue/PageCost adapters end-to-end. - Cost test assertion now expects project=gescat (matches the fixed header). Tests: operations e2e 5/5 green. --- packages/admin/src/app.tsx | 44 +++++++++++----- packages/admin/test/e2e/operations.e2e.ts | 62 +++++++++++++++++++---- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 983b405..654b867 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11267,8 +11267,12 @@ function PageCost({ onNavigate }) { const from = monthStart.toISOString(); const to = now.toISOString(); const url = `${apiUrl('/api/cost/summary')}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; + // Tenant headers must match the admin's selected project so + // the server scopes correctly. The mock fixtures use + // `gescat`; a future tenant-switcher hook can swap this. + // PR #39 Copilot iter 2. const res = await fetch(url, { - headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'demo' }, + headers: { 'x-aqa-org': 'padosoft', 'x-aqa-project': 'gescat' }, }); if (cancelled || !res.ok) return; const body = await res.json(); @@ -11815,8 +11819,10 @@ function PageNotifications({ onNavigate }) { // @aqa/schemas Notification ({id, kind, summary, actor, read_by, // at, …}); UI expects fixture shape ({id, kind, title, body, // unread, link, at}). Normalize so unread counts + rendering work - // against the live payload. PR #39 Copilot iter 1. - const SELF = 'usr_self'; + // against the live payload. PR #39 Copilot iter 1. SELF tracks + // the actual session user so read_by membership checks resolve + // correctly (iter 2 — previously was a hardcoded `usr_self`). + const SELF = SESSION_USER.id; const [items, setItems] = React.useState(NOTIFICATIONS); React.useEffect(() => { let cancelled = false; @@ -11848,15 +11854,29 @@ function PageNotifications({ onNavigate }) { cancelled = true; }; }, []); - const kinds = [ - 'all', - 'finding.critical', - 'run.failed', - 'run.completed', - 'budget.threshold', - 'pack.signed', - 'audit.verified', - ]; + // PR #39 Copilot iter 2: derive kinds dynamically (fixture + + // server kinds + whatever's actually in items[]) so a live + // notifications payload doesn't hide rows for kinds the static + // list didn't anticipate (audit.chain_broken, pack.install_failed, + // queue.stuck, user.invited, …). + const kinds = React.useMemo(() => { + const set = new Set([ + 'finding.critical', + 'run.failed', + 'run.completed', + 'budget.threshold', + 'pack.signed', + 'audit.verified', + 'audit.chain_broken', + 'pack.install_failed', + 'queue.stuck', + 'user.invited', + ]); + for (const n of items) { + if (typeof n.kind === 'string') set.add(n.kind); + } + return ['all', ...[...set].sort()]; + }, [items]); const filtered = filter === 'all' ? items : items.filter((n) => n.kind === filter); return ( diff --git a/packages/admin/test/e2e/operations.e2e.ts b/packages/admin/test/e2e/operations.e2e.ts index cc3226d..616b8ea 100644 --- a/packages/admin/test/e2e/operations.e2e.ts +++ b/packages/admin/test/e2e/operations.e2e.ts @@ -26,16 +26,38 @@ async function gotoNav(page: import('@playwright/test').Page, label: string): Pr test.describe('Operations pages wire-up', () => { test('Audit page fetches /api/audit with x-aqa-org and renders live count', async ({ page }) => { let captured: { url: string; org: string | null } | null = null; - await page.route('**/api/audit', async (route) => { + // PR #39 Copilot iter 2: use **/api/audit** so the matcher stays + // resilient to future querystring additions. + await page.route('**/api/audit**', async (route) => { const req = route.request(); captured = { url: req.url(), org: req.headers()['x-aqa-org'] ?? null }; await route.fulfill({ status: 200, contentType: 'application/json', + // Schema-conforming Event payload: seq, ts, actor:{type,id}, + // prev_hash, hash, run_id. body: JSON.stringify({ events: [ - { schema_version: '1', ts: '2026-05-19T10:00:00Z', kind: 'run.started', payload: {} }, - { schema_version: '1', ts: '2026-05-19T10:05:00Z', kind: 'run.finished', payload: {} }, + { + schema_version: '1', + seq: 1, + ts: '2026-05-19T10:00:00Z', + actor: { type: 'system', id: 'runner' }, + kind: 'run.started', + payload: { run_id: 'r-1' }, + prev_hash: '0'.repeat(64), + hash: 'a'.repeat(64), + }, + { + schema_version: '1', + seq: 2, + ts: '2026-05-19T10:05:00Z', + actor: { type: 'system', id: 'runner' }, + kind: 'run.finished', + payload: { run_id: 'r-1' }, + prev_hash: 'a'.repeat(64), + hash: 'b'.repeat(64), + }, ], }), }); @@ -43,12 +65,12 @@ test.describe('Operations pages wire-up', () => { await gotoNav(page, 'Audit log'); await expect(page.locator('h1, .page-title').first()).toContainText(/Audit log/i); await expect(page.locator('text=2 events · live from /api/audit')).toBeVisible(); - expect(captured?.url).toMatch(/\/api\/audit$/); + expect(captured?.url).toMatch(/\/api\/audit(\?|$)/); expect(captured?.org).toBe('padosoft'); }); test('Audit falls back to the fixture when the endpoint fails', async ({ page }) => { - await page.route('**/api/audit', (route) => route.abort('failed')); + await page.route('**/api/audit**', (route) => route.abort('failed')); await gotoNav(page, 'Audit log'); // Fixture-mode sub-header (no "live from" claim). await expect(page.locator('text=Hash-chained, tamper-evident')).toBeVisible(); @@ -56,8 +78,11 @@ test.describe('Operations pages wire-up', () => { test('Queue page fetches /api/queue and renders the live jobs', async ({ page }) => { let hit = false; - await page.route('**/api/queue', async (route) => { + await page.route('**/api/queue**', async (route) => { hit = true; + // PR #39 Copilot iter 2: use the SERVER's EnqueuedJob shape + // ({status, leased_until, payload}) so the test actually + // exercises PageQueue's adapter logic. await route.fulfill({ status: 200, contentType: 'application/json', @@ -65,10 +90,10 @@ test.describe('Operations pages wire-up', () => { jobs: [ { id: 'job-live-1', - kind: 'aqa.run', enqueued_at: '2026-05-19T11:00:00Z', - leased_by: null, - stuck: false, + payload: { kind: 'aqa.run', profile: 'smoke' }, + status: 'queued', + leased_until: null, }, ], }), @@ -120,13 +145,28 @@ test.describe('Operations pages wire-up', () => { await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ summary: { total_usd: 123.45, by_profile: [], by_model: [] } }), + // PR #39 Copilot iter 2: schema-conforming CostSummary + // (schema_version + tenant scope echoed back). + body: JSON.stringify({ + summary: { + schema_version: '1', + org: 'padosoft', + project: 'gescat', + from: '2026-05-01T00:00:00.000Z', + to: '2026-05-19T23:00:00.000Z', + total_usd: 123.45, + by_profile: [], + currency: 'USD', + }, + }), }); }); await gotoNav(page, 'Cost'); await expect(page.locator('h1, .page-title').first()).toContainText(/Cost/i); expect(captured?.org).toBe('padosoft'); - expect(captured?.project).toBe('demo'); + // PR #39 Copilot iter 2: project is now `gescat` (matches the + // admin's selected project). + expect(captured?.project).toBe('gescat'); // `from` is the first of the current month at 00:00 UTC. expect(captured?.from).toMatch(/^\d{4}-\d{2}-01T00:00:00\.000Z$/); expect(captured?.to).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); From f5277435049d0bfba1da48db98db33fadac1c9ce Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 03:23:53 +0200 Subject: [PATCH 4/9] review(slice-4e iter 3): explicit !== null, filter done jobs, comment accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot iter-3 (3 new + many stale repeats): - Audit subtitle now uses `liveEvents !== null` (matches the viewer's loaded signal). The previous truthy check `liveEvents ? …` happened to work for [] (arrays are truthy in JS), but the explicit form is clearer and aligns with the viewer's branch. - Queue adapter filters out terminal `done` jobs before mapping to the UI shape; otherwise those would land in the pending KPI (status='done' but leased_by=null after the map). - Test comment "(last 12 chars)" was misleading because `job-live-1` is shorter than 12 chars; clarified that the full id matches in this case. (Codex/Copilot also re-emitted ~7 already-addressed comments from iter 1/2; all noted line:null and already fixed.) Tests: operations e2e 5/5 green. --- packages/admin/src/app.tsx | 41 ++++++++++++++--------- packages/admin/test/e2e/operations.e2e.ts | 4 ++- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 654b867..0e7a5d4 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11205,7 +11205,10 @@ function PageAudit({ onNavigate }) {
} sub={ - liveEvents + // PR #39 Copilot iter 3: use the same "loaded" signal as + // the viewer below (`!== null`) — an empty server list + // is a valid loaded state, not a fixture-fallback. + liveEvents !== null ? `${liveEvents.length} events · live from /api/audit` : 'Hash-chained, tamper-evident event log · verify in-browser with Web Crypto' } @@ -11530,21 +11533,27 @@ function PageQueue({ onNavigate }) { if (cancelled || !res.ok) return; const body = await res.json(); if (!Array.isArray(body?.jobs)) return; - const adapted = body.jobs.map((j) => ({ - id: j.id, - kind: (j.payload && typeof j.payload === 'object' ? j.payload.kind : null) ?? 'aqa.run', - enqueued_at: j.enqueued_at ?? new Date().toISOString(), - leased_by: j.status === 'in_flight' ? (j.leased_by ?? 'runner') : null, - attempts: j.attempts ?? 0, - stuck: - typeof j.leased_until === 'string' - ? new Date(j.leased_until).getTime() < Date.now() - : false, - payload_summary: - j.payload && typeof j.payload === 'object' - ? JSON.stringify(j.payload).slice(0, 80) - : '', - })); + // PR #39 Copilot iter 3: filter out terminal `done` jobs + // before mapping — they'd otherwise be counted as "pending" + // (no leased_by) on the KPI grid. + const adapted = body.jobs + .filter((j) => j.status !== 'done') + .map((j) => ({ + id: j.id, + kind: + (j.payload && typeof j.payload === 'object' ? j.payload.kind : null) ?? 'aqa.run', + enqueued_at: j.enqueued_at ?? new Date().toISOString(), + leased_by: j.status === 'in_flight' ? (j.leased_by ?? 'runner') : null, + attempts: j.attempts ?? 0, + stuck: + typeof j.leased_until === 'string' + ? new Date(j.leased_until).getTime() < Date.now() + : false, + payload_summary: + j.payload && typeof j.payload === 'object' + ? JSON.stringify(j.payload).slice(0, 80) + : '', + })); setJobs(adapted); } catch { /* mock mode — keep the fixture */ diff --git a/packages/admin/test/e2e/operations.e2e.ts b/packages/admin/test/e2e/operations.e2e.ts index 616b8ea..c36ee17 100644 --- a/packages/admin/test/e2e/operations.e2e.ts +++ b/packages/admin/test/e2e/operations.e2e.ts @@ -102,7 +102,9 @@ test.describe('Operations pages wire-up', () => { await gotoNav(page, 'Queue'); await expect(page.locator('h1, .page-title').first()).toContainText(/Queue/i); expect(hit).toBe(true); - // Live job id appears in the table (last 12 chars). + // Live job id appears in the table. The UI renders only the last + // 12 chars in the id column, but `job-live-1` is already <12 so + // the full string matches the table's mono cell. await expect(page.locator('table.tbl', { hasText: 'job-live-1' })).toBeVisible(); }); From dc49b350aa0f8e94b8fe1e0ef012b5a480dfc153 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 03:31:45 +0200 Subject: [PATCH 5/9] review(slice-4e iter 4): schema-true test mocks + kinds list from server enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot iter-4 (4): - Audit test mock now matches @aqa/schemas Event exactly: run_id at top level, EventKind enum values (run_started / run_finished, not dotted), prev_hash null on the chain head. - Queue mock: queued jobs omit `leased_until` (it's only set when leased to a runner), matching the server's EnqueuedJob shape. - Cost mock dropped the non-schema `currency` field; added the required `daily` record (date → usd, empty here). - Notifications kinds filter list rebased on the @aqa/schemas NotificationKind enum (the server contract); still unioned with items[] so live extras still surface as filter options. Previously mixed fixture-only kinds with server kinds and presented filters the server could never produce. Tests: operations e2e 5/5 green. --- packages/admin/src/app.tsx | 34 +++++++++++------------ packages/admin/test/e2e/operations.e2e.ts | 24 +++++++++++----- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 0e7a5d4..f82a5a5 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11863,24 +11863,24 @@ function PageNotifications({ onNavigate }) { cancelled = true; }; }, []); - // PR #39 Copilot iter 2: derive kinds dynamically (fixture + - // server kinds + whatever's actually in items[]) so a live - // notifications payload doesn't hide rows for kinds the static - // list didn't anticipate (audit.chain_broken, pack.install_failed, - // queue.stuck, user.invited, …). + // PR #39 Copilot iter 4: filter base is the @aqa/schemas + // NotificationKind enum (the contract the server commits to). + // Then union with whatever's actually in items[] so any extras + // (legacy fixture rows, future enum additions) still show up. + // Previously this mixed fixture-only kinds with server kinds and + // presented filters that the server would never produce. + const SERVER_NOTIFICATION_KINDS = [ + 'run.failed', + 'finding.critical', + 'finding.verified', + 'budget.threshold', + 'audit.chain_broken', + 'pack.install_failed', + 'queue.stuck', + 'user.invited', + ]; const kinds = React.useMemo(() => { - const set = new Set([ - 'finding.critical', - 'run.failed', - 'run.completed', - 'budget.threshold', - 'pack.signed', - 'audit.verified', - 'audit.chain_broken', - 'pack.install_failed', - 'queue.stuck', - 'user.invited', - ]); + const set = new Set(SERVER_NOTIFICATION_KINDS); for (const n of items) { if (typeof n.kind === 'string') set.add(n.kind); } diff --git a/packages/admin/test/e2e/operations.e2e.ts b/packages/admin/test/e2e/operations.e2e.ts index c36ee17..9b84f4f 100644 --- a/packages/admin/test/e2e/operations.e2e.ts +++ b/packages/admin/test/e2e/operations.e2e.ts @@ -36,25 +36,30 @@ test.describe('Operations pages wire-up', () => { contentType: 'application/json', // Schema-conforming Event payload: seq, ts, actor:{type,id}, // prev_hash, hash, run_id. + // PR #39 Copilot iter 4: schema-conforming Event — run_id at + // the top level, EventKind enum values (underscored, not + // dotted), prev_hash null on the chain head. body: JSON.stringify({ events: [ { schema_version: '1', seq: 1, ts: '2026-05-19T10:00:00Z', + run_id: 'run-1', actor: { type: 'system', id: 'runner' }, - kind: 'run.started', - payload: { run_id: 'r-1' }, - prev_hash: '0'.repeat(64), + kind: 'run_started', + payload: {}, + prev_hash: null, hash: 'a'.repeat(64), }, { schema_version: '1', seq: 2, ts: '2026-05-19T10:05:00Z', + run_id: 'run-1', actor: { type: 'system', id: 'runner' }, - kind: 'run.finished', - payload: { run_id: 'r-1' }, + kind: 'run_finished', + payload: {}, prev_hash: 'a'.repeat(64), hash: 'b'.repeat(64), }, @@ -86,6 +91,8 @@ test.describe('Operations pages wire-up', () => { await route.fulfill({ status: 200, contentType: 'application/json', + // PR #39 Copilot iter 4: queued jobs omit `leased_until` + // (it's only set when the job is leased to a runner). body: JSON.stringify({ jobs: [ { @@ -93,7 +100,6 @@ test.describe('Operations pages wire-up', () => { enqueued_at: '2026-05-19T11:00:00Z', payload: { kind: 'aqa.run', profile: 'smoke' }, status: 'queued', - leased_until: null, }, ], }), @@ -149,6 +155,10 @@ test.describe('Operations pages wire-up', () => { contentType: 'application/json', // PR #39 Copilot iter 2: schema-conforming CostSummary // (schema_version + tenant scope echoed back). + // PR #39 Copilot iter 4: aligned to @aqa/schemas CostSummary + // exactly — no extra `currency` field (the schema doesn't + // define it). `daily` is the date→usd record used by the + // chart. body: JSON.stringify({ summary: { schema_version: '1', @@ -158,7 +168,7 @@ test.describe('Operations pages wire-up', () => { to: '2026-05-19T23:00:00.000Z', total_usd: 123.45, by_profile: [], - currency: 'USD', + daily: {}, }, }), }); From a357707d2b6323a3bed2fc6c90fc3c62a3257690 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 03:38:26 +0200 Subject: [PATCH 6/9] review(slice-4e iter 5): kind fallback + counts from live items Copilot iter-5 (2): - Notification adapter fallback `kind ?? 'audit.verified'` used a non-schema kind ('audit.verified' isn't in NotificationKind). Switched the (defensive-only) fallback to 'run.failed' so a malformed payload still satisfies the schema-aligned filter list. - Header subtitle ("N unread of M") and per-kind chip counts still read from the static NOTIFICATIONS fixture, so the counts disagreed with the rendered list when the server replaced items. Sourced both from `items` instead. Tests: operations e2e 5/5 green. --- packages/admin/src/app.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index f82a5a5..187a74a 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11845,7 +11845,10 @@ function PageNotifications({ onNavigate }) { if (!Array.isArray(body?.notifications)) return; const adapted = body.notifications.map((n) => ({ id: n.id, - kind: n.kind ?? 'audit.verified', + // Fallback to a schema-valid NotificationKind (the server + // always returns a valid kind, but stay defensive against + // malformed payloads). PR #39 Copilot iter 5. + kind: n.kind ?? 'run.failed', // Title falls back to a humanized kind so we always render // something even when the server omits `summary`. title: n.summary ?? n.title ?? (n.kind ?? 'event').replace(/\./g, ' ').toUpperCase(), @@ -11892,7 +11895,7 @@ function PageNotifications({ onNavigate }) {
n.unread).length} unread of ${NOTIFICATIONS.length}`} + sub={`${items.filter((n) => n.unread).length} unread of ${items.length}`} actions={ <> ))} From 655165be0cf4cc7caf0f5d91331b9c5f9785358a Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 03:46:21 +0200 Subject: [PATCH 7/9] review(slice-4e iter 6): unmount-safe fetch + cost dayCount from server window Copilot iter-6 (subset of 4 actionable): - AUDIT fetch re-checks `cancelled` after `await res.json()` so an unmount mid-parse can't trigger setState on a dead component. - Comment on the audit wire reworded: the server returns events as stored; AuditChainViewer is the one that verifies hashes client-side via Web Crypto. (The previous "chain-verified events" wording implied server-side verification.) - COST: dayCount is now derived from summary.from/to when a live summary is loaded, so avgDay/projected/cumulative curves stay consistent with the server-requested [from,to] window. Falls back to COST_DAYS.length in mock mode. (The 4th comment about AuditChainViewer's auto-load behavior needs a viewer-API change that's outside this slice; left for follow-up.) Tests: operations e2e 5/5 green. --- packages/admin/src/app.tsx | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 187a74a..61431f1 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11169,13 +11169,13 @@ function PageReplay({ onNavigate }) { // ---------------- Audit log ---------------- function PageAudit({ onNavigate }) { - // v1.7 slice 4e — wire to /api/audit (audit:read). The server route - // returns chain-verified events; in mock mode we fall back to the - // hardcoded fixtures (AUDIT_EVENTS_GOOD / _BAD) so the demo stays - // usable when no server is reachable. The AuditChainViewer's - // schema is whatever the server returns plus its existing demo - // demoGood/demoBad shape — we pass the live events as `demoGood` - // and clear the bad demo to avoid mixing concerns. + // v1.7 slice 4e — wire to /api/audit (audit:read). The server + // route returns Event records as-stored — hash verification is the + // CLIENT'S job (AuditChainViewer recomputes hashes in the browser + // via Web Crypto). In mock mode the fixture fallback keeps the + // page usable when no server is reachable. + // PR #39 Copilot iter 6: re-check the cancellation guard AFTER + // await res.json() so an unmount mid-parse can't setState. const [liveEvents, setLiveEvents] = React.useState(null); React.useEffect(() => { let cancelled = false; @@ -11186,6 +11186,7 @@ function PageAudit({ onNavigate }) { }); if (cancelled || !res.ok) return; const body = await res.json(); + if (cancelled) return; if (Array.isArray(body?.events)) setLiveEvents(body.events); } catch { /* mock mode — leave the fixtures */ @@ -11289,7 +11290,21 @@ function PageCost({ onNavigate }) { }; }, []); const mtd = summary?.total_usd ?? COST_DAYS.reduce((a, d) => a + d.usd, 0); - const dayCount = COST_DAYS.length; + // PR #39 Copilot iter 6: when a live summary is loaded, derive + // dayCount from its [from,to] window so avgDay/projected/cum + // curves stay consistent with the server's requested range. + // Falls back to COST_DAYS.length in mock mode. + const dayCount = (() => { + if (summary?.from && summary?.to) { + const fromMs = new Date(summary.from).getTime(); + const toMs = new Date(summary.to).getTime(); + if (Number.isFinite(fromMs) && Number.isFinite(toMs) && toMs >= fromMs) { + const days = Math.max(1, Math.ceil((toMs - fromMs) / 86_400_000)); + return days; + } + } + return COST_DAYS.length; + })(); const avgDay = mtd / dayCount; const budget = 250; const projection = budget * 1.18; // 18% over by month-end From ed65556ec730f7c9b58c622a0951f3310bc1f30c Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 03:48:24 +0200 Subject: [PATCH 8/9] fix(slice-4e iter 6): anchor projDays loop to COST_DAYS last entry, not dayCount When dayCount is derived from a live summary window it can be > COST_DAYS.length, and COST_DAYS[dayCount-1] would be undefined. Anchor to the fixture's last realized day instead. Regression from iter 6. Tests: operations e2e 5/5 green. --- packages/admin/src/app.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 61431f1..970a1b9 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11319,11 +11319,16 @@ function PageCost({ onNavigate }) { cum: (cum += d.usd), projected: false, })); - // Append projection + // Append projection. Anchor the date to the last entry of + // COST_DAYS (the mock fixture's last realized day) regardless of + // dayCount — when dayCount is derived from a live summary window + // it can be larger than COST_DAYS.length and indexing past the + // end would crash. PR #39 Copilot iter 6 (regression fix). const projDays = []; const remaining = daysInMonth - dayCount; + const lastDayDate = COST_DAYS[COST_DAYS.length - 1].date; for (let i = 1; i <= remaining; i++) { - const date = new Date(COST_DAYS[dayCount - 1].date); + const date = new Date(lastDayDate); date.setUTCDate(date.getUTCDate() + i); cum += avgDay; projDays.push({ date: date.toISOString().slice(0, 10), usd: avgDay, cum, projected: true }); From 570b7ea4ab4bc645ade1146fd4f9941eff294fca Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 04:09:59 +0200 Subject: [PATCH 9/9] fix(slice-4e iter 6): use object-wrapper for captured-in-callback (TS strict) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI typecheck (stricter than local) flagged as never because the assignment happens inside the page.route callback closure, and TS flow analysis narrows the outer let back to null at access sites. Switched all four tests to a stable wrapper and mutate properties — TS treats object-property writes through closures as opaque. Tests: operations e2e 5/5 green; typecheck clean. --- packages/admin/test/e2e/operations.e2e.ts | 43 ++++++++++++----------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/admin/test/e2e/operations.e2e.ts b/packages/admin/test/e2e/operations.e2e.ts index 9b84f4f..3a828fa 100644 --- a/packages/admin/test/e2e/operations.e2e.ts +++ b/packages/admin/test/e2e/operations.e2e.ts @@ -25,12 +25,15 @@ async function gotoNav(page: import('@playwright/test').Page, label: string): Pr test.describe('Operations pages wire-up', () => { test('Audit page fetches /api/audit with x-aqa-org and renders live count', async ({ page }) => { - let captured: { url: string; org: string | null } | null = null; - // PR #39 Copilot iter 2: use **/api/audit** so the matcher stays - // resilient to future querystring additions. + // Hold the captured request in a stable wrapper object — TS strict + // flow-analysis narrows a `let` mutated inside a callback to its + // initial value at outer access sites, but object-property writes + // are opaque to it. + const seen: { url: string | null; org: string | null } = { url: null, org: null }; await page.route('**/api/audit**', async (route) => { const req = route.request(); - captured = { url: req.url(), org: req.headers()['x-aqa-org'] ?? null }; + seen.url = req.url(); + seen.org = req.headers()['x-aqa-org'] ?? null; await route.fulfill({ status: 200, contentType: 'application/json', @@ -70,8 +73,8 @@ test.describe('Operations pages wire-up', () => { await gotoNav(page, 'Audit log'); await expect(page.locator('h1, .page-title').first()).toContainText(/Audit log/i); await expect(page.locator('text=2 events · live from /api/audit')).toBeVisible(); - expect(captured?.url).toMatch(/\/api\/audit(\?|$)/); - expect(captured?.org).toBe('padosoft'); + expect(seen.url).toMatch(/\/api\/audit(\?|$)/); + expect(seen.org).toBe('padosoft'); }); test('Audit falls back to the fixture when the endpoint fails', async ({ page }) => { @@ -115,9 +118,9 @@ test.describe('Operations pages wire-up', () => { }); test('Notifications page fetches /api/notifications with x-aqa-org', async ({ page }) => { - let captured: { org: string | null } | null = null; + const seen: { org: string | null } = { org: null }; await page.route('**/api/notifications**', async (route) => { - captured = { org: route.request().headers()['x-aqa-org'] ?? null }; + seen.org = route.request().headers()['x-aqa-org'] ?? null; await route.fulfill({ status: 200, contentType: 'application/json', @@ -126,7 +129,7 @@ test.describe('Operations pages wire-up', () => { }); await gotoNav(page, 'Notifications'); await expect(page.locator('h1, .page-title').first()).toContainText(/Notifications/i); - expect(captured?.org).toBe('padosoft'); + expect(seen.org).toBe('padosoft'); }); test('Cost page fetches /api/cost/summary with tenant scope + explicit MTD bounds', async ({ @@ -136,20 +139,18 @@ test.describe('Operations pages wire-up', () => { // `to` aligned to the current month so the server's total_usd // matches the "MTD spend" KPI label (default window is rolling // 30 days, which spans month boundaries). - let captured: { + const seen: { org: string | null; project: string | null; from: string | null; to: string | null; - } | null = null; + } = { org: null, project: null, from: null, to: null }; await page.route('**/api/cost/summary**', async (route) => { const url = new URL(route.request().url()); - captured = { - org: route.request().headers()['x-aqa-org'] ?? null, - project: route.request().headers()['x-aqa-project'] ?? null, - from: url.searchParams.get('from'), - to: url.searchParams.get('to'), - }; + seen.org = route.request().headers()['x-aqa-org'] ?? null; + seen.project = route.request().headers()['x-aqa-project'] ?? null; + seen.from = url.searchParams.get('from'); + seen.to = url.searchParams.get('to'); await route.fulfill({ status: 200, contentType: 'application/json', @@ -175,12 +176,12 @@ test.describe('Operations pages wire-up', () => { }); await gotoNav(page, 'Cost'); await expect(page.locator('h1, .page-title').first()).toContainText(/Cost/i); - expect(captured?.org).toBe('padosoft'); + expect(seen.org).toBe('padosoft'); // PR #39 Copilot iter 2: project is now `gescat` (matches the // admin's selected project). - expect(captured?.project).toBe('gescat'); + expect(seen.project).toBe('gescat'); // `from` is the first of the current month at 00:00 UTC. - expect(captured?.from).toMatch(/^\d{4}-\d{2}-01T00:00:00\.000Z$/); - expect(captured?.to).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(seen.from).toMatch(/^\d{4}-\d{2}-01T00:00:00\.000Z$/); + expect(seen.to).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); }); });