From 051e675dc9944d573befc305239a3d8fc9dfa533 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 15 Sep 2026 12:19:34 -0600 Subject: [PATCH] fix: separate browser correctness from comparison audits --- .github/workflows/conformance.yml | 52 +++- API-FRICTION.md | 13 + benchmarks/conformance/README.md | 19 ++ .../cases/127-shadcn-dashboard/dashboard.tsx | 2 +- .../cases/127-shadcn-dashboard/styles.ts | 5 +- .../cases/143-shadcn-bar-active/case.json | 3 +- .../cases/149-shadcn-bar-mixed/case.json | 3 +- .../cases/150-shadcn-bar-negative/example.tsx | 2 +- .../156-shadcn-line-interactive/case.json | 3 +- .../181-shadcn-radar-label-custom/example.tsx | 8 +- .../cases/189-shadcn-radial-stacked/case.json | 3 +- .../189-shadcn-radial-stacked/example.tsx | 4 +- benchmarks/conformance/catalog-index.json | 12 +- .../previews/150-shadcn-bar-negative.svg | 2 +- .../181-shadcn-radar-label-custom.svg | 2 +- .../previews/189-shadcn-radial-stacked.svg | 2 +- benchmarks/conformance/previews/manifest.json | 14 +- .../shared/shadcn-catalog-recharts.tsx | 108 +++++++- .../shared/shadcn-data-contracts.test.ts | 27 ++ .../shadcn-reference-accessibility.test.ts | 33 +++ scripts/benchmark/stress-phases.mjs | 92 +++++++ scripts/benchmark/stress-phases.test.mjs | 83 +++++++ scripts/compare-plot-catalog-helpers.mjs | 12 + scripts/compare-plot-catalog-helpers.test.mjs | 20 ++ scripts/compare-plot-catalog.mjs | 232 +++++++++++------- scripts/conformance-workflow.test.mjs | 21 +- scripts/generate-shadcn-cases.mjs | 18 +- scripts/stress-chart-libraries.mjs | 46 ++-- 28 files changed, 683 insertions(+), 158 deletions(-) create mode 100644 benchmarks/conformance/shared/shadcn-data-contracts.test.ts create mode 100644 scripts/benchmark/stress-phases.mjs create mode 100644 scripts/benchmark/stress-phases.test.mjs diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 3b092dd5..ed40f498 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -9,8 +9,8 @@ on: - reopened - ready_for_review schedule: - - cron: '43 7 * * *' - cron: '13 9 * * 1' + - cron: '43 7 1 * *' workflow_dispatch: inputs: shard: @@ -28,6 +28,17 @@ on: - '6' - '7' - '8' + suite: + description: First-party correctness or exhaustive competitor comparison + type: choice + default: first-party + options: + - first-party + - comparison + cases: + description: Optional comma-separated case IDs for a targeted run + type: string + default: '' permissions: contents: read @@ -39,13 +50,15 @@ jobs: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && - contains(github.event.pull_request.labels.*.name, 'full-conformance') && - (github.event.action != 'labeled' || github.event.label.name == 'full-conformance')) + (contains(github.event.pull_request.labels.*.name, 'full-conformance') || + contains(github.event.pull_request.labels.*.name, 'browser-correctness')) && + (github.event.action != 'labeled' || github.event.label.name == 'full-conformance' || github.event.label.name == 'browser-correctness')) runs-on: ubuntu-24.04 timeout-minutes: 2 outputs: mode: ${{ steps.selection.outputs.mode }} shards: ${{ steps.selection.outputs.shards }} + suite: ${{ steps.selection.outputs.suite }} steps: - id: selection @@ -54,17 +67,22 @@ jobs: EVENT_NAME: ${{ github.event_name }} EVENT_SCHEDULE: ${{ github.event.schedule }} REQUESTED_SHARD: ${{ inputs.shard }} + REQUESTED_SUITE: ${{ inputs.suite }} + REQUESTED_CASES: ${{ inputs.cases }} + FULL_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'full-conformance') }} run: | - if [ "$EVENT_NAME" = schedule ] && [ "$EVENT_SCHEDULE" = '43 7 * * *' ]; then - epoch_day=$(( $(date -u +%s) / 86400 )) - shard=$(( epoch_day % 8 + 1 )) - mode=nightly - shards="[$shard]" + suite=first-party + if [ "$EVENT_SCHEDULE" = '43 7 1 * *' ] || [ "$FULL_LABEL" = true ] || [ "$REQUESTED_SUITE" = comparison ]; then + suite=comparison + fi + if [ -n "$REQUESTED_CASES" ]; then + mode=targeted + shards='[1]' elif [ "$EVENT_NAME" = workflow_dispatch ] && [ "$REQUESTED_SHARD" != full ]; then mode=manual shards="[$REQUESTED_SHARD]" elif [ "$EVENT_NAME" = schedule ]; then - mode=weekly + mode=scheduled shards='[1,2,3,4,5,6,7,8]' elif [ "$EVENT_NAME" = pull_request ]; then mode=label @@ -76,10 +94,11 @@ jobs: echo "mode=$mode" >> "$GITHUB_OUTPUT" echo "shards=$shards" >> "$GITHUB_OUTPUT" - echo "Mode: $mode; shards: $shards; revision: $GITHUB_SHA" >> "$GITHUB_STEP_SUMMARY" + echo "suite=$suite" >> "$GITHUB_OUTPUT" + echo "Suite: $suite; mode: $mode; shards: $shards; revision: $GITHUB_SHA" >> "$GITHUB_STEP_SUMMARY" conformance: - name: Conformance (${{ matrix.shard }}/8, ${{ needs.select.outputs.mode }}) + name: Conformance (${{ matrix.shard }}/8, ${{ needs.select.outputs.suite }}, ${{ needs.select.outputs.mode }}) needs: select runs-on: ubuntu-24.04 timeout-minutes: 25 @@ -103,7 +122,16 @@ jobs: playwright: 'true' - name: Run standard conformance shard - run: pnpm conformance -- --shard=${{ matrix.shard }}/8 + env: + SUITE: ${{ needs.select.outputs.suite }} + CASES: ${{ inputs.cases }} + SHARD: ${{ matrix.shard }} + run: | + args=() + if [ -z "$CASES" ]; then args+=(--shard="$SHARD/8"); fi + if [ "$SUITE" = first-party ]; then args+=(--first-party); fi + if [ -n "$CASES" ]; then args+=(--case="$CASES"); fi + pnpm conformance -- "${args[@]}" - name: Publish conformance summary if: success() diff --git a/API-FRICTION.md b/API-FRICTION.md index d1ab54b8..7bfc1098 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -8318,6 +8318,19 @@ Each entry records: complete standard shard 4/8 passes all 24 cases, including the nine former failures, with the original geometry, paint, and accessibility gates intact. +- Follow-up from scheduled run 34865024647: active and mixed bars render five + browser data rows, and stacked radial renders two series, but generated + expectations used generic family counts. The interactive line selects one + series at a time, not two simultaneous lines. Correct the source generator and + pinned reference data. The stacked radial example also used 1260 as the + cumulative end of a 570 + 1260 stack; its cumulative end and domain are 1830. + First-party browser checks exposed outside radar labels with no reserved + space, a negative-bar label below the viewport, and dashboard media queries + tied to the host window instead of the embedded panel. Reserve label space + and use dashboard container queries. Its actual scroll viewport is now + identified for the existing offscreen-label rule, not treated as a clipped + static chart. Retain these cases in routine first-party browser coverage. + ### F-275 — Preview transparency validation rejected semantic IDs - Status: resolved diff --git a/benchmarks/conformance/README.md b/benchmarks/conformance/README.md index 2f239e59..8081919e 100644 --- a/benchmarks/conformance/README.md +++ b/benchmarks/conformance/README.md @@ -47,6 +47,12 @@ pnpm conformance:quick # Standard 320/640/960, light/dark matrix pnpm conformance +# First-party browser correctness, no competitor builds or comparative audits +pnpm conformance -- --first-party + +# Target an example while keeping its full browser matrix +pnpm conformance -- --first-party --case=150-shadcn-bar-negative + # Isolated bundle and type audit only pnpm conformance:size @@ -84,6 +90,19 @@ and tooling compatibility even when a run includes Recharts references. the complete catalog evidence. Long selections use a bounded digest; the JSON always records the resolved case filter. +First-party reports add `--first-party` to the artifact name. They retain all +case geometry counts, guide assertions, containment, accessible names, and +native interaction scenarios, before and after updates at every profile size +and theme. They do not measure type safety, bundle size, timing, competitor +paint parity, or relative geometry similarity. The ordinary cached checks +still validate types, examples, bundles, and generated assets. + +CI runs first-party correctness weekly and the paired comparison monthly. +Ordinary PRs do not run this workflow. Use the `browser-correctness` label for +first-party coverage or `full-conformance` for comparison coverage. Manual +runs accept a suite, shard, or comma-separated case IDs; targeted case runs +use one job rather than launching empty shards. + The [interaction UX audit](./INTERACTION-UX-AUDIT.md) preserves the before-state review of cases 80–92 and records the implementation follow-through for discoverability, rendered feedback, keyboard and touch operation, cancellation, diff --git a/benchmarks/conformance/cases/127-shadcn-dashboard/dashboard.tsx b/benchmarks/conformance/cases/127-shadcn-dashboard/dashboard.tsx index fc34d630..17a60647 100644 --- a/benchmarks/conformance/cases/127-shadcn-dashboard/dashboard.tsx +++ b/benchmarks/conformance/cases/127-shadcn-dashboard/dashboard.tsx @@ -156,7 +156,7 @@ export function ShadcnDashboard({ ChartRenderer, input }: DashboardProps) {
-
+
diff --git a/benchmarks/conformance/cases/127-shadcn-dashboard/styles.ts b/benchmarks/conformance/cases/127-shadcn-dashboard/styles.ts index 8f396b08..0bb3aa32 100644 --- a/benchmarks/conformance/cases/127-shadcn-dashboard/styles.ts +++ b/benchmarks/conformance/cases/127-shadcn-dashboard/styles.ts @@ -1,5 +1,6 @@ export const shadcnDashboardStyles = ` .shadcn-dashboard { + container: dashboard / inline-size; --sd-background: oklch(1 0 0); --sd-foreground: oklch(0.145 0 0); --sd-card: oklch(1 0 0); @@ -752,7 +753,7 @@ export const shadcnDashboardStyles = ` } } - @media (max-width: 767px) { + @container dashboard (max-width: 767px) { .sd-sidebar { display: none; } @@ -781,7 +782,7 @@ export const shadcnDashboardStyles = ` } } - @media (max-width: 480px) { + @container dashboard (max-width: 480px) { .sd-chart-card { height: 414px; } diff --git a/benchmarks/conformance/cases/143-shadcn-bar-active/case.json b/benchmarks/conformance/cases/143-shadcn-bar-active/case.json index 83b28ee8..effa32ea 100644 --- a/benchmarks/conformance/cases/143-shadcn-bar-active/case.json +++ b/benchmarks/conformance/cases/143-shadcn-bar-active/case.json @@ -13,7 +13,8 @@ "geometry": [ { "role": "bar", - "count": 6 + "count": 5, + "maxCount": 5 } ], "source": { diff --git a/benchmarks/conformance/cases/149-shadcn-bar-mixed/case.json b/benchmarks/conformance/cases/149-shadcn-bar-mixed/case.json index 09816de3..3998307c 100644 --- a/benchmarks/conformance/cases/149-shadcn-bar-mixed/case.json +++ b/benchmarks/conformance/cases/149-shadcn-bar-mixed/case.json @@ -13,7 +13,8 @@ "geometry": [ { "role": "bar", - "count": 6 + "count": 5, + "maxCount": 5 } ], "source": { diff --git a/benchmarks/conformance/cases/150-shadcn-bar-negative/example.tsx b/benchmarks/conformance/cases/150-shadcn-bar-negative/example.tsx index 3d447a98..47478127 100644 --- a/benchmarks/conformance/cases/150-shadcn-bar-negative/example.tsx +++ b/benchmarks/conformance/cases/150-shadcn-bar-negative/example.tsx @@ -50,7 +50,7 @@ export function createExampleChart() { y: { scale: scaleLinear, grid: true, axis: false }, }, - margin: { top: 24, right: 5, bottom: 24, left: 5 }, + margin: { top: 24, right: 5, bottom: 36, left: 5 }, theme: shadcnTheme(), }, { diff --git a/benchmarks/conformance/cases/156-shadcn-line-interactive/case.json b/benchmarks/conformance/cases/156-shadcn-line-interactive/case.json index 2ad25717..40eec23d 100644 --- a/benchmarks/conformance/cases/156-shadcn-line-interactive/case.json +++ b/benchmarks/conformance/cases/156-shadcn-line-interactive/case.json @@ -13,7 +13,8 @@ "geometry": [ { "role": "line", - "count": 2 + "count": 1, + "maxCount": 1 } ], "source": { diff --git a/benchmarks/conformance/cases/181-shadcn-radar-label-custom/example.tsx b/benchmarks/conformance/cases/181-shadcn-radar-label-custom/example.tsx index 95591749..f0276dd8 100644 --- a/benchmarks/conformance/cases/181-shadcn-radar-label-custom/example.tsx +++ b/benchmarks/conformance/cases/181-shadcn-radar-label-custom/example.tsx @@ -41,10 +41,14 @@ export function createExampleChart() { }), ] return defineChart( - ({ height }) => ({ + ({ width, height }) => ({ marks: [ polar({ - radiusRatio: height < 220 ? 0.64 : 0.76, + // Reserve space for the two outside label rows at every chart size. + radiusRatio: Math.min( + 0.76, + Math.max(0.1, 1 - 120 / Math.min(width, height)), + ), scales: { angle: { scale: scalePoint().domain(months), wrap: true }, radius: { scale: scaleLinear().domain([0, radiusMax]) }, diff --git a/benchmarks/conformance/cases/189-shadcn-radial-stacked/case.json b/benchmarks/conformance/cases/189-shadcn-radial-stacked/case.json index 9ebebe00..c9a83b62 100644 --- a/benchmarks/conformance/cases/189-shadcn-radial-stacked/case.json +++ b/benchmarks/conformance/cases/189-shadcn-radial-stacked/case.json @@ -13,7 +13,8 @@ "geometry": [ { "role": "bar", - "count": 5, + "count": 2, + "maxCount": 2, "rendererRoles": { "recharts": "arc", "tanstack": "arc" diff --git a/benchmarks/conformance/cases/189-shadcn-radial-stacked/example.tsx b/benchmarks/conformance/cases/189-shadcn-radial-stacked/example.tsx index 1b220d38..7810f4e0 100644 --- a/benchmarks/conformance/cases/189-shadcn-radial-stacked/example.tsx +++ b/benchmarks/conformance/cases/189-shadcn-radial-stacked/example.tsx @@ -25,7 +25,7 @@ export function createExampleChart() { id: 'desktop', ring: 'visitors', start: 570, - end: 1260, + end: 1830, fill: shadcnColors[0], }, ] @@ -36,7 +36,7 @@ export function createExampleChart() { startAngle: rechartsPolarAngle(0), endAngle: rechartsPolarAngle(180), scales: { - angle: { scale: scaleLinear().domain([0, 1260]) }, + angle: { scale: scaleLinear().domain([0, 1830]) }, radius: { scale: scaleBand().domain(['visitors']), range: [80, 110], diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index bad37c69..721ec1ca 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -10694,7 +10694,8 @@ "geometry": [ { "role": "bar", - "count": 6 + "count": 5, + "maxCount": 5 } ], "source": { @@ -10880,7 +10881,8 @@ "geometry": [ { "role": "bar", - "count": 6 + "count": 5, + "maxCount": 5 } ], "source": { @@ -11097,7 +11099,8 @@ "geometry": [ { "role": "line", - "count": 2 + "count": 1, + "maxCount": 1 } ], "source": { @@ -12136,7 +12139,8 @@ "geometry": [ { "role": "bar", - "count": 5, + "count": 2, + "maxCount": 2, "rendererRoles": { "recharts": "arc", "tanstack": "arc" diff --git a/benchmarks/conformance/previews/150-shadcn-bar-negative.svg b/benchmarks/conformance/previews/150-shadcn-bar-negative.svg index 35cf1e4e..1ce78378 100644 --- a/benchmarks/conformance/previews/150-shadcn-bar-negative.svg +++ b/benchmarks/conformance/previews/150-shadcn-bar-negative.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/181-shadcn-radar-label-custom.svg b/benchmarks/conformance/previews/181-shadcn-radar-label-custom.svg index 1b372e88..9e4a108a 100644 --- a/benchmarks/conformance/previews/181-shadcn-radar-label-custom.svg +++ b/benchmarks/conformance/previews/181-shadcn-radar-label-custom.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/189-shadcn-radial-stacked.svg b/benchmarks/conformance/previews/189-shadcn-radial-stacked.svg index ed2802ba..86bd8f79 100644 --- a/benchmarks/conformance/previews/189-shadcn-radial-stacked.svg +++ b/benchmarks/conformance/previews/189-shadcn-radial-stacked.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index f18c50d3..5e6ec216 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "c5e393081d8428d13d2efdeca32ad1e7fc56062b610b5c014d29c28af02bbde9", + "sourceHash": "33ff2cb340686f5c9fedbeb0f14be908c1f28a6bb5e55483a6fe38fc2a213434", "assets": [ { "id": "01-line-gaps", @@ -706,8 +706,8 @@ }, { "id": "150-shadcn-bar-negative", - "sha256": "117285f70baf2b44b96a56023a8b016b4c34ef0b0e44e0f12a23e3311cfbc0d9", - "bytes": 4403 + "sha256": "1d5424d59119d1c1ac939f38581389e0b494ec7aa34927998be1e3a9ceb16e11", + "bytes": 4245 }, { "id": "151-shadcn-bar-stacked", @@ -861,8 +861,8 @@ }, { "id": "181-shadcn-radar-label-custom", - "sha256": "a01a6cb688b465ba300b0b176bce46e2423c76d648358294f6caae6ebb5a199a", - "bytes": 6909 + "sha256": "7f4db6bb2a10fbf96037affa7f88f9558b43231aa166f3e60ec1d810d4547350", + "bytes": 6807 }, { "id": "182-shadcn-radar-legend", @@ -901,8 +901,8 @@ }, { "id": "189-shadcn-radial-stacked", - "sha256": "b280f74e86ffef5e6865462a9be2d51b4e35479223f49df6fc73c64b063b91dd", - "bytes": 2971 + "sha256": "15396b305fbec445a0de4ca90898d18657823e7152c58e4f5160bbec352c5766", + "bytes": 2964 }, { "id": "190-shadcn-tooltip-default", diff --git a/benchmarks/conformance/shared/shadcn-catalog-recharts.tsx b/benchmarks/conformance/shared/shadcn-catalog-recharts.tsx index 3b0ffb45..942dffb6 100644 --- a/benchmarks/conformance/shared/shadcn-catalog-recharts.tsx +++ b/benchmarks/conformance/shared/shadcn-catalog-recharts.tsx @@ -21,6 +21,7 @@ import { XAxis, YAxis, } from 'recharts' +import interactiveLineData from '@tanstack/charts-data/shadcn-area-interactive-data' import { getShadcnCatalogSpec, shadcnActivities, @@ -172,6 +173,54 @@ function areaChart(spec: ShadcnCatalogSpec, width: number, height: number) { } function barChart(spec: ShadcnCatalogSpec, width: number, height: number) { + if (spec.variant === 'active' || spec.variant === 'mixed') { + const active = spec.variant === 'active' + const data = shadcnBrowsers.map((row, index) => ({ + ...row, + visitors: + active && row.browser === 'chrome' + ? 187 + : active && row.browser === 'firefox' + ? 275 + : row.visitors, + fill: + active && row.browser === 'firefox' + ? `color-mix(in srgb, ${shadcnColors[2]} 80%, transparent)` + : shadcnColors[index], + })) + return ( + + {active ? : null} + + + + + + ) + } const horizontal = spec.variant === 'horizontal' || spec.variant === 'label-custom' || @@ -286,20 +335,22 @@ function lineChart(spec: ShadcnCatalogSpec, width: number, height: number) { : 'natural' const dots = spec.variant.includes('dots') const labels = spec.variant.includes('label') - const multiple = spec.variant === 'multiple' || spec.variant === 'interactive' + const multiple = spec.variant === 'multiple' return ( - aria-label={spec.title} width={width} height={height} - data={shadcnMonths} + data={spec.variant === 'interactive' ? interactiveLineData : shadcnMonths} > + + + + 1,830 + + + Visitors + + + ) + } const shape = spec.variant === 'shape' const centeredValue = shape || spec.variant === 'text' const data = centeredValue diff --git a/benchmarks/conformance/shared/shadcn-data-contracts.test.ts b/benchmarks/conformance/shared/shadcn-data-contracts.test.ts new file mode 100644 index 00000000..be31d7f5 --- /dev/null +++ b/benchmarks/conformance/shared/shadcn-data-contracts.test.ts @@ -0,0 +1,27 @@ +import { createChartRuntime } from '@tanstack/charts' +import { describe, expect, it } from 'vitest' +import { createExampleChart as stackedRadial } from '../cases/189-shadcn-radial-stacked/example' + +describe('pinned shadcn example data contracts', () => { + it('stacks 570 mobile and 1260 desktop visitors without losing desktop values', () => { + const scene = createChartRuntime().render(stackedRadial(), { + width: 250, + height: 250, + }) + const rows = scene.points + .map((point) => point.datum) + .filter( + (datum): datum is { id: string; start: number; end: number } => + typeof datum === 'object' && + datum !== null && + 'start' in datum && + 'end' in datum, + ) + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'mobile', start: 0, end: 570 }), + expect.objectContaining({ id: 'desktop', start: 570, end: 1830 }), + ]), + ) + }) +}) diff --git a/benchmarks/conformance/shared/shadcn-reference-accessibility.test.ts b/benchmarks/conformance/shared/shadcn-reference-accessibility.test.ts index 594a783c..b18c54ae 100644 --- a/benchmarks/conformance/shared/shadcn-reference-accessibility.test.ts +++ b/benchmarks/conformance/shared/shadcn-reference-accessibility.test.ts @@ -4,6 +4,39 @@ import { createShadcnRechartsExample } from './shadcn-catalog-recharts' import { getShadcnCatalogSpec, shadcnColors } from './shadcn-catalog-data' describe('shadcn reference accessibility', () => { + it.each([ + ['chart-bar-active', '.recharts-bar-rectangle path', 5], + ['chart-bar-mixed', '.recharts-bar-rectangle path', 5], + ['chart-line-interactive', '.recharts-line-curve', 1], + [ + 'chart-radial-stacked', + 'path.recharts-radial-bar-sector, .recharts-radial-bar-sector path', + 2, + ], + ])( + 'preserves the pinned datum count through resize and revision: %s', + async (name, selector, count) => { + const root = document.createElement('div') + document.body.append(root) + const handle = await act(async () => + createShadcnRechartsExample(name).mount(root, { + width: 640, + height: 600, + revision: 0, + }), + ) + try { + expect(root.querySelectorAll(selector)).toHaveLength(count) + await act(async () => + handle.update({ width: 320, height: 600, revision: 1 }), + ) + expect(root.querySelectorAll(selector)).toHaveLength(count) + } finally { + await act(async () => handle.destroy()) + root.remove() + } + }, + ) it.each([ ['chart-radial-text', '200'], ['chart-radial-shape', '1,260'], diff --git a/scripts/benchmark/stress-phases.mjs b/scripts/benchmark/stress-phases.mjs new file mode 100644 index 00000000..4cbc83cd --- /dev/null +++ b/scripts/benchmark/stress-phases.mjs @@ -0,0 +1,92 @@ +// Raw scatter has independent mount and update trials, not a continuous stream. +// Give each trial its own context and deadline, identically for every renderer. +export async function runStressTimingPhases(workload, run, expectedSamples) { + if (workload.id !== 'raw-scatter') return run(undefined) + const phases = [ + { name: 'mount', mount: true, updates: [] }, + ...workload.updates.map((kind) => ({ + name: `update:${kind}`, + mount: false, + updates: [kind], + })), + ] + const results = [] + for (const phase of phases) { + const startedAt = Date.now() + let result = await run(phase) + if ( + result.status === 'ok' && + (result.updates.map((update) => update.kind).join(',') !== + phase.updates.join(',') || + (results.length && result.digest !== results[0].result.digest) || + (expectedSamples !== undefined && + (phase.mount + ? result.mount.rawSamples.length !== expectedSamples + : result.updates.some( + (update) => update.timing.rawSamples.length !== expectedSamples, + )))) + ) { + result = { + ...result, + status: 'error', + error: + 'Incomplete timing samples, update kinds, or inconsistent source digest.', + } + } + results.push({ + phase: phase.name, + elapsedMs: Date.now() - startedAt, + result, + }) + if (result.status !== 'ok') { + return { + ...result, + error: `${phase.name}: ${result.error}`, + timingPhases: results, + } + } + } + const recoveries = results.flatMap(({ result }) => + result.recovery ? [result.recovery] : [], + ) + return { + ...results[0].result, + updates: results.flatMap(({ result }) => result.updates), + timingPhases: results.map(({ phase, elapsedMs, result }) => ({ + phase, + elapsedMs, + recovery: result.recovery, + })), + ...(recoveries.length + ? { + recovery: { + phase: 'timing', + attempts: + 1 + + recoveries.reduce( + (total, recovery) => total + recovery.attempts - 1, + 0, + ), + recovered: true, + errors: recoveries.flatMap((recovery) => recovery.errors), + }, + } + : {}), + longTasks: { + count: results.reduce( + (sum, { result }) => sum + result.longTasks.count, + 0, + ), + totalMs: results.reduce( + (sum, { result }) => sum + result.longTasks.totalMs, + 0, + ), + maximumMs: Math.max( + ...results.map(({ result }) => result.longTasks.maximumMs), + ), + entries: results.flatMap(({ phase, result }) => + result.longTasks.entries.map((entry) => ({ ...entry, phase })), + ), + }, + } +} diff --git a/scripts/benchmark/stress-phases.test.mjs b/scripts/benchmark/stress-phases.test.mjs new file mode 100644 index 00000000..e2e05dc3 --- /dev/null +++ b/scripts/benchmark/stress-phases.test.mjs @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import { runStressTimingPhases } from './stress-phases.mjs' + +const workload = { + id: 'raw-scatter', + updates: ['noop', 'same', 'append', 'replace', 'reorder', 'resize'], +} +function result(phase) { + return { + status: 'ok', + mount: { samples: [1, 2] }, + output: { items: 10000 }, + updates: phase.updates.map((kind) => ({ kind, samples: [3, 4] })), + longTasks: { + count: 1, + totalMs: 60, + maximumMs: 60, + entries: [{ duration: 60 }], + }, + } +} +describe('stress timing phase isolation', () => { + it('rejects omitted trials and shortened sample sets', async () => { + const omitted = await runStressTimingPhases(workload, async (phase) => ({ + ...result(phase), + updates: [], + })) + expect(omitted.status).toBe('error') + const short = await runStressTimingPhases( + workload, + async (phase) => ({ ...result(phase), mount: { rawSamples: [1] } }), + 20, + ) + expect(short.status).toBe('error') + }) + it('runs mount and every update once, retaining samples, output, and long tasks', async () => { + const run = vi.fn(async (phase) => result(phase)) + const combined = await runStressTimingPhases(workload, run) + expect(run.mock.calls.map(([phase]) => phase.name)).toEqual([ + 'mount', + ...workload.updates.map((kind) => `update:${kind}`), + ]) + expect(combined.updates).toEqual( + workload.updates.map((kind) => ({ kind, samples: [3, 4] })), + ) + expect(combined.mount.samples).toEqual([1, 2]) + expect(combined.output.items).toBe(10000) + expect(combined.longTasks.count).toBe(7) + expect(combined.recovery).toBeUndefined() + }) + it('does not mask a failed phase or continue after it', async () => { + const run = vi.fn(async (phase) => + phase.name === 'update:same' + ? { status: 'error', error: 'wrong item count' } + : result(phase), + ) + const combined = await runStressTimingPhases(workload, run) + expect(combined.status).toBe('error') + expect(combined.error).toBe('update:same: wrong item count') + expect(run).toHaveBeenCalledTimes(3) + }) + it('retains recovered retries from any phase', async () => { + const combined = await runStressTimingPhases(workload, async (phase) => ({ + ...result(phase), + ...(phase.name === 'update:append' + ? { recovery: { attempts: 2, errors: ['timeout'], recovered: true } } + : {}), + })) + expect(combined.recovery).toEqual({ + phase: 'timing', + attempts: 2, + errors: ['timeout'], + recovered: true, + }) + }) + it('leaves continuous and other workloads intact', async () => { + const run = vi.fn(async () => ({ status: 'ok' })) + expect( + await runStressTimingPhases({ id: 'rolling-keyed-window' }, run), + ).toEqual({ status: 'ok' }) + expect(run).toHaveBeenCalledExactlyOnceWith(undefined) + }) +}) diff --git a/scripts/compare-plot-catalog-helpers.mjs b/scripts/compare-plot-catalog-helpers.mjs index 14c3be4f..eef936c0 100644 --- a/scripts/compare-plot-catalog-helpers.mjs +++ b/scripts/compare-plot-catalog-helpers.mjs @@ -1,5 +1,17 @@ import { selectWeightedShard } from './benchmark/filters.mjs' +export function conformanceInspectionPasses(inspection) { + return Boolean( + inspection && + inspection.guidesContained && + inspection.accessibleName && + inspection.guideAssertions.every((assertion) => assertion.pass) && + Object.values(inspection.geometry).every( + (geometry) => geometry.present && geometry.withinMaximum, + ), + ) +} + export function conformanceCaseHeight(entry) { const height = entry.height === undefined ? 360 : entry.height if (!Number.isFinite(height) || height <= 0) { diff --git a/scripts/compare-plot-catalog-helpers.test.mjs b/scripts/compare-plot-catalog-helpers.test.mjs index 2afc4189..5a9c9a9a 100644 --- a/scripts/compare-plot-catalog-helpers.test.mjs +++ b/scripts/compare-plot-catalog-helpers.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { conformanceCaseHeight, + conformanceInspectionPasses, normalizeTypeDiagnosticPath, selectCatalogCases, } from './compare-plot-catalog-helpers.mjs' @@ -10,6 +11,25 @@ const { describe, test } = process.env.VITEST : await import('node:test') describe('catalog comparison helpers', () => { + test('first-party inspection retains containment, names, guides, and both count bounds', () => { + const good = { + guidesContained: true, + accessibleName: true, + guideAssertions: [{ pass: true }], + geometry: { bar: { present: true, withinMaximum: true } }, + } + assert.equal(conformanceInspectionPasses(good), true) + for (const bad of [ + undefined, + { ...good, guidesContained: false }, + { ...good, accessibleName: false }, + { ...good, guideAssertions: [{ pass: false }] }, + { ...good, geometry: { bar: { present: false, withinMaximum: true } } }, + { ...good, geometry: { bar: { present: true, withinMaximum: false } } }, + ]) { + assert.equal(conformanceInspectionPasses(bad), false) + } + }) test('uses authored case heights without changing the legacy default', () => { assert.equal(conformanceCaseHeight({}), 360) assert.equal(conformanceCaseHeight({ height: 600 }), 600) diff --git a/scripts/compare-plot-catalog.mjs b/scripts/compare-plot-catalog.mjs index 591919be..81bb82ce 100644 --- a/scripts/compare-plot-catalog.mjs +++ b/scripts/compare-plot-catalog.mjs @@ -20,6 +20,7 @@ import { estimateConformanceCaseWeight } from './benchmark/conformance-sharding. import { assertKnownFilterValues, parseShard } from './benchmark/filters.mjs' import { conformanceCaseHeight, + conformanceInspectionPasses, normalizeTypeDiagnosticPath, selectCatalogCases, } from './compare-plot-catalog-helpers.mjs' @@ -117,6 +118,9 @@ if (!profile) { ) } const sizeOnly = process.argv.includes('--size-only') +const firstParty = process.argv.includes('--first-party') +if (firstParty && sizeOnly) + throw new Error('--first-party cannot use --size-only.') const caseFilter = csvOption('--case') const shard = parseShard(optionValue('--shard')) @@ -138,9 +142,13 @@ const selectedCases = selectCatalogCases(allCases, caseFilter, shard, (entry) => estimateConformanceCaseWeight(entry, profile), ) -const typeDiagnostics = await createTypeDiagnostics() -const typeAudit = await auditTypes(selectedCases, typeDiagnostics.byFile) -const typeProtection = auditTypeProtection(typeDiagnostics) +const typeDiagnostics = firstParty ? undefined : await createTypeDiagnostics() +const typeAudit = firstParty + ? new Map() + : await auditTypes(selectedCases, typeDiagnostics.byFile) +const typeProtection = firstParty + ? undefined + : auditTypeProtection(typeDiagnostics) const bundles = await buildImplementations(selectedCases, typeAudit) let measurements = [] let visualChecks = [] @@ -156,7 +164,7 @@ if (!sizeOnly) { const implementations = bundles.filter( (bundle) => bundle.caseId === entry.id, ) - for (const implementation of implementations) { + for (const implementation of firstParty ? [] : implementations) { measurements.push( await measureImplementation( browser, @@ -198,6 +206,7 @@ if (!sizeOnly) { const result = { schemaVersion: 1, + mode: firstParty ? 'first-party' : 'comparison', createdAt: new Date().toISOString(), profile: profileName, filters: { @@ -220,8 +229,8 @@ const result = { typescript: ts.version, }, protocol: { - sameTypedRows: true, - sameSemanticDomains: true, + sameTypedRows: !firstParty, + sameSemanticDomains: !firstParty, isolatedBundles: true, width: 640, height: 360, @@ -231,8 +240,8 @@ const result = { variants: profile.widths.flatMap((width) => profile.themes.map((theme) => ({ width, theme })), ), - warmup: profile.warmup, - samples: profile.samples, + warmup: firstParty ? 0 : profile.warmup, + samples: firstParty ? 0 : profile.samples, mount: 'Synchronous mount plus forced layout after module loading; animations disabled.', update: @@ -245,6 +254,17 @@ const result = { 'Geometry, guide containment, and accessibility run before and after a data revision at every viewport/theme variant. A 640px light-mode side-by-side screenshot is retained per case.', interaction: 'Ordered semantic scenarios run from fresh mounts with native Playwright mouse, keyboard, CDP touch, drag, pixel-wheel streams, bounded waits, and in-place revision updates; pointer cancellation and line/page delta modes use explicit DOM events. Driver-state assertions remain renderer-independent; rendered assertions inspect root-scoped DOM text, attributes, focus, visibility, scroll metrics, and bounds directly. Scenarios may retain named checkpoint screenshots. Uncaught page errors fail the active step.', + ...(firstParty + ? { + mount: 'Not measured.', + update: 'Not measured.', + typeSafety: 'Not audited; covered by separate cached validation.', + geometry: + 'First-party logical geometry counts and guide assertions; no relative geometry or paint comparison.', + visual: + 'First-party geometry, containment, and accessible naming before and after revision at every viewport/theme. One first-party screenshot per case.', + } + : {}), }, cases: selectedCases, bundles, @@ -252,28 +272,36 @@ const result = { measurements, visualChecks, behaviorChecks, - summaries: createSummaries( - selectedCases, - bundles, - measurements, - visualChecks, - behaviorChecks, - typeProtection, - ), + summaries: firstParty + ? undefined + : createSummaries( + selectedCases, + bundles, + measurements, + visualChecks, + behaviorChecks, + typeProtection, + ), } const json = `${JSON.stringify(result, null, 2)}\n` -const markdown = renderMarkdown(result) -const artifactStem = conformanceArtifactStem(result.filters.cases) +const markdown = firstParty + ? `# First-party browser correctness\n\n${visualChecks.filter((check) => check.status === 'pass').length}/${visualChecks.length} layout cases passed; ${behaviorChecks.filter((check) => check.status === 'pass').length}/${behaviorChecks.length} interaction cases passed.\n\nNo comparative type, size, or timing measurements were run.\n` + : renderMarkdown(result) +const artifactStem = + conformanceArtifactStem(result.filters.cases) + + (firstParty ? '--first-party' : '') await Promise.all([ writeFile(resolve(resultDirectory, `${artifactStem}.json`), json), writeFile(resolve(resultDirectory, `${artifactStem}.md`), markdown), ]) console.log(markdown) -const failedVisuals = visualChecks.filter((check) => check.status === 'fail') -const failedBehaviors = behaviorChecks.filter( - (check) => check.status === 'fail', +const failedVisuals = visualChecks.filter((check) => + firstParty ? check.status !== 'pass' : check.status === 'fail', +) +const failedBehaviors = behaviorChecks.filter((check) => + firstParty ? check.status !== 'pass' : check.status === 'fail', ) if (failedVisuals.length || failedBehaviors.length) { throw new Error( @@ -1156,7 +1184,9 @@ function typeProtectionProbes() { async function buildImplementations(cases, typeAudit) { const candidates = cases.flatMap((entry) => - pairedRenderers(entry).map((renderer) => ({ entry, renderer })), + (firstParty ? [targetRenderer] : pairedRenderers(entry)).map( + (renderer) => ({ entry, renderer }), + ), ) const bundles = new Array(candidates.length) @@ -1189,6 +1219,10 @@ async function buildImplementations(cases, typeAudit) { legalComments: 'none', logLevel: 'silent', }) + if (firstParty) { + bundles[index] = { id, caseId: entry.id, renderer } + return + } const measurementResult = await build({ entryPoints: [sourcePath], outfile: outputPath, @@ -1408,7 +1442,7 @@ async function compareVisuals( const tanstack = implementations.find( (implementation) => implementation.renderer === targetRenderer, ) - if (!reference || !tanstack) { + if ((!firstParty && !reference) || !tanstack) { return { caseId: entry.id, referenceRenderer, @@ -1440,7 +1474,10 @@ async function compareVisuals( height, }) => { const [{ mount: mountReference }, { mount: mountTanstack }] = - await Promise.all([import(referenceUrl), import(tanstackUrl)]) + await Promise.all([ + referenceUrl ? import(referenceUrl) : {}, + import(tanstackUrl), + ]) await document.fonts?.ready const root = document.createElement('main') root.style.display = 'grid' @@ -1450,7 +1487,8 @@ async function compareVisuals( document.body.append(root) const referenceContainer = document.createElement('div') const tanstackContainer = document.createElement('div') - root.append(referenceContainer, tanstackContainer) + if (mountReference) root.append(referenceContainer) + root.append(tanstackContainer) let referenceHandle let tanstackHandle const results = [] @@ -1467,29 +1505,31 @@ async function compareVisuals( theme === 'dark' ? '#151a24' : '#ffffff' } - if (!referenceHandle) { - referenceHandle = mountReference(referenceContainer, input) + if (!tanstackHandle) { + referenceHandle = mountReference?.(referenceContainer, input) tanstackHandle = mountTanstack(tanstackContainer, input) } else { - referenceHandle.update(input) + referenceHandle?.update(input) tanstackHandle.update(input) } forceLayout(referenceContainer) forceLayout(tanstackContainer) await Promise.all([ - referenceHandle.driver?.settle?.(), + referenceHandle?.driver?.settle?.(), tanstackHandle.driver?.settle?.(), ]) forceLayout(referenceContainer) forceLayout(tanstackContainer) - const referenceInspection = inspect( - referenceContainer, - referenceHandle, - referenceRenderer, - geometry, - guideAssertions, - ) + const referenceInspection = referenceHandle + ? inspect( + referenceContainer, + referenceHandle, + referenceRenderer, + geometry, + guideAssertions, + ) + : undefined const tanstackInspection = inspect( tanstackContainer, tanstackHandle, @@ -1498,23 +1538,25 @@ async function compareVisuals( guideAssertions, ) const updatedInput = { ...input, revision: 1 } - referenceHandle.update(updatedInput) + referenceHandle?.update(updatedInput) tanstackHandle.update(updatedInput) forceLayout(referenceContainer) forceLayout(tanstackContainer) await Promise.all([ - referenceHandle.driver?.settle?.(), + referenceHandle?.driver?.settle?.(), tanstackHandle.driver?.settle?.(), ]) forceLayout(referenceContainer) forceLayout(tanstackContainer) - const updatedReferenceInspection = inspect( - referenceContainer, - referenceHandle, - referenceRenderer, - geometry, - guideAssertions, - ) + const updatedReferenceInspection = referenceHandle + ? inspect( + referenceContainer, + referenceHandle, + referenceRenderer, + geometry, + guideAssertions, + ) + : undefined const updatedTanstackInspection = inspect( tanstackContainer, tanstackHandle, @@ -1528,26 +1570,34 @@ async function compareVisuals( referenceRenderer, [referenceResultKey]: referenceInspection, tanstack: tanstackInspection, - geometrySimilarity: compareGeometry( - referenceInspection.geometry, - tanstackInspection.geometry, - ), - paintParity: comparePaints( - referenceInspection.geometry, - tanstackInspection.geometry, - ), + geometrySimilarity: + referenceInspection && + compareGeometry( + referenceInspection.geometry, + tanstackInspection.geometry, + ), + paintParity: + referenceInspection && + comparePaints( + referenceInspection.geometry, + tanstackInspection.geometry, + ), updated: { referenceRenderer, [referenceResultKey]: updatedReferenceInspection, tanstack: updatedTanstackInspection, - geometrySimilarity: compareGeometry( - updatedReferenceInspection.geometry, - updatedTanstackInspection.geometry, - ), - paintParity: comparePaints( - updatedReferenceInspection.geometry, - updatedTanstackInspection.geometry, - ), + geometrySimilarity: + updatedReferenceInspection && + compareGeometry( + updatedReferenceInspection.geometry, + updatedTanstackInspection.geometry, + ), + paintParity: + updatedReferenceInspection && + comparePaints( + updatedReferenceInspection.geometry, + updatedTanstackInspection.geometry, + ), }, }) } @@ -2345,7 +2395,9 @@ async function compareVisuals( } }, { - referenceUrl: `${serverUrl}bundles/${reference.id}.js`, + referenceUrl: reference + ? `${serverUrl}bundles/${reference.id}.js` + : null, referenceRenderer, referenceResultKey, tanstackUrl: `${serverUrl}bundles/${tanstack.id}.js`, @@ -2372,8 +2424,11 @@ async function compareVisuals( document.body.style.color = '#172033' document.body.style.background = '#f4f6fa' const [{ mount: mountReference }, { mount: mountTanstack }] = - await Promise.all([import(referenceUrl), import(tanstackUrl)]) - for (const mount of [mountReference, mountTanstack]) { + await Promise.all([ + referenceUrl ? import(referenceUrl) : {}, + import(tanstackUrl), + ]) + for (const mount of [mountReference, mountTanstack].filter(Boolean)) { const panel = document.createElement('div') panel.style.width = '640px' panel.style.minHeight = `${height}px` @@ -2384,7 +2439,9 @@ async function compareVisuals( await document.fonts?.ready }, { - referenceUrl: `${serverUrl}bundles/${reference.id}.js`, + referenceUrl: reference + ? `${serverUrl}bundles/${reference.id}.js` + : null, tanstackUrl: `${serverUrl}bundles/${tanstack.id}.js`, height: conformanceCaseHeight(entry), }, @@ -2397,18 +2454,20 @@ async function compareVisuals( return { caseId: entry.id, referenceRenderer, - minimumGeometrySimilarity: entry.minimumGeometrySimilarity, + minimumGeometrySimilarity: firstParty + ? undefined + : entry.minimumGeometrySimilarity, status: variants.every( (variant) => visualPairPasses( variant, referenceResultKey, - entry.minimumGeometrySimilarity, + firstParty ? undefined : entry.minimumGeometrySimilarity, ) && visualPairPasses( variant.updated, referenceResultKey, - entry.minimumGeometrySimilarity, + firstParty ? undefined : entry.minimumGeometrySimilarity, ), ) ? 'pass' @@ -2439,7 +2498,7 @@ async function compareBehaviors( const tanstack = implementations.find( (implementation) => implementation.renderer === targetRenderer, ) - if (!reference || !tanstack) { + if ((!firstParty && !reference) || !tanstack) { return { caseId: entry.id, referenceRenderer, @@ -2457,13 +2516,15 @@ async function compareBehaviors( theme, revision, referenceRenderer, - [referenceResultKey]: await runBehaviorImplementation( - browser, - serverUrl, - reference, - entry.interactionScenarios, - { width, height: conformanceCaseHeight(entry), theme, revision }, - ), + [referenceResultKey]: + reference && + (await runBehaviorImplementation( + browser, + serverUrl, + reference, + entry.interactionScenarios, + { width, height: conformanceCaseHeight(entry), theme, revision }, + )), tanstack: await runBehaviorImplementation( browser, serverUrl, @@ -2481,7 +2542,9 @@ async function compareBehaviors( caseId: entry.id, referenceRenderer, status: variants.every( - (variant) => variant[referenceResultKey].pass && variant.tanstack.pass, + (variant) => + (firstParty || variant[referenceResultKey].pass) && + variant.tanstack.pass, ) ? 'pass' : 'fail', @@ -3354,19 +3417,14 @@ function jsonValuesEqual(left, right) { function visualPairPasses(pair, referenceResultKey, minimumGeometrySimilarity) { return ( - pair.paintParity && + (firstParty || pair.paintParity) && (minimumGeometrySimilarity === undefined || (pair.geometrySimilarity !== undefined && pair.geometrySimilarity >= minimumGeometrySimilarity)) && - [pair[referenceResultKey], pair.tanstack].every( - (inspection) => - inspection.guidesContained && - inspection.accessibleName && - inspection.guideAssertions.every((assertion) => assertion.pass) && - Object.values(inspection.geometry).every( - (geometry) => geometry.present && geometry.withinMaximum, - ), - ) + (firstParty + ? [pair.tanstack] + : [pair[referenceResultKey], pair.tanstack] + ).every(conformanceInspectionPasses) ) } diff --git a/scripts/conformance-workflow.test.mjs b/scripts/conformance-workflow.test.mjs index 763c33f8..bf7f8fbb 100644 --- a/scripts/conformance-workflow.test.mjs +++ b/scripts/conformance-workflow.test.mjs @@ -30,19 +30,20 @@ describe('conformance monitoring workflow contract', () => { ) }) - test('rotates one nightly shard and runs all shards weekly', () => { + test('runs first-party correctness weekly and comparisons monthly, with no nightly audit', () => { assert.deepEqual( [...workflow.matchAll(/^\s+- cron:\s*'([^']+)'\s*$/gm)].map( (match) => match[1], ), - ['43 7 * * *', '13 9 * * 1'], + ['13 9 * * 1', '43 7 1 * *'], ) const select = job('select') - assert.match(select, /epoch_day=\$\(\( \$\(date -u \+%s\) \/ 86400 \)\)/) - assert.match(select, /shard=\$\(\( epoch_day % 8 \+ 1 \)\)/) - assert.match(select, /mode=nightly\s*\n\s+shards="\[\$shard\]"/) - assert.match(select, /mode=weekly\s*\n\s+shards='\[1,2,3,4,5,6,7,8\]'/) + assert.doesNotMatch(select, /epoch_day|mode=nightly/) + assert.match(select, /suite=first-party/) + assert.match(select, /EVENT_SCHEDULE" = '43 7 1 \* \*'/) + assert.match(select, /suite=comparison/) + assert.match(select, /mode=scheduled\s*\n\s+shards='\[1,2,3,4,5,6,7,8\]'/) assert.match(select, /mode:\s*\${{ steps\.selection\.outputs\.mode }}/) assert.match(select, /shards:\s*\${{ steps\.selection\.outputs\.shards }}/) }) @@ -82,10 +83,10 @@ describe('conformance monitoring workflow contract', () => { /shard:\s*\${{ fromJSON\(needs\.select\.outputs\.shards\) }}/, ) assert.match(conformance, /playwright:\s*['"]true['"]/) - assert.match( - conformance, - /pnpm conformance -- --shard=\${{ matrix\.shard }}\/8/, - ) + assert.match(conformance, /pnpm conformance -- "\$\{args\[@\]\}"/) + assert.match(conformance, /args\+=\(--first-party\)/) + assert.match(conformance, /args\+=\(--case="\$CASES"\)/) + assert.match(job('select'), /mode=targeted\s*\n\s+shards='\[1\]'/) assert.doesNotMatch(conformance, /conformance:quick|--profile=full/) assert.match( conformance, diff --git a/scripts/generate-shadcn-cases.mjs b/scripts/generate-shadcn-cases.mjs index 13a04e12..f5cf7707 100644 --- a/scripts/generate-shadcn-cases.mjs +++ b/scripts/generate-shadcn-cases.mjs @@ -310,13 +310,20 @@ function geometry(name, family) { if (family === 'bar') { return { role: 'bar', - count: variant === 'multiple' || variant === 'stacked' ? 12 : 6, + ...(['active', 'mixed'].includes(variant) ? { maxCount: 5 } : {}), + count: + variant === 'multiple' || variant === 'stacked' + ? 12 + : ['active', 'mixed'].includes(variant) + ? 5 + : 6, } } if (family === 'line') { return { role: 'line', - count: variant === 'multiple' || variant === 'interactive' ? 2 : 1, + ...(variant === 'interactive' ? { maxCount: 1 } : {}), + count: variant === 'multiple' ? 2 : 1, } } if (family === 'pie') return { role: 'arc', count: 5 } @@ -329,7 +336,12 @@ function geometry(name, family) { if (family === 'radial') { return { role: 'bar', - count: ['simple', 'text', 'shape'].includes(variant) ? 1 : 5, + ...(variant === 'stacked' ? { maxCount: 2 } : {}), + count: ['simple', 'text', 'shape'].includes(variant) + ? 1 + : variant === 'stacked' + ? 2 + : 5, rendererRoles: { recharts: 'arc', tanstack: 'arc' }, } } diff --git a/scripts/stress-chart-libraries.mjs b/scripts/stress-chart-libraries.mjs index 0901b04d..35be0ab4 100644 --- a/scripts/stress-chart-libraries.mjs +++ b/scripts/stress-chart-libraries.mjs @@ -9,6 +9,7 @@ import { startBenchmarkServer, } from './benchmark/browser.mjs' import { CellTimeoutError } from './benchmark/cell-timeout.mjs' +import { runStressTimingPhases } from './benchmark/stress-phases.mjs' import { createStressDiagnostics } from './benchmark/stress-diagnostics.mjs' import { installStressPointerTiming, @@ -140,20 +141,26 @@ try { ) if (!benchmarkCase) throw new Error(`Missing case for ${cell.id}.`) - const timing = await runIsolatedWithRetry( - browser, - 120_000, - (context, diagnostics) => - runTimingCell( - context, - server.url, - benchmarkCase, - cell.sourceCount, - profile, - diagnostics, + const timing = await runStressTimingPhases( + cell.workload, + (timingPhase) => + runIsolatedWithRetry( + browser, + 120_000, + (context, diagnostics) => + runTimingCell( + context, + server.url, + benchmarkCase, + cell.sourceCount, + profile, + diagnostics, + timingPhase, + ), + cell, + timingPhase?.name ?? 'timing', ), - cell, - 'timing', + profile.samples, ) let memory if ( @@ -234,6 +241,8 @@ const result = { retry: diagnosticMode ? 'No automatic retries in diagnostic mode.' : 'An outer timeout or browser-context infrastructure failure receives one immediate fresh-context retry. Renderer, page, protocol, and correctness failures are not retried; every attempted error remains explicit in the result and report.', + timingIsolation: + 'Raw scatter runs mount and each independent update trial in fresh contexts, each with the original 120-second deadline and unchanged samples. This applies to every renderer. Other workloads retain whole-cell isolation. Long-task entry timestamps are local to their recorded phase.', output: 'Adapter probes gate rendered dimensions, data items or path vertices, numeric endpoint visibility, and multi-series path, identity, and per-series vertex accounting.', ranking: @@ -407,6 +416,7 @@ async function runTimingCell( sourceCount, benchmarkProfile, diagnostics, + timingPhase, ) { const page = await context.newPage() if (diagnosticMode) @@ -429,6 +439,7 @@ async function runTimingCell( profile: currentProfile, profileName: selectedProfile, diagnosticsEnabled, + timingPhase, }) => { const phase = diagnosticsEnabled ? (name) => console.info(`__chartsStressPhase__:${name}`) @@ -513,7 +524,10 @@ async function runTimingCell( let output for ( let sampleIndex = 0; - sampleIndex < currentProfile.warmup + currentProfile.samples; + sampleIndex < + (timingPhase && !timingPhase.mount + ? 0 + : currentProfile.warmup + currentProfile.samples); sampleIndex++ ) { const root = createRoot(initial.input, instances) @@ -546,7 +560,7 @@ async function runTimingCell( const updates = [] const pointerStateInputs = new Map([['initial', initial.input]]) - for (const kind of workload.updates) { + for (const kind of timingPhase?.updates ?? workload.updates) { phase(`update:${kind}`) const target = kind === 'roll' @@ -1824,6 +1838,7 @@ async function runTimingCell( profile: benchmarkProfile, profileName, diagnosticsEnabled: diagnosticMode, + timingPhase, }, ) @@ -2549,6 +2564,7 @@ function renderMarkdown(result) { '- Grouped pointer probes gate exact focused x and per-series values before and after reorder, append, and visibility updates.', '- Rolling streams await every monotonic revision. Bursts enqueue synchronously, drain every returned operation, and reject stale final output.', '- Memory covers JavaScript heap and DOM counters only; GPU and native canvas allocations are outside this protocol.', + '- Raw scatter isolates mount and each independent update trial in fresh contexts for every renderer, retaining all samples and the 120-second deadline per phase. Other workloads keep whole-cell isolation.', '- Compare timing only within this run and browser build.', '', )