From 25f9035ecdbd470f5c4ec85db62e9258e5e0f854 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:05:39 +0800 Subject: [PATCH] ci: split remaining one-minute lanes --- .../2026-07-06-parallel-github-ci-gates.md | 6 +- .github/workflows/ci.yml | 56 ++++++++++++++----- examples/acp-agent/tests/acp.snapshot.ts | 4 ++ .../tests/snapshot-scenario-shard.spec.ts | 20 +++++++ .../tests/snapshot-scenario-shard.ts | 18 ++++++ packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/index.ts | 1 + .../acp-snapshot/src/scenario-shard.ts | 36 ++++++++++++ packages/support/acp-snapshot/src/suite.ts | 14 ++++- .../acp-snapshot/tests/scenario-shard.spec.ts | 25 +++++++++ .../support/acp-snapshot/tests/suite.spec.ts | 22 ++++++++ scripts/run-gates.ts | 20 ++++--- scripts/snapshot-shards.spec.ts | 45 +++++++++++++++ scripts/snapshot-shards.ts | 54 ++++++++++++++++++ scripts/static-shards.ts | 3 +- 15 files changed, 297 insertions(+), 29 deletions(-) create mode 100644 examples/acp-agent/tests/snapshot-scenario-shard.spec.ts create mode 100644 examples/acp-agent/tests/snapshot-scenario-shard.ts create mode 100644 packages/support/acp-snapshot/src/scenario-shard.ts create mode 100644 packages/support/acp-snapshot/tests/scenario-shard.spec.ts create mode 100644 scripts/snapshot-shards.spec.ts create mode 100644 scripts/snapshot-shards.ts diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index a76c425da7..a77493d8a9 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,15 +14,15 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) bounds every non-Windows job to one minute and every Windows job to three minutes. The timeout is an executable regression ceiling; the lane design leaves headroom below it rather than treating a timeout as normal control flow. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, and documentation-site lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. -Snapshot replay is four Vitest file shards. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay files. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. +Snapshot replay uses three explicit multi-file lanes and four scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. Artifacts use three lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus two Vitest shards for built-bin smoke. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) calls publint's supported API in-process against an in-memory publication view made from each manifest's declared files and npm's mandatory metadata files. This preserves the distinction between workspace files and published files without spawning a package-manager pack command 103 times. [scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) stages those structurally validated manifest-declared `lib/` files below the real package, then imports the compiled self-reference through plain Node and Cordis Loader normalization. A companion that reaches an undeclared runtime chunk still fails. -Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once on the primary Node 24 lane; repeating the same compiler analysis on Node 22 and 26 added time without runtime-specific signal. +Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ae8eb35de..d78cb52af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,10 @@ jobs: DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} DSH_COVERAGE_SHARD: ${{ matrix.coverage_shard }} DSH_LINT_SHARD: ${{ matrix.lint_shard }} + DSH_SNAPSHOT_LANE: ${{ matrix.snapshot_lane }} DSH_STATIC_SHARD: ${{ matrix.static_shard }} DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.snapshot_max_concurrency }} DSH_SNAPSHOT_PREBUILT: ${{ matrix.snapshot_prebuilt }} - DSH_SNAPSHOT_SHARD: ${{ matrix.snapshot_shard }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: fail-fast: false @@ -52,10 +52,16 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: prose - - lane: static-site + - lane: static-site-projection command: pnpm run check:ci:static gate_concurrency: '1' - static_shard: site + static_shard: site-projection + - lane: static-site-build + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: site-build + - lane: typecheck + command: pnpm run typecheck - lane: lint-package-sources command: pnpm run check:ci:lint gate_concurrency: '1' @@ -151,30 +157,48 @@ jobs: gate_concurrency: '1' coverage_max_workers: '4' coverage_shard: capabilities - - lane: snapshot-1 + - lane: snapshot-support command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_lane: support snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - snapshot_shard: '1/4' - - lane: snapshot-2 + - lane: snapshot-demos command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_lane: demos snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - snapshot_shard: '2/4' - - lane: snapshot-3 + - lane: snapshot-agents command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_lane: agents snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - snapshot_shard: '3/4' - - lane: snapshot-4 + - lane: snapshot-acp-1 command: pnpm run check:ci:snapshot gate_concurrency: '1' + snapshot_lane: acp-1 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-acp-2 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-2 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-acp-3 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-3 + snapshot_max_concurrency: '5' + snapshot_prebuilt: '1' + - lane: snapshot-acp-4 + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + snapshot_lane: acp-4 snapshot_max_concurrency: '5' snapshot_prebuilt: '1' - snapshot_shard: '4/4' - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' @@ -261,7 +285,7 @@ jobs: - node: '22.19' skip_typecheck: '1' - node: 24 - skip_typecheck: '' + skip_typecheck: '1' - node: 26 skip_typecheck: '1' steps: @@ -368,10 +392,14 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: prose - - lane: static-site + - lane: static-site-projection command: pnpm run check:ci:static gate_concurrency: '1' - static_shard: site + static_shard: site-projection + - lane: static-site-build + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: site-build - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index a3d939c874..485811c42f 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' +import { snapshotScenarioShardFromEnv } from './snapshot-scenario-shard.ts' /** * The acp-agent example's snapshot suite: the scenario table for @@ -191,9 +192,12 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, ] +const scenarioShard = snapshotScenarioShardFromEnv(process.env.DSH_SNAPSHOT_SCENARIO_SHARD) + defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'), scenarios: SCENARIOS, mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT), + ...scenarioShard === undefined ? {} : { scenarioShard }, }) diff --git a/examples/acp-agent/tests/snapshot-scenario-shard.spec.ts b/examples/acp-agent/tests/snapshot-scenario-shard.spec.ts new file mode 100644 index 0000000000..5a70cfe3b1 --- /dev/null +++ b/examples/acp-agent/tests/snapshot-scenario-shard.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { snapshotScenarioShardFromEnv } from './snapshot-scenario-shard.ts' + +describe('ACP snapshot scenario shard environment', () => { + it('keeps ordinary snapshot runs complete', () => { + expect(snapshotScenarioShardFromEnv()).toBeUndefined() + expect(snapshotScenarioShardFromEnv('')).toBeUndefined() + }) + + it('parses a valid one-based shard', () => { + expect(snapshotScenarioShardFromEnv('2/4')).toEqual({ index: 2, total: 4 }) + }) + + it.each(['0/1', '1/0', '2/1', '1.5/2', 'missing', '999999999999999999999/999999999999999999999'])( + 'rejects %s', + (value) => { + expect(() => snapshotScenarioShardFromEnv(value)).toThrow('DSH_SNAPSHOT_SCENARIO_SHARD') + }, + ) +}) diff --git a/examples/acp-agent/tests/snapshot-scenario-shard.ts b/examples/acp-agent/tests/snapshot-scenario-shard.ts new file mode 100644 index 0000000000..4e2763bdb6 --- /dev/null +++ b/examples/acp-agent/tests/snapshot-scenario-shard.ts @@ -0,0 +1,18 @@ +import type { SnapshotScenarioShard } from '@deepseek-ai/dsh-acp-snapshot' + +/** + * Parse the optional CI scenario shard passed to this snapshot suite. + * + * @param value An `INDEX/TOTAL` string or an unset value. + * @returns A validated one-based shard, or undefined for the complete suite. + */ +export function snapshotScenarioShardFromEnv(value?: string): SnapshotScenarioShard | undefined { + if (value === undefined || value === '') return undefined + const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(value) + if (match === null) throw new Error(`DSH_SNAPSHOT_SCENARIO_SHARD must be INDEX/TOTAL, got ${JSON.stringify(value)}`) + const shard = { index: Number(match[1]), total: Number(match[2]) } + if (!Number.isSafeInteger(shard.index) || !Number.isSafeInteger(shard.total) || shard.index > shard.total) { + throw new Error(`DSH_SNAPSHOT_SCENARIO_SHARD is out of range: ${JSON.stringify(value)}`) + } + return shard +} diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index b109ca3afa..2766240135 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -7,7 +7,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the expected-output and purity checks, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Replay may partition subprocess-backed scenarios with `scenarioShard`; every lane still runs fixture guards against the complete table, while record and refresh reject sharding because they write fixtures. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 4d99cc96a2..6dd87b7cab 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -44,3 +44,4 @@ export { type Scenario, type SnapshotSuiteOptions, } from './suite.ts' +export type { SnapshotScenarioShard } from './scenario-shard.ts' diff --git a/packages/support/acp-snapshot/src/scenario-shard.ts b/packages/support/acp-snapshot/src/scenario-shard.ts new file mode 100644 index 0000000000..67b2492b80 --- /dev/null +++ b/packages/support/acp-snapshot/src/scenario-shard.ts @@ -0,0 +1,36 @@ +/** Scenario-level sharding for one ACP snapshot suite. */ + +/** A one-based, exhaustive partition of a scenario table. */ +export interface SnapshotScenarioShard { + /** One-based lane index. */ + index: number + /** Total number of lanes. */ + total: number +} + +/** + * Select one stable modulo partition while rejecting empty or malformed lanes. + * + * @param scenarios Complete ordered scenario table. + * @param shard Optional one-based shard description. + * @returns The complete table or the selected non-empty partition. + */ +export function selectSnapshotScenarios( + scenarios: readonly T[], + shard?: SnapshotScenarioShard, +): T[] { + if (shard === undefined) return [...scenarios] + if (!Number.isSafeInteger(shard.index) || shard.index < 1) { + throw new Error(`acp-snapshot: shard index must be a positive integer, got ${shard.index}`) + } + if (!Number.isSafeInteger(shard.total) || shard.total < 1) { + throw new Error(`acp-snapshot: shard total must be a positive integer, got ${shard.total}`) + } + if (shard.index > shard.total) { + throw new Error(`acp-snapshot: shard index ${shard.index} exceeds total ${shard.total}`) + } + if (shard.total > scenarios.length) { + throw new Error(`acp-snapshot: ${shard.total} shards exceed ${scenarios.length} scenarios`) + } + return scenarios.filter((_, offset) => offset % shard.total === shard.index - 1) +} diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 60f5cfeadb..1c2d16b630 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -28,6 +28,7 @@ import { scrubSystemPrompts, scrubToolSchemas, } from './normalize.ts' +import { selectSnapshotScenarios, type SnapshotScenarioShard } from './scenario-shard.ts' /** The readable system-prompt snapshot beside each header-pinning fixture. */ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md' @@ -110,6 +111,11 @@ export interface SnapshotSuiteOptions { snapshotsDir: string /** The scenario table; exactly one entry per header class must set `pinsHeader`. */ scenarios: Scenario[] + /** + * Optional replay-only scenario partition. Fixture guards still validate the + * complete table in every lane; only subprocess-backed scenario tests split. + */ + scenarioShard?: SnapshotScenarioShard /** * `replay` (keyless, the default tier), `record` (live API; re-records the * `recorded` scenarios' fixtures and refreshes the Vitest expected outputs under @@ -439,7 +445,11 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement * @param options The agent, snapshots directory, scenario table, and mode. */ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { - const { agent, snapshotsDir, scenarios, mode } = options + const { agent, snapshotsDir, scenarios, mode, scenarioShard } = options + if (scenarioShard !== undefined && mode !== 'replay') { + throw new Error('acp-snapshot: scenario sharding is supported only in replay mode') + } + const selectedScenarios = selectSnapshotScenarios(scenarios, scenarioShard) const RECORDING = mode === 'record' const REFRESHING = mode === 'refresh' const childMode: 'replay' | 'record' = RECORDING ? 'record' : 'replay' @@ -464,7 +474,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } scenarioSuite('snapshot scenarios', () => { - for (const scenario of scenarios) { + for (const scenario of selectedScenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones // (sidecar-driven errors/cancel) are never re-recorded. it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { diff --git a/packages/support/acp-snapshot/tests/scenario-shard.spec.ts b/packages/support/acp-snapshot/tests/scenario-shard.spec.ts new file mode 100644 index 0000000000..eb5c117153 --- /dev/null +++ b/packages/support/acp-snapshot/tests/scenario-shard.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { selectSnapshotScenarios } from '../src/scenario-shard.ts' + +describe('ACP snapshot scenario shards', () => { + it('keeps the ordinary suite complete', () => { + expect(selectSnapshotScenarios(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']) + }) + + it('partitions the ordered table without gaps or overlap', () => { + const scenarios = ['a', 'b', 'c', 'd', 'e'] + expect(selectSnapshotScenarios(scenarios, { index: 1, total: 2 })).toEqual(['a', 'c', 'e']) + expect(selectSnapshotScenarios(scenarios, { index: 2, total: 2 })).toEqual(['b', 'd']) + }) + + it.each([ + [{ index: 0, total: 1 }, 'index must be a positive integer'], + [{ index: 1.5, total: 2 }, 'index must be a positive integer'], + [{ index: 1, total: 0 }, 'total must be a positive integer'], + [{ index: 1, total: Number.NaN }, 'total must be a positive integer'], + [{ index: 3, total: 2 }, 'exceeds total'], + [{ index: 1, total: 4 }, 'exceed 3 scenarios'], + ] as const)('rejects malformed shard %#', (shard, message) => { + expect(() => selectSnapshotScenarios(['a', 'b', 'c'], shard)).toThrow(message) + }) +}) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 6fbb06bb9d..33fff5d2bc 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -105,6 +105,16 @@ describe('defineAcpSnapshotSuite: replay mode', () => { defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: REPLAY_DIR, scenarios: REPLAY_SCENARIOS, mode: 'replay' }) }) +describe('defineAcpSnapshotSuite: sharded replay mode', () => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: REPLAY_SCENARIOS, + mode: 'replay', + scenarioShard: { index: 2, total: 2 }, + }) +}) + // The record suite's tests run in registration order: rec-pin re-records the // pinned fixture FIRST, so rec-child's uniformity guard reads the fresh pin. describe('defineAcpSnapshotSuite: record mode', () => { @@ -154,6 +164,18 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => { }) describe('defineAcpSnapshotSuite: registration contract', () => { + it('rejects scenario sharding in a fixture-writing mode', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: REPLAY_SCENARIOS, + mode: 'refresh', + scenarioShard: { index: 1, total: 2 }, + }) + }).toThrow('supported only in replay mode') + }) + it("throws when a scenario's header class has no pinning scenario", () => { expect(() => { defineAcpSnapshotSuite({ diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 262fac886f..e45a254905 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -10,6 +10,7 @@ import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { coverageArgs } from './coverage-shards.ts' import { selectLintShard } from './lint-shards.ts' +import { selectSnapshotLane } from './snapshot-shards.ts' import { selectStaticGates } from './static-shards.ts' type Mode = @@ -307,19 +308,19 @@ function coverageGate(): Gate { // plugins via real exports) — CI and pre-push already build, so they exercise what ships rather // than the tsx/source path dev uses. It therefore waits on `build`. function snapshotGate(needs: string[] = ['build']): Gate { - const shard = process.env.DSH_SNAPSHOT_SHARD - if (shard !== undefined && shard !== '' && !/^\d+\/\d+$/.test(shard)) { - throw new Error(`run-gates: DSH_SNAPSHOT_SHARD must be INDEX/TOTAL, got ${JSON.stringify(shard)}.`) - } + const lane = selectSnapshotLane(process.env.DSH_SNAPSHOT_LANE) return pnpmExec('snapshot', [ 'vitest', 'run', '--config', 'vitest.snapshot.config.ts', - ...(shard === undefined || shard === '' ? [] : [`--shard=${shard}`]), + ...lane.files, ], { label: 'test:snapshot', - env: { DSH_EXAMPLE_MODE: 'lib' }, + env: { + DSH_EXAMPLE_MODE: 'lib', + ...lane.scenarioShard === undefined ? {} : { DSH_SNAPSHOT_SCENARIO_SHARD: lane.scenarioShard }, + }, ...needs.length === 0 ? {} : { needs }, }) } @@ -392,8 +393,11 @@ function docSyncLeafGates(options: { pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), - // Keep the VitePress build in this single gate because projection rewrites website/.generated. - pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }), + pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts'], { + label: 'documentation projection', + }), + // Keep the VitePress build itself in one gate because projection rewrites website/.generated. + pnpmScript('docs-site-build', 'docs:build', { label: 'documentation build' }), pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }), ] } diff --git a/scripts/snapshot-shards.spec.ts b/scripts/snapshot-shards.spec.ts new file mode 100644 index 0000000000..dc4bef62ad --- /dev/null +++ b/scripts/snapshot-shards.spec.ts @@ -0,0 +1,45 @@ +import { existsSync, readdirSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' +import { selectSnapshotLane, snapshotLanes } from './snapshot-shards.ts' + +const root = join(import.meta.dirname, '..') + +function snapshotFiles(dir: string): string[] { + if (!existsSync(dir)) return [] + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return snapshotFiles(path) + return entry.name.endsWith('.snapshot.ts') ? [relative(root, path).split(sep).join('/')] : [] + }) +} + +describe('snapshot lanes', () => { + it('assigns every configured snapshot file and every ACP scenario shard', () => { + const discovered = [ + ...snapshotFiles(join(root, 'examples')), + ...snapshotFiles(join(root, 'packages/sdk')), + ...snapshotFiles(join(root, 'packages/ui/tui')), + ].filter(path => !path.includes('/node_modules/') && !path.includes('/lib/')).sort() + const ordinary = snapshotLanes.filter(lane => lane.scenarioShard === undefined).flatMap(lane => lane.files) + const acp = snapshotLanes.filter(lane => lane.scenarioShard !== undefined) + + expect(new Set(ordinary).size).toBe(ordinary.length) + expect(acp.map(lane => lane.files)).toEqual(Array.from( + { length: 4 }, + () => ['examples/acp-agent/tests/acp.snapshot.ts'], + )) + expect(acp.map(lane => lane.scenarioShard)).toEqual(['1/4', '2/4', '3/4', '4/4']) + expect([...ordinary, 'examples/acp-agent/tests/acp.snapshot.ts'].sort()).toEqual(discovered) + }) + + it('keeps ordinary runs complete and selects known lanes', () => { + expect(selectSnapshotLane()).toEqual({ name: 'complete', files: [] }) + expect(selectSnapshotLane('')).toEqual({ name: 'complete', files: [] }) + for (const lane of snapshotLanes) expect(selectSnapshotLane(lane.name)).toBe(lane) + }) + + it('rejects an unknown lane', () => { + expect(() => selectSnapshotLane('missing')).toThrow('unknown DSH_SNAPSHOT_LANE') + }) +}) diff --git a/scripts/snapshot-shards.ts b/scripts/snapshot-shards.ts new file mode 100644 index 0000000000..a6126f23c2 --- /dev/null +++ b/scripts/snapshot-shards.ts @@ -0,0 +1,54 @@ +/** Snapshot-lane definitions for GitHub Actions. */ + +/** One explicit snapshot file lane, optionally split again by ACP scenarios. */ +export interface SnapshotLane { + /** Stable lane name passed through `DSH_SNAPSHOT_LANE`. */ + name: string + /** Snapshot test files owned by the lane. */ + files: readonly string[] + /** Optional one-based ACP scenario partition. */ + scenarioShard?: string +} + +/** Exhaustive file ownership plus scenario partitions for the large ACP suite. */ +export const snapshotLanes: readonly SnapshotLane[] = [ + { + name: 'support', + files: [ + 'packages/sdk/scripts/tests/config.snapshot.ts', + 'packages/ui/tui/tests/tui.snapshot.ts', + ], + }, + { + name: 'demos', + files: [ + 'examples/tui-agent/tests/tui.snapshot.ts', + 'packages/sdk/create-sdk/tests/create.snapshot.ts', + ], + }, + { + name: 'agents', + files: [ + 'examples/acp-agent/tests/goal.snapshot.ts', + 'examples/headless-agent/tests/headless.snapshot.ts', + ], + }, + ...Array.from({ length: 4 }, (_, offset) => ({ + name: `acp-${offset + 1}`, + files: ['examples/acp-agent/tests/acp.snapshot.ts'], + scenarioShard: `${offset + 1}/4`, + })), +] + +/** + * Resolve one CI lane while preserving a complete ordinary snapshot run. + * + * @param name Optional stable lane name. + * @returns An empty file list for the full suite, or one explicit CI lane. + */ +export function selectSnapshotLane(name?: string): SnapshotLane { + if (name === undefined || name === '') return { name: 'complete', files: [] } + const lane = snapshotLanes.find(candidate => candidate.name === name) + if (lane === undefined) throw new Error(`run-gates: unknown DSH_SNAPSHOT_LANE ${JSON.stringify(name)}.`) + return lane +} diff --git a/scripts/static-shards.ts b/scripts/static-shards.ts index 7376aa44d7..4133470d87 100644 --- a/scripts/static-shards.ts +++ b/scripts/static-shards.ts @@ -46,7 +46,8 @@ export const staticShards = [ 'package-readme-limitations', ], }, - { name: 'site', gateIds: ['docs-site'] }, + { name: 'site-projection', gateIds: ['docs-site-projection'] }, + { name: 'site-build', gateIds: ['docs-site-build'] }, ] as const satisfies readonly StaticShard[] /**