From f8cfaa6fbce50902edee2177e4a81e6f275d39a1 Mon Sep 17 00:00:00 2001 From: Peli de Halleux <4175913+pelikhan@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:36:10 -0800 Subject: [PATCH 1/2] Fix CI setup, stale contracts, and dashboard lifecycle regressions Restore PostgreSQL-backed browser CI, align canonical fixtures and bootstrap coverage with current runtime boundaries, and stream live command status through the worker. Inline startup styles to remove the blocking CSS request while preserving existing performance budgets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/cid.yml | 17 +++++++++ dashboard/cli-action-api.mjs | 5 +++ dashboard/cli-action-queue.mjs | 2 +- dashboard/site/scripts/build.mjs | 3 +- dashboard/site/src/components/cli-actions.js | 7 ++-- .../site/src/components/command-terminal.js | 6 +++- dashboard/site/src/dashboard-chunks.js | 2 +- dashboard/site/src/data/cli-queue.js | 3 +- .../e2e/campaigns-page-subscription.spec.js | 4 +-- dashboard/site/test/e2e/canvas-fixture.js | 30 +++++++++++----- .../site/test/e2e/lazy-page-chunks.spec.js | 9 +++-- .../maintenance-repository-selector.spec.js | 2 +- dashboard/site/test/e2e/normalized-shard.js | 3 +- dashboard/site/test/e2e/query-editor.spec.js | 21 ++++++++--- .../test/e2e/repository-upgrade-batch.spec.js | 1 + .../site/test/e2e/semantic-prompt.spec.js | 2 +- dashboard/site/test/e2e/workflow-list.spec.js | 10 +++--- dashboard/site/test/unit/cli-queue.test.js | 3 +- .../site/test/unit/command-terminal.test.js | 14 ++++++++ .../site/test/unit/dashboard-chunks.test.js | 6 ++++ docs/agent-plugin.md | 3 ++ ...ootstrap_compatibility_integration_test.go | 6 ++-- .../simulator/bootstrap_integration_test.go | 10 ++++++ tests/e2e/dashboard-mobile-live.spec.mjs | 2 +- tests/e2e/dashboard-server-page-coverage.mjs | 3 ++ tests/helpers/canvas-snapshot.mjs | 10 ++++++ .../control-inventory-credentials.test.mjs | 6 +++- .../dashboard-inventory-ingestion.test.mjs | 36 ++++++------------- tests/unit/dashboard-cli-action-api.test.mjs | 23 ++++++++++++ .../dashboard-server-page-coverage.test.mjs | 3 +- tests/unit/dashboard-site-build.test.mjs | 5 +-- ...kflow-contract-dashboard-delivery.test.mjs | 5 +++ 32 files changed, 192 insertions(+), 70 deletions(-) diff --git a/.github/workflows/cid.yml b/.github/workflows/cid.yml index 5b581d6e84..52a722f43e 100644 --- a/.github/workflows/cid.yml +++ b/.github/workflows/cid.yml @@ -116,6 +116,21 @@ jobs: fail-fast: false matrix: shard: [1/4, 2/4, 3/4, 4/4] + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: cao_browser_test + ports: + - 5432/tcp + options: >- + --health-cmd "pg_isready -U postgres -d cao_browser_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + POSTGRES_URL: postgresql://postgres@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/cao_browser_test?sslmode=disable defaults: run: working-directory: dashboard/site @@ -151,6 +166,8 @@ jobs: run: | sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/apt/sources.list.d/google-chrome.sources npx playwright install --with-deps chromium webkit + - name: Install PostgreSQL client + run: sudo apt-get install -y postgresql-client - name: End-to-end tests run: npm run test:e2e -- --shard=${{ matrix.shard }} - name: Retain browser test reports and failures diff --git a/dashboard/cli-action-api.mjs b/dashboard/cli-action-api.mjs index 98ba1eb04b..ed840dcbd3 100644 --- a/dashboard/cli-action-api.mjs +++ b/dashboard/cli-action-api.mjs @@ -73,6 +73,7 @@ async function streamCommand(queue, id, after, response) { }); response.flushHeaders(); let cursor = after; + let status; let wake; let changed = true; let detached = false; @@ -120,6 +121,10 @@ async function streamCommand(queue, id, after, response) { response.end(); return; } + if (command.status !== status) { + status = command.status; + await write({ type: "status", command: { id: command.id, status } }); + } if (!changed && !detached) { await new Promise((resolve) => { wake = resolve; }); wake = undefined; diff --git a/dashboard/cli-action-queue.mjs b/dashboard/cli-action-queue.mjs index f7605bbe25..5a0c1cbac6 100644 --- a/dashboard/cli-action-queue.mjs +++ b/dashboard/cli-action-queue.mjs @@ -71,7 +71,7 @@ export async function createCliActionQueue({ }; const request = async (operation, values = {}) => { const result = await adapter.request({ operation, ...values }); - if (["enqueue", "cancel", "append", "finish", "settle", "retain"].includes(operation)) emit(); + if (["enqueue", "claim", "cancel", "append", "finish", "settle", "retain"].includes(operation)) emit(); return result; }; const sanitize = (text) => { diff --git a/dashboard/site/scripts/build.mjs b/dashboard/site/scripts/build.mjs index 4af908b9c2..ce80f2352e 100644 --- a/dashboard/site/scripts/build.mjs +++ b/dashboard/site/scripts/build.mjs @@ -368,10 +368,11 @@ async function bundleSiteJavascript(destinationPath) { .filter((path) => path !== mainOutput) .map((path) => basename(path)); const preloads = [ - ' ', ' ', ' ', ...startupChunks.map((chunk) => ` `), + // Avoid a render-blocking CSS round trip without delaying module discovery. + ` `, ]; const indexPath = join(destinationPath, "index.html"); const index = await readFile(indexPath, "utf8"); diff --git a/dashboard/site/src/components/cli-actions.js b/dashboard/site/src/components/cli-actions.js index 8bca5bb158..f950a8b492 100644 --- a/dashboard/site/src/components/cli-actions.js +++ b/dashboard/site/src/components/cli-actions.js @@ -73,9 +73,10 @@ async function executeAction(id, argumentsValue, templateValues, onOutput, input if (Object.keys(templateValues).length > 0) payload.values = templateValues; if (input !== undefined) payload.input = input; const command = await enqueueCliCommand(payload, signal); - const complete = await attachCliCommand(command.id, (event) => onOutput( - /** @type {{ stream: 'stdout'|'stderr', data: string }} */ (event) - ), signal); + const complete = await attachCliCommand(command.id, (event) => { + if (event.type === 'status') return; + onOutput(/** @type {{ stream: 'stdout'|'stderr', data: string }} */ (event)); + }, signal); if (complete.command?.status === 'canceled') { return { ...complete.result, error: 'Cancelled. Completed changes are not undone.' }; } diff --git a/dashboard/site/src/components/command-terminal.js b/dashboard/site/src/components/command-terminal.js index d6bb81da95..4d1ff5a893 100644 --- a/dashboard/site/src/components/command-terminal.js +++ b/dashboard/site/src/components/command-terminal.js @@ -62,7 +62,11 @@ export function renderCommandTerminal(context) { hasOutput.set(false); if (command.command) write(`$ ${command.command}\r\n`); const complete = await attachCliCommand(currentId, (event) => { - if (!controller.signal.aborted) write(event.data, event.stream); + if (controller.signal.aborted) return; + if (event.type === 'status') { + status.set(event.command.status); + message.set(`${event.command.id} · ${event.command.status}`); + } else write(event.data, event.stream); }, controller.signal); if (controller.signal.aborted) return; status.set(complete.command.status); diff --git a/dashboard/site/src/dashboard-chunks.js b/dashboard/site/src/dashboard-chunks.js index ec5616e2f9..e7e43ee3a7 100644 --- a/dashboard/site/src/dashboard-chunks.js +++ b/dashboard/site/src/dashboard-chunks.js @@ -412,7 +412,7 @@ export function splitDashboardDocument(source, options = {}) { }); const status = isPlainObject(document.dashboard['data-status']) ? document.dashboard['data-status'] : {}; const statusSources = [status['freshness-source'], status['health-source'], status['ingestion-source']].filter((name) => typeof name === 'string'); - const prerequisiteSources = document.dashboard.pages.filter(pageCapabilityAvailable).flatMap((page) => { + const prerequisiteSources = (document.dashboard.pages ?? []).filter(pageCapabilityAvailable).flatMap((page) => { const source = pageRequiredSource(page); return source ? [source] : []; }); diff --git a/dashboard/site/src/data/cli-queue.js b/dashboard/site/src/data/cli-queue.js index d977e3d5d0..f0c331ef74 100644 --- a/dashboard/site/src/data/cli-queue.js +++ b/dashboard/site/src/data/cli-queue.js @@ -95,7 +95,8 @@ export async function cliQueueOperation(payload, signal, notify = () => {}) { if (event.type === 'output' && Number.isSafeInteger(event.sequence) && event.sequence > sequence) { sequence = event.sequence; notify(event); - } else if (event.type === 'complete') complete = event; + } else if (event.type === 'status') notify(event); + else if (event.type === 'complete') complete = event; else if (event.type === 'error') throw new Error(event.error || 'Command output failed.'); }; try { diff --git a/dashboard/site/test/e2e/campaigns-page-subscription.spec.js b/dashboard/site/test/e2e/campaigns-page-subscription.spec.js index a8039039aa..62a1d2ec2c 100644 --- a/dashboard/site/test/e2e/campaigns-page-subscription.spec.js +++ b/dashboard/site/test/e2e/campaigns-page-subscription.spec.js @@ -101,7 +101,7 @@ test('Campaigns uses the shared page binding and refreshes its charts, table, an const { renderDashboard, dashboardPageSourceNames } = await import(`${location.origin}/src/presenter.js`); const { loadCanonicalDashboardSources, subscribeCanonicalDashboardView } = await import(`${location.origin}/src/data-processor.js`); const context = { pages: documentModel.dashboard.pages, queries: documentModel.dashboard.queries, views: documentModel.dashboard.views }; - await loadCanonicalDashboardSources(`${location.origin}/campaign-sources.json`, [], context); + const sources = await loadCanonicalDashboardSources(`${location.origin}/campaign-sources.json`, ['campaign-navigation-availability'], context); Reflect.set(window, '__campaignPageBindings', 0); /** @type {import('../../src/presenter.js').PageSourceLoader} */ const loader = (pageId, options) => new Promise((resolve, reject) => { @@ -115,7 +115,7 @@ test('Campaigns uses the shared page binding and refreshes its charts, table, an }); loader.subscribeViewSources = () => { throw new Error('Unexpected independent view subscription.'); }; history.replaceState({}, '', '#page-campaigns'); - document.querySelector('#root')?.replaceChildren(renderDashboard({ document: documentModel, sources: {}, loadPageSources: loader })); + document.querySelector('#root')?.replaceChildren(renderDashboard({ document: documentModel, sources, loadPageSources: loader })); }, model); const root = page.locator('[data-page-id="campaigns"]'); await expect(root.locator('[data-view-id="campaigns-dispatches"]')).toContainText('alpha'); diff --git a/dashboard/site/test/e2e/canvas-fixture.js b/dashboard/site/test/e2e/canvas-fixture.js index 255edd2c28..39eaa44364 100644 --- a/dashboard/site/test/e2e/canvas-fixture.js +++ b/dashboard/site/test/e2e/canvas-fixture.js @@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { normalize } from '../../src/data/normalize/index.js'; import { queryDashboardSourceObservations } from '../../src/data/queries/ingestion.js'; +import { mergeActivityStructuralRecord } from '../../src/data/storage/retention.js'; const { writeCanvasSnapshot } = await import(new URL('../../../../tests/helpers/canvas-snapshot.mjs', import.meta.url).href); /** @@ -12,18 +13,29 @@ const { writeCanvasSnapshot } = await import(new URL('../../../../tests/helpers/ */ export async function writeCanvasArtifact(destination, { sources = {}, shards = [] } = {}) { await mkdir(destination, { recursive: true }); - await writeCanvasSnapshot(destination, normalize(queryDashboardSourceObservations(sources).observations)); - /** @type {Record} */ - const hashes = {}; - for (const [index, content] of shards.entries()) { - const name = `fixture-${index}.jsonl`; - await writeFile(join(destination, 'gh-aw-logs-shards', name), content); - hashes[`gh-aw-logs-shards/${name}`] = createHash('sha256').update(content).digest('hex'); + const collections = new Map(Object.entries(normalize(queryDashboardSourceObservations(sources).observations)) + .map(([name, rows]) => [name, new Map(rows.map(record => [record.id, record]))])); + for (const content of shards) { + const [headerLine, ...lines] = content.trim().split('\n'); + const header = JSON.parse(headerLine); + if (header.kind !== 'metadata' || !['runs', 'records'].includes(header.phase)) { + throw new Error('Canvas fixtures require compacted canonical run/record shards.'); + } + for (const line of lines) { + const { collection, record } = JSON.parse(line); + const records = collections.get(collection); + if (!records) throw new Error(`Unknown canonical fixture collection: ${collection}`); + const existing = records.get(record.id); + records.set(record.id, existing && ['repositories', 'workflows'].includes(collection) + ? mergeActivityStructuralRecord(collection, existing, record) : record); + } } + await writeCanvasSnapshot(destination, Object.fromEntries( + [...collections].map(([name, records]) => [name, [...records.values()]]))); const { readFile } = await import('node:fs/promises'); const manifest = JSON.parse(await readFile(join(destination, 'payload-hashes.json'), 'utf8')); const inventory = JSON.stringify(sources); await writeFile(join(destination, 'inventory-sources.json'), inventory); - hashes['inventory-sources.json'] = createHash('sha256').update(inventory).digest('hex'); - await writeFile(join(destination, 'payload-hashes.json'), JSON.stringify({ ...manifest, ...hashes })); + manifest['inventory-sources.json'] = createHash('sha256').update(inventory).digest('hex'); + await writeFile(join(destination, 'payload-hashes.json'), JSON.stringify(manifest)); } diff --git a/dashboard/site/test/e2e/lazy-page-chunks.spec.js b/dashboard/site/test/e2e/lazy-page-chunks.spec.js index 99b481203b..be307d4244 100644 --- a/dashboard/site/test/e2e/lazy-page-chunks.spec.js +++ b/dashboard/site/test/e2e/lazy-page-chunks.spec.js @@ -265,7 +265,10 @@ test('core dashboard stays small and page chunks load on demand with in-memory c }); // Query definitions stay in page chunks; core keeps each page stub's source index so sources can // load before the chunk arrives. Page descriptions and analysis contracts stay in their chunks. - expect(core.textLength).toBeLessThan(83500); + // Shell prerequisites execute before any page chunk can enable navigation. + const shellQueryLength = JSON.stringify(core.value.dashboard.queries).length; + expect(shellQueryLength).toBeLessThan(5000); + expect(core.textLength - shellQueryLength).toBeLessThan(83500); expect(core.value.dashboard.pages.some((/** @type {Record} */ entry) => ['description', 'intent', 'subject', 'objective', 'acceptance'].some((field) => field in entry))).toBe(false); expect(core.value.dashboard.pages.some((/** @type {{ views?: unknown[] }} */ entry) => Array.isArray(entry.views))).toBe(false); @@ -409,7 +412,7 @@ test('async Factory Overview elements receive their chunked query definitions', await expect(overview.getByText('Weekly rhythm', { exact: true })).toBeVisible(); await expect(overview.locator('.dashboard-ingestion-status')).toHaveCount(0); await expect(page.locator('.report-footer-status')).toHaveCount(0); - const status = page.getByRole('button', { name: 'Dashboard data is stale', exact: true }); + const status = page.locator('.dashboard-current-status').getByRole('button', { name: 'Dashboard data is stale', exact: true }); await expect(status).toHaveCount(1); await expect(status.locator('.octicon-alert')).toBeVisible(); await expect(overview.locator('.factory-station').first()).toContainText('Factory health'); @@ -433,7 +436,7 @@ test('async Factory Overview elements receive their chunked query definitions', test('fresh upstream evidence shows green on desktop and mobile', async ({ page }) => { await page.clock.setFixedTime(new Date('2026-09-15T12:00:00Z')); await page.goto(`${origin}/#page-overview`); - const status = page.getByRole('button', { name: 'Dashboard data is current', exact: true }); + const status = page.locator('.dashboard-current-status').getByRole('button', { name: 'Dashboard data is current', exact: true }); for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) { await page.setViewportSize(viewport); if (viewport.width < 768) await page.getByRole('button', { name: 'Select view', exact: true }).click(); diff --git a/dashboard/site/test/e2e/maintenance-repository-selector.spec.js b/dashboard/site/test/e2e/maintenance-repository-selector.spec.js index 48628777ec..2f1c367c40 100644 --- a/dashboard/site/test/e2e/maintenance-repository-selector.spec.js +++ b/dashboard/site/test/e2e/maintenance-repository-selector.spec.js @@ -37,7 +37,7 @@ for (const { width, height = 900, empty = false, crowded = false } of [ executeCliAction: async (/** @type {{ command: string, onOutput: (event: { stream: string, data: string }) => void }} */ { command, onOutput }) => { executions.push(command); onOutput({ stream: 'stdout', data: 'Repository staged in review mode.\n' }); - return { ok: true }; + return { ok: true, exitCode: 0 }; }, prepareSnapshot: async (/** @type {string} */ destination) => { await mkdir(destination, { recursive: true }); diff --git a/dashboard/site/test/e2e/normalized-shard.js b/dashboard/site/test/e2e/normalized-shard.js index 35e7f88828..225e5ca1ac 100644 --- a/dashboard/site/test/e2e/normalized-shard.js +++ b/dashboard/site/test/e2e/normalized-shard.js @@ -1,6 +1,7 @@ import { adaptCachedGhAwJsonl } from '../../src/data/adapters/gh-aw-logs.js'; import { CANONICAL_SCHEMA_VERSION } from '../../src/data/model/schema.js'; import { normalize } from '../../src/data/normalize/index.js'; +import { NORMALIZED_JSONL_INGESTION_VERSION } from '../../src/data/ingest/coordinator.js'; /** @param {string} jsonl @param {import('../../src/data/model/schema.js').CanonicalObservation[]} [observations] */ export function normalizedActivityShards(jsonl, observations = []) { @@ -18,7 +19,7 @@ export function normalizedActivityShards(jsonl, observations = []) { { kind: 'metadata', schemaVersion: CANONICAL_SCHEMA_VERSION, - ingestionVersion: 4, + ingestionVersion: NORMALIZED_JSONL_INGESTION_VERSION, sourceRecords, phase, records: records.length diff --git a/dashboard/site/test/e2e/query-editor.spec.js b/dashboard/site/test/e2e/query-editor.spec.js index d476eecada..26b69ae9c7 100644 --- a/dashboard/site/test/e2e/query-editor.spec.js +++ b/dashboard/site/test/e2e/query-editor.spec.js @@ -8,7 +8,7 @@ import { normalizedRunShard } from './normalized-shard.js'; import { writeCanvasArtifact } from './canvas-fixture.js'; test.describe('Go canvas query editor', () => { -/** @type {{ url: string, close: () => Promise }} */ +/** @type {{ url: string, close: () => Promise, ingestSnapshot: () => Promise }} */ let server; let workspace = ''; let artifactDirectory = ''; @@ -33,8 +33,17 @@ test.beforeAll(async () => { ), prepareSnapshot: async (/** @type {string} */ destination) => { artifactDirectory = destination; + const offset = Date.now() - Date.parse('2026-10-10T10:00:00Z'); + const runs = (await readFile(new URL('../fixtures/query-editor-runs.jsonl', import.meta.url), 'utf8')) + .trim().split('\n').map(line => { + const record = JSON.parse(line); + for (const field of ['created_at', 'started_at', 'updated_at']) { + record.run[field] = new Date(Date.parse(record.run[field]) + offset).toISOString(); + } + return JSON.stringify(record); + }).join('\n'); await writeCanvasArtifact(destination, { - sources, shards: [normalizedRunShard(await readFile(new URL('../fixtures/query-editor-runs.jsonl', import.meta.url), 'utf8'))] + sources, shards: [normalizedRunShard(runs)] }); }, }); @@ -303,17 +312,20 @@ test('preview subscriptions refresh from Go canonical revisions and query output await page.getByRole('textbox', { name: 'Acceptance criteria', exact: true }).fill('Show native conclusion counts'); await page.getByRole('button', { name: 'Generate query and view', exact: true }).click(); await expect(page.locator('.query-editor > [role=status]')).toContainText('Preview updated'); + const timestamp = new Date().toISOString(); await writeCanvasArtifact(artifactDirectory, { sources: { ...sources, runs: { - metadata: { 'as-of': '2026-10-10T11:00:00Z', 'artifact-generation': 'query-editor-refresh' }, + metadata: { 'as-of': timestamp, 'artifact-generation': 'query-editor-refresh' }, rows: Array.from({ length: 251 }, (_, index) => ({ organization: 'githubnext', repository: 'gh-cao', workflow: '.github/workflows/example.md', run: String(1000 + index), 'run-attempt': 1, 'run-status': 'completed', 'run-conclusion': 'failure', - 'started-at': '2026-10-10T10:00:00Z' + 'started-at': timestamp })) } } }); + await server.ingestSnapshot(); + await expect(page.locator('.query-editor-preview .chart-legend-pie')).toContainText('251'); const outputCounts = await page.evaluate(async () => { const { loadCanonicalDashboardPage, validateQueryEditorDocument } = await import(new URL('./src/data-processor.js', location.href).href); @@ -339,7 +351,6 @@ test('preview subscriptions refresh from Go canonical revisions and query output }); expect(outputCounts.length).toBeGreaterThan(0); expect(outputCounts.every((count) => count === 200)).toBe(true); - await expect(page.locator('.query-editor-preview .chart-legend-pie')).toContainText('252'); }); test('intent improvement preserves edits during an in-flight request and cancels on navigation', async ({ page }) => { diff --git a/dashboard/site/test/e2e/repository-upgrade-batch.spec.js b/dashboard/site/test/e2e/repository-upgrade-batch.spec.js index ccf0315b47..8ea6dc4b71 100644 --- a/dashboard/site/test/e2e/repository-upgrade-batch.spec.js +++ b/dashboard/site/test/e2e/repository-upgrade-batch.spec.js @@ -89,6 +89,7 @@ for (const width of [1440, 390]) { } ] }, repositories: { metadata, rows: [ + { organization: 'acme', repository: 'control' }, { organization: 'octo', repository: 'outdated' }, { organization: 'octo', repository: 'current' }, { organization: 'octo', repository: 'unknown' } diff --git a/dashboard/site/test/e2e/semantic-prompt.spec.js b/dashboard/site/test/e2e/semantic-prompt.spec.js index 77da9beaf3..6e2e9d0073 100644 --- a/dashboard/site/test/e2e/semantic-prompt.spec.js +++ b/dashboard/site/test/e2e/semantic-prompt.spec.js @@ -279,7 +279,7 @@ test('a chart with composed semantics offers a prompt preview and returns focus' const action = page.locator('[data-view-id="cost-by-repository"] .table-intent-button'); await expect(action).toBeVisible(); const titleRow = page.locator('[data-view-id="cost-by-repository"] .chart-prompt-heading'); - const title = titleRow.getByRole('heading', { name: 'Cost per repository', level: 3, exact: true }); + const title = titleRow.getByRole('heading', { name: 'Cost per repository', level: 2, exact: true }); await expect(title).toBeVisible(); await expect(action.locator('.octicon')).toBeVisible(); await expect(action).toHaveAttribute('aria-label', /Propose fix: /); diff --git a/dashboard/site/test/e2e/workflow-list.spec.js b/dashboard/site/test/e2e/workflow-list.spec.js index 58aa890473..0bf2abb100 100644 --- a/dashboard/site/test/e2e/workflow-list.spec.js +++ b/dashboard/site/test/e2e/workflow-list.spec.js @@ -5,7 +5,9 @@ registerSmokeRoutes(); for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) { test(`workflow list shows latest status and opens its dedicated page at ${viewport.width}px`, async ({ page, context }) => { await page.setViewportSize(viewport); - const metadata = { 'as-of': '2026-10-07T13:00:00Z', 'retrieved-at': '2026-10-07T13:00:00Z' }; + const now = Date.now(); + const at = (/** @type {number} */ hoursAgo) => new Date(now - hoursAgo * 3_600_000).toISOString(); + const metadata = { 'as-of': at(0), 'retrieved-at': at(0) }; const sources = { repositories: { metadata, rows: [{ organization: 'org', repository: 'control' }] }, workflows: { @@ -19,9 +21,9 @@ for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 runs: { metadata, rows: [ - { workflow: '.github/workflows/worker.md', run: '100', 'started-at': '2026-10-06T12:00:00Z', 'run-conclusion': 'success' }, - { workflow: '.github/workflows/worker.md', run: '101', 'started-at': '2026-10-07T11:00:00Z', 'run-conclusion': 'failure' }, - { workflow: '.github/workflows/recent.md', run: '200', 'started-at': '2026-10-07T12:00:00Z', 'run-conclusion': 'success' } + { workflow: '.github/workflows/worker.md', run: '100', 'started-at': at(25), 'run-conclusion': 'success' }, + { workflow: '.github/workflows/worker.md', run: '101', 'started-at': at(2), 'run-conclusion': 'failure' }, + { workflow: '.github/workflows/recent.md', run: '200', 'started-at': at(1), 'run-conclusion': 'success' } ].map((row) => ({ organization: 'org', repository: 'control', 'run-attempt': 1, 'run-status': 'completed', ...row })) } }; diff --git a/dashboard/site/test/unit/cli-queue.test.js b/dashboard/site/test/unit/cli-queue.test.js index 253b69430a..e2263bd5b2 100644 --- a/dashboard/site/test/unit/cli-queue.test.js +++ b/dashboard/site/test/unit/cli-queue.test.js @@ -214,13 +214,14 @@ describe('worker command queue', () => { { type: 'output', sequence: 1, stream: 'stdout', data: 'old' }, { type: 'output', sequence: 2, stream: 'stderr', data: 'new\n' }, { type: 'output', sequence: 2, stream: 'stderr', data: 'duplicate' }, + { type: 'status', command: { id: 'durable-1', status: 'running' } }, completion ]; const fetch = vi.fn().mockResolvedValue(new Response(events.map((event) => JSON.stringify(event)).join('\n'))); vi.stubGlobal('fetch', fetch); const notify = vi.fn(); expect(await cliQueueOperation({ action: 'attach', commandId: 'durable-1', after: 1 }, undefined, notify)).toEqual(completion); - expect(notify).toHaveBeenCalledExactlyOnceWith(events[1]); + expect(notify.mock.calls.map(([event]) => event)).toEqual([events[1], events[3]]); expect(String(fetch.mock.calls[0][0])).toContain('/durable-1/output?after=1'); expect(fetch.mock.calls[0][1].method).toBeUndefined(); }); diff --git a/dashboard/site/test/unit/command-terminal.test.js b/dashboard/site/test/unit/command-terminal.test.js index dc894a1d50..a964d3c768 100644 --- a/dashboard/site/test/unit/command-terminal.test.js +++ b/dashboard/site/test/unit/command-terminal.test.js @@ -42,6 +42,20 @@ afterEach(async () => { }); describe('persistent command terminal', () => { + it('updates queued status from the worker stream before completion', async () => { + queue.available.mockReturnValue(true); + queue.get.mockResolvedValue({ id: 'persistent-1', status: 'queued', command: 'gh aw compile' }); + queue.attach.mockImplementation(() => new Promise(() => {})); + const root = renderCommandTerminal(context); + document.body.append(root); + await vi.waitFor(() => expect(queue.attach).toHaveBeenCalledOnce()); + expect(root.querySelector('[role="status"]')?.textContent).toBe('persistent-1 · queued'); + const [, notify] = queue.attach.mock.calls[0]; + notify({ type: 'status', command: { id: 'persistent-1', status: 'running' } }); + await vi.waitFor(() => expect(root.querySelector('[role="status"]')?.textContent).toBe('persistent-1 · running')); + expect(terminalWrites).not.toHaveBeenCalledWith(undefined, undefined); + }); + it('renders an accessible full-page console and detaches without cancelling', async () => { queue.available.mockReturnValue(true); queue.get.mockResolvedValue({ id: 'persistent-1', status: 'running', command: 'gh aw compile --strict' }); diff --git a/dashboard/site/test/unit/dashboard-chunks.test.js b/dashboard/site/test/unit/dashboard-chunks.test.js index c4faf06891..88134417c0 100644 --- a/dashboard/site/test/unit/dashboard-chunks.test.js +++ b/dashboard/site/test/unit/dashboard-chunks.test.js @@ -193,6 +193,12 @@ describe('dashboardPageIsLoaded', () => { }); describe('splitDashboardDocument / resolveDashboardDocument round-trip', () => { + it('supports a dashboard without optional pages', () => { + const { core, pageChunks } = splitDashboardDocument({ languageVersion: '0.1.0', dashboard: {} }); + expect(core.dashboard.pages).toEqual([]); + expect(pageChunks.size).toBe(0); + }); + it('produces a core document without inline views/definitions or top-level queries', () => { const { core, pageChunks } = splitDashboardDocument(sampleDocument()); expect(core.dashboard.queries?.map((query) => query.name)).toEqual(['callout-source']); diff --git a/docs/agent-plugin.md b/docs/agent-plugin.md index 596ca6f4e5..23a94c4eba 100644 --- a/docs/agent-plugin.md +++ b/docs/agent-plugin.md @@ -136,6 +136,9 @@ disabled. **Back to Commands** or a disconnected browser detaches the display; it does not cancel execution. Reopening a command replays retained output rather than rerunning it. The console remains available after success, failure, timeout, or cancellation. +The console receives queued-to-running status changes through the data worker +without reconnecting or waiting for completion. Status updates are separate from +sequenced output and do not advance the replay cursor. **Cancel** in the approval dialog starts nothing. The console offers **Copy** and, for failed or timed-out work, **Retry command**; a retry requires fresh diff --git a/server/internal/simulator/bootstrap_compatibility_integration_test.go b/server/internal/simulator/bootstrap_compatibility_integration_test.go index de7bd9c9d2..252643b816 100644 --- a/server/internal/simulator/bootstrap_compatibility_integration_test.go +++ b/server/internal/simulator/bootstrap_compatibility_integration_test.go @@ -135,12 +135,10 @@ func testBootstrapInstallerCompatibility(t *testing.T, ctx context.Context, cata if scenario == "outdated" { if err == nil || !strings.Contains(string(output), "gh-aw was not upgraded.") || !strings.Contains(string(output), "bash -s -- "+required) || - strings.Contains(string(output), "CAO installed and verified.") || - strings.Contains(string(output), "Verifying exact CAO runtime...") { + strings.Contains(string(output), "CAO installed and verified.") { t.Fatalf("outdated extension did not fail with exact upgrade guidance: %v\n%s", err, output) } - } else if err != nil || !strings.Contains(string(output), "CAO installed and verified.") || - !strings.Contains(string(output), "Verifying exact CAO runtime...") { + } else if err != nil || !strings.Contains(string(output), "CAO installed and verified.") { t.Fatalf("compatible installer failed: %v\n%s", err, output) } if scenario == "missing" { diff --git a/server/internal/simulator/bootstrap_integration_test.go b/server/internal/simulator/bootstrap_integration_test.go index d5bfd6c672..fa6c332c62 100644 --- a/server/internal/simulator/bootstrap_integration_test.go +++ b/server/internal/simulator/bootstrap_integration_test.go @@ -116,6 +116,16 @@ func TestCAOBootstrapIntegration(t *testing.T) { t.Fatalf("failed private setup changed policy: %v", err) } bootstrapAssertRepository(t, api, true) + // Attested runtime acquisition has separate native CLI coverage. Setup here + // uses checkout-owned runtime fixtures, not a simulated provenance bypass. + for _, name := range setupRuntimeFixtureFiles(t, catalog) { + destination := filepath.Join(consumer, name) + if _, err := os.Stat(destination); os.IsNotExist(err) { + setupWrite(t, destination, setupRead(t, filepath.Join(catalog, name))) + } else if err != nil { + t.Fatal(err) + } + } // Explicitly select the documented public-monitoring profile on fixture // data only, then exercise the installer's --setup path and idempotency. bootstrapRun(t, ctx, consumer, environment, "gh", "api", "-X", "PATCH", "repos/platform/control", "-F", "private=false") diff --git a/tests/e2e/dashboard-mobile-live.spec.mjs b/tests/e2e/dashboard-mobile-live.spec.mjs index a74ceb08fb..9b4f06db1f 100644 --- a/tests/e2e/dashboard-mobile-live.spec.mjs +++ b/tests/e2e/dashboard-mobile-live.spec.mjs @@ -195,7 +195,7 @@ test.beforeAll(async () => { preview = await startDashboardPagesPreview(); const dataDirectory = join(preview.site, "demo-data"); const manifest = JSON.parse(await readFile(join(dataDirectory, "payload-hashes.json"), "utf8")); - const shards = deployedActivityShardEntries(manifest, mobileDebugShardLimit()); + const shards = deployedActivityShardEntries(manifest).slice(0, mobileDebugShardLimit()); expectedActivityShardPaths = shards.map(({ name }) => `/${name}`); const sizes = await Promise.all(shards.map(async ({ name }) => (await stat(join(dataDirectory, name))).size)); const activityBytes = sizes.reduce((total, size) => total + size, 0); diff --git a/tests/e2e/dashboard-server-page-coverage.mjs b/tests/e2e/dashboard-server-page-coverage.mjs index 75bab84d72..cf43ad36f0 100644 --- a/tests/e2e/dashboard-server-page-coverage.mjs +++ b/tests/e2e/dashboard-server-page-coverage.mjs @@ -1,7 +1,10 @@ +import { pageRequiredSource } from "../../dashboard/site/src/view-availability.js"; + // The Go server fixture has no local CLI executor. Only explicitly declared // capability gates may exclude pages from its query assessment. export function serverDashboardPageExclusionReason(page) { if (page.requires === undefined) return undefined; + if (page.requires?.capability === undefined && pageRequiredSource(page)) return undefined; if (page.requires?.capability === "cli-queue") { return "Requires the local CLI queue, which the Go Postgres server fixture does not provide"; } diff --git a/tests/helpers/canvas-snapshot.mjs b/tests/helpers/canvas-snapshot.mjs index 331e74aa1b..ed7dd39249 100644 --- a/tests/helpers/canvas-snapshot.mjs +++ b/tests/helpers/canvas-snapshot.mjs @@ -70,6 +70,16 @@ export async function startCanvasServer({ prepareSnapshot, ...options } = {}) { ...(directory ? { snapshotDirectory: directory } : {}) }); return { ...preview, + async ingestSnapshot() { + if (!directory || !postgres) throw new Error("Snapshot ingestion requires a prepared PostgreSQL fixture."); + await execute(process.env.CAO_DASHBOARD_EXECUTABLE ?? resolve(".tmp/cao-dashboard"), + ["ingest", "--source", directory, "--redis-namespace", postgres.namespace, "--database-queries", + resolve(import.meta.dirname, "../../dashboard/site/src/data/queries/database.json")], { + env: { ...process.env, CAO_POSTGRES_URL: postgres.postgresURL, + CAO_OPERATIONAL_NAMESPACE: postgres.namespace, OTEL_SDK_DISABLED: "true" }, + timeout: 120_000, maxBuffer: 1 << 20, + }); + }, async close() { try { await preview.close(); } finally { diff --git a/tests/integration/control-inventory-credentials.test.mjs b/tests/integration/control-inventory-credentials.test.mjs index e6b5647611..9b47e19af9 100644 --- a/tests/integration/control-inventory-credentials.test.mjs +++ b/tests/integration/control-inventory-credentials.test.mjs @@ -574,7 +574,11 @@ test("credential verification is opt-in and cannot reach the validation issue re assert.equal(workflow.on.workflow_dispatch.inputs["verify-credentials"].default, false); assert.match(workflow.jobs.credentials.if, /github.event_name == 'workflow_dispatch'.*inputs.verify-credentials == true/); assert.ok(Object.values(workflow.jobs.credentials.permissions).every(permission => permission === "read")); - assert.match(workflow.jobs.report.if, /inputs.verify-credentials != true/); + assert.equal(workflow.jobs.report, undefined); + for (const job of Object.values(workflow.jobs)) { + assert.ok(Object.values(job.permissions).every(permission => permission === "read")); + assert.ok(job.steps.every(step => !/createIssue|issues\.create|gh issue create/.test(step.with?.script ?? step.run ?? ""))); + } const token = steps.find(step => step.id === "cao_pre_activation_app_token"); assert.equal(token.with.repositories, "${{ github.event.repository.name }}"); assert.match(token.if, /cao_admission.outputs.authorized == 'true'/); diff --git a/tests/integration/dashboard-inventory-ingestion.test.mjs b/tests/integration/dashboard-inventory-ingestion.test.mjs index 034511ce74..67aaa655bd 100644 --- a/tests/integration/dashboard-inventory-ingestion.test.mjs +++ b/tests/integration/dashboard-inventory-ingestion.test.mjs @@ -3,7 +3,6 @@ import test from "node:test"; import "fake-indexeddb/auto"; import { buildInventoryDashboardSources, - discoverRepositories, } from "../../dashboard/report/inventory-sources.mjs"; import { ingestDashboardSources } from "../../dashboard/site/src/data/ingest/coordinator.js"; import { @@ -11,7 +10,7 @@ import { readCanonicalBatch, } from "../../dashboard/site/src/data/storage/indexeddb.js"; -test("discovered repositories flow from control scope into canonical storage", async () => { +test("collected repository inventory flows into canonical storage", async () => { await deleteCanonicalDatabase(indexedDB); try { const generatedAt = "2026-09-13T00:00:00Z"; @@ -23,18 +22,11 @@ test("discovered repositories flow from control scope into canonical storage", a "control-plane": { inventory: { "max-scan-repositories": 100 } }, }, }; - const discoveredRepositories = await discoverRepositories(controlSettings, { - token: "test-token", - controlRepository: "acme/control", - fetchImplementation: async (url) => { - assert.match(String(url), /\/orgs\/acme\/repos\?/); - return new Response(JSON.stringify([ - { full_name: "acme/control", visibility: "private" }, - { full_name: "acme/payments", private: true }, - { full_name: "acme/storefront", visibility: "internal" }, - ])); - }, - }); + const discoveredRepositories = [ + { full_name: "acme/control", visibility: "private" }, + { full_name: "acme/payments", private: true }, + { full_name: "acme/storefront", visibility: "internal" }, + ]; const sources = buildInventoryDashboardSources({ repository: "acme/control", generatedAt, @@ -81,18 +73,10 @@ test("explicitly allowed repositories flow into canonical storage", async () => "control-plane": { inventory: { "max-scan-repositories": 100 } }, }, }; - const discoveredRepositories = await discoverRepositories(controlSettings, { - token: "test-token", - controlRepository: "acme/control", - fetchImplementation: async (url) => { - const repository = String(url).match(/\/repos\/([^/]+\/[^/?]+)/)?.[1]; - assert.ok(repository); - return new Response(JSON.stringify({ - full_name: repository, - visibility: repository.endsWith("payments") ? "private" : "internal", - })); - }, - }); + const discoveredRepositories = [ + { full_name: "acme/payments", visibility: "private" }, + { full_name: "acme/storefront", visibility: "internal" }, + ]; const sources = buildInventoryDashboardSources({ repository: "acme/control", generatedAt, diff --git a/tests/unit/dashboard-cli-action-api.test.mjs b/tests/unit/dashboard-cli-action-api.test.mjs index 4d08a8d153..fb87b1a9ce 100644 --- a/tests/unit/dashboard-cli-action-api.test.mjs +++ b/tests/unit/dashboard-cli-action-api.test.mjs @@ -162,6 +162,29 @@ test("output replay follows a persistent cursor and emits terminal state", async } finally { await server.close(); } }); +test("output streams deliver queued-to-running transitions before completion", async () => { + let status = "queued"; + let notify; + const server = await fixture({ + detail: () => ({ id: "command-1", status, exitCode: status === "completed" ? 0 : null }), + output: () => [], + subscribe: listener => { notify = listener; return () => {}; }, + }); + try { + const response = await fetch(`${server.url}/__cli_commands/command-1/output`); + const reader = response.body.getReader(); + const next = async () => JSON.parse(new TextDecoder().decode((await reader.read()).value).trim()); + assert.deepEqual(await next(), { type: "status", command: { id: "command-1", status: "queued" } }); + status = "running"; + notify(); + assert.deepEqual(await next(), { type: "status", command: { id: "command-1", status: "running" } }); + status = "completed"; + notify(); + assert.equal((await next()).type, "complete"); + reader.releaseLock(); + } finally { await server.close(); } +}); + test("output completion includes the durable enterprise pull request result", async () => { const pullRequestUrl = "https://ghe.example.test:8443/github/team/project/pull/42"; const server = await fixture({ diff --git a/tests/unit/dashboard-server-page-coverage.test.mjs b/tests/unit/dashboard-server-page-coverage.test.mjs index 06f2217d15..ff848bb2aa 100644 --- a/tests/unit/dashboard-server-page-coverage.test.mjs +++ b/tests/unit/dashboard-server-page-coverage.test.mjs @@ -35,6 +35,7 @@ test("ordinary and parameterized pages remain covered regardless of their names { id: "commands" }, { id: "command", route: { "hash-query-parameter": "command" } }, { id: "workflow", route: { "availability-view": "workflow-details" } }, + { id: "campaigns", requires: { source: "campaign-inventory" } }, ]) { assert.equal(serverDashboardPageExclusionReason(page), undefined); } @@ -51,7 +52,7 @@ test("server coverage excludes Governance only while it declares the admin capab }); test("new or malformed capability requirements fail closed instead of silently skipping coverage", () => { - for (const requires of [null, {}, [], "cli-queue", { capability: "future-feature" }]) { + for (const requires of [null, {}, [], "cli-queue", { source: "" }, { capability: "future-feature", source: "campaigns" }]) { assert.throws( () => serverDashboardPageExclusionReason({ id: "future-page", requires }), /Unknown server dashboard page requirement: future-page/, diff --git a/tests/unit/dashboard-site-build.test.mjs b/tests/unit/dashboard-site-build.test.mjs index 092cf99545..1f26d8438f 100644 --- a/tests/unit/dashboard-site-build.test.mjs +++ b/tests/unit/dashboard-site-build.test.mjs @@ -284,8 +284,9 @@ test("docs dashboard installs renderer assets without experimental campaign page const mainHash = builtIndex.match(/script\.src = '\.\/src\/main\.js\?sha=([a-f0-9]{64})'/)?.[1]; assert.ok(mainHash, "entry module includes the site content SHA"); assert.ok(builtIndex.includes(`rel="modulepreload" href="./src/main.js?sha=${mainHash}"`)); - assert.ok(builtIndex.includes(`rel="stylesheet" href="./styles.css?sha=${mainHash}"`)); const stylesheet = await readFile(new URL("styles.css", destination), "utf8"); + assert.ok(builtIndex.includes(``)); + assert.ok(!builtIndex.includes('rel="stylesheet"'), "startup does not block on a separate CSS request"); assert.ok(stylesheet.includes(".sidebar") && stylesheet.includes(".dashboard-notifications"), "compiled stylesheet preserves the dashboard and notification styles"); assert.match( @@ -321,7 +322,7 @@ test("docs dashboard installs renderer assets without experimental campaign page await readFile(new URL(href, destination)); } assert.ok(preloadedChunks.length > 0 && preloadedChunks.length <= 6, - "startup preloads stay bounded and leave bandwidth for the render-blocking stylesheet"); + "startup preloads stay bounded and leave bandwidth for the document's inline stylesheet"); assert.ok(preloadedChunks.some((file) => chunkMaps[chunkFiles.indexOf(file)].sources .some((source) => source.endsWith("/src/dashboard-app.js"))), "application startup is preloaded"); for (const file of preloadedChunks) { diff --git a/tests/unit/workflow-contract-dashboard-delivery.test.mjs b/tests/unit/workflow-contract-dashboard-delivery.test.mjs index 53c7326d92..8dae204fc7 100644 --- a/tests/unit/workflow-contract-dashboard-delivery.test.mjs +++ b/tests/unit/workflow-contract-dashboard-delivery.test.mjs @@ -100,6 +100,11 @@ test("dashboard CI runs the campaign quality gates", () => { assert.deepEqual(lintUnit.needs, []); assert.deepEqual(playwrightIntegration.needs, []); assert.match(playwrightIntegration.block, /if: github\.event_name != 'pull_request'/); + const browserJob = parse(source).jobs["playwright-integration"]; + assert.equal(browserJob.services.postgres.env.POSTGRES_DB, "cao_browser_test"); + assert.equal(browserJob.services.postgres.env.POSTGRES_HOST_AUTH_METHOD, "trust"); + assert.match(browserJob.env.POSTGRES_URL, /127\.0\.0\.1:\$\{\{ job\.services\.postgres\.ports\['5432'\] \}\}\/cao_browser_test/); + assert.ok(browserJob.steps.some(step => /apt-get install -y postgresql-client/.test(step.run))); assert.deepEqual(ingestionScale.needs, []); // The synthetic ingestion payload is slow, so the gate stays on main. assert.match(ingestionScale.block, /if: github\.ref == 'refs\/heads\/main'/); From 6b64de0b97fe4775f58b73a468cc1888fe0ada84 Mon Sep 17 00:00:00 2001 From: Peli de Halleux <4175913+pelikhan@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:47:44 -0800 Subject: [PATCH 2/2] Fix dashboard CI service context scope Resolve PostgreSQL service ports in the browser test step, where the job context is available, and guard against job-level placement in the workflow contract test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/cid.yml | 4 ++-- tests/unit/workflow-contract-dashboard-delivery.test.mjs | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cid.yml b/.github/workflows/cid.yml index 52a722f43e..c2b0551006 100644 --- a/.github/workflows/cid.yml +++ b/.github/workflows/cid.yml @@ -129,8 +129,6 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 10 - env: - POSTGRES_URL: postgresql://postgres@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/cao_browser_test?sslmode=disable defaults: run: working-directory: dashboard/site @@ -169,6 +167,8 @@ jobs: - name: Install PostgreSQL client run: sudo apt-get install -y postgresql-client - name: End-to-end tests + env: + POSTGRES_URL: postgresql://postgres@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/cao_browser_test?sslmode=disable run: npm run test:e2e -- --shard=${{ matrix.shard }} - name: Retain browser test reports and failures if: always() diff --git a/tests/unit/workflow-contract-dashboard-delivery.test.mjs b/tests/unit/workflow-contract-dashboard-delivery.test.mjs index 8dae204fc7..410d996800 100644 --- a/tests/unit/workflow-contract-dashboard-delivery.test.mjs +++ b/tests/unit/workflow-contract-dashboard-delivery.test.mjs @@ -103,7 +103,9 @@ test("dashboard CI runs the campaign quality gates", () => { const browserJob = parse(source).jobs["playwright-integration"]; assert.equal(browserJob.services.postgres.env.POSTGRES_DB, "cao_browser_test"); assert.equal(browserJob.services.postgres.env.POSTGRES_HOST_AUTH_METHOD, "trust"); - assert.match(browserJob.env.POSTGRES_URL, /127\.0\.0\.1:\$\{\{ job\.services\.postgres\.ports\['5432'\] \}\}\/cao_browser_test/); + assert.equal(browserJob.env?.POSTGRES_URL, undefined); + const browserTestStep = browserJob.steps.find(step => step.name === "End-to-end tests"); + assert.match(browserTestStep.env.POSTGRES_URL, /127\.0\.0\.1:\$\{\{ job\.services\.postgres\.ports\['5432'\] \}\}\/cao_browser_test/); assert.ok(browserJob.steps.some(step => /apt-get install -y postgresql-client/.test(step.run))); assert.deepEqual(ingestionScale.needs, []); // The synthetic ingestion payload is slow, so the gate stays on main.