Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/cid.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions dashboard/cli-action-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion dashboard/cli-action-queue.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
3 changes: 2 additions & 1 deletion dashboard/site/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,11 @@ async function bundleSiteJavascript(destinationPath) {
.filter((path) => path !== mainOutput)
.map((path) => basename(path));
const preloads = [
' <link rel="stylesheet" href="./styles.css">',
' <link rel="preload" href="./dashboard.json" as="fetch" crossorigin>',
' <link rel="modulepreload" href="./src/main.js" fetchpriority="low">',
...startupChunks.map((chunk) => ` <link rel="modulepreload" href="./src/${chunk}" fetchpriority="low">`),
// Avoid a render-blocking CSS round trip without delaying module discovery.
` <style data-dashboard-styles>${stylesheet.code}</style>`,
];
const indexPath = join(destinationPath, "index.html");
const index = await readFile(indexPath, "utf8");
Expand Down
7 changes: 4 additions & 3 deletions dashboard/site/src/components/cli-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.' };
}
Expand Down
6 changes: 5 additions & 1 deletion dashboard/site/src/components/command-terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion dashboard/site/src/dashboard-chunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] : [];
});
Expand Down
3 changes: 2 additions & 1 deletion dashboard/site/src/data/cli-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions dashboard/site/test/e2e/campaigns-page-subscription.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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');
Expand Down
30 changes: 21 additions & 9 deletions dashboard/site/test/e2e/canvas-fixture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/**
Expand All @@ -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<string, string>} */
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));
}
9 changes: 6 additions & 3 deletions dashboard/site/test/e2e/lazy-page-chunks.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>} */ 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);
Expand Down Expand Up @@ -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');
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
3 changes: 2 additions & 1 deletion dashboard/site/test/e2e/normalized-shard.js
Original file line number Diff line number Diff line change
@@ -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 = []) {
Expand All @@ -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
Expand Down
21 changes: 16 additions & 5 deletions dashboard/site/test/e2e/query-editor.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> }} */
/** @type {{ url: string, close: () => Promise<void>, ingestSnapshot: () => Promise<void> }} */
let server;
let workspace = '';
let artifactDirectory = '';
Expand All @@ -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)]
});
},
});
Expand Down Expand Up @@ -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);
Expand All @@ -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 }) => {
Expand Down
1 change: 1 addition & 0 deletions dashboard/site/test/e2e/repository-upgrade-batch.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
2 changes: 1 addition & 1 deletion dashboard/site/test/e2e/semantic-prompt.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: /);
Expand Down
Loading
Loading