mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 14:43:16 -06:00
fa03d9b913
* refactor: consolidate coercion helpers * fix: remove duplicate coercion imports * fix: preserve serialized coercion guard * chore: ratchet coercion helper carve-outs * fix(test): keep gauntlet subprocess startup lean * fix: preserve imported session timestamp semantics * fix: preserve catalog timestamp string semantics * chore: align plugin SDK surface ratchet * fix: preserve trajectory and SDK string contracts * fix(test): preserve QA record assertion semantics * fix: complete standalone record guard rename * refactor(cron): use canonical string coercion * fix(acpx): preserve Pi timestamp parsing * test(channels): adapt custody test harnesses * test(telegram): classify media harness as test support * test(acpx): split timestamp contract coverage * test(channels): support generated custody contracts * chore: ban the full coercion helper name set Extends the declaration guard to all eleven consolidated helper names and renames the cron schedule-identity readNumber wrapper to readScheduleInteger so the banned generic name cannot regrow. * fix(scripts): repair release-validation guard drift and lint cause Restores the renamed isJsonRecord guard in assertTrustedWorkflowHarness after main added isRecord call sites in parallel, and attaches the caught YAML error as the thrown error cause (preserve-caught-error was red on main). * fix: preserve Claude timestamp string semantics * fix: preserve persisted timestamp string semantics * fix: preserve date-first timestamp contracts * fix(openai): harden delegation failure formatting * chore: close coercion helper guard gaps * test(openai): model non-error delegation rejection * chore: refresh plugin SDK API contract * fix(tasks): use canonical string field reader * fix(ai): use canonical provider error field coercion * fix(browser): migrate native bootstrap coercion * docs(plugin-sdk): clarify text record export compatibility * fix(gateway): normalize approval execution identity * test(outbound): isolate message action poll harness
2451 lines
88 KiB
TypeScript
2451 lines
88 KiB
TypeScript
// Changed Lanes tests cover changed lanes script behavior.
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
createEmptyChangedLanes,
|
|
detectChangedLanes,
|
|
hasDeadcodeScannedSource,
|
|
isChangedLaneTestPath,
|
|
isLiveDockerPackageScriptOnlyChange,
|
|
isPackageScriptOnlyChange,
|
|
listChangedPathsFromGit,
|
|
listStagedChangedPaths,
|
|
} from "../../scripts/changed-lanes.mts";
|
|
import {
|
|
buildChangedCheckCrabboxArgs,
|
|
changedCheckLocalDependenciesReady,
|
|
changedCheckRequiresRemote,
|
|
cleanupCorepackPnpmShimDir,
|
|
createChangedCheckChildEnv,
|
|
createChangedCheckPlan,
|
|
createPnpmManagedCommand,
|
|
createTargetedCoreLintCommand,
|
|
createTargetedExtensionLintCommand,
|
|
createTargetedScriptLintCommand,
|
|
shouldDelegateChangedCheckToCrabbox,
|
|
shouldRunAppcastOwnerTest,
|
|
shouldRunCanvasA2uiNativeResourceCheck,
|
|
shouldRunControlUiI18nVerify,
|
|
shouldRunPromptSnapshotCheck,
|
|
shouldRunPromptSnapshotOwnerTest,
|
|
shouldRunDoctorContractOwnerTests,
|
|
shouldRunRuntimeSidecarBaselineCheck,
|
|
shouldRunNpmLockGuard,
|
|
shouldRunPluginSdkApiBaselineCheck,
|
|
shouldRunDeprecationHygieneChecks,
|
|
shouldRunPluginSdkSurfaceChecks,
|
|
shouldRunSqliteSessionSchemaBaselineCheck,
|
|
shouldRunTestTempCreationReport,
|
|
shouldRunWrapperShadowingCheck,
|
|
createNpmLockGuardCommand,
|
|
delegationFailedBeforeRunning,
|
|
} from "../../scripts/check-changed.mts";
|
|
import { resolveOxfmtInvocation } from "../../scripts/format-docs.mts";
|
|
import { isDirectRunPath } from "../../scripts/lib/direct-run.mjs";
|
|
import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
const repoRoot = process.cwd();
|
|
const tsxImport = pathToFileURL(createRequire(import.meta.url).resolve("tsx")).href;
|
|
type ExecFileSyncFailure = Error & { status?: number | null; stderr?: Buffer };
|
|
const nestedGitEnvKeys = [
|
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
"GIT_DIR",
|
|
"GIT_INDEX_FILE",
|
|
"GIT_OBJECT_DIRECTORY",
|
|
"GIT_QUARANTINE_PATH",
|
|
"GIT_WORK_TREE",
|
|
] as const;
|
|
|
|
function createNestedGitEnv(): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
};
|
|
for (const key of nestedGitEnvKeys) {
|
|
delete env[key];
|
|
}
|
|
return env;
|
|
}
|
|
|
|
const git = (cwd: string, args: string[]) =>
|
|
execFileSync("git", args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
env: createNestedGitEnv(),
|
|
}).trim();
|
|
|
|
function commitAll(cwd: string, message: string): void {
|
|
git(cwd, ["add", "."]);
|
|
git(cwd, [
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"user.name=Test User",
|
|
"commit",
|
|
"-q",
|
|
"-m",
|
|
message,
|
|
]);
|
|
}
|
|
|
|
function expectLanes(
|
|
lanes: ReturnType<typeof createEmptyChangedLanes>,
|
|
expected: Partial<ReturnType<typeof createEmptyChangedLanes>>,
|
|
) {
|
|
expect(lanes).toEqual({ ...createEmptyChangedLanes(), ...expected });
|
|
}
|
|
|
|
function parseChangedLaneOutput(output: string): {
|
|
paths: string[];
|
|
lanes: ReturnType<typeof createEmptyChangedLanes>;
|
|
} {
|
|
return JSON.parse(output) as {
|
|
paths: string[];
|
|
lanes: ReturnType<typeof createEmptyChangedLanes>;
|
|
};
|
|
}
|
|
|
|
function runChangedLanesCli(cwd: string, args: string[]) {
|
|
return parseChangedLaneOutput(
|
|
execFileSync(process.execPath, [path.join(repoRoot, "scripts", "changed-lanes.mjs"), ...args], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
env: createNestedGitEnv(),
|
|
}),
|
|
);
|
|
}
|
|
|
|
function runRepoScript(script: string, args: string[], env = createNestedGitEnv()) {
|
|
const nodeArgs = script.endsWith(".mts")
|
|
? ["--import", "tsx", script, ...args]
|
|
: [script, ...args];
|
|
return spawnSync(process.execPath, nodeArgs, {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
env,
|
|
});
|
|
}
|
|
|
|
function writeRepoFile(repoDir: string, filePath: string, contents: string): void {
|
|
const absolutePath = path.join(repoDir, filePath);
|
|
mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
writeFileSync(absolutePath, contents, "utf8");
|
|
}
|
|
|
|
const prettyJson = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
|
|
|
// Executes the exact "format changed files" plan command with the repo-pinned oxfmt,
|
|
// reconstructing `pnpm format:check <plan args>`. Guards the runtime verdict, not just
|
|
// plan construction: a misformatted added file must fail, deleted paths must not.
|
|
function runChangedFormatLaneWithRepoOxfmt(cwd: string, changedPaths: string[]) {
|
|
const plan = createChangedCheckPlan(detectChangedLanes(changedPaths));
|
|
const formatCommand = plan.commands.find((command) => command.name === "format changed files");
|
|
expect(formatCommand?.args[0]).toBe("format:check");
|
|
const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as {
|
|
scripts: Record<string, string>;
|
|
};
|
|
const formatScript = expectDefined(
|
|
packageJson.scripts["format:check"],
|
|
"format:check package script",
|
|
);
|
|
const [rawScriptBin, ...scriptArgs] = formatScript.split(" ");
|
|
const scriptBin = expectDefined(rawScriptBin, "format:check script binary");
|
|
expect(scriptBin).toBe("oxfmt");
|
|
const invocation = resolveOxfmtInvocation(
|
|
[...scriptArgs, ...(formatCommand?.args.slice(1) ?? [])],
|
|
{ repoRoot },
|
|
);
|
|
return spawnSync(invocation.command, invocation.args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
shell: invocation.shell,
|
|
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
});
|
|
}
|
|
|
|
function createSyntheticMergeRepo(prefix: string): { dir: string; staleBase: string } {
|
|
const dir = makeTempRepoRoot(tempDirs, prefix);
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeRepoFile(dir, "README.md", "base\n");
|
|
commitAll(dir, "base");
|
|
const staleBase = git(dir, ["rev-parse", "HEAD"]);
|
|
|
|
git(dir, ["switch", "-q", "-c", "feature"]);
|
|
writeRepoFile(dir, "src/pr.ts", "export const pr = true;\n");
|
|
commitAll(dir, "feature");
|
|
|
|
git(dir, ["switch", "-q", "main"]);
|
|
writeRepoFile(dir, "src/main-only.ts", "export const mainOnly = true;\n");
|
|
commitAll(dir, "main only");
|
|
git(dir, [
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"user.name=Test User",
|
|
"merge",
|
|
"--no-ff",
|
|
"feature",
|
|
"-m",
|
|
"synthetic merge",
|
|
]);
|
|
|
|
return { dir, staleBase };
|
|
}
|
|
|
|
function classifyPackageJsonChange(
|
|
prefix: string,
|
|
before: Record<string, unknown>,
|
|
after: Record<string, unknown>,
|
|
) {
|
|
const dir = makeTempRepoRoot(tempDirs, prefix);
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeRepoFile(dir, "package.json", prettyJson(before));
|
|
commitAll(dir, "initial");
|
|
writeRepoFile(dir, "package.json", prettyJson(after));
|
|
|
|
const output = execFileSync(
|
|
process.execPath,
|
|
[path.join(repoRoot, "scripts", "changed-lanes.mjs"), "--json", "--base", "HEAD"],
|
|
{ cwd: dir, encoding: "utf8", env: createNestedGitEnv() },
|
|
);
|
|
return parseChangedLaneOutput(output);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanupCorepackPnpmShimDir();
|
|
cleanupTempDirs(tempDirs);
|
|
});
|
|
|
|
describe("scripts/changed-lanes", () => {
|
|
it("detects direct script execution from Windows argv paths", () => {
|
|
expect(
|
|
isDirectRunPath(
|
|
"C:\\repo\\scripts\\check-changed.mjs",
|
|
"c:\\repo\\scripts\\check-changed.mjs",
|
|
"win32",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isDirectRunPath(
|
|
"C:\\repo\\scripts\\changed-lanes.mjs",
|
|
"C:\\repo\\scripts\\check-changed.mjs",
|
|
"win32",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "prints changed lane help without treating --help as a changed path",
|
|
script: "scripts/changed-lanes.mjs",
|
|
expected: {
|
|
contains: "Usage: node scripts/changed-lanes.mjs",
|
|
excludes: "--help: unknown surface",
|
|
},
|
|
},
|
|
{
|
|
name: "prints changed check help without running the changed gate",
|
|
script: "scripts/check-changed.mjs",
|
|
expected: { contains: "Usage: node scripts/check-changed.mjs", excludes: "[check:changed]" },
|
|
},
|
|
])("$name", ({ script, expected }) => {
|
|
const result = runRepoScript(script, ["--help"], {
|
|
...createNestedGitEnv(),
|
|
OPENCLAW_TESTBOX: "1",
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
expect(result.stdout).toContain(expected.contains);
|
|
expect(result.stdout).not.toContain(expected.excludes);
|
|
});
|
|
|
|
it("exits cleanly for no changes without local dependencies", () => {
|
|
const result = runRepoScript("scripts/check-changed.mjs", ["--no-changes"], {
|
|
...createNestedGitEnv(),
|
|
PATH: "/nonexistent",
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr.trim()).toBe("[check:changed] no changed paths; nothing to run");
|
|
});
|
|
|
|
it("delegates when the local checkout cannot resolve the default base ref", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-check-changed-missing-base-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
|
|
commitAll(dir, "initial");
|
|
const binDir = path.join(dir, "bin");
|
|
mkdirSync(binDir, { recursive: true });
|
|
writeFileSync(path.join(binDir, "node"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
|
|
const result = spawnSync(process.execPath, [path.join(repoRoot, "scripts/check-changed.mjs")], {
|
|
cwd: dir,
|
|
encoding: "utf8",
|
|
env: {
|
|
...createNestedGitEnv(),
|
|
CI: "",
|
|
GITHUB_ACTIONS: "",
|
|
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD: "",
|
|
OPENCLAW_TESTBOX: "1",
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toContain("delegating through Crabbox workload routing");
|
|
expect(result.stderr).not.toContain("ambiguous argument");
|
|
});
|
|
|
|
it("delegates path-scoped release metadata when local diff refs are unavailable", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-check-changed-metadata-missing-base-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
|
|
commitAll(dir, "initial");
|
|
writeRepoFile(dir, "node_modules/.modules.yaml", "layoutVersion: 5\n");
|
|
writeRepoFile(dir, "node_modules/.bin/oxfmt", "#!/bin/sh\n");
|
|
writeRepoFile(dir, "node_modules/typescript/package.json", '{"name":"typescript"}\n');
|
|
const binDir = path.join(dir, "bin");
|
|
mkdirSync(binDir, { recursive: true });
|
|
writeFileSync(path.join(binDir, "node"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(repoRoot, "scripts/check-changed.mjs"), "--", "CHANGELOG.md"],
|
|
{
|
|
cwd: dir,
|
|
encoding: "utf8",
|
|
env: {
|
|
...createNestedGitEnv(),
|
|
CI: "",
|
|
GITHUB_ACTIONS: "",
|
|
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD: "",
|
|
OPENCLAW_TESTBOX: "",
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toContain("delegating through Crabbox workload routing");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "rejects unknown changed lane options before treating them as paths",
|
|
script: "scripts/changed-lanes.mjs",
|
|
option: "--jsno",
|
|
expected: { stderr: "Unknown option: --jsno", excludes: [] },
|
|
},
|
|
{
|
|
name: "rejects unknown changed check options before treating them as paths",
|
|
script: "scripts/check-changed.mjs",
|
|
option: "--dr-run",
|
|
expected: {
|
|
stderr: "Unknown option: --dr-run\n[check:changed] FAILED (exit 1)",
|
|
excludes: [],
|
|
},
|
|
},
|
|
])("$name", ({ script, option, expected }) => {
|
|
const result = runRepoScript(script, [option], {
|
|
...createNestedGitEnv(),
|
|
OPENCLAW_TESTBOX: "1",
|
|
});
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr.trim()).toBe(expected.stderr);
|
|
expect(result.stderr).not.toContain("\n at ");
|
|
for (const excluded of expected.excludes) {
|
|
expect(result.stderr).not.toContain(excluded);
|
|
}
|
|
});
|
|
|
|
it("still accepts dash-prefixed explicit changed paths after the separator", () => {
|
|
const result = runRepoScript("scripts/changed-lanes.mjs", ["--json", "--", "--github-output"]);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
expect(parseChangedLaneOutput(result.stdout).paths).toEqual(["--github-output"]);
|
|
});
|
|
|
|
it("keeps changed check option-shaped paths intact after the separator", () => {
|
|
const args = buildChangedCheckCrabboxArgs(["--staged", "--", "--no-changes"], {
|
|
cwd: repoRoot,
|
|
});
|
|
|
|
expect(args.slice(args.indexOf("check:changed") + 1)).toEqual([
|
|
"--staged",
|
|
"--",
|
|
"--no-changes",
|
|
]);
|
|
});
|
|
|
|
it("prints changed check dry-run commands", () => {
|
|
const result = runRepoScript("scripts/check-changed.mjs", [
|
|
"--dry-run",
|
|
"--",
|
|
"extensions/lmstudio/src/api.ts",
|
|
]);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toContain("[check:changed:dry-run] lanes=extensions, extensionTests");
|
|
expect(result.stderr).toContain(
|
|
"[check:changed:dry-run] would run: node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.extensions.json extensions/lmstudio/src/api.ts",
|
|
);
|
|
});
|
|
|
|
it("includes untracked worktree files in the default local diff", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
|
|
commitAll(dir, "initial");
|
|
|
|
mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
|
writeFileSync(path.join(dir, "scripts", "new-check.mjs"), "export {};\n", "utf8");
|
|
|
|
const result = runChangedLanesCli(dir, ["--json", "--base", "HEAD"]);
|
|
|
|
expect(result.paths).toEqual(["scripts/new-check.mjs"]);
|
|
expectLanes(result.lanes, { tooling: true });
|
|
});
|
|
|
|
it("falls back to a two-dot diff when a delegated checkout has no merge base", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-no-merge-base-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
|
|
commitAll(dir, "initial");
|
|
git(dir, ["update-ref", "refs/remotes/origin/main", "HEAD"]);
|
|
git(dir, ["switch", "-q", "--orphan", "feature"]);
|
|
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
|
|
mkdirSync(path.join(dir, "src"), { recursive: true });
|
|
writeFileSync(path.join(dir, "src", "committed.ts"), "export const committed = 1;\n", "utf8");
|
|
commitAll(dir, "feature base");
|
|
writeFileSync(path.join(dir, "src", "feature.ts"), "export const value = 1;\n", "utf8");
|
|
|
|
expect(
|
|
listChangedPathsFromGit({ base: "origin/main", cwd: dir, includeWorktree: false }),
|
|
).toEqual(["src/committed.ts"]);
|
|
expect(listChangedPathsFromGit({ base: "origin/main", cwd: dir })).toEqual([
|
|
"src/committed.ts",
|
|
"src/feature.ts",
|
|
]);
|
|
});
|
|
|
|
it("prefers raw sync worktree paths over an implausibly broad no-merge-base diff", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-raw-sync-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
for (let index = 0; index < 250; index += 1) {
|
|
writeFileSync(path.join(dir, `baseline-${index}.txt`), "baseline\n", "utf8");
|
|
}
|
|
commitAll(dir, "initial");
|
|
git(dir, ["update-ref", "refs/remotes/origin/main", "HEAD"]);
|
|
git(dir, ["switch", "-q", "--orphan", "feature"]);
|
|
git(dir, [
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"user.name=Test User",
|
|
"commit",
|
|
"-q",
|
|
"--allow-empty",
|
|
"-m",
|
|
"raw sync base",
|
|
]);
|
|
mkdirSync(path.join(dir, "src"), { recursive: true });
|
|
writeFileSync(path.join(dir, "src", "feature.ts"), "export const value = 1;\n", "utf8");
|
|
|
|
const previousRawSync = process.env.OPENCLAW_CHANGED_LANES_RAW_SYNC;
|
|
delete process.env.OPENCLAW_CHANGED_LANES_RAW_SYNC;
|
|
try {
|
|
const normalPaths = listChangedPathsFromGit({ base: "origin/main", cwd: dir });
|
|
expect(normalPaths.length).toBeGreaterThan(200);
|
|
expect(normalPaths).toContain("baseline-0.txt");
|
|
expect(normalPaths).toContain("src/feature.ts");
|
|
|
|
process.env.OPENCLAW_CHANGED_LANES_RAW_SYNC = "1";
|
|
expect(listChangedPathsFromGit({ base: "origin/main", cwd: dir })).toEqual([
|
|
"src/feature.ts",
|
|
]);
|
|
} finally {
|
|
if (previousRawSync === undefined) {
|
|
delete process.env.OPENCLAW_CHANGED_LANES_RAW_SYNC;
|
|
} else {
|
|
process.env.OPENCLAW_CHANGED_LANES_RAW_SYNC = previousRawSync;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("includes committed and untracked added files in the changed format check", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-added-format-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeRepoFile(dir, "README.md", "initial\n");
|
|
commitAll(dir, "initial");
|
|
git(dir, ["update-ref", "refs/remotes/origin/main", "HEAD"]);
|
|
git(dir, ["switch", "-q", "-c", "feature"]);
|
|
writeRepoFile(dir, "src/committed.test.ts", "export const committed={value:1};\n");
|
|
commitAll(dir, "add test");
|
|
writeRepoFile(dir, "src/untracked.test.ts", "export const untracked={value:1};\n");
|
|
writeRepoFile(dir, "--help", "ignored\n");
|
|
|
|
const paths = listChangedPathsFromGit({ base: "origin/main", cwd: dir });
|
|
const plan = createChangedCheckPlan(detectChangedLanes(paths));
|
|
|
|
expect(paths).toEqual(["--help", "src/committed.test.ts", "src/untracked.test.ts"]);
|
|
expect(plan.commands.find((command) => command.name === "format changed files")).toEqual({
|
|
name: "format changed files",
|
|
args: [
|
|
"format:check",
|
|
"--no-error-on-unmatched-pattern",
|
|
"--",
|
|
"--help",
|
|
"src/committed.test.ts",
|
|
"src/untracked.test.ts",
|
|
],
|
|
});
|
|
});
|
|
|
|
it("includes staged added, modified, and deleted files in the changed format check", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-staged-format-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeRepoFile(dir, "src/modified.ts", "export const modified = { value: 1 };\n");
|
|
writeRepoFile(dir, "src/removed.ts", "export const removed = { value: 1 };\n");
|
|
commitAll(dir, "initial");
|
|
writeRepoFile(dir, "src/added.test.ts", "export const added={value:1};\n");
|
|
writeRepoFile(dir, "src/modified.ts", "export const modified={value:2};\n");
|
|
git(dir, ["add", "src/added.test.ts", "src/modified.ts"]);
|
|
git(dir, ["rm", "-q", "src/removed.ts"]);
|
|
|
|
const paths = listStagedChangedPaths(dir);
|
|
const plan = createChangedCheckPlan(detectChangedLanes(paths));
|
|
|
|
expect(paths).toEqual(["src/added.test.ts", "src/modified.ts", "src/removed.ts"]);
|
|
expect(plan.commands.find((command) => command.name === "format changed files")).toEqual({
|
|
name: "format changed files",
|
|
args: [
|
|
"format:check",
|
|
"--no-error-on-unmatched-pattern",
|
|
"--",
|
|
"src/added.test.ts",
|
|
"src/modified.ts",
|
|
"src/removed.ts",
|
|
],
|
|
});
|
|
});
|
|
|
|
it("fails the changed format check on a misformatted added file and passes once formatted", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-format-added-");
|
|
writeRepoFile(dir, "src/added.test.ts", "export const added={value:1};\n");
|
|
|
|
const dirty = runChangedFormatLaneWithRepoOxfmt(dir, ["src/added.test.ts"]);
|
|
expect(dirty.status).not.toBe(0);
|
|
expect(`${dirty.stdout}${dirty.stderr}`).toContain("added.test.ts");
|
|
|
|
writeRepoFile(dir, "src/added.test.ts", "export const added = { value: 1 };\n");
|
|
const formatted = runChangedFormatLaneWithRepoOxfmt(dir, ["src/added.test.ts"]);
|
|
expect(formatted.status).toBe(0);
|
|
});
|
|
|
|
it("fails the changed format check on a misformatted modified file", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-format-modified-");
|
|
writeRepoFile(dir, "src/modified.ts", "export const modified={value:2};\n");
|
|
|
|
const result = runChangedFormatLaneWithRepoOxfmt(dir, ["src/modified.ts"]);
|
|
expect(result.status).not.toBe(0);
|
|
expect(`${result.stdout}${result.stderr}`).toContain("modified.ts");
|
|
});
|
|
|
|
it("does not fail the changed format check for deleted paths", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-format-deleted-");
|
|
writeRepoFile(dir, "src/kept.ts", "export const kept = { value: 1 };\n");
|
|
|
|
const result = runChangedFormatLaneWithRepoOxfmt(dir, ["src/deleted.ts", "src/kept.ts"]);
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it("uses the merge commit first parent instead of a stale PR payload base", () => {
|
|
const { dir, staleBase } = createSyntheticMergeRepo("openclaw-changed-lanes-merge-");
|
|
|
|
expect(listChangedPathsFromGit({ base: staleBase, cwd: dir, includeWorktree: false })).toEqual([
|
|
"src/main-only.ts",
|
|
"src/pr.ts",
|
|
]);
|
|
expect(
|
|
listChangedPathsFromGit({
|
|
base: staleBase,
|
|
cwd: dir,
|
|
includeWorktree: false,
|
|
mergeHeadFirstParent: true,
|
|
}),
|
|
).toEqual(["src/pr.ts"]);
|
|
});
|
|
|
|
it("ignores local Crabbox metadata in the default local diff", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-crabbox-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeFileSync(path.join(dir, ".gitignore"), ".crabbox/\n", "utf8");
|
|
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
|
|
commitAll(dir, "initial");
|
|
|
|
mkdirSync(path.join(dir, ".crabbox"), { recursive: true });
|
|
writeFileSync(path.join(dir, ".crabbox", "capture-files.txt"), "stdout.log\n", "utf8");
|
|
writeFileSync(path.join(dir, ".crabbox", "capture-manifest.txt"), "stdout.log\t12\n", "utf8");
|
|
|
|
const result = runChangedLanesCli(dir, ["--json", "--base", "HEAD"]);
|
|
|
|
expect(result.paths).toEqual([]);
|
|
expectLanes(result.lanes, {});
|
|
});
|
|
|
|
it("includes deleted worktree files in the default local diff", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-deleted-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
mkdirSync(path.join(dir, "src", "shared"), { recursive: true });
|
|
writeFileSync(
|
|
path.join(dir, "src", "shared", "obsolete.ts"),
|
|
"export const value = 1;\n",
|
|
"utf8",
|
|
);
|
|
commitAll(dir, "initial");
|
|
|
|
unlinkSync(path.join(dir, "src", "shared", "obsolete.ts"));
|
|
|
|
const result = runChangedLanesCli(dir, ["--json", "--base", "HEAD"]);
|
|
|
|
expect(result.paths).toEqual(["src/shared/obsolete.ts"]);
|
|
expectLanes(result.lanes, { core: true, coreTests: true });
|
|
});
|
|
|
|
it("includes deleted staged files in the staged diff", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-staged-deleted-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
mkdirSync(path.join(dir, "src", "shared"), { recursive: true });
|
|
writeFileSync(
|
|
path.join(dir, "src", "shared", "obsolete.ts"),
|
|
"export const value = 1;\n",
|
|
"utf8",
|
|
);
|
|
commitAll(dir, "initial");
|
|
|
|
unlinkSync(path.join(dir, "src", "shared", "obsolete.ts"));
|
|
git(dir, ["add", "src/shared/obsolete.ts"]);
|
|
|
|
const result = runChangedLanesCli(dir, ["--json", "--staged"]);
|
|
|
|
expect(result.paths).toEqual(["src/shared/obsolete.ts"]);
|
|
expectLanes(result.lanes, { core: true, coreTests: true });
|
|
});
|
|
|
|
it.each([
|
|
{ name: "core source", changedPaths: ["src/agents/api.ts"], expected: true },
|
|
{ name: "extension source", changedPaths: ["extensions/copilot/src/a.ts"], expected: true },
|
|
{ name: "ui source", changedPaths: ["ui/src/pages/a.ts"], expected: true },
|
|
{ name: "package source", changedPaths: ["packages/x/src/a.mts"], expected: true },
|
|
// Matches the `[cm]?[jt]sx?` selector the lint lanes in check-changed.mts use.
|
|
{ name: "tsx source", changedPaths: ["ui/src/pages/Page.tsx"], expected: true },
|
|
{ name: "jsx source", changedPaths: ["ui/src/pages/Page.jsx"], expected: true },
|
|
{ name: "cjs source", changedPaths: ["src/agents/legacy.cjs"], expected: true },
|
|
// An import-only edit can orphan a barrel re-export in a file this diff never
|
|
// touches, so selection is by path; inspecting changed lines would miss it.
|
|
{ name: "import-only edit", changedPaths: ["src/agents/tool-surface-plan.ts"], expected: true },
|
|
{ name: "docs tree", changedPaths: ["docs/example.ts"], expected: false },
|
|
{ name: "scripts tree", changedPaths: ["scripts/check-changed.mjs"], expected: false },
|
|
// knip never reads these, so they must not pull in the scan.
|
|
{ name: "markdown under src", changedPaths: ["src/README.md"], expected: false },
|
|
{ name: "sql under src", changedPaths: ["src/state/schema.sql"], expected: false },
|
|
])("selects the dead-export scan for $name", ({ changedPaths, expected }) => {
|
|
expect(hasDeadcodeScannedSource(changedPaths)).toBe(expected);
|
|
});
|
|
|
|
it("ignores the explicit path separator", () => {
|
|
const result = detectChangedLanes(["--", "scripts/test-live-acp-bind-docker.sh"]);
|
|
|
|
expect(result.paths).toEqual(["scripts/test-live-acp-bind-docker.sh"]);
|
|
expect(result.lanes.liveDockerTooling).toBe(true);
|
|
expect(result.lanes.all).toBe(false);
|
|
});
|
|
|
|
it("routes a subagent-announce-only Docker diff through the live Docker lane", () => {
|
|
const result = detectChangedLanes(["scripts/test-live-subagent-announce-docker.sh"]);
|
|
|
|
expectLanes(result.lanes, { liveDockerTooling: true });
|
|
});
|
|
|
|
it.each([
|
|
"extensions/whatsapp/src/config-ui-hints.ts",
|
|
"extensions/mattermost/src/config-schema-core.ts",
|
|
"extensions/telegram/openclaw.plugin.json",
|
|
"extensions/discord/package.json",
|
|
"extensions/slack/security-contract-api.ts",
|
|
"src/config/zod-schema.core.ts",
|
|
"src/channels/plugins/config-schema.ts",
|
|
"scripts/load-channel-config-surface.ts",
|
|
])("routes %s through the bundled channel config metadata lane", (changedPath) => {
|
|
const result = detectChangedLanes([changedPath]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.lanes.bundledChannelConfigMetadata).toBe(true);
|
|
expect(plan.commands.map((command) => command.args[0])).toContain(
|
|
"check:bundled-channel-config-metadata",
|
|
);
|
|
});
|
|
|
|
it("keeps unrelated plugin runtime changes out of the bundled channel metadata lane", () => {
|
|
const result = detectChangedLanes(["extensions/whatsapp/src/monitor.ts"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.lanes.bundledChannelConfigMetadata).toBe(false);
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain(
|
|
"check:bundled-channel-config-metadata",
|
|
);
|
|
});
|
|
|
|
it("includes bundled channel metadata in the fail-safe all plan", () => {
|
|
const result = detectChangedLanes(["unknown-surface.foo"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.lanes.all).toBe(true);
|
|
expect(plan.commands.map((command) => command.args[0])).toContain(
|
|
"check:bundled-channel-config-metadata",
|
|
);
|
|
});
|
|
|
|
it("exposes the shared changed-lane test path classifier", () => {
|
|
expect(isChangedLaneTestPath("src/shared/string-normalization.test.ts")).toBe(true);
|
|
expect(isChangedLaneTestPath("packages/foo/__tests__/helper.ts")).toBe(true);
|
|
expect(isChangedLaneTestPath("src/example.ts")).toBe(false);
|
|
expect(isChangedLaneTestPath("src/latest.ts")).toBe(false);
|
|
});
|
|
|
|
it("routes core production changes to core prod and core test lanes", () => {
|
|
const result = detectChangedLanes(["packages/normalization-core/src/string-normalization.ts"]);
|
|
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
|
|
|
expectLanes(result.lanes, {
|
|
core: true,
|
|
coreTests: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).toContain(
|
|
"check:database-first-legacy-stores",
|
|
);
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:core");
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:core:test");
|
|
expect(plan.commands.find((command) => command.args[0] === "tsgo:core")?.env).toEqual({
|
|
PATH: "/usr/bin",
|
|
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
|
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
|
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
|
OPENCLAW_TSGO_SPARSE_SKIP: "1",
|
|
});
|
|
expect(plan.commands.find((command) => command.name === "lint core changed file")).toEqual({
|
|
name: "lint core changed file",
|
|
bin: "node",
|
|
args: [
|
|
"scripts/run-oxlint.mjs",
|
|
"--tsconfig",
|
|
"config/tsconfig/oxlint.core.json",
|
|
"packages/normalization-core/src/string-normalization.ts",
|
|
],
|
|
env: {
|
|
PATH: "/usr/bin",
|
|
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
|
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
|
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("targets mixed core, extension, and script lint without full-owner fan-out", () => {
|
|
const result = detectChangedLanes([
|
|
"src/gateway/node-registry.ts",
|
|
"extensions/lmstudio/src/models.fetch.ts",
|
|
"scripts/check-changed.mjs",
|
|
]);
|
|
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
|
|
|
expect(plan.commands).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "lint core changed file",
|
|
args: [
|
|
"scripts/run-oxlint.mjs",
|
|
"--tsconfig",
|
|
"config/tsconfig/oxlint.core.json",
|
|
"src/gateway/node-registry.ts",
|
|
],
|
|
}),
|
|
expect.objectContaining({
|
|
name: "lint extension changed file",
|
|
args: [
|
|
"scripts/run-oxlint.mjs",
|
|
"--tsconfig",
|
|
"config/tsconfig/oxlint.extensions.json",
|
|
"extensions/lmstudio/src/models.fetch.ts",
|
|
],
|
|
}),
|
|
expect.objectContaining({
|
|
name: "lint script changed file",
|
|
args: [
|
|
"scripts/run-oxlint.mjs",
|
|
"--tsconfig",
|
|
"config/tsconfig/oxlint.scripts.json",
|
|
"scripts/check-changed.mjs",
|
|
],
|
|
}),
|
|
]),
|
|
);
|
|
const commandNames = plan.commands.map((command) => command.args[0]);
|
|
for (const fullLane of ["lint:core", "lint:extensions", "lint:scripts"]) {
|
|
expect(commandNames).not.toContain(fullLane);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
owner: "core",
|
|
paths: [
|
|
"src/gateway/node-registry.ts",
|
|
"src/gateway/node-registry.invoke-stream.ts",
|
|
"src/gateway/server-methods/nodes.invoke.ts",
|
|
"src/gateway/server-methods/nodes.invoke-deadline.ts",
|
|
"src/node-host/runtime.ts",
|
|
"src/node-host/runner.ts",
|
|
"src/plugins/provider-self-hosted-setup.ts",
|
|
"packages/gateway-client/src/timeouts.ts",
|
|
"packages/normalization-core/src/number-coercion.ts",
|
|
],
|
|
pluralName: "lint core changed files",
|
|
singularName: "lint core changed file",
|
|
fullLane: "lint:core",
|
|
},
|
|
{
|
|
owner: "extension",
|
|
paths: [
|
|
"extensions/lmstudio/src/embedding-provider.ts",
|
|
"extensions/lmstudio/src/stream.ts",
|
|
"extensions/lmstudio/src/api.ts",
|
|
"extensions/lmstudio/src/models.fetch.ts",
|
|
"extensions/lmstudio/src/setup.ts",
|
|
"extensions/lmstudio/src/defaults.ts",
|
|
"extensions/lmstudio/src/provider-auth.ts",
|
|
"extensions/lmstudio/src/runtime.ts",
|
|
"extensions/lmstudio/src/models.ts",
|
|
],
|
|
pluralName: "lint extension changed files",
|
|
singularName: "lint extension changed file",
|
|
fullLane: "lint:extensions",
|
|
},
|
|
])("batches broad $owner changes without falling back to full lint", (testCase) => {
|
|
const result = detectChangedLanes(testCase.paths);
|
|
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
|
const commands = plan.commands.filter(
|
|
(command) => command.name === testCase.pluralName || command.name === testCase.singularName,
|
|
);
|
|
|
|
expect(commands).toHaveLength(2);
|
|
expect(commands.map((command) => command.args.slice(3).length)).toEqual([8, 1]);
|
|
expect(commands.flatMap((command) => command.args.slice(3)).toSorted()).toEqual(
|
|
testCase.paths.toSorted(),
|
|
);
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain(testCase.fullLane);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "routes UI production changes to UI prod and core test lanes",
|
|
path: "ui/src/app.ts",
|
|
expected: {
|
|
includes: ["tsgo:ui", "tsgo:core:test", "lint:ui:i18n"],
|
|
excludes: ["tsgo:core"],
|
|
},
|
|
},
|
|
{
|
|
name: "routes the UI production config to UI prod and core test lanes",
|
|
path: "tsconfig.ui.json",
|
|
expected: { includes: ["tsgo:ui", "tsgo:core:test"], excludes: [] },
|
|
},
|
|
])("$name", ({ path: changedPath, expected }) => {
|
|
const result = detectChangedLanes([changedPath]);
|
|
const commands = createChangedCheckPlan(result, {
|
|
env: { PATH: "/usr/bin" },
|
|
}).commands.map((command) => command.args[0]);
|
|
|
|
expectLanes(result.lanes, { coreTests: true, ui: true });
|
|
for (const command of expected.includes) {
|
|
expect(commands).toContain(command);
|
|
}
|
|
for (const command of expected.excludes) {
|
|
expect(commands).not.toContain(command);
|
|
}
|
|
});
|
|
|
|
it.each(["scripts/control-ui-i18n.ts", "scripts/lib/example.ts", "tsconfig.scripts.json"])(
|
|
"routes %s to the scripts typecheck lane",
|
|
(changedPath) => {
|
|
const result = detectChangedLanes([changedPath]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.lanes.scripts).toBe(true);
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:scripts");
|
|
},
|
|
);
|
|
|
|
it("keeps the scripts lane when another change selects the full lane", () => {
|
|
const result = detectChangedLanes(["package.json", "scripts/example.mts"]);
|
|
|
|
expect(result.lanes.all).toBe(true);
|
|
});
|
|
|
|
it("routes Control UI i18n tooling changes through keyless catalog verification", () => {
|
|
const result = detectChangedLanes(["scripts/control-ui-i18n-verify.ts"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(shouldRunControlUiI18nVerify(result.paths)).toBe(true);
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("lint:ui:i18n");
|
|
expect(shouldRunControlUiI18nVerify(["ui/config/control-ui-locales.ts"])).toBe(true);
|
|
expect(shouldRunControlUiI18nVerify(["scripts/lib/example.ts"])).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["test/vitest/foo.config.ts", true, true],
|
|
["test/vitest/vitest-runtime-helper.d.mts", true, true],
|
|
["test/fixtures/foo.ts", false, true],
|
|
["test/foo.mjs", false, true],
|
|
["test/tsconfig/tsconfig.test.root.json", true, true],
|
|
])(
|
|
"routes %s to testRoot=%s and tooling=%s",
|
|
(changedPath, expectedTestRoot, expectedTooling) => {
|
|
const result = detectChangedLanes([changedPath]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.lanes.testRoot).toBe(expectedTestRoot);
|
|
expect(result.lanes.tooling).toBe(expectedTooling);
|
|
expect(plan.commands.map((command) => command.args[0]).includes("tsgo:test:root")).toBe(
|
|
expectedTestRoot,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("falls back to full core lint for broad core diffs", () => {
|
|
const targets = Array.from({ length: 9 }, (_, index) => `src/shared/file-${index}.ts`);
|
|
const command = createTargetedCoreLintCommand(targets, { PATH: "/usr/bin" });
|
|
|
|
expect(command).toBeNull();
|
|
});
|
|
|
|
it("falls back to full extension lint for broad extension diffs", () => {
|
|
const targets = Array.from(
|
|
{ length: 9 },
|
|
(_, index) => `extensions/discord/src/file-${index}.ts`,
|
|
);
|
|
const command = createTargetedExtensionLintCommand(targets, { PATH: "/usr/bin" });
|
|
|
|
expect(command).toBeNull();
|
|
});
|
|
|
|
it("falls back to full core lint when a changed core target was deleted", () => {
|
|
expect(
|
|
createTargetedCoreLintCommand(
|
|
["src/shared/deleted.ts"],
|
|
{ PATH: "/usr/bin" },
|
|
{
|
|
fileExists: () => false,
|
|
},
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("falls back to full core lint for mixed core lint configuration diffs", () => {
|
|
expect(
|
|
createTargetedCoreLintCommand(
|
|
[
|
|
"config/tsconfig/oxlint.core.json",
|
|
"packages/normalization-core/src/string-normalization.ts",
|
|
],
|
|
{ PATH: "/usr/bin" },
|
|
{ fileExists: () => true },
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "targets small core lint diffs",
|
|
create: createTargetedCoreLintCommand,
|
|
targets: [
|
|
".github/workflows/ci.yml",
|
|
"scripts/check-changed.mjs",
|
|
"src/agents/auth-profiles/usage.ts",
|
|
"test/scripts/changed-lanes.test.ts",
|
|
],
|
|
expected: {
|
|
name: "lint core changed file",
|
|
tsconfig: "config/tsconfig/oxlint.core.json",
|
|
path: "src/agents/auth-profiles/usage.ts",
|
|
},
|
|
},
|
|
{
|
|
name: "targets small extension lint diffs",
|
|
create: createTargetedExtensionLintCommand,
|
|
targets: ["extensions/lmstudio/src/api.ts", "docs/help/testing.md"],
|
|
expected: {
|
|
name: "lint extension changed file",
|
|
tsconfig: "config/tsconfig/oxlint.extensions.json",
|
|
path: "extensions/lmstudio/src/api.ts",
|
|
},
|
|
},
|
|
{
|
|
name: "targets small script lint diffs",
|
|
create: createTargetedScriptLintCommand,
|
|
targets: ["scripts/check-changed.mjs", "test/scripts/changed-lanes.test.ts"],
|
|
expected: {
|
|
name: "lint script changed file",
|
|
tsconfig: "config/tsconfig/oxlint.scripts.json",
|
|
path: "scripts/check-changed.mjs",
|
|
},
|
|
},
|
|
])("$name", ({ create, targets, expected }) => {
|
|
expect(create(targets, { PATH: "/usr/bin" }, { fileExists: () => true })).toEqual({
|
|
name: expected.name,
|
|
bin: "node",
|
|
args: ["scripts/run-oxlint.mjs", "--tsconfig", expected.tsconfig, expected.path],
|
|
env: { PATH: "/usr/bin" },
|
|
});
|
|
});
|
|
|
|
it("reenables local-check policy for changed typecheck commands", () => {
|
|
const result = detectChangedLanes(["packages/normalization-core/src/string-normalization.ts"]);
|
|
const plan = createChangedCheckPlan(result, {
|
|
env: { OPENCLAW_LOCAL_CHECK: "0", PATH: "/usr/bin" },
|
|
});
|
|
|
|
expect(plan.commands.find((command) => command.args[0] === "tsgo:core")?.env).toEqual({
|
|
OPENCLAW_LOCAL_CHECK: "1",
|
|
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
|
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
|
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
|
OPENCLAW_TSGO_SPARSE_SKIP: "1",
|
|
PATH: "/usr/bin",
|
|
});
|
|
});
|
|
|
|
it("marks changed-check children as covered by the parent heavy-check lock", () => {
|
|
expect(createChangedCheckChildEnv({ PATH: "/usr/bin" })).toEqual({
|
|
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
|
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
|
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
|
PATH: "/usr/bin",
|
|
});
|
|
});
|
|
|
|
it("runs CI changed-check children through Corepack pnpm", () => {
|
|
const command = createPnpmManagedCommand(
|
|
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
|
|
{ CI: "1", PATH: "/usr/bin" },
|
|
);
|
|
|
|
expect(command.bin).toBe("corepack");
|
|
expect(command.args).toEqual(["pnpm", "check:no-conflict-markers"]);
|
|
});
|
|
|
|
it("cleans CI Corepack pnpm shim temp dirs", () => {
|
|
const command = createPnpmManagedCommand(
|
|
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
|
|
{ CI: "1", PATH: "/usr/bin" },
|
|
);
|
|
const shimDir = expectDefined(
|
|
(command.env?.PATH ?? "").split(path.delimiter)[0],
|
|
"CI Corepack pnpm shim directory",
|
|
);
|
|
|
|
expect(path.basename(shimDir)).toMatch(/^openclaw-corepack-pnpm-/u);
|
|
expect(existsSync(path.join(shimDir, "pnpm"))).toBe(true);
|
|
|
|
cleanupCorepackPnpmShimDir();
|
|
|
|
expect(existsSync(shimDir)).toBe(false);
|
|
});
|
|
|
|
it("keeps local changed-check children on the repo pnpm shim", () => {
|
|
const command = createPnpmManagedCommand(
|
|
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
|
|
{ PATH: "/usr/bin" },
|
|
);
|
|
|
|
expect(command.bin).toBe("pnpm");
|
|
expect(command.args).toEqual(["check:no-conflict-markers"]);
|
|
});
|
|
|
|
it("delegates heavy changed gates after classifying their lanes", () => {
|
|
const result = detectChangedLanes(["src/config/config.ts"]);
|
|
expect(
|
|
shouldDelegateChangedCheckToCrabbox(
|
|
["--base", "origin/main"],
|
|
{ PATH: "/usr/bin" },
|
|
{ result },
|
|
),
|
|
).toBe(true);
|
|
expect(changedCheckRequiresRemote(result)).toBe(true);
|
|
|
|
expect(buildChangedCheckCrabboxArgs(["--base", "origin/main", "--head", "HEAD"])).toEqual([
|
|
"scripts/crabbox-wrapper.mjs",
|
|
"run",
|
|
"--workload",
|
|
"ci-fast",
|
|
"--idle-timeout",
|
|
"90m",
|
|
"--ttl",
|
|
"240m",
|
|
"--timing-json",
|
|
"--",
|
|
"env",
|
|
"OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1",
|
|
"OPENCLAW_CHANGED_LANES_RAW_SYNC=1",
|
|
"CI=1",
|
|
"PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false",
|
|
"corepack",
|
|
"pnpm",
|
|
"check:changed",
|
|
"--base",
|
|
"origin/main",
|
|
"--head",
|
|
"HEAD",
|
|
]);
|
|
});
|
|
|
|
it("routes a changed export signature remotely through its own source lane", () => {
|
|
// Detection only fires for source files, and any such file already enables a
|
|
// non-docs lane, so the dead export scan needs no special routing branch.
|
|
const result = detectChangedLanes(["src/config/config.ts"]);
|
|
|
|
expect(changedCheckRequiresRemote(result)).toBe(true);
|
|
expect(shouldDelegateChangedCheckToCrabbox([], {}, { result })).toBe(true);
|
|
});
|
|
|
|
it("adds the dead export scan only for production source changes", () => {
|
|
const command = {
|
|
name: "dead export scan (skip with OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1)",
|
|
bin: "node",
|
|
args: ["--import", "tsx", "scripts/check-deadcode-exports.mts"],
|
|
env: expect.any(Object),
|
|
};
|
|
const sourceResult = detectChangedLanes(["src/config/config.ts"]);
|
|
const toolingResult = detectChangedLanes(["scripts/check-changed.mjs"]);
|
|
|
|
expect(createChangedCheckPlan(sourceResult).commands).toContainEqual(command);
|
|
expect(createChangedCheckPlan(toolingResult).commands).not.toContainEqual(command);
|
|
expect(
|
|
createChangedCheckPlan(sourceResult, {
|
|
env: { OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE: "1" },
|
|
}).commands,
|
|
).not.toContainEqual(command);
|
|
});
|
|
|
|
it("keeps small changed gates local only with a ready dependency install", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-check-changed-local-route-");
|
|
const docsResult = detectChangedLanes(["docs/reference/test.md"]);
|
|
const noChangesResult = detectChangedLanes([]);
|
|
const metadataResult = detectChangedLanes(["CHANGELOG.md"]);
|
|
const mixedResult = detectChangedLanes(["CHANGELOG.md", "src/config/config.ts"]);
|
|
|
|
expect(changedCheckLocalDependenciesReady(dir)).toBe(false);
|
|
expect(shouldDelegateChangedCheckToCrabbox([], {}, { cwd: dir, result: noChangesResult })).toBe(
|
|
false,
|
|
);
|
|
expect(shouldDelegateChangedCheckToCrabbox([], {}, { cwd: dir, result: docsResult })).toBe(
|
|
true,
|
|
);
|
|
|
|
writeRepoFile(dir, "node_modules/.modules.yaml", "layoutVersion: 5\n");
|
|
writeRepoFile(dir, "node_modules/.bin/oxfmt", "#!/bin/sh\n");
|
|
writeRepoFile(dir, "node_modules/typescript/package.json", '{"name":"typescript"}\n');
|
|
|
|
expect(changedCheckLocalDependenciesReady(dir)).toBe(true);
|
|
for (const result of [docsResult, noChangesResult, metadataResult]) {
|
|
expect(changedCheckRequiresRemote(result)).toBe(false);
|
|
expect(shouldDelegateChangedCheckToCrabbox([], {}, { cwd: dir, result })).toBe(false);
|
|
}
|
|
for (const result of [docsResult, metadataResult]) {
|
|
expect(
|
|
shouldDelegateChangedCheckToCrabbox([], { OPENCLAW_TESTBOX: "1" }, { cwd: dir, result }),
|
|
).toBe(true);
|
|
}
|
|
expect(changedCheckRequiresRemote(mixedResult)).toBe(true);
|
|
});
|
|
|
|
it("delegates generated docs baselines with heavy owner checks", () => {
|
|
for (const changedPath of [
|
|
"docs/.generated/plugin-sdk-api-baseline.jsonl",
|
|
"docs/.generated/sqlite-session-transcript-schema-baseline.sha256",
|
|
]) {
|
|
const result = detectChangedLanes([changedPath]);
|
|
expect(result.docsOnly).toBe(true);
|
|
expect(changedCheckRequiresRemote(result)).toBe(true);
|
|
expect(shouldDelegateChangedCheckToCrabbox([], {}, { cwd: repoRoot, result })).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("delegates staged changed gates as explicit remote paths", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-check-changed-staged-delegate-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
|
|
commitAll(dir, "initial");
|
|
mkdirSync(path.join(dir, "src"), { recursive: true });
|
|
writeFileSync(path.join(dir, "src", "staged.ts"), "export const staged = 1;\n", "utf8");
|
|
git(dir, ["add", "src/staged.ts"]);
|
|
|
|
const args = buildChangedCheckCrabboxArgs(["--staged", "--timed"], { cwd: dir });
|
|
expect(args.slice(args.indexOf("check:changed") + 1)).toEqual([
|
|
"--timed",
|
|
"--base",
|
|
"HEAD",
|
|
"--head",
|
|
"HEAD",
|
|
"--",
|
|
"src/staged.ts",
|
|
]);
|
|
});
|
|
|
|
it("delegates empty staged changed gates without rediscovering unstaged paths", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-check-changed-empty-staged-delegate-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeFileSync(path.join(dir, "README.md"), "initial\n", "utf8");
|
|
commitAll(dir, "initial");
|
|
mkdirSync(path.join(dir, "src"), { recursive: true });
|
|
writeFileSync(path.join(dir, "src", "unstaged.ts"), "export const unstaged = 1;\n", "utf8");
|
|
|
|
const args = buildChangedCheckCrabboxArgs(["--staged", "--timed"], { cwd: dir });
|
|
|
|
expect(args.slice(args.indexOf("check:changed") + 1)).toEqual(["--timed", "--no-changes"]);
|
|
});
|
|
|
|
it("does not delegate dry-run, CI, or remote-child changed gates", () => {
|
|
expect(shouldDelegateChangedCheckToCrabbox(["--dry-run"], {})).toBe(false);
|
|
expect(shouldDelegateChangedCheckToCrabbox([], { GITHUB_ACTIONS: "true" })).toBe(false);
|
|
expect(shouldDelegateChangedCheckToCrabbox([], { CI: "1" })).toBe(false);
|
|
expect(
|
|
shouldDelegateChangedCheckToCrabbox([], { OPENCLAW_CHECK_CHANGED_REMOTE_CHILD: "1" }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("runs changed-check lint lanes under the parent heavy-check lock", () => {
|
|
const result = detectChangedLanes(["extensions/lmstudio/src/api.ts"]);
|
|
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
|
const lintCommand = plan.commands.find(
|
|
(command) => command.name === "lint extension changed file",
|
|
);
|
|
|
|
expect(lintCommand?.env).toEqual({
|
|
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
|
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
|
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
|
PATH: "/usr/bin",
|
|
});
|
|
});
|
|
|
|
it("runs changed-check app tests under the parent heavy-check lock", () => {
|
|
const result = detectChangedLanes([
|
|
"apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
|
|
]);
|
|
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
|
const testCommand = plan.commands.find((command) => command.args[0] === "test:macos:ci");
|
|
|
|
expect(testCommand?.env).toEqual({
|
|
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
|
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
|
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
|
PATH: "/usr/bin",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "routes core test-only changes to core test lanes only",
|
|
path: "packages/normalization-core/src/string-normalization.test-support.ts",
|
|
expected: {
|
|
lanes: { coreTests: true },
|
|
includes: ["tsgo:core:test"],
|
|
excludes: ["tsgo:core"],
|
|
},
|
|
},
|
|
{
|
|
name: "routes extension production changes to extension prod and extension test lanes",
|
|
path: "extensions/lmstudio/src/api.ts",
|
|
expected: {
|
|
lanes: { extensions: true, extensionTests: true },
|
|
includes: ["tsgo:extensions", "tsgo:extensions:test"],
|
|
excludes: [],
|
|
},
|
|
},
|
|
{
|
|
name: "routes extension test-only changes to extension test lanes only",
|
|
path: "extensions/discord/src/index.test-helpers.ts",
|
|
expected: {
|
|
lanes: { extensionTests: true },
|
|
includes: ["tsgo:extensions:test"],
|
|
excludes: ["tsgo:extensions"],
|
|
},
|
|
},
|
|
])("$name", ({ path: changedPath, expected }) => {
|
|
const result = detectChangedLanes([changedPath]);
|
|
const commands = createChangedCheckPlan(result).commands.map((command) => command.args[0]);
|
|
|
|
expectLanes(result.lanes, expected.lanes);
|
|
for (const command of expected.includes) {
|
|
expect(commands).toContain(command);
|
|
}
|
|
for (const command of expected.excludes) {
|
|
expect(commands).not.toContain(command);
|
|
}
|
|
});
|
|
|
|
it("expands public core/plugin contracts to extension validation", () => {
|
|
const result = detectChangedLanes(["src/plugin-sdk/core.ts"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.extensionImpactFromCore).toBe(true);
|
|
expectLanes(result.lanes, {
|
|
core: true,
|
|
coreTests: true,
|
|
extensions: true,
|
|
extensionTests: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:core");
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:extensions:test");
|
|
});
|
|
|
|
it("fails safe for root config changes", () => {
|
|
const result = detectChangedLanes(["pnpm-lock.yaml"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.lanes.all).toBe(true);
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:all");
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("test");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "routes gitignore changes to tooling instead of all lanes",
|
|
paths: [".gitignore"],
|
|
excludesTests: true,
|
|
},
|
|
{
|
|
name: "routes root hygiene config changes to tooling instead of all lanes",
|
|
paths: [
|
|
".dockerignore",
|
|
".jscpd.json",
|
|
".npmignore",
|
|
".pre-commit-config.yaml",
|
|
".swiftformat",
|
|
".swiftlint.yml",
|
|
"Makefile",
|
|
"config/knip.config.ts",
|
|
"config/markdownlint-cli2.jsonc",
|
|
"config/shellcheckrc",
|
|
"config/swiftformat",
|
|
"config/swiftlint.yml",
|
|
"deploy/fly.private.toml",
|
|
"docker-setup.sh",
|
|
"openclaw.podman.env",
|
|
"setup-podman.sh",
|
|
"skills/pyproject.toml",
|
|
],
|
|
excludesTests: true,
|
|
},
|
|
{
|
|
name: "routes VS Code workspace settings to tooling instead of all lanes",
|
|
paths: [".vscode/settings.json", ".vscode/extensions.json"],
|
|
excludesTests: true,
|
|
},
|
|
{
|
|
name: "routes legacy root sandbox Dockerfile moves to tooling instead of all lanes",
|
|
paths: [
|
|
"Dockerfile.sandbox",
|
|
"Dockerfile.sandbox-browser",
|
|
"Dockerfile.sandbox-common",
|
|
"scripts/docker/sandbox/Dockerfile",
|
|
"scripts/docker/sandbox/Dockerfile.browser",
|
|
"scripts/docker/sandbox/Dockerfile.common",
|
|
],
|
|
excludesTests: true,
|
|
},
|
|
{
|
|
name: "routes legacy root asset deletions as tooling during root cleanup",
|
|
paths: ["assets/avatar-placeholder.svg", "assets/chrome-extension/icons/icon128.png"],
|
|
excludesTests: false,
|
|
},
|
|
])("$name", ({ paths, excludesTests }) => {
|
|
const result = detectChangedLanes(paths);
|
|
const commands = createChangedCheckPlan(result).commands.map((command) => command.args[0]);
|
|
|
|
expectLanes(result.lanes, { tooling: true });
|
|
expect(commands).toContain("lint:scripts");
|
|
expect(commands).not.toContain("tsgo:all");
|
|
if (excludesTests) {
|
|
expect(commands).not.toContain("test");
|
|
}
|
|
});
|
|
|
|
it("routes live Docker ACP tooling changes through a focused gate", () => {
|
|
const result = detectChangedLanes([
|
|
"scripts/lib/live-docker-auth.sh",
|
|
"scripts/test-docker-all.mjs",
|
|
"scripts/test-live-acp-bind-docker.sh",
|
|
"src/gateway/gateway-acp-bind.live.test.ts",
|
|
"docs/help/testing-live.md",
|
|
]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expectLanes(result.lanes, {
|
|
docs: true,
|
|
liveDockerTooling: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.name)).toEqual([
|
|
"conflict markers",
|
|
"environment variable count ratchet",
|
|
"max-lines suppression ratchet",
|
|
"changelog attributions",
|
|
"doctor deprecation registry",
|
|
"guarded extension wildcard re-exports",
|
|
"plugin-sdk wildcard re-exports",
|
|
"duplicate scan target coverage",
|
|
"coercion helper declaration guard",
|
|
"dependency pin guard",
|
|
"format changed files",
|
|
"deprecated API usage",
|
|
"plugin boundaries",
|
|
"wrapper shadowing",
|
|
"package patch guard",
|
|
// These live-Docker paths include `src/gateway/*.live.test.ts`, and the
|
|
// full-tree knip scan sees test files, so a deleted last consumer can
|
|
// orphan an export here too.
|
|
"dead export scan (skip with OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1)",
|
|
"test temp creation report (warning-only)",
|
|
"typecheck core tests",
|
|
"lint core",
|
|
"lint scripts",
|
|
"live Docker shell syntax",
|
|
"live Docker scheduler dry run",
|
|
]);
|
|
expect(plan.commands.find((command) => command.name === "live Docker shell syntax")).toEqual({
|
|
name: "live Docker shell syntax",
|
|
bin: "bash",
|
|
args: [
|
|
"-n",
|
|
"scripts/lib/live-docker-auth.sh",
|
|
"scripts/test-live-acp-bind-docker.sh",
|
|
"scripts/test-live-cli-backend-docker.sh",
|
|
"scripts/test-live-codex-harness-docker.sh",
|
|
"scripts/test-live-gateway-models-docker.sh",
|
|
"scripts/test-live-models-docker.sh",
|
|
"scripts/test-live-subagent-announce-docker.sh",
|
|
],
|
|
});
|
|
const schedulerDryRun = plan.commands.find(
|
|
(command) => command.name === "live Docker scheduler dry run",
|
|
);
|
|
expect(schedulerDryRun?.bin).toBe("node");
|
|
expect(schedulerDryRun?.args).toEqual(["scripts/test-docker-all.mjs"]);
|
|
expect(schedulerDryRun?.env?.OPENCLAW_DOCKER_ALL_DRY_RUN).toBe("1");
|
|
expect(schedulerDryRun?.env?.OPENCLAW_DOCKER_ALL_LIVE_MODE).toBe("only");
|
|
});
|
|
|
|
it("routes live Docker package script-only changes through the focused gate", () => {
|
|
const before = prettyJson({
|
|
name: "fixture",
|
|
scripts: { "test:docker:all": "node scripts/test-docker-all.mjs" },
|
|
dependencies: { leftpad: "1.0.0" },
|
|
});
|
|
const after = prettyJson({
|
|
name: "fixture",
|
|
scripts: {
|
|
"test:docker:all": "node scripts/test-docker-all.mjs",
|
|
"test:docker:live-acp-bind:droid":
|
|
"OPENCLAW_LIVE_ACP_BIND_AGENT=droid bash scripts/test-live-acp-bind-docker.sh",
|
|
},
|
|
dependencies: { leftpad: "1.0.0" },
|
|
});
|
|
|
|
expect(isLiveDockerPackageScriptOnlyChange(before, after)).toBe(true);
|
|
|
|
const result = detectChangedLanes(["package.json"], {
|
|
packageJsonChangeKind: "liveDockerTooling",
|
|
});
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expectLanes(result.lanes, {
|
|
liveDockerTooling: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.name)).toContain("live Docker scheduler dry run");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "classifies live Docker package script changes from the git diff",
|
|
prefix: "openclaw-live-docker-package-",
|
|
before: {
|
|
name: "fixture",
|
|
scripts: { "test:docker:all": "node scripts/test-docker-all.mjs" },
|
|
},
|
|
after: {
|
|
name: "fixture",
|
|
scripts: {
|
|
"test:docker:all": "node scripts/test-docker-all.mjs",
|
|
"test:docker:live-acp-bind:droid":
|
|
"OPENCLAW_LIVE_ACP_BIND_AGENT=droid bash scripts/test-live-acp-bind-docker.sh",
|
|
},
|
|
},
|
|
expected: { liveDockerTooling: true },
|
|
},
|
|
{
|
|
name: "classifies normal package script changes from the git diff",
|
|
prefix: "openclaw-package-scripts-",
|
|
before: {
|
|
name: "fixture",
|
|
scripts: { test: "node --import tsx scripts/test-projects.mts" },
|
|
dependencies: { leftpad: "1.0.0" },
|
|
},
|
|
after: {
|
|
name: "fixture",
|
|
scripts: {
|
|
test: "node --import tsx scripts/test-projects.mts",
|
|
"test:profile": "node scripts/profile-tests.mjs",
|
|
},
|
|
dependencies: { leftpad: "1.0.0" },
|
|
},
|
|
expected: { tooling: true },
|
|
},
|
|
])("$name", ({ prefix, before, after, expected }) => {
|
|
const result = classifyPackageJsonChange(prefix, before, after);
|
|
|
|
expect(result.paths).toEqual(["package.json"]);
|
|
expectLanes(result.lanes, expected);
|
|
});
|
|
|
|
it("keeps non-script package changes off the live Docker focused gate", () => {
|
|
const before = prettyJson({
|
|
name: "fixture",
|
|
scripts: {},
|
|
dependencies: { leftpad: "1.0.0" },
|
|
});
|
|
const after = prettyJson({
|
|
name: "fixture",
|
|
scripts: {
|
|
"test:docker:live-acp-bind:droid":
|
|
"OPENCLAW_LIVE_ACP_BIND_AGENT=droid bash scripts/test-live-acp-bind-docker.sh",
|
|
},
|
|
dependencies: { leftpad: "1.0.1" },
|
|
});
|
|
|
|
expect(isLiveDockerPackageScriptOnlyChange(before, after)).toBe(false);
|
|
});
|
|
|
|
it("routes package script-only changes through the tooling gate", () => {
|
|
const before = prettyJson({
|
|
name: "fixture",
|
|
scripts: { test: "node test.js" },
|
|
dependencies: { leftpad: "1.0.0" },
|
|
});
|
|
const after = prettyJson({
|
|
name: "fixture",
|
|
scripts: { test: "node test.js", "test:profile": "node scripts/profile-tests.mjs" },
|
|
dependencies: { leftpad: "1.0.0" },
|
|
});
|
|
|
|
expect(isPackageScriptOnlyChange(before, after)).toBe(true);
|
|
|
|
const result = detectChangedLanes(["package.json"], {
|
|
packageJsonChangeKind: "tooling",
|
|
});
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expectLanes(result.lanes, {
|
|
tooling: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("lint:scripts");
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all");
|
|
});
|
|
|
|
it("keeps release metadata commits off the full changed gate", () => {
|
|
const result = detectChangedLanes([
|
|
"CHANGELOG.md",
|
|
"apps/android/CHANGELOG.md",
|
|
"apps/android/Config/Version.properties",
|
|
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
|
|
"apps/android/version.json",
|
|
"apps/ios/CHANGELOG.md",
|
|
"apps/macos/Sources/OpenClaw/Resources/Info.plist",
|
|
"docs/.generated/config-baseline.counts.json",
|
|
"docs/.generated/config-baseline.sha256",
|
|
"package.json",
|
|
]);
|
|
const plan = createChangedCheckPlan(result, { staged: true });
|
|
|
|
expectLanes(result.lanes, {
|
|
docs: true,
|
|
releaseMetadata: true,
|
|
});
|
|
const commands = plan.commands.map((command) => command.args[0]);
|
|
expect(commands).toEqual([
|
|
"check:no-conflict-markers",
|
|
"check:changelog-attributions",
|
|
"check:doctor-deprecation-registry",
|
|
"lint:extensions:no-guarded-wildcard-reexports",
|
|
"lint:extensions:no-plugin-sdk-wildcard-reexports",
|
|
"dup:check:coverage",
|
|
"check:coercion-helpers",
|
|
"deps:pins:check",
|
|
"format:check",
|
|
"--import",
|
|
"check:deprecated-api-usage",
|
|
"plugins:boundary-report:ci",
|
|
"check:wrapper-shadowing",
|
|
"deps:patches:check",
|
|
"release-metadata:check",
|
|
"android:version:check",
|
|
"config:schema:check",
|
|
"config:docs:check",
|
|
"deps:root-ownership:check",
|
|
]);
|
|
expect(commands).not.toContain("ios:version:check");
|
|
expect(
|
|
plan.commands.find((command) => command.args[0] === "release-metadata:check")?.args,
|
|
).toEqual(["release-metadata:check", "--staged"]);
|
|
});
|
|
|
|
it("passes release metadata base and head refs as options", () => {
|
|
const result = detectChangedLanes(["CHANGELOG.md"]);
|
|
const plan = createChangedCheckPlan(result, { base: "main", head: "feature" });
|
|
|
|
expect(
|
|
plan.commands.find((command) => command.args[0] === "release-metadata:check")?.args,
|
|
).toEqual(["release-metadata:check", "--base", "main", "--head", "feature"]);
|
|
});
|
|
|
|
it("keeps docs plus changelog entries on the docs-only changed gate", () => {
|
|
const result = detectChangedLanes(["CHANGELOG.md", "docs/tools/index.md"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.docsOnly).toBe(true);
|
|
expectLanes(result.lanes, {
|
|
docs: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("release-metadata:check");
|
|
});
|
|
|
|
it("runs the npm package-lock guard for dependency package surfaces", () => {
|
|
expect(
|
|
shouldRunNpmLockGuard([
|
|
"extensions/slack/package.json",
|
|
"extensions/slack/deps/local-runtime/package.json",
|
|
"scripts/generate-npm-package-lock.mts",
|
|
]),
|
|
).toBe(true);
|
|
|
|
const result = detectChangedLanes(["extensions/slack/package.json"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
const npmLockGuard = createNpmLockGuardCommand(["extensions/slack/package.json"]);
|
|
|
|
expect(npmLockGuard?.args.slice(0, 3)).toEqual([
|
|
"--import",
|
|
"tsx",
|
|
"scripts/generate-npm-package-lock.mts",
|
|
]);
|
|
expect(
|
|
npmLockGuard?.args.some((arg) => arg.replaceAll("\\", "/").endsWith("extensions/slack")),
|
|
).toBe(true);
|
|
expect(plan.commands.map((command) => command.name)).toContain("npm package-lock guard");
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("deps:npm-lock:check");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "runs prompt snapshot drift checks for prompt snapshot generator surfaces",
|
|
predicate: shouldRunPromptSnapshotCheck,
|
|
predicatePaths: [
|
|
"scripts/generate-prompt-snapshots.ts",
|
|
"test/helpers/agents/happy-path-prompt-snapshots.ts",
|
|
"test/fixtures/agents/prompt-snapshots/runtime-happy-path/telegram-direct-codex-message-tool.md",
|
|
],
|
|
changedPath: "test/helpers/agents/happy-path-prompt-snapshots.ts",
|
|
expected: {
|
|
exact: [{ name: "prompt snapshot drift", args: ["prompt:snapshots:check"] }],
|
|
partial: [
|
|
{
|
|
name: "prompt snapshot owner test",
|
|
args: ["test:serial", "test/scripts/prompt-snapshots.test.ts"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "runs the prompt snapshot owner test for model fixture generator surfaces",
|
|
predicate: shouldRunPromptSnapshotOwnerTest,
|
|
predicatePaths: [
|
|
"scripts/sync-codex-model-prompt-fixture.ts",
|
|
"test/fixtures/agents/prompt-snapshots/codex-model-catalog/gpt-5.5.pragmatic.source.json",
|
|
],
|
|
changedPath: "scripts/sync-codex-model-prompt-fixture.ts",
|
|
expected: {
|
|
exact: [],
|
|
partial: [
|
|
{
|
|
name: "prompt snapshot owner test",
|
|
args: ["test:serial", "test/scripts/prompt-snapshots.test.ts"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "runs runtime sidecar baseline checks for baseline owner surfaces",
|
|
predicate: shouldRunRuntimeSidecarBaselineCheck,
|
|
predicatePaths: [
|
|
"scripts/generate-runtime-sidecar-paths-baseline.ts",
|
|
"scripts/lib/bundled-runtime-sidecar-paths.json",
|
|
"src/plugins/runtime-sidecar-paths-baseline.ts",
|
|
"src/plugins/runtime-sidecar-paths.ts",
|
|
],
|
|
changedPath: "scripts/lib/bundled-runtime-sidecar-paths.json",
|
|
expected: {
|
|
exact: [{ name: "runtime sidecar baseline", args: ["runtime-sidecars:check"] }],
|
|
partial: [
|
|
{
|
|
name: "runtime sidecar owner test",
|
|
args: ["test:serial", "src/plugins/bundled-plugin-metadata.test.ts"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "runs doctor contract owner tests for extension module and manifest changes",
|
|
predicate: shouldRunDoctorContractOwnerTests,
|
|
predicatePaths: [
|
|
"extensions/telegram/doctor-contract-api.ts",
|
|
"extensions/telegram/openclaw.plugin.json",
|
|
"extensions/codex/src/migration/session-binding-sidecars.ts",
|
|
],
|
|
changedPath: "extensions/telegram/doctor-contract-api.ts",
|
|
expected: {
|
|
exact: [],
|
|
partial: [
|
|
{
|
|
name: "doctor contract declaration + closure guard tests",
|
|
args: [
|
|
"test:serial",
|
|
"src/plugins/doctor-contract-declarations.test.ts",
|
|
"src/plugins/doctor-contract-closure-guard.test.ts",
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "runs SQLite sessions/transcripts schema baseline checks for baseline owner surfaces",
|
|
predicate: shouldRunSqliteSessionSchemaBaselineCheck,
|
|
predicatePaths: [
|
|
"src/state/openclaw-agent-schema.sql",
|
|
"scripts/generate-sqlite-session-schema-baseline.ts",
|
|
"scripts/lib/sqlite-session-schema-baseline.ts",
|
|
"test/scripts/sqlite-session-schema-baseline.test.ts",
|
|
"docs/.generated/sqlite-session-transcript-schema-baseline.sha256",
|
|
],
|
|
changedPath: "src/state/openclaw-agent-schema.sql",
|
|
expected: {
|
|
exact: [
|
|
{
|
|
name: "SQLite sessions/transcripts schema baseline",
|
|
args: ["sqlite:sessions-schema:check"],
|
|
},
|
|
],
|
|
partial: [],
|
|
},
|
|
},
|
|
])("$name", ({ predicate, predicatePaths, changedPath, expected }) => {
|
|
expect(predicate(predicatePaths)).toBe(true);
|
|
const commands = createChangedCheckPlan(detectChangedLanes([changedPath])).commands;
|
|
for (const command of expected.exact) {
|
|
expect(commands).toContainEqual(command);
|
|
}
|
|
for (const command of expected.partial) {
|
|
expect(commands).toContainEqual(expect.objectContaining(command));
|
|
}
|
|
});
|
|
|
|
it("runs Plugin SDK API checks for transitive public contract changes", () => {
|
|
expect(
|
|
shouldRunPluginSdkApiBaselineCheck([
|
|
"src/config/sessions/session-accessor.ts",
|
|
"packages/gateway-protocol/src/schema/approvals.ts",
|
|
"extensions/memory-core/index.ts",
|
|
"scripts/generate-plugin-sdk-api-baseline.ts",
|
|
"scripts/lib/plugin-sdk-doc-metadata.ts",
|
|
"scripts/lib/plugin-sdk-entries.mts",
|
|
"docs/.generated/plugin-sdk-api-baseline.jsonl",
|
|
]),
|
|
).toBe(true);
|
|
expect(shouldRunPluginSdkApiBaselineCheck(["docs/help/troubleshooting.md"])).toBe(false);
|
|
|
|
const result = detectChangedLanes(["src/config/sessions/session-accessor.ts"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(plan.commands).toContainEqual({
|
|
name: "Plugin SDK API contract manifest",
|
|
args: ["plugin-sdk:api:check"],
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain(
|
|
"plugin-sdk:surface:check",
|
|
);
|
|
});
|
|
|
|
it("runs Plugin SDK export and surface checks for direct SDK changes", () => {
|
|
expect(
|
|
shouldRunPluginSdkSurfaceChecks([
|
|
"src/plugin-sdk/core.ts",
|
|
"scripts/plugin-sdk-surface-report.mts",
|
|
"scripts/sync-plugin-sdk-exports.mts",
|
|
"scripts/lib/plugin-sdk-entries.mts",
|
|
"scripts/lib/plugin-sdk-entrypoints.json",
|
|
"package.json",
|
|
]),
|
|
).toBe(true);
|
|
expect(shouldRunPluginSdkSurfaceChecks(["src/config/sessions/session-accessor.ts"])).toBe(
|
|
false,
|
|
);
|
|
|
|
const result = detectChangedLanes(["src/plugin-sdk/core.ts"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(plan.commands).toContainEqual({
|
|
name: "Plugin SDK API contract manifest",
|
|
args: ["plugin-sdk:api:check"],
|
|
});
|
|
expect(plan.commands).toContainEqual({
|
|
name: "Plugin SDK package exports",
|
|
args: ["plugin-sdk:check-exports"],
|
|
});
|
|
expect(plan.commands).toContainEqual({
|
|
name: "Plugin SDK surface budget",
|
|
args: ["plugin-sdk:surface:check"],
|
|
});
|
|
|
|
const releaseMetadataPlan = createChangedCheckPlan(
|
|
detectChangedLanes(["CHANGELOG.md", "package.json"]),
|
|
);
|
|
expect(releaseMetadataPlan.commands.map((command) => command.args[0])).not.toContain(
|
|
"plugin-sdk:check-exports",
|
|
);
|
|
});
|
|
|
|
it("runs deprecation hygiene checks for outcome-changing paths and all lanes", () => {
|
|
expect(
|
|
shouldRunDeprecationHygieneChecks([
|
|
"src/plugin-sdk/core.ts",
|
|
"extensions/slack/index.ts",
|
|
"packages/gateway-protocol/src/index.ts",
|
|
"scripts/lib/plugin-sdk-entries.mts",
|
|
"scripts/check-deprecated-api-usage.mts",
|
|
"scripts/plugin-boundary-report.ts",
|
|
"src/plugins/compat/registry.ts",
|
|
"package.json",
|
|
]),
|
|
).toBe(true);
|
|
expect(shouldRunDeprecationHygieneChecks(["docs/plugins/sdk-migration.md"])).toBe(false);
|
|
|
|
for (const result of [
|
|
detectChangedLanes(["extensions/slack/index.ts"]),
|
|
detectChangedLanes(["unknown-surface.foo"]),
|
|
]) {
|
|
const plan = createChangedCheckPlan(result);
|
|
expect(plan.commands).toContainEqual({
|
|
name: "deprecated API usage",
|
|
args: ["check:deprecated-api-usage"],
|
|
});
|
|
expect(plan.commands).toContainEqual({
|
|
name: "plugin boundaries",
|
|
args: ["plugins:boundary-report:ci"],
|
|
});
|
|
}
|
|
});
|
|
|
|
it("runs wrapper shadowing for source and guard-owner changes", () => {
|
|
expect(
|
|
shouldRunWrapperShadowingCheck([
|
|
"src/channels/turn/run-channel-turn.ts",
|
|
"scripts/check-wrapper-shadowing.mts",
|
|
"scripts/check-export-name-collisions.mts",
|
|
"scripts/lib/wrapper-shadowing-baseline.json",
|
|
"scripts/lib/ts-guard-utils.mts",
|
|
"package.json",
|
|
]),
|
|
).toBe(true);
|
|
expect(shouldRunWrapperShadowingCheck(["docs/concepts/message-lifecycle.md"])).toBe(false);
|
|
|
|
const plan = createChangedCheckPlan(
|
|
detectChangedLanes(["scripts/check-wrapper-shadowing.mts"]),
|
|
);
|
|
expect(plan.commands).toContainEqual({
|
|
name: "wrapper shadowing",
|
|
args: ["check:wrapper-shadowing"],
|
|
});
|
|
});
|
|
|
|
it("guards release metadata package changes to the top-level version field", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-release-metadata-");
|
|
git(dir, ["init", "-q", "--initial-branch=main"]);
|
|
writeFileSync(
|
|
path.join(dir, "package.json"),
|
|
`${JSON.stringify({ name: "fixture", version: "2026.4.20", dependencies: { leftpad: "1.0.0" } }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
commitAll(dir, "initial");
|
|
|
|
writeFileSync(
|
|
path.join(dir, "package.json"),
|
|
`${JSON.stringify({ name: "fixture", version: "2026.4.21", dependencies: { leftpad: "1.0.0" } }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
git(dir, ["add", "package.json"]);
|
|
expect(
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
"--import",
|
|
tsxImport,
|
|
path.join(repoRoot, "scripts", "check-release-metadata-only.mts"),
|
|
"--staged",
|
|
],
|
|
{
|
|
cwd: dir,
|
|
env: {
|
|
...createNestedGitEnv(),
|
|
TSX_TSCONFIG_PATH: path.join(repoRoot, "tsconfig.json"),
|
|
},
|
|
stdio: "pipe",
|
|
},
|
|
),
|
|
).toBeInstanceOf(Buffer);
|
|
|
|
writeFileSync(
|
|
path.join(dir, "package.json"),
|
|
`${JSON.stringify({ name: "fixture", version: "2026.4.21", dependencies: { leftpad: "1.0.1" } }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
git(dir, ["add", "package.json"]);
|
|
let failure: ExecFileSyncFailure | undefined;
|
|
try {
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
"--import",
|
|
tsxImport,
|
|
path.join(repoRoot, "scripts", "check-release-metadata-only.mts"),
|
|
"--staged",
|
|
],
|
|
{
|
|
cwd: dir,
|
|
env: {
|
|
...createNestedGitEnv(),
|
|
TSX_TSCONFIG_PATH: path.join(repoRoot, "tsconfig.json"),
|
|
},
|
|
stdio: "pipe",
|
|
},
|
|
);
|
|
} catch (error) {
|
|
failure = error as ExecFileSyncFailure;
|
|
}
|
|
|
|
expect(failure?.status).toBe(1);
|
|
expect(failure?.stderr?.toString("utf8")).toContain(
|
|
"[release-metadata] package.json changed outside the top-level version field",
|
|
);
|
|
});
|
|
|
|
it("routes root test/support changes to the tooling test lane instead of all lanes", () => {
|
|
const result = detectChangedLanes([
|
|
"test/git-hooks-pre-commit.test.ts",
|
|
"test-fixtures/legacy-root-fixture.json",
|
|
]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expectLanes(result.lanes, {
|
|
testRoot: true,
|
|
tooling: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("lint:scripts");
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("test");
|
|
});
|
|
|
|
it("routes legacy Swabble deletions as app surface during the app move", () => {
|
|
const result = detectChangedLanes(["Swabble/Sources/SwabbleKit/WakeWordGate.swift"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expectLanes(result.lanes, {
|
|
apps: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all");
|
|
});
|
|
|
|
it("runs macOS app CI tests for macOS app dependency changes", () => {
|
|
for (const changedPath of [
|
|
"apps/macos/Sources/OpenClawMac/AppDelegate.swift",
|
|
"apps/macos-mlx-tts/Sources/OpenClawMLXTTS/main.swift",
|
|
"apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
|
|
"apps/swabble/Sources/SwabbleKit/WakeWordGate.swift",
|
|
"Swabble/Sources/SwabbleKit/WakeWordGate.swift",
|
|
]) {
|
|
const result = detectChangedLanes([changedPath]);
|
|
const plan = createChangedCheckPlan(result, {
|
|
env: { PATH: "/usr/bin" },
|
|
platform: "linux",
|
|
swiftlintAvailable: false,
|
|
});
|
|
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("lint:apps");
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "lint apps (swiftlint unavailable on this host)",
|
|
bin: "node",
|
|
}),
|
|
);
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "macOS app CI tests",
|
|
args: ["test:macos:ci"],
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("runs macOS CI tests for workspace rsync receiver owners", () => {
|
|
for (const changedPath of [
|
|
"src/worker/workspace-rsync-receiver.ts",
|
|
"src/gateway/worker-environments/workspace-sync.ts",
|
|
"src/gateway/worker-environments/workspace-sync-helpers.ts",
|
|
"src/gateway/worker-environments/workspace-accepted-sync.ts",
|
|
"src/gateway/worker-environments/workspace-accepted-remote-script.ts",
|
|
"src/gateway/worker-environments/workspace-mutation-remote-script.ts",
|
|
"src/gateway/worker-environments/workspace-rsync-path.test.ts",
|
|
]) {
|
|
const plan = createChangedCheckPlan(detectChangedLanes([changedPath]), {
|
|
env: { PATH: "/usr/bin" },
|
|
platform: "linux",
|
|
swiftlintAvailable: false,
|
|
});
|
|
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "macOS app CI tests",
|
|
args: ["test:macos:ci"],
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("runs the native state schema guard for either contract owner", () => {
|
|
for (const changedPath of [
|
|
"apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift",
|
|
"src/state/openclaw-state-db-contract.ts",
|
|
]) {
|
|
const plan = createChangedCheckPlan(detectChangedLanes([changedPath]), {
|
|
env: { PATH: "/usr/bin" },
|
|
platform: "linux",
|
|
swiftlintAvailable: false,
|
|
});
|
|
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "native state schema version guard",
|
|
bin: "node",
|
|
args: ["scripts/check-native-state-schema-version.mjs"],
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("runs macOS app CI tests for macOS packaging scripts and owner tests", () => {
|
|
for (const changedPath of [
|
|
"scripts/codesign-mac-app.sh",
|
|
"scripts/create-dmg.sh",
|
|
"scripts/lib/plistbuddy.sh",
|
|
"scripts/lib/swift-toolchain.sh",
|
|
"scripts/notarize-mac-artifact.sh",
|
|
"scripts/package-mac-app.sh",
|
|
"scripts/package-mac-dist.sh",
|
|
"test/scripts/codesign-mac-app.test.ts",
|
|
"test/scripts/create-dmg.test.ts",
|
|
"test/scripts/notarize-mac-artifact.test.ts",
|
|
"test/scripts/package-mac-app.test.ts",
|
|
"test/scripts/package-mac-dist.test.ts",
|
|
]) {
|
|
const result = detectChangedLanes([changedPath]);
|
|
const plan = createChangedCheckPlan(result, {
|
|
env: { PATH: "/usr/bin" },
|
|
platform: "linux",
|
|
swiftlintAvailable: false,
|
|
});
|
|
|
|
expectLanes(result.lanes, {
|
|
testRoot: changedPath.endsWith(".ts"),
|
|
tooling: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("lint:apps");
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "macOS app CI tests",
|
|
args: ["test:macos:ci"],
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("routes appcast changes to appcast owner tests", () => {
|
|
const result = detectChangedLanes(["appcast.xml"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(shouldRunAppcastOwnerTest(result.paths)).toBe(true);
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "appcast owner tests",
|
|
args: ["test:serial", "test/appcast.test.ts", "test/scripts/make-appcast.test.ts"],
|
|
}),
|
|
);
|
|
expect(plan.commands.map((command) => command.name)).not.toContain("macOS app CI tests");
|
|
});
|
|
|
|
it("runs app lint when SwiftLint is available in Testbox", () => {
|
|
const result = detectChangedLanes([
|
|
"apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
|
|
]);
|
|
const plan = createChangedCheckPlan(result, {
|
|
env: { CI: "1", PATH: "/usr/bin" },
|
|
platform: "linux",
|
|
swiftlintAvailable: true,
|
|
});
|
|
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("lint:apps");
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "macOS app CI tests",
|
|
args: ["test:macos:ci"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps macOS app CI tests out of Android-only app changes", () => {
|
|
const result = detectChangedLanes(["apps/android/app/src/main/AndroidManifest.xml"]);
|
|
const plan = createChangedCheckPlan(result, {
|
|
env: { CI: "1", PATH: "/usr/bin" },
|
|
platform: "linux",
|
|
swiftlintAvailable: true,
|
|
});
|
|
|
|
expectLanes(result.lanes, {
|
|
apps: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.name)).not.toContain("macOS app CI tests");
|
|
});
|
|
|
|
it("routes A2UI bundle source changes as extension changes", () => {
|
|
const result = detectChangedLanes([
|
|
"extensions/canvas/src/host/a2ui-app/bootstrap.js",
|
|
"extensions/canvas/src/host/a2ui-app/rolldown.config.mjs",
|
|
]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expectLanes(result.lanes, {
|
|
extensions: true,
|
|
extensionTests: true,
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:extensions");
|
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all");
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "Canvas A2UI native resource generation",
|
|
bin: "node",
|
|
args: ["--import", "tsx", "scripts/sync-native-a2ui.mts", "--check"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("checks native A2UI resources when the copied resource tree changes", () => {
|
|
const result = detectChangedLanes([
|
|
"apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js",
|
|
]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expectLanes(result.lanes, {
|
|
apps: true,
|
|
});
|
|
expect(shouldRunCanvasA2uiNativeResourceCheck(result.paths)).toBe(true);
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "Canvas A2UI native resource generation",
|
|
bin: "node",
|
|
args: ["--import", "tsx", "scripts/sync-native-a2ui.mts", "--check"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("checks native A2UI resources when bundle inputs or generated outputs change", () => {
|
|
const result = detectChangedLanes([
|
|
"extensions/canvas/package.json",
|
|
"extensions/canvas/src/host/a2ui/.bundle.hash",
|
|
"extensions/canvas/src/host/a2ui/a2ui.bundle.js",
|
|
"pnpm-lock.yaml",
|
|
]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(shouldRunCanvasA2uiNativeResourceCheck(result.paths)).toBe(true);
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "Canvas A2UI native resource generation",
|
|
bin: "node",
|
|
args: ["--import", "tsx", "scripts/sync-native-a2ui.mts", "--check"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"apps/android/app/build.gradle.kts",
|
|
"apps/ios/project.yml",
|
|
"apps/linux/src-tauri/build.rs",
|
|
"apps/linux/src-tauri/src/canvas.rs",
|
|
])("checks native A2UI ownership when %s changes", (ownerPath) => {
|
|
const result = detectChangedLanes([ownerPath]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(shouldRunCanvasA2uiNativeResourceCheck(result.paths)).toBe(true);
|
|
expect(plan.commands).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "Canvas A2UI native resource generation",
|
|
bin: "node",
|
|
args: ["--import", "tsx", "scripts/sync-native-a2ui.mts", "--check"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "keeps shared Vitest wiring changes out of check test execution",
|
|
paths: ["test/vitest/vitest.shared.config.ts"],
|
|
expected: "lint:scripts",
|
|
},
|
|
{
|
|
name: "keeps setup changes out of check test execution",
|
|
paths: ["test/setup.ts"],
|
|
expected: "lint:scripts",
|
|
},
|
|
{
|
|
name: "does not route generated plugin bundle artifacts as direct Vitest targets",
|
|
paths: [
|
|
"extensions/demo/src/host/assets/.bundle.hash",
|
|
"extensions/canvas/scripts/bundle-a2ui.test.ts",
|
|
],
|
|
expected: "tsgo:extensions",
|
|
},
|
|
{
|
|
name: "routes changed extension Vitest configs to only their owning shard",
|
|
paths: ["test/vitest/vitest.extension-discord.config.ts"],
|
|
expected: "lint:scripts",
|
|
},
|
|
])("$name", ({ paths, expected }) => {
|
|
const commands = createChangedCheckPlan(detectChangedLanes(paths)).commands.map(
|
|
(command) => command.args[0],
|
|
);
|
|
|
|
expect(commands).toContain(expected);
|
|
expect(commands).not.toContain("test");
|
|
});
|
|
|
|
it("adds the warning-only temp creation report for changed test paths", () => {
|
|
const result = detectChangedLanes(["test/helpers/temp-fixture.ts"]);
|
|
const plan = createChangedCheckPlan(result, { base: "main", head: "feature" });
|
|
const command = plan.commands.find(
|
|
(candidate) => candidate.name === "test temp creation report (warning-only)",
|
|
);
|
|
|
|
expect(shouldRunTestTempCreationReport(result.paths)).toBe(true);
|
|
expect(command).toMatchObject({
|
|
bin: "node",
|
|
args: ["scripts/report-test-temp-creations.mjs", "--base", "main", "--head", "feature"],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "adds the max-lines suppression ratchet with worktree and staged bases",
|
|
commandName: "max-lines suppression ratchet",
|
|
worktreeOptions: { base: "main", head: "feature" },
|
|
expected: {
|
|
worktree: ["check:max-lines-ratchet", "--base", "main"],
|
|
staged: ["check:max-lines-ratchet", "--staged", "--base", "HEAD"],
|
|
},
|
|
},
|
|
{
|
|
name: "adds the environment variable count ratchet for production source",
|
|
commandName: "environment variable count ratchet",
|
|
worktreeOptions: { base: "main" },
|
|
expected: {
|
|
worktree: ["check:env-var-count", "--base", "main"],
|
|
staged: ["check:env-var-count", "--staged", "--base", "HEAD"],
|
|
},
|
|
},
|
|
])("$name", ({ commandName, worktreeOptions, expected }) => {
|
|
const result = detectChangedLanes(["src/runtime.ts"]);
|
|
const worktreePlan = createChangedCheckPlan(result, worktreeOptions);
|
|
const stagedPlan = createChangedCheckPlan(result, { staged: true });
|
|
|
|
expect(worktreePlan.commands.find((command) => command.name === commandName)).toMatchObject({
|
|
args: expected.worktree,
|
|
});
|
|
expect(stagedPlan.commands.find((command) => command.name === commandName)).toMatchObject({
|
|
args: expected.staged,
|
|
});
|
|
});
|
|
|
|
it("keeps the temp creation report out of non-test changed paths", () => {
|
|
const result = detectChangedLanes(["scripts/check-changed.mjs"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(shouldRunTestTempCreationReport(result.paths)).toBe(false);
|
|
expect(plan.commands.map((command) => command.name)).not.toContain(
|
|
"test temp creation report (warning-only)",
|
|
);
|
|
});
|
|
|
|
it("keeps an empty changed path list as a no-op", () => {
|
|
const result = detectChangedLanes([]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.lanes).toEqual({
|
|
core: false,
|
|
coreTests: false,
|
|
ui: false,
|
|
extensions: false,
|
|
extensionTests: false,
|
|
scripts: false,
|
|
testRoot: false,
|
|
apps: false,
|
|
docs: false,
|
|
tooling: false,
|
|
liveDockerTooling: false,
|
|
bundledChannelConfigMetadata: false,
|
|
releaseMetadata: false,
|
|
all: false,
|
|
});
|
|
expect(plan.commands).toEqual([
|
|
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
|
|
{ name: "changelog attributions", args: ["check:changelog-attributions"] },
|
|
{ name: "doctor deprecation registry", args: ["check:doctor-deprecation-registry"] },
|
|
{
|
|
name: "guarded extension wildcard re-exports",
|
|
args: ["lint:extensions:no-guarded-wildcard-reexports"],
|
|
},
|
|
{
|
|
name: "plugin-sdk wildcard re-exports",
|
|
args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"],
|
|
},
|
|
{ name: "duplicate scan target coverage", args: ["dup:check:coverage"] },
|
|
{ name: "coercion helper declaration guard", args: ["check:coercion-helpers"] },
|
|
{ name: "dependency pin guard", args: ["deps:pins:check"] },
|
|
{ name: "package patch guard", args: ["deps:patches:check"] },
|
|
]);
|
|
});
|
|
|
|
it("keeps docs-only changes cheap", () => {
|
|
const result = detectChangedLanes(["docs/ci.md", "README.md"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.docsOnly).toBe(true);
|
|
expect(plan.commands).toEqual([
|
|
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
|
|
{ name: "changelog attributions", args: ["check:changelog-attributions"] },
|
|
{ name: "doctor deprecation registry", args: ["check:doctor-deprecation-registry"] },
|
|
{
|
|
name: "guarded extension wildcard re-exports",
|
|
args: ["lint:extensions:no-guarded-wildcard-reexports"],
|
|
},
|
|
{
|
|
name: "plugin-sdk wildcard re-exports",
|
|
args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"],
|
|
},
|
|
{ name: "duplicate scan target coverage", args: ["dup:check:coverage"] },
|
|
{ name: "coercion helper declaration guard", args: ["check:coercion-helpers"] },
|
|
{ name: "dependency pin guard", args: ["deps:pins:check"] },
|
|
{
|
|
name: "format changed files",
|
|
args: ["format:check", "--no-error-on-unmatched-pattern", "--", "docs/ci.md", "README.md"],
|
|
},
|
|
{ name: "package patch guard", args: ["deps:patches:check"] },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("delegationFailedBeforeRunning", () => {
|
|
// The wrapper only prints a run summary once the command reached the box, so
|
|
// the summary is the evidence that a verdict exists at all.
|
|
it("treats a lease or network failure as never having run", () => {
|
|
const output = [
|
|
'request failed: Get "https://backend.blacksmith.sh/api/testbox/list?all=true": context deadline exceeded',
|
|
"blacksmith testbox run exited 1",
|
|
].join("\n");
|
|
|
|
expect(delegationFailedBeforeRunning(output)).toBe(true);
|
|
});
|
|
|
|
it("treats a reported command exit as a real check failure", () => {
|
|
const output = [
|
|
" 64.95s failed:1 typecheck core tests",
|
|
'{"provider":"blacksmith-testbox","runStatus":"failed","errorKind":"command-exit","exitCode":1}',
|
|
].join("\n");
|
|
|
|
// Falling back locally here would re-run on macOS and could pass a lane
|
|
// whose truth is Linux, turning a red gate green.
|
|
expect(delegationFailedBeforeRunning(output)).toBe(false);
|
|
});
|
|
|
|
it("treats a full workload-routing provider outage as never having run", () => {
|
|
// Provider selection happens before any dispatch, so an exhausted routing
|
|
// chain (every doctor failing) can never carry a remote verdict.
|
|
const output = [
|
|
"[crabbox] no ready provider for workload=ci-fast",
|
|
"[crabbox] provider readiness blacksmith-testbox:doctor exited 1,daytona:doctor exited 124,azure:doctor exited 124,aws:doctor exited 124",
|
|
].join("\n");
|
|
|
|
expect(delegationFailedBeforeRunning(output)).toBe(true);
|
|
});
|
|
|
|
it("does not mistake an infrastructure error kind for a command verdict", () => {
|
|
const output = [
|
|
"failed to acquire lease for testbox",
|
|
'{"provider":"blacksmith-testbox","runStatus":"failed","errorKind":"lease-timeout","exitCode":1}',
|
|
].join("\n");
|
|
|
|
expect(delegationFailedBeforeRunning(output)).toBe(true);
|
|
});
|
|
|
|
// A crash after dispatch produces no summary either, so absence of one cannot
|
|
// be read as "never ran" — that is how an unknown Linux result would go green.
|
|
it("fails closed when the wrapper dies without saying why", () => {
|
|
expect(delegationFailedBeforeRunning("node: killed\n")).toBe(false);
|
|
expect(delegationFailedBeforeRunning("")).toBe(false);
|
|
});
|
|
|
|
it("keeps a command verdict authoritative even alongside network noise", () => {
|
|
const output = [
|
|
'request failed: Get "https://backend.blacksmith.sh/api/testbox/list": context deadline exceeded',
|
|
'{"provider":"blacksmith-testbox","runStatus":"failed","errorKind":"command-exit","exitCode":1}',
|
|
].join("\n");
|
|
|
|
expect(delegationFailedBeforeRunning(output)).toBe(false);
|
|
});
|
|
});
|