diff --git a/.github/workflows/cid.yml b/.github/workflows/cid.yml index 5b581d6e8..c2b055100 100644 --- a/.github/workflows/cid.yml +++ b/.github/workflows/cid.yml @@ -116,6 +116,19 @@ 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 defaults: run: working-directory: dashboard/site @@ -151,7 +164,11 @@ 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 + 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/dashboard/cli-action-api.mjs b/dashboard/cli-action-api.mjs index 98ba1eb04..ed840dcbd 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 f7605bbe2..5a0c1cbac 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 4af908b9c..ce80f2352 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 8bca5bb15..f950a8b49 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 d6bb81da9..4d1ff5a89 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 ec5616e2f..e7e43ee3a 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 d977e3d5d..f0c331ef7 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 a8039039a..62a1d2ec2 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 255edd2c2..39eaa4436 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 99b481203..be307d424 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 48628777e..2f1c367c4 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 35e7f8882..225e5ca1a 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 d476eecad..26b69ae9c 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 ccf0315b4..8ea6dc4b7 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 77da9beaf..6e2e9d007 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 58aa89047..0bf2abb10 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 253b69430..e2263bd5b 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 dc894a1d5..a964d3c76 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 c4faf0689..88134417c 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 596ca6f4e..23a94c4eb 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 de7bd9c9d..252643b81 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 d5bfd6c67..fa6c332c6 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 a74ceb08f..9b4f06db1 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 75bab84d7..cf43ad36f 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 331e74aa1..ed7dd3924 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 e6b564761..9b47e19af 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 034511ce7..67aaa655b 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 4d08a8d15..fb87b1a9c 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 06f2217d1..ff848bb2a 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 092cf9954..1f26d8438 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 53c7326d9..410d99680 100644 --- a/tests/unit/workflow-contract-dashboard-delivery.test.mjs +++ b/tests/unit/workflow-contract-dashboard-delivery.test.mjs @@ -100,6 +100,13 @@ 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.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. assert.match(ingestionScale.block, /if: github\.ref == 'refs\/heads\/main'/);