diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd262157eb9..1023e301730 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ permissions: contents: read concurrency: - group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-{1}', github.workflow, github.ref) || format('{0}-{1}-{2}', github.workflow, github.ref, github.sha)) }} + cancel-in-progress: true env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -23,7 +23,7 @@ jobs: permissions: contents: read if: github.event_name != 'pull_request' || !github.event.pull_request.draft - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 20 outputs: docs_only: ${{ steps.manifest.outputs.docs_only }} @@ -39,10 +39,14 @@ jobs: run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }} run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }} checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }} + channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }} checks_node_extensions_matrix: ${{ steps.manifest.outputs.checks_node_extensions_matrix }} run_checks: ${{ steps.manifest.outputs.run_checks }} checks_matrix: ${{ steps.manifest.outputs.checks_matrix }} - checks_node_core_test_matrix: ${{ steps.manifest.outputs.checks_node_core_test_matrix }} + run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }} + checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }} + run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }} + checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }} run_extension_fast: ${{ steps.manifest.outputs.run_extension_fast }} extension_fast_matrix: ${{ steps.manifest.outputs.extension_fast_matrix }} run_check: ${{ steps.manifest.outputs.run_check }} @@ -134,12 +138,16 @@ jobs: OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }} OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }} OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }} + OPENCLAW_CI_REPOSITORY: ${{ github.repository }} run: | node --input-type=module <<'EOF' import { appendFileSync } from "node:fs"; import { createNodeTestShards, } from "./scripts/lib/ci-node-test-plan.mjs"; + import { + createChannelContractTestShards, + } from "./scripts/lib/channel-contract-test-plan.mjs"; import { createExtensionTestShards, DEFAULT_EXTENSION_TEST_SHARD_COUNT, @@ -165,12 +173,16 @@ jobs: const outputPath = process.env.GITHUB_OUTPUT; const eventName = process.env.GITHUB_EVENT_NAME ?? "pull_request"; const isPush = eventName === "push"; + const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw"; const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY); const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED); const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly; - const runMacos = parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly; - const runAndroid = parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly; - const runWindows = parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly; + const runMacos = + parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository; + const runAndroid = + parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository; + const runWindows = + parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly && isCanonicalRepository; const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly; const runControlUiI18n = parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly; @@ -179,10 +191,13 @@ jobs: const changedExtensionsMatrix = hasChangedExtensions ? parseJson(process.env.OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX, { include: [] }) : { include: [] }; + const extensionTestShardCount = isCanonicalRepository + ? DEFAULT_EXTENSION_TEST_SHARD_COUNT + : Math.max(DEFAULT_EXTENSION_TEST_SHARD_COUNT, 36); const extensionShardMatrix = createMatrix( runNode ? createExtensionTestShards({ - shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT, + shardCount: extensionTestShardCount, }).map((shard) => ({ check_name: shard.checkName, extensions_csv: shard.extensionIds.join(","), @@ -191,6 +206,19 @@ jobs: })) : [], ); + const nodeTestShards = runNode + ? createNodeTestShards().map((shard) => ({ + check_name: shard.checkName, + runtime: "node", + task: "test-shard", + shard_name: shard.shardName, + configs: shard.configs, + includePatterns: shard.includePatterns, + requires_dist: shard.requiresDist, + })) + : []; + const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist); + const nodeTestDistShards = nodeTestShards.filter((shard) => shard.requires_dist); const manifest = { docs_only: docsOnly, @@ -209,13 +237,14 @@ jobs: ? [ { check_name: "checks-fast-bundled", runtime: "node", task: "bundled" }, { - check_name: "checks-fast-contracts-protocol", + check_name: "checks-fast-contracts-plugins", runtime: "node", - task: "contracts", + task: "contracts-plugins", }, ] : [], ), + channel_contracts_matrix: createMatrix(runNode ? createChannelContractTestShards() : []), checks_node_extensions_matrix: extensionShardMatrix, run_checks: runNode, checks_matrix: createMatrix( @@ -236,18 +265,10 @@ jobs: ] : [], ), - checks_node_core_test_matrix: createMatrix( - runNode - ? createNodeTestShards().map((shard) => ({ - check_name: shard.checkName, - runtime: "node", - task: "test-shard", - shard_name: shard.shardName, - configs: shard.configs, - requires_dist: shard.requiresDist, - })) - : [], - ), + run_checks_node_core_nondist: nodeTestNonDistShards.length > 0, + checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards), + run_checks_node_core_dist: nodeTestDistShards.length > 0, + checks_node_core_dist_matrix: createMatrix(nodeTestDistShards), run_extension_fast: hasChangedExtensions, extension_fast_matrix: createMatrix( hasChangedExtensions @@ -302,7 +323,7 @@ jobs: permissions: contents: read if: github.event_name != 'pull_request' || !github.event.pull_request.draft - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 20 env: PRE_COMMIT_CACHE_KEY_SUFFIX: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} @@ -402,7 +423,7 @@ jobs: contents: read needs: [preflight] if: needs.preflight.outputs.run_build_artifacts == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 20 steps: - name: Checkout @@ -499,7 +520,7 @@ jobs: name: ${{ matrix.check_name }} needs: [preflight] if: needs.preflight.outputs.run_checks_fast == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: fail-fast: false @@ -569,8 +590,11 @@ jobs: bundled) pnpm test:bundled ;; - contracts) - pnpm test:contracts + contracts-channels) + pnpm test:contracts:channels + ;; + contracts-plugins) + pnpm test:contracts:plugins ;; *) echo "Unsupported checks-fast task: $TASK" >&2 @@ -578,13 +602,115 @@ jobs: ;; esac + checks-fast-channel-contracts-shard: + permissions: + contents: read + name: ${{ matrix.checkName }} + needs: [preflight] + if: needs.preflight.outputs.run_checks_fast == 'true' + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }} + steps: + - name: Checkout + shell: bash + env: + CHECKOUT_REPO: ${{ github.repository }} + CHECKOUT_SHA: ${{ github.sha }} + CHECKOUT_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + workdir="$GITHUB_WORKSPACE" + auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')" + + reset_checkout_dir() { + mkdir -p "$workdir" + find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + } + + checkout_attempt() { + local attempt="$1" + + reset_checkout_dir + git init "$workdir" >/dev/null + git config --global --add safe.directory "$workdir" + git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}" + git -C "$workdir" config gc.auto 0 + + timeout --signal=TERM 30s git -C "$workdir" \ + -c protocol.version=2 \ + -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \ + fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ + "+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1 + + git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1 + test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1 + echo "checkout attempt ${attempt}/2 succeeded" + } + + for attempt in 1 2; do + if checkout_attempt "$attempt"; then + exit 0 + fi + echo "checkout attempt ${attempt}/2 failed" + sleep $((attempt * 5)) + done + + echo "checkout failed after 2 attempts" >&2 + exit 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Run channel contract shard + env: + OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }} + shell: bash + run: | + set -euo pipefail + include_file="$RUNNER_TEMP/channel-contract-include.json" + INCLUDE_FILE="$include_file" node --input-type=module <<'EOF' + import { writeFileSync } from "node:fs"; + + const includePatterns = JSON.parse(process.env.OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON ?? "[]"); + if (!Array.isArray(includePatterns) || includePatterns.length === 0) { + console.error("Missing channel contract include patterns"); + process.exit(1); + } + writeFileSync(process.env.INCLUDE_FILE, JSON.stringify(includePatterns), "utf8"); + EOF + OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels + + checks-fast-channel-contracts: + permissions: + contents: read + name: checks-fast-contracts-channels + needs: [preflight, checks-fast-channel-contracts-shard] + if: always() && needs.preflight.outputs.run_checks_fast == 'true' + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} + timeout-minutes: 5 + steps: + - name: Verify channel contract shards + env: + SHARD_RESULT: ${{ needs.checks-fast-channel-contracts-shard.result }} + run: | + if [ "$SHARD_RESULT" != "success" ]; then + echo "Channel contract shards failed: $SHARD_RESULT" >&2 + exit 1 + fi + checks-fast-protocol: permissions: contents: read name: "checks-fast-protocol" needs: [preflight] if: needs.preflight.outputs.run_checks_fast == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 30 steps: - name: Checkout @@ -649,7 +775,7 @@ jobs: name: ${{ matrix.check_name }} needs: [preflight] if: needs.preflight.outputs.run_checks_fast == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: fail-fast: false @@ -719,7 +845,7 @@ jobs: name: checks-node-extensions needs: [preflight, checks-node-extensions-shard] if: always() && needs.preflight.outputs.run_checks_fast == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - name: Verify extension shards @@ -737,7 +863,7 @@ jobs: name: ${{ matrix.check_name }} needs: [preflight, build-artifacts] if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' - runs-on: blacksmith-32vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: fail-fast: false @@ -809,17 +935,14 @@ jobs: env: TASK: ${{ matrix.task }} run: | - { - echo "OPENCLAW_VITEST_MAX_WORKERS=2" - if [ "$TASK" = "test" ]; then - echo "OPENCLAW_TEST_PROJECTS_LEAF_SHARDS=1" - echo "OPENCLAW_TEST_PROJECTS_PARALLEL=4" - echo "OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD=1" - fi - if [ "$TASK" = "channels" ]; then - echo "OPENCLAW_VITEST_MAX_WORKERS=1" - fi - } >> "$GITHUB_ENV" + echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV" + if [ "$TASK" = "test" ]; then + echo "OPENCLAW_TEST_PROJECTS_LEAF_SHARDS=1" >> "$GITHUB_ENV" + echo "OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD=1" >> "$GITHUB_ENV" + fi + if [ "$TASK" = "channels" ]; then + echo "OPENCLAW_VITEST_MAX_WORKERS=1" >> "$GITHUB_ENV" + fi - name: Download dist artifact if: matrix.task == 'test' @@ -863,17 +986,141 @@ jobs: ;; esac - checks-node-core-test-shard: + checks-node-core-test-nondist-shard: + permissions: + contents: read + name: ${{ matrix.check_name }} + needs: [preflight] + if: needs.preflight.outputs.run_checks_node_core_nondist == 'true' + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }} + steps: + - name: Checkout + shell: bash + env: + CHECKOUT_REPO: ${{ github.repository }} + CHECKOUT_SHA: ${{ github.sha }} + CHECKOUT_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + workdir="$GITHUB_WORKSPACE" + auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')" + + reset_checkout_dir() { + mkdir -p "$workdir" + find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + } + + checkout_attempt() { + local attempt="$1" + + reset_checkout_dir + git init "$workdir" >/dev/null + git config --global --add safe.directory "$workdir" + git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}" + git -C "$workdir" config gc.auto 0 + + timeout --signal=TERM 30s git -C "$workdir" \ + -c protocol.version=2 \ + -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \ + fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ + "+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1 + + git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1 + test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1 + echo "checkout attempt ${attempt}/2 succeeded" + } + + for attempt in 1 2; do + if checkout_attempt "$attempt"; then + exit 0 + fi + echo "checkout attempt ${attempt}/2 failed" + sleep $((attempt * 5)) + done + + echo "checkout failed after 2 attempts" >&2 + exit 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: "${{ matrix.node_version || '24.x' }}" + cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}" + install-bun: "false" + + - name: Configure Node test resources + run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV" + + - name: Run Node test shard + env: + NODE_OPTIONS: --max-old-space-size=6144 + OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }} + OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }} + shell: bash + run: | + set -euo pipefail + node --input-type=module <<'EOF' + import { spawnSync } from "node:child_process"; + import { writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { resolveVitestCliEntry, resolveVitestNodeArgs } from "./scripts/run-vitest.mjs"; + + const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"); + if (!Array.isArray(configs) || configs.length === 0) { + console.error("Missing node test shard configs"); + process.exit(1); + } + const includePatterns = JSON.parse(process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null"); + const childEnv = { ...process.env }; + if (Array.isArray(includePatterns) && includePatterns.length > 0) { + const includeFile = join( + process.env.RUNNER_TEMP ?? ".", + `node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`, + ); + writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8"); + childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile; + } + + for (const config of configs) { + console.error(`[test] starting ${config}`); + const result = spawnSync( + "pnpm", + [ + "exec", + "node", + ...resolveVitestNodeArgs(process.env), + resolveVitestCliEntry(), + "run", + "--config", + config, + ], + { + env: childEnv, + stdio: "inherit", + }, + ); + if ((result.status ?? 1) !== 0) { + process.exit(result.status ?? 1); + } + } + EOF + + checks-node-core-test-dist-shard: permissions: contents: read name: ${{ matrix.check_name }} needs: [preflight, build-artifacts] - if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' - runs-on: blacksmith-16vcpu-ubuntu-2404 + if: always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success' + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: fail-fast: false - matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_test_matrix) }} + matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_dist_matrix) }} steps: - name: Checkout shell: bash @@ -935,14 +1182,13 @@ jobs: - name: Restore dist cache id: dist-cache - if: matrix.requires_dist == true uses: actions/cache@v5 with: path: dist/ key: ${{ runner.os }}-dist-build-${{ github.sha }} - name: Download dist artifact - if: matrix.requires_dist == true && steps.dist-cache.outputs.cache-hit != 'true' + if: steps.dist-cache.outputs.cache-hit != 'true' uses: actions/download-artifact@v8 with: name: dist-build @@ -958,11 +1204,14 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=6144 OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }} + OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }} shell: bash run: | set -euo pipefail node --input-type=module <<'EOF' import { spawnSync } from "node:child_process"; + import { writeFileSync } from "node:fs"; + import { join } from "node:path"; import { resolveVitestCliEntry, resolveVitestNodeArgs } from "./scripts/run-vitest.mjs"; const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"); @@ -970,6 +1219,16 @@ jobs: console.error("Missing node test shard configs"); process.exit(1); } + const includePatterns = JSON.parse(process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null"); + const childEnv = { ...process.env }; + if (Array.isArray(includePatterns) && includePatterns.length > 0) { + const includeFile = join( + process.env.RUNNER_TEMP ?? ".", + `node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`, + ); + writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8"); + childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile; + } for (const config of configs) { console.error(`[test] starting ${config}`); @@ -985,7 +1244,7 @@ jobs: config, ], { - env: process.env, + env: childEnv, stdio: "inherit", }, ); @@ -999,17 +1258,24 @@ jobs: permissions: contents: read name: checks-node-core - needs: [preflight, checks-node-core-test-shard] + needs: [preflight, checks-node-core-test-nondist-shard, checks-node-core-test-dist-shard] if: always() && needs.preflight.outputs.run_checks == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - name: Verify node test shards env: - SHARD_RESULT: ${{ needs.checks-node-core-test-shard.result }} + DIST_SHARD_RESULT: ${{ needs.checks-node-core-test-dist-shard.result }} + NONDIST_SHARD_RESULT: ${{ needs.checks-node-core-test-nondist-shard.result }} + RUN_DIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_dist }} + RUN_NONDIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_nondist }} run: | - if [ "$SHARD_RESULT" != "success" ]; then - echo "Node test shards failed: $SHARD_RESULT" >&2 + if [ "$RUN_NONDIST_SHARDS" = "true" ] && [ "$NONDIST_SHARD_RESULT" != "success" ]; then + echo "Node non-dist test shards failed: $NONDIST_SHARD_RESULT" >&2 + exit 1 + fi + if [ "$RUN_DIST_SHARDS" = "true" ] && [ "$DIST_SHARD_RESULT" != "success" ]; then + echo "Node dist test shards failed: $DIST_SHARD_RESULT" >&2 exit 1 fi @@ -1019,7 +1285,7 @@ jobs: name: "extension-fast" needs: [preflight] if: needs.preflight.outputs.run_extension_fast == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: fail-fast: false @@ -1083,15 +1349,31 @@ jobs: OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }} run: pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION" - # Types, lint, and format check. - check: + # Types, lint, and format check shards. + check-shard: permissions: contents: read - name: "check" + name: ${{ matrix.check_name }} needs: [preflight] if: always() && needs.preflight.outputs.run_check == 'true' - runs-on: blacksmith-32vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - check_name: check-preflight-guards + task: preflight-guards + - check_name: check-prod-types + task: prod-types + - check_name: check-lint + task: lint + - check_name: check-policy-guards + task: policy-guards + - check_name: check-test-types + task: test-types + - check_name: check-strict-smoke + task: strict-smoke steps: - name: Checkout shell: bash @@ -1146,18 +1428,60 @@ jobs: with: install-bun: "false" - - name: Check types and lint and oxfmt + - name: Run check shard env: OPENCLAW_LOCAL_CHECK: "0" - run: pnpm check + TASK: ${{ matrix.task }} + shell: bash + run: | + set -euo pipefail + case "$TASK" in + preflight-guards) + pnpm check:no-conflict-markers + pnpm tool-display:check + pnpm check:host-env-policy:swift + ;; + prod-types) + pnpm tsgo:prod + ;; + lint) + pnpm lint + ;; + policy-guards) + pnpm lint:webhook:no-low-level-body-read + pnpm lint:auth:no-pairing-store-group + pnpm lint:auth:pairing-account-scope + pnpm check:import-cycles + ;; + test-types) + pnpm check:test-types + ;; + strict-smoke) + pnpm build:strict-smoke + ;; + *) + echo "Unsupported check task: $TASK" >&2 + exit 1 + ;; + esac - - name: Check test types + check: + permissions: + contents: read + name: "check" + needs: [preflight, check-shard] + if: always() && needs.preflight.outputs.run_check == 'true' + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} + timeout-minutes: 5 + steps: + - name: Verify check shards env: - OPENCLAW_LOCAL_CHECK: "0" - run: pnpm check:test-types - - - name: Strict TS build smoke - run: pnpm build:strict-smoke + SHARD_RESULT: ${{ needs.check-shard.result }} + run: | + if [ "$SHARD_RESULT" != "success" ]; then + echo "Check shards failed: $SHARD_RESULT" >&2 + exit 1 + fi check-additional-shard: permissions: @@ -1165,7 +1489,7 @@ jobs: name: ${{ matrix.check_name }} needs: [preflight] if: always() && needs.preflight.outputs.run_check_additional == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 20 strategy: fail-fast: false @@ -1173,10 +1497,18 @@ jobs: include: - check_name: check-additional-boundaries group: boundaries - - check_name: check-additional-extension-surfaces - group: extension-surfaces - - check_name: check-additional-runtime-topology - group: runtime-topology + - check_name: check-additional-extension-channels + group: extension-channels + - check_name: check-additional-extension-bundled + group: extension-bundled + - check_name: check-additional-extension-package-boundary-compile + group: extension-package-boundary-compile + - check_name: check-additional-extension-package-boundary-canary + group: extension-package-boundary-canary + - check_name: check-additional-runtime-topology-gateway + group: runtime-topology-gateway + - check_name: check-additional-runtime-topology-architecture + group: runtime-topology-architecture steps: - name: Checkout shell: bash @@ -1276,16 +1608,25 @@ jobs: run_check "extension-relative-outside-package-boundary" pnpm run lint:extensions:no-relative-outside-package run_check "lint:ui:no-raw-window-open" pnpm lint:ui:no-raw-window-open ;; - extension-surfaces) + extension-channels) run_check "lint:extensions:channels" pnpm run lint:extensions:channels - run_check "lint:extensions:bundled" pnpm run lint:extensions:bundled - run_check "test:extensions:package-boundary" pnpm run test:extensions:package-boundary ;; - runtime-topology) + extension-bundled) + run_check "lint:extensions:bundled" pnpm run lint:extensions:bundled + ;; + extension-package-boundary-compile) + run_check "test:extensions:package-boundary:compile" pnpm run test:extensions:package-boundary:compile + ;; + extension-package-boundary-canary) + run_check "test:extensions:package-boundary:canary" pnpm run test:extensions:package-boundary:canary + ;; + runtime-topology-gateway) if [ "$RUN_CONTROL_UI_I18N" = "true" ]; then run_check "ui:i18n:check" pnpm ui:i18n:check fi run_check "gateway-watch-regression" pnpm test:gateway:watch-regression + ;; + runtime-topology-architecture) run_check "check:architecture" pnpm check:architecture ;; *) @@ -1297,7 +1638,7 @@ jobs: exit "$failures" - name: Upload gateway watch regression artifacts - if: always() && matrix.group == 'runtime-topology' + if: always() && matrix.group == 'runtime-topology-gateway' uses: actions/upload-artifact@v7 with: name: gateway-watch-regression @@ -1310,7 +1651,7 @@ jobs: name: "check-additional" needs: [preflight, check-additional-shard] if: always() && needs.preflight.outputs.run_check_additional == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - name: Verify additional check shards @@ -1328,7 +1669,7 @@ jobs: name: "build-smoke" needs: [preflight, build-artifacts] if: always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 20 steps: - name: Checkout @@ -1424,7 +1765,7 @@ jobs: contents: read needs: [preflight] if: needs.preflight.outputs.run_check_docs == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 20 steps: - name: Checkout @@ -1488,7 +1829,7 @@ jobs: contents: read needs: [preflight] if: needs.preflight.outputs.run_skills_python_job == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 20 steps: - name: Checkout @@ -1519,7 +1860,7 @@ jobs: name: ${{ matrix.check_name }} needs: [preflight, build-artifacts] if: always() && needs.preflight.outputs.run_checks_windows == 'true' && needs.build-artifacts.result == 'success' - runs-on: blacksmith-32vcpu-windows-2025 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-32vcpu-windows-2025' || 'windows-2025' }} timeout-minutes: 60 env: NODE_OPTIONS: --max-old-space-size=6144 @@ -1581,9 +1922,6 @@ jobs: - name: Capture node path run: | node_bin="$(dirname "$(node -p 'process.execPath')")" - if command -v cygpath >/dev/null 2>&1; then - node_bin="$(cygpath -u "$node_bin")" - fi echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV" - name: Install dependencies @@ -1636,7 +1974,7 @@ jobs: name: ${{ matrix.check_name }} needs: [preflight, build-artifacts] if: always() && needs.preflight.outputs.run_macos_node == 'true' && needs.build-artifacts.result == 'success' - runs-on: blacksmith-6vcpu-macos-latest + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest' }} timeout-minutes: 20 strategy: fail-fast: false @@ -1715,7 +2053,7 @@ jobs: name: "macos-swift" needs: [preflight] if: needs.preflight.outputs.run_macos_swift == 'true' - runs-on: blacksmith-12vcpu-macos-latest + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }} timeout-minutes: 20 steps: - name: Checkout @@ -1826,7 +2164,7 @@ jobs: name: ${{ matrix.check_name }} needs: [preflight] if: needs.preflight.outputs.run_android_job == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 20 strategy: fail-fast: false diff --git a/docs/ci.md b/docs/ci.md index 80ad7a774d6..a73e61c1446 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -12,25 +12,26 @@ The CI runs on every push to `main` and every pull request. It uses smart scopin ## Job Overview -| Job | Purpose | When it runs | -| ------------------------ | ------------------------------------------------------------------------------------------- | ----------------------------------- | -| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs | -| `security-fast` | Private key detection, workflow audit via `zizmor`, production dependency audit | Always on non-draft pushes and PRs | -| `build-artifacts` | Build `dist/` and the Control UI once, upload reusable artifacts for downstream jobs | Node-relevant changes | -| `checks-fast-core` | Fast Linux correctness lanes such as bundled/plugin-contract/protocol checks | Node-relevant changes | -| `checks-node-extensions` | Full bundled-plugin test shards across the extension suite | Node-relevant changes | -| `checks-node-core-test` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes | -| `extension-fast` | Focused tests for only the changed bundled plugins | When extension changes are detected | -| `check` | Main local gate in CI: `pnpm check`, `pnpm check:test-types`, and `pnpm build:strict-smoke` | Node-relevant changes | -| `check-additional` | Architecture, boundary, import-cycle guards plus the gateway watch regression harness | Node-relevant changes | -| `build-smoke` | Built-CLI smoke tests and startup-memory smoke | Node-relevant changes | -| `checks` | Remaining Linux Node lanes: channel tests and push-only Node 22 compatibility | Node-relevant changes | -| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed | -| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes | -| `checks-windows` | Windows-specific test lanes | Windows-relevant changes | -| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes | -| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes | -| `android` | Android build and test matrix | Android-relevant changes | +| Job | Purpose | When it runs | +| -------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------- | +| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs | +| `security-fast` | Private key detection, workflow audit via `zizmor`, production dependency audit | Always on non-draft pushes and PRs | +| `build-artifacts` | Build `dist/` and the Control UI once, upload reusable artifacts for downstream jobs | Node-relevant changes | +| `checks-fast-core` | Fast Linux correctness lanes such as bundled/plugin-contract/protocol checks | Node-relevant changes | +| `checks-fast-contracts-channels` | Sharded channel contract checks with a stable aggregate check result | Node-relevant changes | +| `checks-node-extensions` | Full bundled-plugin test shards across the extension suite | Node-relevant changes | +| `checks-node-core-test` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes | +| `extension-fast` | Focused tests for only the changed bundled plugins | When extension changes are detected | +| `check` | Sharded main local gate equivalent: prod types, lint, guards, test types, and strict smoke | Node-relevant changes | +| `check-additional` | Architecture, boundary, extension-surface guards, package-boundary, and gateway-watch shards | Node-relevant changes | +| `build-smoke` | Built-CLI smoke tests and startup-memory smoke | Node-relevant changes | +| `checks` | Remaining Linux Node lanes: channel tests and push-only Node 22 compatibility | Node-relevant changes | +| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed | +| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes | +| `checks-windows` | Windows-specific test lanes | Windows-relevant changes | +| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes | +| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes | +| `android` | Android build and test matrix | Android-relevant changes | ## Fail-Fast Order @@ -39,7 +40,7 @@ Jobs are ordered so cheap checks fail before expensive ones run: 1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs. 2. `security-fast`, `check`, `check-additional`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs. 3. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready. -4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-node-extensions`, `checks-node-core-test`, `extension-fast`, `checks`, `checks-windows`, `macos-node`, `macos-swift`, and `android`. +4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-channels`, `checks-node-extensions`, `checks-node-core-test`, `extension-fast`, `checks`, `checks-windows`, `macos-node`, `macos-swift`, and `android`. Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It computes `run_install_smoke` from the narrower changed-smoke signal, so Docker/install smoke only runs for install, packaging, and container-relevant changes. @@ -48,6 +49,8 @@ Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by On pushes, the `checks` matrix adds the push-only `compat-node22` lane. On pull requests, that lane is skipped and the matrix stays focused on the normal test/channel lanes. +The slowest Node test families are split into include-file shards so each job stays small: channel contracts split registry/core/extension coverage into focused shards, and auto-reply reply tests split each large prefix group into two include-pattern shards. `check-additional` also separates package-boundary compile/canary work from runtime topology gateway/architecture work. + ## Runners | Runner | Jobs | diff --git a/scripts/lib/channel-contract-test-plan.mjs b/scripts/lib/channel-contract-test-plan.mjs new file mode 100644 index 00000000000..1f191d27d65 --- /dev/null +++ b/scripts/lib/channel-contract-test-plan.mjs @@ -0,0 +1,54 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; + +function listContractTestFiles(rootDir = "src/channels/plugins/contracts") { + if (!existsSync(rootDir)) { + return []; + } + + return readdirSync(rootDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts")) + .map((entry) => join(rootDir, entry.name).replaceAll("\\", "/")) + .toSorted((a, b) => a.localeCompare(b)); +} + +export function createChannelContractTestShards() { + const rootDir = "src/channels/plugins/contracts"; + const groups = { + "checks-fast-contracts-channels-registry-a": [], + "checks-fast-contracts-channels-registry-b": [], + "checks-fast-contracts-channels-core-a": [], + "checks-fast-contracts-channels-core-b": [], + "checks-fast-contracts-channels-extensions": [], + }; + const pushBalanced = (firstKey, secondKey, file) => { + const target = groups[firstKey].length <= groups[secondKey].length ? firstKey : secondKey; + groups[target].push(file); + }; + + for (const file of listContractTestFiles(rootDir)) { + const name = relative(rootDir, file).replaceAll("\\", "/"); + if (name.startsWith("plugins-core-extension.")) { + groups["checks-fast-contracts-channels-extensions"].push(file); + } else if (name.startsWith("plugins-core.") || name.startsWith("plugin.")) { + pushBalanced( + "checks-fast-contracts-channels-core-a", + "checks-fast-contracts-channels-core-b", + file, + ); + } else { + pushBalanced( + "checks-fast-contracts-channels-registry-a", + "checks-fast-contracts-channels-registry-b", + file, + ); + } + } + + return Object.entries(groups).map(([checkName, includePatterns]) => ({ + checkName, + includePatterns, + task: "contracts-channels", + runtime: "node", + })); +} diff --git a/scripts/lib/ci-node-test-plan.mjs b/scripts/lib/ci-node-test-plan.mjs index 732ba6a3ebc..d0ef0b51eab 100644 --- a/scripts/lib/ci-node-test-plan.mjs +++ b/scripts/lib/ci-node-test-plan.mjs @@ -1,3 +1,5 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; import { fullSuiteVitestShards } from "../../test/vitest/vitest.test-shards.mjs"; const EXCLUDED_FULL_SUITE_SHARDS = new Set([ @@ -7,7 +9,137 @@ const EXCLUDED_FULL_SUITE_SHARDS = new Set([ ]); const EXCLUDED_PROJECT_CONFIGS = new Set(["test/vitest/vitest.channels.config.ts"]); +function listTestFiles(rootDir) { + if (!existsSync(rootDir)) { + return []; + } + + const files = []; + const visit = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + visit(path); + continue; + } + if (entry.isFile() && entry.name.endsWith(".test.ts")) { + files.push(path.replaceAll("\\", "/")); + } + } + }; + + visit(rootDir); + return files.toSorted((a, b) => a.localeCompare(b)); +} + +function createAutoReplyReplySplitShards() { + const files = listTestFiles("src/auto-reply/reply"); + const groups = { + "auto-reply-reply-agent-runner": [], + "auto-reply-reply-commands": [], + "auto-reply-reply-dispatch": [], + "auto-reply-reply-state-routing": [], + }; + + for (const file of files) { + const name = relative("src/auto-reply/reply", file).replaceAll("\\", "/"); + if ( + name.startsWith("agent-runner") || + name.startsWith("acp-") || + name === "abort.test.ts" || + name === "bash-command.stop.test.ts" || + name.startsWith("block-") + ) { + groups["auto-reply-reply-agent-runner"].push(file); + } else if (name.startsWith("commands")) { + groups["auto-reply-reply-commands"].push(file); + } else if ( + name.startsWith("directive-") || + name.startsWith("dispatch") || + name.startsWith("followup-") || + name.startsWith("get-reply") + ) { + groups["auto-reply-reply-dispatch"].push(file); + } else { + groups["auto-reply-reply-state-routing"].push(file); + } + } + + return Object.entries(groups).flatMap(([groupName, includePatterns]) => { + const midpoint = Math.ceil(includePatterns.length / 2); + return [ + { + shardName: `${groupName}-a`, + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + includePatterns: includePatterns.slice(0, midpoint), + requiresDist: false, + }, + { + shardName: `${groupName}-b`, + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + includePatterns: includePatterns.slice(midpoint), + requiresDist: false, + }, + ].filter((shard) => shard.includePatterns.length > 0); + }); +} + const SPLIT_NODE_SHARDS = new Map([ + [ + "core-runtime", + [ + { + shardName: "core-runtime-infra", + configs: [ + "test/vitest/vitest.infra.config.ts", + "test/vitest/vitest.hooks.config.ts", + "test/vitest/vitest.runtime-config.config.ts", + "test/vitest/vitest.secrets.config.ts", + "test/vitest/vitest.logging.config.ts", + "test/vitest/vitest.process.config.ts", + ], + requiresDist: true, + }, + { + shardName: "core-runtime-media-ui", + configs: [ + "test/vitest/vitest.media.config.ts", + "test/vitest/vitest.media-understanding.config.ts", + "test/vitest/vitest.tui.config.ts", + "test/vitest/vitest.ui.config.ts", + "test/vitest/vitest.wizard.config.ts", + ], + requiresDist: true, + }, + { + shardName: "core-runtime-shared", + configs: [ + "test/vitest/vitest.acp.config.ts", + "test/vitest/vitest.cron.config.ts", + "test/vitest/vitest.shared-core.config.ts", + "test/vitest/vitest.tasks.config.ts", + "test/vitest/vitest.utils.config.ts", + ], + requiresDist: true, + }, + ], + ], + [ + "auto-reply", + [ + { + shardName: "auto-reply-core", + configs: ["test/vitest/vitest.auto-reply-core.config.ts"], + requiresDist: false, + }, + { + shardName: "auto-reply-top-level", + configs: ["test/vitest/vitest.auto-reply-top-level.config.ts"], + requiresDist: false, + }, + ...createAutoReplyReplySplitShards(), + ], + ], [ "agentic", [ @@ -77,6 +209,7 @@ export function createNodeTestShards() { checkName: formatNodeTestShardCheckName(splitShard.shardName), shardName: splitShard.shardName, configs: splitConfigs, + ...(splitShard.includePatterns ? { includePatterns: splitShard.includePatterns } : {}), requiresDist: splitShard.requiresDist, }, ]; diff --git a/test/scripts/channel-contract-test-plan.test.ts b/test/scripts/channel-contract-test-plan.test.ts new file mode 100644 index 00000000000..5b862b2de80 --- /dev/null +++ b/test/scripts/channel-contract-test-plan.test.ts @@ -0,0 +1,62 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createChannelContractTestShards } from "../../scripts/lib/channel-contract-test-plan.mjs"; + +function listContractTests(rootDir = "src/channels/plugins/contracts"): string[] { + if (!existsSync(rootDir)) { + return []; + } + + return readdirSync(rootDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts")) + .map((entry) => join(rootDir, entry.name).replaceAll("\\", "/")) + .toSorted((a, b) => a.localeCompare(b)); +} + +describe("scripts/lib/channel-contract-test-plan.mjs", () => { + it("splits channel contracts into focused shards", () => { + expect( + createChannelContractTestShards().map((shard) => ({ + checkName: shard.checkName, + runtime: shard.runtime, + task: shard.task, + })), + ).toEqual([ + { + checkName: "checks-fast-contracts-channels-registry-a", + runtime: "node", + task: "contracts-channels", + }, + { + checkName: "checks-fast-contracts-channels-registry-b", + runtime: "node", + task: "contracts-channels", + }, + { + checkName: "checks-fast-contracts-channels-core-a", + runtime: "node", + task: "contracts-channels", + }, + { + checkName: "checks-fast-contracts-channels-core-b", + runtime: "node", + task: "contracts-channels", + }, + { + checkName: "checks-fast-contracts-channels-extensions", + runtime: "node", + task: "contracts-channels", + }, + ]); + }); + + it("covers every channel contract test exactly once", () => { + const actual = createChannelContractTestShards() + .flatMap((shard) => shard.includePatterns) + .toSorted((a, b) => a.localeCompare(b)); + + expect(actual).toEqual(listContractTests()); + expect(new Set(actual).size).toBe(actual.length); + }); +}); diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index ee8698ef48c..bfce7667382 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -1,6 +1,29 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createNodeTestShards } from "../../scripts/lib/ci-node-test-plan.mjs"; +function listTestFiles(rootDir: string): string[] { + if (!existsSync(rootDir)) { + return []; + } + + const files: string[] = []; + const visit = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + visit(path); + } else if (entry.isFile() && entry.name.endsWith(".test.ts")) { + files.push(path.replaceAll("\\", "/")); + } + } + }; + + visit(rootDir); + return files.toSorted((a, b) => a.localeCompare(b)); +} + describe("scripts/lib/ci-node-test-plan.mjs", () => { it("names the node shard checks as core test lanes", () => { const shards = createNodeTestShards(); @@ -32,11 +55,60 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { expect(requiresDistShardNames).toEqual([ "core-support-boundary", - "core-runtime", + "core-runtime-infra", + "core-runtime-media-ui", + "core-runtime-shared", "agentic-agents-plugins", ]); }); + it("splits core runtime configs into smaller dist-dependent shards", () => { + const runtimeShards = createNodeTestShards() + .filter((shard) => shard.shardName.startsWith("core-runtime-")) + .map((shard) => ({ + configs: shard.configs, + requiresDist: shard.requiresDist, + shardName: shard.shardName, + })); + + expect(runtimeShards).toEqual([ + { + configs: [ + "test/vitest/vitest.infra.config.ts", + "test/vitest/vitest.hooks.config.ts", + "test/vitest/vitest.runtime-config.config.ts", + "test/vitest/vitest.secrets.config.ts", + "test/vitest/vitest.logging.config.ts", + "test/vitest/vitest.process.config.ts", + ], + requiresDist: true, + shardName: "core-runtime-infra", + }, + { + configs: [ + "test/vitest/vitest.media.config.ts", + "test/vitest/vitest.media-understanding.config.ts", + "test/vitest/vitest.tui.config.ts", + "test/vitest/vitest.ui.config.ts", + "test/vitest/vitest.wizard.config.ts", + ], + requiresDist: true, + shardName: "core-runtime-media-ui", + }, + { + configs: [ + "test/vitest/vitest.acp.config.ts", + "test/vitest/vitest.cron.config.ts", + "test/vitest/vitest.shared-core.config.ts", + "test/vitest/vitest.tasks.config.ts", + "test/vitest/vitest.utils.config.ts", + ], + requiresDist: true, + shardName: "core-runtime-shared", + }, + ]); + }); + it("splits the agentic lane into control-plane, commands, and agent/plugin shards", () => { const shards = createNodeTestShards(); const controlPlaneShard = shards.find((shard) => shard.shardName === "agentic-control-plane"); @@ -77,4 +149,89 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { requiresDist: true, }); }); + + it("splits auto-reply into independent core, top-level, and reply subtree shards", () => { + const shards = createNodeTestShards(); + const autoReplyShards = shards + .filter((shard) => shard.shardName.startsWith("auto-reply")) + .map((shard) => ({ + checkName: shard.checkName, + configs: shard.configs, + requiresDist: shard.requiresDist, + shardName: shard.shardName, + })); + + expect(autoReplyShards).toEqual([ + { + checkName: "checks-node-auto-reply-core", + configs: ["test/vitest/vitest.auto-reply-core.config.ts"], + requiresDist: false, + shardName: "auto-reply-core", + }, + { + checkName: "checks-node-auto-reply-top-level", + configs: ["test/vitest/vitest.auto-reply-top-level.config.ts"], + requiresDist: false, + shardName: "auto-reply-top-level", + }, + { + checkName: "checks-node-auto-reply-reply-agent-runner-a", + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + requiresDist: false, + shardName: "auto-reply-reply-agent-runner-a", + }, + { + checkName: "checks-node-auto-reply-reply-agent-runner-b", + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + requiresDist: false, + shardName: "auto-reply-reply-agent-runner-b", + }, + { + checkName: "checks-node-auto-reply-reply-commands-a", + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + requiresDist: false, + shardName: "auto-reply-reply-commands-a", + }, + { + checkName: "checks-node-auto-reply-reply-commands-b", + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + requiresDist: false, + shardName: "auto-reply-reply-commands-b", + }, + { + checkName: "checks-node-auto-reply-reply-dispatch-a", + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + requiresDist: false, + shardName: "auto-reply-reply-dispatch-a", + }, + { + checkName: "checks-node-auto-reply-reply-dispatch-b", + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + requiresDist: false, + shardName: "auto-reply-reply-dispatch-b", + }, + { + checkName: "checks-node-auto-reply-reply-state-routing-a", + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + requiresDist: false, + shardName: "auto-reply-reply-state-routing-a", + }, + { + checkName: "checks-node-auto-reply-reply-state-routing-b", + configs: ["test/vitest/vitest.auto-reply-reply.config.ts"], + requiresDist: false, + shardName: "auto-reply-reply-state-routing-b", + }, + ]); + }); + + it("covers every auto-reply reply test exactly once across split shards", () => { + const actual = createNodeTestShards() + .filter((shard) => shard.shardName.startsWith("auto-reply-reply-")) + .flatMap((shard) => shard.includePatterns ?? []) + .toSorted((a, b) => a.localeCompare(b)); + + expect(actual).toEqual(listTestFiles("src/auto-reply/reply")); + expect(new Set(actual).size).toBe(actual.length); + }); });