mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
673 lines
26 KiB
TypeScript
673 lines
26 KiB
TypeScript
// Run Oxlint tests cover run oxlint script behavior.
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import { runWithFailedTrailer } from "../../scripts/lib/failed-trailer.mts";
|
|
import {
|
|
createOxlintShards,
|
|
filterOxlintShards,
|
|
parseShardRunnerArgs,
|
|
createWindowsExtensionShards,
|
|
resolveShardKillGraceMs,
|
|
resolveShardHeartbeatMs,
|
|
resolveShardTimeoutMs,
|
|
resolveOxlintShardConcurrency,
|
|
resolveWindowsExtensionChunkSize,
|
|
runShard,
|
|
shouldPrepareExtensionPackageBoundaryArtifactsForShards,
|
|
shouldRunOxlintShardsSerial,
|
|
} from "../../scripts/run-oxlint-shards.mts";
|
|
import {
|
|
filterSparseMissingOxlintTargets,
|
|
shouldPrepareExtensionPackageBoundaryArtifacts,
|
|
} from "../../scripts/run-oxlint.mts";
|
|
import { createScriptTestHarness } from "./test-helpers.js";
|
|
|
|
const { createTempDir } = createScriptTestHarness();
|
|
const CONSTRAINED_HOST = { totalMemoryBytes: 8 * 1024 ** 3, logicalCpuCount: 4 };
|
|
const ROOMY_HOST = { totalMemoryBytes: 64 * 1024 ** 3, logicalCpuCount: 16 };
|
|
const RUN_OXLINT_SHARDS_URL = pathToFileURL(
|
|
join(process.cwd(), "scripts/run-oxlint-shards.mts"),
|
|
).href;
|
|
type SignalScenario = "forward" | "group" | "ignore";
|
|
|
|
async function captureFailedTrailer(
|
|
run: () => Promise<void> | void,
|
|
): Promise<{ exitCode: number | undefined; lines: unknown[] }> {
|
|
const priorExitCode = process.exitCode;
|
|
const lines: unknown[] = [];
|
|
try {
|
|
process.exitCode = 0;
|
|
await runWithFailedTrailer("oxlint", run, (line: unknown) => lines.push(line));
|
|
return { exitCode: process.exitCode, lines };
|
|
} finally {
|
|
process.exitCode = priorExitCode;
|
|
}
|
|
}
|
|
|
|
function shouldSerializeShards(env: NodeJS.ProcessEnv, hostResources = CONSTRAINED_HOST): boolean {
|
|
return shouldRunOxlintShardsSerial({ env, platform: "linux", hostResources });
|
|
}
|
|
|
|
function resolveSplitCoreConcurrency(env: NodeJS.ProcessEnv, hostResources = ROOMY_HOST): number {
|
|
return resolveOxlintShardConcurrency({ env, platform: "linux", hostResources, splitCore: true });
|
|
}
|
|
|
|
function writeModule(target: string, lines: string[]): void {
|
|
writeFileSync(target, `${lines.join("\n")}\n`, "utf8");
|
|
}
|
|
|
|
function createSignalRunner(mode: SignalScenario, target: string): void {
|
|
if (mode === "group") {
|
|
const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
|
|
writeModule(target, [
|
|
"import { spawn } from 'node:child_process';",
|
|
"import { writeFileSync } from 'node:fs';",
|
|
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
|
|
"writeFileSync(process.env.CHILD_PID_PATH, String(child.pid)); writeFileSync(process.env.READY_FILE, String(process.pid));",
|
|
"process.on('SIGTERM', () => process.exit(0));",
|
|
"setInterval(() => {}, 1000);",
|
|
]);
|
|
return;
|
|
}
|
|
|
|
const markerEnv = mode === "forward" ? "SIGNALED_FILE" : "IGNORED_FILE";
|
|
writeModule(target, [
|
|
"import { writeFileSync } from 'node:fs';",
|
|
"process.on('SIGTERM', () => {",
|
|
` writeFileSync(process.env.${markerEnv}, 'SIGTERM');`,
|
|
...(mode === "forward" ? [" process.exit(0);"] : []),
|
|
"});",
|
|
"writeFileSync(process.env.READY_FILE, String(process.pid));",
|
|
"setInterval(() => {}, 1000);",
|
|
]);
|
|
}
|
|
|
|
function runParentTerminationScenario(mode: SignalScenario) {
|
|
const groupScenario = mode === "group";
|
|
const tempDir = createTempDir(
|
|
groupScenario ? "openclaw-oxlint-parent-group-" : "openclaw-oxlint-signal-",
|
|
);
|
|
const runner = join(tempDir, "signal-runner.mjs");
|
|
const harness = join(tempDir, "signal-harness.mjs");
|
|
const readyFile = join(tempDir, "ready");
|
|
const markerFile = groupScenario
|
|
? undefined
|
|
: join(tempDir, mode === "forward" ? "signaled" : "ignored");
|
|
const childPidPath = groupScenario ? join(tempDir, "child.pid") : undefined;
|
|
createSignalRunner(mode, runner);
|
|
|
|
// Execute cancellation in a subprocess because runShard installs process-level signal handlers.
|
|
writeModule(harness, [
|
|
"import { existsSync, readFileSync } from 'node:fs';",
|
|
`import { runShard } from ${JSON.stringify(RUN_OXLINT_SHARDS_URL)};`,
|
|
"const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const groupScenario = process.env.SCENARIO === 'group';",
|
|
"const waitFor = async (predicate) => { const attempts = groupScenario ? 500 : 100; const delay = groupScenario ? 5 : 10; for (let attempt = 0; attempt < attempts; attempt += 1) { if (predicate()) return true; await sleep(delay); } return false; };",
|
|
"const shardEnv = { ...process.env, OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS: '0', OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: '0' };",
|
|
"if (process.env.SCENARIO === 'ignore') shardEnv.OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS = '250';",
|
|
"if (groupScenario) shardEnv.OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS = '25';",
|
|
"const promise = runShard({ env: shardEnv, extraArgs: [], runner: process.env.RUNNER_FILE, shard: { name: groupScenario ? 'signal-group-test' : 'signal-test', args: [] } });",
|
|
"const waitPath = groupScenario ? process.env.CHILD_PID_PATH : process.env.READY_FILE;",
|
|
"if (!(await waitFor(() => existsSync(waitPath)))) process.exit(2);",
|
|
"const childPid = groupScenario ? Number(readFileSync(process.env.CHILD_PID_PATH, 'utf8')) : 0;",
|
|
"process.kill(process.pid, 'SIGTERM'); const status = await promise;",
|
|
"if (process.env.MARKER_FILE && !existsSync(process.env.MARKER_FILE)) process.exit(3);",
|
|
"if (groupScenario && !(await waitFor(() => { try { process.kill(childPid, 0); return false; } catch { return true; } }))) { process.kill(childPid, 'SIGKILL'); process.exit(5); }",
|
|
"process.exit(status === 143 ? 0 : 4);",
|
|
]);
|
|
|
|
const markerEnv = mode === "forward" ? "SIGNALED_FILE" : "IGNORED_FILE";
|
|
const scenarioEnv: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
CHILD_PID_PATH: childPidPath,
|
|
MARKER_FILE: markerFile,
|
|
READY_FILE: readyFile,
|
|
RUNNER_FILE: runner,
|
|
SCENARIO: mode,
|
|
...(markerFile ? { [markerEnv]: markerFile } : {}),
|
|
};
|
|
return spawnSync(process.execPath, [harness], {
|
|
encoding: "utf8",
|
|
env: scenarioEnv,
|
|
timeout: 5_000,
|
|
});
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
|
const deadlineAt = Date.now() + timeoutMs;
|
|
while (Date.now() < deadlineAt) {
|
|
if (predicate()) {
|
|
return;
|
|
}
|
|
await new Promise<void>((resolvePoll) => {
|
|
setTimeout(resolvePoll, 5);
|
|
});
|
|
}
|
|
throw new Error("condition was not met before timeout");
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function oxlintShard(name: string, config: string, ...targets: string[]) {
|
|
return { name, args: ["--tsconfig", `config/tsconfig/oxlint.${config}.json`, ...targets] };
|
|
}
|
|
|
|
describe("run-oxlint", () => {
|
|
it("ends a failing run with a stable final status line", async () => {
|
|
const { lines } = await captureFailedTrailer(() => {
|
|
process.exitCode = 2;
|
|
});
|
|
|
|
expect(lines).toEqual(["[oxlint] FAILED (exit 2)"]);
|
|
});
|
|
|
|
it("converts a wrapper crash into a nonzero exit with the status line last", async () => {
|
|
// The original incident: a crashed wrapper printed only a stack trace, and
|
|
// truncated output read as success. The marker must be the final line.
|
|
const { exitCode, lines } = await captureFailedTrailer(() => {
|
|
throw new Error("artifact prep failed");
|
|
});
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(lines).toHaveLength(2);
|
|
expect(lines[0]).toBeInstanceOf(Error);
|
|
expect(lines[1]).toBe("[oxlint] FAILED (exit 1)");
|
|
});
|
|
|
|
it("stays silent on a clean run", async () => {
|
|
const { lines } = await captureFailedTrailer(async () => {});
|
|
|
|
expect(lines).toEqual([]);
|
|
});
|
|
|
|
it("prepares extension package boundary artifacts for normal lint runs", () => {
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifacts([])).toBe(true);
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["src/index.ts"])).toBe(true);
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--type-aware"])).toBe(true);
|
|
expect(
|
|
shouldPrepareExtensionPackageBoundaryArtifacts([
|
|
"--tsconfig",
|
|
"config/tsconfig/oxlint.extensions.json",
|
|
"extensions/telegram/src/index.ts",
|
|
]),
|
|
).toBe(true);
|
|
expect(
|
|
shouldPrepareExtensionPackageBoundaryArtifacts([
|
|
"--tsconfig=config/tsconfig/oxlint.core.json",
|
|
"--tsconfig=config/tsconfig/oxlint.extensions.json",
|
|
]),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
["--tsconfig", "config/tsconfig/oxlint.core.json", "src/index.ts"],
|
|
["--tsconfig=config/tsconfig/oxlint.core.json", "src/index.ts"],
|
|
["--tsconfig", "config/tsconfig/oxlint.scripts.json", "scripts/check-changed.mts"],
|
|
])("skips extension artifacts for an exact source-backed config: %s", (...args) => {
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifacts(args)).toBe(false);
|
|
});
|
|
|
|
it("skips artifact preparation for metadata-only oxlint commands", () => {
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--help"])).toBe(false);
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--version"])).toBe(false);
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--print-config"])).toBe(false);
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifacts(["--rules"])).toBe(false);
|
|
});
|
|
|
|
it("prepares shard artifacts only when a selected config consumes them", () => {
|
|
const core = oxlintShard("core", "core", "src");
|
|
const scripts = oxlintShard("scripts", "scripts", "scripts");
|
|
const extensions = oxlintShard("extensions", "extensions", "extensions");
|
|
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifactsForShards([core, scripts])).toBe(false);
|
|
expect(shouldPrepareExtensionPackageBoundaryArtifactsForShards([core, extensions])).toBe(true);
|
|
});
|
|
|
|
it("does not run package-boundary artifact prep twice in pnpm check", () => {
|
|
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
|
|
scripts: Record<string, string>;
|
|
};
|
|
const shardedLintRunner = readFileSync("scripts/run-oxlint-shards.mts", "utf8");
|
|
|
|
expect(packageJson.scripts.check).toBe("node --import tsx scripts/check.mts");
|
|
expect(packageJson.scripts.lint).toBe("node --import tsx scripts/run-lint.mts");
|
|
expect(packageJson.scripts["lint:core"]).toBe(
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=core --split-core",
|
|
);
|
|
expect(packageJson.scripts.check).not.toContain(
|
|
"node --import tsx scripts/prepare-extension-package-boundary-artifacts.mts",
|
|
);
|
|
expect(shardedLintRunner).toContain("prepare-extension-package-boundary-artifacts.mts");
|
|
expect(shardedLintRunner).toContain('OPENCLAW_OXLINT_SKIP_PREPARE: "1"');
|
|
});
|
|
|
|
it("prepares the worktree toolchain before the complete lint pre-step", () => {
|
|
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
|
|
scripts: Record<string, string>;
|
|
};
|
|
const lintRunner = readFileSync("scripts/run-lint.mts", "utf8");
|
|
|
|
expect(packageJson.scripts.lint).toBe("node --import tsx scripts/run-lint.mts");
|
|
expect(lintRunner.indexOf("ensureRepoToolNodeModulesLink(")).toBeGreaterThan(-1);
|
|
expect(
|
|
lintRunner.indexOf('path.resolve("scripts", "control-ui-i18n-verify.ts")'),
|
|
).toBeGreaterThan(lintRunner.indexOf("ensureRepoToolNodeModulesLink("));
|
|
expect(lintRunner.indexOf('path.resolve("scripts", "run-oxlint-shards.mts")')).toBeGreaterThan(
|
|
lintRunner.indexOf('path.resolve("scripts", "control-ui-i18n-verify.ts")'),
|
|
);
|
|
});
|
|
|
|
it("holds one parent heavy-check lock for sharded lint runs", () => {
|
|
const shardedLintRunner = readFileSync("scripts/run-oxlint-shards.mts", "utf8");
|
|
const skipLockIndex = shardedLintRunner.indexOf('env.OPENCLAW_OXLINT_SKIP_LOCK === "1"');
|
|
const lockIndex = shardedLintRunner.indexOf("acquireLocalHeavyCheckLockSync({");
|
|
const childSkipIndex = shardedLintRunner.indexOf('OPENCLAW_OXLINT_SKIP_LOCK: "1"');
|
|
|
|
expect(shardedLintRunner).toContain("resolveLocalHeavyCheckEnv");
|
|
expect(shardedLintRunner).toContain("shouldAcquireLocalHeavyCheckLockForOxlint");
|
|
expect(skipLockIndex).toBeGreaterThan(-1);
|
|
expect(lockIndex).toBeGreaterThan(-1);
|
|
expect(lockIndex).toBeGreaterThan(skipLockIndex);
|
|
expect(childSkipIndex).toBeGreaterThan(lockIndex);
|
|
});
|
|
|
|
it("serializes broad oxlint shards on constrained local hosts", () => {
|
|
expect(shouldSerializeShards({})).toBe(true);
|
|
});
|
|
|
|
it("serializes broad oxlint shards on constrained CI hosts", () => {
|
|
expect(shouldSerializeShards({ CI: "true" })).toBe(true);
|
|
expect(shouldSerializeShards({ CI: "true", OPENCLAW_LOCAL_CHECK_MODE: "throttled" })).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("keeps oxlint shards parallel on dedicated CI runner classes", () => {
|
|
// Blacksmith's 16 vCPU class carries 32GB; the local-Mac 48GB threshold
|
|
// must not force CI serial (measured: serial shards cost 89s vs ~47s).
|
|
expect(
|
|
shouldSerializeShards(
|
|
{ CI: "true" },
|
|
{ totalMemoryBytes: 32 * 1024 ** 3, logicalCpuCount: 16 },
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
shouldSerializeShards(
|
|
{ CI: "true" },
|
|
{ totalMemoryBytes: 16 * 1024 ** 3, logicalCpuCount: 8 },
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps oxlint shards parallel for roomy CI and explicit full-speed runs", () => {
|
|
expect(shouldSerializeShards({ CI: "true" }, ROOMY_HOST)).toBe(false);
|
|
expect(shouldSerializeShards({ OPENCLAW_LOCAL_CHECK_MODE: "full" })).toBe(false);
|
|
});
|
|
|
|
it("honors explicit oxlint shard serial overrides", () => {
|
|
expect(
|
|
shouldSerializeShards({ OPENCLAW_OXLINT_SHARDS_SERIAL: "1", CI: "true" }, ROOMY_HOST),
|
|
).toBe(true);
|
|
expect(shouldSerializeShards({ OPENCLAW_OXLINT_SHARDS_SERIAL: "0" }, ROOMY_HOST)).toBe(false);
|
|
});
|
|
|
|
it("bounds split-core shard parallelism on roomy CI hosts", () => {
|
|
expect(resolveSplitCoreConcurrency({ CI: "true" })).toBe(4);
|
|
});
|
|
|
|
it("keeps split-core shard runs serial on constrained hosts", () => {
|
|
expect(resolveSplitCoreConcurrency({ CI: "true" }, CONSTRAINED_HOST)).toBe(1);
|
|
});
|
|
|
|
it("does not let local throttled mode serialize remote changed gates", () => {
|
|
expect(
|
|
resolveSplitCoreConcurrency({
|
|
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD: "1",
|
|
OPENCLAW_LOCAL_CHECK_MODE: "throttled",
|
|
}),
|
|
).toBe(4);
|
|
});
|
|
|
|
it("honors explicit oxlint shard concurrency overrides", () => {
|
|
expect(
|
|
resolveSplitCoreConcurrency({ CI: "true", OPENCLAW_OXLINT_SHARD_CONCURRENCY: "2" }),
|
|
).toBe(2);
|
|
|
|
expect(() =>
|
|
resolveSplitCoreConcurrency({
|
|
CI: "true",
|
|
OPENCLAW_OXLINT_SHARD_CONCURRENCY: "2x",
|
|
}),
|
|
).toThrow("OPENCLAW_OXLINT_SHARD_CONCURRENCY must be a positive integer; got: 2x");
|
|
});
|
|
|
|
it("uses a bounded oxlint shard heartbeat by default", () => {
|
|
expect(resolveShardHeartbeatMs({})).toBe(30_000);
|
|
expect(resolveShardHeartbeatMs({ OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS: "0" })).toBe(0);
|
|
expect(resolveShardHeartbeatMs({ OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS: "5000" })).toBe(5000);
|
|
expect(() => resolveShardHeartbeatMs({ OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS: "5000ms" })).toThrow(
|
|
"OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS must be a non-negative integer; got: 5000ms",
|
|
);
|
|
});
|
|
|
|
it("uses a bounded oxlint shard timeout by default", () => {
|
|
expect(resolveShardTimeoutMs({})).toBe(900_000);
|
|
expect(resolveShardTimeoutMs({ OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: "0" })).toBe(0);
|
|
expect(resolveShardTimeoutMs({ OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: "5000" })).toBe(5000);
|
|
expect(() => resolveShardTimeoutMs({ OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: "1e3" })).toThrow(
|
|
"OPENCLAW_OXLINT_SHARD_TIMEOUT_MS must be a non-negative integer; got: 1e3",
|
|
);
|
|
expect(resolveShardKillGraceMs({})).toBe(5_000);
|
|
expect(resolveShardKillGraceMs({ OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS: "0" })).toBe(0);
|
|
expect(() => resolveShardKillGraceMs({ OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS: "-1" })).toThrow(
|
|
"OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS must be a non-negative integer; got: -1",
|
|
);
|
|
});
|
|
|
|
it("fails a stuck oxlint shard instead of waiting forever", async () => {
|
|
const tempDir = createTempDir("openclaw-oxlint-shard-");
|
|
const runner = join(tempDir, "hang-runner.mjs");
|
|
writeFileSync(runner, "setInterval(() => {}, 1000);\n", "utf8");
|
|
|
|
const status = await runShard({
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS: "0",
|
|
OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: "25",
|
|
OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS: "25",
|
|
},
|
|
extraArgs: [],
|
|
runner,
|
|
shard: { name: "timeout-test", args: [] },
|
|
});
|
|
|
|
expect(status).toBe(124);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"kills timed-out shard process groups when the leader exits first",
|
|
async () => {
|
|
const tempDir = createTempDir("openclaw-oxlint-timeout-group-");
|
|
const runner = join(tempDir, "timeout-runner.mjs");
|
|
const childPidPath = join(tempDir, "child.pid");
|
|
let childPid = 0;
|
|
const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
|
|
try {
|
|
writeModule(runner, [
|
|
"import { spawn } from 'node:child_process';",
|
|
"import { writeFileSync } from 'node:fs';",
|
|
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
|
|
"writeFileSync(process.env.CHILD_PID_PATH, String(child.pid));",
|
|
"process.on('SIGTERM', () => process.exit(0));",
|
|
"setInterval(() => {}, 1000);",
|
|
]);
|
|
|
|
const command = runShard({
|
|
env: {
|
|
...process.env,
|
|
CHILD_PID_PATH: childPidPath,
|
|
OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS: "0",
|
|
OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS: "25",
|
|
OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: "250",
|
|
},
|
|
extraArgs: [],
|
|
runner,
|
|
shard: { name: "timeout-group-test", args: [] },
|
|
});
|
|
|
|
await waitFor(() => existsSync(childPidPath), 15_000);
|
|
childPid = Number(readFileSync(childPidPath, "utf8"));
|
|
expect(isProcessAlive(childPid)).toBe(true);
|
|
|
|
await expect(command).resolves.toBe(124);
|
|
await waitFor(() => !isProcessAlive(childPid), 15_000);
|
|
} finally {
|
|
if (childPid && isProcessAlive(childPid)) {
|
|
process.kill(childPid, "SIGKILL");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"forwards parent termination to detached oxlint shard processes",
|
|
() => {
|
|
const result = runParentTerminationScenario("forward");
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.signal).toBeNull();
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"force kills detached shard processes that ignore parent termination",
|
|
() => {
|
|
const result = runParentTerminationScenario("ignore");
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.signal).toBeNull();
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"kills parent-terminated shard process groups when the leader exits first",
|
|
() => {
|
|
const result = runParentTerminationScenario("group");
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.signal).toBeNull();
|
|
},
|
|
);
|
|
|
|
it("chunks extension oxlint shards on Windows", () => {
|
|
const shards = createOxlintShards({
|
|
cwd: "/repo",
|
|
env: {
|
|
OPENCLAW_OXLINT_WINDOWS_EXTENSION_CHUNK_SIZE: "2",
|
|
},
|
|
platform: "win32",
|
|
readDir: () =>
|
|
[
|
|
{ name: "zeta", isDirectory: () => true, isFile: () => false },
|
|
{ name: "ignored.txt", isDirectory: () => false, isFile: () => true },
|
|
{ name: "root.live.test.ts", isDirectory: () => false, isFile: () => true },
|
|
{ name: "notes.md", isDirectory: () => false, isFile: () => true },
|
|
{ name: "alpha", isDirectory: () => true, isFile: () => false },
|
|
{ name: "beta", isDirectory: () => true, isFile: () => false },
|
|
] as never,
|
|
});
|
|
|
|
expect(shards).toEqual([
|
|
oxlintShard("core", "core", "src", "ui", "packages"),
|
|
oxlintShard("extensions:root", "extensions", "extensions/root.live.test.ts"),
|
|
oxlintShard("extensions:01", "extensions", "extensions/alpha", "extensions/beta"),
|
|
oxlintShard("extensions:02", "extensions", "extensions/zeta"),
|
|
oxlintShard("scripts", "scripts", "scripts"),
|
|
]);
|
|
});
|
|
|
|
it("splits core oxlint shards when requested", () => {
|
|
const shards = createOxlintShards({
|
|
cwd: "/repo",
|
|
splitCore: true,
|
|
readDir: (target: string) => {
|
|
if (target.endsWith("/src")) {
|
|
return [
|
|
{ name: "zeta.ts", isDirectory: () => false, isFile: () => true },
|
|
{ name: "omega.ts", isDirectory: () => false, isFile: () => true },
|
|
{ name: "notes.md", isDirectory: () => false, isFile: () => true },
|
|
{ name: "alpha", isDirectory: () => true, isFile: () => false },
|
|
] as never;
|
|
}
|
|
return [];
|
|
},
|
|
});
|
|
|
|
expect(shards.slice(0, 4)).toEqual([
|
|
oxlintShard("core:src:alpha", "core", "src/alpha"),
|
|
oxlintShard("core:src:root", "core", "src/omega.ts", "src/zeta.ts"),
|
|
oxlintShard("core:ui", "core", "ui"),
|
|
oxlintShard("core:packages", "core", "packages"),
|
|
]);
|
|
});
|
|
|
|
it("parses shard runner flags without forwarding them to oxlint", () => {
|
|
const parsed = parseShardRunnerArgs(["--only=core", "--split-core", "--max-warnings", "0"]);
|
|
|
|
expect([...parsed.only]).toEqual(["core"]);
|
|
expect(parsed.splitCore).toBe(true);
|
|
expect(parsed.oxlintArgs).toEqual(["--max-warnings", "0"]);
|
|
});
|
|
|
|
it.each([["--only"], ["--only", "--split-core"], ["--only="], ["--only=-h"]])(
|
|
"rejects shard selectors without a name: %s",
|
|
(...args) => {
|
|
expect(() => parseShardRunnerArgs(args)).toThrow("--only requires a shard name");
|
|
},
|
|
);
|
|
|
|
it("filters split core shards by shard family", () => {
|
|
const shards = filterOxlintShards(
|
|
createOxlintShards({
|
|
cwd: "/repo",
|
|
splitCore: true,
|
|
readDir: () => [{ name: "alpha", isDirectory: () => true, isFile: () => false }] as never,
|
|
}),
|
|
new Set(["core"]),
|
|
);
|
|
|
|
expect(shards.map((shard) => shard.name)).toEqual([
|
|
"core:src:alpha",
|
|
"core:ui",
|
|
"core:packages",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{ selectors: ["wat"], message: "Unknown oxlint shard selector: wat" },
|
|
{
|
|
selectors: ["core", "wat"],
|
|
message: "Unknown oxlint shard selector: wat",
|
|
},
|
|
])("rejects unmatched shard selectors: $selectors", ({ selectors, message }) => {
|
|
expect(() =>
|
|
filterOxlintShards(createOxlintShards({ cwd: "/repo" }), new Set(selectors)),
|
|
).toThrow(message);
|
|
});
|
|
|
|
it.each([
|
|
["--only"],
|
|
["--only", "--split-core"],
|
|
["--only="],
|
|
["--only=-h"],
|
|
["--only=wat"],
|
|
["--only=core", "--only=wat"],
|
|
])("rejects invalid shard CLI input before starting work: %s", (...args) => {
|
|
const tempDir = createTempDir("openclaw-oxlint-selector-");
|
|
const result = spawnSync(process.execPath, [RUN_OXLINT_SHARDS_URL, ...args], {
|
|
cwd: tempDir,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE: "worktree",
|
|
OPENCLAW_LOCAL_CHECK: "1",
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).not.toContain("[oxlint:");
|
|
expect(existsSync(join(tempDir, ".artifacts/openclaw-local-checks/heavy-check.lock"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("falls back to the full extension shard when Windows extension dirs are unavailable", () => {
|
|
const shards = createWindowsExtensionShards({
|
|
cwd: "/repo",
|
|
readDir: () => {
|
|
throw new Error("missing extensions");
|
|
},
|
|
});
|
|
|
|
expect(shards).toEqual([oxlintShard("extensions", "extensions", "extensions")]);
|
|
});
|
|
|
|
it("rejects invalid Windows oxlint extension chunk size overrides", () => {
|
|
expect(resolveWindowsExtensionChunkSize({})).toBe(8);
|
|
expect(() =>
|
|
resolveWindowsExtensionChunkSize({ OPENCLAW_OXLINT_WINDOWS_EXTENSION_CHUNK_SIZE: "0" }),
|
|
).toThrow("OPENCLAW_OXLINT_WINDOWS_EXTENSION_CHUNK_SIZE must be a positive integer; got: 0");
|
|
expect(() =>
|
|
resolveWindowsExtensionChunkSize({
|
|
OPENCLAW_OXLINT_WINDOWS_EXTENSION_CHUNK_SIZE: "8 chunks",
|
|
}),
|
|
).toThrow(
|
|
"OPENCLAW_OXLINT_WINDOWS_EXTENSION_CHUNK_SIZE must be a positive integer; got: 8 chunks",
|
|
);
|
|
});
|
|
|
|
it("filters tracked targets missing from sparse checkouts", () => {
|
|
const result = filterSparseMissingOxlintTargets(
|
|
["--tsconfig", "config/tsconfig/oxlint.core.json", "src", "ui", "packages", "--threads=1"],
|
|
{
|
|
fileExists: (target: string) => target.endsWith("/src"),
|
|
isSparseCheckoutEnabled: () => true,
|
|
isTrackedPath: ({ target }: { target: string }) => target === "ui" || target === "packages",
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
args: ["--tsconfig", "config/tsconfig/oxlint.core.json", "src", "--threads=1"],
|
|
hadExplicitTargets: true,
|
|
remainingExplicitTargets: 1,
|
|
skippedTargets: ["ui", "packages"],
|
|
skippedConfigs: [],
|
|
});
|
|
});
|
|
|
|
it("filters tracked tsconfig files missing from sparse checkouts", () => {
|
|
const result = filterSparseMissingOxlintTargets(
|
|
["--tsconfig", "config/tsconfig/oxlint.core.json", "src"],
|
|
{
|
|
fileExists: (target: string) => target.endsWith("/src"),
|
|
isSparseCheckoutEnabled: () => true,
|
|
isTrackedPath: ({ target }: { target: string }) =>
|
|
target === "config/tsconfig/oxlint.core.json",
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
args: ["src"],
|
|
hadExplicitTargets: true,
|
|
remainingExplicitTargets: 1,
|
|
skippedTargets: [],
|
|
skippedConfigs: ["config/tsconfig/oxlint.core.json"],
|
|
});
|
|
});
|
|
|
|
it("keeps missing untracked oxlint targets so typos still fail", () => {
|
|
const result = filterSparseMissingOxlintTargets(["src", "typo"], {
|
|
fileExists: (target: string) => target.endsWith("/src"),
|
|
isSparseCheckoutEnabled: () => true,
|
|
isTrackedPath: () => false,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
args: ["src", "typo"],
|
|
hadExplicitTargets: true,
|
|
remainingExplicitTargets: 2,
|
|
skippedTargets: [],
|
|
skippedConfigs: [],
|
|
});
|
|
});
|
|
});
|