mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-13 12:28:18 +00:00
* test(integration): deslop s0017 tests Reuse existing Teams authority, registry and HTTP fixtures; consolidate updater custody assertions and hook fixture operations. Share isolated env restoration setup without deleting cases. Retain distinct authorization, credential, lifecycle and recovery branches. CI-only: plain oxlint, oxfmt and diff check passed; no tests or coverage run. Shard LOC 5657 to 5538 (2.1%); 25% target not reached without removing distinct proof. * test(integration): consolidate s0017 owner-boundary proof Remove redundant Teams CLI harnessing, Discord metadata/result-fence replays, Feishu local-result settlement replays, and updater/pre-commit duplicates. Reuse the existing launcher fixture and consolidate live-home setup. Keepers: registry tests for untrusted registrations, originating context and revoked responses; Feishu channel tests for target and sticker policy; update-runner-git-runtime compensation and source-update-artifact-preflight preservation; pre-commit-boundaries path/formatter tests; message CLI parser and formatter owner tests; tsdown-config standalone launcher boundary. Keep the distinct Feishu no-ID direct-member adapter and Discord filtered channel-list policy branches. CI-only validation: plain oxlint, oxfmt and diff check passed; no tests or coverage run. Original shard 5657 to 5084 lines (10.1 percent reduction); additional 454 lines removed beyond the prior commit, short of 25 percent. * test(integration): remove redundant s0017 harness layers Retire the Teams Gateway cross-product (send.test.ts delegated-turn fence and send.scheduled-reads.integration.test.ts installed-provider proof remain). Keep Teams target policy through the real message tool and adapter. Drop Slack metadata settlement replays owned by message-action-dispatch.registry.test.ts revoked response and subsequent-I/O cases. Keep provider request revocation in the read suite. Deduplicate Feishu local-tool turn/claim replays against retained pins and normal-tool boundary proof. Keep native-search requester timing and wire results while removing dirty-topology setup owned by codex-web-search-policy.integration.test.ts and internal mock assertions. Fold disconnect revocation into the admitted candidate lifecycle with before/after state assertions. Reuse the updater seed as origin and remove Git-only hook probes. CI-only: oxfmt, plain oxlint and diff-check passed; no tests, builds or coverage run. Original shard 5657 to 4897 lines (13.4 percent), 187 fewer than prior commit; 25 percent target still unmet. * test(infra): deslop s0020 tests Share repeated archive SQL/path and credential fixtures, reuse approval cleanup, and simplify Tailscale owner fixtures. Preserve all declarations and cases, including authority, recovery, publication, and lifecycle boundaries. This already-consolidated shard cannot safely meet the 25% line target without dropping distinct behavior. CI-only validation: plain oxlint, oxfmt, and diff-check. * test(infra): remove duplicate s0020 test layers Keep archive pagination, cancellation, rollback/resume and lease-renewal proof in state-migrations.transcript-directives-archives.progress.test.ts; remove duplicate cases and their fixture mode from batching. Keep secrets-audit producers in secrets/audit.test.ts and commands/doctor-security.test.ts while retaining canary advisory, receipt and redaction proof. Consolidate duplicate Tailscale aggregation and no-op abandonment variants; remove retained-tree call-count restatements and a same-branch fallback replay. CI-only static gates: plain oxlint, oxfmt, git diff --check. * test(infra): trim remaining s0020 duplicate checks Retain one native runtime assertion per delivery and observer contract. Keep manifest hardlink rejection in the retained-tree preexisting-twin case and manifest-registry link-policy tests; avoid repeating cache-reader and equivalent inode checks. Keep successful candidate credential serialization proof in update-candidate-rehearsal.resources.test.ts and failed-retention proof in the canary suite. No production changes; CI-only static lint and formatting gates passed. * test(gateway): deslop s0022 tests Reuse local connection, publication, visibility, and channel fixtures without deleting distinct scenarios. Keep all authority, privacy, recovery, and lifecycle cases. Remove redundant command suppression assertions already covered by the suppressed-status row in chat-send-user-turn.test.ts, and internal supersession call-shape assertions covered by the normal and revoked publication response cases in sessions-github-read.test.ts. CI-only validation: plain oxlint, oxfmt, and git diff --check pass. No tests or coverage run. Shard LOC: 5230 to 5142 (1.7%); 91 declarations unchanged. The 25% target was not safely achievable in these already consolidated suites. * test(gateway): consolidate s0022 overlapping owner proof Fold pending identity concurrency into the retained refresh-timeout case. Remove token stale and profileless receipt variants covered by github-oauth-client.test.ts and sessions-github-read.target.test.ts, retaining native stale projection and unverified-profile rejection. Keep mixed-media slot indexing here; chat-attachments.test.ts covers canonical fact ordering and server.chat.gateway-server-chat-b.test.ts covers persisted private-field redaction. Remove incidental native continuation from catalog import: session-catalog-adoption.test.ts covers upstream settlement and the response. Consolidate draft/shared description loops and remove unused local fixture fields. CI-only: oxfmt, plain oxlint, and git diff --check pass; no tests or coverage run. Additional reduction 5142 to 5050 lines. Cumulative 5230 to 5050 (3.4%); 25% remains unmet without dropping distinct authority, lifecycle, and durability proof. * test(gateway): remove redundant s0022 proof layers Remove global/unknown transcript replay already covered by sessions-read-active.test.ts (keeps active owners and their physical transcript, board, and sharing rows distinct). Fold disconnected personal-receipt success into cold options; retain account/generation revalidation and all authority failures. Reuse exact RPC response matchers in pairing tests, remove unreachable execution mock and irrelevant setup reset, trim redundant MCP prompt counts while retaining unauthorized prompt and execution checks, and simplify local catalog fixture declarations. CI-only validation: oxfmt, plain oxlint, and git diff --check pass; no tests or coverage. This commit removes 53 lines (5050 to 4997); cumulative shard reduction 5230 to 4997 (4.5%). Retention audit did not justify the requested 25% cut. * test(agents): deslop s0023 tests Reuse local fixtures and consolidate equivalent error cases while preserving distinct authority, lifecycle, recovery, and privacy assertions. The retained provider-review transport table covers the removed literal-steer and one-shot-metadata replay. The retained aborted streamed-text/reasoning case also covers partial-answer and reply-directive suppression. CI-only mode: oxfmt, plain oxlint, and diff checks passed; no tests or coverage run. Lines reduced from 5908 to 5592 (5.35%); below the 25% target because remaining cases protect distinct boundaries. * test(agents): consolidate s0023 boundary test layers Remove terminal auth, text-call and rejected-call replays retained in the corresponding terminal-resolution sibling suites. Keep formatter detail checks in embedded-agent-helpers.formatassistanterrortext.test.ts. Keep full compaction/replay behavior in attempt-prompt-submit.test.ts and test phase-specific carrier accounting directly. The real cancellation path remains in attempt-stream-settle.test.ts. Consolidate candidate-anchor, preflight-custody and timeout tables without dropping their distinct boundary assertions. CI-only: formatting, plain oxlint and diff checks pass; no tests or coverage run. Cumulative shard LOC: 5908 to 4882 (17.37 percent), short of the 25 percent target. * test(commands): deslop s0021 tests * test(commands): deslop s0021 duplicate coverage Remove duplicate automatic-migration and prepared-plan cases retained verbatim in doctor-config-flow.automatic-migrations.test.ts and doctor-config-flow.gateway-bind-persistence.test.ts. Keep plugin availability recovery in doctor-lint.plugin-availability.test.ts, disabled Codex routes in doctor-core-checks.test.ts, and final-component ACP symlink protection in state-migrations.test.ts. Share captured-context proof, keep sanitized send cases instead of plain same-path replays, shrink catalog fixtures, and remove obsolete migration mocks. CI-only: plain oxlint, oxfmt, and diff checks pass; no tests or coverage run. Cumulative shard LOC 6290 to 5417 (13.9%); below the 25% target to retain distinct remaining boundary behavior. * test(commands): deslop s0021 tests Share refusal, archive-retry and approval-isolation scenario setup while retaining every distinct boundary. Consolidate pin envelope and display-limit proof into the retained message terminal-safety table. SQLite-only catalog detection remains in the partial-cost preview case; malformed sidecar import retains missing-API repair and valid-row preservation. * test(agents): deslop s0024 tests Remove duplicate source-scope cases covered by context-engine-source-scope.test.ts and cursor cases covered by context-engine-transcript-cursor.test.ts. Consolidate local fixtures while preserving distinct authority, durability, ordering, protocol, and native execution assertions. CI-only validation: plain oxlint, oxfmt, and git diff --check. Shard test LOC 6304 to 5743 (8.9 percent); retained distinct boundary cases prevent an honest 25 percent cut. No production or support-file changes. * test(agents): further reduce s0024 test scaffolding Consolidate byte-bounding proof in the retained synthetic tool-result cap test. Bounded-context owner tests retain projection identity and instruction preservation; host-param-projection tests retain compact parameter forwarding. Keep model-specific tool projection in the local/worker table, with execution covered by code-mode owner tests. Simplify fixture construction and remove redundant representation and call-shape assertions. Correct the secret-question exclusion to compare actual question IDs. Preserve documented SQLite query-bound, host-thread deadlock, and schema-admission regressions. CI-only: plain oxlint, oxfmt, and git diff --check passed. No tests or coverage run. Cumulative shard LOC 6304 to 5506, below the requested 25 percent reduction to preserve distinct boundary contracts. * test(agents): consolidate remaining s0024 duplicate proof Retain deferred-dispatch fallback coverage in the shared selector table instead of a standalone replay. Remove the directory-builder self-comparison while keeping actual filtering, bounds, restoration, and exact configuration preservation assertions. Share question fixture fields, simplify decoder and persisted-state helpers, and remove redundant setup/cache-accessor checks. Keep receipt consumption, prepared-route authority, staged secret custody, recovery, ordering, and SQL regression outcomes. Static-only validation: plain oxlint, oxfmt, and git diff --check passed. No test or coverage runs. Third pass removes 68 lines; cumulative shard LOC 6304 to 5438. Target remains unmet because no further duplicated layers or retired subjects were found. * test(state): deslop s0026 tests Reuse existing migration fixtures and version assertions, share per-case lease state, and consolidate typed reader-result guards. Preserve all distinct migration, authority, snapshot and lifecycle cases. CI-only campaign: formatting, plain oxlint and diff checks pass; runtime proof deferred to hosted CI. Reduction is 82 of 5982 lines; further cuts would remove distinct storage and lifecycle proof. * test(state): remove redundant s0026 proof layers Keep the three maintenance-scope cases in openclaw-state-db-resource-scope.test.ts, where their bodies are duplicated exactly. Keep transaction ownership proof in openclaw-state-lease.test.ts and worker liveness in the heartbeat termination case. The known-directory retirement case retains successful root fencing; failed drainage remains separate. Exercise workspace lookup through its public boundary, retain native and cached account results without SQL call-shape spies, and leave repair-log field proof to openclaw-agent-db.open-timing.test.ts while preserving one repair per agent. All distinct recovery, authority and ordering assertions remain. CI-only: plain oxlint, formatting and diff-check passed; no tests or coverage ran. Cumulative reduction is 5982 to 5690 lines, below the 25 percent target. * test(state): consolidate s0026 redundant test layers Remove handwritten SQL plan inventories; retain captured production watch, orphan-cleanup, delivery and reply plans. Session history queries also retain production-path proof in session-accessor.sqlite-history-query-plan.test.ts; schema admission and index repair suites own canonical index shape. Cold-worker fingerprints remain in sqlite-schema-contract.worker.test.ts; native admission identity and receipt publication remain in node-sqlite.test.ts, sqlite-worker-store.test.ts and sqlite-worker-broker-reply.test.ts. Canonical read-only and cached-admission tests retain drift refusal. Generic witness mutation checks remain in doctor-migration-preservation and write-layout migration tests. Consolidate resource promotion/drain success into native-reader and selection-close cases, busy lease refusal into existing preparation/timeout/held-owner cases, and profile-name/invalid-avatar checks into stronger existing lifecycle cases. Retain migration preservation and raw predicate edge paths; use one compressed envelope for all fallback fields. CI-only: oxfmt, plain oxlint and diff checks pass; no tests or coverage run. Cumulative test LOC 5982 to 5244 (-12.3 percent), still below the requested 25 percent cut. * test(new-session): deslop s0025 tests Remove duplicate worker-inference cases retained in worker-inference.test.ts and the oversized placement option check retained in session-placement-recovery-state.test.ts. The submit-gates cooldown row retains the ready-draft contract. Consolidate composer and catalog lifecycle fixtures without removing distinct behaviors. CI-only campaign: plain oxlint, oxfmt, and diff checks passed; no tests or coverage runs. Reduce shard from 5702 to 5418 lines (5.0%); 25% target remains unmet to retain distinct auth, persistence, recovery, and lifecycle proof. * test(new-session): consolidate s0025 owner-boundary coverage Replace repeated input matrices, internal call assertions, and fixture layers with branch representatives. Reuse the metadata reader settlement promise. Preserve distinct permission, recovery, persistence, and late-result behaviors. Keepers: chat-pane-keyboard-focus, session-menu-solid, and chat-composer-focus e2e retain shared key routing; native-terminal-submission retains cancellation and terminal-panel-readiness retains exit formatting; chat-effort-picker.browser and model-select-state retain speed presentation; required-placement retains hosted runtime rejection; submit-gates now proves missing and present device metadata at the actual submission boundary. create-params retains the mocked runtime/create payload projection. CI-only: oxfmt, plain oxlint, and diff check passed; no test or build runs. Additional 531-line cut, 5418 to 4887. Cumulative reduction is 14.3 percent from 5702, below the 25 percent target; retained distinct durability and lifecycle cases have no equivalent keeper. * test(new-session): reduce s0025 fixture scaffolding Reuse typed checkout and media fixtures, retain each placement and authority branch, and consolidate completion and route-transition setup. Multi-branch checkout navigation remains in the dedicated suggestion lifecycle test. No runtime tests per CI-only campaign mode. * test(gateway): deslop s0028 tests Consolidate repeated transcript, model, HTTP and lifecycle fixtures while preserving distinct authority, durability and ordering assertions. The hooks invalid-config table retains root-path rejection after removing its standalone duplicate; both wildcard mapping cases remain table rows. Remove stale backfill label-generation mocks: the host and worker owners no longer invoke that path, and stored metadata and title assertions remain. CI-only validation: plain oxlint, oxfmt and diff-check. No test or coverage runs. * test(gateway): consolidate s0028 owner-boundary coverage Remove duplicate transport fallback and alias normalization layers retained by sessions.acp-runtime-metadata.test.ts and model-selection.resolved-pin.test.ts. HTTP hook owner routing and rebinding remain covered by server.hooks.test.ts. Fallback runtime equivalence remains covered by auto-reply/fallback-state.test.ts. Consolidate cold-preview, title-preservation and stale-avatar assertions into stronger retained paths. Drop internal pool/query/collaborator-count instrumentation, shrink fixtures, and retain authority, durability, privacy and lifecycle interleavings. CI-only: plain oxlint, oxfmt and diff-check passed; no tests or coverage run. * test(gateway): remove duplicate s0028 contract layers Remove the Gravatar waiter harness duplicated by user-profiles-http.gravatar.test.ts deadline, disconnect, and authorization-disconnect tests. Keep signed lineage redemption in gateway.hosted-routing.test.ts and stored Ultra row coverage in server.sessions.thinking-e2e.test.ts plus session-utils.metadata-perf.test.ts. Retain unknown mapping fallback and invalid request rejection while dropping hook target replays already covered by server.hooks.test.ts and server-http.hooks-delivery.test.ts. Static validation only: oxlint, formatting, and diff-check; no test runs. * test(plugins): deslop s0027 tests Remove installed manifest ownership duplicates covered by manifest-registry-installed.ownership.test.ts: persisted cold reload identities and phase-local replacement path checks. Reuse loader and scheduled-memory fixtures while retaining authority, lifecycle, and durability assertions. CI-only campaign: runtime proof deferred to hosted CI. * test(plugins): consolidate s0027 owner fixtures Consolidate HTTP ownership conflicts, loader outcomes, scheduled ownership caps, and frozen manager adaptation. Reuse production metadata loading and local package/resource/config fixture owners; remove internal writer-options and registration-spy inventories. Getter descriptor proof remains in loader.runtime-registry and loader.capability-factory tests; detailed OpenAI policy output remains in extensions/openai/provider-policy-api.test.ts. Keep bundled adapter lookup covered directly. CI-only campaign: no test runs. * test(plugins): reduce s0027 resource fixture duplication Share captured-registry GC scenarios and scoped loader resource cleanup without removing authority or lifecycle assertions. Compact versioned install fixtures and route observations. The removed config-writer inventory is covered by install-record-commit.sqlite.test.ts: returns committed source separately from authored configuration. Preserve adversarial provider-key order. CI-only: no test runs. * test(plugin-sdk): deslop s0029 tests Consolidate repeated agent, schema-validation, artifact, and QA fixtures while retaining all cases and distinct assertions. Keep authority, persistence, cursor, stream-redaction, and public SDK contract coverage intact. CI-only validation: formatting, plain oxlint, and diff-check; hosted CI owns test execution. * test: preserve boundary coverage in batch d502 Restore review-flagged files to the pinned main baseline, retaining distinct authorization, privacy, migration, recovery, lifecycle and UI input contracts. Drop replay hunks implicated by hosted type, lint, fixture and mock-export checks. Keep only the remaining reviewed test consolidation. * test: retain lint-compatible fixture boundaries
588 lines
23 KiB
TypeScript
588 lines
23 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[] = [];
|
|
const write = (root: string, name: string, content: string | Buffer) =>
|
|
writeFileSync(path.join(root, name), content);
|
|
const read = (root: string, name: string) => readFileSync(path.join(root, name), "utf8");
|
|
const hookFailure = (dir: string) => runFailure(dir, "bash", ["git-hooks/pre-commit"]);
|
|
const expectNoCommit = (dir: string) =>
|
|
expect(runFailure(dir, "git", ["rev-parse", "--verify", "HEAD"]).status).not.toBe(0);
|
|
const expectNoToolingWrites = (dir: string) => {
|
|
for (const name of ["node_modules", "formatter-call.json"]) {
|
|
expect(existsSync(path.join(dir, name))).toBe(false);
|
|
}
|
|
};
|
|
|
|
function writeJson(dir: string, value: Record<string, unknown>) {
|
|
write(dir, "package.json", JSON.stringify(value));
|
|
}
|
|
|
|
function patchManifest(dir: string, patch: Record<string, unknown>) {
|
|
writeJson(dir, { ...JSON.parse(read(dir, "package.json")), ...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") {
|
|
write(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("preserves force-added ignored skill files without running the changed-scope check", () => {
|
|
const name = ".agents/skills/discord-clawd/SKILL.md";
|
|
const dir = createContentGuardFixture(tempDirs);
|
|
const fakeBinDir = path.join(dir, "bin");
|
|
write(dir, "secret.txt", "do-not-stage\n");
|
|
write(dir, "package.json", '{"name":"tmp"}\n');
|
|
write(dir, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
|
|
writeExecutable(
|
|
fakeBinDir,
|
|
"pnpm",
|
|
"#!/bin/sh\necho 'pnpm should not run from pre-commit' >&2\nexit 99\n",
|
|
);
|
|
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([
|
|
name,
|
|
".gitignore",
|
|
]);
|
|
});
|
|
|
|
it("formats with external private rules and then blocks their literals", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-normal-");
|
|
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
|
|
const fakeBinDir = installPreCommitFixture(dir);
|
|
const logPath = installFormattingRecorder(dir);
|
|
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]);
|
|
stage(dir, "changed.ts", "export const value = 1;\n");
|
|
|
|
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",
|
|
]);
|
|
stage(dir, "changed.ts", literals[0]);
|
|
expect(hookFailure(dir).stderr).toContain("Blocked staged content");
|
|
});
|
|
|
|
it.each(["gone.ts", "alias.ts"])(
|
|
"preserves staged and working-tree bytes independently for %s",
|
|
(name) => {
|
|
const dir = createContentGuardFixture(tempDirs);
|
|
const deleted = name === "gone.ts";
|
|
const symlink = name === "alias.ts";
|
|
const staged = "export const keep = 1;\n";
|
|
const working = "const other = 2;\n";
|
|
if (symlink) {
|
|
write(dir, "target-a.ts", "const unformatted = 1\n");
|
|
write(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);
|
|
unlinkSync(path.join(dir, name));
|
|
}
|
|
run(dir, "git", commitArgs);
|
|
expect(run(dir, "git", ["show", `HEAD:${name}`])).toBe(
|
|
symlink ? "target-a.ts" : staged.trim(),
|
|
);
|
|
if (deleted) {
|
|
expect(existsSync(path.join(dir, name))).toBe(false);
|
|
} else {
|
|
expect(read(dir, name)).toBe(working);
|
|
}
|
|
},
|
|
);
|
|
|
|
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");
|
|
write(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;");
|
|
expectNoCommit(dir);
|
|
});
|
|
});
|
|
|
|
describe("staged content guard", () => {
|
|
const fixture = () => createContentGuardFixture(tempDirs);
|
|
|
|
function blocked(dir: string, names: string[], commit = false) {
|
|
const result = commit ? runFailure(dir, "git", commitArgs) : hookFailure(dir);
|
|
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`);
|
|
write(dir, name, "clean working tree\n");
|
|
}
|
|
blocked(dir, [name], kind === "added");
|
|
expect(readFormatterLog(log)).toEqual([]);
|
|
if (kind === "added") {
|
|
expectNoCommit(dir);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("discovers a new path staged during formatting", () => {
|
|
const dir = fixture();
|
|
stage(dir, "payload.ts", "clean\n");
|
|
write(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();
|
|
write(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 beyond both path-count and path-byte batch limits", () => {
|
|
const dir = fixture();
|
|
// 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 });
|
|
write(dir, name, "clean\n");
|
|
batchPaths.push(name);
|
|
}
|
|
run(dir, "git", ["add", "--", ...batchPaths]);
|
|
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"));
|
|
write(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 {
|
|
write(dir, rulePath, content);
|
|
}
|
|
const result = hookFailure(dir);
|
|
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();
|
|
write(dir, rulePath, `${rules.join("\n")}\n`);
|
|
const name = `report-${text}\n🦞.ts`;
|
|
stage(dir, name, text);
|
|
const finding = hookFailure(dir);
|
|
|
|
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 = hookFailure(dir);
|
|
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`]);
|
|
} else if (kind === "index") {
|
|
write(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.
|
|
write(dir, ".gitignore", "*.txt\n");
|
|
stage(dir, "trigger.ts", "formatter trigger\n");
|
|
installFormattingRecorder(dir, `rm -- '${objectPath}'`);
|
|
} else {
|
|
unlinkSync(path.join(dir, objectPath));
|
|
}
|
|
}
|
|
const result = hookFailure(dir);
|
|
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(read(dir, "formatter-call.json"))).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") {
|
|
write(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" });
|
|
write(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");
|
|
}
|
|
expectNoToolingWrites(dir);
|
|
});
|
|
|
|
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`;
|
|
write(dir, "partial.ts", working);
|
|
run(dir, "git", commitArgs, env);
|
|
expect(run(dir, "git", ["show", "HEAD:partial.ts"])).toBe("export const value = FORMATTED;");
|
|
expect(read(dir, "partial.ts")).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)");
|
|
expectNoCommit(dir);
|
|
});
|
|
|
|
it("reports missing dependencies without hydrating them", () => {
|
|
const dir = makeTempRepoRoot(tempDirs, "openclaw-run-node-tool-");
|
|
installRunNodeToolFixture(dir);
|
|
write(dir, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
|
|
|
|
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 ?? ""}` };
|
|
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);
|
|
});
|
|
});
|