openclaw/scripts/lib/ci-test-runtime.mts
Peter Steinberger b06666e3ad
test(runtime): remove low-value tests (batch d110) (#161714)
* test(auto-reply): deslop t0258 tests

* test(config): deslop t0331 tests

* test(memory-core): deslop t0285 tests

* test(updater): deslop t0320 tests

* test(telegram): deslop t0333 tests

* test(gateway): deslop t0341 tests

* test(agents): deslop t0318 tests

* test(gateway): deslop t0345 tests

* test(gateway): deslop t0328 tests

* test(gateway): deslop t0337 tests

* test: retain unique safety contracts in reduction batch

* fix(ci): keep native compiler import proof on Node
2026-09-30 11:58:18 +00:00

402 lines
15 KiB
TypeScript

import { globSync } from "node:fs";
import { agentVitestProjectOwners } from "../../test/vitest/vitest.agents-paths.mjs";
import { databaseWorkerCoreTestFiles } from "../../test/vitest/vitest.database-worker-core-paths.mjs";
import {
matchesVitestCliSelection,
matchesVitestGlob,
relativizeScopedPatterns,
} from "../../test/vitest/vitest.pattern-file.ts";
import { controlUiE2eTestGlobs, controlUiTestGlobs } from "../../test/vitest/vitest.ui-paths.mjs";
import {
getUnitFastIsolatedTestFiles,
getUnitFastTestFiles,
getUnitFastTimerTestFiles,
} from "../../test/vitest/vitest.unit-fast-paths.mjs";
import { buildVitestRunPlans } from "../test-projects.test-support.mts";
import { vitestOptionConsumesNextArg } from "./vitest-cli-mode.mts";
export type CiTestRuntimePolicy = "node" | "bun-compatible" | "dual";
type TestRuntime = "node" | "bun";
type TestSelection = {
configs?: readonly string[];
targets?: readonly string[];
includePatterns?: readonly string[] | null;
env?: Record<string, unknown> | null;
vitestArgs?: readonly string[];
};
type TestShard = TestSelection & { groups?: readonly TestSelection[] };
export type CiTestRuntimeSelection = {
runtime: TestRuntime;
configs?: string[];
includePatterns?: string[];
includeAfterShard?: true;
env?: Readonly<Record<string, string>>;
};
// Short-lived UI workers spend less time compiling their top JIT tier when it
// starts later. Keep every tier enabled and share the producer/consumer policy.
export const BUN_UI_TEST_ENV = {
BUN_JSC_thresholdForFTLOptimizeAfterWarmUp: "512000",
BUN_JSC_thresholdForFTLOptimizeSoon: "8000",
// Avoid sweeping parked allocator threads between short UI update cycles.
MIMALLOC_PURGE_HOLES_MIN_INTERVAL: "1000",
} as const;
const gatewayCoreConfig = "test/vitest/vitest.gateway-core.config.ts";
const gatewayClientConfig = "test/vitest/vitest.gateway-client.config.ts";
const bunCompatibleConfigs = new Set([
"test/vitest/vitest.unit-fast-fake-timers.config.ts",
"test/vitest/vitest.extension-memory.config.ts",
gatewayClientConfig,
]);
// Measured whole-file admission; the rest of agents-support retains Node.
const bunCompatibleAgentSupportFiles = ["src/agents/worktrees/service.removal-recovery.test.ts"];
// TypeScript's synchronous native API uses Node child-process pipe handles.
// Keep these compiler assertions on Node, including those in mixed runtime suites.
const nativeCompilerTestFiles = [
"src/agents/agent-bundle-mcp-requester-connect.import-boundary.test.ts",
"src/agents/agent-model-discovery.imports.test.ts",
"src/agents/code-mode.auto-results.test.ts",
"src/agents/harness/native-hook-relay.imports.test.ts",
"src/auto-reply/reply/get-reply.imports.test.ts",
"src/cli/program/register.database.import-boundary.test.ts",
"src/plugin-sdk/provider-tools.test.ts",
"test/scripts/audit-control-ui-dead-css.test.ts",
"test/scripts/canvas-cli-import-closure.test.ts",
"test/scripts/check-session-accessor-boundary.test.ts",
"test/scripts/check-session-transcript-reader-boundary.test.ts",
"test/scripts/check-sqlite-transaction-boundary.test.ts",
"test/scripts/native-typescript.test.ts",
"test/scripts/nodes-cli-import-closure.test.ts",
"test/scripts/ts-topology.test.ts",
"test/scripts/typecheck-inert.test.ts",
"test/test-helper-extension-import-boundary.test.ts",
];
// Bun fork 3ff0efc82217775e04094a1d4402d7c6932ecb24 failed or added skips in these files.
// Keep every case on Node while the canonical inventories own all other membership.
const runtimePartitions = new Map<
string,
{
files: (cwd: string) => string[];
nodeRequired: ReadonlySet<string> | ((file: string) => boolean);
includeAfterShard?: true;
}
>([
[
"test/vitest/vitest.process.config.ts",
{
files: (cwd) =>
globSync("src/process/**/*.test.ts", { cwd, exclude: databaseWorkerCoreTestFiles })
.map((file) => file.replaceAll("\\", "/"))
.toSorted(),
// Only this native-Bun contract is qualified; process siblings retain Node.
nodeRequired: (file) => file !== "src/process/terminal-pty-bun.test.ts",
},
],
[
"test/vitest/vitest.unit-fast.config.ts",
{
files: unitFastFiles,
nodeRequired: new Set([
...nativeCompilerTestFiles,
"packages/markdown-core/src/render-aware-chunking.test.ts",
// Bun skips a sibling diagnostics subscriber when warm-worker cleanup unsubscribes.
"src/agents/code-mode-node.test.ts",
"src/cli/cli-process-diagnostics.test.ts",
// Native heap accounting, GC, and Worker limits require V8.
"src/infra/worker-task-pool.memory.test.ts",
"src/plugins/runtime.retention.test.ts",
"src/process/spawn-broker/callback-context.test.ts",
"src/process/spawn-broker/cleanup.test.ts",
"src/process/spawn-broker/handoff.test.ts",
"src/process/spawn-broker/proxy-retention.test.ts",
"src/process/spawn-broker/relay.test.ts",
"src/process/spawn-broker/startup.test.ts",
"src/process/spawn-broker/stdin-handoff.test.ts",
"src/process/spawn-broker/transports.test.ts",
// Preserve native Node process and SQLite lifecycle semantics for this benchmark.
"test/scripts/bench-session-history.test.ts",
"test/scripts/update-restart-module-outcome.test.ts",
]),
},
],
[
"test/vitest/vitest.unit-fast-isolated.config.ts",
{
files: () => getUnitFastIsolatedTestFiles(),
nodeRequired: new Set(nativeCompilerTestFiles),
},
],
[
"ui/vitest.config.ts",
{
files: (cwd) =>
globSync(controlUiTestGlobs, { cwd, exclude: controlUiE2eTestGlobs })
.map((file) => file.replaceAll("\\", "/"))
.toSorted(),
// Bun GC can retain released chat and overview payloads; keep their retention proof on Node.
nodeRequired: new Set([
"ui/src/pages/chat/chat-pane-retained-presentation.test.ts",
"ui/src/pages/chat/chat-thread.test.ts",
"ui/src/pages/usage/usage-page-details.test.ts",
]),
includeAfterShard: true,
},
],
]);
function partitionRequiresNode(
partition: { nodeRequired: ReadonlySet<string> | ((file: string) => boolean) },
file: string,
): boolean {
return typeof partition.nodeRequired === "function"
? partition.nodeRequired(file)
: partition.nodeRequired.has(file);
}
function unitFastFiles(): string[] {
const otherOwners = new Set([...getUnitFastTimerTestFiles(), ...getUnitFastIsolatedTestFiles()]);
return getUnitFastTestFiles().filter((file) => !otherOwners.has(file));
}
function selectionVitestArgs(selection: TestSelection): string[] | undefined {
let args: unknown = selection.vitestArgs;
if (!args) {
try {
const encoded = selection.env?.OPENCLAW_NODE_TEST_VITEST_ARGS_JSON;
args = typeof encoded === "string" && encoded.trim() ? JSON.parse(encoded) : [];
} catch {
return undefined;
}
}
return Array.isArray(args) && args.every((arg) => typeof arg === "string") ? args : undefined;
}
function supportsRuntimePartition(args: string[]): boolean {
// Native sharding, alternate roots/projects, filters and config overrides can
// change membership. Collection skips every body but preserves file imports.
return args.every(
(arg) =>
arg === "--testNamePattern=(?!)" ||
/^--(?:maxWorkers|testTimeout|hookTimeout)=\d+$/u.test(arg) ||
/^--exclude=[\w./-]+\.test\.[cm]?[jt]sx?$/u.test(arg),
);
}
function supportsUiRuntime(args: string[]): boolean {
for (let index = 0; index < args.length; index++) {
const arg = args[index]!;
const consumesNext = vitestOptionConsumesNextArg(arg, args[index + 1]);
const separator = arg.indexOf("=");
const option = separator < 0 ? arg : arg.slice(0, separator);
const value = consumesNext
? args[++index]
: separator < 0
? undefined
: arg.slice(separator + 1);
if (/^--(?:maxWorkers|testTimeout|hookTimeout)$/u.test(option) && /^\d+$/u.test(value ?? "")) {
continue;
}
if (option === "--shard" && /^[1-9]\d*\/[1-9]\d*$/u.test(value ?? "")) {
const [shard, count] = value!.split("/").map(Number);
if (shard! <= count!) {
continue;
}
}
if (
option === "--reporter" &&
["verbose", "github-actions", "./scripts/lib/vitest-resource-reporter.mts"].includes(
value ?? "",
)
) {
continue;
}
return false;
}
return true;
}
export function resolveCiTestRuntimePolicy(
env: NodeJS.ProcessEnv = process.env,
): CiTestRuntimePolicy {
const policy = env.OPENCLAW_CI_TEST_RUNTIME_POLICY?.trim() || "node";
if (policy !== "node" && policy !== "bun-compatible" && policy !== "dual") {
throw new Error(
`Invalid OPENCLAW_CI_TEST_RUNTIME_POLICY: ${policy}; expected node, bun-compatible, or dual`,
);
}
return policy;
}
export function resolveCiTestRuntimeSelections(
selection: TestSelection,
policy: CiTestRuntimePolicy,
cwd = process.cwd(),
): CiTestRuntimeSelection[] {
const node: CiTestRuntimeSelection[] = [{ runtime: "node" }];
const args = selectionVitestArgs(selection);
if (
policy === "node" ||
selection.env?.OPENCLAW_VITEST_INCLUDE_FILE ||
selection.env?.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE ||
!args
) {
return node;
}
const completeBun = (): CiTestRuntimeSelection[] =>
policy === "dual" ? [{ runtime: "node" }, { runtime: "bun" }] : [{ runtime: "bun" }];
const uiPartition =
selection.configs?.length === 1 &&
selection.configs[0] === "ui/vitest.config.ts" &&
!selection.targets?.length &&
supportsUiRuntime(args);
if (!uiPartition && !supportsRuntimePartition(args)) {
return node;
}
if (selection.targets?.length) {
// Preserve exact target argv and its native owner; broad targets can carry
// multiple process/filter contracts and stay on Node.
if (selection.targets.some((target) => !/^[\w./-]+\.test\.[cm]?[jt]sx?$/u.test(target))) {
return node;
}
const plans = selection.targets.flatMap((target) => buildVitestRunPlans([target], cwd));
if (!plans.length) {
return node;
}
if (plans.every((plan) => bunCompatibleConfigs.has(plan.config))) {
return completeBun();
}
if (
plans.every((plan) => plan.config === agentVitestProjectOwners.support.config) &&
selection.targets.every((file) => bunCompatibleAgentSupportFiles.includes(file))
) {
return completeBun();
}
const config = plans[0]!.config;
const partition = runtimePartitions.get(config);
if (
!partition ||
partition.includeAfterShard ||
!plans.every((plan) => plan.config === config)
) {
return node;
}
const files = new Set(partition.files(cwd));
return selection.targets.every(
(target) => files.has(target) && !partitionRequiresNode(partition, target),
)
? completeBun()
: node;
}
if (
selection.configs?.length === 2 &&
selection.configs[0] === gatewayCoreConfig &&
selection.configs[1] === gatewayClientConfig
) {
const parallelProjects = selection.env?.OPENCLAW_TEST_PROJECTS_PARALLEL;
if (typeof parallelProjects === "string" && parallelProjects.trim() !== "1") {
return node;
}
// These leaf configs already run sequentially and intersect the shared
// include envelope with their own inventories. Keep that ownership intact.
return [
...(policy === "dual" ? node : [{ runtime: "node" as const, configs: [gatewayCoreConfig] }]),
{ runtime: "bun", configs: [gatewayClientConfig] },
];
}
if (selection.configs?.length !== 1) {
return node;
}
const config = selection.configs[0]!;
if (bunCompatibleConfigs.has(config)) {
return completeBun();
}
if (config === agentVitestProjectOwners.support.config) {
const owner = agentVitestProjectOwners.support;
const includePatterns = selection.includePatterns?.length ? selection.includePatterns : null;
const qualifiedPatterns = relativizeScopedPatterns(bunCompatibleAgentSupportFiles, owner.dir);
if (
includePatterns &&
relativizeScopedPatterns(includePatterns, owner.dir).every((pattern) =>
qualifiedPatterns.includes(pattern),
)
) {
return completeBun();
}
const bunFiles =
policy === "dual"
? bunCompatibleAgentSupportFiles.filter((file) =>
matchesVitestCliSelection(file, owner.include, [], owner.dir, {}, includePatterns),
)
: [];
return bunFiles.length ? [...node, { runtime: "bun", includePatterns: bunFiles }] : node;
}
const partition = runtimePartitions.get(config);
if (!partition || (partition.includeAfterShard && !uiPartition)) {
return node;
}
const inventory = partition.files(cwd);
const requested = new Set(selection.includePatterns ?? []);
// Canonical file inventories should not reparse every file pair as a glob.
const exactFiles = selection.includePatterns?.every(
(pattern) => /^[\w./-]+$/u.test(pattern) && inventory.includes(pattern),
);
const files = inventory.filter(
(file) =>
!selection.includePatterns ||
(exactFiles
? requested.has(file)
: selection.includePatterns.some((pattern) => matchesVitestGlob(file, pattern))),
);
const bunFiles = files.filter((file) => !partitionRequiresNode(partition, file));
if (!bunFiles.length) {
return node;
}
const nodeFiles = files.filter((file) => partitionRequiresNode(partition, file));
return [
...(policy === "dual"
? node
: nodeFiles.length
? [
{
runtime: "node" as const,
includePatterns: nodeFiles,
...(partition.includeAfterShard ? { includeAfterShard: true as const } : {}),
},
]
: []),
{
runtime: "bun",
includePatterns: bunFiles,
...(partition.includeAfterShard ? { includeAfterShard: true } : {}),
...(config === "ui/vitest.config.ts" ? { env: BUN_UI_TEST_ENV } : {}),
},
];
}
/** Match the shard runner's original process envelopes without splitting or dropping coverage. */
export function ciTestShardRequiresBun(
shard: TestShard,
policy: CiTestRuntimePolicy,
cwd = process.cwd(),
): boolean {
const selections = shard.targets?.length
? shard.targets.map((target) => ({ ...shard, targets: [target] }))
: shard.groups?.length
? shard.groups.map((group) => ({
...group,
env: {
...shard.env,
...group.env,
// The runner replaces inherited project parallelism before applying group overrides.
OPENCLAW_TEST_PROJECTS_PARALLEL:
typeof group.env?.OPENCLAW_TEST_PROJECTS_PARALLEL === "string"
? group.env.OPENCLAW_TEST_PROJECTS_PARALLEL
: "1",
},
}))
: [shard];
return selections.some((selection) =>
resolveCiTestRuntimeSelections(selection, policy, cwd).some(({ runtime }) => runtime === "bun"),
);
}