mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:10:43 +00:00
test: auto-discover vitest suites
This commit is contained in:
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
@@ -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'
|
||||
|
||||
19
docs/ci.md
19
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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/**"],
|
||||
});
|
||||
|
||||
@@ -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 {");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user