mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 16:51:36 +00:00
* fix(tooling): check changed exports for dead code
* fix(tooling): name mismatched PR wrapper components
* feat(tooling): repin PR review artifacts
* fix(tooling): scope dead-export detection to source files
Matching only the top-level tree let a docs file under src/ carrying `export`
in a code sample set the flag. That mattered because the flag also short-
circuited changedCheckRequiresRemote above its docsOnly guard, so a docs-only
change could be dragged onto the heavy remote route and made to run knip.
Scope detection to the source extensions knip actually reads, and drop the
routing branch entirely: any file that can now trigger detection already
enables a non-docs lane, which the existing final clause routes remotely. The
branch was dead for every real source change and wrong for the docs case.
* refactor(tooling): round-trip the repinned review JSON
Preserving byte-exact formatting required a hand-rolled JSON scanner: string
escape handling, container nesting, and span splicing, for a file that lives in
gitignored .local/ and is only ever read back through JSON.parse.
repin already parses the artifact to validate it, so assign the two identity
fields and re-serialize. JSON.parse/stringify keeps insertion order, which is
the only formatting property worth holding. Drops four helpers.
* fix(tooling): select the dead-export scan by path, not changed lines
Inspecting changed lines for an `export` token has two false negatives that
defeat the purpose. Barrels export through multiline `export { ... }` lists, so
removing a symbol changes a line carrying no `export` token. Worse, the failure
that motivated this scan was an import-only edit: it orphaned a re-export in a
barrel the diff never touched, which no changed-line rule can see.
Select by path instead: any changed production source file under src/,
extensions/, ui/, or packages/. This also removes the merge-base dependency, so
a shallow checkout or unrelated history can no longer silently skip the scan.
Those paths already enable a non-docs lane, so the run already routes remotely
and knip's cost lands on the box already doing the heavy work.
OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1 still opts out.
* Revert "feat(tooling): repin PR review artifacts"
The repin subcommand defeats the property the identity pin exists to enforce.
It rewrites the pinned PR number and head SHA while keeping every
recommendation, finding, and Markdown conclusion, so a completed
"READY FOR /prepare-pr" verdict authored against one revision can be relabelled
as covering another and then pass validation. The test added alongside it
restamped PR #7 as PR #42 and asserted validation succeeded, which is exactly
the substitution the fail-closed gate was built to stop.
Re-authoring a review after the head moves is friction on purpose: the code the
verdict describes may no longer be the code being landed. A safe version has to
prove the reviewed content is equivalent across the move, which is a design
decision about what equivalence means, not a convenience wrapper.
Reverts 5d89a704d9 and its follow-up c6304eb539.
* docs(tooling): note why the wrapper diagnostic reads HEAD blobs
Two reviewers independently read the new component comparison as inspecting
committed state while the refusal was triggered by working-tree files. The
uncommitted-wrapper guard above already exits for any staged or unstaged edit to
these three paths, so HEAD matches the working tree by the time this runs. State
the invariant at the site instead of letting a third reader re-derive it.
* fix(tooling): cover jsx in the dead-export source selector
The hand-written extension alternation matched .tsx but not .jsx, so a JSX
source change would skip a scan that knip does apply to it. Use the
`[cm]?[jt]sx?` selector the lint lanes in check-changed.mjs already use, which
covers both and is shorter, and pin the extension coverage in tests.
2325 lines
84 KiB
TypeScript
2325 lines
84 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 path from "node:path";
|
|
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.mjs";
|
|
import {
|
|
buildChangedCheckCrabboxArgs,
|
|
changedCheckLocalDependenciesReady,
|
|
changedCheckRequiresRemote,
|
|
cleanupCorepackPnpmShimDir,
|
|
createChangedCheckChildEnv,
|
|
createChangedCheckPlan,
|
|
createPnpmManagedCommand,
|
|
createTargetedCoreLintCommand,
|
|
createTargetedExtensionLintCommand,
|
|
createTargetedScriptLintCommand,
|
|
shouldDelegateChangedCheckToCrabbox,
|
|
shouldRunAppcastOwnerTest,
|
|
shouldRunCanvasA2uiNativeResourceCheck,
|
|
shouldRunControlUiI18nVerify,
|
|
shouldRunPromptSnapshotCheck,
|
|
shouldRunPromptSnapshotOwnerTest,
|
|
shouldRunRuntimeSidecarBaselineCheck,
|
|
shouldRunNpmLockGuard,
|
|
shouldRunPluginSdkApiBaselineCheck,
|
|
shouldRunDeprecationHygieneChecks,
|
|
shouldRunPluginSdkSurfaceChecks,
|
|
shouldRunSqliteSessionSchemaBaselineCheck,
|
|
shouldRunTestTempCreationReport,
|
|
createNpmLockGuardCommand,
|
|
delegationFailedBeforeRunning,
|
|
} from "../../scripts/check-changed.mjs";
|
|
import { resolveOxfmtInvocation } from "../../scripts/format-docs.mjs";
|
|
import { isDirectRunPath } from "../../scripts/lib/direct-run.mjs";
|
|
import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
const repoRoot = process.cwd();
|
|
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()) {
|
|
return spawnSync(process.execPath, [script, ...args], {
|
|
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("keeps a non-executed changed-gate warning fixture", () => {
|
|
// openclaw-temp-dir: allow test fixture for the temp warning report
|
|
const warningFixture = 'fs.mkdtemp("openclaw-warning-fixture-", () => {})';
|
|
|
|
expect(warningFixture).toContain("mkdtemp");
|
|
});
|
|
|
|
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 to Blacksmith Testbox");
|
|
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 to Blacksmith Testbox");
|
|
});
|
|
|
|
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", excludes: ["[check:changed]"] },
|
|
},
|
|
])("$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.mjs 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",
|
|
"scripts/lib/example.d.mts",
|
|
"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");
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("check:script-declarations");
|
|
});
|
|
|
|
it("keeps the declaration guard when another change selects the full lane", () => {
|
|
const result = detectChangedLanes(["package.json", "scripts/example.mjs"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
|
|
expect(result.lanes.all).toBe(true);
|
|
expect(plan.commands.map((command) => command.args[0])).toContain("check:script-declarations");
|
|
});
|
|
|
|
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(["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",
|
|
"--provider",
|
|
"blacksmith-testbox",
|
|
"--blacksmith-org",
|
|
"openclaw",
|
|
"--blacksmith-workflow",
|
|
".github/workflows/ci-check-testbox.yml",
|
|
"--blacksmith-job",
|
|
"check",
|
|
"--blacksmith-ref",
|
|
"main",
|
|
"--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: ["scripts/check-deadcode-exports.mjs"],
|
|
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.sha256",
|
|
"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",
|
|
"guarded extension wildcard re-exports",
|
|
"plugin-sdk wildcard re-exports",
|
|
"duplicate scan target coverage",
|
|
"dependency pin guard",
|
|
"format changed files",
|
|
"deprecated API usage",
|
|
"plugin boundaries",
|
|
"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 scripts/test-projects.mjs" },
|
|
dependencies: { leftpad: "1.0.0" },
|
|
},
|
|
after: {
|
|
name: "fixture",
|
|
scripts: {
|
|
test: "node scripts/test-projects.mjs",
|
|
"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,
|
|
});
|
|
expect(plan.commands.map((command) => command.args[0])).toEqual([
|
|
"check:no-conflict-markers",
|
|
"check:changelog-attributions",
|
|
"lint:extensions:no-guarded-wildcard-reexports",
|
|
"lint:extensions:no-plugin-sdk-wildcard-reexports",
|
|
"dup:check:coverage",
|
|
"deps:pins:check",
|
|
"format:check",
|
|
"scripts/generate-npm-package-lock.mjs",
|
|
"check:deprecated-api-usage",
|
|
"plugins:boundary-report:ci",
|
|
"deps:patches:check",
|
|
"release-metadata:check",
|
|
"android:version:check",
|
|
"ios:version:check",
|
|
"config:schema:check",
|
|
"config:docs:check",
|
|
"deps:root-ownership: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.mjs",
|
|
]),
|
|
).toBe(true);
|
|
|
|
const result = detectChangedLanes(["extensions/slack/package.json"]);
|
|
const plan = createChangedCheckPlan(result);
|
|
const npmLockGuard = createNpmLockGuardCommand(["extensions/slack/package.json"]);
|
|
|
|
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 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",
|
|
"docs/.generated/plugin-sdk-api-baseline.sha256",
|
|
]),
|
|
).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.mjs",
|
|
"scripts/sync-plugin-sdk-exports.mjs",
|
|
"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.mjs",
|
|
"scripts/check-deprecated-api-usage.mjs",
|
|
"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("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,
|
|
[path.join(repoRoot, "scripts", "check-release-metadata-only.mjs"), "--staged"],
|
|
{
|
|
cwd: dir,
|
|
env: createNestedGitEnv(),
|
|
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,
|
|
[path.join(repoRoot, "scripts", "check-release-metadata-only.mjs"), "--staged"],
|
|
{
|
|
cwd: dir,
|
|
env: createNestedGitEnv(),
|
|
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 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 sync",
|
|
bin: "node",
|
|
args: ["scripts/sync-native-a2ui.mjs", "--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 sync",
|
|
bin: "node",
|
|
args: ["scripts/sync-native-a2ui.mjs", "--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 sync",
|
|
bin: "node",
|
|
args: ["scripts/sync-native-a2ui.mjs", "--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: "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: "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: "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: "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("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);
|
|
});
|
|
});
|