test: auto-discover vitest suites

This commit is contained in:
Peter Steinberger
2026-04-27 00:54:40 +01:00
parent 21c51bc140
commit fa0729e145
19 changed files with 220 additions and 94 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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;
});
}

View File

@@ -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",
);
});

View File

@@ -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", () => {

View File

@@ -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<string, string | undefined>) => VitestConfig;
function isVitestConfigFactory(value: unknown): value is VitestConfigFactory {
return typeof value === "function";
}
function findVitestConfigFactory(mod: Record<string, unknown>): 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<VitestConfig> {
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<string, unknown>;
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<string[]> {
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<Map<string, string[]>> {
const configs = [...new Set(fullSuiteVitestShards.flatMap((shard) => shard.projects))];
const matches = new Map<string, string[]>();
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(

View File

@@ -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", () => {

View File

@@ -1,9 +1,11 @@
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
import { boundaryTestFiles } from "./vitest.unit-paths.mjs";
export function createInfraVitestConfig(env?: Record<string, string | undefined>) {
return createScopedVitestConfig(["src/infra/**/*.test.ts"], {
dir: "src",
env,
exclude: boundaryTestFiles,
name: "infra",
passWithNoTests: true,
});

View File

@@ -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<string, string | undefined>) {
return createScopedVitestConfig(["src/plugin-sdk/**/*.test.ts"], {
dir: "src",
env,
exclude: pluginSdkLightTestFiles,
exclude: [...pluginSdkLightTestFiles, ...bundledPluginDependentUnitTestFiles],
name: "plugin-sdk",
passWithNoTests: true,
});

View File

@@ -4,7 +4,7 @@ export function createPluginsVitestConfig(env?: Record<string, string | undefine
return createScopedVitestConfig(["src/plugins/**/*.test.ts"], {
dir: "src/plugins",
env,
exclude: ["src/plugins/contracts/**"],
exclude: ["src/plugins/contracts/**", "src/plugins/loader.test.ts"],
isolate: true,
name: "plugins",
passWithNoTests: true,

View File

@@ -131,6 +131,7 @@ export const fullSuiteVitestShards = [
"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",
],
},

View File

@@ -1,5 +1,6 @@
import { loadPatternListFromEnv } from "./vitest.pattern-file.ts";
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
import { boundaryTestFiles } from "./vitest.unit-paths.mjs";
export function loadIncludePatternsFromEnv(
env: Record<string, string | undefined> = process.env,
@@ -9,15 +10,10 @@ export function loadIncludePatternsFromEnv(
export function createToolingVitestConfig(env?: Record<string, string | undefined>) {
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,
},

View File

@@ -17,11 +17,13 @@ export function createUiVitestConfig(
env?: Record<string, string | undefined>,
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,

View File

@@ -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 = [

View File

@@ -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/**"],
});

View File

@@ -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 {");

View File

@@ -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", () => {

View File

@@ -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");