mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
Derive the prerequisite from the file route executed by target children instead of its remapped canonical selection owner. Reuse the existing private-QA runtime-only preparation and successful-build prebuilt gate; keep all selected files and assertions. Proof: three real-route regressions fail before and pass after; 61 planner tests pass; four shallow preflight manifest shapes pass; single E2E file passes all 9 tests after runtime prep; check-changed and boundary lint pass; independent P2 review is clean. No workflow, job, dependency, export, or repository-variable changes.
1168 lines
48 KiB
TypeScript
1168 lines
48 KiB
TypeScript
import childProcess, { execFileSync } from "node:child_process";
|
|
import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { syncBuiltinESMExports } from "node:module";
|
|
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { resolveTestGitCommits } from "../../.github/actions/git-owner/test-prerequisites.mjs";
|
|
import { buildChildEnv, resolveShardPlans } from "../../scripts/ci-run-node-test-shard.mts";
|
|
import {
|
|
createChangedNodeTestShards as createChangedNodeTestShardsWithSmoke,
|
|
hasUiE2eAffectingChange,
|
|
} from "../../scripts/lib/ci-changed-node-test-plan.mts";
|
|
import { encodeNodeTestGroups } from "../../scripts/lib/ci-node-test-groups-codec.mts";
|
|
import {
|
|
createNodeTestShardBundles,
|
|
createSelectedNodeTestShardBundles,
|
|
type CompactNodeTestShard,
|
|
} from "../../scripts/lib/ci-node-test-plan.mts";
|
|
import {
|
|
CI_PROOF_TEST_FILES,
|
|
isCiProofTestFile,
|
|
} from "../../scripts/lib/ci-proof-test-inventory.mts";
|
|
import { refitTestTimings } from "../../scripts/lib/ci-test-timings-refit.mts";
|
|
import * as testTimings from "../../scripts/lib/ci-test-timings.mts";
|
|
import * as buildPrerequisites from "../../scripts/lib/vitest-build-prerequisites.mts";
|
|
import * as testProjects from "../../scripts/test-projects.test-support.mts";
|
|
import {
|
|
buildVitestRunPlans,
|
|
hasImportGraphConsumers,
|
|
hasImportGraphImpactOnTargets,
|
|
resolveAffectedTestsFromImportGraph,
|
|
} from "../../scripts/test-projects.test-support.mts";
|
|
import { listGitTrackedFiles } from "../../src/test-utils/repo-files.js";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
import { createNestedGitEnv } from "../helpers/temp-repo.js";
|
|
import {
|
|
gatewayCallsitesGuard,
|
|
createChangedNodeTestShards,
|
|
materializeGatewayCallsitesFixture,
|
|
fallbackGroups,
|
|
selectedFiles,
|
|
expectCanonicalGroupedConcurrency,
|
|
expectProtectedOwnerExpansion,
|
|
} from "./ci-changed-node-test-plan.test-support.js";
|
|
|
|
const argvTempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
|
|
const gitToolingTargets = [
|
|
"ci-git-owner",
|
|
"ci-linux-git",
|
|
"ci-platform-checkout",
|
|
"openclaw-performance-workflow",
|
|
"openclaw-performance-git-lifecycle",
|
|
"plugin-release-git-lifecycle",
|
|
"release-workflow-git-lifecycle",
|
|
"ci-workflow-guards",
|
|
].map((name) => `test/scripts/${name}.test.ts`);
|
|
|
|
it("keeps activity unit changes narrow alongside root package metadata", () => {
|
|
expect(hasUiE2eAffectingChange(["ui/src/pages/activity/activity-page.test.ts"])).toBe(false);
|
|
expect(
|
|
hasUiE2eAffectingChange(["ui/src/pages/activity/activity-page.test.ts", "package.json"]),
|
|
).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["extensions/browser/chrome-extension/background.js", false],
|
|
["src/gateway/control-ui.ts", true],
|
|
["packages/gateway-protocol/src/schema/protocol-schemas.ts", true],
|
|
["src/gateway/cron-stream-matcher.worker.ts", false],
|
|
] as const)(
|
|
"selects browser proof only for its owner or imported wire contract: %s",
|
|
(file, expected) => {
|
|
expect(hasUiE2eAffectingChange([file])).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["src/gateway/control-ui.ts", true],
|
|
["packages/gateway-protocol/src/schema/protocol-schemas.ts", true],
|
|
["ui/src/e2e/control-ui-e2e-suite.test-support.ts", true],
|
|
["ui/src/e2e/settings-layout.e2e.test.ts", false],
|
|
] as const)(
|
|
"selects real-Gateway tests through their source, fixture, and protocol owners: %s",
|
|
(file, expected) => {
|
|
expect(hasUiE2eAffectingChange([file], { family: "real-gateway" })).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["ui/src/styles/chat.css", false],
|
|
["extensions/browser/chrome-extension/background.js", true],
|
|
["extensions/browser/src/browser/extension-install.ts", true],
|
|
["test/vitest/vitest.shared.config.ts", false],
|
|
] as const)(
|
|
"selects the extension bootstrap through its source and shared owners: %s",
|
|
(file, expected) => {
|
|
expect(hasUiE2eAffectingChange([file], { family: "browser-extension" })).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
[
|
|
[],
|
|
["ui/src/pages/activity/deleted.test.ts"],
|
|
["ui/src/pages/activity/activity-page.test.ts", "ui/src/pages/activity/activity-page.ts"],
|
|
["ui/src/test-helpers/control-ui-e2e.test.ts"],
|
|
["ui/src/app/gateway-store.test-support.ts"],
|
|
["ui/src/styles/cursor-policy.browser.test.ts"],
|
|
["ui/src/pages/activity/../activity/activity-page.test.ts"],
|
|
].map((paths) => ({ paths })),
|
|
)("retains UI E2E for protected or unresolved inputs $paths", ({ paths }) => {
|
|
expect(hasUiE2eAffectingChange(paths)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
["ui/src/e2e/page.e2e.test.ts", true],
|
|
["ui/src/pages/page.ts", false],
|
|
["ui/.cache/vitest/generated.mjs", false],
|
|
] as const)("resolves UI E2E ownership for importer %s: %s", (consumer, expected) => {
|
|
const cwd = argvTempDirs.make("openclaw-ui-unit-consumer-");
|
|
const target = "ui/src/pages/unit.test.ts";
|
|
|
|
mkdirSync(path.dirname(path.join(cwd, target)), { recursive: true });
|
|
writeFileSync(path.join(cwd, target), "export const fixture = 1;\n");
|
|
if (consumer) {
|
|
mkdirSync(path.dirname(path.join(cwd, consumer)), { recursive: true });
|
|
const relative = path.relative(path.dirname(consumer), target).split(path.sep).join("/");
|
|
writeFileSync(
|
|
path.join(cwd, consumer),
|
|
`import "${relative.startsWith(".") ? relative : `./${relative}`}";\n`,
|
|
);
|
|
}
|
|
writeFileSync(path.join(cwd, ".gitignore"), ".cache/\n.artifacts/\ndist/\nnode_modules/\n");
|
|
execFileSync("git", ["init", "-q"], { cwd });
|
|
execFileSync("git", ["add", "."], { cwd });
|
|
expect(
|
|
hasImportGraphImpactOnTargets([target], (file) => file !== target, cwd, { tooling: true }),
|
|
).toBe(!/\/(?:\.cache|\.artifacts|dist|node_modules)\//u.test(consumer));
|
|
expect(hasUiE2eAffectingChange([target], { cwd })).toBe(expected);
|
|
});
|
|
|
|
it.each(["archive", "untracked", "symlink"])(
|
|
"bounds UI E2E selection for %s unit inputs",
|
|
(mode) => {
|
|
const cwd = argvTempDirs.make("openclaw-ui-unit-inventory-");
|
|
const target = "ui/src/unit.test.ts";
|
|
|
|
mkdirSync(path.join(cwd, "ui/src"), { recursive: true });
|
|
if (mode === "symlink") {
|
|
writeFileSync(path.join(cwd, "ui/src/source.ts"), "export {};\n");
|
|
symlinkSync("source.ts", path.join(cwd, target));
|
|
} else {
|
|
writeFileSync(path.join(cwd, target), "export {};\n");
|
|
}
|
|
if (mode !== "archive") {
|
|
execFileSync("git", ["init", "-q"], { cwd });
|
|
}
|
|
if (mode === "symlink") {
|
|
execFileSync("git", ["add", "."], { cwd });
|
|
}
|
|
expect(hasUiE2eAffectingChange([target], { cwd })).toBe(mode === "symlink");
|
|
},
|
|
);
|
|
|
|
describe("CI changed Node test plan", () => {
|
|
it("retains changed-owner proof tiers and direct test opt-in without unrelated areas", () => {
|
|
const cwd = argvTempDirs.make("changed-owner-proof-tiers-");
|
|
const areaSource = "src/infra/owner-entry.ts";
|
|
const ordinary = "src/infra/ordinary-owner.test.ts";
|
|
const prExempt = "src/infra/device-bootstrap.test.ts";
|
|
const releaseOnly = "src/infra/state-migrations.test.ts";
|
|
const unrelated = "src/config/io.factory.test.ts";
|
|
for (const [file, content] of Object.entries({
|
|
[areaSource]: "export {};\n",
|
|
[ordinary]: "export {};\n",
|
|
[unrelated]: "export {};\n",
|
|
[prExempt]: 'import "./exempt-subject.js";\n',
|
|
[releaseOnly]: 'import "./release-subject.js";\n',
|
|
"src/infra/exempt-subject.ts": "export {};\n",
|
|
"src/infra/release-subject.ts": "export {};\n",
|
|
})) {
|
|
mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
|
|
writeFileSync(path.join(cwd, file), content);
|
|
}
|
|
const options = {
|
|
cwd,
|
|
includePrExemptRuntimeTests: false,
|
|
includeReleaseOnlyRuntimeTests: false,
|
|
};
|
|
const automatic = createChangedNodeTestShards([areaSource], options);
|
|
expect(automatic).not.toBeNull();
|
|
expect(selectedFiles(automatic).toSorted()).toEqual(
|
|
[ordinary, prExempt, releaseOnly].toSorted(),
|
|
);
|
|
for (const [source, target] of [
|
|
["src/infra/exempt-subject.ts", prExempt],
|
|
["src/infra/release-subject.ts", releaseOnly],
|
|
] as const) {
|
|
for (const changedPath of [source, target]) {
|
|
const direct = createChangedNodeTestShards([changedPath], options);
|
|
expect(direct, changedPath).not.toBeNull();
|
|
expect(selectedFiles(direct).toSorted(), changedPath).toEqual(
|
|
changedPath === target ? [target] : [ordinary, prExempt, releaseOnly].toSorted(),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("protects caught and deferred owner contracts without widening a direct test edit", () => {
|
|
const cwd = argvTempDirs.make("changed-protected-owner-contracts-");
|
|
const source = "src/cli/fixture-command.ts";
|
|
const sibling = "src/cli/fixture-command.test.ts";
|
|
const caught = "src/cli/update-cli.test.ts";
|
|
const deferred = "src/cli/claws-cli-legacy-resume.test.ts";
|
|
const unrelated = "src/other/unprotected.test.ts";
|
|
writeFileSync(path.join(cwd, "package.json"), "{}\n");
|
|
for (const file of [source, sibling, caught, deferred, unrelated]) {
|
|
mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
|
|
writeFileSync(path.join(cwd, file), "export {};\n");
|
|
}
|
|
const options = {
|
|
cwd,
|
|
includePrExemptRuntimeTests: false,
|
|
includeReleaseOnlyRuntimeTests: false,
|
|
};
|
|
const owner = createChangedNodeTestShards([source], options);
|
|
expect(owner).not.toBeNull();
|
|
expect(selectedFiles(owner).toSorted()).toEqual([sibling, caught, deferred].toSorted());
|
|
const global = createChangedNodeTestShards(["package.json"], options);
|
|
expect(global).not.toBeNull();
|
|
expect(selectedFiles(global)).toEqual([caught]);
|
|
for (const target of [sibling, caught, deferred]) {
|
|
const leaf = createChangedNodeTestShards([target], options);
|
|
expect(leaf, target).not.toBeNull();
|
|
expect(selectedFiles(leaf), target).toEqual([target]);
|
|
}
|
|
});
|
|
|
|
it("reuses a complete consumer graph until the tracked inventory changes", () => {
|
|
const cwd = argvTempDirs.make("changed-cached-consumers-");
|
|
const unshared = Array.from({ length: 40 }, (_, index) => `src/leaf-${index}.test.ts`);
|
|
const files = {
|
|
...Object.fromEntries(unshared.map((file) => [file, "export {};\n"])),
|
|
"tsconfig.json": JSON.stringify({
|
|
compilerOptions: { paths: { "@fixture/shared": ["./src/shared.ts"] } },
|
|
}),
|
|
"src/shared.ts": "export const shared = 1;\n",
|
|
"src/consumer.test.ts": 'import "@fixture/shared";\n',
|
|
"src/self.ts": 'import "./self.js";\n',
|
|
"src/types.ts": "export interface Value { value: string }\n",
|
|
"src/type-consumer.test.ts": 'import type { Value } from "./types.js";\n',
|
|
"src/later.ts": "export const value = 1;\n",
|
|
};
|
|
for (const [file, source] of Object.entries(files)) {
|
|
mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
|
|
writeFileSync(path.join(cwd, file), source);
|
|
}
|
|
const git = (...args: string[]) =>
|
|
execFileSync("git", ["-c", "core.hooksPath=/dev/null", ...args], {
|
|
cwd,
|
|
env: createNestedGitEnv(),
|
|
stdio: "pipe",
|
|
});
|
|
git("init", "--quiet");
|
|
git("add", ".");
|
|
const options = { tooling: true, resolveAliases: true, runtimeOnly: true, forceFull: true };
|
|
// A wide prefilter miss must not cache unrelated files as having no imports.
|
|
expect(hasImportGraphConsumers(unshared, cwd, options)).toBe(false);
|
|
expect(resolveAffectedTestsFromImportGraph(["src/shared.ts"], cwd, options)).toEqual([
|
|
"src/consumer.test.ts",
|
|
]);
|
|
const spawn = vi.spyOn(childProcess, "spawnSync");
|
|
syncBuiltinESMExports();
|
|
try {
|
|
for (const [file, expected] of [
|
|
["src/shared.ts", true],
|
|
["src/self.ts", false],
|
|
["src/types.ts", false],
|
|
["src/missing.ts", true],
|
|
] as const) {
|
|
expect(hasImportGraphConsumers([file], cwd, options), file).toBe(expected);
|
|
}
|
|
expect(
|
|
spawn.mock.calls.filter(([command, args]) => command === "git" && args?.[0] === "grep"),
|
|
).toEqual([]);
|
|
writeFileSync(path.join(cwd, "src/later-consumer.test.ts"), 'import "./later.js";\n');
|
|
git("add", "src/later-consumer.test.ts");
|
|
expect(hasImportGraphConsumers(["src/later.ts"], cwd, options)).toBe(true);
|
|
} finally {
|
|
spawn.mockRestore();
|
|
syncBuiltinESMExports();
|
|
}
|
|
});
|
|
|
|
it("defers named process proofs without dropping mixed ordinary targets", () => {
|
|
const ordinary = "src/plugin-sdk/plugin-config-runtime.test.ts";
|
|
const shards = createChangedNodeTestShards([...CI_PROOF_TEST_FILES, ordinary]);
|
|
expect(shards).not.toBeNull();
|
|
const files = selectedFiles(shards);
|
|
expect(files).toContain(ordinary);
|
|
expect(files.some(isCiProofTestFile)).toBe(false);
|
|
const dedicatedProofs = [
|
|
"test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts",
|
|
"test/e2e/qa-lab/plugins/discord-show-widget-contextual-presenter.e2e.test.ts",
|
|
"test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts",
|
|
];
|
|
for (const changedPaths of [[...dedicatedProofs, ordinary], ["package.json"]]) {
|
|
const protectedSelection = createChangedNodeTestShardsWithSmoke(changedPaths, {
|
|
selectedTestTargets: [...dedicatedProofs, ordinary],
|
|
});
|
|
expect(protectedSelection).not.toBeNull();
|
|
expect(selectedFiles(protectedSelection)).toContain(ordinary);
|
|
for (const proof of dedicatedProofs) {
|
|
expect(selectedFiles(protectedSelection), proof).not.toContain(proof);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps runtime proofs reached through the transitive helper graph", () => {
|
|
const cwd = argvTempDirs.make("changed-runtime-proof-");
|
|
const source = "src/infra/release-proof.ts";
|
|
const helper = "src/infra/release-proof.test-support.ts";
|
|
const deferred = "src/state/openclaw-database-preflight.lifecycle.test.ts";
|
|
const ordinary = "src/plugin-sdk/plugin-config-runtime.test.ts";
|
|
const unknown = "src/infra/unowned.ts";
|
|
for (const [file, content] of [
|
|
[source, "export {};\n"],
|
|
[helper, 'import "./release-proof.js";\n'],
|
|
[deferred, 'import "../infra/release-proof.test-support.js";\n'],
|
|
[ordinary, 'import "../infra/release-proof.js";\n'],
|
|
[unknown, "export {};\n"],
|
|
] as const) {
|
|
mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
|
|
writeFileSync(path.join(cwd, file), content);
|
|
}
|
|
materializeGatewayCallsitesFixture(cwd);
|
|
const options = { cwd, includeReleaseOnlyRuntimeTests: false };
|
|
const helperPlan = createChangedNodeTestShards([helper], options);
|
|
expect(helperPlan).not.toBeNull();
|
|
expect(selectedFiles(helperPlan).toSorted()).toEqual([deferred].toSorted());
|
|
expect(helperPlan).toContainEqual(
|
|
expect.objectContaining({
|
|
checkName: "checks-node-changed-boundary",
|
|
configs: ["test/vitest/vitest.boundary.config.ts"],
|
|
}),
|
|
);
|
|
const sourcePlan = createChangedNodeTestShards([source], options);
|
|
expect(sourcePlan).not.toBeNull();
|
|
expect(selectedFiles(sourcePlan).toSorted()).toEqual(
|
|
[ordinary, deferred, gatewayCallsitesGuard].toSorted(),
|
|
);
|
|
for (const companion of [unknown, "src/infra/deleted.ts"]) {
|
|
const mixed = createChangedNodeTestShards([helper, companion], options);
|
|
expect(mixed).not.toBeNull();
|
|
expect(selectedFiles(mixed)).toContain(gatewayCallsitesGuard);
|
|
expect(selectedFiles(mixed)).not.toContain(companion);
|
|
}
|
|
});
|
|
|
|
it.each(["blacksmith", "github", "hybrid"])(
|
|
"retains runtime proof child policies when release-only proofs are disabled (%s)",
|
|
(runnerBackend) => {
|
|
const targets = [
|
|
"src/agents/agent-bundle-mcp-retention.test.ts",
|
|
"src/agents/mcp-stdio-client.cleanup.real.test.ts",
|
|
"src/cli/gateway-cli/pre-bootstrap.process.test.ts",
|
|
"src/commands/doctor-config-preflight.refusal.process.test.ts",
|
|
"src/flows/doctor-health.test.ts",
|
|
"src/gateway/server.sessions.archive-worktree-lifecycle.test.ts",
|
|
"src/gateway/server.sessions.delete-worktree-lifecycle.test.ts",
|
|
"src/infra/update-managed-service-handoff-foreground.test.ts",
|
|
"src/node-host/node-worker-supervisor.recovery.test.ts",
|
|
"src/process/supervisor/adapters/child.service-lifecycle.test.ts",
|
|
"src/state/openclaw-database-preflight.lifecycle.test.ts",
|
|
"src/config/state-startup-corpus.part-2.test.ts",
|
|
"test/scripts/ci-linux-git.test.ts",
|
|
"test/scripts/full-release-validation-at-sha.test.ts",
|
|
"test/scripts/package-acceptance-workflow.test.ts",
|
|
"test/scripts/pr-merge-admission.test.ts",
|
|
"test/scripts/pr-merge-outcome.test.ts",
|
|
"test/scripts/pr-merge-receipt.test.ts",
|
|
"test/scripts/pr-merge-recovery.test.ts",
|
|
"test/scripts/pr-merge-rest.test.ts",
|
|
"test/scripts/pr-worktree-interruption.test.ts",
|
|
"test/scripts/pr-worktree-provision.test.ts",
|
|
];
|
|
const before = createChangedNodeTestShards(targets, { runnerBackend });
|
|
const selected = createChangedNodeTestShards(targets, {
|
|
runnerBackend,
|
|
includeReleaseOnlyRuntimeTests: false,
|
|
});
|
|
expect(before).not.toBeNull();
|
|
expect(selected).not.toBeNull();
|
|
const envScratch = argvTempDirs.make("changed-runtime-owner-env-");
|
|
expect(selectedFiles(selected).toSorted()).toEqual(
|
|
[
|
|
...new Set([...targets, ...gitToolingTargets, "test/scripts/test-projects.test.ts"]),
|
|
].toSorted(),
|
|
);
|
|
for (const target of targets) {
|
|
const ownerJob = expectDefined(
|
|
before?.find(
|
|
(shard) =>
|
|
shard.targets?.includes(target) ||
|
|
shard.includePatterns?.includes(target) ||
|
|
shard.groups?.some((group) => group.includePatterns?.includes(target)),
|
|
),
|
|
`canonical job for ${target}`,
|
|
);
|
|
const ownerGroup = ownerJob.groups?.find((group) =>
|
|
group.includePatterns?.includes(target),
|
|
);
|
|
const owner = ownerGroup ?? ownerJob;
|
|
const selectedJob = expectDefined(
|
|
selected?.find(
|
|
(shard) =>
|
|
shard.targets?.includes(target) ||
|
|
shard.includePatterns?.includes(target) ||
|
|
shard.groups?.some((group) => group.includePatterns?.includes(target)),
|
|
),
|
|
`selected job for ${target}`,
|
|
);
|
|
const selectedGroup = selectedJob.groups?.find((group) =>
|
|
group.includePatterns?.includes(target),
|
|
);
|
|
const selectedOwner = selectedGroup ?? selectedJob;
|
|
expect(selectedOwner.configs).toEqual(owner.configs);
|
|
expect(selectedOwner.env).toEqual(owner.env);
|
|
expect(selectedOwner.requiresDist).toBe(owner.requiresDist);
|
|
expect(selectedOwner.pretestBuildMode).toBe(owner.pretestBuildMode);
|
|
expect(selectedGroup?.fallbackMaxWorkers).toBe(ownerGroup?.fallbackMaxWorkers);
|
|
expect(selectedGroup?.minTotalMemoryBytes).toBe(ownerGroup?.minTotalMemoryBytes);
|
|
const ownerEntry = ownerGroup
|
|
? { kind: "group" as const, name: ownerGroup.shard_name, plan: ownerGroup }
|
|
: { kind: "target" as const, name: target, target };
|
|
const selectedEntry = selectedGroup
|
|
? { kind: "group" as const, name: selectedGroup.shard_name, plan: selectedGroup }
|
|
: { kind: "target" as const, name: target, target };
|
|
expect(buildChildEnv(selectedEntry, selectedJob.env ?? {}, envScratch, 0)).toEqual({
|
|
...buildChildEnv(ownerEntry, ownerJob.env ?? {}, envScratch, 0),
|
|
// Repacking changes the label, but every execution policy stays fixed.
|
|
...(selectedGroup ? { OPENCLAW_VITEST_SHARD_NAME: selectedGroup.shard_name } : {}),
|
|
});
|
|
expect(selectedJob.runner).toBe(ownerJob.runner);
|
|
expect(selectedJob.requiresDist).toBe(ownerJob.requiresDist);
|
|
expect(selectedJob.planConcurrency).toBe(ownerJob.planConcurrency);
|
|
expect(selectedJob.pretestBuildMode).toBe(ownerJob.pretestBuildMode);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("retains the paired tooling group for direct Docker helper selection", () => {
|
|
const shards = createSelectedNodeTestShardBundles(["test/scripts/docker-build-helper.test.ts"]);
|
|
expect(shards).not.toBeNull();
|
|
expect(shards?.flatMap((shard) => shard.groups).map((group) => group.shard_name)).toEqual([
|
|
"core-tooling-isolated",
|
|
]);
|
|
});
|
|
|
|
it.each(["blacksmith", "github", "hybrid"])(
|
|
"retains exact plugin selections in their canonical process owner without enabling the unrelated sweep (%s)",
|
|
(runnerBackend) => {
|
|
const pluginConfig = "test/vitest/vitest.plugins.config.ts";
|
|
const targets = [
|
|
"src/plugins/activation-planner.test.ts",
|
|
"src/plugins/manifest-registry.test.ts",
|
|
"src/infra/retry.test.ts",
|
|
];
|
|
const selected = createSelectedNodeTestShardBundles(targets, {
|
|
runnerBackend,
|
|
preparedTestPlans: new Map(
|
|
targets.map((target) => [target, buildVitestRunPlans([target])]),
|
|
),
|
|
});
|
|
expect(selected).not.toBeNull();
|
|
const groups = selected?.flatMap((row) => row.groups) ?? [];
|
|
expect(groups.flatMap((group) => group.includePatterns ?? []).toSorted()).toEqual(
|
|
targets.toSorted(),
|
|
);
|
|
const pluginGroups = groups.filter((group) => group.configs.includes(pluginConfig));
|
|
expect(pluginGroups.flatMap((group) => group.includePatterns ?? []).toSorted()).toEqual(
|
|
targets.slice(0, 2).toSorted(),
|
|
);
|
|
for (const group of pluginGroups) {
|
|
expect(group.requiresDist).toBe(false);
|
|
}
|
|
|
|
const unrelated = createNodeTestShardBundles({
|
|
runnerBackend,
|
|
compactMode: "pull-request",
|
|
includeReleaseOnlyPluginShards: false,
|
|
includeReleaseOnlyToolingShards: false,
|
|
});
|
|
expect(
|
|
unrelated
|
|
.flatMap((row) => row.groups ?? [row])
|
|
.some((group) => group.configs.includes(pluginConfig)),
|
|
).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("routes each selected test once while retaining its canonical plugin coverage", () => {
|
|
const targets = [
|
|
"src/plugins/activation-planner.test.ts",
|
|
"src/plugins/manifest-registry.test.ts",
|
|
"src/infra/retry.test.ts",
|
|
];
|
|
const routing = vi.spyOn(testProjects, "buildVitestRunPlans");
|
|
try {
|
|
const shards = createChangedNodeTestShardsWithSmoke(targets, {
|
|
selectedTestTargets: targets,
|
|
runnerBackend: "hybrid",
|
|
includeReleaseOnlyRuntimeTests: true,
|
|
includePrExemptRuntimeTests: true,
|
|
});
|
|
expect(shards).not.toBeNull();
|
|
expect(selectedFiles(shards).toSorted()).toEqual(targets.toSorted());
|
|
for (const target of targets) {
|
|
expect(
|
|
routing.mock.calls.filter(([args]) => args.length === 1 && args[0] === target),
|
|
target,
|
|
).toHaveLength(1);
|
|
}
|
|
} finally {
|
|
routing.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
"src/agents/model-fallback.reply-entry.e2e.test.ts",
|
|
"src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts",
|
|
"src/agents/bash-tools.process.e2e.test.ts",
|
|
])("prepares the runtime for the executed E2E route of %s", (target) => {
|
|
const shards = expectDefined(
|
|
createChangedNodeTestShardsWithSmoke([target], { selectedTestTargets: [target] }),
|
|
"changed E2E plan",
|
|
);
|
|
expect(selectedFiles(shards)).toEqual([target]);
|
|
const row = expectDefined(
|
|
shards.find((shard) => shard.targets?.includes(target)),
|
|
"target row",
|
|
);
|
|
const plans = resolveShardPlans({
|
|
OPENCLAW_NODE_TEST_TARGETS_JSON: JSON.stringify(row.targets),
|
|
});
|
|
expect(plans).toHaveLength(1);
|
|
expect(plans[0]).toMatchObject({ kind: "target", target });
|
|
expect(buildVitestRunPlans([target]).map((plan) => plan.config)).toContain(
|
|
"test/vitest/vitest.e2e.config.ts",
|
|
);
|
|
expect(row.pretestBuildMode).toBe("private-qa");
|
|
});
|
|
|
|
it("retains selected compact coverage when time splitting exceeds the non-dist matrix cap", async () => {
|
|
const targets = [
|
|
"test/scripts/ci-node-test-plan.test.ts",
|
|
"test/scripts/ci-changed-node-test-plan.test.ts",
|
|
];
|
|
const artifactTarget = "test/scripts/build-all.test.ts";
|
|
const selectedTestTargets = [...targets, artifactTarget];
|
|
const compact: CompactNodeTestShard = {
|
|
checkName: "checks-node-changed-tooling",
|
|
shardName: "changed-tooling",
|
|
runner: "ubuntu-24.04",
|
|
requiresDist: false,
|
|
planConcurrency: 1,
|
|
timeoutMinutes: 10,
|
|
pretestBuildMode: "runtime",
|
|
env: { OPENCLAW_VITEST_MAX_WORKERS: "2" },
|
|
predictedSeconds: 200,
|
|
predictedTestSeconds: 200,
|
|
groups: [
|
|
{
|
|
shard_name: "core-tooling",
|
|
configs: ["test/vitest/vitest.tooling.config.ts"],
|
|
includePatterns: targets,
|
|
runner: "ubuntu-24.04",
|
|
env: { NODE_OPTIONS: "--max-old-space-size=4096" },
|
|
fallbackMaxWorkers: 1,
|
|
minTotalMemoryBytes: 8 * 1024 ** 3,
|
|
requiresDist: false,
|
|
},
|
|
],
|
|
};
|
|
const artifact: CompactNodeTestShard = {
|
|
checkName: "checks-node-changed-build",
|
|
shardName: "changed-build",
|
|
runner: "ubuntu-24.04",
|
|
requiresDist: true,
|
|
predictedSeconds: 10,
|
|
groups: [
|
|
{
|
|
shard_name: "core-build",
|
|
configs: ["test/vitest/vitest.tooling.config.ts"],
|
|
includePatterns: [artifactTarget],
|
|
runner: "ubuntu-24.04",
|
|
requiresDist: true,
|
|
},
|
|
],
|
|
};
|
|
const selected = vi
|
|
.spyOn(
|
|
await import("../../scripts/lib/ci-node-test-plan.mts"),
|
|
"createSelectedNodeTestShardBundles",
|
|
)
|
|
.mockReturnValue([compact, artifact]);
|
|
const timing = vi
|
|
.spyOn(testTimings, "readToolingFileTimings")
|
|
.mockReturnValue(Object.fromEntries(targets.map((file) => [file, 100])));
|
|
const groups = vi.spyOn(testTimings, "readCompactGroupTimings").mockReturnValue({});
|
|
try {
|
|
const split = createChangedNodeTestShardsWithSmoke(selectedTestTargets, {
|
|
selectedTestTargets,
|
|
});
|
|
expect(split?.filter((shard) => !shard.requiresDist)).toHaveLength(2);
|
|
expect(selectedFiles(split).toSorted()).toEqual(selectedTestTargets.toSorted());
|
|
|
|
const capped = createChangedNodeTestShardsWithSmoke(selectedTestTargets, {
|
|
selectedTestTargets,
|
|
compactNodeJobCap: 1,
|
|
});
|
|
expect(capped?.filter((shard) => !shard.requiresDist)).toHaveLength(1);
|
|
expect(capped).toEqual([
|
|
{ ...compact, configs: [] },
|
|
{ ...artifact, configs: [] },
|
|
]);
|
|
expect(selectedFiles(capped).toSorted()).toEqual(selectedTestTargets.toSorted());
|
|
|
|
// A dist descriptor does not consume the Node matrix budget.
|
|
expect(
|
|
createChangedNodeTestShardsWithSmoke(selectedTestTargets, {
|
|
selectedTestTargets,
|
|
compactNodeJobCap: 2,
|
|
}),
|
|
).toEqual(split);
|
|
} finally {
|
|
groups.mockRestore();
|
|
timing.mockRestore();
|
|
selected.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("packs broad changed targets by execution policy without losing coverage", () => {
|
|
const runtimeTargets = Array.from(
|
|
{ length: 4 },
|
|
(_, i) => `src/infra/row-cap-runtime-${i}.test.ts`,
|
|
);
|
|
const e2eTargets = Array.from({ length: 4 }, (_, i) => `src/infra/row-cap-${i}.e2e.test.ts`);
|
|
const serialTargets = Array.from(
|
|
{ length: 4 },
|
|
(_, i) => `extensions/memory-core/row-cap-${i}.test.ts`,
|
|
);
|
|
const selectedTestTargets = [
|
|
...Array.from({ length: 1_000 }, (_, i) => `src/infra/row-cap-${i}.test.ts`),
|
|
...runtimeTargets,
|
|
...e2eTargets,
|
|
...serialTargets,
|
|
serialTargets[0]!,
|
|
];
|
|
const seconds = Object.fromEntries(selectedTestTargets.map((target) => [target, 20]));
|
|
const plans = vi.spyOn(testProjects, "buildVitestRunPlans").mockImplementation((targets) => [
|
|
{
|
|
config: targets.some((target) => e2eTargets.includes(target))
|
|
? "test/vitest/vitest.e2e.config.ts"
|
|
: "test/vitest/vitest.unit.config.ts",
|
|
includePatterns: null,
|
|
forwardedArgs: [...targets],
|
|
watchMode: false,
|
|
},
|
|
]);
|
|
const buildMode = vi
|
|
.spyOn(buildPrerequisites, "resolveVitestPretestBuildMode")
|
|
.mockImplementation((selections) =>
|
|
selections.some((selection) =>
|
|
selection.includePatterns?.some((target) => runtimeTargets.includes(target)),
|
|
)
|
|
? "runtime"
|
|
: undefined,
|
|
);
|
|
const e2eTimings = vi.spyOn(testTimings, "readRepoE2eFileTimings").mockReturnValue(seconds);
|
|
const toolingTimings = vi.spyOn(testTimings, "readToolingFileTimings").mockReturnValue({});
|
|
try {
|
|
const options = { selectedTestTargets };
|
|
const original = expectDefined(
|
|
createChangedNodeTestShardsWithSmoke(selectedTestTargets, options),
|
|
"broad changed-target plan",
|
|
);
|
|
expect(original.filter((shard) => !shard.requiresDist).length).toBeGreaterThan(130);
|
|
const packed = expectDefined(
|
|
createChangedNodeTestShardsWithSmoke(selectedTestTargets, {
|
|
...options,
|
|
compactNodeJobCap: 130,
|
|
}),
|
|
"packed changed-target plan",
|
|
);
|
|
expect(packed.filter((shard) => !shard.requiresDist)).toHaveLength(130);
|
|
expect(selectedFiles(packed).toSorted()).toEqual(selectedTestTargets.toSorted());
|
|
expect(packed.filter((shard) => !shard.targets)).toEqual(
|
|
original.filter((shard) => !shard.targets),
|
|
);
|
|
const targetRows = packed.filter((shard) => shard.targets);
|
|
// These three small policy partitions each need just one of the 129 target rows.
|
|
expect(targetRows.filter((shard) => shard.pretestBuildMode)).toHaveLength(2);
|
|
expect(targetRows.filter((shard) => shard.planConcurrency === 1)).toHaveLength(1);
|
|
for (const [index, shard] of targetRows.entries()) {
|
|
expect(shard).toMatchObject({
|
|
checkName: `checks-node-changed-${index + 1}`,
|
|
shardName: `changed-${index + 1}`,
|
|
predictedSeconds: shard.targets!.length * 20,
|
|
});
|
|
for (const target of shard.targets!) {
|
|
expect(shard.pretestBuildMode).toBe(
|
|
e2eTargets.includes(target)
|
|
? "private-qa"
|
|
: runtimeTargets.includes(target)
|
|
? "runtime"
|
|
: undefined,
|
|
);
|
|
expect(shard.planConcurrency).toBe(serialTargets.includes(target) ? 1 : undefined);
|
|
}
|
|
}
|
|
const ordinarySeconds = targetRows
|
|
.filter((shard) => !shard.pretestBuildMode && !shard.planConcurrency)
|
|
.map((shard) => shard.predictedSeconds!);
|
|
expect(Math.max(...ordinarySeconds) - Math.min(...ordinarySeconds)).toBeLessThanOrEqual(20);
|
|
expect(() =>
|
|
createChangedNodeTestShardsWithSmoke(selectedTestTargets, {
|
|
...options,
|
|
compactNodeJobCap: 3,
|
|
}),
|
|
).toThrow("4 execution policies exceed the changed-target row budget of 2");
|
|
// The boundary row already consumes this cap; leave rejection to the workflow.
|
|
expect(
|
|
createChangedNodeTestShardsWithSmoke(selectedTestTargets, {
|
|
...options,
|
|
compactNodeJobCap: 1,
|
|
}),
|
|
).toEqual(original);
|
|
const small = selectedTestTargets.slice(0, 2);
|
|
expect(
|
|
createChangedNodeTestShardsWithSmoke(small, {
|
|
selectedTestTargets: small,
|
|
compactNodeJobCap: 130,
|
|
}),
|
|
).toEqual(createChangedNodeTestShardsWithSmoke(small, { selectedTestTargets: small }));
|
|
} finally {
|
|
plans.mockRestore();
|
|
buildMode.mockRestore();
|
|
e2eTimings.mockRestore();
|
|
toolingTimings.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each(["blacksmith", "github", "hybrid"])(
|
|
"retains precise process ownership and independent timing keys (%s)",
|
|
(runnerBackend) => {
|
|
const targets = [
|
|
"src/agents/embedded-agent-runner/model-resolution-consistency.test.ts",
|
|
"src/agents/embedded-agent-runner/run/attempt-transcript-helpers.presence.test.ts",
|
|
];
|
|
const placement = vi.spyOn(testTimings, "readRuntimePlacementTimings").mockReturnValue([]);
|
|
let full: CompactNodeTestShard[];
|
|
try {
|
|
full = createNodeTestShardBundles({
|
|
compactMode: "pull-request",
|
|
runnerBackend,
|
|
includeReleaseOnlyPluginShards: false,
|
|
});
|
|
} finally {
|
|
placement.mockRestore();
|
|
}
|
|
const selected = expectDefined(
|
|
createChangedNodeTestShards(targets, { runnerBackend }),
|
|
"selected embedded plan",
|
|
);
|
|
expect(selectedFiles(selected).toSorted()).toEqual(targets.toSorted());
|
|
for (const job of selected.filter((candidate) => candidate.groups)) {
|
|
for (const group of job.groups ?? []) {
|
|
const ownerJob = expectDefined(
|
|
full.find((candidate) =>
|
|
candidate.groups.some((owner) => owner.shard_name === group.shard_name),
|
|
),
|
|
"canonical job",
|
|
);
|
|
const owner = expectDefined(
|
|
ownerJob.groups.find((candidate) => candidate.shard_name === group.shard_name),
|
|
"canonical group",
|
|
);
|
|
expect(group.env).toEqual(owner.env);
|
|
expect(group.env?.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS).toBe("660000");
|
|
for (const target of group.includePatterns ?? []) {
|
|
expect(group.configs).toEqual([buildVitestRunPlans([target])[0]?.config]);
|
|
}
|
|
for (const key of [
|
|
"runner",
|
|
"planConcurrency",
|
|
"pretestBuildMode",
|
|
"timeoutMinutes",
|
|
] as const) {
|
|
expect(job[key], key).toEqual(ownerJob[key]);
|
|
}
|
|
expect(job.predictedSeconds).toBeGreaterThan(0);
|
|
expect(job.predictedSeconds).toBeLessThanOrEqual(300);
|
|
expect(group.timing_key).toContain("#selector-");
|
|
expect(group.timing_key).not.toBe(group.shard_name);
|
|
const { timings } = refitTestTimings(
|
|
[1, 2].map((id) => ({
|
|
id,
|
|
createdAt: "2026-09-12T00:00:00Z",
|
|
completeInventory: false,
|
|
logs: [
|
|
{
|
|
kind: "compact" as const,
|
|
labels: ["blacksmith-8vcpu-ubuntu-2404"],
|
|
text: [
|
|
`2026-09-12T00:00:00Z [shard:${group.timing_key}] begin`,
|
|
`2026-09-12T00:00:01Z [shard:${group.timing_key}] end (exit 0)`,
|
|
].join("\n"),
|
|
},
|
|
],
|
|
})),
|
|
);
|
|
expect(timings.compactGroupSeconds.blacksmith[group.timing_key!]).toBe(1);
|
|
expect(timings.compactGroupSeconds.blacksmith[group.shard_name]).toBeUndefined();
|
|
}
|
|
}
|
|
expect(selected.filter((shard) => !shard.groups)).toEqual([
|
|
expect.objectContaining({
|
|
configs: ["test/vitest/vitest.boundary.config.ts"],
|
|
requiresDist: false,
|
|
}),
|
|
]);
|
|
expect(selected.some((shard) => shard.requiresDist)).toBe(false);
|
|
const acp = "src/acp/control-plane/manager.accepted-controls.test.ts";
|
|
const logging = "src/logging/logger-file-transport.test.ts";
|
|
const processTest = "src/process/exec.test.ts";
|
|
for (const { targets: ownerTargets, configs } of [
|
|
{ targets: [acp], configs: ["test/vitest/vitest.acp.config.ts"] },
|
|
{
|
|
targets: ["src/gateway/setup-inference.first-signin.integration.test.ts"],
|
|
configs: ["test/vitest/vitest.gateway-database-workers.config.ts"],
|
|
},
|
|
{
|
|
targets: [
|
|
"src/audit/execution-decision-facts.test.ts",
|
|
"src/auto-reply/reply/commands-export-session.test.ts",
|
|
"src/gateway/server-methods/session-change-event.fallback.test.ts",
|
|
],
|
|
configs: [
|
|
"test/vitest/vitest.unit-src.config.ts",
|
|
"test/vitest/vitest.auto-reply-reply.config.ts",
|
|
"test/vitest/vitest.gateway-methods.config.ts",
|
|
],
|
|
},
|
|
{ targets: [logging], configs: ["test/vitest/vitest.logging.config.ts"] },
|
|
{
|
|
targets: [logging, processTest],
|
|
configs: ["test/vitest/vitest.logging.config.ts", "test/vitest/vitest.process.config.ts"],
|
|
},
|
|
]) {
|
|
const ownerSelection = expectDefined(
|
|
createSelectedNodeTestShardBundles(ownerTargets, { runnerBackend }),
|
|
"precise multi-config owner selection",
|
|
);
|
|
if (ownerTargets.includes("src/gateway/setup-inference.first-signin.integration.test.ts")) {
|
|
expect(ownerSelection).toEqual([expect.objectContaining({ planConcurrency: 1 })]);
|
|
}
|
|
expect(
|
|
ownerSelection
|
|
.flatMap((job) => job.groups.flatMap((group) => group.includePatterns ?? []))
|
|
.toSorted(),
|
|
).toEqual(ownerTargets.toSorted());
|
|
expect(
|
|
ownerSelection.flatMap((job) => job.groups.flatMap((group) => group.configs)).toSorted(),
|
|
).toEqual(configs.toSorted());
|
|
for (const job of ownerSelection) {
|
|
for (const group of job.groups) {
|
|
const ownerJob = expectDefined(
|
|
full.find((candidate) =>
|
|
candidate.groups.some((owner) => owner.shard_name === group.shard_name),
|
|
),
|
|
`canonical job for ${group.shard_name}`,
|
|
);
|
|
const owner = expectDefined(
|
|
ownerJob.groups.find((candidate) => candidate.shard_name === group.shard_name),
|
|
`canonical group for ${group.shard_name}`,
|
|
);
|
|
expect(group.env).toEqual(owner.env);
|
|
expect(group.fallbackMaxWorkers).toBe(owner.fallbackMaxWorkers);
|
|
expect(group.minTotalMemoryBytes).toBe(owner.minTotalMemoryBytes);
|
|
expect(group.pretestBuildMode).toBe(owner.pretestBuildMode);
|
|
expect(job.env).toEqual(ownerJob.env);
|
|
expect(job.runner).toBe(ownerJob.runner);
|
|
expect(job.planConcurrency).toBe(ownerJob.planConcurrency);
|
|
expect(job.timeoutMinutes).toBe(ownerJob.timeoutMinutes);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["blacksmith", "github", "hybrid"])(
|
|
"retains the complete built TUI process owner for precise selections (%s)",
|
|
(runnerBackend) => {
|
|
const targets = ["src/tui/tui-pty-local.e2e.test.ts", "src/tui/tui-pty-harness.e2e.test.ts"];
|
|
const full = createNodeTestShardBundles({
|
|
compactMode: "pull-request",
|
|
runnerBackend,
|
|
includeReleaseOnlyPluginShards: false,
|
|
});
|
|
const job = expectDefined(
|
|
full.find((candidate) =>
|
|
candidate.groups.some((group) => group.shard_name === "core-runtime-tui-pty"),
|
|
),
|
|
"built TUI job",
|
|
);
|
|
const owner = expectDefined(
|
|
job.groups.find((group) => group.shard_name === "core-runtime-tui-pty"),
|
|
"built TUI group",
|
|
);
|
|
expect(owner).toMatchObject({
|
|
configs: ["test/vitest/vitest.tui-pty.config.ts"],
|
|
env: { OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1", OPENCLAW_TUI_PTY_USE_BUILT_CLI: "1" },
|
|
requiresDist: true,
|
|
});
|
|
expect(owner.includePatterns).toBeUndefined();
|
|
expect(createSelectedNodeTestShardBundles(targets, { runnerBackend })).toEqual([
|
|
{
|
|
...job,
|
|
checkName: `checks-node-changed-${job.shardName}`,
|
|
shardName: `changed-${job.shardName}`,
|
|
groups: [owner],
|
|
},
|
|
]);
|
|
const changed = expectDefined(
|
|
createChangedNodeTestShards(targets, { runnerBackend }),
|
|
"changed TUI plan",
|
|
);
|
|
expect(
|
|
changed
|
|
.flatMap((candidate) => candidate.groups ?? [])
|
|
.filter((group) => group.requiresDist),
|
|
).toEqual([owner]);
|
|
const changedJob = expectDefined(
|
|
changed.find((candidate) => candidate.requiresDist),
|
|
"changed TUI job",
|
|
);
|
|
for (const key of [
|
|
"env",
|
|
"runner",
|
|
"planConcurrency",
|
|
"pretestBuildMode",
|
|
"timeoutMinutes",
|
|
] as const) {
|
|
expect(changedJob[key], key).toEqual(job[key]);
|
|
}
|
|
expect(
|
|
fallbackGroups(changed.filter((candidate) => !candidate.requiresDist)).flatMap(
|
|
(group) => group.configs,
|
|
),
|
|
).not.toContain("test/vitest/vitest.tui-pty.config.ts");
|
|
},
|
|
);
|
|
|
|
it.each(["blacksmith", "github", "hybrid"])(
|
|
"keeps the complete Git tooling family in canonical serial owners (%s)",
|
|
(runnerBackend) => {
|
|
const changedPaths = ["test/scripts/ci-linux-git.test.ts"];
|
|
const expectedTargets = [...gitToolingTargets, "test/scripts/test-projects.test.ts"];
|
|
const shards = createChangedNodeTestShards(changedPaths, { runnerBackend });
|
|
expect(shards).not.toBeNull();
|
|
const groups = shards?.flatMap((shard) => shard.groups ?? []) ?? [];
|
|
const tooling = groups.filter((group) => !group.requiresDist);
|
|
expect(tooling.flatMap((group) => group.includePatterns ?? []).toSorted()).toEqual(
|
|
expectedTargets.toSorted(),
|
|
);
|
|
expectCanonicalGroupedConcurrency(shards, runnerBackend);
|
|
const full = createNodeTestShardBundles({
|
|
compactMode: "pull-request",
|
|
runnerBackend,
|
|
includeReleaseOnlyPluginShards: false,
|
|
changedPaths,
|
|
});
|
|
const canonical = full.flatMap((shard) => shard.groups);
|
|
for (const group of tooling) {
|
|
const owner = canonical.find((candidate) => candidate.shard_name === group.shard_name);
|
|
expect(owner).toBeDefined();
|
|
expect(group.configs).toEqual(["test/vitest/vitest.tooling.config.ts"]);
|
|
expect(group.env).toEqual({ ...owner?.env, OPENCLAW_VITEST_MAX_WORKERS: "2" });
|
|
expect(group.pretestBuildMode).toBeUndefined();
|
|
// Capacity for an excluded compiler fixture must not transfer to these files.
|
|
expect(group.runner).toBe("blacksmith-4vcpu-ubuntu-2404");
|
|
expect(group.timing_key).not.toBe(owner?.timing_key ?? owner?.shard_name);
|
|
expect(group.timing_key).toContain("#selector-");
|
|
expect(group.includePatterns?.every((file) => owner?.includePatterns?.includes(file))).toBe(
|
|
true,
|
|
);
|
|
}
|
|
expect(shards?.some((shard) => shard.requiresDist)).toBe(false);
|
|
expect(shards).toContainEqual(
|
|
expect.objectContaining({ configs: ["test/vitest/vitest.boundary.config.ts"] }),
|
|
);
|
|
for (const shard of shards?.filter((job) => job.groups) ?? []) {
|
|
const encodedGroups = (shard.groups ?? []).map(
|
|
({ configs, env, includePatterns, shard_name, timing_key }) => ({
|
|
configs,
|
|
env,
|
|
includePatterns,
|
|
shard_name,
|
|
timing_key,
|
|
}),
|
|
);
|
|
expect(
|
|
resolveShardPlans({
|
|
OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64: encodeNodeTestGroups(encodedGroups ?? []),
|
|
}),
|
|
).toEqual(
|
|
encodedGroups.map((plan: { shard_name: string; timing_key?: string }) => ({
|
|
kind: "group",
|
|
name: plan.shard_name,
|
|
plan,
|
|
timingKey: plan.timing_key ?? plan.shard_name,
|
|
})),
|
|
);
|
|
expect(shard.predictedSeconds).toBeGreaterThan(0);
|
|
if (runnerBackend === "blacksmith" && !shard.requiresDist) {
|
|
expect(shard.runner).toBe("blacksmith-32vcpu-ubuntu-2404");
|
|
}
|
|
}
|
|
expect(new Set((shards ?? []).flatMap(resolveTestGitCommits))).toEqual(
|
|
new Set(expectedTargets.flatMap((target) => resolveTestGitCommits({ targets: [target] }))),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("retains ordinary and embedded targets beside a shared Git fixture's canonical family", () => {
|
|
const ordinary = "src/plugin-sdk/plugin-config-runtime.test.ts";
|
|
const embedded =
|
|
"src/agents/embedded-agent-runner/run/attempt-transcript-helpers.presence.test.ts";
|
|
const shards = createChangedNodeTestShards([
|
|
"test/scripts/ci-git-owner.test-support.ts",
|
|
ordinary,
|
|
embedded,
|
|
]);
|
|
expect(shards).not.toBeNull();
|
|
expectProtectedOwnerExpansion(
|
|
shards,
|
|
[ordinary, "test/scripts/ci-git-prerequisites.test.ts", ...gitToolingTargets, embedded],
|
|
["scripts", "src/scripts", "test/scripts"],
|
|
);
|
|
expect(selectedFiles(shards)).not.toContain(
|
|
"extensions/acpx/src/runtime-advertised-model.process.test.ts",
|
|
);
|
|
expect(shards?.some((shard) => shard.requiresDist)).toBe(false);
|
|
expect(shards).toContainEqual(
|
|
expect.objectContaining({ configs: ["test/vitest/vitest.boundary.config.ts"] }),
|
|
);
|
|
for (const other of ["src/deleted.ts", "tsconfig.json"]) {
|
|
const mixed = createChangedNodeTestShards(["test/scripts/ci-linux-git.test.ts", other]);
|
|
expect(mixed).not.toBeNull();
|
|
expect(selectedFiles(mixed)).toContain("test/scripts/ci-linux-git.test.ts");
|
|
expect(selectedFiles(mixed)).not.toContain(
|
|
"extensions/acpx/src/runtime-advertised-model.process.test.ts",
|
|
);
|
|
}
|
|
});
|
|
|
|
it.each(
|
|
[
|
|
[],
|
|
["test/scripts/unknown-tooling.test.ts"],
|
|
["test/vitest/vitest.tooling.config.ts"],
|
|
["test/scripts/docker-build-helper.test.ts", "test/scripts/unknown-tooling.test.ts"],
|
|
].map((targets) => ({ targets })),
|
|
)("refuses incomplete or unsupported canonical selection $targets", ({ targets }) => {
|
|
expect(createSelectedNodeTestShardBundles(targets)).toBeNull();
|
|
});
|
|
|
|
it("refuses prepared plans belonging to another selected file", () => {
|
|
const target = "src/plugins/activation-planner.test.ts";
|
|
const preparedTestPlans = new Map([
|
|
[target, buildVitestRunPlans(["src/plugins/manifest-registry.test.ts"])],
|
|
]);
|
|
expect(createSelectedNodeTestShardBundles([target], { preparedTestPlans })).toBeNull();
|
|
});
|
|
|
|
it("does not borrow canonical embedded ownership for another checkout", () => {
|
|
const cwd = argvTempDirs.make("openclaw-embedded-owner-");
|
|
const target =
|
|
"src/agents/embedded-agent-runner/run/attempt-transcript-helpers.presence.test.ts";
|
|
|
|
mkdirSync(path.dirname(path.join(cwd, target)), { recursive: true });
|
|
writeFileSync(path.join(cwd, target), "export {};\n");
|
|
expect(buildVitestRunPlans([target], cwd)[0]?.includePatterns).toEqual([target]);
|
|
expect(createChangedNodeTestShards([target], { cwd })).toBeNull();
|
|
});
|
|
|
|
it("keeps more than 96 changed tests and a direct plugin test precise with canonical worker budgets", () => {
|
|
const cronTests = listGitTrackedFiles({ pathspecs: "src/cron" })
|
|
?.filter((file) => file.endsWith(".test.ts") && !/\.(?:e2e|live)\.test\.ts$/u.test(file))
|
|
.slice(0, 97);
|
|
expect(cronTests?.length).toBe(97);
|
|
const changedTests = [...(cronTests ?? []), "src/plugins/tools.optional.test.ts"];
|
|
const shards = createChangedNodeTestShards(changedTests);
|
|
expect(shards).not.toBeNull();
|
|
const targets = selectedFiles(shards);
|
|
expect(targets.toSorted()).toEqual(changedTests?.toSorted());
|
|
expect(new Set(targets).size).toBe(targets.length);
|
|
const full = createNodeTestShardBundles({
|
|
changedPaths: changedTests,
|
|
compactMode: "pull-request",
|
|
includeReleaseOnlyPluginShards: false,
|
|
});
|
|
const selectedJobs = shards?.filter((shard) => shard.groups?.length) ?? [];
|
|
expect(selectedJobs.length).toBeGreaterThan(0);
|
|
for (const job of selectedJobs) {
|
|
for (const group of job.groups ?? []) {
|
|
const ownerJob = expectDefined(
|
|
full.find((candidate) =>
|
|
candidate.groups.some((owner) => owner.shard_name === group.shard_name),
|
|
),
|
|
`canonical job for ${group.shard_name}`,
|
|
);
|
|
const owner = expectDefined(
|
|
ownerJob.groups.find((candidate) => candidate.shard_name === group.shard_name),
|
|
`canonical group for ${group.shard_name}`,
|
|
);
|
|
expect(group.configs).toEqual(owner.configs);
|
|
expect(group.env).toEqual(owner.env);
|
|
expect(group.fallbackMaxWorkers).toBe(owner.fallbackMaxWorkers);
|
|
expect(group.minTotalMemoryBytes).toBe(owner.minTotalMemoryBytes);
|
|
expect(job.env).toEqual(ownerJob.env);
|
|
expect(job.runner).toBe(ownerJob.runner);
|
|
expect(job.planConcurrency).toBe(ownerJob.planConcurrency);
|
|
}
|
|
}
|
|
const cheapFiles = vi
|
|
.spyOn(testTimings, "readRepoE2eFileTimings")
|
|
.mockReturnValue(Object.fromEntries(changedTests.map((file) => [file, 1])));
|
|
const measuredGroups = vi.spyOn(testTimings, "readCompactGroupTimings").mockReturnValue(
|
|
Object.fromEntries(
|
|
full.flatMap((job) =>
|
|
job.groups.flatMap((group) => [
|
|
[group.shard_name, 1],
|
|
[group.timing_key ?? group.shard_name, 1],
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
try {
|
|
const measured = createChangedNodeTestShards(changedTests);
|
|
expect(measured).not.toBeNull();
|
|
expect(selectedFiles(measured).toSorted()).toEqual(changedTests.toSorted());
|
|
expect(
|
|
measured?.every((job) => (job.predictedTestSeconds ?? job.predictedSeconds ?? 0) <= 150),
|
|
).toBe(true);
|
|
expect(
|
|
measured?.some((job) =>
|
|
job.groups?.some((group) => (group.includePatterns?.length ?? 0) > 12),
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
measuredGroups.mockRestore();
|
|
cheapFiles.mockRestore();
|
|
}
|
|
});
|
|
});
|