mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* fix(ci): reuse JavaScript caches across test layouts and runtimes * ci: report memory and pressure around test shards Record Node memory estimates and Linux host pressure before shard execution and after worker cleanup so cache and replay timings retain resource context. Keep scheduling, cache policy, test inventory and deadlines unchanged. The original CLI custody timeout remains unexplained after exact-source native replay and bounded phase traces. These diagnostics do not claim a fix. Existing shard/warmer suites pass 121 cases in 6.85 seconds; scripts types, typed lint, docs sanity and independent P2 review pass.
282 lines
9.9 KiB
TypeScript
282 lines
9.9 KiB
TypeScript
import { globSync } from "node:fs";
|
|
import { matchesVitestGlob } 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;
|
|
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",
|
|
} as const;
|
|
|
|
const bunCompatibleConfigs = new Set(["test/vitest/vitest.unit-fast-fake-timers.config.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>; includeAfterShard?: true }
|
|
>([
|
|
[
|
|
"test/vitest/vitest.unit-fast.config.ts",
|
|
{
|
|
files: unitFastFiles,
|
|
nodeRequired: new Set([
|
|
"packages/markdown-core/src/render-aware-chunking.test.ts",
|
|
"src/agents/sandbox/docker.execDockerRaw.enoent.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/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(["src/proxy-capture/proxy-server.test.ts"]),
|
|
},
|
|
],
|
|
[
|
|
"ui/vitest.config.ts",
|
|
{
|
|
files: (cwd) =>
|
|
globSync(controlUiTestGlobs, { cwd, exclude: controlUiE2eTestGlobs })
|
|
.map((file) => file.replaceAll("\\", "/"))
|
|
.toSorted(),
|
|
// These whole files retain their GC assertions on Node; Bun runs every other UI file.
|
|
nodeRequired: new Set([
|
|
"ui/src/pages/chat/chat-pane-retained-presentation.test.ts",
|
|
"ui/src/pages/usage/usage-page-details.test.ts",
|
|
]),
|
|
includeAfterShard: true,
|
|
},
|
|
],
|
|
]);
|
|
|
|
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),
|
|
);
|
|
}
|
|
|
|
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();
|
|
}
|
|
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) && !partition.nodeRequired.has(target),
|
|
)
|
|
? completeBun()
|
|
: node;
|
|
}
|
|
if (selection.configs?.length !== 1) {
|
|
return node;
|
|
}
|
|
const config = selection.configs[0]!;
|
|
if (bunCompatibleConfigs.has(config)) {
|
|
return completeBun();
|
|
}
|
|
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) => !partition.nodeRequired.has(file));
|
|
if (!bunFiles.length) {
|
|
return node;
|
|
}
|
|
const nodeFiles = files.filter((file) => partition.nodeRequired.has(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 } }))
|
|
: [shard];
|
|
return selections.some((selection) =>
|
|
resolveCiTestRuntimeSelections(selection, policy, cwd).some(({ runtime }) => runtime === "bun"),
|
|
);
|
|
}
|