mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-29 03:01:18 +00:00
* test(agents): deslop x7512 tests * test(agents): deslop x7512 tests * test(agents): deslop x7512 tests * test(gateway): deslop x7516 tests * test(gateway): deslop x7516 tests * test(gateway): deslop x7516 tests * test(node-host): deslop x7514 tests * test(codex): deslop x7518 tests * test(codex): deslop x7518 fixtures and assertions * test(codex): deslop x7518 fixture setup * test(package-update): deslop x7522 tests * test(package-update): deslop x7522 tests * test(package-update): deslop x7522 tests * test(sdk): deslop x7519 tests * test(sdk): further deslop x7519 tests * test(sdk): deslop x7519 tests * test(sessions): deslop x7520 tests * test(sessions): deslop x7520 tests * test(sessions): deslop x7520 tests * test(gateway): deslop x7521 tests * test(gateway): deslop x7521 tests * test(gateway): deslop x7521 tests * test(ui): deslop x7525 tests * test(ui): deslop x7525 tests * test(ui): deslop x7525 tests * test(code-mode): deslop x7523 tests * test(code-mode): deslop x7523 tests * test(code-mode): deslop x7523 tests * test: repair d206 fixture regressions from CI * test: retain independent d206 regression coverage * test: retain subagent restart fixture baseline * test: shrink d206 mock export exceptions
665 lines
26 KiB
TypeScript
665 lines
26 KiB
TypeScript
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
commitArgs,
|
|
createContentGuardFixture,
|
|
installFormattingRecorder,
|
|
installPreCommitFixture,
|
|
readFormatterLog,
|
|
literals,
|
|
rulePath,
|
|
ruleSetting,
|
|
run,
|
|
runFailure,
|
|
stageContent as stage,
|
|
writeExecutable,
|
|
} from "./git-hooks-pre-commit.test-support.js";
|
|
import { cleanupTempDirs, makeTempDir as makeTempRepoRoot } from "./helpers/temp-dir.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
function writeJson(dir: string, value: Record<string, unknown>) {
|
|
writeFileSync(path.join(dir, "package.json"), JSON.stringify(value));
|
|
}
|
|
|
|
function patchManifest(dir: string, patch: Record<string, unknown>) {
|
|
writeJson(dir, { ...JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")), ...patch });
|
|
}
|
|
|
|
function packageFixture(root: string, name: string, manifest: Record<string, unknown>) {
|
|
const dir = path.join(root, "node_modules", name);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeJson(dir, { name, version: "0.68.0", ...manifest });
|
|
if (typeof manifest.main === "string") {
|
|
writeFileSync(path.join(dir, manifest.main), "module.exports = {};\n");
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function installRunNodeToolFixture(dir: string): void {
|
|
mkdirSync(path.join(dir, "scripts", "pre-commit"), { recursive: true });
|
|
symlinkSync(
|
|
path.join(process.cwd(), "scripts", "pre-commit", "run-node-tool.sh"),
|
|
path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"),
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanupTempDirs(tempDirs);
|
|
});
|
|
|
|
describe("git-hooks/pre-commit (integration)", () => {
|
|
it.each(["--all", ".agents/skills/discord-clawd/SKILL.md"])(
|
|
"preserves the staged path %s without running the changed-scope check",
|
|
(name) => {
|
|
const dir = createContentGuardFixture(tempDirs);
|
|
const fakeBinDir = path.join(dir, "bin");
|
|
writeFileSync(path.join(dir, "secret.txt"), "do-not-stage\n", "utf8");
|
|
writeFileSync(path.join(dir, "package.json"), '{"name":"tmp"}\n', "utf8");
|
|
writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8");
|
|
writeExecutable(
|
|
fakeBinDir,
|
|
"pnpm",
|
|
"#!/bin/sh\necho 'pnpm should not run from pre-commit' >&2\nexit 99\n",
|
|
);
|
|
const ignored = name.startsWith(".agents/");
|
|
if (ignored) {
|
|
stage(dir, ".gitignore", ".agents/skills/discord-clawd/\n");
|
|
}
|
|
stage(dir, name, "hello\n");
|
|
run(dir, "bash", ["git-hooks/pre-commit"], {
|
|
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
|
|
});
|
|
expect(run(dir, "git", ["diff", "--cached", "--name-only"]).split("\n")).toEqual(
|
|
ignored ? [name, ".gitignore"] : [name],
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(["unconfigured", "external"])("formats staged files with %s private rules", (mode) => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-normal-");
|
|
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
|
|
const fakeBinDir = installPreCommitFixture(dir);
|
|
const logPath = installFormattingRecorder(dir);
|
|
if (mode === "unconfigured") {
|
|
run(dir, "git", ["config", "--local", "--unset", ruleSetting]);
|
|
unlinkSync(path.join(dir, rulePath));
|
|
} else if (mode === "external") {
|
|
const privateDir = makeTempRepoRoot(tempDirs, "openclaw-private-rules-");
|
|
const privatePath = path.join(privateDir, "private rules.txt");
|
|
copyFileSync(path.join(dir, rulePath), privatePath);
|
|
unlinkSync(path.join(dir, rulePath));
|
|
run(dir, "git", ["config", "--local", ruleSetting, privatePath]);
|
|
expect(run(dir, "git", ["config", "--path", "--get", ruleSetting])).toBe(privatePath);
|
|
}
|
|
|
|
writeFileSync(
|
|
path.join(dir, "changed.ts"),
|
|
mode === "unconfigured" ? literals[0] : "export const value = 1;\n",
|
|
"utf8",
|
|
);
|
|
run(dir, "git", ["add", "--", "changed.ts"]);
|
|
|
|
run(dir, "bash", ["git-hooks/pre-commit"], {
|
|
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
|
|
});
|
|
|
|
expect(readFormatterLog(logPath)).toEqual([
|
|
"oxfmt --write --threads=1 --no-error-on-unmatched-pattern changed.ts",
|
|
]);
|
|
if (mode === "external") {
|
|
writeFileSync(path.join(dir, "changed.ts"), literals[0]);
|
|
run(dir, "git", ["add", "--", "changed.ts"]);
|
|
expect(runFailure(dir, "bash", ["git-hooks/pre-commit"]).stderr).toContain(
|
|
"Blocked staged content",
|
|
);
|
|
}
|
|
});
|
|
|
|
it.each(["partial.ts", "gone.ts", "alias.ts", "payload.ts"])(
|
|
"preserves staged and working-tree bytes independently for %s",
|
|
(name) => {
|
|
const dir = createContentGuardFixture(tempDirs);
|
|
const partial = name === "partial.ts";
|
|
const deleted = name === "gone.ts";
|
|
const symlink = name === "alias.ts";
|
|
const staged = partial
|
|
? "export const value = FORMAT_ME;\n"
|
|
: deleted
|
|
? "export const keep = 1;\n"
|
|
: "clean staged version\n";
|
|
const working = partial
|
|
? `${staged}export const unstagedOnly = 1;\n`
|
|
: symlink
|
|
? "const other = 2;\n"
|
|
: literals[0];
|
|
if (partial) {
|
|
writeExecutable(
|
|
path.join(dir, "node_modules/.bin"),
|
|
"oxfmt",
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
printf 'oxfmt %s\n' "$*" >> hook-tool.log
|
|
case "$*" in *--stdin-filepath=*) sed 's/FORMAT_ME/FORMATTED/' ;; esac
|
|
`,
|
|
);
|
|
}
|
|
if (symlink) {
|
|
writeFileSync(path.join(dir, "target-a.ts"), "const unformatted = 1\n", "utf8");
|
|
writeFileSync(path.join(dir, "target-b.ts"), working);
|
|
symlinkSync("target-a.ts", path.join(dir, name));
|
|
run(dir, "git", ["add", "--", name]);
|
|
unlinkSync(path.join(dir, name));
|
|
symlinkSync("target-b.ts", path.join(dir, name));
|
|
} else {
|
|
stage(dir, name, staged);
|
|
if (deleted) {
|
|
unlinkSync(path.join(dir, name));
|
|
} else {
|
|
writeFileSync(path.join(dir, name), working);
|
|
}
|
|
}
|
|
run(dir, "git", commitArgs);
|
|
expect(run(dir, "git", ["show", `HEAD:${name}`])).toBe(
|
|
symlink ? "target-a.ts" : partial ? "export const value = FORMATTED;" : staged.trim(),
|
|
);
|
|
if (deleted) {
|
|
expect(existsSync(path.join(dir, name))).toBe(false);
|
|
} else {
|
|
expect(readFileSync(path.join(dir, name), "utf8")).toBe(working);
|
|
}
|
|
if (partial) {
|
|
expect(readFormatterLog(path.join(dir, "hook-tool.log"))).toEqual([
|
|
"oxfmt --stdin-filepath=partial.ts",
|
|
]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("fails instead of staging empty formatter output for a partially staged file", () => {
|
|
const dir = createContentGuardFixture(tempDirs);
|
|
// Drain stdin so SIGPIPE cannot preempt the hook's empty-output check.
|
|
writeExecutable(
|
|
path.join(dir, "node_modules/.bin"),
|
|
"oxfmt",
|
|
"#!/bin/sh\ncat >/dev/null\nexit 0\n",
|
|
);
|
|
stage(dir, "partial.ts", "export const value = 1;\n");
|
|
writeFileSync(
|
|
path.join(dir, "partial.ts"),
|
|
"export const value = 1;\nexport const extra = 2;\n",
|
|
);
|
|
|
|
const result = runFailure(dir, "git", commitArgs);
|
|
|
|
expect(result.stderr).toContain("Formatter returned no output");
|
|
expect(run(dir, "git", ["show", ":partial.ts"])).toBe("export const value = 1;");
|
|
expect(runFailure(dir, "git", ["rev-parse", "--verify", "HEAD"]).status).not.toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("staged content guard", () => {
|
|
const fixture = () => createContentGuardFixture(tempDirs);
|
|
|
|
function blocked(dir: string, names: string[], commit = false) {
|
|
const result = commit
|
|
? runFailure(dir, "git", commitArgs)
|
|
: runFailure(dir, "bash", ["git-hooks/pre-commit"]);
|
|
const output = result.stdout + result.stderr;
|
|
expect(result.status).toBe(1);
|
|
expect(output).toContain("Blocked staged content");
|
|
expect(output).toContain("restage");
|
|
for (const name of names) {
|
|
expect(output).toContain(JSON.stringify(name));
|
|
}
|
|
for (const literal of literals) {
|
|
expect(output).not.toContain(literal);
|
|
}
|
|
expect(output).not.toContain("PRIVATE_SOURCE_CONTEXT");
|
|
return result;
|
|
}
|
|
|
|
it.each(["added", "rename", "typechange", "binary"])(
|
|
"blocks the full staged %s blob before formatting",
|
|
(kind) => {
|
|
const dir = fixture();
|
|
const log = installFormattingRecorder(dir);
|
|
const name = kind === "added" ? "payload.ts" : "payload.txt";
|
|
if (kind === "rename") {
|
|
stage(dir, "old.txt", literals[0]);
|
|
run(dir, "git", ["commit", "-qm", "historical fixture"]);
|
|
run(dir, "git", ["mv", "--", "old.txt", name]);
|
|
} else if (kind === "typechange") {
|
|
symlinkSync("absent-target", path.join(dir, name));
|
|
run(dir, "git", ["add", "--", name]);
|
|
run(dir, "git", ["commit", "-qm", "symlink fixture"]);
|
|
unlinkSync(path.join(dir, name));
|
|
stage(dir, name, literals[0]);
|
|
} else if (kind === "binary") {
|
|
stage(
|
|
dir,
|
|
name,
|
|
Buffer.concat([Buffer.from([0, 255]), Buffer.from(literals[1]), Buffer.from([0])]),
|
|
);
|
|
} else {
|
|
stage(dir, name, `PRIVATE_SOURCE_CONTEXT prefix${literals[1]}suffix\n`);
|
|
writeFileSync(path.join(dir, name), "clean working tree\n");
|
|
}
|
|
blocked(dir, [name], kind === "added");
|
|
expect(readFormatterLog(log)).toEqual([]);
|
|
if (kind === "added") {
|
|
expect(runFailure(dir, "git", ["rev-parse", "--verify", "HEAD"]).status).not.toBe(0);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("discovers a new path staged during formatting", () => {
|
|
const dir = fixture();
|
|
stage(dir, "payload.ts", "clean\n");
|
|
writeFileSync(path.join(dir, "introduced.txt"), literals[1]);
|
|
const log = installFormattingRecorder(dir, "git add -- introduced.txt");
|
|
blocked(dir, ["introduced.txt"], true);
|
|
expect(readFormatterLog(log)).toHaveLength(1);
|
|
});
|
|
|
|
it("uses fixed, case-sensitive matches despite Git grep defaults", () => {
|
|
const dir = fixture();
|
|
run(dir, "git", ["config", "grep.patternType", "extended"]);
|
|
run(dir, "git", ["config", "grep.ignoreCase", "true"]);
|
|
stage(dir, "payload.txt", `${literals[0].toLowerCase()}\nGUARD_SYNTHETIC_BETA_xanything42\n`);
|
|
run(dir, "git", commitArgs);
|
|
expect(run(dir, "git", ["show", "HEAD:payload.txt"])).toContain("xanything42");
|
|
});
|
|
|
|
it("scans unchanged lines in modified files but permits unchanged history and deletion-only commits", () => {
|
|
const dir = fixture();
|
|
stage(dir, "historical.txt", `${literals[0]}\nold line\n`);
|
|
run(dir, "git", ["commit", "-qm", "historical fixture"]);
|
|
stage(dir, "clean.txt", "clean\n");
|
|
run(dir, "git", commitArgs);
|
|
stage(dir, "historical.txt", `${literals[0]}\nnew line\n`);
|
|
blocked(dir, ["historical.txt"]);
|
|
run(dir, "git", ["rm", "-f", "--", "historical.txt"]);
|
|
run(dir, "git", commitArgs);
|
|
expect(run(dir, "git", ["ls-tree", "--name-only", "HEAD"])).toBe("clean.txt");
|
|
});
|
|
|
|
it("reports literal paths safely and includes ignored docs, tests and generated files", () => {
|
|
const dir = fixture();
|
|
writeFileSync(path.join(dir, ".gitignore"), "ignored/\n");
|
|
const names = [
|
|
"space name.txt",
|
|
"--all",
|
|
":(exclude)payload.txt",
|
|
"[literal]*?.txt",
|
|
"line\nbreak.txt",
|
|
"control\u001b.txt",
|
|
"ignored/file.txt",
|
|
"docs/example.md",
|
|
"test/example.ts",
|
|
"extensions/example/src/host/web/file.bundle.js",
|
|
];
|
|
for (const name of names) {
|
|
stage(dir, name, literals[0]);
|
|
}
|
|
stage(dir, `${literals[0]}.txt`, literals[1]);
|
|
const result = blocked(dir, [...names, "[REDACTED].txt"]);
|
|
expect(result.stderr).not.toContain("\u001b");
|
|
});
|
|
|
|
it("scans the former public rule filename and beyond both batch limits", () => {
|
|
const dir = fixture();
|
|
const formerRulePath = "scripts/pre-commit/blocked-literals.txt";
|
|
stage(dir, formerRulePath, literals[0]);
|
|
blocked(dir, [formerRulePath]);
|
|
// Long paths cross the byte budget first; short paths also cross the 256-entry count budget.
|
|
const batchPaths = [];
|
|
for (let i = 0; i < 600; i++) {
|
|
const suffix = i < 70 ? `/${"x".repeat(180)}/${"y".repeat(180)}` : "";
|
|
const name = `batch-${String(i).padStart(3, "0")}${suffix}.txt`;
|
|
mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
|
|
writeFileSync(path.join(dir, name), "clean\n");
|
|
batchPaths.push(name);
|
|
}
|
|
run(dir, "git", ["add", "--", ...batchPaths]);
|
|
stage(dir, formerRulePath, "clean\n");
|
|
stage(dir, "zzz-last.txt", literals[1]);
|
|
blocked(dir, ["zzz-last.txt"]);
|
|
});
|
|
|
|
it("permits unborn and existing empty commits and ignores submodule contents", () => {
|
|
const dir = fixture();
|
|
run(dir, "git", [...commitArgs, "--allow-empty"]);
|
|
run(dir, "git", [...commitArgs, "--allow-empty"]);
|
|
const head = run(dir, "git", ["rev-parse", "HEAD"]);
|
|
run(dir, "git", ["update-index", "--add", "--cacheinfo", `160000,${head},submodule`]);
|
|
mkdirSync(path.join(dir, "submodule"));
|
|
writeFileSync(path.join(dir, "submodule", "payload.txt"), literals[0]);
|
|
run(dir, "bash", ["git-hooks/pre-commit"]);
|
|
expect(run(dir, "git", ["diff", "--cached", "--name-only"])).toBe("submodule");
|
|
});
|
|
|
|
it.each([
|
|
["missing file", null],
|
|
["empty file", ""],
|
|
["invalid UTF-8", Buffer.from([255])],
|
|
["NUL literal", "\0"],
|
|
])("fails closed with %s", (_label, content) => {
|
|
const dir = fixture();
|
|
const log = installFormattingRecorder(dir);
|
|
stage(dir, "payload.ts", "clean\n");
|
|
if (content === null) {
|
|
unlinkSync(path.join(dir, rulePath));
|
|
} else {
|
|
writeFileSync(path.join(dir, rulePath), content);
|
|
}
|
|
const result = runFailure(dir, "bash", ["git-hooks/pre-commit"]);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(ruleSetting);
|
|
expect(result.stderr).toContain("retry");
|
|
expect(result.stdout + result.stderr).not.toContain(path.join(dir, rulePath));
|
|
expect(readFormatterLog(log)).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
["literal metacharacters", [...literals], literals.join(" "), "[REDACTED] [REDACTED]"],
|
|
[
|
|
"shorter prefix first",
|
|
["foo", "foobar"],
|
|
"foobar foo FOOBAR",
|
|
"[REDACTED] [REDACTED] FOOBAR",
|
|
],
|
|
["crossing overlaps", ["abc", "bcd"], "abcd", "[REDACTED]"],
|
|
["self-overlap", ["aba"], "ababa", "[REDACTED]"],
|
|
["marker literal", ["foo", "REDACTED"], "foo REDACTED", "[REDACTED] [REDACTED]"],
|
|
])(
|
|
"redacts filenames and formatter streams with %s while preserving failure status",
|
|
(_label, rules, text, redacted) => {
|
|
const dir = fixture();
|
|
writeFileSync(path.join(dir, rulePath), `${rules.join("\n")}\n`);
|
|
const name = `report-${text}\n🦞.ts`;
|
|
stage(dir, name, text);
|
|
const finding = runFailure(dir, "bash", ["git-hooks/pre-commit"]);
|
|
|
|
stage(dir, name, "clean\n");
|
|
const context = `🦞 café ${text}\nuntouched ${text} tail\n`;
|
|
const expected = `🦞 café ${redacted}\nuntouched ${redacted} tail\n`;
|
|
installFormattingRecorder(
|
|
dir,
|
|
`printf 'stdout %s' '${context}'\nprintf 'stderr %s' '${context}' >&2\nprintf broken > .git/index\nexit 23`,
|
|
);
|
|
const result = runFailure(dir, "bash", ["git-hooks/pre-commit"]);
|
|
expect(result).toEqual({
|
|
status: 23,
|
|
stdout: `stdout ${expected}`,
|
|
stderr: `stderr ${expected}[pre-commit] Formatter failed. Fix the reported error and retry.\n[pre-commit] FAILED (exit 23)\n`,
|
|
});
|
|
expect(finding.status).toBe(1);
|
|
expect(finding.stderr).toContain(` ${JSON.stringify(`report-${redacted}\n🦞.ts`)}\n`);
|
|
},
|
|
);
|
|
|
|
it.each(["config path", "index", "blob", "post-format blob"])(
|
|
"blocks Git %s read errors without raw diagnostics",
|
|
(kind) => {
|
|
const dir = fixture();
|
|
const name = `${literals[0]}.txt`;
|
|
stage(dir, name, "clean\n");
|
|
if (kind === "config path") {
|
|
run(dir, "git", ["config", "--local", ruleSetting, `~${literals[1]}/private rules.txt`]);
|
|
expect(runFailure(dir, "git", ["config", "--path", "--get", ruleSetting]).status).not.toBe(
|
|
1,
|
|
);
|
|
} else if (kind === "index") {
|
|
writeFileSync(path.join(dir, ".git/index"), literals[1]);
|
|
} else {
|
|
const oid = run(dir, "git", ["rev-parse", `:${name}`]);
|
|
const objectPath = `.git/objects/${oid.slice(0, 2)}/${oid.slice(2)}`;
|
|
if (kind === "post-format blob") {
|
|
// Keep git add from recreating the missing blob before the post-scan.
|
|
writeFileSync(path.join(dir, ".gitignore"), "*.txt\n");
|
|
stage(dir, "trigger.ts", "formatter trigger\n");
|
|
installFormattingRecorder(dir, `rm -- '${objectPath}'`);
|
|
} else {
|
|
unlinkSync(path.join(dir, objectPath));
|
|
const grep = runFailure(dir, "git", [
|
|
"grep",
|
|
"--cached",
|
|
"--fixed-strings",
|
|
"clean",
|
|
"--",
|
|
name,
|
|
]);
|
|
expect(grep.status).toBe(1);
|
|
expect(grep.stderr.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
const result = runFailure(dir, "bash", ["git-hooks/pre-commit"]);
|
|
expect(result.stderr).toContain("Git could not");
|
|
for (const literal of literals) {
|
|
expect(result.stdout + result.stderr).not.toContain(literal);
|
|
}
|
|
expect(result.stderr).not.toContain("error:");
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("scripts/pre-commit/run-node-tool.sh", () => {
|
|
function toolingFixture() {
|
|
const dir = createContentGuardFixture(tempDirs);
|
|
const owner = makeTempRepoRoot(tempDirs, "openclaw-hook-tooling-");
|
|
run(owner, "git", ["init", "-q", "--initial-branch=main"]);
|
|
for (const root of [dir, owner]) {
|
|
run(root, "git", ["remote", "add", "origin", "https://github.com/example/project.git"]);
|
|
writeJson(root, { devDependencies: { oxfmt: "0.68.0" } });
|
|
}
|
|
rmSync(path.join(dir, "node_modules"), { recursive: true });
|
|
const bindingName = `@oxfmt/binding-${process.platform}-${process.arch}`;
|
|
const dependency = packageFixture(owner, "tinypool", { version: "2.1.2", main: "index.cjs" });
|
|
const pkg = packageFixture(owner, "oxfmt", {
|
|
bin: { oxfmt: "cli.cjs" },
|
|
dependencies: { tinypool: "2.1.2" },
|
|
optionalDependencies: { [bindingName]: "0.68.0" },
|
|
});
|
|
const binding = packageFixture(owner, bindingName, {
|
|
os: [process.platform],
|
|
cpu: [process.arch],
|
|
main: "binding.cjs",
|
|
});
|
|
const cli = path.join(pkg, "cli.cjs");
|
|
const formatter = `const fs = require("node:fs");
|
|
if (process.argv[2] === "--version") { console.log("Version: 0.68.0"); process.exit(0); }
|
|
fs.writeFileSync("formatter-call.json", JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }));
|
|
if (process.argv.some(arg => arg.startsWith("--stdin-filepath="))) process.stdout.write(fs.readFileSync(0, "utf8").replace("FORMAT_ME", "FORMATTED"));
|
|
`;
|
|
writeFileSync(cli, formatter);
|
|
return {
|
|
dir,
|
|
owner,
|
|
pkg,
|
|
binding,
|
|
dependency,
|
|
cli,
|
|
formatter,
|
|
env: { OPENCLAW_PR_TOOLING_ROOT: owner },
|
|
};
|
|
}
|
|
|
|
it.each([{ selection: "environment" }, { selection: "config" }, { selection: "canonical" }])(
|
|
"uses the $selection tooling owner with task cwd and exact arguments",
|
|
({ selection }) => {
|
|
const fixture = toolingFixture();
|
|
let { dir } = fixture;
|
|
const { owner, env } = fixture;
|
|
if (selection === "config") {
|
|
run(dir, "git", ["config", "openclaw.pr.toolingRoot", owner]);
|
|
env.OPENCLAW_PR_TOOLING_ROOT = "";
|
|
} else if (selection === "canonical") {
|
|
run(owner, "git", ["add", "package.json"]);
|
|
run(owner, "git", ["commit", "-qm", "tooling fixture"]);
|
|
dir = path.join(owner, "task");
|
|
run(owner, "git", ["worktree", "add", "--detach", dir, "HEAD"]);
|
|
installPreCommitFixture(dir);
|
|
rmSync(path.join(dir, "node_modules"), { recursive: true });
|
|
env.OPENCLAW_PR_TOOLING_ROOT = "";
|
|
} else {
|
|
run(dir, "git", ["config", "openclaw.pr.toolingRoot", path.join(owner, "missing")]);
|
|
}
|
|
const args = ["--write", "space name.ts", ":(exclude)literal.ts", "line\nbreak.ts"];
|
|
run(dir, "/bin/bash", ["scripts/pre-commit/run-node-tool.sh", "oxfmt", ...args], {
|
|
...env,
|
|
NAPI_RS_FORCE_WASI: undefined,
|
|
});
|
|
expect(JSON.parse(readFileSync(path.join(dir, "formatter-call.json"), "utf8"))).toEqual({
|
|
cwd: dir,
|
|
args,
|
|
});
|
|
expect(existsSync(path.join(dir, "node_modules"))).toBe(false);
|
|
expect(existsSync(path.join(owner, "formatter-call.json"))).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.each<
|
|
[string, ("pkg" | "binding" | "dependency")?, Record<string, unknown>?, [string, string]?]
|
|
>([
|
|
["wrong pin", "pkg", { version: "0.60.0" }],
|
|
["wrong package", "pkg", { name: "another-formatter" }],
|
|
["unrelated"],
|
|
["sparse"],
|
|
["subdirectory"],
|
|
["escaped package"],
|
|
["wrong platform", "binding", { cpu: ["unsupported"] }],
|
|
["wrong binding pin", "binding", { version: "0.60.0" }],
|
|
["broken binding"],
|
|
["wrong dependency pin", "dependency", { version: "1.0.0" }],
|
|
["escaped dependency"],
|
|
["drift"],
|
|
["native path override", undefined, undefined, ["NAPI_RS_NATIVE_LIBRARY_PATH", "override"]],
|
|
["forced WASI", undefined, undefined, ["NAPI_RS_FORCE_WASI", "true"]],
|
|
["WASI error fallback", undefined, undefined, ["NAPI_RS_FORCE_WASI", "error"]],
|
|
["WASI flavor override", undefined, undefined, ["NAPI_RS_WASI_FLAVOR", "wasm32-wasi"]],
|
|
])("rejects %s before formatting", (kind, target, patch, override) => {
|
|
const fixture = toolingFixture();
|
|
const { dir, owner, pkg, binding, dependency, cli, formatter, env } = fixture;
|
|
if (target && patch) {
|
|
patchManifest(fixture[target], patch);
|
|
} else if (kind === "unrelated") {
|
|
run(owner, "git", [
|
|
"remote",
|
|
"set-url",
|
|
"origin",
|
|
"https://github.com/example/unrelated.git",
|
|
]);
|
|
} else if (kind === "sparse") {
|
|
run(owner, "git", ["config", "core.sparseCheckout", "true"]);
|
|
} else if (kind === "subdirectory") {
|
|
env.OPENCLAW_PR_TOOLING_ROOT = pkg;
|
|
} else if (kind === "escaped package") {
|
|
const outside = makeTempRepoRoot(tempDirs, "openclaw-outside-formatter-");
|
|
rmSync(pkg, { recursive: true });
|
|
symlinkSync(outside, pkg);
|
|
} else if (kind === "broken binding") {
|
|
writeFileSync(
|
|
path.join(binding, "binding.cjs"),
|
|
'throw new Error("unusable native binding");',
|
|
);
|
|
} else if (kind === "escaped dependency") {
|
|
const outside = makeTempRepoRoot(tempDirs, "openclaw-outside-dependency-");
|
|
rmSync(dependency, { recursive: true });
|
|
writeJson(outside, { name: "tinypool", version: "2.1.2", main: "index.cjs" });
|
|
writeFileSync(path.join(outside, "index.cjs"), "module.exports = {};\n");
|
|
symlinkSync(outside, dependency);
|
|
} else if (kind === "drift") {
|
|
writeFileSync(
|
|
cli,
|
|
formatter.replace(
|
|
"console.log",
|
|
`fs.appendFileSync(${JSON.stringify(path.join(pkg, "package.json"))}, " "); console.log`,
|
|
),
|
|
);
|
|
}
|
|
const result = runFailure(
|
|
dir,
|
|
"/bin/bash",
|
|
["scripts/pre-commit/run-node-tool.sh", "oxfmt", "--write", "a.ts"],
|
|
override ? { ...env, [override[0]]: override[1] } : env,
|
|
);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("Cannot use tooling-owner oxfmt");
|
|
if (override) {
|
|
expect(result.stderr).toContain("Cannot qualify an overridden formatter platform binding");
|
|
}
|
|
expect(existsSync(path.join(dir, "formatter-call.json"))).toBe(false);
|
|
expect(existsSync(path.join(dir, "node_modules"))).toBe(false);
|
|
});
|
|
|
|
it("keeps partial-stage and private-content guards around the tooling formatter", () => {
|
|
const { dir, env } = toolingFixture();
|
|
stage(dir, "partial.ts", "export const value = FORMAT_ME;\n");
|
|
const working = `export const value = FORMAT_ME;\n// ${literals[0]}\n`;
|
|
writeFileSync(path.join(dir, "partial.ts"), working);
|
|
run(dir, "git", commitArgs, env);
|
|
expect(run(dir, "git", ["show", "HEAD:partial.ts"])).toBe("export const value = FORMATTED;");
|
|
expect(readFileSync(path.join(dir, "partial.ts"), "utf8")).toBe(working);
|
|
rmSync(path.join(dir, "formatter-call.json"));
|
|
stage(dir, "blocked.ts", literals[1]);
|
|
const failed = runFailure(dir, "git", commitArgs, env);
|
|
expect(failed.stderr).toContain("Blocked staged content");
|
|
expect(failed.stderr).not.toContain(literals[1]);
|
|
expect(existsSync(path.join(dir, "formatter-call.json"))).toBe(false);
|
|
});
|
|
|
|
it("propagates a tooling formatter failure through the hook without committing", () => {
|
|
const { dir, cli, formatter, env } = toolingFixture();
|
|
writeFileSync(cli, `${formatter}\nprocess.exit(23);\n`);
|
|
stage(dir, "a.ts", "export const value = 1;\n");
|
|
const failed = runFailure(dir, "git", commitArgs, env);
|
|
expect(failed.status).toBe(1);
|
|
expect(failed.stderr).toContain("[pre-commit] FAILED (exit 23)");
|
|
expect(runFailure(dir, "git", ["rev-parse", "--verify", "HEAD"]).status).not.toBe(0);
|
|
});
|
|
|
|
it.each([true, false])(
|
|
"never hydrates dependencies with the local formatter installed=%s",
|
|
(installed) => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-run-node-tool-");
|
|
installRunNodeToolFixture(dir);
|
|
writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8");
|
|
|
|
const fakeBinDir = path.join(dir, "bin");
|
|
const markerPath = path.join(dir, "pnpm-called");
|
|
mkdirSync(fakeBinDir, { recursive: true });
|
|
writeExecutable(
|
|
fakeBinDir,
|
|
"pnpm",
|
|
`#!/usr/bin/env bash\ntouch ${JSON.stringify(markerPath)}\nexit 99\n`,
|
|
);
|
|
|
|
const args = ["scripts/pre-commit/run-node-tool.sh", "oxfmt", "--write", "a.ts"];
|
|
const env = { PATH: `${fakeBinDir}:${process.env.PATH ?? ""}` };
|
|
if (installed) {
|
|
const toolBinDir = path.join(dir, "node_modules", ".bin");
|
|
mkdirSync(toolBinDir, { recursive: true });
|
|
writeExecutable(toolBinDir, "oxfmt", "#!/usr/bin/env bash\nprintf 'local:%s\\n' \"$*\"\n");
|
|
expect(run(dir, "bash", args, env)).toBe("local:--write a.ts");
|
|
} else {
|
|
const result = runFailure(dir, "bash", args, env);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(
|
|
"Missing repo dependencies: cannot run oxfmt without node_modules.",
|
|
);
|
|
}
|
|
expect(existsSync(markerPath)).toBe(false);
|
|
},
|
|
);
|
|
});
|