From 091cab49b72baafa67afdcc45833ffa3b506ebf2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:46:26 +0000 Subject: [PATCH] Add workflows built-in page renderer --- pages/dashboard/PLAN.md | 9 + pages/dashboard/src/presenter.js | 172 +++++++++++++++++ pages/dashboard/test/e2e/smoke.spec.js | 201 ++++++++++++++++++++ pages/dashboard/test/unit/presenter.test.js | 181 ++++++++++++++++++ 4 files changed, 563 insertions(+) diff --git a/pages/dashboard/PLAN.md b/pages/dashboard/PLAN.md index 6b5c800..16e3e38 100644 --- a/pages/dashboard/PLAN.md +++ b/pages/dashboard/PLAN.md @@ -20,6 +20,7 @@ - [x] Slice: `DLS-PAGE-006` conservative run-link coverage validation for the `runs` built-in page. - [x] Slice: `DLS-PAGE-002` conservative `overview` linked-findings and operational-value timeline coverage validation. - [x] Slice: `DLS-PAGE-006` and `DLS-PAGE-014` presenter render for the `runs` built-in page status counts, outcome counts, scope/model/time columns, run links, and independent data-state summaries. + - [x] Slice: `DLS-PAGE-005` and `DLS-PAGE-014` presenter render for the `workflows` built-in page inventory, active state, rollout mode, run conclusions, downstream outcomes, available usage, findings, operational value counts, and independent data-state summaries. - [ ] **Security, privacy, accessibility** — Section 13 including escaping, redaction, and keyboard and screen-reader behavior verified with Playwright. - [ ] **Compliance suite** — Section 14 test suite, the compliance checklist, Appendix A as a passing fixture, and Appendix C as failing fixtures. - [ ] **Parity** — inventory the features of the existing dashboard in `.github/scripts/pages-report/report.mjs`, record them in `PLAN.md` as a parity checklist, then express each one as YAML configuration plus data fixtures, closing the checklist incrementally. @@ -39,6 +40,14 @@ ## Run log +### 2026-08-29 (built-in workflows render slice) + +- Extended the Built-in pages milestone with a narrow `DLS-PAGE-005` and `DLS-PAGE-014` presenter increment for the `workflows` built-in page, rendering workflow inventory with active state, rollout mode, run counts, run conclusion summaries, downstream outcome counts, available AIC totals, finding counts, operational value counts, and independent data-state summaries. +- Updated `src/presenter.js` so declarative built-in `workflows` definitions now render a concrete table from `workflows`, `runs`, `outcomes`, `usage`, `findings`, and `operational-values` logical sources, reusing the keyed-list DOM primitive for deterministic row reconciliation. +- Expanded `test/unit/presenter.test.js` with a jsdom presenter contract for the `workflows` slice and replaced the browser smoke coverage in `test/e2e/smoke.spec.js` with a Playwright browser test that verifies the rendered workflow rows, aggregated counts, provenance, and independent `availability`, `completeness`, and `freshness` text. +- Verified `npm install`, `npm run typecheck`, `npm run lint`, and `npm test`; `npm run test:e2e` remains blocked in this environment because the Playwright Chromium executable is not provisioned (`browserType.launch: Executable doesn't exist`). +- Next milestone: Built-in pages, next slice for rendering one additional Section 10 built-in page from the declarative definitions or extracting the first reusable presentation component needed by that rendering. + ### 2026-08-29 (built-in runs render slice) - Extended the Built-in pages milestone with a narrow `DLS-PAGE-006` and `DLS-PAGE-014` presenter increment for the `runs` built-in page, rendering run status counts, terminal conclusions, downstream outcome counts, scope, rollout mode, engine, requested model, resolved model, started time, run links, and independent data-state summaries. diff --git a/pages/dashboard/src/presenter.js b/pages/dashboard/src/presenter.js index 45cebe7..3441a74 100644 --- a/pages/dashboard/src/presenter.js +++ b/pages/dashboard/src/presenter.js @@ -144,6 +144,10 @@ function renderBuiltInPageBody(page, pageSources) { return renderRunsPage(pageSources); } + if (page.page === 'workflows') { + return renderWorkflowsPage(pageSources); + } + return h('p', { className: 'page-placeholder' }, `Built-in page ${page.page} is not rendered in this increment.`); } @@ -249,6 +253,101 @@ function renderRunRow(item) { ); } +/** + * @param {Map} pageSources + * @returns {HTMLElement} + */ +function renderWorkflowsPage(pageSources) { + const workflowsSource = pageSources.get('workflows'); + const runsSource = pageSources.get('runs'); + const outcomesSource = pageSources.get('outcomes'); + const usageSource = pageSources.get('usage'); + const findingsSource = pageSources.get('findings'); + const operationalValuesSource = pageSources.get('operational-values'); + + const workflows = Array.isArray(workflowsSource?.rows) ? workflowsSource.rows : []; + const runs = Array.isArray(runsSource?.rows) ? runsSource.rows : []; + const outcomes = Array.isArray(outcomesSource?.rows) ? outcomesSource.rows : []; + const usage = Array.isArray(usageSource?.rows) ? usageSource.rows : []; + const findings = Array.isArray(findingsSource?.rows) ? findingsSource.rows : []; + const operationalValues = Array.isArray(operationalValuesSource?.rows) ? operationalValuesSource.rows : []; + + const workflowItems = workflows.map((workflow, index) => ({ + key: getWorkflowKey(workflow, index), + workflow, + runCount: countMatchingRows(runs, workflow, 'workflow'), + conclusionCounts: countByMatchingRows(runs, workflow, 'workflow', 'run-conclusion'), + outcomeCount: countMatchingRows(outcomes, workflow, 'workflow'), + aicTotal: sumMatchingNumericRows(usage, workflow, 'workflow', 'aic'), + findingCount: countMatchingRows(findings, workflow, 'workflow'), + operationalValueCount: countMatchingRows(operationalValues, workflow, 'workflow') + })); + + return h( + 'div', + { className: 'workflows-page' }, + h('h3', null, 'Workflow Inventory'), + h( + 'table', + { className: 'workflows-table' }, + h( + 'thead', + null, + h( + 'tr', + null, + h('th', null, 'Workflow'), + h('th', null, 'Organization'), + h('th', null, 'Repository'), + h('th', null, 'Active State'), + h('th', null, 'Rollout Mode'), + h('th', null, 'Run Count'), + h('th', null, 'Run Conclusions'), + h('th', null, 'Outcome Count'), + h('th', null, 'Available AIC'), + h('th', null, 'Finding Count'), + h('th', null, 'Operational Value Count') + ) + ), + h( + 'tbody', + null, + workflowItems.length > 0 + ? keyed( + workflowItems, + (item) => renderWorkflowRow(/** @type {{ key: string, workflow: Record, runCount: number, conclusionCounts: Map, outcomeCount: number, aicTotal: number, findingCount: number, operationalValueCount: number }} */ (item)), + (item) => /** @type {{ key: string }} */ (item).key + ) + : h('tr', null, h('td', { colSpan: 11 }, 'No workflows available.')) + ) + ) + ); +} + +/** + * @param {{ key: string, workflow: Record, runCount: number, conclusionCounts: Map, outcomeCount: number, aicTotal: number, findingCount: number, operationalValueCount: number }} item + * @returns {HTMLElement} + */ +function renderWorkflowRow(item) { + const workflow = item.workflow; + + return h( + 'tr', + { 'data-workflow-id': String(workflow.workflow ?? item.key) }, + h('td', null, toText(workflow.workflow)), + h('td', null, toText(workflow.organization)), + h('td', null, toText(workflow.repository)), + h('td', null, toText(workflow['workflow-active'])), + h('td', null, toText(workflow['rollout-mode'])), + h('td', null, String(item.runCount)), + h('td', null, formatCounts(item.conclusionCounts)), + h('td', null, String(item.outcomeCount)), + h('td', null, formatNumber(item.aicTotal)), + h('td', null, String(item.findingCount)), + h('td', null, String(item.operationalValueCount)) + ); +} + /** * @param {Map} pageSources * @returns {DataState} @@ -376,6 +475,43 @@ function countMatchingOutcomes(outcomes, run) { return outcomes.filter((outcome) => outcome.run === run.run).length; } +/** + * @param {Array>} rows + * @param {Record} matchRow + * @param {string} field + * @returns {number} + */ +function countMatchingRows(rows, matchRow, field) { + return rows.filter((row) => row[field] === matchRow[field]).length; +} + +/** + * @param {Array>} rows + * @param {Record} matchRow + * @param {string} matchField + * @param {string} countField + * @returns {Map} + */ +function countByMatchingRows(rows, matchRow, matchField, countField) { + return countBy(rows.filter((row) => row[matchField] === matchRow[matchField]), countField); +} + +/** + * @param {Array>} rows + * @param {Record} matchRow + * @param {string} matchField + * @param {string} numericField + * @returns {number} + */ +function sumMatchingNumericRows(rows, matchRow, matchField, numericField) { + return rows.reduce((total, row) => { + if (row[matchField] !== matchRow[matchField]) { + return total; + } + return total + toNumber(row[numericField]); + }, 0); +} + /** * @param {Record} run * @param {number} index @@ -385,6 +521,15 @@ function getRunKey(run, index) { return typeof run.run === 'string' && run.run.length > 0 ? run.run : `run-${index}`; } +/** + * @param {Record} workflow + * @param {number} index + * @returns {string} + */ +function getWorkflowKey(workflow, index) { + return typeof workflow.workflow === 'string' && workflow.workflow.length > 0 ? workflow.workflow : `workflow-${index}`; +} + /** * @param {Record} row * @returns {{ href: string, label: string } | null} @@ -405,6 +550,33 @@ function toText(value) { return value == null || value === '' ? 'unknown' : String(value); } +/** + * @param {unknown} value + * @returns {number} + */ +function toNumber(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +/** + * @param {Map} counts + * @returns {string} + */ +function formatCounts(counts) { + const entries = [...counts.entries()]; + return entries.length > 0 + ? entries.map(([name, count]) => `${name}: ${count}`).join(', ') + : 'No data available.'; +} + +/** + * @param {number} value + * @returns {string} + */ +function formatNumber(value) { + return Number.isInteger(value) ? String(value) : value.toFixed(2); +} + /** * @param {string} value * @returns {string} diff --git a/pages/dashboard/test/e2e/smoke.spec.js b/pages/dashboard/test/e2e/smoke.spec.js index 8d894c6..dd7283c 100644 --- a/pages/dashboard/test/e2e/smoke.spec.js +++ b/pages/dashboard/test/e2e/smoke.spec.js @@ -1,6 +1,207 @@ import { readFileSync } from 'node:fs'; import { test, expect } from '@playwright/test'; +test('DLS-PAGE-005 DLS-PAGE-014 built-in workflows page renders inventory, active state, rollout mode, run conclusions, outcomes, usage, findings, operational value, and independent data state in browser', async ({ page }) => { + const domSource = readFileSync(new URL('../../src/dom.js', import.meta.url), 'utf8'); + const presenterSource = readFileSync(new URL('../../src/presenter.js', import.meta.url), 'utf8'); + const domModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(domSource)}`; + const presenterModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(presenterSource.replace("'./dom.js'", JSON.stringify(domModuleUrl)))}`; + + await page.setContent(` +
+ + `); + + await expect(page.getByRole('heading', { name: 'Built In Workflows Render' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Workflows', exact: true })).toBeVisible(); + await expect(page.locator('[data-state-axis="availability"]')).toHaveText('available'); + await expect(page.locator('[data-state-axis="completeness"]')).toHaveText('partial'); + await expect(page.locator('[data-state-axis="freshness"]')).toHaveText('stale'); + await expect(page.locator('.workflows-table tbody tr')).toHaveCount(2); + await expect(page.locator('.workflows-table tbody tr').first()).toContainText([ + 'dashboard.yml', + 'githubnext', + 'central-agentic-ops', + 'true', + 'review', + '2', + 'success: 1, failure: 1', + '2', + '5', + '1', + '1' + ]); + await expect(page.locator('.workflows-table tbody tr').nth(1)).toContainText([ + 'release.yml', + 'false', + 'live', + '1', + 'success: 1', + '1', + '5', + '2', + '1' + ]); + await expect(page.locator('.provenance-list li')).toContainText([ + 'workflows: workflows-fixture (fixture) — as of 2026-08-29T13:00:00Z', + 'runs: runs-fixture (fixture) — as of 2026-08-29T13:00:00Z', + 'outcomes: outcomes-fixture (fixture) — as of 2026-08-29T13:00:00Z', + 'usage: usage-fixture (fixture) — as of 2026-08-29T13:00:00Z', + 'findings: findings-fixture (fixture) — as of 2026-08-29T13:00:00Z', + 'operational-values: operational-values-fixture (fixture) — as of 2026-08-29T13:00:00Z' + ]); +}); + test('DLS-PAGE-006 DLS-PAGE-014 built-in runs page renders status counts, outcomes, scope, models, time, run links, and independent data state in browser', async ({ page }) => { const domSource = readFileSync(new URL('../../src/dom.js', import.meta.url), 'utf8'); const presenterSource = readFileSync(new URL('../../src/presenter.js', import.meta.url), 'utf8'); diff --git a/pages/dashboard/test/unit/presenter.test.js b/pages/dashboard/test/unit/presenter.test.js index 8444ec7..1a275ba 100644 --- a/pages/dashboard/test/unit/presenter.test.js +++ b/pages/dashboard/test/unit/presenter.test.js @@ -3,6 +3,187 @@ import { describe, expect, it } from 'vitest'; import { renderDashboard } from '../../src/presenter.js'; describe('presenter built-in pages', () => { + it('DLS-PAGE-005 DLS-PAGE-014 renders built-in workflows page inventory, active state, rollout mode, run conclusions, outcomes, usage, findings, operational value, and independent data state deterministically', () => { + /** @type {import('../../src/presenter.js').PresentationInput['document']} */ + const document = { + languageVersion: '0.1.0', + dashboard: { + id: 'workflows-dashboard', + title: 'Workflows Dashboard', + pages: [ + { + id: 'workflows', + kind: /** @type {'built-in'} */ ('built-in'), + page: 'workflows', + title: 'Workflows', + definition: { + 'data-state': { + availability: true, + completeness: true, + freshness: true + }, + views: [ + { id: 'workflows-source', data: { source: 'workflows' } }, + { id: 'runs-source', data: { source: 'runs' } }, + { id: 'outcomes-source', data: { source: 'outcomes' } }, + { id: 'usage-source', data: { source: 'usage' } }, + { id: 'findings-source', data: { source: 'findings' } }, + { id: 'operational-values-source', data: { source: 'operational-values' } } + ] + } + } + ] + } + }; + + const rendered = renderDashboard({ + document, + sources: { + workflows: { + source: 'workflows', + rows: [ + { + organization: 'githubnext', + repository: 'central-agentic-ops', + workflow: 'dashboard.yml', + 'workflow-active': 'true', + 'rollout-mode': 'review' + }, + { + organization: 'githubnext', + repository: 'central-agentic-ops', + workflow: 'release.yml', + 'workflow-active': 'false', + 'rollout-mode': 'live' + } + ], + metadata: { + 'source-id': 'workflows-fixture', + 'source-kind': 'fixture', + 'as-of': '2026-08-29T13:00:00Z', + 'retrieved-at': '2026-08-29T13:01:00Z', + completeness: 'complete', + freshness: 'fresh', + availability: 'available' + } + }, + runs: { + source: 'runs', + rows: [ + { + workflow: 'dashboard.yml', + run: '1001', + 'run-conclusion': 'success' + }, + { + workflow: 'dashboard.yml', + run: '1002', + 'run-conclusion': 'failure' + }, + { + workflow: 'release.yml', + run: '1003', + 'run-conclusion': 'success' + } + ], + metadata: { + 'source-id': 'runs-fixture', + 'source-kind': 'fixture', + 'as-of': '2026-08-29T13:00:00Z', + 'retrieved-at': '2026-08-29T13:01:00Z', + completeness: 'complete', + freshness: 'fresh', + availability: 'available' + } + }, + outcomes: { + source: 'outcomes', + rows: [ + { workflow: 'dashboard.yml', 'outcome-state': 'accepted' }, + { workflow: 'dashboard.yml', 'outcome-state': 'pending' }, + { workflow: 'release.yml', 'outcome-state': 'rejected' } + ], + metadata: { + 'source-id': 'outcomes-fixture', + 'source-kind': 'fixture', + 'as-of': '2026-08-29T13:00:00Z', + 'retrieved-at': '2026-08-29T13:01:00Z', + completeness: 'partial', + freshness: 'stale', + availability: 'available' + } + }, + usage: { + source: 'usage', + rows: [ + { workflow: 'dashboard.yml', aic: 3 }, + { workflow: 'dashboard.yml', aic: 2 }, + { workflow: 'release.yml', aic: 5 } + ], + metadata: { + 'source-id': 'usage-fixture', + 'source-kind': 'fixture', + 'as-of': '2026-08-29T13:00:00Z', + 'retrieved-at': '2026-08-29T13:01:00Z', + completeness: 'complete', + freshness: 'fresh', + availability: 'available' + } + }, + findings: { + source: 'findings', + rows: [ + { workflow: 'dashboard.yml', finding: 'f-1' }, + { workflow: 'release.yml', finding: 'f-2' }, + { workflow: 'release.yml', finding: 'f-3' } + ], + metadata: { + 'source-id': 'findings-fixture', + 'source-kind': 'fixture', + 'as-of': '2026-08-29T13:00:00Z', + 'retrieved-at': '2026-08-29T13:01:00Z', + completeness: 'complete', + freshness: 'fresh', + availability: 'available' + } + }, + 'operational-values': { + source: 'operational-values', + rows: [ + { workflow: 'dashboard.yml', 'operational-value': 0.8 }, + { workflow: 'release.yml', 'operational-value': 0.4 } + ], + metadata: { + 'source-id': 'operational-values-fixture', + 'source-kind': 'fixture', + 'as-of': '2026-08-29T13:00:00Z', + 'retrieved-at': '2026-08-29T13:01:00Z', + completeness: 'complete', + freshness: 'fresh', + availability: 'available' + } + } + } + }); + + expect(rendered.querySelector('[data-page-name="workflows"]')?.textContent).toContain('dashboard.yml'); + expect(rendered.querySelector('[data-state-axis="availability"]')?.textContent).toBe('available'); + expect(rendered.querySelector('[data-state-axis="completeness"]')?.textContent).toBe('partial'); + expect(rendered.querySelector('[data-state-axis="freshness"]')?.textContent).toBe('stale'); + expect(rendered.querySelectorAll('.workflows-table tbody tr')).toHaveLength(2); + expect(rendered.querySelector('.workflows-table tbody tr')?.textContent).toContain('dashboard.yml'); + expect(rendered.querySelector('.workflows-table tbody tr')?.textContent).toContain('true'); + expect(rendered.querySelector('.workflows-table tbody tr')?.textContent).toContain('review'); + expect(rendered.querySelector('.workflows-table tbody tr')?.textContent).toContain('2'); + expect(rendered.querySelector('.workflows-table tbody tr')?.textContent).toContain('success: 1, failure: 1'); + expect(rendered.querySelector('.workflows-table tbody tr')?.textContent).toContain('5'); + expect(rendered.querySelectorAll('.workflows-table tbody tr')[1]?.textContent).toContain('release.yml'); + expect(rendered.querySelectorAll('.workflows-table tbody tr')[1]?.textContent).toContain('false'); + expect(rendered.querySelectorAll('.workflows-table tbody tr')[1]?.textContent).toContain('live'); + expect(rendered.querySelectorAll('.workflows-table tbody tr')[1]?.textContent).toContain('1'); + expect(rendered.querySelectorAll('.workflows-table tbody tr')[1]?.textContent).toContain('success: 1'); + }); + it('DLS-PAGE-006 DLS-PAGE-014 renders built-in runs page counts, rows, links, and independent data state deterministically', () => { /** @type {import('../../src/presenter.js').PresentationInput['document']} */ const document = {