mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* fix: release deleted agents' model and auth resources * fix: await only existing deleted-agent builds * fix: close deleted agents' native memory managers * fix(agents): close deleted database readers across worker isolates * fix(agents): revive deleted stores by their captured owner * refactor(state): remove superseded reader-close entry points * fix(agents): preserve commit outcomes and close readers before trash * fix(tooling): include agent readers in native wrapper inventory * fix(agents): adapt deleted-reader cleanup to native worker owners * fix(agents): preserve deletion fences across worker generations * fix(agents): release deletion guards and repair CI proof Release worker heartbeat references when a deletion lease closes. Keep the journal authority SELECT in its worker-only module and route the catalog reader fixture through the existing database-worker lane. Close the survivor fixture's seed writer before recovery, establish archive LRU order, and restore the shared logger mock's trace contract. Add effect boundary coverage for revoked deletion authority and move bounded transport, channel logging, and fixture code to their existing modules. Preserves the agent deletion and recreation repair for #159007. Co-authored-by: Chris Eckert <christopher.k.eckert@gmail.com> * test(ci): retain native command and auth retirement diagnostics Wait for a native command receipt before checking its result so a terminal failure exposes its reason immediately. Include isolated Gateway logs in the removed-profile assertion, with a debug-only publication retirement summary that identifies cache, owner, pending-build, and Gateway-loan state. This diagnoses UI and auth failures from CI run 36965599681 without relaxing assertions, extending timeouts, or changing retirement/recovery authority. Co-authored-by: Chris Eckert <christopher.k.eckert@gmail.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Chris Eckert <christopher.k.eckert@gmail.com>
3518 lines
140 KiB
TypeScript
3518 lines
140 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { assert, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { listExtensionTestFilesForRoots } from "../../scripts/lib/extension-test-plan.mts";
|
|
import { readTestSelectorSourceFacts } from "../../scripts/lib/test-selector-source-facts.mts";
|
|
import {
|
|
listVitestRuntimeConsumerFiles,
|
|
resolveVitestPretestBuildMode,
|
|
resolveVitestRuntimeConfigScopes,
|
|
} from "../../scripts/lib/vitest-build-prerequisites.mts";
|
|
import { resolveDefaultVitestNoOutputTimeoutMs } from "../../scripts/lib/vitest-process-env.mts";
|
|
import { resolveVitestRuntimeCliSelections } from "../../scripts/lib/vitest-runtime-selection.mts";
|
|
import { resolveShardTimingKey } from "../../scripts/lib/vitest-shard-metadata.mts";
|
|
import { scriptModuleEntrypoints } from "../../scripts/script-module-runtime.test-support.mjs";
|
|
import {
|
|
applyDefaultVitestCachePaths,
|
|
applyDefaultVitestNoOutputTimeout,
|
|
applyFullExtensionsHeapBudget,
|
|
buildFullSuiteVitestRunPlans,
|
|
buildVitestRunPlans,
|
|
createVitestRunSpecs,
|
|
findUnmatchedExplicitTestTargets,
|
|
hasImportGraphImpactOnTargets,
|
|
isTestFileTarget,
|
|
orderFullSuiteSpecsForParallelRun,
|
|
parseTestProjectsArgs,
|
|
resolveChangedTestTargetPlanForArgs,
|
|
resolveChangedTestTargetPlan,
|
|
resolveChangedTargetArgs,
|
|
resolveAffectedTestsFromImportGraph,
|
|
resolveControlUiTestConsumers,
|
|
resolveDependencyTestConsumers,
|
|
writeVitestIncludeFile,
|
|
} from "../../scripts/test-projects.test-support.mts";
|
|
import {
|
|
resolveRuntimeWorkerArgv,
|
|
resolveRuntimeWorkerUrl,
|
|
} from "../../src/infra/runtime-worker-url.js";
|
|
import { withEnv } from "../../src/test-utils/env.js";
|
|
import { listGitTrackedFiles, toRepoPath } from "../../src/test-utils/repo-files.js";
|
|
import { requireNodeTool } from "../helpers/node-toolchain.js";
|
|
import { listVitestConfigTestFiles } from "../vitest-projects-config.test-support.js";
|
|
import { databaseWorkerCoreTestFiles } from "../vitest/vitest.database-worker-core-paths.mjs";
|
|
import { databaseWorkerExtensionTestFiles } from "../vitest/vitest.extension-database-workers-paths.mjs";
|
|
import {
|
|
gatewayDatabaseWorkerTestFiles,
|
|
isGatewayServerTestFile,
|
|
} from "../vitest/vitest.gateway-server-paths.mjs";
|
|
import {
|
|
isSharedVitestExcludedPath,
|
|
matchesVitestCliSelection,
|
|
} from "../vitest/vitest.pattern-file.ts";
|
|
|
|
const normalizeRepoPath = toRepoPath;
|
|
const MATRIX_TEST_PROCESS_FILE_LIMIT = 40;
|
|
const TELEGRAM_TEST_PROCESS_FILE_LIMIT = 10;
|
|
|
|
describe("Windows CI partitions", () => {
|
|
it("keeps explicit coverage disjoint without repeating small project setup", () => {
|
|
const { scripts } = JSON.parse(fs.readFileSync("package.json", "utf8")) as {
|
|
scripts: Record<string, string>;
|
|
};
|
|
const [first, second] = [1, 2].map((part) => {
|
|
const command = scripts[`test:windows:ci:${part}`];
|
|
assert(command);
|
|
const entrypoint = "scripts/test-projects.mts ";
|
|
expect(command).toContain(entrypoint);
|
|
const targets = command.slice(command.indexOf(entrypoint) + entrypoint.length).split(/\s+/u);
|
|
expect(targets.every((target) => isTestFileTarget(target))).toBe(true);
|
|
expect(findUnmatchedExplicitTestTargets(targets)).toEqual([]);
|
|
return {
|
|
targets,
|
|
configs: new Set(createVitestRunSpecs(targets, { baseEnv: {} }).map((spec) => spec.config)),
|
|
};
|
|
});
|
|
assert(first && second);
|
|
const targets = [...first.targets, ...second.targets];
|
|
expect(new Set(targets).size).toBe(targets.length);
|
|
// Tooling and infra own shared fixtures; the extension catch-all retains
|
|
// separate plugin processes. The other projects share setup within one part.
|
|
expect([...first.configs].filter((config) => second.configs.has(config))).toEqual([
|
|
"test/vitest/vitest.tooling.config.ts",
|
|
"test/vitest/vitest.infra.config.ts",
|
|
"test/vitest/vitest.extensions.config.ts",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("test runtime prerequisites", () => {
|
|
it.each([
|
|
["lifecycle file", ["extensions/qa-lab/src/suite-process-lifecycle.test.ts"], "private-qa"],
|
|
[
|
|
"cold identity child",
|
|
["extensions/qa-lab/src/agent-run-identity-repeated-turn-child.process.test.ts"],
|
|
"private-qa",
|
|
],
|
|
["full local suite", [], "private-qa"],
|
|
["Windows Claude CLI process", ["src/process/exec.windows.integration.test.ts"], "runtime"],
|
|
["agent command child", ["src/agents/agent-command-local.test.ts"], "runtime"],
|
|
["process config", ["test/vitest/vitest.process.config.ts"], "runtime"],
|
|
["ordinary process unit", ["src/process/exec.windows.test.ts"], undefined],
|
|
["TUI native provider policy", ["src/tui/tui-session-identity-pty.e2e.test.ts"], "runtime"],
|
|
["TUI PTY config", ["test/vitest/vitest.tui-pty.config.ts"], "runtime"],
|
|
["ordinary TUI PTY test", ["src/tui/tui-text-wrap-pty.e2e.test.ts"], undefined],
|
|
["config startup SDK", ["src/config/config-startup-corpus.test.ts"], "runtime"],
|
|
["runtime config project", ["test/vitest/vitest.runtime-config.config.ts"], "runtime"],
|
|
["native direct loader SDK", ["src/plugins/loader.test.ts"], "runtime"],
|
|
] as const)("prepares only the prerequisite selected by %s", (_name, args, expected) => {
|
|
const plans = args.length ? buildVitestRunPlans([...args]) : buildFullSuiteVitestRunPlans([]);
|
|
expect(
|
|
resolveVitestPretestBuildMode(
|
|
plans.map((plan) => ({
|
|
configs: [plan.config],
|
|
includePatterns: plan.includePatterns,
|
|
})),
|
|
),
|
|
).toBe(expected);
|
|
});
|
|
|
|
it("prepares direct lifecycle selection under test/vitest/vitest.full-agentic.config.ts", () => {
|
|
const config = "test/vitest/vitest.full-agentic.config.ts";
|
|
for (const [file, expected] of [
|
|
["src/gateway/server-sidecar-retention.test.ts", "runtime"],
|
|
["src/gateway/server-request-context.test.ts", undefined],
|
|
] as const) {
|
|
const selections = resolveVitestRuntimeCliSelections(config, ["run", file], {});
|
|
expect(resolveVitestPretestBuildMode(selections), file).toBe(expected);
|
|
}
|
|
});
|
|
|
|
const catalogFile = "test/plugins/codex-model-catalog.gateway.test.ts";
|
|
const freshnessFile = "src/gateway/server-methods/models-list.freshness.integration.test.ts";
|
|
const scopedFreshnessFile = "**/models-list.freshness.integration.test.ts";
|
|
it.each([
|
|
["gateway-database-workers", [], "runtime"],
|
|
["gateway-database-workers", [freshnessFile], "runtime"],
|
|
["gateway-database-workers", ["models-list.freshness.integration"], "runtime"],
|
|
["gateway-database-workers", [freshnessFile, "--exclude", scopedFreshnessFile], undefined],
|
|
["gateway-methods", [catalogFile], undefined],
|
|
["gateway", [catalogFile, "--exclude", catalogFile], undefined],
|
|
] as const)("binds %s runtime prerequisites to their owner for %s", (project, args, expected) => {
|
|
const selections = resolveVitestRuntimeCliSelections(
|
|
`test/vitest/vitest.${project}.config.ts`,
|
|
["run", ...args],
|
|
{},
|
|
);
|
|
expect(resolveVitestPretestBuildMode(selections)).toBe(expected);
|
|
});
|
|
|
|
it("keeps Gateway worker runtime selection rooted at the repository for src/gateway/setup-inference.first-signin.integration.test.ts", () => {
|
|
const file = "src/gateway/setup-inference.first-signin.integration.test.ts";
|
|
const config = "test/vitest/vitest.gateway-database-workers.config.ts";
|
|
expect(listVitestRuntimeConsumerFiles([config])).toContain(file);
|
|
expect(
|
|
listVitestRuntimeConsumerFiles(["test/vitest/vitest.gateway-methods.config.ts"]),
|
|
).not.toContain(file);
|
|
expect(
|
|
resolveVitestPretestBuildMode(resolveVitestRuntimeCliSelections(config, ["run", file], {})),
|
|
).toBe("runtime");
|
|
expect(
|
|
resolveVitestPretestBuildMode(
|
|
resolveVitestRuntimeCliSelections(config, ["run", file, "--exclude", file], {}),
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
["bundled", ["src/plugins/loader.test.ts"], undefined],
|
|
["extensions", ["deepinfra/**"], "runtime"],
|
|
["extensions", ["deepinfra/**", "google-meet/**", "file-transfer/**"], undefined],
|
|
["tui-pty", ["tui/tui-session-identity-pty.e2e.test.ts"], undefined],
|
|
["tui-pty", ["tui/tui-text-wrap-pty.e2e.test.ts"], "runtime"],
|
|
["gateway-core", ["gateway-*.test.ts"], undefined],
|
|
["gateway", ["gateway-*.test.ts"], "runtime"],
|
|
["tooling", ["**/gateway-codex-delivery-cache.test.ts"], "runtime"],
|
|
[
|
|
"agents-core",
|
|
resolveVitestRuntimeConfigScopes("test/vitest/vitest.agents-core.config.ts").map(
|
|
({ file, dir }) => path.posix.relative(dir, file),
|
|
),
|
|
undefined,
|
|
],
|
|
] as const)("keeps %s selection scoped after excluding %s", (project, exclude, expected) => {
|
|
const selections = resolveVitestRuntimeCliSelections(
|
|
`test/vitest/vitest.${project}.config.ts`,
|
|
["run", ...exclude.flatMap((pattern) => ["--exclude", pattern])],
|
|
{},
|
|
);
|
|
expect(resolveVitestPretestBuildMode(selections)).toBe(expected);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"gateway-server",
|
|
"src/gateway/server-request-context.test.ts",
|
|
"src/gateway/server-sidecar-retention.test.ts",
|
|
],
|
|
["gateway-database-workers", "src/gateway/server-methods/cron.runs.test.ts", freshnessFile],
|
|
])("projects invocation-owned include files under %s", (project, ordinaryFile, runtimeFile) => {
|
|
const selections = resolveVitestRuntimeCliSelections(
|
|
`test/vitest/vitest.${project}.config.ts`,
|
|
["run"],
|
|
{},
|
|
);
|
|
for (const selection of selections) {
|
|
selection.includePatterns = [ordinaryFile];
|
|
}
|
|
expect(resolveVitestPretestBuildMode(selections)).toBeUndefined();
|
|
for (const selection of selections) {
|
|
selection.includePatterns = [runtimeFile];
|
|
}
|
|
expect(resolveVitestPretestBuildMode(selections)).toBe("runtime");
|
|
});
|
|
|
|
it("combines private QA and runtime readers into one private build", () => {
|
|
expect(
|
|
resolveVitestPretestBuildMode([
|
|
{ includePatterns: ["test/e2e/qa-lab/runtime/**/*.test.ts"] },
|
|
{ includePatterns: ["extensions/qa-lab/**/*.test.ts"] },
|
|
]),
|
|
).toBe("private-qa");
|
|
expect(resolveVitestPretestBuildMode([])).toBeUndefined();
|
|
expect(resolveVitestPretestBuildMode([{ configs: ["test/vitest/vitest.config.ts"] }])).toBe(
|
|
"private-qa",
|
|
);
|
|
});
|
|
});
|
|
|
|
function listOrdinaryExtensionFiles(root: string) {
|
|
return listExtensionTestFilesForRoots([root]).filter(
|
|
(file) => !databaseWorkerExtensionTestFiles.includes(file) && !isSharedVitestExcludedPath(file),
|
|
);
|
|
}
|
|
|
|
function expectedTelegramTestProcessCount() {
|
|
const testFileCount = listOrdinaryExtensionFiles("extensions/telegram").length;
|
|
return Math.max(1, Math.ceil(testFileCount / TELEGRAM_TEST_PROCESS_FILE_LIMIT));
|
|
}
|
|
|
|
function withTinyGitRepo(files: Record<string, string>, test: (cwd: string) => void): void {
|
|
withTinyFileTree(files, (cwd) => {
|
|
const init = spawnSync("git", ["init"], { cwd, stdio: "ignore" });
|
|
expect(init.status).toBe(0);
|
|
const add = spawnSync("git", ["add", "."], { cwd, stdio: "ignore" });
|
|
expect(add.status).toBe(0);
|
|
test(cwd);
|
|
});
|
|
}
|
|
|
|
function withTinyFileTree(files: Record<string, string>, test: (cwd: string) => void): void {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-projects-"));
|
|
try {
|
|
for (const [file, source] of Object.entries(files)) {
|
|
const absolute = path.join(cwd, file);
|
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
fs.writeFileSync(absolute, source);
|
|
}
|
|
test(cwd);
|
|
} finally {
|
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function expectChangedTargets(changedPaths: string[], targets: string[]): void {
|
|
expect(resolveChangedTestTargetPlan(changedPaths), changedPaths.join(", ")).toEqual({
|
|
mode: "targets",
|
|
targets,
|
|
});
|
|
}
|
|
|
|
function runPlan(
|
|
config: string,
|
|
includePatterns: string[] | null = null,
|
|
forwardedArgs: string[] = [],
|
|
) {
|
|
return {
|
|
config: `test/vitest/vitest.${config}.config.ts`,
|
|
includePatterns,
|
|
forwardedArgs,
|
|
watchMode: false,
|
|
};
|
|
}
|
|
|
|
function expectSingleVitestRunPlan(
|
|
actual: ReturnType<typeof buildVitestRunPlans>,
|
|
expected: {
|
|
config: string;
|
|
forwardedArgs?: string[];
|
|
includePatterns?: string[] | null;
|
|
watchMode?: boolean;
|
|
},
|
|
): void {
|
|
expect(actual).toEqual([
|
|
{
|
|
config: expected.config,
|
|
forwardedArgs: expected.forwardedArgs ?? [],
|
|
includePatterns: expected.includePatterns ?? null,
|
|
watchMode: expected.watchMode ?? false,
|
|
},
|
|
]);
|
|
}
|
|
|
|
describe("scripts/test-projects changed-target routing", () => {
|
|
beforeAll(() => {
|
|
buildVitestRunPlans(["src/commands/onboard-non-interactive.test-helpers.ts"]);
|
|
findUnmatchedExplicitTestTargets(["test/vitest/vitest.shared.config.ts"], process.cwd());
|
|
});
|
|
|
|
it("bounds extensionless prefix probes while excluding deleted cached matches", () => {
|
|
const target = "src/selector/topic";
|
|
const rejectedFiles = [
|
|
"src/other/topic.test.ts",
|
|
"src/selector/topic-other.test.ts",
|
|
"src/selector/topic.helper.ts",
|
|
"src/selector/topic.child/nested.test.ts",
|
|
];
|
|
const files = Object.fromEntries(
|
|
[`${target}.test.ts`, `${target}.extra.spec.mts`, ...rejectedFiles].map((file) => [file, ""]),
|
|
);
|
|
withTinyGitRepo(files, (tempDir) => {
|
|
const cwd = fs.realpathSync(tempDir);
|
|
const candidatePaths = new Set(Object.keys(files).map((file) => path.join(cwd, file)));
|
|
const rejectedPaths = new Set(rejectedFiles.map((file) => path.join(cwd, file)));
|
|
const candidateProbes: string[] = [];
|
|
const originalExistsSync = fs.existsSync;
|
|
fs.existsSync = (file) => {
|
|
if (typeof file === "string" && candidatePaths.has(file)) {
|
|
candidateProbes.push(file);
|
|
}
|
|
return originalExistsSync(file);
|
|
};
|
|
try {
|
|
const parsed = [
|
|
parseTestProjectsArgs(["--changed", "origin/main"], cwd),
|
|
parseTestProjectsArgs(["--changed", "origin/main"], cwd),
|
|
];
|
|
for (const result of parsed) {
|
|
expect(result).toStrictEqual({
|
|
forwardedArgs: ["--changed", "origin/main"],
|
|
nonTargetArgs: ["--changed", "origin/main"],
|
|
targetArgs: [],
|
|
watchMode: false,
|
|
});
|
|
}
|
|
expect(candidateProbes).toHaveLength(0);
|
|
|
|
expectSingleVitestRunPlan(buildVitestRunPlans([target], cwd), {
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
forwardedArgs: [`${target}.extra.spec.mts`, `${target}.test.ts`],
|
|
});
|
|
expect(findUnmatchedExplicitTestTargets([target], cwd)).toEqual([]);
|
|
|
|
fs.unlinkSync(path.join(cwd, `${target}.extra.spec.mts`));
|
|
expectSingleVitestRunPlan(buildVitestRunPlans([target], cwd), {
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
forwardedArgs: [`${target}.test.ts`],
|
|
includePatterns: [`${target}.test.ts`],
|
|
});
|
|
expect(findUnmatchedExplicitTestTargets([target], cwd)).toEqual([]);
|
|
|
|
fs.unlinkSync(path.join(cwd, `${target}.test.ts`));
|
|
expect(findUnmatchedExplicitTestTargets([target], cwd)).toEqual([
|
|
{
|
|
target,
|
|
reason: "path-does-not-exist",
|
|
includePattern: `${target}{,.*}.{test,spec}.{js,jsx,ts,tsx,mjs,cjs,mts,cts}`,
|
|
},
|
|
]);
|
|
expect(candidateProbes.filter((file) => rejectedPaths.has(file))).toHaveLength(0);
|
|
} finally {
|
|
fs.existsSync = originalExistsSync;
|
|
}
|
|
});
|
|
});
|
|
|
|
it("skips deleted direct test files in changed mode", () => {
|
|
expect(
|
|
resolveChangedTargetArgs(["--changed", "origin/main"], process.cwd(), () => [
|
|
"test/deleted-changed-target.test.ts",
|
|
]),
|
|
).toStrictEqual([]);
|
|
});
|
|
|
|
it("records broad fallback paths skipped by focused changed mode", () => {
|
|
expect(
|
|
resolveChangedTestTargetPlan([
|
|
"test/vitest/vitest.shared.config.ts",
|
|
"src/utils/provider-utils.ts",
|
|
]),
|
|
).toEqual({
|
|
mode: "targets",
|
|
skippedBroadFallbackPaths: ["test/vitest/vitest.shared.config.ts"],
|
|
targets: ["src/utils/provider-utils.test.ts"],
|
|
});
|
|
});
|
|
|
|
it("keeps changed mode precise for global config and source edits", () => {
|
|
expect(resolveChangedTestTargetPlan(["package.json", "src/commands/channels.add.ts"])).toEqual({
|
|
mode: "targets",
|
|
skippedBroadFallbackPaths: ["package.json"],
|
|
targets: ["src/commands/channels.add.test.ts"],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["src/plugin-sdk/provider-entry.ts", "src/plugin-sdk/provider-entry.test.ts"],
|
|
[
|
|
"extensions/qa-lab/src/scenario-catalog.test.ts",
|
|
"extensions/qa-lab/src/scenario-catalog.test.ts",
|
|
],
|
|
["ui/src/components/markdown.test.ts", "ui/src/components/markdown.test.ts"],
|
|
])("retains the precise owner for changed %s", (changedPath, target) => {
|
|
expectChangedTargets([changedPath], [target]);
|
|
});
|
|
|
|
it("discovers conventional nested, dashed and basename script owners for .mts", () => {
|
|
const extension = "mts";
|
|
withTinyFileTree(
|
|
{
|
|
[`scripts/nested/fixture.${extension}`]: "",
|
|
"test/scripts/nested/fixture.test.ts": "",
|
|
"test/scripts/nested-fixture.test.ts": "",
|
|
"test/scripts/fixture.test.ts": "",
|
|
"src/scripts/nested/fixture.test.ts": "",
|
|
"src/scripts/nested-fixture.test.ts": "",
|
|
"src/scripts/fixture.test.ts": "",
|
|
"test/scripts/fixture.live.test.ts": "",
|
|
},
|
|
(cwd) => {
|
|
expect(
|
|
resolveChangedTestTargetPlan([`scripts/nested/fixture.${extension}`], { cwd }),
|
|
).toEqual({
|
|
mode: "targets",
|
|
targets: [
|
|
"test/scripts/nested/fixture.test.ts",
|
|
"test/scripts/nested-fixture.test.ts",
|
|
"test/scripts/fixture.test.ts",
|
|
"src/scripts/nested/fixture.test.ts",
|
|
"src/scripts/nested-fixture.test.ts",
|
|
"src/scripts/fixture.test.ts",
|
|
],
|
|
});
|
|
expect(
|
|
resolveChangedTestTargetPlan([`scripts/fixture.${extension}`], { cwd }).targets,
|
|
).toEqual(["test/scripts/fixture.test.ts", "src/scripts/fixture.test.ts"]);
|
|
expect(
|
|
resolveChangedTestTargetPlan([`scripts/unowned.${extension}`], { cwd }).targets,
|
|
).toEqual(["test/vitest/vitest.tooling.config.ts"]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("routes unmatched script changes to the tooling suite instead of skipping tests", () => {
|
|
const targets = ["scripts/check-no-raw-http2-imports.mts"];
|
|
|
|
expectChangedTargets(targets, ["test/vitest/vitest.tooling.config.ts"]);
|
|
expectSingleVitestRunPlan(
|
|
buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => targets),
|
|
{ config: "test/vitest/vitest.tooling.config.ts" },
|
|
);
|
|
});
|
|
|
|
it("keeps Crabbox runner script edits on their regression tests", () => {
|
|
expectChangedTargets(["scripts/crabbox-wrapper.mts"], ["test/scripts/crabbox-wrapper.test.ts"]);
|
|
});
|
|
|
|
it("unions semantic workflow owners with bounded direct references", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
".github/workflows/full-release-validation.yml": "name: FRV\n",
|
|
"test/scripts/full-release-validation-state.test.ts":
|
|
'const workflow = ".github/workflows/full-release-validation.yml";\n',
|
|
"test/scripts/unknown-frv-contract.test.ts":
|
|
'const workflow = ".github/workflows/full-release-validation.yml";\n',
|
|
"test/scripts/unknown-frv-contract.live.test.ts":
|
|
'const workflow = ".github/workflows/full-release-validation.yml";\n',
|
|
"test/scripts/test-projects.test.ts":
|
|
'const workflow = ".github/workflows/full-release-validation.yml";\n',
|
|
"test/scripts/workflow-substring.test.ts":
|
|
'const workflow = ".github/workflows/full-release-validation.yml.bak";\n',
|
|
},
|
|
(cwd) => {
|
|
const targets = resolveChangedTestTargetPlan(
|
|
[".github/workflows/full-release-validation.yml"],
|
|
{ cwd },
|
|
).targets;
|
|
|
|
expect(targets).toContain("test/scripts/unknown-frv-contract.test.ts");
|
|
expect(
|
|
targets.filter(
|
|
(target) => target === "test/scripts/full-release-validation-state.test.ts",
|
|
),
|
|
).toHaveLength(1);
|
|
expect(targets).not.toContain("test/scripts/unknown-frv-contract.live.test.ts");
|
|
expect(targets).not.toContain("test/scripts/test-projects.test.ts");
|
|
expect(targets).not.toContain("test/scripts/workflow-substring.test.ts");
|
|
},
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
changedPath: ".github/workflows/plugin-npm-release.yml",
|
|
exactTargets: [
|
|
"test/scripts/plugin-npm-extended-stable-workflow.test.ts",
|
|
"test/scripts/plugin-release-git-lifecycle.test.ts",
|
|
],
|
|
},
|
|
{
|
|
changedPath: ".github/actions/setup-node-env/action.yml",
|
|
exactTargets: [
|
|
"test/scripts/setup-node-env-bun.test.ts",
|
|
"test/scripts/setup-node-env-semantic-memory.test.ts",
|
|
],
|
|
},
|
|
])("unions exact owners and references for $changedPath", ({ changedPath, exactTargets }) => {
|
|
withTinyGitRepo(
|
|
{
|
|
[changedPath]: "name: fixture\n",
|
|
"test/scripts/direct-workflow-reference.test.ts": `const target = "${changedPath}";\n`,
|
|
},
|
|
(cwd) => {
|
|
const targets = resolveChangedTestTargetPlan([changedPath], { cwd }).targets;
|
|
|
|
for (const exactTarget of exactTargets) {
|
|
expect(targets).toContain(exactTarget);
|
|
}
|
|
expect(targets).toContain("test/scripts/direct-workflow-reference.test.ts");
|
|
expect(targets).toContain("test/scripts/ci-workflow-guards.test.ts");
|
|
},
|
|
);
|
|
});
|
|
|
|
it("does not scan direct references for semantic non-YAML tooling", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
"scripts/pr": "#!/bin/sh\n",
|
|
"test/scripts/direct-tooling-reference.test.ts": 'const target = "scripts/pr";\n',
|
|
},
|
|
(cwd) => {
|
|
const targets = resolveChangedTestTargetPlan(["scripts/pr"], { cwd }).targets;
|
|
|
|
expect(targets).not.toContain("test/scripts/direct-tooling-reference.test.ts");
|
|
},
|
|
);
|
|
});
|
|
|
|
it("keeps workflow helper guard edits on their regression tests", () => {
|
|
expectChangedTargets(
|
|
["scripts/check-composite-action-input-interpolation.py"],
|
|
["test/scripts/check-composite-action-input-interpolation.test.ts"],
|
|
);
|
|
|
|
expectChangedTargets(
|
|
["scripts/check-no-conflict-markers.mjs"],
|
|
["test/scripts/check-no-conflict-markers.test.ts"],
|
|
);
|
|
});
|
|
|
|
it("leaves actionlint validation external while retaining config consumers", () => {
|
|
for (const withConsumers of [false, true]) {
|
|
withTinyGitRepo(
|
|
{
|
|
".github/actionlint.yaml": "self-hosted-runner: {}\n",
|
|
".github/unknown-lint.yaml": "{}\n",
|
|
...(withConsumers
|
|
? {
|
|
"src/lint-config.ts":
|
|
'export const config = new URL("../.github/actionlint.yaml", import.meta.url);\n',
|
|
"test/scripts/lint-import.test.ts": 'import "../../src/lint-config.js";\n',
|
|
"test/scripts/lint-reader.test.ts": 'const config = ".github/actionlint.yaml";\n',
|
|
}
|
|
: {}),
|
|
},
|
|
(cwd) => {
|
|
expect(
|
|
resolveChangedTestTargetPlan([".github/actionlint.yaml"], {
|
|
cwd,
|
|
forceFullImportGraph: true,
|
|
resolveAliases: true,
|
|
runtimeOnly: true,
|
|
}),
|
|
).toEqual({
|
|
mode: "targets",
|
|
targets: withConsumers
|
|
? ["test/scripts/lint-import.test.ts", "test/scripts/lint-reader.test.ts"]
|
|
: [],
|
|
});
|
|
expect(
|
|
resolveChangedTestTargetPlan([".github/unknown-lint.yaml"], { cwd }).targets,
|
|
).toEqual(["test/vitest/vitest.tooling.config.ts"]);
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
it("keeps CI scope edits on owner tests", () => {
|
|
const changedScopeTestFamily = fs
|
|
.readdirSync("src/scripts")
|
|
.filter((file) => /^ci-changed-scope(?:\.[^/]+)?\.test\.ts$/u.test(file))
|
|
.map((file) => `src/scripts/${file}`)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
expectChangedTargets(
|
|
["scripts/ci-changed-scope.mjs"],
|
|
[...changedScopeTestFamily, "test/scripts/control-ui-i18n.test.ts"],
|
|
);
|
|
});
|
|
|
|
it("routes explicit source files through precise owner tests before broad globs", () => {
|
|
expectSingleVitestRunPlan(buildVitestRunPlans(["src/gateway/server-startup-early.ts"]), {
|
|
config: "test/vitest/vitest.gateway-server.config.ts",
|
|
includePatterns: ["src/gateway/server-startup-early.test.ts"],
|
|
});
|
|
expectSingleVitestRunPlan(buildVitestRunPlans(["src/commands/onboarding-plugin-install.ts"]), {
|
|
config: "test/vitest/vitest.commands.config.ts",
|
|
includePatterns: ["src/commands/onboarding-plugin-install.test.ts"],
|
|
});
|
|
});
|
|
|
|
it.each(["src/selector/one.test.ts", "./src/selector/one.test.ts"])(
|
|
"records exact default-unit ownership for %s without removing CLI filters",
|
|
(target) => {
|
|
withTinyFileTree({ "src/selector/one.test.ts": "" }, (cwd) => {
|
|
expectSingleVitestRunPlan(buildVitestRunPlans([target, "--", "-t", "case"], cwd), {
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
forwardedArgs: ["-t", "case", target],
|
|
includePatterns: ["src/selector/one.test.ts"],
|
|
});
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["src/selector/missing.test.ts"],
|
|
["src/selector/one.spec.ts"],
|
|
["src/selector/*.test.ts"],
|
|
["src/selector/one.test.ts", "src/selector/one.spec.ts"],
|
|
["src/selector/one.test.ts", "src/selector/missing.test.ts"],
|
|
["src/selector/one.live.test.ts"],
|
|
["outside/one.test.ts"],
|
|
])("keeps unsupported default-unit targets on the CLI route: %j", (...targets) => {
|
|
withTinyFileTree(
|
|
{
|
|
"src/selector/one.test.ts": "",
|
|
"src/selector/one.spec.ts": "",
|
|
"src/selector/one.live.test.ts": "",
|
|
"outside/one.test.ts": "",
|
|
},
|
|
(cwd) => {
|
|
expectSingleVitestRunPlan(buildVitestRunPlans(targets, cwd), {
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
forwardedArgs: targets,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
it("preserves absolute and watch default-unit target semantics", () => {
|
|
const target = "src/selector/one.test.ts";
|
|
withTinyFileTree({ [target]: "" }, (cwd) => {
|
|
const absolute = path.join(cwd, target);
|
|
expectSingleVitestRunPlan(buildVitestRunPlans([absolute], cwd), {
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
forwardedArgs: [absolute],
|
|
});
|
|
expectSingleVitestRunPlan(buildVitestRunPlans(["--watch", target], cwd), {
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
forwardedArgs: [target],
|
|
watchMode: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("keeps inherited default-unit include files intersected with the CLI target", () => {
|
|
const target = "src/selector/one.test.ts";
|
|
withTinyFileTree({ [target]: "", "include.json": JSON.stringify([]) }, (cwd) => {
|
|
const includeFile = path.join(cwd, "include.json");
|
|
const [spec] = createVitestRunSpecs([target], {
|
|
cwd,
|
|
baseEnv: { OPENCLAW_VITEST_INCLUDE_FILE: includeFile },
|
|
});
|
|
expect(spec).toMatchObject({
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
includePatterns: null,
|
|
includeFilePath: null,
|
|
env: { OPENCLAW_VITEST_INCLUDE_FILE: includeFile },
|
|
});
|
|
expect(spec?.pnpmArgs.at(-1)).toBe(target);
|
|
expect(fs.readFileSync(includeFile, "utf8")).toBe("[]");
|
|
});
|
|
});
|
|
|
|
it.each(["test/scripts/run-node.test.ts", "extensions/matrix/src/matrix/client/storage.test.ts"])(
|
|
"keeps owned include selection independent of missing borrowed metadata: %s",
|
|
(target) => {
|
|
withTinyFileTree({}, (tempDir) => {
|
|
const borrowed = path.join(tempDir, "missing.json");
|
|
const [spec] = createVitestRunSpecs([target], {
|
|
baseEnv: { OPENCLAW_VITEST_INCLUDE_FILE: borrowed },
|
|
});
|
|
expect(spec?.includePatterns).toEqual([target]);
|
|
expect(spec?.includeFilePath).toBeTruthy();
|
|
expect(spec?.env.OPENCLAW_VITEST_INCLUDE_FILE).toBe(spec?.includeFilePath);
|
|
expect(spec?.env.OPENCLAW_VITEST_INCLUDE_FILE).not.toBe(borrowed);
|
|
expect(fs.existsSync(borrowed)).toBe(false);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
"preserves mixed target order with directory first=%s",
|
|
(directoryFirst) => {
|
|
withTinyFileTree({}, (tempDir) => {
|
|
const owned = "test/scripts/run-node.test.ts";
|
|
const selected = "test/helpers/temp-dir.test.ts";
|
|
const borrowed = path.join(tempDir, "include.json");
|
|
fs.writeFileSync(borrowed, JSON.stringify([selected]));
|
|
const args = directoryFirst ? ["test/helpers", owned] : [owned, "test/helpers"];
|
|
const [spec] = createVitestRunSpecs(args, {
|
|
baseEnv: { OPENCLAW_VITEST_INCLUDE_FILE: borrowed },
|
|
});
|
|
expect(spec?.includePatterns).toEqual(
|
|
directoryFirst ? [selected, owned] : [owned, selected],
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps an owned worker file beside a restricted directory selection", () => {
|
|
withTinyFileTree({}, (tempDir) => {
|
|
const owned = "extensions/matrix/src/matrix/client/storage.test.ts";
|
|
const selected = "extensions/matrix/src/matrix/sdk/idb-persistence.test.ts";
|
|
const borrowed = path.join(tempDir, "include.json");
|
|
fs.writeFileSync(borrowed, JSON.stringify([selected]));
|
|
const specs = createVitestRunSpecs([owned, "extensions/matrix/src/matrix/sdk"], {
|
|
baseEnv: { OPENCLAW_VITEST_INCLUDE_FILE: borrowed },
|
|
});
|
|
const worker = specs.find(
|
|
(spec) => spec.config === "test/vitest/vitest.extension-database-workers.config.ts",
|
|
);
|
|
expect(worker?.includePatterns?.toSorted()).toEqual([owned, selected].toSorted());
|
|
expect(fs.readFileSync(borrowed, "utf8")).toBe(JSON.stringify([selected]));
|
|
});
|
|
});
|
|
|
|
it("deduplicates explicit source tests that share import-graph owners", () => {
|
|
let plans: ReturnType<typeof buildVitestRunPlans> = [];
|
|
withTinyGitRepo(
|
|
{
|
|
"src/runtime-a.ts": "export const a = 'a';\n",
|
|
"src/runtime-b.ts": "export const b = 'b';\n",
|
|
"src/runtime.consumer.test.ts":
|
|
"import { a } from './runtime-a.js';\nimport { b } from './runtime-b.js';\nvoid [a, b];\n",
|
|
},
|
|
(cwd) => {
|
|
plans = buildVitestRunPlans(["src/runtime-a.ts", "src/runtime-b.ts"], cwd);
|
|
},
|
|
);
|
|
|
|
expectSingleVitestRunPlan(plans, {
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
forwardedArgs: ["src/runtime.consumer.test.ts"],
|
|
includePatterns: ["src/runtime.consumer.test.ts"],
|
|
});
|
|
});
|
|
|
|
it("preserves Git membership for large changed and explicit test inventories", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
".gitignore": "src/runtime.ignored.test.ts\n",
|
|
"src/runtime.ts": "export const value = 1;\n",
|
|
"src/runtime.consumer.test.ts": 'import "./runtime.js";\n',
|
|
},
|
|
(cwd) => {
|
|
// Index-only padding reproduces a large checkout without thousands of fixture files.
|
|
const blob = spawnSync("git", ["hash-object", "-w", "--stdin"], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
input: "",
|
|
});
|
|
expect(blob.status).toBe(0);
|
|
const padding = Array.from(
|
|
{ length: 6_000 },
|
|
(_, index) =>
|
|
`100644 ${blob.stdout.trim()}\tsrc/padding/${index}-${"x".repeat(190)}.ts\n`,
|
|
).join("");
|
|
const indexed = spawnSync("git", ["update-index", "--index-info"], {
|
|
cwd,
|
|
input: padding,
|
|
});
|
|
expect(indexed.status).toBe(0);
|
|
for (const root of ["extensions", "packages", "ui/src", "ui/config", "test"]) {
|
|
fs.mkdirSync(path.join(cwd, root), { recursive: true });
|
|
}
|
|
fs.writeFileSync(
|
|
path.join(cwd, "src/runtime.untracked.test.ts"),
|
|
'import "./runtime.js";\n',
|
|
);
|
|
fs.writeFileSync(path.join(cwd, "src/runtime.ignored.test.ts"), 'import "./runtime.js";\n');
|
|
|
|
expect(resolveChangedTestTargetPlan(["src/runtime.ts"], { cwd }).targets).toEqual([
|
|
"src/runtime.consumer.test.ts",
|
|
]);
|
|
const includeFile = path.join(cwd, "include.json");
|
|
writeVitestIncludeFile(includeFile, ["src/**/*.test.ts"], { cwd });
|
|
expect(JSON.parse(fs.readFileSync(includeFile, "utf8")).toSorted()).toEqual([
|
|
"src/runtime.consumer.test.ts",
|
|
"src/runtime.untracked.test.ts",
|
|
]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("follows converging import frontiers without crossing test boundaries", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
"src/value.ts": 'export * from "./left/bridge.js";\n',
|
|
"src/left/bridge.ts": 'export\n * from\n "../value.js";\n',
|
|
"src/right/bridge.ts": 'export * from "../value.js";\n',
|
|
"src/other/bridge.ts": "export const unrelated = 1;\n",
|
|
"src/combined.consumer.test.ts":
|
|
'import\u00a0"./left/bridge.js";\nimport(\n"./right/bridge.js"\n);\n',
|
|
"src/right.consumer.test.ts": 'import "./right/bridge.js";\n',
|
|
"src/other.consumer.test.ts": 'import "./other/bridge.js";\n',
|
|
"src/after-test.consumer.test.ts": 'import "./combined.consumer.test.js";\n',
|
|
"src/value.consumer.live.test.ts": 'import "./value.js";\n',
|
|
},
|
|
(cwd) => {
|
|
expect(resolveChangedTestTargetPlan(["src/value.ts"], { cwd }).targets).toEqual([
|
|
"src/combined.consumer.test.ts",
|
|
"src/right.consumer.test.ts",
|
|
]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("bounds direct importer queries to one edge and retains edited tests", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
"src/value.ts": "export const value = 1;\n",
|
|
"src/direct.test.ts": 'import "./value.js";\n',
|
|
"src/bridge.ts": 'export * from "./value.js";\n',
|
|
"src/indirect.test.ts": 'import "./bridge.js";\n',
|
|
"src/shared.test.ts": "export const fixture = 1;\n",
|
|
"src/shared-consumer.test.ts": 'import "./shared.test.js";\n',
|
|
},
|
|
(cwd) => {
|
|
for (const changed of ["src/value.ts", ["src/value.ts"]]) {
|
|
expect(resolveAffectedTestsFromImportGraph(changed, cwd, { direct: true })).toEqual([
|
|
"src/direct.test.ts",
|
|
]);
|
|
}
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph(["src/shared.test.ts"], cwd, { direct: true }),
|
|
).toEqual(["src/shared-consumer.test.ts", "src/shared.test.ts"]);
|
|
expect(
|
|
hasImportGraphImpactOnTargets(["src/value.ts"], ["src/direct.test.ts"], cwd, {
|
|
direct: true,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
hasImportGraphImpactOnTargets(["src/value.ts"], ["src/indirect.test.ts"], cwd, {
|
|
direct: true,
|
|
}),
|
|
).toBe(false);
|
|
expect(hasImportGraphImpactOnTargets(["src/value.ts"], ["src/indirect.test.ts"], cwd)).toBe(
|
|
true,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("retains configured Vitest setup, runner, and environment consumers", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
"test/vitest/vitest.shared.config.ts": `
|
|
import "./vitest.extension-config.ts";
|
|
const runnerPath = path.join(repoRoot, "test", "runner.ts",);
|
|
export const config = {
|
|
setupFiles: [resolveRepoRootPath("test/setup.ts")],
|
|
globalSetup: [resolveRepoRootPath("test/global-setup.ts")],
|
|
runner: runnerPath,
|
|
include: ["src/unrelated.test.ts"],
|
|
};`,
|
|
"test/vitest/vitest.extension-config.ts": `
|
|
export const createConfig = () => ({ setupFiles: ["test/extension-setup.ts"] });`,
|
|
"test/vitest/vitest.ui.config.ts": `
|
|
import type { DOMWindow } from "jsdom";
|
|
import "./vitest.shared.config.ts";
|
|
export default { environment: "jsdom", setupFiles: ["ui/src/setup.ts"] };`,
|
|
"ui/vitest.config.ts": `
|
|
const nodeSetupFiles = ["./src/setup.ts"];
|
|
export default { environment: "jsdom", setupFiles: nodeSetupFiles };`,
|
|
"test/setup.ts": 'import "./helper.ts";',
|
|
"test/helper.ts": "export const helper = true;",
|
|
"test/runner.ts": 'import "./runner-helper.ts";',
|
|
"test/runner-helper.ts": "export const runnerHelper = true;",
|
|
"test/global-setup.ts": 'import "./global-helper.ts";',
|
|
"test/global-helper.ts": "export const globalHelper = true;",
|
|
"test/extension-setup.ts": 'import "./extension-helper.ts";',
|
|
"test/extension-helper.ts": "export const extensionHelper = true;",
|
|
"ui/src/setup.ts": 'import "./setup-helper.ts";',
|
|
"ui/src/setup-helper.ts": "export const uiSetup = true;",
|
|
"ui/src/component.test.ts": 'import "jsdom";',
|
|
"src/unrelated.test.ts": "export const unrelated = true;",
|
|
},
|
|
(cwd) => {
|
|
const options = { tooling: true, resolveAliases: true, runtimeOnly: true };
|
|
for (const helper of [
|
|
"test/helper.ts",
|
|
"test/runner-helper.ts",
|
|
"test/global-helper.ts",
|
|
"test/extension-helper.ts",
|
|
]) {
|
|
expect(
|
|
hasImportGraphImpactOnTargets(
|
|
[helper],
|
|
["test/vitest/vitest.ui.config.ts"],
|
|
cwd,
|
|
options,
|
|
),
|
|
helper,
|
|
).toBe(true);
|
|
}
|
|
expect(
|
|
hasImportGraphImpactOnTargets(
|
|
["ui/src/setup-helper.ts"],
|
|
["ui/vitest.config.ts"],
|
|
cwd,
|
|
options,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
hasImportGraphImpactOnTargets(
|
|
["src/unrelated.test.ts"],
|
|
["test/vitest/vitest.ui.config.ts"],
|
|
cwd,
|
|
options,
|
|
),
|
|
).toBe(false);
|
|
const consumers = resolveDependencyTestConsumers(
|
|
[{ root: ".", dependencies: ["jsdom"] }],
|
|
cwd,
|
|
{ runtimeOnly: true },
|
|
);
|
|
expect(consumers.unresolved).toEqual([]);
|
|
expect(consumers.sources.toSorted()).toEqual([
|
|
"test/vitest/vitest.ui.config.ts",
|
|
"ui/src/component.test.ts",
|
|
"ui/vitest.config.ts",
|
|
]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("follows source aliases and package exports through shared test consumers for CI", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
"tsconfig.json": JSON.stringify({
|
|
compilerOptions: {
|
|
paths: {
|
|
"openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"],
|
|
"@openclaw/library/*": ["./packages/library/src/*.ts"],
|
|
"@test-fixture/*": ["./test/fixtures/*/index.test.ts"],
|
|
},
|
|
},
|
|
}),
|
|
"packages/library/package.json": JSON.stringify({
|
|
name: "@openclaw/library",
|
|
exports: {
|
|
".": { default: "./src/index.ts" },
|
|
"./test-entry": { import: "./src/import.test.ts", require: "./src/require.test.ts" },
|
|
},
|
|
}),
|
|
"src/value.ts": "export const value = 1;\n",
|
|
"src/native-helper.js": 'export * from "./value.js";\n',
|
|
"src/script-consumer.test.ts": 'import "./native-helper.js";\n',
|
|
"src/plugin-sdk/public.ts": 'export * from "../value.js";\n',
|
|
"packages/library/src/index.ts": 'export * from "openclaw/plugin-sdk/public";\n',
|
|
"packages/library/src/other.ts": "export const unrelated = 1;\n",
|
|
"packages/library/src/import.test.ts": "export const importValue = 1;\n",
|
|
"packages/library/src/require.test.ts": "export const requireValue = 1;\n",
|
|
"test/fixtures/owner/index.test.ts": "export const value = 1;\n",
|
|
"extensions/consumer/conditional.test.ts": 'import "@openclaw/library/test-entry";\n',
|
|
"extensions/consumer/fixture.test.ts": 'import "@test-fixture/owner";\n',
|
|
"extensions/consumer/shared.test.ts": 'import "@openclaw/library";\n',
|
|
"extensions/consumer/downstream.test.ts": 'import "./shared.test.js";\n',
|
|
"extensions/consumer/direct.test.ts": 'require("openclaw/plugin-sdk/public");\n',
|
|
"extensions/consumer/type-only.test.ts":
|
|
'import type { value } from "openclaw/plugin-sdk/public";\n',
|
|
"extensions/consumer/other.test.ts": 'import "@openclaw/library/other";\n',
|
|
},
|
|
(cwd) => {
|
|
const expected = [
|
|
"extensions/consumer/direct.test.ts",
|
|
"extensions/consumer/downstream.test.ts",
|
|
"extensions/consumer/shared.test.ts",
|
|
];
|
|
expect(resolveAffectedTestsFromImportGraph(["src/value.ts"], cwd)).toEqual([]);
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph(["src/value.ts"], cwd, { resolveAliases: true }),
|
|
).toEqual([...expected, "extensions/consumer/type-only.test.ts"]);
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph(["src/value.ts"], cwd, {
|
|
resolveAliases: true,
|
|
runtimeOnly: true,
|
|
}),
|
|
).toEqual(expected);
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph(["extensions/consumer/shared.test.ts"], cwd, {
|
|
resolveAliases: true,
|
|
forceFull: true,
|
|
}),
|
|
).toEqual(["extensions/consumer/downstream.test.ts"]);
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph(["packages/library/src/other.ts"], cwd, {
|
|
resolveAliases: true,
|
|
}),
|
|
).toEqual(["extensions/consumer/other.test.ts"]);
|
|
for (const target of [
|
|
"packages/library/src/import.test.ts",
|
|
"packages/library/src/require.test.ts",
|
|
]) {
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph([target], cwd, {
|
|
resolveAliases: true,
|
|
forceFull: true,
|
|
}),
|
|
).toEqual(["extensions/consumer/conditional.test.ts"]);
|
|
}
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph(["test/fixtures/owner/index.test.ts"], cwd, {
|
|
resolveAliases: true,
|
|
forceFull: true,
|
|
}),
|
|
).toEqual(["extensions/consumer/fixture.test.ts"]);
|
|
expect(resolveAffectedTestsFromImportGraph("src/value.ts", cwd, { tooling: true })).toEqual(
|
|
["src/script-consumer.test.ts"],
|
|
);
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph(["src/value.ts"], cwd, {
|
|
tooling: true,
|
|
resolveAliases: true,
|
|
runtimeOnly: true,
|
|
}),
|
|
).toEqual([...expected, "src/script-consumer.test.ts"]);
|
|
fs.unlinkSync(path.join(cwd, "extensions/consumer/shared.test.ts"));
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph(["extensions/consumer/shared.test.ts"], cwd, {
|
|
resolveAliases: true,
|
|
forceFull: true,
|
|
}),
|
|
).toEqual(["extensions/consumer/downstream.test.ts"]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("retains import consumers after a changed source is removed from the index", () => {
|
|
withTinyGitRepo({ "src/consumer.test.ts": 'export * from "./removed.js";\n' }, (cwd) => {
|
|
expect(
|
|
resolveAffectedTestsFromImportGraph(["src/removed.ts"], cwd, {
|
|
resolveAliases: true,
|
|
}),
|
|
).toEqual(["src/consumer.test.ts"]);
|
|
});
|
|
});
|
|
|
|
it("routes changed resolved dependencies through scoped bare and subpath consumers", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
"src/root.ts": 'import "dependency/runtime"; import "second-dependency";\n',
|
|
"src/type-owner.ts":
|
|
'import type { Shape } from "types-only"; export type Callback = <T>(value: T) => T;\n',
|
|
"src/root.test.ts": 'import "./root.js";\n',
|
|
"extensions/consumer/source.ts": 'require("dependency");\n',
|
|
"extensions/consumer/source.test.ts": 'import "./source.js";\n',
|
|
"extensions/consumer/direct.test.ts": 'import("dependency/entry");\n',
|
|
"extensions/consumer/other.test.ts": 'import "dependency-other";\n',
|
|
"test/scripts/tool.test.ts":
|
|
'require.resolve("dependency"); import.meta.resolve("dependency/runtime");\n',
|
|
"test/scripts/generated-cli.test.ts": 'spawnSync("node_modules/.bin/opaque-cli", []);\n',
|
|
"ui/src/element-view.tsx":
|
|
'export const view = <><span></span><Lazy load={() => import("dependency")} /></>;\n',
|
|
"ui/src/element-view.test.ts": 'import "./element-view.js";\n',
|
|
"ui/src/text-view.tsx":
|
|
'export const view = <div> // inline text {import("dependency")} </div>;\n',
|
|
"ui/src/text-view.test.ts": 'import "./text-view.js";\n',
|
|
},
|
|
(cwd) => {
|
|
expect(
|
|
resolveDependencyTestConsumers(
|
|
[{ root: "extensions/consumer", dependencies: ["dependency"] }],
|
|
cwd,
|
|
).tests,
|
|
).toEqual(["extensions/consumer/direct.test.ts", "extensions/consumer/source.test.ts"]);
|
|
const root = resolveDependencyTestConsumers(
|
|
[{ root: ".", dependencies: ["dependency"] }],
|
|
cwd,
|
|
);
|
|
expect(root.sources.toSorted()).toEqual([
|
|
"extensions/consumer/direct.test.ts",
|
|
"extensions/consumer/source.ts",
|
|
"src/root.ts",
|
|
"test/scripts/tool.test.ts",
|
|
"ui/src/element-view.tsx",
|
|
"ui/src/text-view.tsx",
|
|
]);
|
|
expect(root.tests).toEqual([
|
|
"extensions/consumer/direct.test.ts",
|
|
"extensions/consumer/source.test.ts",
|
|
"src/root.test.ts",
|
|
"test/scripts/tool.test.ts",
|
|
"ui/src/element-view.test.ts",
|
|
"ui/src/text-view.test.ts",
|
|
]);
|
|
expect(root.unresolved).toEqual([]);
|
|
const mixed = resolveDependencyTestConsumers(
|
|
[
|
|
{
|
|
root: ".",
|
|
dependencies: ["dependency", "second-dependency", "opaque-cli", "types-only"],
|
|
},
|
|
],
|
|
cwd,
|
|
{ runtimeOnly: true },
|
|
);
|
|
expect(mixed.tests).toEqual(root.tests);
|
|
expect(mixed.unresolved).toEqual([{ root: ".", dependency: "opaque-cli" }]);
|
|
expect(
|
|
resolveDependencyTestConsumers([{ root: ".", dependencies: ["types-only"] }], cwd, {
|
|
runtimeOnly: true,
|
|
}),
|
|
).toEqual({
|
|
sources: [],
|
|
tests: [],
|
|
unresolved: [],
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
it("keeps root dependency changes behind nearer unchanged workspace bindings", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
"src/root.test.ts": 'import "dependency";\n',
|
|
"ui/src/own.test.ts": 'import "dependency/subpath";\n',
|
|
"ui/src/shadowed.test.ts": 'import "workspace-only";\n',
|
|
"packages/own/src/value.ts": 'require("dependency");\n',
|
|
"packages/own/src/value.test.ts": 'import "./value.js";\n',
|
|
"src/package-consumer.test.ts": 'import "../packages/own/src/value.js";\n',
|
|
"extensions/own/value.test.ts": 'import "dependency";\n',
|
|
"extensions/undeclared/value.test.ts": 'import "dependency";\n',
|
|
},
|
|
(cwd) => {
|
|
const importerBindings = [".", "ui", "packages/own", "extensions/own"].map((root) => ({
|
|
root,
|
|
dependencies: root === "ui" ? ["dependency", "workspace-only"] : ["dependency"],
|
|
}));
|
|
expect(
|
|
resolveDependencyTestConsumers([{ root: ".", dependencies: ["dependency"] }], cwd, {
|
|
importerBindings,
|
|
}).tests,
|
|
).toEqual(["extensions/undeclared/value.test.ts", "src/root.test.ts"]);
|
|
expect(
|
|
resolveDependencyTestConsumers(
|
|
[{ root: "packages/own", dependencies: ["dependency"] }],
|
|
cwd,
|
|
{ importerBindings },
|
|
).tests,
|
|
).toEqual(["packages/own/src/value.test.ts", "src/package-consumer.test.ts"]);
|
|
expect(
|
|
resolveDependencyTestConsumers([{ root: "ui", dependencies: ["dependency"] }], cwd, {
|
|
importerBindings: importerBindings.filter(({ root }) => root !== "ui"),
|
|
}).tests,
|
|
).toEqual(["ui/src/own.test.ts"]);
|
|
expect(
|
|
resolveDependencyTestConsumers([{ root: ".", dependencies: ["workspace-only"] }], cwd, {
|
|
importerBindings,
|
|
}).unresolved,
|
|
).toEqual([{ root: ".", dependency: "workspace-only" }]);
|
|
expect(
|
|
resolveDependencyTestConsumers([{ root: "ui", dependencies: ["workspace-only"] }], cwd, {
|
|
importerBindings,
|
|
}).unresolved,
|
|
).toEqual([]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"keeps broad-name consumers complete without synchronous source opens (narrow importer: %s)",
|
|
(includeNarrowImporter) => {
|
|
const files: Record<string, string> = {
|
|
"src/owner/value.ts": "export const value = 1;\n",
|
|
"src/owner/barrel.ts": 'export\n { value } from\n "./value.js";\n',
|
|
"src/owner/directory/index.ts": 'export * from "../barrel.js";\n',
|
|
"src/owner/consumer.test.ts": 'import(\n "./directory"\n);\n',
|
|
"src/owner/type.consumer.test.ts": 'import type {\n Value\n } from "./value.js";\n',
|
|
"src/owner/deleted.test.ts": 'import "./value.js";\n',
|
|
"src/owner/value.live.test.ts": 'import "./value.js";\n',
|
|
};
|
|
if (includeNarrowImporter) {
|
|
files["src/outside.consumer.test.ts"] = 'import "./owner/value.js";\n';
|
|
}
|
|
// A narrow path match must not hide consumers of a widely occurring basename.
|
|
for (let index = 0; index < 801; index += 1) {
|
|
files[`src/padding/${index}.ts`] = "// value\n";
|
|
}
|
|
withTinyGitRepo(files, (cwd) => {
|
|
fs.unlinkSync(path.join(cwd, "src/owner/deleted.test.ts"));
|
|
fs.writeFileSync(path.join(cwd, "src/owner/untracked.test.ts"), 'import "./value.js";\n');
|
|
const reads = vi.spyOn(fs, "readFileSync");
|
|
try {
|
|
const expectedTargets = [
|
|
...(includeNarrowImporter ? ["src/outside.consumer.test.ts"] : []),
|
|
"src/owner/consumer.test.ts",
|
|
"src/owner/type.consumer.test.ts",
|
|
];
|
|
expect(
|
|
resolveChangedTestTargetPlan(["src/owner/value.ts"], { cwd }).targets,
|
|
"initial owner selection",
|
|
).toEqual(expectedTargets);
|
|
expect(
|
|
resolveChangedTestTargetPlan(["src/owner/value.ts"], { cwd }).targets,
|
|
"cached owner selection",
|
|
).toEqual(expectedTargets);
|
|
expect(
|
|
reads.mock.calls.filter(([file]) => typeof file === "string" && file.startsWith(cwd)),
|
|
).toEqual([]);
|
|
} finally {
|
|
reads.mockRestore();
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ name: "Git inventory", withRepo: withTinyGitRepo },
|
|
{ name: "filesystem inventory", withRepo: withTinyFileTree },
|
|
])(
|
|
"keeps tooling imports direct while preserving literal file references ($name)",
|
|
({ withRepo }) => {
|
|
const files: Record<string, string> = {
|
|
"tsconfig.json": JSON.stringify({
|
|
compilerOptions: { paths: { "@fixture/tool": ["./scripts/fixture-bridge.mts"] } },
|
|
}),
|
|
"scripts/fixture-source.mts": "export const value = 1;\n",
|
|
"scripts/fixture-data.json": '{"value":1}\n',
|
|
"scripts/no-direct-owner.mts": "export const other = 1;\n",
|
|
"scripts/fixture-bridge.mts":
|
|
'import data from "./fixture-data.json" with { type: "json" }; export { data }; export * from "./fixture-source.mjs"; export * from "./no-direct-owner.mjs";\n',
|
|
"test/scripts/alias.consumer.test.ts": 'import "@fixture/tool";\n',
|
|
"test/scripts/direct.consumer.test.ts": 'import "../../scripts/fixture-source.mjs";\n',
|
|
"test/scripts/type.consumer.test.ts":
|
|
'import type { Value } from "../../scripts/fixture-source.mjs";\n',
|
|
"test/scripts/transitive.consumer.test.ts": 'import "../../scripts/fixture-bridge.mjs";\n',
|
|
"test/scripts/literal.consumer.test.ts": 'const fixture = "scripts/fixture-source.mts";\n',
|
|
"test/scripts/substring.consumer.test.ts":
|
|
'const fixture = "scripts/fixture-source.mts.bak";\n',
|
|
};
|
|
for (let index = 0; index < 801; index += 1) {
|
|
files[`src/padding/${index}.ts`] = "// scripts/fixture-source\n";
|
|
}
|
|
withRepo(files, (cwd) => {
|
|
expect(
|
|
resolveChangedTestTargetPlan(["scripts/fixture-source.mts"], { cwd }).targets,
|
|
).toEqual([
|
|
"test/scripts/direct.consumer.test.ts",
|
|
"test/scripts/type.consumer.test.ts",
|
|
"test/scripts/literal.consumer.test.ts",
|
|
]);
|
|
const ciOptions = {
|
|
cwd,
|
|
forceFullImportGraph: true,
|
|
resolveAliases: true,
|
|
runtimeOnly: true,
|
|
};
|
|
expect(
|
|
resolveChangedTestTargetPlan(["scripts/fixture-source.mts"], ciOptions).targets,
|
|
).toEqual([
|
|
"test/scripts/alias.consumer.test.ts",
|
|
"test/scripts/direct.consumer.test.ts",
|
|
"test/scripts/transitive.consumer.test.ts",
|
|
"test/scripts/literal.consumer.test.ts",
|
|
]);
|
|
expect(
|
|
resolveChangedTestTargetPlan(["scripts/no-direct-owner.mts"], { cwd }).targets,
|
|
).toEqual(["test/vitest/vitest.tooling.config.ts"]);
|
|
expect(
|
|
resolveChangedTestTargetPlan(["scripts/no-direct-owner.mts"], ciOptions).targets,
|
|
).toEqual([
|
|
"test/scripts/alias.consumer.test.ts",
|
|
"test/scripts/transitive.consumer.test.ts",
|
|
]);
|
|
expect(
|
|
resolveChangedTestTargetPlan(["scripts/fixture-data.json"], { cwd }).targets,
|
|
).toEqual(["test/vitest/vitest.tooling.config.ts"]);
|
|
expect(
|
|
resolveChangedTestTargetPlan(["scripts/fixture-data.json"], ciOptions).targets,
|
|
).toEqual([
|
|
"test/scripts/alias.consumer.test.ts",
|
|
"test/scripts/transitive.consumer.test.ts",
|
|
]);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ name: "Git inventory", withRepo: withTinyGitRepo },
|
|
{ name: "filesystem inventory", withRepo: withTinyFileTree },
|
|
])("routes test helper and file-URL fixture consumers completely ($name)", ({ withRepo }) => {
|
|
const fixture = "test/scripts/fixtures/worker.mjs";
|
|
const helper = "test/scripts/runtime.test-support.ts";
|
|
const direct = "test/scripts/direct.consumer.test.ts";
|
|
const indirect = "test/scripts/indirect.consumer.test.ts";
|
|
const opaque = "test/scripts/opaque/index.mjs";
|
|
const explicitHelper = "test/scripts/source/input.test-support.ts";
|
|
const explicitConsumer = "test/scripts/outer/consumer.test.ts";
|
|
withRepo(
|
|
{
|
|
[fixture]: "export {};\n",
|
|
[helper]:
|
|
'export const worker = new URL("./fixtures/worker.mjs?generation=1#child", import.meta.url);\n',
|
|
"test/scripts/bridge.test-support.ts": 'export * from "./runtime.test-support.js";\n',
|
|
[direct]: 'import "./runtime.test-support.js";\n',
|
|
[indirect]: 'import "./bridge.test-support.js";\n',
|
|
"test/scripts/after-test.consumer.test.ts": 'import "./direct.consumer.test.js";\n',
|
|
"test/scripts/unrelated.consumer.test.ts": "export {};\n",
|
|
"test/scripts/live.consumer.live.test.ts": 'import "./runtime.test-support.js";\n',
|
|
[opaque]: "export {};\n",
|
|
"test/scripts/literal.opaque.consumer.test.ts": `const fixture = "${opaque}";\n`,
|
|
"test/scripts/relative.opaque.consumer.test.ts": 'import "./opaque/index.mjs";\n',
|
|
[explicitHelper]: "export const value = 1;\n",
|
|
"test/scripts/bridge/index.ts": 'export * from "../source/input.test-support.js";\n',
|
|
[explicitConsumer]: 'import "../bridge/index.js";\n',
|
|
},
|
|
(cwd) => {
|
|
const options = { cwd, broad: true, forceFullImportGraph: true };
|
|
for (const changed of [fixture, helper]) {
|
|
expect(resolveChangedTestTargetPlan([changed], { cwd, broad: true })).toEqual({
|
|
mode: "targets",
|
|
targets: [direct, indirect],
|
|
});
|
|
expect(resolveChangedTestTargetPlan([changed], options)).toEqual({
|
|
mode: "targets",
|
|
targets: ["test/scripts/after-test.consumer.test.ts", direct, indirect],
|
|
});
|
|
}
|
|
// A literal reference alone cannot prove an opaque helper's complete frontier.
|
|
expect(resolveChangedTestTargetPlan([opaque, direct], { cwd, broad: true })).toEqual({
|
|
mode: "targets",
|
|
targets: [opaque, direct],
|
|
});
|
|
expect(resolveChangedTestTargetPlan([opaque, direct], options)).toEqual({
|
|
mode: "targets",
|
|
targets: [
|
|
"test/scripts/relative.opaque.consumer.test.ts",
|
|
"test/scripts/literal.opaque.consumer.test.ts",
|
|
direct,
|
|
],
|
|
});
|
|
expect(buildVitestRunPlans([explicitHelper], cwd)).toEqual([
|
|
runPlan("tooling", [explicitConsumer]),
|
|
]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"retains an unowned changed tooling test alongside its consumers (mixed input: %s)",
|
|
(mixedInput) => {
|
|
const changedTest = "test/e2e/qa-lab/runtime/changed-tooling.test.ts";
|
|
const reader = "test/scripts/tooling-reader.test.ts";
|
|
const otherTest = "src/independent.test.ts";
|
|
withTinyGitRepo(
|
|
{
|
|
[changedTest]: "export const value = 1;\n",
|
|
[reader]:
|
|
'import "../e2e/qa-lab/runtime/changed-tooling.test.js";\n' +
|
|
`const fixture = "${changedTest}";\n`,
|
|
[otherTest]: "export const independent = true;\n",
|
|
},
|
|
(cwd) => {
|
|
const inputs = mixedInput ? [changedTest, otherTest, changedTest] : [changedTest];
|
|
expect(resolveChangedTestTargetPlan(inputs, { cwd })).toEqual({
|
|
mode: "targets",
|
|
targets: [changedTest, reader, ...(mixedInput ? [otherTest] : [])],
|
|
});
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
changedPath: "scripts/unowned-source.mts",
|
|
expectedTargets: ["test/scripts/tooling-reader.test.ts"],
|
|
},
|
|
{
|
|
changedPath: "test/scripts/owned.test.ts",
|
|
expectedTargets: ["test/scripts/owned.test.ts"],
|
|
},
|
|
{
|
|
changedPath: "test/e2e/qa-lab/runtime/changed-tooling.live.test.ts",
|
|
expectedTargets: ["test/scripts/tooling-reader.test.ts"],
|
|
},
|
|
])(
|
|
"preserves non-test, explicit-owner, and live selection for $changedPath",
|
|
({ changedPath, expectedTargets }) => {
|
|
withTinyGitRepo(
|
|
{
|
|
[changedPath]: "export const value = 1;\n",
|
|
"test/scripts/tooling-reader.test.ts": `const fixture = "${changedPath}";\n`,
|
|
},
|
|
(cwd) => {
|
|
expect(resolveChangedTestTargetPlan([changedPath], { cwd })).toEqual({
|
|
mode: "targets",
|
|
targets: expectedTargets,
|
|
});
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
it("routes many explicit source files through one import-graph-backed owner set", () => {
|
|
let plans: ReturnType<typeof buildVitestRunPlans> = [];
|
|
const files: Record<string, string> = {};
|
|
const imports: string[] = [];
|
|
const refs: string[] = [];
|
|
for (let index = 0; index < 13; index += 1) {
|
|
files[`src/runtime-${index}.ts`] = `export const value${index} = ${index};\n`;
|
|
imports.push(`import { value${index} } from './runtime-${index}.js';`);
|
|
refs.push(`value${index}`);
|
|
}
|
|
files["src/runtime.consumer.test.ts"] = `${imports.join("\n")}\nvoid [${refs.join(", ")}];\n`;
|
|
|
|
withTinyFileTree(files, (cwd) => {
|
|
plans = buildVitestRunPlans(
|
|
Array.from({ length: 13 }, (_, index) => `src/runtime-${index}.ts`),
|
|
cwd,
|
|
);
|
|
});
|
|
|
|
expectSingleVitestRunPlan(plans, {
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
forwardedArgs: ["src/runtime.consumer.test.ts"],
|
|
includePatterns: ["src/runtime.consumer.test.ts"],
|
|
});
|
|
});
|
|
|
|
it("does not route live tests through the normal changed-test lane", () => {
|
|
expectChangedTargets(["src/gateway/gateway-codex-harness.live.test.ts"], []);
|
|
});
|
|
|
|
it.each<{
|
|
args: string[];
|
|
owner: string;
|
|
watchMode: boolean;
|
|
env?: NodeJS.ProcessEnv;
|
|
inheritedProjectShards?: string;
|
|
}>([
|
|
{ args: ["src/gateway/server.health.test.ts"], owner: "gateway-server", watchMode: false },
|
|
{
|
|
args: ["--watch", "src/gateway/server.health.test.ts"],
|
|
owner: "gateway",
|
|
watchMode: true,
|
|
},
|
|
{
|
|
args: ["src/gateway/server.health.test.ts", "src/gateway/call.test.ts"],
|
|
owner: "gateway",
|
|
watchMode: false,
|
|
},
|
|
{
|
|
args: ["--watch", "src/gateway/server.health.test.ts", "src/gateway/call.test.ts"],
|
|
owner: "gateway",
|
|
watchMode: true,
|
|
},
|
|
{
|
|
args: ["src/gateway/server.health.test.ts"],
|
|
owner: "gateway",
|
|
watchMode: false,
|
|
env: { OPENCLAW_GATEWAY_PROJECT_SHARDS: "0" },
|
|
},
|
|
{
|
|
args: ["src/gateway/server.health.test.ts"],
|
|
owner: "gateway",
|
|
watchMode: false,
|
|
inheritedProjectShards: "0",
|
|
},
|
|
{
|
|
args: ["src/gateway/server.health.test.ts"],
|
|
owner: "gateway-server",
|
|
watchMode: false,
|
|
env: { OPENCLAW_GATEWAY_PROJECT_SHARDS: "1" },
|
|
inheritedProjectShards: "0",
|
|
},
|
|
{
|
|
args: ["test/vitest/vitest.gateway-server.config.ts"],
|
|
owner: "gateway-server",
|
|
watchMode: false,
|
|
env: { OPENCLAW_GATEWAY_PROJECT_SHARDS: "0" },
|
|
},
|
|
])(
|
|
"keeps exact Gateway server files on their $owner owner for $args",
|
|
({ args, owner, watchMode, env, inheritedProjectShards }) => {
|
|
withEnv({ OPENCLAW_GATEWAY_PROJECT_SHARDS: inheritedProjectShards }, () => {
|
|
expectSingleVitestRunPlan(
|
|
buildVitestRunPlans(args, process.cwd(), () => [], { env }),
|
|
{
|
|
config: `test/vitest/vitest.${owner}.config.ts`,
|
|
includePatterns: args.some((arg) => arg.endsWith(".config.ts"))
|
|
? null
|
|
: args.filter((arg) => arg !== "--watch"),
|
|
watchMode,
|
|
},
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
"src/gateway/health/collector.queue-health.test.ts",
|
|
"src/gateway/provider-auth-account-relogin.persistence.integration.test.ts",
|
|
"src/gateway/server-methods/server-methods.test.ts",
|
|
])("routes Gateway SQLite consumer %s exactly once to its broker owner", (testFile) => {
|
|
expectSingleVitestRunPlan(buildVitestRunPlans([testFile]), {
|
|
config: "test/vitest/vitest.gateway-database-workers.config.ts",
|
|
includePatterns: [testFile],
|
|
});
|
|
expect(gatewayDatabaseWorkerTestFiles.filter((file) => file === testFile)).toEqual([testFile]);
|
|
});
|
|
|
|
it.each(
|
|
["src/gateway", "src/gateway/**/*.test.ts"].flatMap((target) =>
|
|
["alone", "worker-first", "aggregate-first"].map((order) => ({ target, order })),
|
|
),
|
|
)(
|
|
"keeps Gateway database consumers in the aggregate for $target ($order)",
|
|
({ target, order }) => {
|
|
const [workerFile] = gatewayDatabaseWorkerTestFiles;
|
|
assert(workerFile);
|
|
const targets =
|
|
order === "alone"
|
|
? [target]
|
|
: order === "worker-first"
|
|
? [workerFile, target]
|
|
: [target, workerFile];
|
|
const forwardedArgs = ["--reporter=dot", "--coverage"];
|
|
expect(buildVitestRunPlans([...targets, ...forwardedArgs])).toEqual([
|
|
{
|
|
config: "test/vitest/vitest.gateway.config.ts",
|
|
forwardedArgs,
|
|
includePatterns: targets.map((file) =>
|
|
file === "src/gateway" ? "src/gateway/**/*.test.ts" : file,
|
|
),
|
|
watchMode: false,
|
|
},
|
|
{
|
|
config: "test/vitest/vitest.infra.config.ts",
|
|
forwardedArgs,
|
|
includePatterns: databaseWorkerCoreTestFiles.filter((file) =>
|
|
file.startsWith("src/gateway/"),
|
|
),
|
|
watchMode: false,
|
|
},
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
[
|
|
"test/vitest/vitest.gateway.config.ts",
|
|
"src/gateway/config-reload.telegram-policy.test.ts",
|
|
].flatMap((target) => [true, false].map((workerFirst) => ({ target, workerFirst }))),
|
|
)(
|
|
"coalesces Gateway worker config with $target (worker first: $workerFirst)",
|
|
({ target, workerFirst }) => {
|
|
const workerConfig = "test/vitest/vitest.gateway-database-workers.config.ts";
|
|
const targets = workerFirst ? [workerConfig, target] : [target, workerConfig];
|
|
expectSingleVitestRunPlan(buildVitestRunPlans(targets), {
|
|
config: "test/vitest/vitest.gateway.config.ts",
|
|
includePatterns: target.endsWith(".config.ts")
|
|
? null
|
|
: workerFirst
|
|
? [...gatewayDatabaseWorkerTestFiles, target]
|
|
: [target, ...gatewayDatabaseWorkerTestFiles],
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
"src/agents/command/session-store.test.ts",
|
|
"src/agents/models-config.providers.endpoint.test.ts",
|
|
"src/agents/models-config.root-authorship.test.ts",
|
|
"src/agents/models-config.runtime-source-snapshot.test.ts",
|
|
"src/agents/models-config.write-serialization.test.ts",
|
|
"src/agents/plugin-model-catalog-auth.test.ts",
|
|
"src/agents/plugin-model-catalog.test.ts",
|
|
"src/agents/prepared-model-catalog-worker.agent-database.integration.test.ts",
|
|
"src/agents/prepared-model-catalog-worker.heap.integration.test.ts",
|
|
"src/agents/prepared-model-catalog-worker.workspace-heap.integration.test.ts",
|
|
"src/state/openclaw-state-db.test.ts",
|
|
"src/worker/worker.runtime.test.ts",
|
|
])("routes native shared-state consumer %s exactly once to its broker owner", (testFile) => {
|
|
expectSingleVitestRunPlan(buildVitestRunPlans([testFile]), {
|
|
config: "test/vitest/vitest.infra.config.ts",
|
|
includePatterns: [testFile],
|
|
});
|
|
expect(databaseWorkerCoreTestFiles.filter((file) => file === testFile)).toEqual([testFile]);
|
|
});
|
|
|
|
it.each([
|
|
["src/agents/**/*.test.ts", "test/vitest/vitest.agents.config.ts"],
|
|
["test/plugins", "test/vitest/vitest.tooling.config.ts"],
|
|
["test/scripts", "test/vitest/vitest.tooling.config.ts"],
|
|
["src/plugin-state", "test/vitest/vitest.unit.config.ts"],
|
|
])("preserves watch selection across database ownership for %s", (target, owner) => {
|
|
expect(buildVitestRunPlans(["--watch", target])).toEqual([
|
|
{
|
|
config: "test/vitest/vitest.database-worker-watch.config.ts",
|
|
databaseWorkerWatchOwner: owner,
|
|
databaseWorkerWatchTests: databaseWorkerCoreTestFiles.filter((file) =>
|
|
file.startsWith(`${target.split("/**")[0]}/`),
|
|
),
|
|
forwardedArgs: [],
|
|
includePatterns: [target.endsWith(".test.ts") ? target : `${target}/**/*.test.ts`],
|
|
watchMode: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["src/agents/**/*.test.ts", "src/agents/**/memory-*.test.ts", "src/agents/**/memory-*.test.ts"],
|
|
[
|
|
"extensions/matrix",
|
|
"extensions/matrix/**/storage.test.ts",
|
|
"extensions/matrix/**/storage.test.ts",
|
|
],
|
|
[
|
|
"extensions/matrix/**/storage.test.ts",
|
|
"extensions/matrix/**/*.test.ts",
|
|
"extensions/matrix/**/storage.test.ts",
|
|
],
|
|
[
|
|
"extensions/matrix/**/storage.test.ts",
|
|
"extensions/matrix/**/storage.test.ts",
|
|
"extensions/matrix/**/storage.test.ts",
|
|
],
|
|
])("retains watch intersection for %s and %s", (directory, pattern, expected) => {
|
|
withTinyFileTree({}, (tempDir) => {
|
|
const includeFile = path.join(tempDir, "include.json");
|
|
fs.writeFileSync(includeFile, JSON.stringify([pattern]));
|
|
const specs = createVitestRunSpecs(["--watch", directory], {
|
|
baseEnv: { OPENCLAW_VITEST_INCLUDE_FILE: includeFile },
|
|
});
|
|
expect(specs).toHaveLength(1);
|
|
expect(specs[0]?.includePatterns).toContain(expected);
|
|
expect(
|
|
specs[0]?.includePatterns?.every(
|
|
(value) => value === expected || path.matchesGlob(value, expected),
|
|
),
|
|
).toBe(true);
|
|
expect(specs[0]?.watchMode).toBe(true);
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["src/agents/**/*.test.ts", "test/plugins"],
|
|
["src/plugin-sdk/memory-host-events.test.ts", "src/plugin-sdk/provider-auth.test.ts"],
|
|
["src/plugin-sdk/outbound-media.bulk.test.ts", "src/plugin-sdk/provider-auth.test.ts"],
|
|
])("retains the mixed watch rejection for %s and %s", (...targets) => {
|
|
expect(() => buildVitestRunPlans(["--watch", ...targets])).toThrow(
|
|
"watch mode with mixed test suites is not supported",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"test/vitest/vitest.plugins.config.ts",
|
|
"src/plugins/doctor-contract-registry.load-paths.test.ts",
|
|
],
|
|
["test/vitest/vitest.plugin-sdk.config.ts", "src/plugin-sdk/provider-auth.test.ts"],
|
|
[
|
|
"test/vitest/vitest.unit-fast.config.ts",
|
|
"src/agents/embedded-agent-runner/run/model-setup.selected-model.test.ts",
|
|
],
|
|
[
|
|
"test/vitest/vitest.unit-fast.config.ts",
|
|
"test/e2e/qa-lab/runtime/gateway-loopback-lan-access.test.ts",
|
|
],
|
|
[
|
|
"test/vitest/vitest.unit-fast-isolated.config.ts",
|
|
"src/state/openclaw-agent-execution-cleanup.test.ts",
|
|
],
|
|
])("preserves whole-owner watch coverage for %s with %s", (config, file) => {
|
|
const [plan] = buildVitestRunPlans(["--watch", config, file]);
|
|
expect(plan).toMatchObject({
|
|
config: "test/vitest/vitest.database-worker-watch.config.ts",
|
|
databaseWorkerWatchOwner: config,
|
|
includePatterns: null,
|
|
watchMode: true,
|
|
});
|
|
expect(plan?.databaseWorkerWatchTests).toContain(file);
|
|
if (config.endsWith("vitest.plugin-sdk.config.ts")) {
|
|
expect(plan?.databaseWorkerWatchTests).toContain("src/plugin-sdk/memory-host-core.test.ts");
|
|
expect(plan?.databaseWorkerWatchTests).not.toContain(
|
|
"src/plugin-sdk/memory-host-events.test.ts",
|
|
);
|
|
expect(plan?.databaseWorkerWatchTests).not.toContain(
|
|
"src/plugin-sdk/outbound-media.bulk.test.ts",
|
|
);
|
|
}
|
|
const [spec] = createVitestRunSpecs(["--watch", config, file], { baseEnv: {} });
|
|
expect(spec?.includeFilePath).toBeNull();
|
|
expect(spec?.env.OPENCLAW_VITEST_DATABASE_WORKER_WATCH_OWNER).toBe(config);
|
|
expect(JSON.parse(spec?.env.OPENCLAW_VITEST_DATABASE_WORKER_WATCH_TESTS ?? "null")).toEqual(
|
|
plan?.databaseWorkerWatchTests,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"src/plugin-state",
|
|
"src/plugin-sdk",
|
|
"src/agents",
|
|
"src/commands",
|
|
"src/config",
|
|
"test/plugins",
|
|
])("retains database worker ownership for directory and glob target %s", (directory) => {
|
|
const expected = databaseWorkerCoreTestFiles.filter((file) => file.startsWith(`${directory}/`));
|
|
for (const target of [directory, `${directory}/**/*.test.ts`]) {
|
|
const plans = buildVitestRunPlans([target]);
|
|
const infra = plans.find((plan) => plan.config === "test/vitest/vitest.infra.config.ts");
|
|
expect(infra?.includePatterns?.toSorted()).toEqual(expected.toSorted());
|
|
}
|
|
});
|
|
|
|
it("routes isolated agent test src/agents/cli-runner/bundle-mcp.user-config.test.ts to the isolated agents-core shard", () => {
|
|
const testFile = "src/agents/cli-runner/bundle-mcp.user-config.test.ts";
|
|
expectSingleVitestRunPlan(buildVitestRunPlans([testFile]), {
|
|
config: "test/vitest/vitest.agents-core-isolated.config.ts",
|
|
includePatterns: [testFile],
|
|
});
|
|
});
|
|
|
|
it("routes production-boundary agent test src/agents/subagents/spawn/subagent-spawn.production-boundary.test.ts to its dedicated shard", () => {
|
|
const testFile = "src/agents/subagents/spawn/subagent-spawn.production-boundary.test.ts";
|
|
expectSingleVitestRunPlan(buildVitestRunPlans([testFile]), {
|
|
config: "test/vitest/vitest.agents-spawn-production-boundary.config.ts",
|
|
includePatterns: [testFile],
|
|
});
|
|
});
|
|
|
|
describe("agent directory and glob inventory", () => {
|
|
const inventories = new Map<string, string[]>();
|
|
const toolRoot = "src/agents/tools";
|
|
const failoverRoot = "src/agents/failover";
|
|
const embeddedRoot = "src/agents/embedded-agent-runner";
|
|
const runtimeRoot = "src/agents/runtime-plan";
|
|
const ownerExamples = [
|
|
["src/agents/agent-command-local.test.ts", "cli-process"],
|
|
[`${toolRoot}/chat-history-text.test.ts`, "unit-fast"],
|
|
[`${toolRoot}/computer-tool.schema.test.ts`, "unit-fast-isolated"],
|
|
[`${toolRoot}/gateway.hosted-routing.test.ts`, "infra"],
|
|
[`${failoverRoot}/classify.legacy-provider-predicates.test.ts`, "agents-core-isolated"],
|
|
[`${failoverRoot}/failover-classification.corpus.test.ts`, "agents-core-isolated"],
|
|
[`${failoverRoot}/provider-structured-signals.test.ts`, "agents-core-isolated"],
|
|
[`${runtimeRoot}/materialize-model.test.ts`, "agents-support"],
|
|
[`${embeddedRoot}/run.inherited-auth-owner.test.ts`, "agents-embedded-agent"],
|
|
[
|
|
`${embeddedRoot}/run.incomplete-turn.classification.test.ts`,
|
|
"agents-embedded-agent-incomplete-turn",
|
|
],
|
|
[
|
|
`${embeddedRoot}/run.overflow-compaction.test.ts`,
|
|
"agents-embedded-agent-overflow-compaction",
|
|
],
|
|
[`${embeddedRoot}/run/attempt.abort-race.test.ts`, "infra"],
|
|
[
|
|
`${embeddedRoot}/run/attempt-transcript-helpers.presence.test.ts`,
|
|
"agents-embedded-agent-run",
|
|
],
|
|
[`${embeddedRoot}/run/attempt-system-prompt.test.ts`, "infra"],
|
|
] as const;
|
|
|
|
it.each([
|
|
{
|
|
label: "agent root directory",
|
|
targets: ["src/agents"],
|
|
patterns: ["src/agents/**/*.test.ts"],
|
|
},
|
|
{
|
|
label: "agent root glob",
|
|
targets: ["src/agents/**/*.test.ts"],
|
|
patterns: ["src/agents/**/*.test.ts"],
|
|
},
|
|
{ label: "tools directory", targets: [toolRoot], patterns: [`${toolRoot}/**/*.test.ts`] },
|
|
{
|
|
label: "tools glob",
|
|
targets: [`${toolRoot}/**/*.test.ts`],
|
|
patterns: [`${toolRoot}/**/*.test.ts`],
|
|
},
|
|
{
|
|
label: "failover trailing slash",
|
|
targets: [`./${failoverRoot}/`],
|
|
patterns: [`${failoverRoot}/**/*.test.ts`],
|
|
},
|
|
{
|
|
label: "failover glob",
|
|
targets: [`${failoverRoot}/**/*.test.ts`],
|
|
patterns: [`${failoverRoot}/**/*.test.ts`],
|
|
},
|
|
{
|
|
label: "runtime directory",
|
|
targets: [runtimeRoot],
|
|
patterns: [`${runtimeRoot}/**/*.test.ts`],
|
|
},
|
|
{
|
|
label: "embedded directory",
|
|
targets: [embeddedRoot],
|
|
patterns: [`${embeddedRoot}/**/*.test.ts`],
|
|
},
|
|
{
|
|
label: "embedded run glob",
|
|
targets: [`${embeddedRoot}/run/*.test.ts`],
|
|
patterns: [`${embeddedRoot}/run/*.test.ts`],
|
|
},
|
|
{
|
|
label: "mixed overlapping directories and globs",
|
|
targets: [
|
|
path.resolve(toolRoot),
|
|
`src/agents/{tools,failover}/**/*.test.ts`,
|
|
`${toolRoot}/chat-history-text.test.ts`,
|
|
],
|
|
patterns: [`${toolRoot}/**/*.test.ts`, `${failoverRoot}/**/*.test.ts`],
|
|
},
|
|
])("selects every ordinary test exactly once for $label", async ({ targets, patterns }) => {
|
|
const expected = [...new Set(fs.globSync(patterns).map(normalizeRepoPath))]
|
|
.filter((file) => !isSharedVitestExcludedPath(file))
|
|
.toSorted();
|
|
expect(expected.length).toBeGreaterThan(0);
|
|
const controls = ["--sequence.shuffle", "--sequence.seed", "3"];
|
|
const plans = buildVitestRunPlans([...targets, "--", ...controls]);
|
|
const selected: Array<{ file: string; config: string }> = [];
|
|
for (const plan of plans) {
|
|
// Config loading temporarily owns process.argv and the include-file environment.
|
|
const ownerFiles =
|
|
inventories.get(plan.config) ?? (await listVitestConfigTestFiles(plan.config));
|
|
inventories.set(plan.config, ownerFiles);
|
|
// Repeating include expansion for every file makes broad inventory checks quadratic.
|
|
const includedFiles =
|
|
plan.includePatterns === null
|
|
? null
|
|
: new Set(
|
|
fs
|
|
.globSync(plan.includePatterns)
|
|
.map((file) =>
|
|
normalizeRepoPath(path.relative(process.cwd(), path.resolve(file))),
|
|
),
|
|
);
|
|
selected.push(
|
|
...ownerFiles
|
|
.filter(
|
|
(file) =>
|
|
(!includedFiles || includedFiles.has(file)) &&
|
|
matchesVitestCliSelection(
|
|
file,
|
|
[file],
|
|
["run", ...plan.forwardedArgs],
|
|
"",
|
|
{},
|
|
includedFiles ? [file] : null,
|
|
),
|
|
)
|
|
.map((file) => ({ file, config: plan.config })),
|
|
);
|
|
expect(plan.watchMode).toBe(false);
|
|
expect(plan.forwardedArgs.slice(0, controls.length)).toEqual(controls);
|
|
}
|
|
expect(selected.map(({ file }) => file).toSorted()).toEqual(expected);
|
|
for (const [file, owner] of ownerExamples) {
|
|
if (expected.includes(file)) {
|
|
expect(
|
|
selected.filter((entry) => entry.file === file).map(({ config }) => config),
|
|
).toEqual([`test/vitest/vitest.${owner}.config.ts`]);
|
|
}
|
|
}
|
|
});
|
|
|
|
it.each([toolRoot, `${toolRoot}/**/*.test.ts`])(
|
|
"intersects inherited includes for %s without dropping an explicit file",
|
|
(target) => {
|
|
const selected = `${toolRoot}/computer-tool.schema.test.ts`;
|
|
const explicit = `${failoverRoot}/classify.legacy-provider-predicates.test.ts`;
|
|
withTinyFileTree({ "include.json": JSON.stringify([selected]) }, (cwd) => {
|
|
const specs = createVitestRunSpecs([target, explicit], {
|
|
baseEnv: { OPENCLAW_VITEST_INCLUDE_FILE: path.join(cwd, "include.json") },
|
|
});
|
|
expect(specs.flatMap((spec) => spec.includePatterns ?? []).toSorted()).toEqual(
|
|
[selected, explicit].toSorted(),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([toolRoot, `${toolRoot}/**/*.test.ts`])(
|
|
"keeps live and E2E opt-in outside ordinary selection for %s",
|
|
(target) => {
|
|
const ordinary = `${toolRoot}/example.test.ts`;
|
|
const live = `${toolRoot}/example.live.test.ts`;
|
|
const e2e = `${toolRoot}/example.e2e.test.ts`;
|
|
withTinyFileTree(
|
|
Object.fromEntries([ordinary, live, e2e].map((file) => [file, ""])),
|
|
(cwd) => {
|
|
const plans = buildVitestRunPlans([target], cwd);
|
|
expect(plans.flatMap((plan) => plan.includePatterns ?? [])).toEqual([ordinary]);
|
|
const explicitPlans = buildVitestRunPlans([target, live, e2e], cwd);
|
|
expect(
|
|
explicitPlans.find((plan) => plan.config === "test/vitest/vitest.e2e.config.ts")
|
|
?.forwardedArgs,
|
|
).toEqual([e2e]);
|
|
expect(explicitPlans.flatMap((plan) => plan.includePatterns ?? [])).toContain(live);
|
|
expectSingleVitestRunPlan(buildVitestRunPlans([`${toolRoot}/*.e2e.test.ts`], cwd), {
|
|
config: "test/vitest/vitest.e2e.config.ts",
|
|
forwardedArgs: [`${toolRoot}/*.e2e.test.ts`],
|
|
});
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it.each(["scripts/docker/setup.sh", "scripts/lib/build-metadata.sh"])(
|
|
"routes stubbed Docker setup checks to tooling for %s",
|
|
(target) => {
|
|
const plan = buildVitestRunPlans([target]).find((candidate) =>
|
|
candidate.includePatterns?.includes("test/scripts/docker-setup.test.ts"),
|
|
);
|
|
expect(plan).toMatchObject({ config: "test/vitest/vitest.tooling.config.ts" });
|
|
},
|
|
);
|
|
|
|
it("routes Docker E2E script targets to their owner tooling tests", () => {
|
|
const targets = ["scripts/e2e/kitchen-sink-plugin-docker.sh"];
|
|
expect(findUnmatchedExplicitTestTargets(targets)).toEqual([]);
|
|
expect(buildVitestRunPlans(targets)).toEqual([
|
|
runPlan("tooling-docker", ["test/scripts/docker-build-helper.test.ts"]),
|
|
runPlan("tooling", ["test/scripts/plugin-prerelease-test-plan.test.ts"]),
|
|
]);
|
|
});
|
|
|
|
it("routes changed Parallels process helpers to their owner tooling tests", () => {
|
|
expectSingleVitestRunPlan(
|
|
buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
|
"scripts/e2e/parallels/host-command.ts",
|
|
]),
|
|
{
|
|
config: "test/vitest/vitest.tooling.config.ts",
|
|
includePatterns: [
|
|
"test/scripts/parallels-smoke-model.test.ts",
|
|
"test/scripts/parallels-npm-update-smoke.test.ts",
|
|
],
|
|
},
|
|
);
|
|
});
|
|
|
|
it("routes mac restart helpers through restart-mac owner tests", () => {
|
|
expectChangedTargets(
|
|
["scripts/lib/restart-mac-gateway.sh"],
|
|
["test/scripts/build-and-run-mac.test.ts", "test/scripts/restart-mac.test.ts"],
|
|
);
|
|
});
|
|
|
|
it("routes MCP and cron Docker E2E script targets instead of skipping changed tests", () => {
|
|
const targets = [
|
|
"scripts/e2e/mcp-channels-docker.sh",
|
|
"test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts",
|
|
"test/e2e/qa-lab/runtime/mcp-channels.fixture.ts",
|
|
"test/e2e/qa-lab/runtime/mcp-client-temp-state.fixture.ts",
|
|
"scripts/e2e/mcp-code-mode-gateway-docker.sh",
|
|
"scripts/e2e/mcp-code-mode-gateway-live-docker.sh",
|
|
"scripts/e2e/agent-bundle-mcp-tools-docker.sh",
|
|
"test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts",
|
|
"scripts/mcp-code-mode-gateway-e2e.ts",
|
|
"scripts/e2e/cron-cli-docker.sh",
|
|
"scripts/e2e/cron-mcp-cleanup-docker.sh",
|
|
"scripts/e2e/cron-mcp-cleanup-docker-client.ts",
|
|
];
|
|
|
|
expect(findUnmatchedExplicitTestTargets(targets)).toEqual([]);
|
|
expectChangedTargets(targets, [
|
|
"test/scripts/docker-build-helper.test.ts",
|
|
"test/scripts/docker-e2e-observability.test.ts",
|
|
"test/scripts/docker-e2e-plan.test.ts",
|
|
"test/scripts/plugin-prerelease-test-plan.test.ts",
|
|
"test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts",
|
|
"test/scripts/cron-mcp-cleanup-docker-client.test.ts",
|
|
"test/scripts/mcp-code-mode-gateway-client.test.ts",
|
|
"test/scripts/session-log-mentions.test.ts",
|
|
"src/agents/agent-bundle-mcp-runtime.test.ts",
|
|
"src/agents/agent-bundle-mcp-tools.materialize.test.ts",
|
|
"src/gateway/server.cron.test.ts",
|
|
"src/gateway/server-methods/agent.test.ts",
|
|
"src/cron/isolated-agent/run.fast-mode.test.ts",
|
|
"src/cron/active-jobs-manual-run.test.ts",
|
|
]);
|
|
});
|
|
|
|
it("routes OpenAI image auth Docker E2E script targets instead of skipping changed tests", () => {
|
|
const targets = [
|
|
"scripts/e2e/openai-image-auth-docker.sh",
|
|
"test/e2e/qa-lab/runtime/openai-image-auth-docker-client.ts",
|
|
];
|
|
|
|
expect(findUnmatchedExplicitTestTargets(targets)).toEqual([]);
|
|
expectChangedTargets(targets, [
|
|
"test/scripts/docker-build-helper.test.ts",
|
|
"test/scripts/docker-e2e-plan.test.ts",
|
|
"test/scripts/openai-image-auth-docker-client.test.ts",
|
|
"extensions/openai/image-generation-provider.test.ts",
|
|
"src/image-generation/openai-compatible-image-provider.test.ts",
|
|
]);
|
|
});
|
|
|
|
it("routes package-backed Docker shell targets instead of skipping changed tests", () => {
|
|
const targets = [
|
|
"scripts/e2e/codex-media-path-docker.sh",
|
|
"scripts/e2e/codex-npm-plugin-live-docker.sh",
|
|
"scripts/e2e/codex-on-demand-docker.sh",
|
|
"scripts/e2e/live-plugin-tool-docker.sh",
|
|
"scripts/e2e/plugin-binding-command-escape-docker.sh",
|
|
"scripts/e2e/qr-import-docker.sh",
|
|
];
|
|
|
|
expect(findUnmatchedExplicitTestTargets(targets)).toEqual([]);
|
|
expectChangedTargets(targets, [
|
|
"test/scripts/docker-build-helper.test.ts",
|
|
"test/scripts/docker-e2e-plan.test.ts",
|
|
"test/scripts/codex-media-path-client.test.ts",
|
|
"test/scripts/package-acceptance-workflow.test.ts",
|
|
"test/scripts/live-plugin-tool-assertions.test.ts",
|
|
"test/scripts/plugin-binding-command-escape-docker.test.ts",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["chunks the broad shell helper tooling shard after isolated targets", "test/scripts"],
|
|
["chunks broad shell helper globs after isolated targets", "test/scripts/*.test.ts"],
|
|
])("%s", (_title, target) => {
|
|
const plans = buildVitestRunPlans([target, "--reporter=dot"], process.cwd());
|
|
const pattern = target === "test/scripts" ? "test/scripts/**/*.test.ts" : target;
|
|
const expected = fs.globSync(pattern).map(normalizeRepoPath).toSorted();
|
|
const selected = plans.flatMap(
|
|
(plan) =>
|
|
plan.includePatterns ?? plan.forwardedArgs.filter((arg) => arg.endsWith(".test.ts")),
|
|
);
|
|
expect(selected.toSorted()).toEqual(expected);
|
|
expect(new Set(selected).size).toBe(selected.length);
|
|
|
|
const requiredOwners: Record<string, string> = {
|
|
"test/scripts/ci-git-prerequisites.test.ts":
|
|
"test/vitest/vitest.unit-fast-isolated.config.ts",
|
|
"test/scripts/pr-ci-sweeper.reopen-timer.test.ts":
|
|
"test/vitest/vitest.unit-fast-fake-timers.config.ts",
|
|
"test/scripts/docker-build-helper.test.ts": "test/vitest/vitest.tooling-docker.config.ts",
|
|
"test/scripts/openclaw-e2e-instance.test.ts": "test/vitest/vitest.tooling-isolated.config.ts",
|
|
};
|
|
for (const plan of plans) {
|
|
const files =
|
|
plan.includePatterns ?? plan.forwardedArgs.filter((arg) => arg.endsWith(".test.ts"));
|
|
expect(plan.watchMode).toBe(false);
|
|
expect(plan.forwardedArgs).toContain("--reporter=dot");
|
|
if (plan.config === "test/vitest/vitest.tooling.config.ts") {
|
|
expect(files.length).toBeLessThanOrEqual(60);
|
|
}
|
|
for (const file of files) {
|
|
const owner = file.endsWith(".e2e.test.ts")
|
|
? "test/vitest/vitest.e2e.config.ts"
|
|
: requiredOwners[file];
|
|
if (owner) {
|
|
expect(plan.config, file).toBe(owner);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("routes the agent command child through its isolated CLI project", () => {
|
|
const file = "src/agents/agent-command-local.test.ts";
|
|
expect(buildVitestRunPlans([file])).toEqual([runPlan("cli-process", [file])]);
|
|
});
|
|
|
|
it("adds the CLI process project for broad CLI targets", () => {
|
|
const plans = buildVitestRunPlans(["src/cli"]);
|
|
|
|
expect(plans.map((plan) => plan.config)).toEqual(
|
|
expect.arrayContaining([
|
|
"test/vitest/vitest.unit-fast.config.ts",
|
|
"test/vitest/vitest.cli-process.config.ts",
|
|
"test/vitest/vitest.cli.config.ts",
|
|
"test/vitest/vitest.tooling-isolated.config.ts",
|
|
]),
|
|
);
|
|
const processPlan = plans.find(
|
|
(plan) => plan.config === "test/vitest/vitest.cli-process.config.ts",
|
|
);
|
|
expect(processPlan?.includePatterns).toContain("src/cli/help-exit.process.test.ts");
|
|
expect(processPlan?.includePatterns).toContain("src/cli/update-dry-run-state.process.test.ts");
|
|
expect(
|
|
plans.find((plan) => plan.config === "test/vitest/vitest.tooling-isolated.config.ts")
|
|
?.includePatterns,
|
|
).toEqual(["src/cli/update-cli/update-command-legacy-finalize.test.ts"]);
|
|
});
|
|
|
|
it.each([
|
|
{ directory: "src/state", file: "src/state/openclaw-database-verify.process.test.ts" },
|
|
{ directory: "src/agents", file: "src/agents/agent-command-local.test.ts" },
|
|
])(
|
|
"deduplicates the process selected by $directory and its exact leaf",
|
|
({ directory, file }) => {
|
|
const plans = buildVitestRunPlans([directory, file]);
|
|
expect(
|
|
plans.filter((plan) => plan.config === "test/vitest/vitest.cli-process.config.ts"),
|
|
).toEqual([runPlan("cli-process", [file])]);
|
|
},
|
|
);
|
|
|
|
it("preserves post-separator Vitest args without parsing them as targets", () => {
|
|
for (const [arg, watchMode] of [
|
|
["--reporter=verbose", false],
|
|
["--watch", true],
|
|
] as const) {
|
|
expectSingleVitestRunPlan(
|
|
buildVitestRunPlans(["test/scripts/run-vitest.test.ts", "--", arg]),
|
|
{
|
|
config: "test/vitest/vitest.tooling.config.ts",
|
|
forwardedArgs: [arg],
|
|
includePatterns: ["test/scripts/run-vitest.test.ts"],
|
|
watchMode,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
it("prints wrapper help for --help without starting a broad local suite", () => {
|
|
const helpFlag = "--help";
|
|
const nodeExecPath = requireNodeTool("node");
|
|
withTinyFileTree({}, (tempDir) => {
|
|
const result = spawnSync(
|
|
nodeExecPath,
|
|
[
|
|
...resolveRuntimeWorkerArgv(
|
|
resolveRuntimeWorkerUrl(scriptModuleEntrypoints.testProjects),
|
|
nodeExecPath,
|
|
),
|
|
helpFlag,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
// Keep the native help probe inside its invocation-owned temporary directory.
|
|
env: { ...process.env, TMPDIR: tempDir, TMP: tempDir, TEMP: tempDir },
|
|
timeout: 5_000,
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("Usage: node --import tsx scripts/test-projects.mts");
|
|
expect(result.stderr).not.toContain("[test] starting");
|
|
});
|
|
});
|
|
|
|
it("fans contract directory targets out to the owning contract lanes", () => {
|
|
// Regression: the generic channels project excludes contracts/**, so the
|
|
// directory target used to run zero tests and exit green.
|
|
const plans = buildVitestRunPlans(["src/channels/plugins/contracts"]);
|
|
|
|
expect(plans.map((plan) => plan.config)).toEqual([
|
|
"test/vitest/vitest.contracts-channel-surface.config.ts",
|
|
"test/vitest/vitest.contracts-channel-config.config.ts",
|
|
"test/vitest/vitest.contracts-channel-registry.config.ts",
|
|
"test/vitest/vitest.contracts-channel-session.config.ts",
|
|
]);
|
|
expect(plans.every((plan) => plan.includePatterns === null)).toBe(true);
|
|
});
|
|
|
|
it("routes the plugin contracts directory to the plugin contracts lane", () => {
|
|
const plans = buildVitestRunPlans(["src/plugins/contracts"]);
|
|
|
|
expectSingleVitestRunPlan(plans, {
|
|
config: "test/vitest/vitest.contracts-plugin.config.ts",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["extensions/imessage/message-tool-api.ts", "extensions/imessage/src/message-tool-api.test.ts"],
|
|
["extensions/slack/message-tool-api.ts", "extensions/slack/message-tool-api.ts"],
|
|
[
|
|
"extensions/slack/src/channel-actions.ts",
|
|
"extensions/slack/src/channel-actions-setup-status.contract.test.ts",
|
|
],
|
|
["extensions/telegram/src/channel.ts", "test/telegram-question-gateway.test.ts"],
|
|
["extensions/matrix/src/channel.ts", "extensions/matrix/src/channel.threading.test.ts"],
|
|
] as const)(
|
|
"routes %s through its owner and the plugin-shape parity contract",
|
|
(changedPath, ownerTarget) => {
|
|
const plan = resolveChangedTestTargetPlan([changedPath]);
|
|
|
|
expect(plan.mode).toBe("targets");
|
|
expect(plan.targets).toContain(ownerTarget);
|
|
expect(plan.targets).toContain(
|
|
"src/channels/plugins/contracts/plugin-shape.contract.test.ts",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("fails safe for raw Git paths that explicit-path normalization would rewrite", () => {
|
|
for (const changedPath of [
|
|
" scripts/changed-lanes.mts",
|
|
String.raw`scripts\changed-lanes.mts`,
|
|
]) {
|
|
expect(
|
|
resolveChangedTestTargetPlanForArgs(
|
|
["--changed", "origin/main"],
|
|
process.cwd(),
|
|
() => [changedPath],
|
|
{ broad: true },
|
|
),
|
|
changedPath,
|
|
).toEqual({ mode: "broad", targets: [] });
|
|
}
|
|
});
|
|
|
|
it("keeps unknown root surface skip reasons available to changed-mode callers", () => {
|
|
expect(
|
|
resolveChangedTestTargetPlanForArgs(["--changed", "origin/main"], process.cwd(), () => [
|
|
"unknown/file.txt",
|
|
]),
|
|
).toEqual({
|
|
mode: "targets",
|
|
skippedBroadFallbackPaths: ["unknown/file.txt"],
|
|
targets: [],
|
|
});
|
|
});
|
|
|
|
it("keeps the broad changed run available for unknown root surfaces", () => {
|
|
expect(
|
|
resolveChangedTargetArgs(
|
|
["--changed", "origin/main"],
|
|
process.cwd(),
|
|
() => ["unknown/file.txt"],
|
|
{ env: { OPENCLAW_TEST_CHANGED_BROAD: "1" } },
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("skips app-only changes because app tests are separate from Vitest lanes", () => {
|
|
expect(
|
|
buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
|
"apps/macos/OpenClaw/AppDelegate.swift",
|
|
]),
|
|
).toStrictEqual([]);
|
|
});
|
|
|
|
it("keeps the top-level extensions watch target on its aggregate owner", () => {
|
|
expectSingleVitestRunPlan(buildVitestRunPlans(["--watch", "extensions"]), {
|
|
config: "test/vitest/vitest.full-extensions.config.ts",
|
|
watchMode: true,
|
|
});
|
|
});
|
|
|
|
it("bounds an explicit Telegram config target across process lifetimes", () => {
|
|
const config = "test/vitest/vitest.extension-telegram.config.ts";
|
|
const plans = buildVitestRunPlans([config], process.cwd());
|
|
|
|
expect(plans).toHaveLength(expectedTelegramTestProcessCount());
|
|
expect(plans.every((plan) => plan.config === config)).toBe(true);
|
|
expect(
|
|
plans.every(
|
|
(plan) => (plan.includePatterns?.length ?? 0) <= TELEGRAM_TEST_PROCESS_FILE_LIMIT,
|
|
),
|
|
).toBe(true);
|
|
expect(plans.flatMap((plan) => plan.includePatterns ?? [])).toEqual(
|
|
listOrdinaryExtensionFiles("extensions/telegram"),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
channel: "Telegram",
|
|
config: "test/vitest/vitest.extension-telegram.config.ts",
|
|
},
|
|
{ channel: "Matrix", config: "test/vitest/vitest.extension-matrix.config.ts" },
|
|
])("preserves an externally scoped $channel config target", ({ config }) => {
|
|
expectSingleVitestRunPlan(
|
|
buildVitestRunPlans([config], process.cwd(), () => [], {
|
|
env: { OPENCLAW_VITEST_INCLUDE_FILE: "ci-shard.json" },
|
|
}),
|
|
{
|
|
config,
|
|
},
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
channel: "Telegram",
|
|
config: "test/vitest/vitest.extension-telegram.config.ts",
|
|
directory: "extensions/telegram",
|
|
},
|
|
{
|
|
channel: "Matrix",
|
|
config: "test/vitest/vitest.extension-matrix.config.ts",
|
|
directory: "extensions/matrix",
|
|
},
|
|
])("preserves an externally scoped $channel directory run spec", ({ config, directory }) => {
|
|
withTinyFileTree({}, (tempDir) => {
|
|
const includeFile = path.join(tempDir, "ci-shard.json");
|
|
fs.writeFileSync(includeFile, JSON.stringify([`${directory}/src/example.test.ts`]));
|
|
const [spec] = createVitestRunSpecs([directory], {
|
|
baseEnv: { OPENCLAW_VITEST_INCLUDE_FILE: includeFile },
|
|
});
|
|
|
|
expect(spec).toMatchObject({
|
|
config,
|
|
env: { OPENCLAW_VITEST_INCLUDE_FILE: includeFile },
|
|
includeFilePath: null,
|
|
includePatterns: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
directory: "extensions/matrix/src/matrix/client",
|
|
selected: ["extensions/matrix/src/matrix/client/storage.test.ts"],
|
|
inherited: [
|
|
"extensions/matrix/src/matrix/client/storage.test.ts",
|
|
"extensions/matrix/src/matrix/thread-bindings.test.ts",
|
|
],
|
|
},
|
|
{
|
|
directory: "extensions/matrix/src/matrix/sdk",
|
|
selected: [
|
|
"extensions/matrix/src/matrix/sdk/idb-persistence.test.ts",
|
|
"extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts",
|
|
],
|
|
inherited: ["extensions/matrix/**/*.test.ts"],
|
|
},
|
|
])(
|
|
"intersects $directory and inherited worker selections before emitting include files",
|
|
({ directory, selected, inherited }) => {
|
|
withTinyFileTree({}, (tempDir) => {
|
|
const includeFile = path.join(tempDir, "include.json");
|
|
fs.writeFileSync(includeFile, JSON.stringify(inherited));
|
|
const specs = createVitestRunSpecs([directory], {
|
|
baseEnv: { OPENCLAW_VITEST_INCLUDE_FILE: includeFile },
|
|
});
|
|
const worker = specs.find(
|
|
(spec) => spec.config === "test/vitest/vitest.extension-database-workers.config.ts",
|
|
);
|
|
expect(worker?.includePatterns).toEqual(selected);
|
|
expect(specs.flatMap((spec) => spec.includePatterns ?? [])).not.toContain(
|
|
"extensions/matrix/src/matrix/thread-bindings.test.ts",
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps grouped Matrix targets covered when bounding the directory", () => {
|
|
const testFile = listExtensionTestFilesForRoots(["extensions/matrix"])[0];
|
|
if (!testFile) {
|
|
throw new Error("expected a Matrix test fixture");
|
|
}
|
|
|
|
const plans = buildVitestRunPlans(["extensions/matrix", testFile], process.cwd());
|
|
|
|
expect(plans.length).toBeGreaterThan(1);
|
|
expect(
|
|
plans.every((plan) => (plan.includePatterns?.length ?? 0) <= MATRIX_TEST_PROCESS_FILE_LIMIT),
|
|
).toBe(true);
|
|
expect(plans.flatMap((plan) => plan.includePatterns ?? []).toSorted()).toEqual(
|
|
listExtensionTestFilesForRoots(["extensions/matrix"]).toSorted(),
|
|
);
|
|
});
|
|
|
|
it("keeps a grouped Matrix config target unsplit beside its worker tests", () => {
|
|
const plans = buildVitestRunPlans([
|
|
"extensions/matrix",
|
|
"test/vitest/vitest.extension-matrix.config.ts",
|
|
]);
|
|
expect(plans).toEqual([
|
|
{
|
|
config: "test/vitest/vitest.extension-database-workers.config.ts",
|
|
forwardedArgs: [],
|
|
includePatterns: databaseWorkerExtensionTestFiles.filter((file) =>
|
|
file.startsWith("extensions/matrix/"),
|
|
),
|
|
watchMode: false,
|
|
},
|
|
runPlan("extension-matrix"),
|
|
]);
|
|
});
|
|
|
|
it("keeps explicit Matrix files and watch runs unchunked", () => {
|
|
const testFile = listExtensionTestFilesForRoots(["extensions/matrix"])[0];
|
|
expect(testFile).toBeDefined();
|
|
expect(buildVitestRunPlans([testFile!])).toHaveLength(1);
|
|
const plans = buildVitestRunPlans(["--watch", "extensions/matrix"]);
|
|
expect(plans).toEqual([
|
|
{
|
|
config: "test/vitest/vitest.database-worker-watch.config.ts",
|
|
databaseWorkerWatchOwner: "test/vitest/vitest.extension-matrix.config.ts",
|
|
databaseWorkerWatchTests: databaseWorkerExtensionTestFiles.filter((file) =>
|
|
file.startsWith("extensions/matrix/"),
|
|
),
|
|
forwardedArgs: [],
|
|
includePatterns: expect.arrayContaining(["extensions/matrix/**/*.test.ts"]),
|
|
watchMode: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps the opaque retention child owner beside additional fixture consumers", () => {
|
|
const helper = "src/plugins/runtime.retention.test-support.ts";
|
|
const owner = "src/plugins/runtime.retention.test.ts";
|
|
const direct = "src/other/direct.test.ts";
|
|
const indirect = "src/plugins/shared-consumer.test.ts";
|
|
withTinyGitRepo(
|
|
{
|
|
[helper]: "export const fixture = 1;\n",
|
|
[owner]: "export {};\n",
|
|
[direct]: 'import "../plugins/runtime.retention.test-support.js";\n',
|
|
"src/plugins/bridge.ts": 'export * from "./runtime.retention.test-support.js";\n',
|
|
[indirect]: 'import "./bridge.js";\n',
|
|
"src/plugins/unrelated.test.ts": "export {};\n",
|
|
},
|
|
(cwd) => {
|
|
const plan = resolveChangedTestTargetPlan([helper], { cwd, boundedOwners: true });
|
|
expect(plan.ownerTargets).toEqual([owner]);
|
|
expect(plan.targets.toSorted()).toEqual([owner, direct, indirect].toSorted());
|
|
expect(plan.ownerAreas).toEqual(["src/plugins"]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("adds transitive owner tests and keeps direct cross-area readers without broad fallback", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
"package.json": "{}",
|
|
"src/feature/value.ts": "export const value = 1;\n",
|
|
"src/feature/value.test.ts": "export const owner = true;\n",
|
|
"src/feature/consumer.test.ts": 'import "./value.js";\n',
|
|
"src/feature/bridge.ts": 'export * from "./value.js";\n',
|
|
"src/feature/indirect.test.ts": 'import "./bridge.js";\n',
|
|
"src/feature/unrelated.test.ts": "export const unrelated = true;\n",
|
|
"src/other/reader.test.ts": 'import "../feature/value.js";\n',
|
|
"src/other/indirect.test.ts": 'import "../feature/bridge.js";\n',
|
|
},
|
|
(cwd) => {
|
|
expect(
|
|
resolveChangedTestTargetPlan(["package.json", "src/feature/value.ts"], {
|
|
cwd,
|
|
broad: true,
|
|
boundedOwners: true,
|
|
resolveAliases: true,
|
|
runtimeOnly: true,
|
|
combineSiblingWithImportGraph: true,
|
|
}),
|
|
).toEqual({
|
|
mode: "targets",
|
|
ownerTargets: ["src/feature/value.test.ts"],
|
|
ownerAreas: ["scripts", "src/scripts", "test/scripts", "src/feature"],
|
|
targets: [
|
|
"src/feature/value.test.ts",
|
|
"src/feature/consumer.test.ts",
|
|
"src/feature/indirect.test.ts",
|
|
"src/other/reader.test.ts",
|
|
],
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("Kova schema selection", () => {
|
|
const wrapper = "src/config/zod-schema.agent-defaults.ts";
|
|
const base = "src/config/zod-schema.agent-defaults-base.ts";
|
|
const sibling = "src/config/zod-schema.agent-defaults.test.ts";
|
|
const baseConsumer = "src/config/base-consumer.test.ts";
|
|
const wrapperConsumer = "src/config/wrapper-consumer.test.ts";
|
|
const unrelated = "src/config/unrelated.ts";
|
|
const unrelatedTest = "src/config/unrelated.test.ts";
|
|
const kova = "test/scripts/openclaw-performance-workflow.test.ts";
|
|
const changed = ["--changed", "origin/main"];
|
|
const normal = [baseConsumer, sibling, wrapperConsumer];
|
|
const files = {
|
|
[base]: "export const defaults = {};\n",
|
|
[wrapper]: 'export { defaults } from "./zod-schema.agent-defaults-base.js";\n',
|
|
[sibling]: 'import "./zod-schema.agent-defaults.js";\n',
|
|
[baseConsumer]: 'import "./zod-schema.agent-defaults-base.js";\n',
|
|
[wrapperConsumer]: 'import "./zod-schema.agent-defaults.js";\n',
|
|
[unrelated]: "export const unrelated = true;\n",
|
|
[unrelatedTest]: 'import "./unrelated.js";\n',
|
|
[kova]: "export {};\n",
|
|
};
|
|
it.each<[string, string[], string[], string[], boolean, boolean]>([
|
|
["changed wrapper", changed, [wrapper], [sibling, kova], false, false],
|
|
["explicit schemas", [base, wrapper, kova, base], [], [...normal, kova], false, false],
|
|
["CI wrapper", changed, [wrapper], [sibling, wrapperConsumer, kova], false, true],
|
|
["unrelated source", changed, [unrelated], [unrelatedTest], false, false],
|
|
["changed watch", ["--watch", ...changed], [base], normal, true, false],
|
|
["explicit watch", ["--watch", wrapper], [], [sibling], true, false],
|
|
["disabled watch", ["--watch", "false", wrapper], [], [sibling, kova], false, false],
|
|
["equals watch", ["--watch=false", wrapper], [], [sibling, kova], false, false],
|
|
["negated watch", ["--no-watch", wrapper], [], [sibling, kova], false, false],
|
|
])("preserves owner selection for %s", (_name, args, paths, expected, watchMode, ci) => {
|
|
withTinyGitRepo(files, (cwd) => {
|
|
const options = ci
|
|
? { combineSiblingWithImportGraph: true, forceFullImportGraph: true }
|
|
: {};
|
|
const plans = buildVitestRunPlans(args, cwd, () => paths, options);
|
|
expect(plans.flatMap((plan) => plan.includePatterns ?? []).toSorted()).toEqual(
|
|
expected.toSorted(),
|
|
);
|
|
expect(plans.every((plan) => plan.watchMode === watchMode)).toBe(true);
|
|
expect(
|
|
plans.find((plan) => plan.config === "test/vitest/vitest.tooling.config.ts")
|
|
?.includePatterns,
|
|
).toEqual(expected.includes(kova) ? [kova] : undefined);
|
|
if (watchMode) {
|
|
expectSingleVitestRunPlan(plans, {
|
|
config: "test/vitest/vitest.runtime-config.config.ts",
|
|
includePatterns: expect.arrayContaining(expected),
|
|
watchMode: true,
|
|
});
|
|
}
|
|
expect(findUnmatchedExplicitTestTargets(args, cwd)).toEqual([]);
|
|
});
|
|
});
|
|
it("rejects explicitly mixed watch suites", () => {
|
|
withTinyGitRepo(files, (cwd) => {
|
|
expect(() => buildVitestRunPlans(["--watch", wrapper, kova], cwd)).toThrow(
|
|
"watch mode with mixed test suites is not supported",
|
|
);
|
|
});
|
|
});
|
|
it("does not admit unmatched watch sources through Kova", () => {
|
|
withTinyGitRepo({ [base]: "export {};\n", [kova]: "export {};\n" }, (cwd) => {
|
|
expect(findUnmatchedExplicitTestTargets([base], cwd)).toEqual([]);
|
|
expect(findUnmatchedExplicitTestTargets(["--watch", base], cwd)).toEqual([
|
|
expect.objectContaining({ target: base, reason: "target-matched-no-test-files" }),
|
|
]);
|
|
});
|
|
});
|
|
it("keeps skipped import-graph paths visible with mixed broad changes", () => {
|
|
withTinyGitRepo(files, (cwd) => {
|
|
const paths = [base, "unknown/file.txt"];
|
|
expect(resolveChangedTestTargetPlan(paths, { cwd })).toEqual({
|
|
mode: "targets",
|
|
targets: [kova],
|
|
skippedBroadFallbackPaths: paths,
|
|
});
|
|
expect(resolveChangedTestTargetPlan(paths, { cwd, broad: true })).toEqual({
|
|
mode: "broad",
|
|
targets: [],
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
it.each(["changed", "explicit"])(
|
|
"routes %s ui support files to the ui lane without dead include globs",
|
|
(mode) => {
|
|
const targets = ["ui/src/styles/base.css", "ui/src/test-helpers/lit-warnings.setup.ts"];
|
|
const plans = buildVitestRunPlans(
|
|
mode === "changed" ? ["--changed", "origin/main"] : targets,
|
|
process.cwd(),
|
|
() => targets,
|
|
);
|
|
|
|
expect(plans[0]).toEqual(runPlan("ui"));
|
|
expect(plans[1]?.config).toBe("test/vitest/vitest.ui-browser.config.ts");
|
|
expect(plans[1]?.includePatterns).toContain(
|
|
"ui/src/components/markdown-mermaid.runtime.browser.test.ts",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(["relative", "trailing slash", "absolute", "dot segments"])(
|
|
"keeps an existing nested ui directory and explicit e2es scoped (%s)",
|
|
(spelling) => {
|
|
const directory = "ui/src/pages/new-session";
|
|
const isolated = `${directory}/draft-persistence.test.ts`;
|
|
const unitFiles = [
|
|
`${directory}/view.test.ts`,
|
|
`${directory}/nested/child.test.ts`,
|
|
isolated,
|
|
];
|
|
const e2es = [
|
|
"ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts",
|
|
"ui/src/e2e/new-session-page.projects-places.e2e.test.ts",
|
|
"ui/src/e2e/new-session-page.device-dispatch.e2e.test.ts",
|
|
];
|
|
withTinyFileTree(
|
|
Object.fromEntries(
|
|
[
|
|
...unitFiles,
|
|
...e2es,
|
|
"ui/src/pages/other/view.test.ts",
|
|
"ui/src/pages/workboard/view.test.ts",
|
|
"ui/src/components/other.browser.test.ts",
|
|
"ui/src/e2e/other.e2e.test.ts",
|
|
].map((file) => [file, ""]),
|
|
),
|
|
(cwd) => {
|
|
const target =
|
|
spelling === "absolute"
|
|
? path.join(cwd, directory)
|
|
: spelling === "trailing slash"
|
|
? `./${directory}/`
|
|
: spelling === "dot segments"
|
|
? "ui/src/pages/./new-session/../new-session"
|
|
: directory;
|
|
const plans = buildVitestRunPlans([target, ...e2es], cwd);
|
|
expect(plans).toEqual([
|
|
runPlan("ui", [`${directory}/**/*.test.ts`]),
|
|
runPlan("ui-isolated", [isolated]),
|
|
runPlan("ui-e2e", e2es),
|
|
]);
|
|
const includeFile = path.join(cwd, "include.json");
|
|
const filesByPlan = plans.map((plan) => {
|
|
writeVitestIncludeFile(includeFile, plan.includePatterns!, { cwd });
|
|
return JSON.parse(fs.readFileSync(includeFile, "utf8"));
|
|
});
|
|
// Shared UI intersects these files with its ownership exclusions.
|
|
expect(filesByPlan).toEqual([unitFiles.toSorted(), [isolated], e2es]);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
it("adds isolated and Chromium projects before timing budgets for broad ui targets", () => {
|
|
const plans = buildVitestRunPlans(["ui/src"]);
|
|
|
|
expect(plans.map((plan) => plan.config)).toEqual([
|
|
"test/vitest/vitest.ui.config.ts",
|
|
"test/vitest/vitest.ui-isolated.config.ts",
|
|
"test/vitest/vitest.ui-browser.config.ts",
|
|
"test/vitest/vitest.ui-timing.config.ts",
|
|
]);
|
|
expect(plans[1]?.includePatterns).toContain("ui/src/pages/chat/chat-pane.test.ts");
|
|
expect(plans[2]?.includePatterns).toContain(
|
|
"ui/src/components/markdown-mermaid.runtime.browser.test.ts",
|
|
);
|
|
expect(plans[3]?.includePatterns).toEqual(["ui/src/components/markdown.progress.node.test.ts"]);
|
|
});
|
|
|
|
it.each([
|
|
["ui/src/**/*.browser.test.ts"],
|
|
[
|
|
"ui/src/components/*.browser.test.ts",
|
|
"ui/src/pages/chat",
|
|
"ui/src/styles/cursor-policy.browser.test.ts",
|
|
],
|
|
])("retains both browser owners for mixed selectors %j", (...targets) => {
|
|
const plans = buildVitestRunPlans(targets);
|
|
const native = plans.find((plan) => plan.config === "test/vitest/vitest.ui-browser.config.ts");
|
|
const node = plans.find((plan) => plan.config === "test/vitest/vitest.ui.config.ts");
|
|
expect(native?.includePatterns).toContain(
|
|
"ui/src/components/markdown-mermaid.runtime.browser.test.ts",
|
|
);
|
|
expect(
|
|
node?.includePatterns === null ||
|
|
node?.includePatterns?.includes("ui/src/components/form-controls.browser.test.ts"),
|
|
).toBe(true);
|
|
expect(node?.includePatterns ?? []).not.toContain(
|
|
"ui/src/components/markdown-mermaid.runtime.browser.test.ts",
|
|
);
|
|
expect(native?.includePatterns).not.toContain(
|
|
"ui/src/components/form-controls.browser.test.ts",
|
|
);
|
|
if (targets.length === 1) {
|
|
// Browser screenshots live in directories named after their test files.
|
|
const matchingFiles = fs.globSync(targets[0]!).filter((file) => fs.statSync(file).isFile());
|
|
expect(plans.flatMap((plan) => plan.includePatterns ?? []).toSorted()).toEqual(
|
|
matchingFiles.toSorted(),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("rejects UI glob watch before freezing the current matching files", () => {
|
|
expect(() =>
|
|
buildVitestRunPlans(["--watch", "ui/src/components/markdown-*.browser.test.ts"]),
|
|
).toThrow("use a literal test path, directory, or dedicated UI suite");
|
|
});
|
|
|
|
it("keeps scoped include files distinct across invocations and borrowed metadata", () => {
|
|
const borrowed = path.join(os.tmpdir(), "borrowed-vitest-include.json");
|
|
const files = Array.from({ length: 2 }, () => {
|
|
const [spec] = createVitestRunSpecs(["src/plugin-sdk/temp-path.test.ts"], {
|
|
baseEnv: { OPENCLAW_VITEST_INCLUDE_FILE: borrowed },
|
|
});
|
|
assert(spec?.includeFilePath);
|
|
expect(path.dirname(spec.includeFilePath)).toBe(os.tmpdir());
|
|
expect(spec.includeFilePath).not.toBe(borrowed);
|
|
return spec.includeFilePath;
|
|
});
|
|
expect(files[0]).not.toBe(files[1]);
|
|
});
|
|
|
|
it("expands routed glob targets to literal include-file paths", () => {
|
|
withTinyGitRepo(
|
|
{
|
|
"src/gateway/core.test.ts": "",
|
|
"src/gateway/server-methods/ping.test.ts": "",
|
|
"src/gateway/server-startup.test.ts": "",
|
|
},
|
|
(cwd) => {
|
|
const includeFile = path.join(cwd, "include.json");
|
|
writeVitestIncludeFile(
|
|
includeFile,
|
|
[
|
|
"src/gateway/**/*.test.ts",
|
|
"src/gateway/server-*.test.ts",
|
|
"src/gateway/@(core|server-startup).test.ts",
|
|
],
|
|
{ cwd },
|
|
);
|
|
|
|
expect(JSON.parse(fs.readFileSync(includeFile, "utf8"))).toEqual([
|
|
"src/gateway/core.test.ts",
|
|
"src/gateway/server-methods/ping.test.ts",
|
|
"src/gateway/server-startup.test.ts",
|
|
]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("retains routed glob targets in watch-mode include files", () => {
|
|
withTinyFileTree({}, (tempDir) => {
|
|
const includeFile = path.join(tempDir, "include.json");
|
|
writeVitestIncludeFile(includeFile, ["src/gateway/**/*.test.ts"], {
|
|
expandGlobs: false,
|
|
});
|
|
|
|
expect(JSON.parse(fs.readFileSync(includeFile, "utf8"))).toEqual([
|
|
"src/gateway/**/*.test.ts",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("preflights targeted UI E2E specs with Playwright browser assets", () => {
|
|
const [spec] = createVitestRunSpecs(["ui/src/pages/cron/run-transcript.e2e.test.ts"], {
|
|
baseEnv: {},
|
|
});
|
|
|
|
expect(spec?.config).toBe("test/vitest/vitest.ui-e2e.config.ts");
|
|
expect(spec?.preflightPnpmArgs).toEqual([
|
|
"exec",
|
|
"node",
|
|
"--import",
|
|
"tsx",
|
|
"scripts/ensure-playwright-chromium.mts",
|
|
]);
|
|
});
|
|
|
|
it("skips import-graph scans once a diff already needs broad fallback", () => {
|
|
const readFileSync = vi.spyOn(fs, "readFileSync");
|
|
const before = readFileSync.mock.calls.length;
|
|
const plan = resolveChangedTestTargetPlan([
|
|
".crabbox.yaml",
|
|
"scripts/check.mts",
|
|
"src/gateway/server.impl.ts",
|
|
]);
|
|
const repoSourceReads = readFileSync.mock.calls
|
|
.slice(before)
|
|
.filter(([file]) => typeof file === "string" && normalizeRepoPath(file).includes("/src/"));
|
|
readFileSync.mockRestore();
|
|
|
|
expect(plan).toEqual({
|
|
mode: "targets",
|
|
skippedBroadFallbackPaths: ["src/gateway/server.impl.ts"],
|
|
targets: [
|
|
"test/scripts/package-acceptance-workflow.test.ts",
|
|
"test/scripts/check.test.ts",
|
|
"test/scripts/pr-gate-base.test.ts",
|
|
],
|
|
});
|
|
expect(repoSourceReads).toEqual([]);
|
|
});
|
|
|
|
it("routes prompt snapshot generator helper edits to the owner test", () => {
|
|
for (const target of [
|
|
"scripts/generate-prompt-snapshots.ts",
|
|
"scripts/prompt-snapshot-files.ts",
|
|
"scripts/sync-codex-model-prompt-fixture.ts",
|
|
"test/helpers/agents/happy-path-prompt-snapshots.ts",
|
|
"test/fixtures/agents/prompt-snapshots/codex-model-catalog/gpt-5.5.pragmatic.source.json",
|
|
"test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md",
|
|
"test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md.diff",
|
|
]) {
|
|
expectChangedTargets([target], ["test/scripts/prompt-snapshots.test.ts"]);
|
|
}
|
|
});
|
|
|
|
it("routes package fixture assets to their owner test", () => {
|
|
const owner = "packages/ai/src/provider-transport-parity.test.ts";
|
|
const fixturePaths = [
|
|
"packages/ai/test/fixtures/provider-transport-parity/anthropic-success.snap.txt",
|
|
"packages/ai/test/fixtures/provider-transport-parity/anthropic-error.snap.txt",
|
|
];
|
|
for (const fixturePath of fixturePaths) {
|
|
expectChangedTargets([fixturePath], [owner]);
|
|
}
|
|
expectChangedTargets(fixturePaths, [owner]);
|
|
expectSingleVitestRunPlan(
|
|
buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => fixturePaths),
|
|
{
|
|
config: "test/vitest/vitest.unit.config.ts",
|
|
forwardedArgs: [owner],
|
|
includePatterns: [owner],
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("test selector native source facts", () => {
|
|
it("keeps whole-area UI consumers and source readers across graph cache scopes", () => {
|
|
const pluginModule = "extensions/example/browser/view.ts";
|
|
const pluginConsumer = "test/plugin-browser-consumer.test.ts";
|
|
// This import belongs to the virtual repository, not this test's module graph.
|
|
const pluginImport = path.posix
|
|
.relative(path.posix.dirname(pluginConsumer), pluginModule)
|
|
.replace(/\.ts$/u, ".js");
|
|
withTinyGitRepo(
|
|
{
|
|
"src/owner/value.ts": "export const value = 1;\n",
|
|
"ui/src/presenter.ts": 'export { value } from "../../src/owner/value.js";\n',
|
|
"ui/src/catalog.json": '{"label":"Changed dynamically loaded data"}\n',
|
|
"ui/src/catalog-extra.json": '{"label":"Another dynamically loaded catalog"}\n',
|
|
"ui/src/presenter.test.ts": 'import { value } from "./presenter.js"; void value;\n',
|
|
[pluginModule]: "export const view = 1;\n",
|
|
"src/consumer.test.ts": 'import { value } from "../ui/src/presenter.js"; void value;\n',
|
|
"scripts/ui-consumer.mjs": 'export { value } from "../ui/src/presenter.js";\n',
|
|
"test/scripts/ui-consumer.test.ts":
|
|
'import { value } from "../../scripts/ui-consumer.mjs"; void value;\n',
|
|
[pluginConsumer]: `import { view } from ${JSON.stringify(pluginImport)}; void view;\n`,
|
|
"test/scripts/ui-catalog-reader.test.ts":
|
|
'import { readFileSync } from "node:fs"; readFileSync("ui/src/catalog.json", "utf8");\n',
|
|
"test/scripts/ui-extra-reader.test.ts":
|
|
'import { readFileSync } from "node:fs"; readFileSync("ui/src/catalog-extra.json", "utf8");\n',
|
|
"test/scripts/ui-shared-reader.test.ts":
|
|
'import { readFileSync } from "node:fs"; ["ui/src/catalog.json", "ui/src/catalog-extra.json"].map((file) => readFileSync(file, "utf8"));\n',
|
|
"test/ui-consumer.live.test.ts": 'import "../ui/src/presenter.js";\n',
|
|
"src/unrelated.test.ts": "export {};\n",
|
|
},
|
|
(cwd) => {
|
|
const sourcePlan = () =>
|
|
resolveChangedTestTargetPlan(["src/owner/value.ts"], {
|
|
cwd,
|
|
forceFullImportGraph: true,
|
|
});
|
|
const expectedSourcePlan = {
|
|
mode: "targets",
|
|
targets: ["src/consumer.test.ts", "ui/src/presenter.test.ts"],
|
|
};
|
|
expect(sourcePlan()).toEqual(expectedSourcePlan);
|
|
const graphConsumers = [
|
|
"src/consumer.test.ts",
|
|
"test/plugin-browser-consumer.test.ts",
|
|
"test/scripts/ui-consumer.test.ts",
|
|
];
|
|
expect(resolveControlUiTestConsumers(["ui/src/catalog.json"], cwd)).toEqual([
|
|
...graphConsumers,
|
|
"test/scripts/ui-catalog-reader.test.ts",
|
|
"test/scripts/ui-shared-reader.test.ts",
|
|
]);
|
|
expect(
|
|
resolveControlUiTestConsumers(
|
|
["ui/src/catalog.json", "ui/src/catalog-extra.json", "ui/src/catalog.json"],
|
|
cwd,
|
|
),
|
|
).toEqual([
|
|
...graphConsumers,
|
|
"test/scripts/ui-catalog-reader.test.ts",
|
|
"test/scripts/ui-shared-reader.test.ts",
|
|
"test/scripts/ui-extra-reader.test.ts",
|
|
]);
|
|
expect(
|
|
resolveControlUiTestConsumers(["ui/src/catalog-extra.json", "ui/src/catalog.json"], cwd),
|
|
).toEqual([
|
|
...graphConsumers,
|
|
"test/scripts/ui-extra-reader.test.ts",
|
|
"test/scripts/ui-shared-reader.test.ts",
|
|
"test/scripts/ui-catalog-reader.test.ts",
|
|
]);
|
|
expect(sourcePlan()).toEqual(expectedSourcePlan);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("separates executable imports from source fixtures and erased type declarations", () => {
|
|
const regexContexts = {
|
|
arrow: `const pattern = () => /['"]/;`,
|
|
asyncArrow: `const pattern = async () => /['"]/;`,
|
|
typeof: `const pattern = typeof /['"]/;`,
|
|
void: `const pattern = void /['"]/;`,
|
|
delete: `const pattern = delete /['"]/.source;`,
|
|
binary: `const pattern = 1 + /['"]/.source;`,
|
|
comparison: `const pattern = 1 < /['"]/.source;`,
|
|
if: `if (true) /['"]/.test('');`,
|
|
while: `while (false) /['"]/.test('');`,
|
|
ifBlock: `if (true) {} /['"]/.test('');`,
|
|
functionDeclaration: `function example() {} /['"]/.test('');`,
|
|
asyncDeclaration: `async function example() {} /['"]/.test('');`,
|
|
classDeclaration: `class Example {} /['"]/.test('');`,
|
|
classExtends: `class Example extends factory({}) {} /['"]/.test('');`,
|
|
labeledBlock: `label: {} /['"]/.test('');`,
|
|
caseBlock: `switch (1) { case 1: {} /['"]/.test(''); }`,
|
|
asiDeclaration: `const value = 1\nfunction example() {} /['"]/.test('');`,
|
|
};
|
|
const divisionContexts = {
|
|
object: `const value = ({}) / await import('./during.js') / 2;`,
|
|
function: `const value = function() {} / await import('./during.js') / 2;`,
|
|
asyncFunction: `const value = async function() {} / await import('./during.js') / 2;`,
|
|
class: `const value = class {} / await import('./during.js') / 2;`,
|
|
arrowBody: `const value = (() => {}) / await import('./during.js') / 2;`,
|
|
increment: `let value = 1; value++ / await import('./during.js') / 2;`,
|
|
decrement: `let value = 1; value-- / await import('./during.js') / 2;`,
|
|
};
|
|
const syntaxFiles = Object.fromEntries(
|
|
Object.entries({ ...regexContexts, ...divisionContexts }).map(([name, prefix]) => [
|
|
`${name}.ts`,
|
|
`${prefix}\nawait import('./consumer.js');`,
|
|
]),
|
|
);
|
|
syntaxFiles["ambiguous-template.ts"] =
|
|
`${regexContexts.labeledBlock} await import(\`./template-consumer.js\`);`;
|
|
syntaxFiles["generic-callback.ts"] = "export type Callback = <T>(value: T) => T;";
|
|
syntaxFiles["generic-callback-import.ts"] =
|
|
'export type Callback = <T>(value: T) => import("./callback-type.js").Value;';
|
|
withTinyFileTree(
|
|
{
|
|
...syntaxFiles,
|
|
"source.ts": [
|
|
'// import "./comment.js";',
|
|
'/* export * from "./block-comment.js"; */',
|
|
`const fixture = 'import "./quoted.js";';`,
|
|
'const template = `import "./template.js"; ${import("./expression.js")}`;',
|
|
String.raw`const pattern = /import "\.\/regexp\.js"/;`,
|
|
'import type { Value } from "./types.js";',
|
|
'export type * from "./reexport-types.js";',
|
|
'import { type Value, type Other as Alias } from "./named-types.js";',
|
|
'import { type Value, runtime } from "./mixed.js";',
|
|
'type Shape = typeof import("./import-type.js");',
|
|
'interface Options { input: import("./interface-type.js").Value }',
|
|
'const runtimeTypeof = typeof import("./runtime-expression.js");',
|
|
'import type from "./default-value.js";',
|
|
'import {} from "./side-effect.js";',
|
|
'import { type as value } from "./named-value.js";',
|
|
'export { type Value } from "./shared.js";',
|
|
'import "./shared.js";',
|
|
'import /* boundary */ "./real.js";',
|
|
].join("\n"),
|
|
},
|
|
(cwd) => {
|
|
const [facts, ...syntaxFacts] = readTestSelectorSourceFacts(
|
|
cwd,
|
|
[
|
|
{ file: "source.ts", parseImports: true },
|
|
...Object.keys(syntaxFiles).map((file) => ({ file, parseImports: true })),
|
|
],
|
|
[],
|
|
16 * 1024 * 1024,
|
|
);
|
|
expect(facts?.imports).toEqual([
|
|
"./expression.js",
|
|
"./types.js",
|
|
"./reexport-types.js",
|
|
"./named-types.js",
|
|
"./mixed.js",
|
|
"./import-type.js",
|
|
"./interface-type.js",
|
|
"./runtime-expression.js",
|
|
"./default-value.js",
|
|
"./side-effect.js",
|
|
"./named-value.js",
|
|
"./shared.js",
|
|
"./real.js",
|
|
]);
|
|
expect(facts?.typeOnlyImports).toEqual([
|
|
"./types.js",
|
|
"./reexport-types.js",
|
|
"./import-type.js",
|
|
"./interface-type.js",
|
|
]);
|
|
const importsByFile = new Map(syntaxFacts.map(({ file, imports }) => [file, imports]));
|
|
expect(syntaxFacts.find(({ file }) => file === "generic-callback.ts")).toMatchObject({
|
|
imports: [],
|
|
typeOnlyImports: [],
|
|
});
|
|
expect(syntaxFacts.find(({ file }) => file === "generic-callback-import.ts")).toMatchObject(
|
|
{
|
|
imports: ["./callback-type.js"],
|
|
typeOnlyImports: ["./callback-type.js"],
|
|
},
|
|
);
|
|
expect(importsByFile.get("ambiguous-template.ts")).toEqual(["./template-consumer.js"]);
|
|
for (const [name, source] of Object.entries(regexContexts)) {
|
|
expect(importsByFile.get(`${name}.ts`), source).toEqual(["./consumer.js"]);
|
|
}
|
|
for (const [name, source] of Object.entries(divisionContexts)) {
|
|
expect(importsByFile.get(`${name}.ts`), source).toEqual(["./during.js", "./consumer.js"]);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
it("preserves literal matches and whole-token references across a native source batch", () => {
|
|
const sources = {
|
|
"empty.txt": "",
|
|
"overlap.txt": "ushers ababa",
|
|
"paths.txt": "scripts/tool.mts @scope/name+tag_value-1.0 xabcdy abcd",
|
|
"unicode.txt": "éabcd😀foo/bar中 abc😀xyz",
|
|
"embedded.txt": "xabcdy scripts/tool.mts scripts/tool abcd",
|
|
"binary.txt": "null\0abcd\0tail",
|
|
"repeated.txt": "aaaaaaaaa aba aaa aaaa",
|
|
"late-references.txt":
|
|
"ushers ababa xabcdy scripts/tool.mts @scope/name+tag_value-1.0 xfoo/bary 😀 null\0 aaaaaa absent\n abcd scripts/tool foo/bar aaaa",
|
|
};
|
|
const terms = [
|
|
"",
|
|
"he",
|
|
"she",
|
|
"hers",
|
|
"aba",
|
|
"ba",
|
|
"aba",
|
|
"abcd",
|
|
"bcd",
|
|
"scripts/tool",
|
|
"scripts/tool.mts",
|
|
"@scope/name+tag_value-1.0",
|
|
"foo/bar",
|
|
"😀",
|
|
"\ud83d",
|
|
"\ude00",
|
|
// Failure links cross between dense ASCII rows and sparse non-ASCII edges.
|
|
"d😀foo",
|
|
"中 abc",
|
|
"😀xyz",
|
|
"é😀",
|
|
"null\0",
|
|
"aaa",
|
|
"aaaa",
|
|
"absent",
|
|
];
|
|
withTinyFileTree(sources, (cwd) => {
|
|
const files = Object.keys(sources).map((file) => ({ file, parseImports: false }));
|
|
const expected = Object.entries(sources).map(([file, source]) => {
|
|
const tokens = new Set(source.match(/[A-Za-z0-9_.@+/-]{4,}/gu));
|
|
return {
|
|
file,
|
|
imports: [],
|
|
typeOnlyImports: [],
|
|
matches: terms.filter((term) => source.includes(term)),
|
|
references: terms.filter((term) => tokens.has(term)),
|
|
};
|
|
});
|
|
expect(readTestSelectorSourceFacts(cwd, files, terms, 1024 * 1024)).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
it("keeps request order across a striped multi-worker source scan", () => {
|
|
// Enough files for several scan workers; each row must return to its request slot.
|
|
const rows = Array.from({ length: 600 }, (_, index) => ({
|
|
file: `f${String(index).padStart(3, "0")}.ts`,
|
|
dependency: `./dep-${index}.js`,
|
|
readable: index % 7 !== 3,
|
|
matched: index % 3 === 0,
|
|
}));
|
|
const sources = Object.fromEntries(
|
|
rows
|
|
.filter(({ readable }) => readable)
|
|
.map(({ file, dependency, matched }) => [
|
|
file,
|
|
`import "${dependency}";\n${matched ? "// needle\n" : ""}`,
|
|
]),
|
|
);
|
|
withTinyFileTree(sources, (cwd) => {
|
|
const files = rows.map(({ file }) => ({ file, parseImports: true }));
|
|
expect(
|
|
readTestSelectorSourceFacts(cwd, files, ["needle"], 16 * 1024 * 1024, {
|
|
matchingOnly: true,
|
|
}),
|
|
).toEqual(
|
|
rows
|
|
.filter(({ readable, matched }) => readable && matched)
|
|
.map(({ file, dependency }) => ({
|
|
file,
|
|
imports: [dependency],
|
|
typeOnlyImports: [],
|
|
matches: ["needle"],
|
|
references: ["needle"],
|
|
})),
|
|
);
|
|
expect(readTestSelectorSourceFacts(cwd, files, [], 16 * 1024 * 1024)).toEqual(
|
|
rows
|
|
.filter(({ readable }) => readable)
|
|
.map(({ file, dependency }) => ({
|
|
file,
|
|
imports: [dependency],
|
|
typeOnlyImports: [],
|
|
matches: [],
|
|
references: [],
|
|
})),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("reads complete files without installed packages, inherited hooks, or reparsing cached imports", () => {
|
|
withTinyFileTree(
|
|
{
|
|
"unterminated.ts": '// "\nconst value = "\\u{000',
|
|
"large.mts": `${"// padding\n".repeat(220_000)}export type {\n Value\n } from "./barrel.js";\nimport(\n "./dynamic.mjs"\n);\nconst fixture = "scripts/tool.mts";\nnew URL(\n "./native-fixture.mjs?generation=1#child", import.meta.url,\n);\nnew URL("./other-base.mjs", "file:///elsewhere/");\nrequire("dependency/runtime");\nrequire.resolve("dependency/package.json");\nimport.meta.resolve("other-dependency");`,
|
|
},
|
|
(cwd) => {
|
|
const files = [
|
|
{ file: "large.mts", parseImports: true },
|
|
{ file: "unterminated.ts", parseImports: true },
|
|
{ file: "deleted.ts", parseImports: true },
|
|
];
|
|
const unterminatedFacts = { imports: [], typeOnlyImports: [], matches: [], references: [] };
|
|
const expectedFacts = {
|
|
imports: [
|
|
"./barrel.js",
|
|
"./dynamic.mjs",
|
|
"./native-fixture.mjs",
|
|
"dependency/runtime",
|
|
"dependency/package.json",
|
|
"other-dependency",
|
|
],
|
|
typeOnlyImports: ["./barrel.js"],
|
|
matches: ["scripts/tool.mts", "scripts/tool"],
|
|
references: ["scripts/tool.mts"],
|
|
};
|
|
for (const file of [
|
|
"scripts/lib/test-selector-source-facts.mts",
|
|
"scripts/lib/test-source-term-matcher.mts",
|
|
"src/infra/node-runtime-executable.ts",
|
|
]) {
|
|
const target = path.join(cwd, file);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.copyFileSync(path.resolve(file), target);
|
|
}
|
|
const scanner = path.join(
|
|
fs.realpathSync(cwd),
|
|
"scripts/lib/test-selector-source-facts.mts",
|
|
);
|
|
const native = spawnSync(requireNodeTool("node"), [scanner], {
|
|
cwd,
|
|
input: JSON.stringify({ files, terms: ["scripts/tool.mts", "scripts/tool"] }),
|
|
encoding: "utf8",
|
|
// A malformed escape must not rewind the scanner's cursor forever.
|
|
timeout: 5_000,
|
|
});
|
|
expect(native.error).toBeUndefined();
|
|
expect(native.status, native.stderr).toBe(0);
|
|
expect(JSON.parse(native.stdout)).toEqual([expectedFacts, unterminatedFacts, null]);
|
|
vi.stubEnv(
|
|
"NODE_OPTIONS",
|
|
"--import=data:text/javascript,throw%20Error('inherited-loader')",
|
|
);
|
|
try {
|
|
expect(
|
|
readTestSelectorSourceFacts(
|
|
cwd,
|
|
files,
|
|
["scripts/tool.mts", "scripts/tool"],
|
|
16 * 1024 * 1024,
|
|
{ matchingOnly: true },
|
|
),
|
|
).toEqual([{ file: "large.mts", ...expectedFacts }]);
|
|
expect(
|
|
readTestSelectorSourceFacts(
|
|
cwd,
|
|
files,
|
|
["scripts/tool.mts", "scripts/tool"],
|
|
16 * 1024 * 1024,
|
|
),
|
|
).toEqual([
|
|
{ file: "large.mts", ...expectedFacts },
|
|
{ file: "unterminated.ts", ...unterminatedFacts },
|
|
]);
|
|
expect(
|
|
readTestSelectorSourceFacts(
|
|
cwd,
|
|
[{ file: "large.mts", parseImports: false }],
|
|
["scripts/tool.mts"],
|
|
16 * 1024 * 1024,
|
|
),
|
|
).toEqual([
|
|
{
|
|
file: "large.mts",
|
|
imports: [],
|
|
typeOnlyImports: [],
|
|
matches: ["scripts/tool.mts"],
|
|
references: ["scripts/tool.mts"],
|
|
},
|
|
]);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
it("fails loudly on child launch, output overflow, and invalid native requests", () => {
|
|
withTinyFileTree({ "value.ts": 'import "./dependency.js";' }, (cwd) => {
|
|
const files = [{ file: "value.ts", parseImports: true }];
|
|
expect(() => readTestSelectorSourceFacts(path.join(cwd, "missing"), files, [], 1024)).toThrow(
|
|
"Test selector source scan failed",
|
|
);
|
|
expect(() => readTestSelectorSourceFacts(cwd, files, [], 1)).toThrow(
|
|
"Test selector source scan failed",
|
|
);
|
|
const result = spawnSync(
|
|
requireNodeTool("node"),
|
|
[path.resolve("scripts/lib/test-selector-source-facts.mts")],
|
|
{
|
|
cwd,
|
|
input: '{"files":false}',
|
|
encoding: "utf8",
|
|
},
|
|
);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("[test-selector-source-facts] FAILED (exit 1)");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("scripts/test-projects full-suite sharding", () => {
|
|
it.each([
|
|
{ label: "null", includePatterns: null },
|
|
{ label: "empty", includePatterns: [] },
|
|
])("retains whole-config costs for $label selection", ({ includePatterns }) => {
|
|
const whole = { config: "test/vitest/vitest.gateway.config.ts", includePatterns };
|
|
const selected = {
|
|
config: "test/vitest/vitest.tooling.config.ts",
|
|
includePatterns: ["test/scripts/run-with-env.test.ts"],
|
|
};
|
|
|
|
expect(orderFullSuiteSpecsForParallelRun([selected, whole])).toEqual([whole, selected]);
|
|
});
|
|
|
|
it("prioritizes expensive selected files over whole-config defaults", () => {
|
|
const tooling = {
|
|
config: "test/vitest/vitest.tooling.config.ts",
|
|
includePatterns: ["test/scripts/vitest-worker-artifacts.test.ts"],
|
|
};
|
|
const runtime = {
|
|
config: "test/vitest/vitest.runtime-config.config.ts",
|
|
includePatterns: ["src/config/sessions/session-accessor.sqlite-archive.worker.test.ts"],
|
|
};
|
|
|
|
expect(orderFullSuiteSpecsForParallelRun([runtime, tooling])).toEqual([tooling, runtime]);
|
|
});
|
|
|
|
it("prices expanded chunk files without enabling include-file filtering", () => {
|
|
const chunk = {
|
|
config: "test/vitest/vitest.tooling.config.ts",
|
|
includePatterns: null,
|
|
timingTargets: ["test/scripts/vitest-worker-artifacts.test.ts"],
|
|
};
|
|
const whole = { config: "test/vitest/vitest.runtime-config.config.ts", includePatterns: null };
|
|
|
|
expect(orderFullSuiteSpecsForParallelRun([whole, chunk])).toEqual([chunk, whole]);
|
|
});
|
|
|
|
it("uses observed selection timings without substituting a whole-config sample", () => {
|
|
const fastTooling = {
|
|
config: "test/vitest/vitest.tooling.config.ts",
|
|
includePatterns: ["test/scripts/run-with-env.test.ts"],
|
|
};
|
|
const slowTooling = {
|
|
config: fastTooling.config,
|
|
includePatterns: ["test/scripts/vitest-worker-artifacts.test.ts"],
|
|
};
|
|
const runtime = {
|
|
config: "test/vitest/vitest.runtime-config.config.ts",
|
|
includePatterns: ["src/config/sessions/session-accessor.sqlite-archive.worker.test.ts"],
|
|
};
|
|
const timings = new Map([
|
|
[fastTooling.config, 1_000_000],
|
|
[resolveShardTimingKey(fastTooling), 500],
|
|
[resolveShardTimingKey(slowTooling), 153_000],
|
|
[resolveShardTimingKey(runtime), 35_000],
|
|
]);
|
|
|
|
expect(orderFullSuiteSpecsForParallelRun([fastTooling, slowTooling, runtime], timings)).toEqual(
|
|
[slowTooling, fastTooling, runtime],
|
|
);
|
|
});
|
|
|
|
it("keeps CI=1 full-suite runs on aggregate shard configs", () => {
|
|
vi.stubEnv("CI", "1");
|
|
vi.stubEnv("GITHUB_ACTIONS", "");
|
|
vi.stubEnv("OPENCLAW_TESTBOX_REMOTE_RUN", "");
|
|
vi.stubEnv("OPENCLAW_TEST_PROJECTS_LEAF_SHARDS", "");
|
|
vi.stubEnv("OPENCLAW_TEST_PROJECTS_PARALLEL", "");
|
|
try {
|
|
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
|
|
|
|
expect(configs).toContain("test/vitest/vitest.full-agentic.config.ts");
|
|
expect(configs).toContain("test/vitest/vitest.full-extensions.config.ts");
|
|
expect(configs).not.toContain("test/vitest/vitest.gateway-server.config.ts");
|
|
expect(configs).not.toContain("test/vitest/vitest.extension-telegram.config.ts");
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
it("raises the effective last aggregate extension heap override", () => {
|
|
const specs = applyFullExtensionsHeapBudget([
|
|
{
|
|
config: "test/vitest/vitest.full-extensions.config.ts",
|
|
env: { NODE_OPTIONS: "--max-old-space-size=12288 --max_old_space_size=4096" },
|
|
},
|
|
]);
|
|
|
|
expect(specs[0]?.env.NODE_OPTIONS).toBe("--max-old-space-size=12288 --max_old_space_size=8192");
|
|
});
|
|
|
|
it("splits the Testbox agentic and extension shards into bounded processes", () => {
|
|
vi.stubEnv("CI", "1");
|
|
vi.stubEnv("GITHUB_ACTIONS", "");
|
|
vi.stubEnv("OPENCLAW_TESTBOX_REMOTE_RUN", "1");
|
|
vi.stubEnv("OPENCLAW_TEST_PROJECTS_LEAF_SHARDS", "");
|
|
vi.stubEnv("OPENCLAW_TEST_PROJECTS_PARALLEL", "");
|
|
try {
|
|
const plans = buildFullSuiteVitestRunPlans([], process.cwd());
|
|
const configs = plans.map((plan) => plan.config);
|
|
|
|
expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts");
|
|
expect(configs).not.toContain("test/vitest/vitest.full-extensions.config.ts");
|
|
expect(configs).toContain("test/vitest/vitest.agents-core.config.ts");
|
|
expect(configs).toContain("test/vitest/vitest.extension-telegram.config.ts");
|
|
|
|
const targetedPlans = (config: string) =>
|
|
plans.filter((plan) => plan.config === config && plan.forwardedArgs.length > 0);
|
|
expect(targetedPlans("test/vitest/vitest.agents-core.config.ts")).toHaveLength(6);
|
|
expect(
|
|
targetedPlans("test/vitest/vitest.agents-core.config.ts").flatMap(
|
|
(plan) => plan.forwardedArgs,
|
|
),
|
|
).not.toContain("src/agents/agent-command-local.test.ts");
|
|
expect(
|
|
configs.filter((config) => config === "test/vitest/vitest.cli-process.config.ts"),
|
|
).toHaveLength(1);
|
|
const gatewayTargets = targetedPlans("test/vitest/vitest.gateway-server.config.ts").map(
|
|
(plan) => plan.forwardedArgs,
|
|
);
|
|
expect(gatewayTargets.every((files) => files.length > 0 && files.length <= 50)).toBe(true);
|
|
expect(gatewayTargets.flat()).toEqual(
|
|
listGitTrackedFiles({ pathspecs: "src/gateway" })
|
|
?.filter(isGatewayServerTestFile)
|
|
.toSorted((a, b) => a.localeCompare(b)),
|
|
);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
it("expands untargeted local runs to leaf project configs by default", async () => {
|
|
const infraConfig = "test/vitest/vitest.infra.config.ts";
|
|
const infraFiles = await listVitestConfigTestFiles(infraConfig);
|
|
withEnv(
|
|
{
|
|
OPENCLAW_TEST_PROJECTS_LEAF_SHARDS: undefined,
|
|
OPENCLAW_TEST_PROJECTS_PARALLEL: undefined,
|
|
OPENCLAW_TEST_PROJECTS_SERIAL: undefined,
|
|
CI: undefined,
|
|
GITHUB_ACTIONS: undefined,
|
|
OPENCLAW_VITEST_MAX_WORKERS: undefined,
|
|
OPENCLAW_TEST_WORKERS: undefined,
|
|
},
|
|
() => {
|
|
const plans = buildFullSuiteVitestRunPlans([], process.cwd());
|
|
const configs = plans.map((plan) => plan.config);
|
|
|
|
expect(configs).toContain("test/vitest/vitest.gateway-server.config.ts");
|
|
expect(configs).toContain("test/vitest/vitest.extension-telegram.config.ts");
|
|
expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts");
|
|
expect(configs).not.toContain("test/vitest/vitest.full-core-unit-fast.config.ts");
|
|
|
|
const targetedPlans = (config: string) =>
|
|
plans.filter((plan) => plan.config === config && plan.forwardedArgs.length > 0);
|
|
const unitFastPlans = targetedPlans("test/vitest/vitest.unit-fast.config.ts");
|
|
expect(unitFastPlans.length).toBeGreaterThan(1);
|
|
expect(unitFastPlans.every((plan) => plan.forwardedArgs.length <= 70)).toBe(true);
|
|
const unitSrcPlans = targetedPlans("test/vitest/vitest.unit-src.config.ts");
|
|
expect(unitSrcPlans.length).toBeGreaterThan(1);
|
|
expect(unitSrcPlans.every((plan) => plan.forwardedArgs.length <= 150)).toBe(true);
|
|
const toolingPlans = targetedPlans("test/vitest/vitest.tooling.config.ts");
|
|
expect(toolingPlans.length).toBeGreaterThan(1);
|
|
expect(toolingPlans.every((plan) => plan.forwardedArgs.length <= 2)).toBe(true);
|
|
const infraPlans = plans.filter((plan) => plan.config === infraConfig);
|
|
expect(infraPlans.length).toBeGreaterThan(1);
|
|
expect(
|
|
infraPlans.every(
|
|
(plan) => plan.forwardedArgs.length > 0 && plan.forwardedArgs.length <= 64,
|
|
),
|
|
).toBe(true);
|
|
expect(infraPlans.flatMap((plan) => plan.forwardedArgs).toSorted()).toEqual(
|
|
[...new Set(infraFiles)].toSorted(),
|
|
);
|
|
const toolingTargets = toolingPlans.flatMap((plan) => plan.forwardedArgs);
|
|
expect(toolingTargets.filter((file) => file.startsWith("test/fixtures/"))).toEqual([]);
|
|
expect(plans.flatMap((plan) => plan.forwardedArgs)).toEqual(
|
|
expect.arrayContaining([
|
|
"test/scripts/oxlint-boundary-guards.test.ts",
|
|
"test/scripts/ts-topology.test.ts",
|
|
]),
|
|
);
|
|
for (const plan of plans.filter((entry) => entry.forwardedArgs.length > 0)) {
|
|
expect(plan.timingTargets).toEqual(plan.forwardedArgs);
|
|
expect(plan.includePatterns).toBeNull();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
it("keeps shared Vitest config helpers out of whole-config targets", () => {
|
|
const args = ["test/vitest/vitest.shared.config.ts"];
|
|
|
|
expect(findUnmatchedExplicitTestTargets(args, process.cwd())).toEqual([]);
|
|
expectSingleVitestRunPlan(buildVitestRunPlans(args, process.cwd()), {
|
|
config: "test/vitest/vitest.tooling.config.ts",
|
|
includePatterns: ["test/vitest/**/*.test.ts"],
|
|
});
|
|
|
|
withTinyFileTree({ "test/vitest/vitest.helper.config.ts": "export default {};\n" }, (cwd) => {
|
|
const helperArgs = ["test/vitest/vitest.helper.config.ts"];
|
|
expect(findUnmatchedExplicitTestTargets(helperArgs, cwd)).toEqual([
|
|
{
|
|
target: "test/vitest/vitest.helper.config.ts",
|
|
reason: "target-matched-no-test-files",
|
|
includePattern: "test/vitest/**/*.test.ts",
|
|
},
|
|
]);
|
|
expectSingleVitestRunPlan(buildVitestRunPlans(helperArgs, cwd), {
|
|
config: "test/vitest/vitest.tooling.config.ts",
|
|
includePatterns: ["test/vitest/**/*.test.ts"],
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("scripts/test-projects Vitest stall watchdog", () => {
|
|
it("arms non-watch jobs without shortening the direct runner's slow-config watchdog", () => {
|
|
const configs = [
|
|
"test/vitest/vitest.extension-feishu.config.ts",
|
|
"test/vitest/vitest.gateway-server.config.ts",
|
|
];
|
|
const specs = applyDefaultVitestNoOutputTimeout(
|
|
configs.map((config) => ({ config, env: {}, watchMode: false })),
|
|
{ env: {} },
|
|
);
|
|
|
|
for (const [index, spec] of specs.entries()) {
|
|
const timeout = Number(spec.env.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS);
|
|
const heartbeat = Number(spec.env.OPENCLAW_VITEST_NO_OUTPUT_HEARTBEAT_MS);
|
|
expect(timeout).toBeGreaterThan(0);
|
|
expect(heartbeat).toBeGreaterThan(0);
|
|
expect(heartbeat).toBeLessThan(timeout);
|
|
expect(timeout).toBeGreaterThanOrEqual(
|
|
resolveDefaultVitestNoOutputTimeoutMs(["run", "--config", configs[index]!]),
|
|
);
|
|
}
|
|
expect(Number(specs[1]?.env.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS)).toBeGreaterThan(
|
|
Number(specs[0]?.env.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("scripts/test-projects Vitest cache isolation", () => {
|
|
it("assigns isolated fs-module caches to multi-spec non-watch runs", () => {
|
|
const specs = applyDefaultVitestCachePaths(
|
|
[
|
|
{
|
|
config: "test/vitest/vitest.unit-fast.config.ts",
|
|
env: {},
|
|
includeFilePath: null,
|
|
includePatterns: null,
|
|
pnpmArgs: [],
|
|
watchMode: false,
|
|
},
|
|
{
|
|
config: "test/vitest/vitest.extension-memory.config.ts",
|
|
env: {},
|
|
includeFilePath: null,
|
|
includePatterns: null,
|
|
pnpmArgs: [],
|
|
watchMode: false,
|
|
},
|
|
],
|
|
{ cwd: "/repo", env: {} },
|
|
);
|
|
|
|
const paths = specs.map((spec) => spec.env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH);
|
|
expect(new Set(paths).size).toBe(2);
|
|
for (const cachePath of paths) {
|
|
expect(path.relative(path.join("/repo", ".cache", "vitest"), cachePath!)).toMatch(
|
|
/^slots[/\\][a-f\d]+[/\\]0$/u,
|
|
);
|
|
}
|
|
});
|
|
});
|