From fa0729e1458063603ffacb38a526e1aad997e53f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 00:54:40 +0100 Subject: [PATCH] test: auto-discover vitest suites --- .github/workflows/ci.yml | 37 ++++---- docs/ci.md | 19 +++- docs/reference/RELEASING.md | 18 +++- scripts/e2e/npm-telegram-live-runner.ts | 22 +++-- src/docker-build-cache.test.ts | 40 ++------ src/scripts/test-projects.test.ts | 11 +-- test/scripts/test-projects.test.ts | 116 ++++++++++++++++++++++++ test/vitest-scoped-config.test.ts | 14 +-- test/vitest/vitest.infra.config.ts | 2 + test/vitest/vitest.plugin-sdk.config.ts | 3 +- test/vitest/vitest.plugins.config.ts | 2 +- test/vitest/vitest.test-shards.mjs | 1 + test/vitest/vitest.tooling.config.ts | 10 +- test/vitest/vitest.ui.config.ts | 6 +- test/vitest/vitest.unit-fast-paths.mjs | 2 + test/vitest/vitest.unit-src.config.ts | 2 +- ui/src/styles/components.test.ts | 3 +- ui/src/styles/config-quick.test.ts | 3 +- ui/src/styles/layout.mobile.test.ts | 3 +- 19 files changed, 220 insertions(+), 94 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7cd5af05ff..7505e45af83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: push: branches: [main] paths-ignore: @@ -13,8 +14,8 @@ permissions: contents: read concurrency: - group: ${{ github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha)) }} - cancel-in-progress: true + group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-v1-{1}', github.workflow, github.run_id) || (github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha))) }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -75,6 +76,7 @@ jobs: submodules: false - name: Ensure preflight base commit + if: github.event_name != 'workflow_dispatch' uses: ./.github/actions/ensure-base-commit with: base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} @@ -82,11 +84,12 @@ jobs: - name: Detect docs-only changes id: docs_scope + if: github.event_name != 'workflow_dispatch' uses: ./.github/actions/detect-docs-changes - name: Detect changed scopes id: changed_scope - if: steps.docs_scope.outputs.docs_only != 'true' + if: github.event_name != 'workflow_dispatch' && steps.docs_scope.outputs.docs_only != 'true' shell: bash run: | set -euo pipefail @@ -101,7 +104,7 @@ jobs: - name: Detect changed extensions id: changed_extensions - if: steps.docs_scope.outputs.docs_only != 'true' && steps.changed_scope.outputs.run_node == 'true' + if: github.event_name != 'workflow_dispatch' && steps.docs_scope.outputs.docs_only != 'true' && steps.changed_scope.outputs.run_node == 'true' env: BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} BASE_REF: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }} @@ -125,19 +128,19 @@ jobs: - name: Build CI manifest id: manifest env: - OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }} - OPENCLAW_CI_DOCS_CHANGED: ${{ steps.docs_scope.outputs.docs_changed }} - OPENCLAW_CI_RUN_NODE: ${{ steps.changed_scope.outputs.run_node || 'false' }} - OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }} - OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }} - OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }} - OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ steps.changed_scope.outputs.run_node_fast_only || 'false' }} - OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }} - OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }} - OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }} - 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_DOCS_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.docs_scope.outputs.docs_only }} + OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }} + OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }} + OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }} + OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_android || 'false' }} + OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }} + OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }} + OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }} + OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }} + OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_skills_python || 'false' }} + OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }} + OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_extensions.outputs.has_changed_extensions || 'false' }} + OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ github.event_name == 'workflow_dispatch' && '{"include":[]}' || steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }} OPENCLAW_CI_REPOSITORY: ${{ github.repository }} run: | node --input-type=module <<'EOF' diff --git a/docs/ci.md b/docs/ci.md index 1387fb4d3e1..070e744e576 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -6,7 +6,7 @@ read_when: - You are debugging failing GitHub Actions checks --- -The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only unrelated areas changed. +The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only unrelated areas changed. Manual `workflow_dispatch` runs intentionally bypass smart scoping and fan out the full CI graph for release candidates or broad validation. QA Lab has dedicated CI lanes outside the main smart-scoped workflow. The `Parity gate` workflow runs on matching PR changes and manual dispatch; it @@ -79,6 +79,19 @@ gh workflow run duplicate-after-merge.yml \ | `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes | | `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch | +Manual CI dispatches run the same job graph as normal CI but force every +scoped lane on: Linux Node shards, bundled-plugin shards, channel contracts, +`check`, `check-additional`, build smoke, docs checks, Python skills, Windows, +macOS, Android, and Control UI i18n. They do not run the PR-only +`extension-fast` lane because the full bundled-plugin shard matrix already +covers bundled-plugin tests. Manual runs use a unique concurrency group so a +release-candidate full suite is not cancelled by another push or PR run on the +same ref. + +```bash +gh workflow run ci.yml --ref release/YYYY.M.D +``` + ## Fail-fast order Jobs are ordered so cheap checks fail before expensive ones run: @@ -89,6 +102,8 @@ Jobs are ordered so cheap checks fail before expensive ones run: 4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-channels`, `checks-node-extensions`, `checks-node-core-test`, PR-only `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`. +Manual dispatch skips changed-scope detection and makes the preflight manifest +act as if every scoped area changed. CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes. CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits use a fast Node-only manifest path: preflight, security, and a single `checks-fast-core` task. That path avoids build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the changed files are limited to the routing or helper surfaces that the fast task exercises directly. Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards. @@ -103,7 +118,7 @@ Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest`, `extension-fast` is PR-only because push runs already execute the full bundled plugin shards. That keeps changed-plugin feedback for reviews without reserving an extra Blacksmith worker on `main` for coverage already present in `checks-node-extensions`. GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Aggregate shard checks use `!cancelled() && always()` so they still report normal shard failures but do not queue after the whole workflow has already been superseded. -The CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. +The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs. ## Runners diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 1600117b3a8..ea0f9deec96 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -49,6 +49,12 @@ OpenClaw has three public release lanes: - Run `pnpm build && pnpm ui:build` before `pnpm release:check` so the expected `dist/*` release artifacts and Control UI bundle exist for the pack validation step +- Run the manual `CI` workflow before release approval when you need full normal + CI coverage for the release candidate. Manual CI dispatches bypass changed + scoping and force the Linux Node shards, bundled-plugin shards, channel + contracts, `check`, `check-additional`, build smoke, docs checks, Python + skills, Windows, macOS, Android, and Control UI i18n lanes. + Example: `gh workflow run ci.yml --ref release/YYYY.M.D` - Run `pnpm qa:otel:smoke` when validating release telemetry. It exercises QA-lab through a local OTLP/HTTP receiver and verifies the exported trace span names, bounded attributes, and content/identifier redaction without @@ -182,18 +188,20 @@ When cutting a stable npm release: SHA for a validation-only dry run of the preflight workflow 2. Choose `npm_dist_tag=beta` for the normal beta-first flow, or `latest` only when you intentionally want a direct stable publish -3. Run `OpenClaw Release Checks` separately with the same tag or the +3. Run the manual `CI` workflow on the release ref when you want full normal CI + coverage instead of smart-scoped merge coverage +4. Run `OpenClaw Release Checks` separately with the same tag or the full current workflow-branch commit SHA when you want live prompt cache, QA Lab parity, Matrix, and Telegram coverage - This is separate on purpose so live coverage stays available without recoupling long-running or flaky checks to the publish workflow -4. Save the successful `preflight_run_id` -5. Run `OpenClaw NPM Release` again with `preflight_only=false`, the same +5. Save the successful `preflight_run_id` +6. Run `OpenClaw NPM Release` again with `preflight_only=false`, the same `tag`, the same `npm_dist_tag`, and the saved `preflight_run_id` -6. If the release landed on `beta`, use the private +7. If the release landed on `beta`, use the private `openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml` workflow to promote that stable version from `beta` to `latest` -7. If the release intentionally published directly to `latest` and `beta` +8. If the release intentionally published directly to `latest` and `beta` should follow the same stable build immediately, use that same private workflow to point both dist-tags at the stable version, or let its scheduled self-healing sync move `beta` later diff --git a/scripts/e2e/npm-telegram-live-runner.ts b/scripts/e2e/npm-telegram-live-runner.ts index d7b26995aa5..ad5500968fa 100644 --- a/scripts/e2e/npm-telegram-live-runner.ts +++ b/scripts/e2e/npm-telegram-live-runner.ts @@ -5,7 +5,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { runTelegramQaLive } from "../../extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts"; function parseBoolean(value: string | undefined) { const normalized = value?.trim().toLowerCase(); @@ -27,10 +26,6 @@ function resolveCredentialRole(env: NodeJS.ProcessEnv) { return env.OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE ?? env.OPENCLAW_QA_CREDENTIAL_ROLE; } -function formatErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - async function resolveTrustedOpenClawCommand(rawCommand: string) { if (!path.isAbsolute(rawCommand)) { throw new Error("OPENCLAW_NPM_TELEGRAM_SUT_COMMAND must be an absolute path."); @@ -56,6 +51,8 @@ async function resolveTrustedOpenClawCommand(rawCommand: string) { } async function main() { + const { runTelegramQaLive } = + await import("../../extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts"); const rawSutOpenClawCommand = process.env.OPENCLAW_NPM_TELEGRAM_SUT_COMMAND?.trim(); if (!rawSutOpenClawCommand) { throw new Error("Missing OPENCLAW_NPM_TELEGRAM_SUT_COMMAND."); @@ -92,9 +89,20 @@ async function main() { } } +async function formatRunnerErrorMessage(error: unknown) { + try { + const { formatErrorMessage } = await import("../../dist/infra/errors.js"); + return formatErrorMessage(error); + } catch { + return error instanceof Error ? error.message : String(error); + } +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error) => { - process.stderr.write(`npm telegram live e2e failed: ${formatErrorMessage(error)}\n`); + main().catch(async (error) => { + process.stderr.write( + `npm telegram live e2e failed: ${await formatRunnerErrorMessage(error)}\n`, + ); process.exitCode = 1; }); } diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index 9854c135f9a..434751341d2 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -28,7 +28,6 @@ describe("docker build cache layout", () => { it("uses pnpm cache mounts in Dockerfiles that install repo dependencies", async () => { for (const path of [ "Dockerfile", - "scripts/e2e/Dockerfile", "scripts/e2e/Dockerfile.qr-import", "scripts/docker/cleanup-smoke/Dockerfile", ]) { @@ -89,41 +88,16 @@ describe("docker build cache layout", () => { } }); - it("copies only install inputs before pnpm install in the e2e image", async () => { + it("keeps the shared e2e image on the packaged tarball install path", async () => { const dockerfile = await readRepoFile("scripts/e2e/Dockerfile"); - const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); - const expectPatternBeforeInstall = (pattern: RegExp) => { - const index = indexOfPattern(dockerfile, pattern); - expect(index).toBeGreaterThan(-1); - expect(index).toBeLessThan(installIndex); - }; - const expectPatternAfterInstall = (pattern: RegExp) => { - const index = indexOfPattern(dockerfile, pattern); - expect(index).toBeGreaterThan(installIndex); - }; - expectPatternBeforeInstall( - /^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.npmrc \.\/$/m, + expect(dockerfile).not.toContain("pnpm install --frozen-lockfile"); + expect(dockerfile).not.toContain("COPY . ."); + expect(dockerfile).toMatch( + /^COPY --from=openclaw_package --chown=appuser:appuser openclaw-current\.tgz \/tmp\/openclaw-current\.tgz$/m, ); - expectPatternBeforeInstall( - /^COPY(?:\s+--chown=\S+)?\s+ui\/package\.json \.\/ui\/package\.json$/m, - ); - expectPatternBeforeInstall( - /^RUN --mount=type=bind,source=extensions,target=\/tmp\/extensions,readonly\s+\\$/m, - ); - expectPatternBeforeInstall(/^COPY(?:\s+--chown=\S+)?\s+patches \.\/patches$/m); - expectPatternBeforeInstall( - /^COPY(?:\s+--chown=\S+)?\s+scripts\/postinstall-bundled-plugins\.mjs scripts\/preinstall-package-manager-warning\.mjs scripts\/npm-runner\.mjs scripts\/windows-cmd-helpers\.mjs \.\/scripts\/$/m, - ); - expectPatternAfterInstall( - /^COPY(?:\s+--chown=\S+)?\s+\.oxlintrc\.json tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsconfig\.oxlint\*\.json tsdown\.config\.ts vitest\.config\.ts openclaw\.mjs \.\/$/m, - ); - expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+src \.\/src$/m); - expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+test \.\/test$/m); - expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+scripts \.\/scripts$/m); - expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+ui \.\/ui$/m); - expectPatternAfterInstall( - /^COPY(?:\s+--link)?(?:\s+--chown=\S+)?\s+extensions \.\/extensions$/m, + expect(dockerfile).toContain( + "npm install -g --prefix /tmp/openclaw-prefix /tmp/openclaw-current.tgz --no-fund --no-audit", ); }); diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index c72805a8d99..324110488de 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -904,25 +904,20 @@ describe("test-projects args", () => { ]); }); - it("widens extension-facing core contract changes to extension tests", () => { + it("keeps extension-facing core contract changes focused by default", () => { const changedPaths = ["src/plugin-sdk/core.ts"]; const plans = buildVitestRunPlans(["--changed=origin/main"], process.cwd(), () => changedPaths); expect( resolveChangedTargetArgs(["--changed=origin/main"], process.cwd(), () => changedPaths), - ).toEqual(["src/plugin-sdk/core.test.ts", "extensions"]); + ).toEqual(["src/plugin-sdk/core.test.ts"]); expect(plans[0]).toEqual({ config: "test/vitest/vitest.plugin-sdk.config.ts", forwardedArgs: [], includePatterns: ["src/plugin-sdk/core.test.ts"], watchMode: false, }); - expect(plans.map((plan) => plan.config)).toContain( - "test/vitest/vitest.extension-discord.config.ts", - ); - expect(plans.map((plan) => plan.config)).toContain( - "test/vitest/vitest.extension-providers.config.ts", - ); + expect(plans).toHaveLength(1); }); it("keeps extension production changes on the owning extension lane", () => { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 78450d3ab67..b028386ce03 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import fg from "fast-glob"; import { describe, expect, it } from "vitest"; import { DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS, @@ -14,6 +15,87 @@ import { resolveParallelFullSuiteConcurrency, shouldRetryVitestNoOutputTimeout, } from "../../scripts/test-projects.test-support.mjs"; +import { fullSuiteVitestShards } from "../vitest/vitest.test-shards.mjs"; + +const normalizeRepoPath = (value: string) => value.replaceAll("\\", "/"); + +type VitestTestConfig = { + dir?: string; + exclude?: string[]; + include?: string[]; +}; + +type VitestConfig = { + test?: VitestTestConfig; +}; + +type VitestConfigFactory = (env?: Record) => VitestConfig; + +function isVitestConfigFactory(value: unknown): value is VitestConfigFactory { + return typeof value === "function"; +} + +function findVitestConfigFactory(mod: Record): VitestConfigFactory | null { + for (const [name, value] of Object.entries(mod)) { + if ( + name !== "default" && + /^create.*VitestConfig$/u.test(name) && + isVitestConfigFactory(value) + ) { + return value; + } + } + return null; +} + +async function loadRawVitestConfig(configPath: string): Promise { + const previousArgv = process.argv; + const previousIncludeFile = process.env.OPENCLAW_VITEST_INCLUDE_FILE; + process.argv = [previousArgv[0] ?? "node", previousArgv[1] ?? "vitest"]; + delete process.env.OPENCLAW_VITEST_INCLUDE_FILE; + try { + const mod = (await import(path.resolve(process.cwd(), configPath))) as Record; + return findVitestConfigFactory(mod)?.(process.env) ?? ((mod.default ?? {}) as VitestConfig); + } finally { + process.argv = previousArgv; + if (previousIncludeFile === undefined) { + delete process.env.OPENCLAW_VITEST_INCLUDE_FILE; + } else { + process.env.OPENCLAW_VITEST_INCLUDE_FILE = previousIncludeFile; + } + } +} + +async function listMatchedTestFilesForConfig(configPath: string): Promise { + const testConfig = (await loadRawVitestConfig(configPath)).test ?? {}; + const dir = testConfig.dir ? path.resolve(process.cwd(), testConfig.dir) : process.cwd(); + const include = testConfig.include ?? []; + const exclude = (testConfig.exclude ?? []).map((pattern) => + path.isAbsolute(pattern) + ? normalizeRepoPath(path.relative(dir, pattern)) + : normalizeRepoPath(pattern), + ); + return fg + .sync(include, { + absolute: false, + cwd: dir, + dot: false, + ignore: exclude, + }) + .map((file) => normalizeRepoPath(path.relative(process.cwd(), path.resolve(dir, file)))) + .toSorted((left, right) => left.localeCompare(right)); +} + +async function listFullSuiteTestFileMatches(): Promise> { + const configs = [...new Set(fullSuiteVitestShards.flatMap((shard) => shard.projects))]; + const matches = new Map(); + for (const config of configs) { + for (const file of await listMatchedTestFilesForConfig(config)) { + matches.set(file, [...(matches.get(file) ?? []), config]); + } + } + return matches; +} describe("scripts/test-projects changed-target routing", () => { it("maps changed source files into scoped lane targets", () => { @@ -707,6 +789,39 @@ describe("scripts/test-projects local heavy-check lock", () => { }); describe("scripts/test-projects full-suite sharding", () => { + it("covers each normal full-suite test file exactly once", async () => { + const matches = await listFullSuiteTestFileMatches(); + const e2eNamedIntegrationTests = new Set([ + "src/gateway/gateway.test.ts", + "src/gateway/server.startup-matrix-migration.integration.test.ts", + "src/gateway/sessions-history-http.test.ts", + ]); + const normalTestFiles = fg + .sync(["**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], { + cwd: process.cwd(), + dot: false, + ignore: ["**/.*/**", "**/dist/**", "**/node_modules/**", "**/vendor/**"], + }) + .map(normalizeRepoPath) + .filter( + (file) => + !file.includes(".live.test.") && + !file.includes(".e2e.test.") && + !file.startsWith("test/fixtures/") && + !e2eNamedIntegrationTests.has(file), + ) + .toSorted((left, right) => left.localeCompare(right)); + + const missing = normalTestFiles.filter((file) => !matches.has(file)); + const duplicated = [...matches.entries()] + .filter(([, configs]) => configs.length > 1) + .map(([file, configs]) => `${file}: ${configs.join(", ")}`) + .toSorted((left, right) => left.localeCompare(right)); + + expect(missing).toEqual([]); + expect(duplicated).toEqual([]); + }); + it("uses the large host-aware local profile on roomy local hosts", () => { expect( resolveParallelFullSuiteConcurrency( @@ -965,6 +1080,7 @@ describe("scripts/test-projects full-suite sharding", () => { "test/vitest/vitest.extension-browser.config.ts", "test/vitest/vitest.extension-qa.config.ts", "test/vitest/vitest.extension-media.config.ts", + "test/vitest/vitest.extensions.config.ts", "test/vitest/vitest.extension-misc.config.ts", ]); expect(plans).toEqual( diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 22321328d0d..d1552272286 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -731,11 +731,10 @@ describe("scoped vitest configs", () => { it("keeps tooling tests in their own lane", () => { expect(defaultToolingConfig.test?.include).toEqual( - expect.arrayContaining([ - "test/**/*.test.ts", - "src/scripts/**/*.test.ts", - "src/config/doc-baseline.integration.test.ts", - ]), + expect.arrayContaining(["test/**/*.test.ts", "src/scripts/**/*.test.ts"]), + ); + expect(defaultToolingConfig.test?.include).not.toContain( + "src/config/doc-baseline.integration.test.ts", ); }); @@ -771,8 +770,9 @@ describe("scoped vitest configs", () => { }); it("normalizes ui include patterns relative to the scoped dir", () => { - expect(defaultUiConfig.test?.dir).toBe(path.join(process.cwd(), "ui", "src", "ui")); - expect(defaultUiConfig.test?.include).toEqual(["**/*.test.ts"]); + expect(defaultUiConfig.test?.dir).toBe(process.cwd()); + expect(defaultUiConfig.test?.include).toEqual(["ui/src/**/*.test.ts"]); + expect(defaultUiConfig.test?.exclude).toContain("ui/src/ui/app-chat.test.ts"); }); it("normalizes utils include patterns relative to the scoped dir", () => { diff --git a/test/vitest/vitest.infra.config.ts b/test/vitest/vitest.infra.config.ts index 065cdc2a060..015c1cde297 100644 --- a/test/vitest/vitest.infra.config.ts +++ b/test/vitest/vitest.infra.config.ts @@ -1,9 +1,11 @@ import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; +import { boundaryTestFiles } from "./vitest.unit-paths.mjs"; export function createInfraVitestConfig(env?: Record) { return createScopedVitestConfig(["src/infra/**/*.test.ts"], { dir: "src", env, + exclude: boundaryTestFiles, name: "infra", passWithNoTests: true, }); diff --git a/test/vitest/vitest.plugin-sdk.config.ts b/test/vitest/vitest.plugin-sdk.config.ts index 37fed11ed48..0125d42fdd3 100644 --- a/test/vitest/vitest.plugin-sdk.config.ts +++ b/test/vitest/vitest.plugin-sdk.config.ts @@ -1,11 +1,12 @@ import { pluginSdkLightTestFiles } from "./vitest.plugin-sdk-paths.mjs"; import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; +import { bundledPluginDependentUnitTestFiles } from "./vitest.unit-paths.mjs"; export function createPluginSdkVitestConfig(env?: Record) { return createScopedVitestConfig(["src/plugin-sdk/**/*.test.ts"], { dir: "src", env, - exclude: pluginSdkLightTestFiles, + exclude: [...pluginSdkLightTestFiles, ...bundledPluginDependentUnitTestFiles], name: "plugin-sdk", passWithNoTests: true, }); diff --git a/test/vitest/vitest.plugins.config.ts b/test/vitest/vitest.plugins.config.ts index ba63916e560..2a188095bcb 100644 --- a/test/vitest/vitest.plugins.config.ts +++ b/test/vitest/vitest.plugins.config.ts @@ -4,7 +4,7 @@ export function createPluginsVitestConfig(env?: Record = process.env, @@ -9,15 +10,10 @@ export function loadIncludePatternsFromEnv( export function createToolingVitestConfig(env?: Record) { return createScopedVitestConfig( - loadIncludePatternsFromEnv(env) ?? [ - "test/**/*.test.ts", - "src/scripts/**/*.test.ts", - "src/config/doc-baseline.integration.test.ts", - "src/config/schema.base.generated.test.ts", - "src/config/schema.help.quality.test.ts", - ], + loadIncludePatternsFromEnv(env) ?? ["test/**/*.test.ts", "src/scripts/**/*.test.ts"], { env, + exclude: boundaryTestFiles, name: "tooling", passWithNoTests: true, }, diff --git a/test/vitest/vitest.ui.config.ts b/test/vitest/vitest.ui.config.ts index cf6a2bee026..98d7b745ee4 100644 --- a/test/vitest/vitest.ui.config.ts +++ b/test/vitest/vitest.ui.config.ts @@ -17,11 +17,13 @@ export function createUiVitestConfig( env?: Record, options?: { includePatterns?: string[]; name?: string }, ) { - return createScopedVitestConfig(options?.includePatterns ?? ["ui/src/ui/**/*.test.ts"], { + const includePatterns = options?.includePatterns ?? ["ui/src/**/*.test.ts"]; + const exclude = options?.includePatterns ? [] : unitUiIncludePatterns; + return createScopedVitestConfig(includePatterns, { deps: jsdomOptimizedDeps, - dir: "ui/src/ui", environment: "jsdom", env, + exclude, excludeUnitFastTests: false, includeOpenClawRuntimeSetup: false, isolate: true, diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index cd1438e9d70..751aed4ea1c 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -5,6 +5,7 @@ import { commandsLightTestFiles, } from "./vitest.commands-light-paths.mjs"; import { pluginSdkLightSourceFiles, pluginSdkLightTestFiles } from "./vitest.plugin-sdk-paths.mjs"; +import { boundaryTestFiles } from "./vitest.unit-paths.mjs"; const normalizeRepoPath = (value) => value.replaceAll("\\", "/"); @@ -71,6 +72,7 @@ const broadUnitFastCandidateSkipGlobs = [ "src/plugin-sdk/browser-subpaths.test.ts", "src/security/**/*.test.ts", "src/secrets/**/*.test.ts", + ...boundaryTestFiles, ]; const disqualifyingPatterns = [ diff --git a/test/vitest/vitest.unit-src.config.ts b/test/vitest/vitest.unit-src.config.ts index ff889eda037..b0d8a4a92d9 100644 --- a/test/vitest/vitest.unit-src.config.ts +++ b/test/vitest/vitest.unit-src.config.ts @@ -3,5 +3,5 @@ import { createUnitVitestConfigWithOptions } from "./vitest.unit.config.ts"; export default createUnitVitestConfigWithOptions(process.env, { name: "unit-src", includePatterns: ["src/**/*.test.ts"], - extraExcludePatterns: ["src/security/**"], + extraExcludePatterns: ["src/acp/**", "src/security/**"], }); diff --git a/ui/src/styles/components.test.ts b/ui/src/styles/components.test.ts index cfa33785535..fb1e556bd58 100644 --- a/ui/src/styles/components.test.ts +++ b/ui/src/styles/components.test.ts @@ -1,9 +1,10 @@ import { readFileSync } from "node:fs"; +import path from "node:path"; import { describe, expect, it } from "vitest"; describe("agent fallback chip styles", () => { it("styles the chip remove control inside the agent model input", () => { - const css = readFileSync(new URL("./components.css", import.meta.url), "utf8"); + const css = readFileSync(path.join(process.cwd(), "ui/src/styles/components.css"), "utf8"); expect(css).toContain(".agent-chip-input .chip {"); expect(css).toContain(".agent-chip-input .chip-remove {"); diff --git a/ui/src/styles/config-quick.test.ts b/ui/src/styles/config-quick.test.ts index 1effbf8e8e5..f4967cd9958 100644 --- a/ui/src/styles/config-quick.test.ts +++ b/ui/src/styles/config-quick.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -const css = readFileSync(new URL("./config-quick.css", import.meta.url), "utf8"); +const css = readFileSync(path.join(process.cwd(), "ui/src/styles/config-quick.css"), "utf8"); describe("config-quick styles", () => { it("includes the local user identity quick-settings styles", () => { diff --git a/ui/src/styles/layout.mobile.test.ts b/ui/src/styles/layout.mobile.test.ts index 05e25c6cd72..5a1fe3b0e13 100644 --- a/ui/src/styles/layout.mobile.test.ts +++ b/ui/src/styles/layout.mobile.test.ts @@ -1,9 +1,10 @@ import { readFileSync } from "node:fs"; +import path from "node:path"; import { describe, expect, it } from "vitest"; describe("chat header responsive mobile styles", () => { it("keeps the chat header and session controls from clipping on narrow widths", () => { - const css = readFileSync(new URL("./layout.mobile.css", import.meta.url), "utf8"); + const css = readFileSync(path.join(process.cwd(), "ui/src/styles/layout.mobile.css"), "utf8"); expect(css).toContain("@media (max-width: 1320px)"); expect(css).toContain(".content--chat .content-header");