From 5bbc8ae8d19d1040da062018f5f495f6296cbb8d Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:49:02 +0000 Subject: [PATCH 1/6] test(ci): balance test shards by recorded duration --- sdk/typescript/TESTING.md | 21 +- sdk/typescript/scripts/run-ci-tests.mts | 17 +- sdk/typescript/scripts/test-shards.mts | 34 + sdk/typescript/scripts/test-timings.json | 1072 +++++++++++++++++ .../scripts/update-test-timings.mts | 86 ++ sdk/typescript/tests-ts/test-shards.test.ts | 72 +- sdk/typescript/tests-ts/test-timings.test.ts | 72 ++ sdk/typescript/tsconfig.json | 3 +- 8 files changed, 1354 insertions(+), 23 deletions(-) create mode 100644 sdk/typescript/scripts/test-shards.mts create mode 100644 sdk/typescript/scripts/test-timings.json create mode 100644 sdk/typescript/scripts/update-test-timings.mts create mode 100644 sdk/typescript/tests-ts/test-timings.test.ts diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 44566ac034..1c3ccdf515 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -147,13 +147,26 @@ package checks instead of repeating the same Bun suite. MCP and Python tests run in separate required jobs. Python uses four isolated pytest-xdist workers with work stealing; worker crashes fail the run without automatic restarts. -`scripts/run-ci-tests.mts` discovers every shared test file and passes the -inventory to Bun's native `--shard` option. Bun sorts the file paths and assigns -them round-robin across the shards. New files are included automatically; -there is no timing table to update when adding or splitting tests. +`scripts/run-ci-tests.mts` discovers every shared test file and balances whole +files using the platform's recorded durations in `scripts/test-timings.json`. +It assigns longer files first to the shard with the least estimated work, then +passes each shard's files directly to Bun. Every job uses the same checked-in +snapshot. New files are included automatically with the mean known-file duration; +deleted files in the timing snapshot are ignored. Timing hints never select or +exclude tests, and adding or splitting tests does not require updating them. To reproduce one Windows shard locally after building the plugin, run `node --experimental-strip-types scripts/run-ci-tests.mts 3/7 --seed=12345`. +Refresh timing hints from successful CI JUnit artifacts when shard runtimes +become uneven. From the SDK directory, pass reports for one platform to +`node --experimental-strip-types scripts/update-test-timings.mts win32 ...` +(use `linux` or `darwin` for the other platforms). The updater sums testcase +durations per file and uses their median across reports, preserving the other +platforms. Review and commit the generated snapshot. The initial hints use runs +`39521539826`, `39694506447`, and `39694510011`. Keep runner versions, coverage, +timeouts, seeds, and platform-specific checks unchanged when comparing scheduling +changes; compare complete JUnit inventories and outcomes as well as wall time. + Every Bun lane uploads JUnit; Linux lanes also upload LCOV per shard. Python reports include case durations, and the MCP runner can upload its JUnit report. Coverage is diagnostic and split across shards, not a combined percentage. diff --git a/sdk/typescript/scripts/run-ci-tests.mts b/sdk/typescript/scripts/run-ci-tests.mts index 73ff9a7068..dba9b5bbb7 100644 --- a/sdk/typescript/scripts/run-ci-tests.mts +++ b/sdk/typescript/scripts/run-ci-tests.mts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; -import { mkdir, readdir } from "node:fs/promises"; +import { mkdir, readFile, readdir } from "node:fs/promises"; import { parseArgs } from "node:util"; +import { partitionTests, type TestTimings } from "./test-shards.mts"; const selection = /^([1-9]\d*)\/([1-9]\d*)$/.exec(process.argv[2] ?? ""); const shard = Number(selection?.[1]); @@ -16,9 +17,14 @@ const tests = (await readdir(new URL("../tests-ts/", import.meta.url))) file.endsWith(".test.ts") && file !== "windows-machine-policy.test.ts", ) .sort(); -// Match Bun 1.3.14's sorted round-robin assignment for startup diagnostics. -// https://github.com/oven-sh/bun/blob/bun-v1.3.14/src/cli/test_command.zig -const selected = tests.filter((_, index) => index % count === shard - 1); +// The checked-in snapshot keeps every job in this run on the same assignment. +// Discovery remains authoritative, including files without timing history. +const timings = JSON.parse( + await readFile(new URL("./test-timings.json", import.meta.url), "utf8"), +) as TestTimings; +const selected = partitionTests(tests, count, timings, process.platform)[ + shard - 1 +]!; if (selected.length === 0) { throw new Error(`Test shard ${shard}/${count} is empty.`); } @@ -32,13 +38,12 @@ await mkdir(new URL("../reports/", import.meta.url), { recursive: true }).catch( ); const testArguments = [ "test", - `--shard=${shard}/${count}`, "--timeout", defaultTestTimeoutMs, "--reporter=junit", `--reporter-outfile=reports/junit-${shard}.xml`, ...process.argv.slice(3), - ...tests.map((file) => `./tests-ts/${file}`), + ...selected.map((file) => `./tests-ts/${file}`), ]; // Keep arities aligned with the pinned Bun's test_params: // https://github.com/oven-sh/bun/blob/bun-v1.3.14/src/cli/Arguments.zig diff --git a/sdk/typescript/scripts/test-shards.mts b/sdk/typescript/scripts/test-shards.mts new file mode 100644 index 0000000000..9628e28e5c --- /dev/null +++ b/sdk/typescript/scripts/test-shards.mts @@ -0,0 +1,34 @@ +export type TestTimings = Record< + string, + Partial> +>; + +export function partitionTests( + files: readonly string[], + count: number, + timings: TestTimings, + platform: NodeJS.Platform, +): string[][] { + const known = files.flatMap((file) => { + const seconds = timings[file]?.[platform]; + return seconds === undefined ? [] : [seconds]; + }); + const fallback = known.length + ? known.reduce((total, seconds) => total + seconds, 0) / known.length + : 1; + const duration = (file: string) => timings[file]?.[platform] ?? fallback; + const shards = Array.from({ length: count }, () => ({ + files: [] as string[], + seconds: 0, + })); + for (const file of [...files] + .sort() + .sort((left, right) => duration(right) - duration(left))) { + const shard = shards.reduce((least, candidate) => + candidate.seconds < least.seconds ? candidate : least, + ); + shard.files.push(file); + shard.seconds += duration(file); + } + return shards.map((shard) => shard.files.sort()); +} diff --git a/sdk/typescript/scripts/test-timings.json b/sdk/typescript/scripts/test-timings.json new file mode 100644 index 0000000000..1c280766e8 --- /dev/null +++ b/sdk/typescript/scripts/test-timings.json @@ -0,0 +1,1072 @@ +{ + "action-release-workflow.test.ts": { + "linux": 2.9, + "darwin": 10.7, + "win32": 53.4 + }, + "api-attribution-concurrency.test.ts": { + "linux": 1.9, + "darwin": 5.5, + "win32": 134.7 + }, + "api-audit-admission.test.ts": { + "linux": 0.2, + "darwin": 0.2, + "win32": 0.6 + }, + "api-credentials.test.ts": { + "linux": 2.0, + "darwin": 4.5, + "win32": 142.5 + }, + "api-cyber-access.test.ts": { + "linux": 0.1, + "darwin": 0.3, + "win32": 1.7 + }, + "api-deep-scan-advisory-routing.test.ts": { + "linux": 0.2, + "darwin": 0.1, + "win32": 4.4 + }, + "api-deep-scan-runtime-routing.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.6 + }, + "api-direct-deep-scan.test.ts": { + "linux": 0.5, + "darwin": 1.7, + "win32": 12.6 + }, + "api-environment.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "api-events.test.ts": { + "linux": 6.3, + "darwin": 11.2, + "win32": 17.9 + }, + "api-integration.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "api-login.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 2.7 + }, + "api-operation-registration.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "api-patch.test.ts": { + "linux": 0.2, + "darwin": 0.4, + "win32": 6.7 + }, + "api-policy.test.ts": { + "linux": 25.3, + "darwin": 26.8, + "win32": 191.6 + }, + "api-post-scan.test.ts": { + "linux": 10.2, + "darwin": 12.8, + "win32": 25.5 + }, + "api-preflight-config.test.ts": { + "linux": 0.1, + "darwin": 0.2, + "win32": 0.9 + }, + "api-provider-isolation.test.ts": { + "linux": 3.6, + "darwin": 5.4, + "win32": 150.2 + }, + "api-runtime-storage.test.ts": { + "linux": 0.1, + "darwin": 0.3, + "win32": 62.8 + }, + "api-time-budget.test.ts": { + "linux": 0.5, + "darwin": 0.7, + "win32": 2.1 + }, + "api-usage-finalization.test.ts": { + "linux": 0.4, + "darwin": 0.4, + "win32": 1.9 + }, + "api-usage.test.ts": { + "linux": 0.7, + "darwin": 1.1, + "win32": 2.1 + }, + "api.test.ts": { + "linux": 62.1, + "darwin": 116.1, + "win32": 445.9 + }, + "artifact-export.test.ts": { + "linux": 3.5, + "darwin": 3.5, + "win32": 2.5 + }, + "attack-path-tool-name.test.ts": { + "linux": 0.1, + "darwin": 0.2, + "win32": 0.2 + }, + "auth-status-concurrency.test.ts": { + "linux": 1.5, + "darwin": 2.8, + "win32": 62.8 + }, + "auth.test.ts": { + "linux": 1.9, + "darwin": 2.6, + "win32": 3.7 + }, + "azure-pipelines-example.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.7 + }, + "bind-repo-scopes.test.ts": { + "linux": 3.0, + "darwin": 4.5, + "win32": 9.5 + }, + "build-plugin.test.ts": { + "linux": 10.6, + "darwin": 8.7, + "win32": 15.9 + }, + "bulk-scan-discovery.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "candidate-normalizer.test.ts": { + "linux": 11.3, + "darwin": 19.4, + "win32": 17.3 + }, + "check-markdown.test.ts": { + "linux": 2.3, + "darwin": 2.4, + "win32": 4.7 + }, + "classify-scan-severity.test.ts": { + "linux": 33.0, + "darwin": 48.0, + "win32": 74.4 + }, + "classify-severity-records.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "classify-severity.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "cli-authentication.test.ts": { + "linux": 12.3, + "darwin": 17.2, + "win32": 308.7 + }, + "cli-classify-severity.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.2 + }, + "cli-cloud-publish.test.ts": { + "linux": 1.5, + "darwin": 0.7, + "win32": 1.9 + }, + "cli-daybreak.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "cli-dedupe.test.ts": { + "linux": 0.2, + "darwin": 0.2, + "win32": 0.3 + }, + "cli-deep-scan-summary.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "cli-diagnostics.test.ts": { + "linux": 0.4, + "darwin": 0.4, + "win32": 0.5 + }, + "cli-error-diagnostics.test.ts": { + "linux": 0.4, + "darwin": 0.7, + "win32": 1.0 + }, + "cli-export.test.ts": { + "linux": 7.6, + "darwin": 6.2, + "win32": 16.0 + }, + "cli-feedback.test.ts": { + "linux": 1.3, + "darwin": 2.4, + "win32": 4.4 + }, + "cli-help.test.ts": { + "linux": 0.2, + "darwin": 0.3, + "win32": 0.4 + }, + "cli-history-paths.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "cli-launcher.test.ts": { + "linux": 1.5, + "darwin": 2.2, + "win32": 2.0 + }, + "cli-patch-results.test.ts": { + "linux": 3.2, + "darwin": 8.7, + "win32": 89.9 + }, + "cli-patch-trust.test.ts": { + "linux": 1.0, + "darwin": 2.4, + "win32": 4.7 + }, + "cli-patch.test.ts": { + "linux": 2.7, + "darwin": 8.9, + "win32": 23.3 + }, + "cli-policy.test.ts": { + "linux": 4.6, + "darwin": 5.9, + "win32": 23.6 + }, + "cli-project-config.test.ts": { + "linux": 0.9, + "darwin": 1.7, + "win32": 4.2 + }, + "cli-publish.test.ts": { + "linux": 1.1, + "darwin": 1.8, + "win32": 1.7 + }, + "cli-saved-finding-evidence.test.ts": { + "linux": 6.2, + "darwin": 6.7, + "win32": 14.7 + }, + "cli-scan-failures.test.ts": { + "linux": 1.1, + "darwin": 0.8, + "win32": 1.6 + }, + "cli-scan-import.test.ts": { + "linux": 0.2, + "darwin": 0.3, + "win32": 0.3 + }, + "cli-scan-logs.test.ts": { + "linux": 6.4, + "darwin": 5.7, + "win32": 8.8 + }, + "cli-scan-navigation.test.ts": { + "linux": 2.8, + "darwin": 2.7, + "win32": 5.9 + }, + "cli-scan-patch-settings.test.ts": { + "linux": 1.5, + "darwin": 1.9, + "win32": 3.4 + }, + "cli-scan-prompts.test.ts": { + "linux": 0.3, + "darwin": 0.4, + "win32": 1.1 + }, + "cli-show-cost.test.ts": { + "linux": 0.3, + "darwin": 0.3, + "win32": 0.5 + }, + "cli-signals.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "cli-skill-chatgpt-provider-auth.test.ts": { + "linux": 2.3, + "darwin": 2.7, + "win32": 271.3 + }, + "cli-skill-native-config.test.ts": { + "linux": 16.3, + "darwin": 27.2, + "win32": 223.9 + }, + "cli-skills.test.ts": { + "linux": 4.3, + "darwin": 5.2, + "win32": 5.1 + }, + "cli-suggest-owners.test.ts": { + "linux": 0.3, + "darwin": 0.2, + "win32": 0.4 + }, + "cli-verify-fix.test.ts": { + "linux": 0.4, + "darwin": 0.2, + "win32": 0.7 + }, + "cli-workbench.test.ts": { + "linux": 0.6, + "darwin": 0.6, + "win32": 1.1 + }, + "cli.test.ts": { + "linux": 5.8, + "darwin": 5.9, + "win32": 13.0 + }, + "cloud-publish.test.ts": { + "linux": 1.8, + "darwin": 1.6, + "win32": 6.9 + }, + "codeowners.test.ts": { + "linux": 0.1, + "darwin": 0.3, + "win32": 0.2 + }, + "codex-process-cancellation.test.ts": { + "linux": 2.2, + "darwin": 3.8, + "win32": 3.0 + }, + "codex-review.test.ts": { + "linux": 4.7, + "darwin": 6.7, + "win32": 12.7 + }, + "compact-diff-scan.test.ts": { + "linux": 12.7, + "darwin": 29.1, + "win32": 47.2 + }, + "completed-scan-handoff.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "component-scan.test.ts": { + "linux": 13.2, + "darwin": 17.2, + "win32": 88.6 + }, + "config-approval-policy.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "config.test.ts": { + "linux": 0.5, + "darwin": 3.3, + "win32": 27.1 + }, + "container-ci-workflow.test.ts": { + "linux": 0.1, + "darwin": 0.2, + "win32": 2.0 + }, + "container-entrypoint.test.ts": { + "linux": 0.1, + "darwin": 0.5, + "win32": 0.1 + }, + "container-release-workflow.test.ts": { + "linux": 0.5, + "darwin": 2.4, + "win32": 14.6 + }, + "contract.test.ts": { + "linux": 12.2, + "darwin": 11.2, + "win32": 21.5 + }, + "cost-context.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "cost-model.property.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "cost.test.ts": { + "linux": 1.3, + "darwin": 1.5, + "win32": 2.1 + }, + "custom-publish.test.ts": { + "linux": 0.4, + "darwin": 0.5, + "win32": 0.7 + }, + "custom-validation-example.test.ts": { + "linux": 0.2, + "darwin": 0.6, + "win32": 0.3 + }, + "custom-validation.test.ts": { + "linux": 16.1, + "darwin": 19.0, + "win32": 44.7 + }, + "dedupe-records.test.ts": { + "linux": 0.7, + "darwin": 1.2, + "win32": 1.4 + }, + "deduplication-retry.test.ts": { + "linux": 0.2, + "darwin": 0.3, + "win32": 0.9 + }, + "deep-config.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.2 + }, + "deep-progress.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "deep-review-input.test.ts": { + "linux": 6.2, + "darwin": 9.4, + "win32": 14.6 + }, + "deep-scan-parent-denials.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "deep-scan-reducer-recovery.test.ts": { + "linux": 0.3, + "darwin": 0.4, + "win32": 0.5 + }, + "deep-scan-windows-executable.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "deep-scan-workbench.test.ts": { + "linux": 13.8, + "darwin": 19.2, + "win32": 38.0 + }, + "deep-scan-worker-shutdown.test.ts": { + "linux": 32.1, + "darwin": 72.1, + "win32": 33.8 + }, + "devcontainer-cooldown.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "diff-rank-input.test.ts": { + "linux": 0.8, + "darwin": 1.7, + "win32": 3.4 + }, + "direct-deep-scan.test.ts": { + "linux": 0.1, + "darwin": 0.4, + "win32": 10.5 + }, + "effort-forwarding.test.ts": { + "linux": 0.1, + "darwin": 0.2, + "win32": 0.2 + }, + "errors.property.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "errors.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "external-findings-publish.test.ts": { + "linux": 6.0, + "darwin": 10.5, + "win32": 25.7 + }, + "feedback.test.ts": { + "linux": 0.4, + "darwin": 0.5, + "win32": 1.8 + }, + "finding-catalogue.test.ts": { + "linux": 21.4, + "darwin": 10.3, + "win32": 11.7 + }, + "finding-deduplication.test.ts": { + "linux": 6.2, + "darwin": 5.6, + "win32": 6.5 + }, + "finding-embeddings.test.ts": { + "linux": 2.6, + "darwin": 2.5, + "win32": 3.3 + }, + "finding-preview.test.ts": { + "linux": 8.9, + "darwin": 16.1, + "win32": 50.4 + }, + "finding-workflow-integration.test.ts": { + "linux": 76.6, + "darwin": 151.4, + "win32": 163.6 + }, + "finding-workflow-publication.test.ts": { + "linux": 11.1, + "darwin": 10.2, + "win32": 14.3 + }, + "finding-workflow.test.ts": { + "linux": 0.6, + "darwin": 0.9, + "win32": 4.6 + }, + "findings-client-retry.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "findings-import-literal-fields.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "findings-import-record-endings.test.ts": { + "linux": 0.2, + "darwin": 0.2, + "win32": 0.6 + }, + "findings-import.test.ts": { + "linux": 1.7, + "darwin": 3.2, + "win32": 3.6 + }, + "findings-store.test.ts": { + "linux": 9.0, + "darwin": 12.8, + "win32": 16.0 + }, + "git-remote.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "github-actions-example.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.4 + }, + "github.test.ts": { + "linux": 0.2, + "darwin": 0.4, + "win32": 0.4 + }, + "host-protocol-node.test.ts": { + "linux": 7.5, + "darwin": 6.2, + "win32": 12.8 + }, + "hosted-remediation.test.ts": { + "linux": 1.2, + "darwin": 3.3, + "win32": 7.4 + }, + "hosted-scan.test.ts": { + "linux": 39.7, + "darwin": 76.2, + "win32": 174.1 + }, + "import-scan.test.ts": { + "linux": 30.5, + "darwin": 34.4, + "win32": 55.5 + }, + "invoice-desk-workflow.test.ts": { + "linux": 0.2, + "darwin": 0.5, + "win32": 1.4 + }, + "knowledge-base.test.ts": { + "linux": 1.7, + "darwin": 2.0, + "win32": 8.6 + }, + "linear-comment-pagination.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "linear-pagination.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "linear.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "local-dedupe.test.ts": { + "linux": 71.6, + "darwin": 75.9, + "win32": 143.2 + }, + "mcp-launcher.test.ts": { + "linux": 1.3, + "darwin": 2.6, + "win32": 7.6 + }, + "mock-scan.test.ts": { + "linux": 10.8, + "darwin": 17.6, + "win32": 44.1 + }, + "multiscan.test.ts": { + "linux": 42.9, + "darwin": 67.3, + "win32": 216.6 + }, + "package-provenance.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "package-public-content.test.ts": { + "linux": 0.9, + "darwin": 1.2, + "win32": 1.1 + }, + "package-smoke-timeouts.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "package-tar-entries.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "package-tar-listing.test.ts": { + "linux": 19.8, + "darwin": 45.3, + "win32": 91.7 + }, + "patch-publication.test.ts": { + "linux": 0.1, + "darwin": 0.2, + "win32": 0.9 + }, + "patch-risk-contract.test.ts": { + "linux": 14.4, + "darwin": 29.8, + "win32": 48.1 + }, + "patch-tui.test.ts": { + "linux": 2.0, + "darwin": 3.1, + "win32": 16.2 + }, + "plugin-apps.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "plugin-finding-detail-contract.test.ts": { + "linux": 0.2, + "darwin": 0.3, + "win32": 0.5 + }, + "plugin-report-limits.test.ts": { + "linux": 0.5, + "darwin": 0.7, + "win32": 0.7 + }, + "project-config.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "prompt-only-start-timeout.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "provider-profile.test.ts": { + "linux": 1.5, + "darwin": 2.9, + "win32": 9.9 + }, + "publication-check.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "publication-events.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "publication-integration.test.ts": { + "linux": 15.1, + "darwin": 21.4, + "win32": 31.7 + }, + "publication-store.test.ts": { + "linux": 22.2, + "darwin": 25.1, + "win32": 44.6 + }, + "publication.test.ts": { + "linux": 2.1, + "darwin": 2.0, + "win32": 2.6 + }, + "publish.test.ts": { + "linux": 3.5, + "darwin": 4.9, + "win32": 6.5 + }, + "published-install-smoke.test.ts": { + "linux": 0.2, + "darwin": 0.2, + "win32": 0.6 + }, + "release-automation.test.ts": { + "linux": 3.6, + "darwin": 11.6, + "win32": 25.3 + }, + "release-cut-workflow.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "release-pr.test.ts": { + "linux": 27.5, + "darwin": 53.6, + "win32": 226.9 + }, + "repository-findings.test.ts": { + "linux": 0.1, + "darwin": 0.3, + "win32": 0.4 + }, + "request-metadata.test.ts": { + "linux": 0.1, + "darwin": 0.8, + "win32": 0.7 + }, + "result.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "runtime-plugin-reuse.test.ts": { + "linux": 0.4, + "darwin": 1.9, + "win32": 1.4 + }, + "runtime-zip-descriptor.test.ts": { + "linux": 0.4, + "darwin": 0.5, + "win32": 0.7 + }, + "runtime.test.ts": { + "linux": 39.7, + "darwin": 41.6, + "win32": 195.4 + }, + "sandbox-probe.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "sarif.test.ts": { + "linux": 0.8, + "darwin": 1.2, + "win32": 1.2 + }, + "saved-scan-bootstrap.test.ts": { + "linux": 118.8, + "darwin": 119.0, + "win32": 103.7 + }, + "scan-activity.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "scan-comparison.property.test.ts": { + "linux": 0.4, + "darwin": 0.3, + "win32": 0.5 + }, + "scan-comparison.test.ts": { + "linux": 4.6, + "darwin": 5.0, + "win32": 127.7 + }, + "scan-dashboard.test.ts": { + "linux": 4.5, + "darwin": 21.5, + "win32": 40.1 + }, + "scan-history-renderer.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "scan-logs-node.test.ts": { + "linux": 0.1, + "darwin": 0.2, + "win32": 0.2 + }, + "scan-logs.test.ts": { + "linux": 0.1, + "darwin": 0.3, + "win32": 0.3 + }, + "scan-matching-e2e.test.ts": { + "linux": 7.4, + "darwin": 12.4, + "win32": 17.5 + }, + "scan-mcp-startup.test.ts": { + "linux": 1.9, + "darwin": 2.1, + "win32": 8.7 + }, + "scan-owner-suggestions.test.ts": { + "linux": 0.3, + "darwin": 1.3, + "win32": 9.4 + }, + "scan-progress.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "scan-recipe.test.ts": { + "linux": 12.2, + "darwin": 12.5, + "win32": 35.5 + }, + "scan-recovery.test.ts": { + "linux": 15.2, + "darwin": 32.8, + "win32": 66.1 + }, + "scan-resume.test.ts": { + "linux": 97.0, + "darwin": 164.8, + "win32": 231.9 + }, + "scan-skill-paths.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "scanner-import.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "script-entrypoints.test.ts": { + "linux": 3.1, + "darwin": 6.1, + "win32": 7.0 + }, + "sdk-project-config.test.ts": { + "linux": 0.2, + "darwin": 0.3, + "win32": 1.4 + }, + "sdk-scan-prompts.test.ts": { + "linux": 0.9, + "darwin": 1.1, + "win32": 3.3 + }, + "security-policy-diff.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "security-policy-helper.test.ts": { + "linux": 8.3, + "darwin": 9.5, + "win32": 9.7 + }, + "security-policy.test.ts": { + "linux": 8.4, + "darwin": 24.5, + "win32": 82.5 + }, + "severity-review.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "shell.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "skeleton.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.4 + }, + "stopped-scan-results.test.ts": { + "linux": 8.0, + "darwin": 10.9, + "win32": 19.3 + }, + "suggest-owners.test.ts": { + "linux": 2.9, + "darwin": 11.8, + "win32": 70.5 + }, + "targets-large-output.test.ts": { + "linux": 0.4, + "darwin": 1.3, + "win32": 4.8 + }, + "targets.test.ts": { + "linux": 0.8, + "darwin": 3.9, + "win32": 12.0 + }, + "test-reports.test.ts": { + "linux": 3.1, + "darwin": 4.6, + "win32": 6.0 + }, + "test-shards.test.ts": { + "linux": 2.3, + "darwin": 5.3, + "win32": 6.1 + }, + "trusted-executable.test.ts": { + "linux": 0.6, + "darwin": 1.1, + "win32": 0.1 + }, + "update-notice.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "validation-records.test.ts": { + "linux": 0.1, + "darwin": 0.3, + "win32": 0.3 + }, + "windows-scan-output.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 16.6 + }, + "wiz-findings.test.ts": { + "linux": 4.4, + "darwin": 5.4, + "win32": 10.6 + }, + "workbench-arguments.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "workbench-canonical-paths.test.ts": { + "linux": 0.8, + "darwin": 2.3, + "win32": 4.9 + }, + "workbench-nested-git-utf8.test.ts": { + "linux": 0.1, + "darwin": 0.2, + "win32": 1.6 + }, + "workbench-scan-history.test.ts": { + "linux": 1.5, + "darwin": 2.3, + "win32": 3.9 + }, + "workbench-scan-root-alias.test.ts": { + "linux": 0.2, + "darwin": 0.3, + "win32": 0.6 + }, + "workbench-windows-compatibility.test.ts": { + "linux": 0.5, + "darwin": 1.1, + "win32": 2.0 + }, + "worker-progress.property.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "worker-progress.test.ts": { + "linux": 0.1, + "darwin": 0.1, + "win32": 0.1 + }, + "workflow-quality.test.ts": { + "linux": 0.1, + "darwin": 0.2, + "win32": 0.1 + } +} diff --git a/sdk/typescript/scripts/update-test-timings.mts b/sdk/typescript/scripts/update-test-timings.mts new file mode 100644 index 0000000000..ee30cf0bb4 --- /dev/null +++ b/sdk/typescript/scripts/update-test-timings.mts @@ -0,0 +1,86 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SaxesParser } from "saxes"; +import type { TestTimings } from "./test-shards.mts"; + +export function updateTestTimings( + previous: TestTimings, + platform: "linux" | "darwin" | "win32", + reports: readonly string[], +): TestTimings { + const samples = new Map(); + for (const report of reports) { + const totals = new Map(); + const parser = new SaxesParser({ xmlns: false }); + parser.on("opentag", ({ name, attributes }) => { + if ( + name === "failure" || + name === "error" || + Number(attributes["failures"] ?? 0) > 0 || + Number(attributes["errors"] ?? 0) > 0 + ) { + throw new Error( + "Use successful JUnit reports to refresh test timings.", + ); + } + if (name !== "testcase") return; + const file = attributes["file"]?.replaceAll("\\", "/").split("/").at(-1); + const seconds = Number(attributes["time"] ?? 0); + if (!file || !Number.isFinite(seconds) || seconds < 0) { + throw new Error( + "JUnit testcase needs a file and a nonnegative duration.", + ); + } + totals.set(file, (totals.get(file) ?? 0) + seconds); + }); + parser.write(report).close(); + if (!totals.size) throw new Error("JUnit report contains no test cases."); + for (const [file, seconds] of totals) { + const values = samples.get(file) ?? []; + values.push(seconds); + samples.set(file, values); + } + } + + const updated = { ...previous }; + for (const [file, values] of samples) { + values.sort((left, right) => left - right); + const middle = Math.floor(values.length / 2); + const median = + values.length % 2 + ? values[middle]! + : (values[middle - 1]! + values[middle]!) / 2; + updated[file] = { + ...previous[file], + [platform]: Math.max(0.1, Math.round(median * 10) / 10), + }; + } + return Object.fromEntries( + Object.keys(updated) + .sort() + .map((file) => [file, updated[file]!]), + ); +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === resolve(process.argv[1]) +) { + const [platform, ...paths] = process.argv.slice(2); + if ( + !paths.length || + (platform !== "linux" && platform !== "darwin" && platform !== "win32") + ) { + throw new Error( + "Usage: node --experimental-strip-types scripts/update-test-timings.mts [...]", + ); + } + const target = new URL("./test-timings.json", import.meta.url); + const previous = JSON.parse(await readFile(target, "utf8")) as TestTimings; + const reports = await Promise.all( + paths.map((path) => readFile(path, "utf8")), + ); + const updated = updateTestTimings(previous, platform, reports); + await writeFile(target, `${JSON.stringify(updated, null, 2)}\n`); +} diff --git a/sdk/typescript/tests-ts/test-shards.test.ts b/sdk/typescript/tests-ts/test-shards.test.ts index ebf27282c7..8d55cdd5d1 100644 --- a/sdk/typescript/tests-ts/test-shards.test.ts +++ b/sdk/typescript/tests-ts/test-shards.test.ts @@ -9,9 +9,62 @@ import { import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { expect, test } from "bun:test"; +import { partitionTests, type TestTimings } from "../scripts/test-shards.mts"; import { readSubprocess } from "./support/shell.js"; import { temporaryDirectory } from "./support/temporary-directories.js"; +async function copyRunner(root: string, timings: TestTimings = {}) { + for (const name of ["run-ci-tests.mts", "test-shards.mts"]) { + await copyFile( + new URL(`../scripts/${name}`, import.meta.url), + join(root, "scripts", name), + ); + } + await writeFile( + join(root, "scripts", "test-timings.json"), + JSON.stringify(timings), + ); +} + +test.each(["linux", "darwin", "win32"] as const)( + "balances whole files using %s durations without depending on discovery order", + (platform) => { + const files = ["a", "b", "c", "d", "e", "f"]; + const timings = Object.fromEntries( + files.map((file, index) => [file, { [platform]: 100 - index * 10 }]), + ); + const shards = partitionTests(files, 3, timings, platform); + expect(shards).toEqual([ + ["a", "f"], + ["b", "e"], + ["c", "d"], + ]); + expect(partitionTests(files.toReversed(), 3, timings, platform)).toEqual( + shards, + ); + expect(files).toEqual(["a", "b", "c", "d", "e", "f"]); + }, +); + +test("uses each platform's history and includes new files without selecting deleted files", () => { + const files = ["a", "b", "c", "new"]; + const timings = { + a: { linux: 100, win32: 1 }, + b: { linux: 1, win32: 100 }, + c: { linux: 1, win32: 1 }, + deleted: { linux: 1_000, win32: 1_000 }, + }; + expect(partitionTests(files, 2, timings, "linux")).toEqual([ + ["a"], + ["b", "c", "new"], + ]); + expect(partitionTests(files, 2, timings, "win32")).toEqual([ + ["b"], + ["a", "c", "new"], + ]); + expect(partitionTests(files, 2, {}, "darwin").flat().sort()).toEqual(files); +}); + test.each([3, 7])( "runs each eligible file once across %i native shards, including new files", async (count) => { @@ -21,10 +74,11 @@ test.each([3, 7])( try { await mkdir(join(root, "scripts")); await mkdir(join(root, "tests-ts")); - await copyFile( - new URL("../scripts/run-ci-tests.mts", import.meta.url), - join(root, "scripts", "run-ci-tests.mts"), - ); + await copyRunner(root, { + "probe-0.test.ts": { linux: 100, darwin: 100, win32: 100 }, + "probe-1.test.ts": { linux: 80, darwin: 80, win32: 80 }, + "deleted.test.ts": { linux: 1_000, darwin: 1_000, win32: 1_000 }, + }); const files = [ ...Array.from({ length: 20 }, (_, index) => `probe-${index}.test.ts`), "newly-added.test.ts", @@ -88,10 +142,7 @@ test("rejects an empty shard while allowing a nonempty shard with the same count try { await mkdir(join(root, "scripts")); await mkdir(join(root, "tests-ts")); - await copyFile( - new URL("../scripts/run-ci-tests.mts", import.meta.url), - join(root, "scripts", "run-ci-tests.mts"), - ); + await copyRunner(root); await writeFile( join(root, "tests-ts", "probe.test.ts"), 'import { test } from "bun:test"; test("synthetic shard probe", () => {});\n', @@ -167,10 +218,7 @@ test.each([ try { await mkdir(join(root, "scripts")); await mkdir(join(root, "tests-ts")); - await copyFile( - new URL("../scripts/run-ci-tests.mts", import.meta.url), - join(root, "scripts", "run-ci-tests.mts"), - ); + await copyRunner(root); await writeFile( join(root, "tests-ts", "probe.test.ts"), `import { expect, test } from "bun:test"; diff --git a/sdk/typescript/tests-ts/test-timings.test.ts b/sdk/typescript/tests-ts/test-timings.test.ts new file mode 100644 index 0000000000..02f253da1d --- /dev/null +++ b/sdk/typescript/tests-ts/test-timings.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test"; +import { updateTestTimings } from "../scripts/update-test-timings.mts"; + +function report(cases: string) { + return `${cases}`; +} + +test("refreshes per-file medians while retaining other platforms and undiscovered files", () => { + const previous = { + "slow.test.ts": { linux: 100, darwin: 20, win32: 50 }, + "stale.test.ts": { linux: 2, darwin: 3 }, + }; + const updated = updateTestTimings(previous, "linux", [ + report(` + + + + `), + report(''), + report(''), + ]); + expect(updated).toEqual({ + "new.test.ts": { linux: 0.2 }, + "slow.test.ts": { linux: 10, darwin: 20, win32: 50 }, + "stale.test.ts": { linux: 2, darwin: 3 }, + }); + expect(previous["slow.test.ts"].linux).toBe(100); + expect(Object.keys(updated)).toEqual([ + "new.test.ts", + "slow.test.ts", + "stale.test.ts", + ]); +}); + +test("handles XML-escaped and Windows file paths and zero-duration skipped cases", () => { + const updated = updateTestTimings({}, "win32", [ + report(String.raw` + + + + `), + report(''), + ]); + expect(updated).toEqual({ + "names&paths.test.ts": { win32: 2.4 }, + "skipped.test.ts": { win32: 0.1 }, + }); +}); + +test.each([ + '', + report( + '', + ), + report(''), + report(''), + report(''), + report(""), + "", +])( + "rejects unusable reports without altering the timing snapshot: %s", + (invalid) => { + const previous = { "example.test.ts": { darwin: 5 } }; + expect(() => + updateTestTimings(previous, "darwin", [ + report(''), + invalid, + ]), + ).toThrow(); + expect(previous).toEqual({ "example.test.ts": { darwin: 5 } }); + }, +); diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json index ad25f8e4f0..641b4fe4df 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -7,7 +7,8 @@ "../../plugins/codex-security/native/*.mts", "../../examples/custom-validation/*.mts", "scripts/compare-test-reports.mts", - "scripts/run-ci-tests.mts" + "scripts/run-ci-tests.mts", + "scripts/update-test-timings.mts" ], "compilerOptions": { "allowJs": true, From b78bda2de7f112c207e74d53baf3b4241022f76a Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:53:49 +0000 Subject: [PATCH 2/6] test(ci): compare weighted shards across Bun runner modes --- .github/workflows/test-quality.yml | 105 ++++++------------ sdk/typescript/TESTING.md | 37 +++--- .../scripts/compare-test-reports.mts | 69 ++++++------ sdk/typescript/tests-ts/test-reports.test.ts | 91 +++++++++++---- 4 files changed, 159 insertions(+), 143 deletions(-) diff --git a/.github/workflows/test-quality.yml b/.github/workflows/test-quality.yml index 6fd7690bf7..2302abc72b 100644 --- a/.github/workflows/test-quality.yml +++ b/.github/workflows/test-quality.yml @@ -30,68 +30,33 @@ jobs: runner: needs: native if: ${{ !cancelled() && (inputs.native-artifacts-ready || needs.native.result == 'success') }} - name: ${{ matrix.os }} / ${{ matrix.mode }} - runs-on: ${{ matrix.os }} - timeout-minutes: ${{ matrix.os == 'windows-latest' && (matrix.mode == 'baseline' || matrix.mode == 'parallel') && 90 || 30 }} + name: ${{ matrix.target.os }} / ${{ matrix.mode.name }} / shard-${{ matrix.target.shard }} + runs-on: ${{ matrix.target.os }} + timeout-minutes: ${{ matrix.target.os == 'windows-latest' && (matrix.mode.name == 'baseline' || matrix.mode.name == 'parallel') && 90 || 30 }} env: CODEX_SECURITY_PROPERTY_SEED: ${{ github.event_name == 'pull_request' && 1 || github.run_number }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - mode: [baseline, isolated, parallel] - exclude: - - os: windows-latest - mode: isolated - include: - - mode: baseline - args: "" - - mode: isolated - args: --isolate - - mode: parallel - args: --parallel=2 - - os: windows-latest - mode: isolated-1 - args: --isolate --shard=1/7 - - os: windows-latest - mode: isolated-2 - args: --isolate --shard=2/7 - - os: windows-latest - mode: isolated-3 - args: --isolate --shard=3/7 - - os: windows-latest - mode: isolated-4 - args: --isolate --shard=4/7 - - os: windows-latest - mode: isolated-5 - args: --isolate --shard=5/7 - - os: windows-latest - mode: isolated-6 - args: --isolate --shard=6/7 - - os: windows-latest - mode: isolated-7 - args: --isolate --shard=7/7 - - os: windows-latest - mode: shard-1 - args: --shard=1/7 - - os: windows-latest - mode: shard-2 - args: --shard=2/7 - - os: windows-latest - mode: shard-3 - args: --shard=3/7 - - os: windows-latest - mode: shard-4 - args: --shard=4/7 - - os: windows-latest - mode: shard-5 - args: --shard=5/7 - - os: windows-latest - mode: shard-6 - args: --shard=6/7 - - os: windows-latest - mode: shard-7 - args: --shard=7/7 + # Every mode uses the same production shard count and timing snapshot. + target: + - { os: ubuntu-latest, shard: 1, count: 3 } + - { os: ubuntu-latest, shard: 2, count: 3 } + - { os: ubuntu-latest, shard: 3, count: 3 } + - { os: macos-latest, shard: 1, count: 3 } + - { os: macos-latest, shard: 2, count: 3 } + - { os: macos-latest, shard: 3, count: 3 } + - { os: windows-latest, shard: 1, count: 7 } + - { os: windows-latest, shard: 2, count: 7 } + - { os: windows-latest, shard: 3, count: 7 } + - { os: windows-latest, shard: 4, count: 7 } + - { os: windows-latest, shard: 5, count: 7 } + - { os: windows-latest, shard: 6, count: 7 } + - { os: windows-latest, shard: 7, count: 7 } + mode: + - { name: baseline, args: "" } + - { name: isolated, args: --isolate } + - { name: parallel, args: --parallel=2 } steps: - &actions-checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -116,17 +81,9 @@ jobs: sdk/typescript/pnpm-lock.yaml plugins/codex-security/mcp-app/pnpm-lock.yaml - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - if: runner.os == 'Windows' with: - bun-version: "1.4.2" - - name: Set up Bun on Linux - if: runner.os == 'Linux' - shell: bash - run: | - # Includes the subprocess fix from https://github.com/oven-sh/bun/pull/44581 - # for the isolated-runner hang tracked in https://github.com/oven-sh/bun/issues/34069. - npm install --prefix "$RUNNER_TEMP/bun" --ignore-scripts --no-audit --no-fund @oven/bun-linux-x64@1.4.2-canary.20261008.1 - echo "$RUNNER_TEMP/bun/node_modules/@oven/bun-linux-x64/bin" >> "$GITHUB_PATH" + # Includes the isolated spawnSync fix from oven-sh/bun#44581. + bun-version: "1.4.3" - name: Install dependencies run: | pnpm --dir sdk/typescript install --frozen-lockfile @@ -136,20 +93,21 @@ jobs: id: windows-temp shell: pwsh run: ./sdk/typescript/scripts/prepare-windows-test-root.ps1 + - name: Build bundled plugin + run: pnpm --dir sdk/typescript run build:plugin - name: Test runner mode + working-directory: sdk/typescript env: CODEX_SECURITY_TEST_TIMEOUT_MS: ${{ runner.os == 'Windows' && '120000' || '30000' }} TEMP: ${{ steps.windows-temp.outputs.path || runner.temp }} TMP: ${{ steps.windows-temp.outputs.path || runner.temp }} TMPDIR: ${{ steps.windows-temp.outputs.path || runner.temp }} - run: | - node -e "require('node:fs').mkdirSync('sdk/typescript/reports',{recursive:true})" - pnpm --dir sdk/typescript run test ${{ runner.os == 'Windows' && '--timeout=120000' || '' }} ${{ matrix.args }} --seed=${{ env.CODEX_SECURITY_PROPERTY_SEED }} --reporter=junit --reporter-outfile=reports/runner-${{ matrix.os }}-${{ matrix.mode }}.xml + run: node --experimental-strip-types scripts/run-ci-tests.mts ${{ matrix.target.shard }}/${{ matrix.target.count }} ${{ matrix.mode.args }} --seed=${{ env.CODEX_SECURITY_PROPERTY_SEED }} --reporter-outfile=reports/runner-${{ matrix.target.os }}-${{ matrix.mode.name }}-${{ matrix.target.shard }}.xml - name: Upload runner report if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: runner-${{ matrix.os }}-${{ matrix.mode }} + name: runner-${{ matrix.target.os }}-${{ matrix.mode.name }}-${{ matrix.target.shard }} overwrite: true path: sdk/typescript/reports/runner-*.xml if-no-files-found: error @@ -178,12 +136,11 @@ jobs: shell: bash run: | comparison_status=0 - for os in ubuntu-latest windows-latest; do + for os in ubuntu-latest macos-latest windows-latest; do for mode in isolated parallel; do - node --experimental-strip-types sdk/typescript/scripts/compare-test-reports.mts "reports/runner-$os-baseline.xml" "reports/runner-$os-$mode*.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 + node --experimental-strip-types sdk/typescript/scripts/compare-test-reports.mts "reports/runner-$os-baseline-*.xml" "reports/runner-$os-$mode-*.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 done done - node --experimental-strip-types sdk/typescript/scripts/compare-test-reports.mts reports/runner-windows-latest-baseline.xml 'reports/runner-windows-latest-shard-*.xml' >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 exit "$comparison_status" mutation: diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 1c3ccdf515..32d84cde94 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -174,19 +174,30 @@ Failed tests and missing package artifacts block CI; failed diagnostic uploads do not. Use `python -m pytest -n 0` to reproduce Python failures serially. The separate `test-quality` workflow runs weekly, can be dispatched manually, -and runs on pull requests that change its workflow file. It compares Bun's -default runner, `--isolate`, `--parallel=2`, and seven-way Windows sharding. -Every mode uses the same seed for property cases and test ordering: pull -requests replay seed 1; scheduled and manual runs use the workflow run number. -The workflow compares test identities and outcomes against the unsharded -default run and records timings, including failed shards, in the job summary. -It is not a required check or part of the release trigger. - -Runner trials use Bun 1.4.2 to avoid the -[async-module initialization bug in 1.3.14](https://github.com/oven-sh/bun/issues/31410) -that breaks the Ink UI tests under isolation. Keep the trial pin until a newer -release passes the full SDK suite in every mode. Required CI and the mutation -trial remain on Bun 1.3.14. +and runs on pull requests that change its workflow or runner tooling. It compares +Bun's default runner, `--isolate`, and `--parallel=2` on Linux, macOS, and Windows. +Each mode uses the same duration-based file assignment and production shard count: +three on Linux/macOS and seven on Windows. This gives 39 sharded runner jobs in place +of 19 jobs that included an unsharded Windows baseline. It measures the production +partition rather than cross-file interactions across one unsharded suite; local +full-suite runs still cover that ordering. More jobs add setup overhead, so compare +workflow wall time and runner minutes, alongside shard test timings, before +adopting a runner change. + +Every mode uses the same seed for property cases and test ordering: pull requests +replay seed 1; scheduled and manual runs use the workflow run number. Reports have +unique OS/mode/shard names. The workflow compares complete shard groups by test +identity, outcome, and occurrence count, retaining legitimate repeated names from +parameterized tests while detecting missing or extra instances. It records every +shard's timing, including failed shards, in the job summary. It is not a required +check or part of the release trigger. + +Runner trials use Bun 1.4.3, including the +[isolated subprocess fix](https://github.com/oven-sh/bun/pull/44581). +`--parallel=2` runs files in two worker processes and isolates each file; it does +not make tests inside one file concurrent. Required CI and the mutation trial +remain on Bun 1.3.14 until a newer release passes the full SDK suite in every mode +on all three platforms. Keep the machine-policy test serial. Do not replace the full required suite with `--changed`: Python files, schemas, fixtures, and workflows loaded at diff --git a/sdk/typescript/scripts/compare-test-reports.mts b/sdk/typescript/scripts/compare-test-reports.mts index 75ed2307b5..0e44f840e0 100644 --- a/sdk/typescript/scripts/compare-test-reports.mts +++ b/sdk/typescript/scripts/compare-test-reports.mts @@ -114,7 +114,6 @@ function readReport(path: string) { ); parser.write(content).close(); - const identities = new Set(); const cases = new Map(); for (const { attributes, status } of records) { const identity: TestIdentity = [ @@ -124,18 +123,13 @@ function readReport(path: string) { attributes["classname"]?.value ?? "", attributes["name"]?.value ?? "", ]; - const key = JSON.stringify(identity); - if (identities.has(key)) - throw new Error( - `${path}: duplicate test identity: ${identity.join(" > ")}`, - ); - identities.add(key); - cases.set(JSON.stringify([...identity, status]), 1); + const key = JSON.stringify([...identity, status]); + cases.set(key, (cases.get(key) ?? 0) + 1); } if (!cases.size) throw new Error(`${path}: no test cases`); if ( - integer(root!.attributes["tests"]?.value ?? String(cases.size)) !== - BigInt(cases.size) + integer(root!.attributes["tests"]?.value ?? String(records.length)) !== + BigInt(records.length) ) { throw new Error(`${path}: reported test count does not match test cases`); } @@ -150,7 +144,7 @@ function readReport(path: string) { const duration = seconds(root!.attributes["time"]?.value ?? "0"); const skipped = records.filter(({ status }) => status === "skipped").length; console.log( - `| ${basename(path)} | ${cases.size} | ${skipped} | ${duration.toFixed(2)} |`, + `| ${basename(path)} | ${records.length} | ${skipped} | ${duration.toFixed(2)} |`, ); return { cases, @@ -159,6 +153,26 @@ function readReport(path: string) { }; } +function readReports(patterns: string[]) { + const cases = new Map(); + const durations: number[] = []; + let failed = false; + for (const pattern of patterns) { + const paths = matchingReports(pattern).sort((a, b) => + Buffer.compare(Buffer.from(a), Buffer.from(b)), + ); + if (!paths.length) throw new Error(`No reports match ${pattern}`); + for (const path of paths) { + const report = readReport(path); + failed ||= report.failed; + for (const [identity, count] of report.cases) + cases.set(identity, (cases.get(identity) ?? 0) + count); + durations.push(report.duration); + } + } + return { cases, durations, failed }; +} + function main(): number { let args: { values: { help?: boolean }; positionals: string[] }; try { @@ -176,32 +190,18 @@ function main(): number { } if (args.values.help) { console.log( - "Compare Bun JUnit inventories before changing the required CI runner.\n\nUsage: node --experimental-strip-types compare-test-reports.mts baseline candidates [candidates ...]\nCandidates are JUnit files or glob patterns.", + "Compare Bun JUnit inventories before changing the required CI runner.\n\nUsage: node --experimental-strip-types compare-test-reports.mts baseline candidates [candidates ...]\nBaseline and candidates are JUnit files or glob patterns.", ); return 0; } console.log("| Report | Cases | Skipped | Seconds |"); console.log("| --- | ---: | ---: | ---: |"); - const baseline = readReport(args.positionals[0]!); - let failed = baseline.failed; - const candidates = new Map(); - const durations: number[] = []; - for (const pattern of args.positionals.slice(1)) { - const paths = matchingReports(pattern).sort((a, b) => - Buffer.compare(Buffer.from(a), Buffer.from(b)), - ); - if (!paths.length) throw new Error(`No reports match ${pattern}`); - for (const path of paths) { - const report = readReport(path); - failed ||= report.failed; - for (const [identity, count] of report.cases) - candidates.set(identity, (candidates.get(identity) ?? 0) + count); - durations.push(report.duration); - } - } + const baseline = readReports([args.positionals[0]!]); + const candidate = readReports(args.positionals.slice(1)); + let failed = baseline.failed || candidate.failed; for (const [label, left, right] of [ - ["Missing", baseline.cases, candidates], - ["Extra", candidates, baseline.cases], + ["Missing", baseline.cases, candidate.cases], + ["Extra", candidate.cases, baseline.cases], ] as const) { const differences = [...left] .map(([identity, count]): [TestOutcome, number] => [ @@ -225,8 +225,13 @@ function main(): number { } } if (failed) return 1; + if (baseline.durations.length > 1) { + console.log( + `\nBaseline shards: slowest ${Math.max(...baseline.durations).toFixed(2)}s; combined test time: ${baseline.durations.reduce((sum, duration) => sum + duration, 0).toFixed(2)}s.`, + ); + } console.log( - `\nIdentical test inventory and outcomes. Slowest candidate: ${Math.max(...durations).toFixed(2)}s; combined test time: ${durations.reduce((sum, duration) => sum + duration, 0).toFixed(2)}s.\n`, + `\nIdentical test inventory and outcomes. Slowest candidate: ${Math.max(...candidate.durations).toFixed(2)}s; combined test time: ${candidate.durations.reduce((sum, duration) => sum + duration, 0).toFixed(2)}s.\n`, ); return 0; } diff --git a/sdk/typescript/tests-ts/test-reports.test.ts b/sdk/typescript/tests-ts/test-reports.test.ts index 341858145d..0e7a0684d4 100644 --- a/sdk/typescript/tests-ts/test-reports.test.ts +++ b/sdk/typescript/tests-ts/test-reports.test.ts @@ -67,16 +67,18 @@ describe("JUnit inventory comparison", () => { (step) => step.name === "Compare inventories and outcomes", )!.run!; const expected = [ - ...["ubuntu-latest", "windows-latest"].flatMap((os) => - ["isolated", "parallel"].map( - (mode) => `reports/runner-${os}-${mode}*.xml`, - ), + "ubuntu-latest", + "macos-latest", + "windows-latest", + ].flatMap((os) => + ["isolated", "parallel"].map( + (mode) => + `reports/runner-${os}-baseline-*.xml|reports/runner-${os}-${mode}-*.xml`, ), - "reports/runner-windows-latest-shard-*.xml", - ]; + ); const mock = `node() { - printf '%s\\n' "$4" - [[ "$4" != "$CODEX_SECURITY_TEST_FAIL_REPORT" ]] + printf '%s\\n' "$3|$4" + [[ "$3|$4" != "$CODEX_SECURITY_TEST_FAIL_REPORT" ]] }`; const summary = join(fixture.root, "summary.md"); for (const failedReport of ["", expected[0]!]) { @@ -257,27 +259,68 @@ ${testcase("a & " ' < >")} expect(result.stdout).toContain("| defaults.xml | 1 | 0 | 0.00 |"); }); - test("rejects ambiguous test identities even when totals match", async () => { + test("preserves repeated parameterized identities and their outcome counts", async () => { const fixture = await fixtures(); - const first = testcase("same parameterized name"); - for (const [name, repeated] of [ - ["same-outcome", first], - ["different-outcome", testcase("same parameterized name", "")], + const passed = testcase("same parameterized name"); + const skipped = testcase("same parameterized name", ""); + const baseline = await fixture.report("baseline.xml", [ + passed, + passed, + skipped, + ]); + const candidate = await fixture.report("candidate.xml", [ + skipped, + passed, + passed, + ]); + const result = await compare(baseline, candidate); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("| baseline.xml | 3 | 1 | 1.25 |"); + for (const [name, cases, diagnostic] of [ + ["missing", [passed, skipped], "Missing (1)"], + ["extra", [passed, passed, passed, skipped], "Extra (1)"], + ["changed", [passed, skipped, skipped], "Missing (1)"], ] as const) { - const baseline = await fixture.report(`${name}-baseline.xml`, [ - first, - repeated, - ]); - const candidate = await fixture.report(`${name}-candidate.xml`, [ - first, - repeated, - ]); - const result = await compare(baseline, candidate); - expect(result.status).toBe(1); - expect(result.stderr).toContain("duplicate test identity"); + const changed = await fixture.report(`${name}.xml`, [...cases]); + const comparison = await compare(baseline, changed); + expect(comparison.status, name).toBe(1); + expect(comparison.stderr).toContain(diagnostic); } }); + test("compares complete baseline and candidate shard groups", async () => { + const fixture = await fixtures(); + const first = testcase("first"); + const repeated = testcase("parameterized name"); + await fixture.report("baseline-1.xml", [first, repeated]); + await fixture.report("baseline-2.xml", [repeated]); + await fixture.report("candidate-1.xml", [repeated, repeated]); + await fixture.report("candidate-2.xml", [first]); + const baseline = join(fixture.root, "baseline-*.xml"); + const candidate = join(fixture.root, "candidate-*.xml"); + const result = await compare(baseline, candidate); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain( + "Baseline shards: slowest 1.25s; combined test time: 2.50s.", + ); + expect(result.stdout).toContain( + "Slowest candidate: 1.25s; combined test time: 2.50s.", + ); + const extra = await fixture.report("extra.xml", [repeated]); + const duplicated = await compare(baseline, candidate, extra); + expect(duplicated.status).toBe(1); + expect(duplicated.stderr).toContain("Extra (1)"); + const missing = await compare( + baseline, + join(fixture.root, "candidate-1.xml"), + ); + expect(missing.status).toBe(1); + expect(missing.stderr).toContain("Missing (1)"); + expect( + (await compare(join(fixture.root, "absent-*.xml"), candidate)).status, + ).toBe(1); + }); + test("reports every shard's timing even when an earlier shard fails", async () => { const fixture = await fixtures(); const first = testcase("first"); From 158f02dfb477be1c7555574788ed393629d4ca19 Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:02:04 +0000 Subject: [PATCH 3/6] test(ci): require complete runner diagnostic shard reports --- .github/workflows/test-quality.yml | 17 +++ sdk/typescript/TESTING.md | 3 +- sdk/typescript/tests-ts/skeleton.test.ts | 91 ++++++++------ sdk/typescript/tests-ts/test-reports.test.ts | 118 +++++++++++++++++-- 4 files changed, 177 insertions(+), 52 deletions(-) diff --git a/.github/workflows/test-quality.yml b/.github/workflows/test-quality.yml index 2302abc72b..83ace3f6b6 100644 --- a/.github/workflows/test-quality.yml +++ b/.github/workflows/test-quality.yml @@ -136,7 +136,24 @@ jobs: shell: bash run: | comparison_status=0 + shopt -s nullglob for os in ubuntu-latest macos-latest windows-latest; do + shards=3 + if [[ "$os" == windows-latest ]]; then shards=7; fi + for mode in baseline isolated parallel; do + reports=(reports/runner-"$os"-"$mode"-*.xml) + if (( ${#reports[@]} != shards )); then + echo "Expected $shards reports for $os/$mode, found ${#reports[@]}" >&2 + comparison_status=1 + fi + for ((shard=1; shard<=shards; shard++)); do + report="reports/runner-$os-$mode-$shard.xml" + if [[ ! -f "$report" ]]; then + echo "Missing shard report: $report" >&2 + comparison_status=1 + fi + done + done for mode in isolated parallel; do node --experimental-strip-types sdk/typescript/scripts/compare-test-reports.mts "reports/runner-$os-baseline-*.xml" "reports/runner-$os-$mode-*.xml" >> "$GITHUB_STEP_SUMMARY" || comparison_status=1 done diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 32d84cde94..e9b5edbb34 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -186,7 +186,8 @@ adopting a runner change. Every mode uses the same seed for property cases and test ordering: pull requests replay seed 1; scheduled and manual runs use the workflow run number. Reports have -unique OS/mode/shard names. The workflow compares complete shard groups by test +unique OS/mode/shard names. The workflow requires every expected shard report +before accepting comparisons, then compares complete shard groups by test identity, outcome, and occurrence count, retaining legitimate repeated names from parameterized tests while detecting missing or extra instances. It records every shard's timing, including failed shards, in the job summary. It is not a required diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 9a4b37c89b..779da28295 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -499,15 +499,6 @@ describe("TypeScript package skeleton", () => { "false", ); expect(quality.env?.["CODEX_SECURITY_INTEGRATION"]).toBe("0"); - for (let shard = 1; shard <= 7; shard += 1) { - expect( - quality.jobs["runner"]?.strategy?.matrix["include"], - ).toContainEqual({ - os: "windows-latest", - mode: `shard-${shard}`, - args: `--shard=${shard}/7`, - }); - } }); test("keeps runner modes reproducible and report uploads rerunnable", async () => { @@ -518,36 +509,56 @@ describe("TypeScript package skeleton", () => { "${{ github.event_name == 'pull_request' && 1 || github.run_number }}"; expect(quality.env).not.toHaveProperty("CODEX_SECURITY_PROPERTY_SEED"); expect(runner.env?.["CODEX_SECURITY_PROPERTY_SEED"]).toBe(seed); - expect(runner.strategy?.matrix["mode"]).toEqual([ - "baseline", - "isolated", - "parallel", - ]); - for (const [mode, args] of [ - ["baseline", ""], - ["isolated", "--isolate"], - ["parallel", "--parallel=2"], + const unixMatrix = ci.jobs["test"]!.strategy!.matrix; + const unixShards = unixMatrix["shard"] as number[]; + const windowsShards = ci.jobs["windows-test"]!.strategy!.matrix[ + "shard" + ] as number[]; + expect(runner.strategy?.matrix).toEqual({ + target: [ + ...(unixMatrix["os"] as string[]).flatMap((os) => + unixShards.map((shard) => ({ os, shard, count: unixShards.length })), + ), + ...windowsShards.map((shard) => ({ + os: "windows-latest", + shard, + count: windowsShards.length, + })), + ], + mode: [ + { name: "baseline", args: "" }, + { name: "isolated", args: "--isolate" }, + { name: "parallel", args: "--parallel=2" }, + ], + }); + for (const [job, version] of [ + [runner, "1.4.3"], + [ci.jobs["test"]!, "1.3.14"], + [ci.jobs["windows-test"]!, "1.3.14"], + [quality.jobs["mutation"]!, "1.3.14"], ] as const) { - expect(runner.strategy?.matrix["include"]).toContainEqual({ - mode, - args, - }); - } - for (let shard = 1; shard <= 7; shard += 1) { - expect(runner.strategy?.matrix["include"]).toContainEqual({ - os: "windows-latest", - mode: `isolated-${shard}`, - args: `--isolate --shard=${shard}/7`, - }); + expect( + job + .steps!.filter((step) => step.uses?.startsWith("oven-sh/setup-bun@")) + .map((step) => step.with?.["bun-version"]), + ).toEqual([version]); } - const command = runner.steps!.find( + const execution = runner.steps!.find( (step) => step.name === "Test runner mode", - )?.run; - expect(command).toContain( - "${{ runner.os == 'Windows' && '--timeout=120000' || '' }}", ); - expect(command).toContain("${{ matrix.args }}"); - expect(command).toContain("--seed=${{ env.CODEX_SECURITY_PROPERTY_SEED }}"); + expect(execution).toMatchObject({ + "working-directory": "sdk/typescript", + env: { + CODEX_SECURITY_TEST_TIMEOUT_MS: + "${{ runner.os == 'Windows' && '120000' || '30000' }}", + }, + }); + expect(execution?.run).toContain( + "scripts/run-ci-tests.mts ${{ matrix.target.shard }}/${{ matrix.target.count }} ${{ matrix.mode.args }}", + ); + expect(execution?.run).toContain( + "--seed=${{ env.CODEX_SECURITY_PROPERTY_SEED }}", + ); const uploads = [...Object.values(ci.jobs), ...Object.values(quality.jobs)] .flatMap((job) => job.steps ?? []) @@ -558,9 +569,13 @@ describe("TypeScript package skeleton", () => { expect( uploads.find((step) => step.name === "Upload mutation report"), ).toMatchObject({ "continue-on-error": true }); - expect( - uploads.find((step) => step.name === "Upload runner report"), - ).not.toHaveProperty("continue-on-error"); + const runnerUpload = uploads.find( + (step) => step.name === "Upload runner report", + ); + expect(runnerUpload).not.toHaveProperty("continue-on-error"); + expect(runnerUpload?.with?.["name"]).toBe( + "runner-${{ matrix.target.os }}-${{ matrix.mode.name }}-${{ matrix.target.shard }}", + ); expect( quality.jobs["mutation"]?.steps!.find( (step) => step.name === "Run mutation trial", diff --git a/sdk/typescript/tests-ts/test-reports.test.ts b/sdk/typescript/tests-ts/test-reports.test.ts index 0e7a0684d4..74756174b2 100644 --- a/sdk/typescript/tests-ts/test-reports.test.ts +++ b/sdk/typescript/tests-ts/test-reports.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { bashCommand, readSubprocess } from "./support/shell.js"; @@ -32,6 +32,43 @@ async function fixtures() { }; } +async function workflowFixtures() { + const fixture = await fixtures(); + const workflow = Bun.YAML.parse( + await readFile( + new URL("../../../.github/workflows/test-quality.yml", import.meta.url), + "utf8", + ), + ) as { + jobs: { + runner: { + strategy: { + matrix: { + target: Array<{ os: string; shard: number }>; + mode: Array<{ name: string }>; + }; + }; + }; + compare: { steps: Array<{ name?: string; run?: string }> }; + }; + }; + await mkdir(join(fixture.root, "reports")); + const { target, mode } = workflow.jobs.runner.strategy.matrix; + for (const { os, shard } of target) { + for (const { name } of mode) { + await fixture.report(`reports/runner-${os}-${name}-${shard}.xml`, [ + testcase(`${os} shard ${shard}`), + ]); + } + } + return { + ...fixture, + script: workflow.jobs.compare.steps.find( + (step) => step.name === "Compare inventories and outcomes", + )!.run!, + }; +} + function testcase(name: string, status = "") { return `${status}`; } @@ -54,18 +91,8 @@ async function compare(baseline: string, ...candidates: string[]) { describe("JUnit inventory comparison", () => { test("runs every workflow comparison before reporting a mismatch", async () => { - const fixture = await fixtures(); - const workflow = Bun.YAML.parse( - await readFile( - new URL("../../../.github/workflows/test-quality.yml", import.meta.url), - "utf8", - ), - ) as { - jobs: { compare: { steps: Array<{ name?: string; run?: string }> } }; - }; - const script = workflow.jobs.compare.steps.find( - (step) => step.name === "Compare inventories and outcomes", - )!.run!; + const fixture = await workflowFixtures(); + const script = fixture.script; const expected = [ "ubuntu-latest", "macos-latest", @@ -104,6 +131,71 @@ describe("JUnit inventory comparison", () => { } }); + for (const scenario of [ + { name: "missing Linux shard", os: "ubuntu-latest", missing: 3 }, + { name: "missing Windows shard", os: "windows-latest", missing: 7 }, + { + name: "missing shard replaced by an unexpected index", + os: "ubuntu-latest", + missing: 3, + extra: 4, + }, + { name: "extra shard", os: "windows-latest", extra: 8 }, + ]) { + test(`rejects identically incomplete workflow groups: ${scenario.name}`, async () => { + const fixture = await workflowFixtures(); + for (const mode of ["baseline", "isolated", "parallel"]) { + if (scenario.missing !== undefined) { + await rm( + join( + fixture.root, + `reports/runner-${scenario.os}-${mode}-${scenario.missing}.xml`, + ), + ); + } + if (scenario.extra !== undefined) { + await fixture.report( + `reports/runner-${scenario.os}-${mode}-${scenario.extra}.xml`, + [testcase("unexpected shard")], + ); + } + } + const child = Bun.spawn({ + cmd: [ + bash, + "-e", + "-o", + "pipefail", + "-c", + `node() { printf '%s\\n' "$3|$4"; }\n${fixture.script}`, + ], + cwd: fixture.root, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + env: { ...process.env, GITHUB_STEP_SUMMARY: "summary.md" }, + timeout: 10_000, + }); + const { status, stderr } = await readSubprocess(child); + expect(status, stderr).toBe(1); + if (scenario.missing !== undefined) { + expect(stderr).toContain( + `Missing shard report: reports/runner-${scenario.os}-baseline-${scenario.missing}.xml`, + ); + } else { + expect(stderr).toContain( + `Expected 7 reports for ${scenario.os}/baseline, found 8`, + ); + } + // Missing artifacts must not prevent the other OS/mode diagnostics. + expect( + (await readFile(join(fixture.root, "summary.md"), "utf8")) + .trim() + .split(/\r?\n/u), + ).toHaveLength(6); + }); + } + test("merges native shards without depending on test order", async () => { const fixture = await fixtures(); const passed = testcase("accepts & preserves"); From a3dbe5f14c605bd5df106943e8924f296794e0f1 Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:12:08 +0000 Subject: [PATCH 4/6] test(ci): align Linux diagnostic ripgrep setup --- .github/workflows/test-quality.yml | 5 +++++ sdk/typescript/TESTING.md | 7 +++++++ sdk/typescript/tests-ts/skeleton.test.ts | 12 ++++++++++++ 3 files changed, 24 insertions(+) diff --git a/.github/workflows/test-quality.yml b/.github/workflows/test-quality.yml index 83ace3f6b6..34c77e9c91 100644 --- a/.github/workflows/test-quality.yml +++ b/.github/workflows/test-quality.yml @@ -95,6 +95,11 @@ jobs: run: ./sdk/typescript/scripts/prepare-windows-test-root.ps1 - name: Build bundled plugin run: pnpm --dir sdk/typescript run build:plugin + - name: Install ripgrep + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes ripgrep - name: Test runner mode working-directory: sdk/typescript env: diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index e9b5edbb34..a09c5c6f49 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -200,6 +200,13 @@ not make tests inside one file concurrent. Required CI and the mutation trial remain on Bun 1.3.14 until a newer release passes the full SDK suite in every mode on all three platforms. +Runner modes use the same property seed and coverage settings as each other. +Required CI uses the default property seed and collects Linux coverage, while +these diagnostics use the seed above and omit coverage instrumentation. Compare +inventories across required CI and diagnostics, but measure scheduling speed +between diagnostic modes to avoid attributing these differences to the runner. +Both workflows install ripgrep on Linux so the inventory-path test executes. + Keep the machine-policy test serial. Do not replace the full required suite with `--changed`: Python files, schemas, fixtures, and workflows loaded at runtime are not necessarily part of Bun's import graph. diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 779da28295..87dbf85df3 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -546,6 +546,18 @@ describe("TypeScript package skeleton", () => { const execution = runner.steps!.find( (step) => step.name === "Test runner mode", ); + const ripgrep = runner.steps!.find( + (step) => step.name === "Install ripgrep", + ); + expect(ripgrep).toMatchObject({ + if: "runner.os == 'Linux'", + run: ci.jobs["test"]!.steps!.find( + (step) => step.name === "Install ripgrep", + )?.run, + }); + expect(runner.steps!.indexOf(ripgrep!)).toBeLessThan( + runner.steps!.indexOf(execution!), + ); expect(execution).toMatchObject({ "working-directory": "sdk/typescript", env: { From 8006278e2114464cb44a657d750b663a57c6a726 Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:54:28 +0000 Subject: [PATCH 5/6] test: qualify changes to CI runner tooling --- .github/workflows/node-ci.yml | 11 ++++- sdk/typescript/TESTING.md | 9 ++-- .../tests-ts/container-ci-workflow.test.ts | 1 + .../tests-ts/workflow-quality.test.ts | 44 +++++++++++++++++-- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index cfb239100f..c1a4b4eb66 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -78,9 +78,16 @@ jobs: ;; .github/workflows/node-ci.yml | .github/actions/setup-tools/*) container_validate=true - test_quality=true ;; - .github/workflows/test-quality.yml) + esac + case "$path" in + .github/workflows/node-ci.yml | .github/workflows/test-quality.yml | \ + .github/actions/setup-tools/* | sdk/typescript/bunfig.toml | \ + sdk/typescript/scripts/compare-test-reports.mts | \ + sdk/typescript/scripts/prepare-windows-test-root.ps1 | \ + sdk/typescript/scripts/run-ci-tests.mts | \ + sdk/typescript/scripts/test-shards.mts | \ + sdk/typescript/scripts/test-timings.json) test_quality=true ;; esac diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index a09c5c6f49..fe5c8a1650 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -119,7 +119,8 @@ The requirements file is the single list of pinned Python test dependencies. Ruf - Add property tests for meaningful invariants, with accepted and rejected inputs. Keep example-based regression tests for readable failure cases. - Give parameterized cases distinct names. Use `%p`, `%j`, or `%#` for values - that are not strings. The JUnit comparison rejects duplicate identities. + that are not strings. Duplicate identities remain valid; the JUnit comparison + checks their occurrence and outcome counts. Property tests use a fixed default seed. Fast-check prints the seed, shrink path, and counterexample on failure. To replay one property, select its file @@ -174,8 +175,10 @@ Failed tests and missing package artifacts block CI; failed diagnostic uploads do not. Use `python -m pytest -n 0` to reproduce Python failures serially. The separate `test-quality` workflow runs weekly, can be dispatched manually, -and runs on pull requests that change its workflow or runner tooling. It compares -Bun's default runner, `--isolate`, and `--parallel=2` on Linux, macOS, and Windows. +and runs on pull requests that change its workflow, setup tools, Bun configuration, +shard runner, timing metadata, report comparator, or Windows test-root helper. +It compares Bun's default runner, `--isolate`, and `--parallel=2` on Linux, macOS, +and Windows. Each mode uses the same duration-based file assignment and production shard count: three on Linux/macOS and seven on Windows. This gives 39 sharded runner jobs in place of 19 jobs that included an unsharded Windows baseline. It measures the production diff --git a/sdk/typescript/tests-ts/container-ci-workflow.test.ts b/sdk/typescript/tests-ts/container-ci-workflow.test.ts index 84d3818019..b22f446a82 100644 --- a/sdk/typescript/tests-ts/container-ci-workflow.test.ts +++ b/sdk/typescript/tests-ts/container-ci-workflow.test.ts @@ -94,6 +94,7 @@ test.each([ ["pull_request", false, ".github/workflows/node-ci.yml", true], ["pull_request", false, ".github/actions/setup-tools/action.yml", true], ["pull_request", false, "sdk/typescript/src/index.ts", true], + ["pull_request", false, "sdk/typescript/scripts/run-ci-tests.mts", true], [ "pull_request", false, diff --git a/sdk/typescript/tests-ts/workflow-quality.test.ts b/sdk/typescript/tests-ts/workflow-quality.test.ts index 7ebb0a4f00..d76bcad2ba 100644 --- a/sdk/typescript/tests-ts/workflow-quality.test.ts +++ b/sdk/typescript/tests-ts/workflow-quality.test.ts @@ -46,16 +46,22 @@ const musl = Bun.YAML.parse( const bashTest = test.skipIf(process.platform === "win32"); bashTest( - "selects workflow checks for workflow changes and main pushes", + "selects workflow and runner checks for relevant changes and main pushes", async () => { const scope = ci.jobs["validate-title"]!.steps.find( (step) => step.name === "Select additional checks", )!; const directory = await mkdtemp(join(tmpdir(), "workflow-quality-")); try { - for (const [event, baseChanged, paths, expected] of [ + for (const [ + event, + baseChanged, + paths, + expected, + runnerExpected = "false", + ] of [ ["push", false, [], "true"], - ["pull_request", true, [], "true"], + ["pull_request", true, [], "true", "true"], ["pull_request", false, [".github/workflows/node-release.yml"], "true"], [ "pull_request", @@ -86,6 +92,37 @@ bashTest( ["pull_request", false, ["docker/verify-container-compose.sh"], "true"], ["pull_request", false, ["sdk/typescript/src/index.ts"], "false"], ["pull_request", false, ["README.md"], "false"], + [ + "pull_request", + false, + [".github/workflows/node-ci.yml"], + "true", + "true", + ], + [ + "pull_request", + false, + [".github/workflows/test-quality.yml"], + "true", + "true", + ], + [ + "pull_request", + false, + [".github/actions/setup-tools/action.yml"], + "true", + "true", + ], + ...[ + "sdk/typescript/bunfig.toml", + "sdk/typescript/scripts/compare-test-reports.mts", + "sdk/typescript/scripts/prepare-windows-test-root.ps1", + "sdk/typescript/scripts/run-ci-tests.mts", + "sdk/typescript/scripts/test-shards.mts", + "sdk/typescript/scripts/test-timings.json", + ].map( + (path) => ["pull_request", false, [path], "false", "true"] as const, + ), ] as const) { const output = join(directory, "output"); await rm(output, { force: true }); @@ -116,6 +153,7 @@ bashTest( .map((line) => line.split("=")), ); expect(outputs["workflow-quality"], paths.join(",")).toBe(expected); + expect(outputs["test-quality"], paths.join(",")).toBe(runnerExpected); } } finally { await rm(directory, { recursive: true, force: true }); From 7a8ea428ea9b5736ca9c85ac87f2306325a5233b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 22 Oct 2026 21:22:52 +0000 Subject: [PATCH 6/6] test: close native configuration fixture before preflight reply --- .../tests-ts/cli-skill-native-config.test.ts | 100 +++++++++++++++++- .../tests-ts/fixtures/skill-native-config.mjs | 38 ++++++- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/tests-ts/cli-skill-native-config.test.ts b/sdk/typescript/tests-ts/cli-skill-native-config.test.ts index 007ce942a0..59a63a8cf8 100644 --- a/sdk/typescript/tests-ts/cli-skill-native-config.test.ts +++ b/sdk/typescript/tests-ts/cli-skill-native-config.test.ts @@ -1,6 +1,13 @@ import { execFileSync, spawn } from "node:child_process"; import { once } from "node:events"; -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,6 +25,97 @@ import { } from "../src/runtime.js"; import { capture } from "./cli-fixtures.js"; +test.each([ + ["codex_security_deep_scan", true, false], + ["codex_security_deep_scan", true, true], + ["codex_security_deep_scan", false, false], + ["codex-security", true, false], +] as const)( + "native configuration fixture owns preflight shutdown: client=%s, layers=%p, error=%p", + async (clientName, includeLayers, error) => { + const root = await mkdtemp(join(tmpdir(), "native-config-lifetime-")); + const exited = join(root, "native-exited"); + const response = { + id: 2, + ...(error + ? { error: { code: -32603, message: "synthetic configuration error" } } + : { + result: { + config: { model: "synthetic-model", daybreak: true }, + origins: { daybreak: { name: { type: "user" } } }, + layers: [{ name: { type: "user" }, config: { daybreak: true } }], + }, + }), + }; + const child = spawn( + process.execPath, + [ + fileURLToPath( + new URL("./fixtures/skill-native-config.mjs", import.meta.url), + ), + "-e", + `const { createInterface } = require("node:readline"); + const { writeFileSync } = require("node:fs"); + createInterface({ input: process.stdin }).on("line", line => { + const request = JSON.parse(line); + const response = request.method === "config/read" + ? ${JSON.stringify(response)} + : { id: request.id, result: {} }; + process.stdout.write(JSON.stringify(response) + "\\n"); + }); + process.on("exit", () => writeFileSync(${JSON.stringify(exited)}, "exited"));`, + ], + { + env: { ...process.env, SYNTHETIC_NATIVE_CODEX: process.execPath }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }, + ); + const closed = once(child, "close"); + const lines = createInterface({ input: child.stdout }); + const timeout = setTimeout(() => child.kill(), 10_000); + try { + child.stdin.write( + JSON.stringify({ + id: 1, + method: "initialize", + params: { clientInfo: { name: clientName } }, + }) + "\n", + ); + await once(lines, "line"); + child.stdin.write( + JSON.stringify({ + id: 2, + method: "config/read", + params: { includeLayers }, + }) + "\n", + ); + const [line] = await once(lines, "line"); + expect(line).toBe(JSON.stringify(response)); + if (clientName === "codex_security_deep_scan" && includeLayers) { + expect(await readFile(exited, "utf8")).toBe("exited"); + child.stdin.end(); + child.kill("SIGTERM"); + } else { + await expect(readFile(exited, "utf8")).rejects.toHaveProperty( + "code", + "ENOENT", + ); + child.stdin.end(); + } + await closed; + expect(await readFile(exited, "utf8")).toBe("exited"); + } finally { + child.stdin.end(); + await closed; + clearTimeout(timeout); + lines.close(); + child.stderr.destroy(); + await rm(root, { recursive: true, force: true }); + } + }, +); + test.each([0, 23])( "native configuration fixture preserves early exit %p with its input still open", async (exitCode) => { diff --git a/sdk/typescript/tests-ts/fixtures/skill-native-config.mjs b/sdk/typescript/tests-ts/fixtures/skill-native-config.mjs index 39b478635d..c22736c97e 100644 --- a/sdk/typescript/tests-ts/fixtures/skill-native-config.mjs +++ b/sdk/typescript/tests-ts/fixtures/skill-native-config.mjs @@ -10,11 +10,16 @@ const child = spawn(process.env.SYNTHETIC_NATIVE_CODEX, process.argv.slice(2), { }); child.stderr.pipe(process.stderr); const input = createInterface({ input: process.stdin }); -child.once("close", (code) => { - process.exitCode = code ?? 1; - input.close(); - process.stdin.destroy(); +const childClosed = new Promise((resolve) => { + child.once("close", (code) => { + process.exitCode = code ?? 1; + input.close(); + process.stdin.destroy(); + resolve(); + }); }); +let preflightClient = false; +let preflightConfigId; let turnRequest; let modelProvider; let initialConfig; @@ -24,6 +29,15 @@ await Promise.all([ for await (const line of input) { const request = JSON.parse(line); if (request.id !== undefined) methods.set(request.id, request.method); + if (request.method === "initialize") + preflightClient = + request.params.clientInfo.name === "codex_security_deep_scan"; + if ( + preflightClient && + request.method === "config/read" && + request.params.includeLayers === true + ) + preflightConfigId = request.id; if (request.method === "thread/start") { daybreakEnabled = request.params.daybreakEnabled ?? null; } @@ -45,8 +59,19 @@ await Promise.all([ child.stdin.end(); })(), (async () => { + let preflightResponse; for await (const line of createInterface({ input: child.stdout })) { const response = JSON.parse(line); + if ( + preflightConfigId !== undefined && + response.id === preflightConfigId + ) { + // The one-shot origins reader terminates this proxy after the response. + // Close its native child first, including on Windows where SIGTERM is forced. + preflightResponse = line; + child.stdin.end(); + continue; + } if (methods.get(response.id) === "thread/start") modelProvider = response.result?.modelProvider; if (methods.get(response.id) === "config/read") @@ -77,5 +102,10 @@ await Promise.all([ threadId, ); } + if (preflightResponse !== undefined) { + await childClosed; + process.stdout.write(preflightResponse + "\n"); + } })(), + childClosed, ]);