diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f32208edea..9d4b22ea51 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -474,10 +474,10 @@ jobs: rm -f "${refused}" states=$( for dep in $(read_api "repos/${REPO}/deployments?sha=${SHA}&per_page=100" '.[].id'); do - read_api "repos/${REPO}/deployments/${dep}/statuses?per_page=100" '.[].state' + read_api "repos/${REPO}/deployments/${dep}/statuses?per_page=100" 'max_by([.created_at, .id]) | .state // empty' done read_api "repos/${REPO}/commits/${SHA}/statuses?per_page=100" \ - '.[] | select(.context | ascii_downcase | test("vercel")) | .state' + 'group_by(.context)[] | max_by([.created_at, .id]) | select(.context | ascii_downcase | test("vercel")) | .state' # A refused read prints nothing and is recorded in ${refused}, read just below, so the # subshell's own exit status is not the signal and must not trip `set -e`. true @@ -654,9 +654,10 @@ jobs: - name: Public surface -- production contracts if: ${{ always() && steps.preview.outputs.ready == 'true' }} env: + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report/public-contracts PW_BASE_URL: https://frequencylocal.com PW_PROBE_CRON: '0' - run: pnpm exec playwright test public-surface.spec.ts --project mobile + run: pnpm exec playwright test public-surface.spec.ts --project mobile --output=test-results/public-contracts # ── THE VISUAL COMPARE, IN TWO TIERS (2026-09-14) ────────────────────────────────── # @@ -701,25 +702,27 @@ jobs: if: ${{ always() && steps.preview.outputs.ready == 'true' }} env: PW_BASE_URL: ${{ steps.preview.outputs.url }} + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report/visual-public VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} PW_STORAGE_STATE: ${{ steps.session.outputs.path }} PW_ROOM_PATH: ${{ vars.PW_ROOM_PATH }} PW_SPACE_SLUG: ${{ vars.PW_SPACE_SLUG }} PW_REQUIRE_SHELL: ${{ vars.PW_REQUIRE_SHELL || secrets.PW_REQUIRE_SHELL }} PW_REQUIRE_OPERATOR: ${{ vars.PW_REQUIRE_OPERATOR || secrets.PW_REQUIRE_OPERATOR }} - run: pnpm test:e2e:visual:stable + run: pnpm test:e2e:visual:stable --output=test-results/visual-public - name: Visual compare -- member shell + operator if: ${{ always() && steps.preview.outputs.ready == 'true' }} env: PW_BASE_URL: ${{ steps.preview.outputs.url }} + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report/visual-shell VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} PW_STORAGE_STATE: ${{ steps.session.outputs.path }} PW_ROOM_PATH: ${{ vars.PW_ROOM_PATH }} PW_SPACE_SLUG: ${{ vars.PW_SPACE_SLUG }} PW_REQUIRE_SHELL: ${{ vars.PW_REQUIRE_SHELL || secrets.PW_REQUIRE_SHELL }} PW_REQUIRE_OPERATOR: ${{ vars.PW_REQUIRE_OPERATOR || secrets.PW_REQUIRE_OPERATOR }} - run: pnpm test:e2e:visual:shell + run: pnpm test:e2e:visual:shell --output=test-results/visual-shell # LIVE-313 / ADR-1449: the member-shell step above is a real gate. /discover stays in # its own advisory step (LIVE-373) because its HEIGHT is production Circles, events and @@ -741,13 +744,14 @@ jobs: if: ${{ always() && steps.preview.outputs.ready == 'true' }} env: PW_BASE_URL: ${{ steps.preview.outputs.url }} + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report/visual-advisory VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} PW_STORAGE_STATE: ${{ steps.session.outputs.path }} PW_ROOM_PATH: ${{ vars.PW_ROOM_PATH }} PW_SPACE_SLUG: ${{ vars.PW_SPACE_SLUG }} PW_REQUIRE_SHELL: ${{ vars.PW_REQUIRE_SHELL || secrets.PW_REQUIRE_SHELL }} PW_REQUIRE_OPERATOR: ${{ vars.PW_REQUIRE_OPERATOR || secrets.PW_REQUIRE_OPERATOR }} - run: pnpm test:e2e:visual:advisory + run: pnpm test:e2e:visual:advisory --output=test-results/visual-advisory # An advisory tier that fails silently is the thing it was built to avoid, so say it in # the summary where a reviewer already looks. EVERY surface on the tier is named here, with @@ -1106,10 +1110,10 @@ jobs: rm -f "${refused}" states=$( for dep in $(read_api "repos/${REPO}/deployments?sha=${SHA}&per_page=100" '.[].id'); do - read_api "repos/${REPO}/deployments/${dep}/statuses?per_page=100" '.[].state' + read_api "repos/${REPO}/deployments/${dep}/statuses?per_page=100" 'max_by([.created_at, .id]) | .state // empty' done read_api "repos/${REPO}/commits/${SHA}/statuses?per_page=100" \ - '.[] | select(.context | ascii_downcase | test("vercel")) | .state' + 'group_by(.context)[] | max_by([.created_at, .id]) | select(.context | ascii_downcase | test("vercel")) | .state' # A refused read prints nothing and is recorded in ${refused}, read just below, so the # subshell's own exit status is not the signal and must not trip `set -e`. true diff --git a/app/api/status/route.test.ts b/app/api/status/route.test.ts index f352c8e196..3ac6c00b4f 100644 --- a/app/api/status/route.test.ts +++ b/app/api/status/route.test.ts @@ -71,6 +71,11 @@ describe('the build identity a deploy check reads', () => { }) describe('the index contract it already had is unchanged', () => { + it('publishes the one-hour browser and shared-cache policy at the origin', () => { + const directives = GET().headers.get('cache-control')?.split(',').map((directive) => directive.trim()) + expect(directives).toEqual(['public', 'max-age=3600', 's-maxage=3600']) + }) + it('still lists the endpoint family and the SLO summary', async () => { const json = await body() expect(json.service).toBe('frequency') diff --git a/components/frequency/site/pages/home/weave.tsx b/components/frequency/site/pages/home/weave.tsx index 5cd28e50e1..36b944b9fd 100644 --- a/components/frequency/site/pages/home/weave.tsx +++ b/components/frequency/site/pages/home/weave.tsx @@ -67,11 +67,17 @@ export function Weave({ stages }: { stages: WeaveStage[] }) { const s = svg.current if (!host || !s) return const R = host.getBoundingClientRect() + // Measure each card once, and keep path writes detached until every read is complete. + const boxes = new Map() const box = (el: Element) => { + const cached = boxes.get(el) + if (cached) return cached const r = el.getBoundingClientRect() - return { x: r.left - R.left, y: r.top - R.top, w: r.width, h: r.height, cx: r.left - R.left + r.width / 2, cy: r.top - R.top + r.height / 2 } + const result = { x: r.left - R.left, y: r.top - R.top, w: r.width, h: r.height, cx: r.left - R.left + r.width / 2, cy: r.top - R.top + r.height / 2 } + boxes.set(el, result) + return result } - s.replaceChildren() + const paths = document.createDocumentFragment() const add = (d: string, colour: string, cols: number[], lane: Row, cls: string[]) => { const p = document.createElementNS(SVG_NS, 'path') p.setAttribute('d', d) @@ -79,7 +85,7 @@ export function Weave({ stages }: { stages: WeaveStage[] }) { cls.forEach((c) => p.classList.add(c)) p.dataset.cols = cols.join(',') p.dataset.lane = lane - s.appendChild(p) + paths.appendChild(p) } const link = (a: Element | null, b: Element | null, colour: string, cols: number[], flow: boolean, lane: Row) => { if (!a || !b) return @@ -123,6 +129,7 @@ export function Weave({ stages }: { stages: WeaveStage[] }) { setStageBoxes([]) setLoopTops(null) } + s.replaceChildren(paths) }, [mobile, stages]) // Draw after layout, on resize (debounced), and once the web fonts settle the boxes. diff --git a/docs/ledger/rows/CI-VISUAL-ARTIFACT-RETENTION.json b/docs/ledger/rows/CI-VISUAL-ARTIFACT-RETENTION.json new file mode 100644 index 0000000000..98fccee253 --- /dev/null +++ b/docs/ledger/rows/CI-VISUAL-ARTIFACT-RETENTION.json @@ -0,0 +1,19 @@ +{ + "id": "CI-VISUAL-ARTIFACT-RETENTION", + "title": "Preserve every visual tier failure artifact through the final CI upload", + "status": "open", + "priority": "P2", + "lane": "hygiene", + "size": "S", + "source": { + "file": ".github/workflows/e2e.yml", + "ref": "Visual compare public/member/advisory steps and final Upload report + diffs" + }, + "detail": "Read-only audit of exact control run41904712802 found112 blocking public/member visual failures in logs, but its final official artifact13361658194 contains only6 final advisory Discover cases:4failed and2skipped. Independent Playwright invocations reuse default test-results and playwright-report, so later tiers erase earlier screenshots/reports before the single final upload. Isolate both output and HTML report directories per tier without changing selectors, thresholds, baselines, timeouts, secrets or blocking/advisory semantics. Preserve initial smoke/root evidence too. Related broader audit SCAN-1032 remains open. This repair preserves evidence only; it does not resolve visual differences, demonstrate a visual/performance pass or authorize baseline recapture.", + "verify": { + "kind": "manual", + "evidence": "OPEN. Daniel approved exact workflow patch SHA256 05b29b302606bea428c260a5e22b617a28c4dbd95767ee04d72eaaea9aa0c7f4 on 2026-11-04. Applied against main bbdfba1b: exactly six YAML leaves isolate public/member/advisory results and HTML directories; upload roots, selectors, secrets, gates, thresholds, baselines and blocking/advisory semantics preserved. Installed pnpm/Playwright non-browser controls reproduce pristine advisory-only retention and preserve candidate smoke plus all three visual tiers; intentional public/advisory exit1 and reporter append controls pass. Exact-head CI and inspection of actual retained artifacts remain pending. Local fixtures do not establish a visual or performance pass; do not close until CI artifact retention is verified.", + "checked": "2026-11-04" + }, + "wave": "W7" +} diff --git a/test/e2e/public-surface.spec.ts b/test/e2e/public-surface.spec.ts index c9aacf64fb..e12822d2d8 100644 --- a/test/e2e/public-surface.spec.ts +++ b/test/e2e/public-surface.spec.ts @@ -88,11 +88,19 @@ test.describe('public surface (platform host)', { tag: '@public-surface' }, () = const res = await request.get('/api/status') expect(res.status()).toBe(200) expect(res.headers()['content-type']).toContain('application/json') - expect(res.headers()['cache-control']).toMatch(/s-maxage=\d+/) + // Vercel consumes s-maxage at its edge. Check the client policy and actual cache reuse. + const cacheControl = res.headers()['cache-control'].split(',').map((directive) => directive.trim()) + expect(cacheControl).toEqual(expect.arrayContaining(['public', 'max-age=3600'])) + expect(cacheControl).not.toContain('private') + expect(cacheControl).not.toContain('no-store') expect(res.headers()['set-cookie'], 'a cacheable machine endpoint sets no cookie').toBeUndefined() const json = await res.json() expect(json.service).toBe('frequency') expect(json.build.env).toBe('production') + await expect.poll(async () => { + const cached = await request.get('/api/status') + return cached.ok() ? cached.headers()['x-vercel-cache'] : cached.status() + }, { message: '/api/status is served from the Vercel cache after warming' }).toBe('HIT') }) test('unknown API paths and anonymous private APIs never 5xx and answer JSON-or-empty', async ({ request }) => {