mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
* test(agents): deslop s035 tests * test(scripts): deslop s030 tests * test(browser): deslop s039 tests * test(gateway): deslop s034 tests * test(copilot): deslop s040 tests * test(auto-reply): deslop s042 tests * test(agents): deslop s041 tests * test(scripts): deslop s022 tests * test(sessions): deslop s036 tests * test(cli): deslop s037 tests * test: adapt d011 fixtures to current owners * test: preserve current native i18n guidance * test: preserve d011 authorization coverage and diagnostic assertions * test: retain fixture imports used by newer main tests * test: fix lint in d011 test reductions * test(copilot): remove obsolete max-lines suppression
1315 lines
49 KiB
TypeScript
1315 lines
49 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
GITHUB_ERROR_BODY_MAX_BYTES,
|
|
GITHUB_RESPONSE_BODY_MAX_BYTES,
|
|
canAutoscrubPullRequest,
|
|
createAutoscrubCommit,
|
|
dependencyGuardCommentAuthors,
|
|
dependencyFieldChanges,
|
|
githubApi,
|
|
isAutoscrubbedDependencyComment,
|
|
isDependencyGuardMarkerComment,
|
|
isRemovalOnlyDependencyGraphChange,
|
|
readBoundedGitHubErrorText,
|
|
renderAutoscrubbedDependencyComment,
|
|
renderBlockedDependencyComment,
|
|
renderClearedDependencyGuardComment,
|
|
renderRemovalOnlyDependencyComment,
|
|
shouldAutoscrubDependencyLockfiles,
|
|
} from "../../scripts/github/dependency-guard.mjs";
|
|
import { loadSecurityReviewPolicy } from "../../scripts/github/security-review-policy.mjs";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const headSha = "a".repeat(40);
|
|
const staleSha = "b".repeat(40);
|
|
const rolloutSha = "c".repeat(40);
|
|
const mergeBaseSha = "d".repeat(40);
|
|
const comparisonPath = `/repos/openclaw/openclaw/compare/${staleSha}...${headSha}`;
|
|
const { isDependencyFile, isDependencyManifest, isPackageLockfile } = loadSecurityReviewPolicy();
|
|
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
function interruptedJsonResponse(error: Error) {
|
|
let started = false;
|
|
return new Response(
|
|
new ReadableStream({
|
|
pull(controller) {
|
|
if (!started) {
|
|
started = true;
|
|
controller.enqueue(new TextEncoder().encode('{"partial":'));
|
|
} else {
|
|
controller.error(error);
|
|
}
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const pullPath = "/repos/openclaw/openclaw/pulls/7";
|
|
const issuePath = "/repos/openclaw/openclaw/issues/7";
|
|
const pullRequest = {
|
|
number: 7,
|
|
state: "open",
|
|
draft: false,
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
changed_files: 1,
|
|
user: { id: 1, login: "contributor", type: "User" },
|
|
base: { ref: "main", sha: staleSha, repo: { id: 1, full_name: "openclaw/openclaw" } },
|
|
head: { ref: "change", sha: headSha, repo: { id: 1, full_name: "openclaw/openclaw" } },
|
|
};
|
|
const approval = {
|
|
id: 11,
|
|
body: "/allow-dependencies-change",
|
|
created_at: "2026-01-01T00:00:01Z",
|
|
updated_at: "2026-01-01T00:00:01Z",
|
|
html_url: "https://github.com/openclaw/openclaw/pull/7#issuecomment-11",
|
|
user: { id: 2, login: "maintainer", type: "User" },
|
|
};
|
|
const approvalNotice = {
|
|
id: 4,
|
|
user: { login: "github-actions[bot]", type: "Bot" },
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
body: `<!-- openclaw:dependency-graph-guard -->\n<!-- openclaw:approval-request ${JSON.stringify({ head: headSha, base: "main", requestedAt: "2026-01-01T00:00:00Z" })} -->\n`,
|
|
};
|
|
|
|
function runDependencyGuard(
|
|
routes: Record<string, unknown> = {},
|
|
mode = "enforce",
|
|
autoscrubToken: string | null = "fixture-autoscrub-token",
|
|
) {
|
|
const dir = tempDirs.make("openclaw-dependency-guard-");
|
|
const eventPath = path.join(dir, "event.json");
|
|
const fixturePath = path.join(dir, "fixture.json");
|
|
const logPath = path.join(dir, "requests.jsonl");
|
|
const outputPath = path.join(dir, "output.txt");
|
|
writeFileSync(outputPath, "");
|
|
writeFileSync(eventPath, JSON.stringify({ pull_request: pullRequest }));
|
|
writeFileSync(logPath, "");
|
|
writeFileSync(
|
|
fixturePath,
|
|
JSON.stringify({
|
|
logPath,
|
|
routes: {
|
|
[`GET ${pullPath}`]: pullRequest,
|
|
[`GET /repos/openclaw/openclaw/commits/${headSha}/statuses`]: [],
|
|
"GET /repos/openclaw/openclaw/pulls/152415": {
|
|
number: 152415,
|
|
state: "closed",
|
|
merged: true,
|
|
merged_at: "2025-12-01T00:00:00Z",
|
|
merge_commit_sha: rolloutSha,
|
|
base: { ref: "main", repo: { full_name: "openclaw/openclaw" } },
|
|
},
|
|
[`GET ${pullPath}/files`]: [{ filename: "pnpm-workspace.yaml" }],
|
|
[`GET ${comparisonPath}`]: {
|
|
base_commit: { sha: staleSha },
|
|
merge_base_commit: { sha: staleSha },
|
|
},
|
|
[`GET ${issuePath}/comments`]: [],
|
|
[`GET ${issuePath}/labels`]: [],
|
|
"GET /repos/openclaw/openclaw/collaborators/contributor/permission": { role_name: "write" },
|
|
"GET /repos/openclaw/openclaw/collaborators/maintainer/permission": {
|
|
role_name: "maintain",
|
|
},
|
|
...routes,
|
|
},
|
|
}),
|
|
);
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"--import",
|
|
fileURLToPath(new URL("../fixtures/github-guard-fetch.mjs", import.meta.url)),
|
|
fileURLToPath(new URL("../../scripts/github/dependency-guard.mjs", import.meta.url)),
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
GITHUB_TOKEN: "fixture-token",
|
|
GITHUB_EVENT_PATH: eventPath,
|
|
GITHUB_REPOSITORY: "openclaw/openclaw",
|
|
GITHUB_RUN_ID: "1",
|
|
GITHUB_OUTPUT: outputPath,
|
|
OPENCLAW_GUARD_TEST_FIXTURE: fixturePath,
|
|
OPENCLAW_DEPENDENCY_GUARD_MODE: mode,
|
|
...(autoscrubToken ? { OPENCLAW_DEPENDENCY_GUARD_AUTOSCRUB_TOKEN: autoscrubToken } : {}),
|
|
},
|
|
},
|
|
);
|
|
const calls: Array<{
|
|
method: string;
|
|
path: string;
|
|
body?: { state?: string; body?: string; variables?: { input?: unknown } };
|
|
}> = readFileSync(logPath, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
return {
|
|
...result,
|
|
calls,
|
|
output: readFileSync(outputPath, "utf8"),
|
|
statuses: calls.filter((call) => call.path.includes("/statuses/")),
|
|
};
|
|
}
|
|
|
|
describe("dependency guard script", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it.each(["maintain", "admin"])("allows %s authors without organization membership", (role) => {
|
|
const result = runDependencyGuard({
|
|
"GET /repos/openclaw/openclaw/collaborators/contributor/permission": { role_name: role },
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.statuses.map((call) => call.body?.state)).toEqual(["failure", "success"]);
|
|
expect(result.stdout).toContain("informational");
|
|
expect(result.stdout).toContain("- `pnpm-workspace.yaml`\n");
|
|
});
|
|
|
|
it("does not transfer command approval to a duplicate PR with the same head", () => {
|
|
const result = runDependencyGuard({
|
|
[`GET ${issuePath}/comments`]: [approvalNotice, approval],
|
|
[`GET /repos/openclaw/openclaw/commits/${headSha}/statuses`]: [
|
|
{
|
|
context: "openclaw/ci-gate",
|
|
description: "PR #8: Security review has not completed",
|
|
creator: { login: "github-actions[bot]", type: "Bot" },
|
|
},
|
|
],
|
|
"GET /repos/openclaw/openclaw/pulls/8": { ...pullRequest, number: 8 },
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.statuses.map((call) => call.body?.state)).not.toContain("success");
|
|
expect(result.statuses.at(-1)?.body?.state).toBe("failure");
|
|
});
|
|
|
|
it.each([
|
|
{ name: "current maintainer command", comment: approval, role: "maintain", allowed: true },
|
|
{ name: "current admin command", comment: approval, role: "admin", allowed: true },
|
|
{ name: "write-only commenter", comment: approval, role: "write", allowed: false },
|
|
{
|
|
name: "command before the current request",
|
|
comment: {
|
|
...approval,
|
|
created_at: "2025-12-31T00:00:00Z",
|
|
updated_at: "2025-12-31T00:00:00Z",
|
|
},
|
|
role: "maintain",
|
|
allowed: false,
|
|
},
|
|
{
|
|
name: "edited comment",
|
|
comment: { ...approval, updated_at: "2026-01-01T00:00:02Z" },
|
|
role: "maintain",
|
|
allowed: false,
|
|
},
|
|
{
|
|
name: "bot command",
|
|
comment: { ...approval, user: { ...approval.user, type: "Bot" } },
|
|
role: "maintain",
|
|
allowed: false,
|
|
},
|
|
{
|
|
name: "security-only command",
|
|
comment: { ...approval, body: "/allow-security-sensitive-change" },
|
|
role: "maintain",
|
|
allowed: false,
|
|
},
|
|
{
|
|
name: "both commands on separate lines",
|
|
comment: {
|
|
...approval,
|
|
body: "/allow-security-sensitive-change\n/allow-dependencies-change",
|
|
},
|
|
role: "maintain",
|
|
allowed: true,
|
|
},
|
|
])("uses $name for an external dependency PR", ({ comment, role, allowed }) => {
|
|
const result = runDependencyGuard({
|
|
"GET /repos/openclaw/openclaw/collaborators/maintainer/permission": { role_name: role },
|
|
[`GET ${issuePath}/comments`]: [approvalNotice, comment],
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.statuses.map((call) => call.body?.state)).toEqual([
|
|
"failure",
|
|
allowed ? "success" : "failure",
|
|
]);
|
|
expect(result.stdout).toContain(
|
|
allowed ? "Dependency graph changes approved" : "Maintainer dependency review required",
|
|
);
|
|
expect(result.stdout).toContain("<!-- openclaw:approval-request ");
|
|
});
|
|
|
|
it("rechecks a command comment before publishing dependency success", () => {
|
|
const result = runDependencyGuard({
|
|
[`GET ${issuePath}/comments`]: {
|
|
responses: [[approvalNotice, approval], [approvalNotice, approval], [approvalNotice]],
|
|
},
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.statuses.at(-1)?.body?.state).toBe("failure");
|
|
expect(result.stdout).toContain("Maintainer dependency review required");
|
|
});
|
|
|
|
it("requires review when a patch is renamed out of its protected directory", () => {
|
|
const result = runDependencyGuard({
|
|
[`GET ${pullPath}/files`]: [
|
|
{ filename: "archived.patch", previous_filename: "patches/package.patch" },
|
|
],
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.statuses.at(-1)?.body?.state).toBe("failure");
|
|
expect(result.stdout).toContain("patches/package.patch");
|
|
});
|
|
|
|
it("requires review when unchanged manifest contents move to a new package", () => {
|
|
const manifest = {
|
|
type: "file",
|
|
encoding: "base64",
|
|
content: Buffer.from(JSON.stringify({ dependencies: { example: "1" } })).toString("base64"),
|
|
};
|
|
const result = runDependencyGuard({
|
|
[`GET ${pullPath}/files`]: [
|
|
{
|
|
filename: "extensions/new/package.json",
|
|
previous_filename: "extensions/old/package.json",
|
|
},
|
|
],
|
|
"GET /repos/openclaw/openclaw/contents/extensions/old/package.json": manifest,
|
|
"GET /repos/openclaw/openclaw/contents/extensions/new/package.json": manifest,
|
|
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${staleSha}...${headSha}`]: [
|
|
{ change_type: "removed", name: "example", manifest: "extensions/old/package.json" },
|
|
],
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.statuses.at(-1)?.body?.state).toBe("failure");
|
|
expect(result.stdout).toContain(
|
|
"- `extensions/old/package.json`\n- `extensions/new/package.json`\n",
|
|
);
|
|
});
|
|
|
|
it("requires review for a renamed lockfile without scrubbing the contributor artifact", () => {
|
|
const routes = {
|
|
[`GET ${pullPath}/files`]: [
|
|
{ filename: "fixtures/old-lockfile.txt", previous_filename: "pnpm-lock.yaml" },
|
|
],
|
|
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${staleSha}...${headSha}`]: [
|
|
{ change_type: "removed", name: "example", manifest: "pnpm-lock.yaml" },
|
|
],
|
|
};
|
|
const detection = runDependencyGuard(routes, "detect");
|
|
expect(detection.status, detection.stderr).toBe(0);
|
|
expect(detection.output).toBe("autoscrub=false\n");
|
|
expect(detection.calls.some((call) => call.path === "/graphql")).toBe(false);
|
|
const enforcement = runDependencyGuard(routes);
|
|
expect(enforcement.status, enforcement.stderr).toBe(0);
|
|
expect(enforcement.statuses.at(-1)?.body?.state).toBe("failure");
|
|
});
|
|
|
|
it("publishes success when a manifest changes only scripts", () => {
|
|
const content = (scripts: unknown) => ({
|
|
type: "file",
|
|
encoding: "base64",
|
|
content: Buffer.from(JSON.stringify({ scripts })).toString("base64"),
|
|
});
|
|
const result = runDependencyGuard({
|
|
[`GET ${pullPath}/files`]: [{ filename: "package.json" }],
|
|
"GET /repos/openclaw/openclaw/contents/package.json": {
|
|
responses: [content({ test: "old" }), content({ test: "new" })],
|
|
},
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.statuses.at(-1)?.body?.state).toBe("success");
|
|
});
|
|
|
|
it.each([
|
|
{ role: "write", headVersion: "1", expected: "success", notice: false },
|
|
{ role: "admin", headVersion: "1", expected: "success", notice: false },
|
|
{ role: "write", headVersion: "2", expected: "failure", notice: true },
|
|
])(
|
|
"evaluates PR manifest changes from the merge base ($role author, version $headVersion)",
|
|
({ role, headVersion, expected, notice }) => {
|
|
const content = (version: string, test: string) => ({
|
|
type: "file",
|
|
encoding: "base64",
|
|
content: Buffer.from(
|
|
JSON.stringify({ devDependencies: { example: version }, scripts: { test } }),
|
|
).toString("base64"),
|
|
});
|
|
const manifestPath = "/repos/openclaw/openclaw/contents/package.json";
|
|
const result = runDependencyGuard({
|
|
[`GET ${pullPath}/files`]: [{ filename: "package.json" }],
|
|
[`GET ${comparisonPath}`]: {
|
|
base_commit: { sha: staleSha },
|
|
merge_base_commit: { sha: mergeBaseSha },
|
|
},
|
|
[`GET ${manifestPath}?ref=${mergeBaseSha}`]: content("1", "old"),
|
|
[`GET ${manifestPath}?ref=${staleSha}`]: content("2", "old"),
|
|
[`GET ${manifestPath}?ref=${headSha}`]: content(headVersion, "new"),
|
|
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${staleSha}...${headSha}`]: [],
|
|
"GET /repos/openclaw/openclaw/collaborators/contributor/permission": { role_name: role },
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.statuses.at(-1)?.body?.state).toBe(expected);
|
|
expect(result.calls.some((call) => call.body?.body)).toBe(notice);
|
|
if (notice) {
|
|
expect(result.stdout).toContain("/allow-dependencies-change");
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["added", "removed"])("still detects a manifest that is %s", (status) => {
|
|
const manifestPath = "/repos/openclaw/openclaw/contents/package.json";
|
|
const content = {
|
|
type: "file",
|
|
encoding: "base64",
|
|
content: Buffer.from(JSON.stringify({ dependencies: { example: "1" } })).toString("base64"),
|
|
};
|
|
const result = runDependencyGuard({
|
|
[`GET ${pullPath}/files`]: [{ filename: "package.json", status }],
|
|
[`GET ${manifestPath}?ref=${staleSha}`]: status === "added" ? { httpError: 404 } : content,
|
|
[`GET ${manifestPath}?ref=${headSha}`]: status === "removed" ? { httpError: 404 } : content,
|
|
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${staleSha}...${headSha}`]: [],
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.statuses.at(-1)?.body?.state).toBe("failure");
|
|
expect(result.stdout).toContain("/allow-dependencies-change");
|
|
});
|
|
|
|
it.each([
|
|
{ base_commit: { sha: headSha }, merge_base_commit: { sha: mergeBaseSha } },
|
|
{ base_commit: { sha: staleSha }, merge_base_commit: { sha: "invalid" } },
|
|
])("fails closed when the manifest merge base is invalid: %j", (comparison) => {
|
|
const result = runDependencyGuard({
|
|
[`GET ${pullPath}/files`]: [{ filename: "package.json" }],
|
|
[`GET ${comparisonPath}`]: comparison,
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("merge base");
|
|
expect(result.statuses.map((call) => call.body?.state)).toEqual(["failure"]);
|
|
expect(result.calls.some((call) => call.path === "/graphql")).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
{ lateApproval: false, writeError: false, headChanged: false },
|
|
{ lateApproval: true, writeError: false, headChanged: false },
|
|
{ lateApproval: false, writeError: true, headChanged: false },
|
|
{ lateApproval: false, writeError: false, headChanged: true },
|
|
])(
|
|
"preserves autoscrub with late approval=$lateApproval, write error=$writeError, head changed=$headChanged",
|
|
({ lateApproval, writeError, headChanged }) => {
|
|
const result = runDependencyGuard(
|
|
{
|
|
[`GET ${pullPath}`]: {
|
|
responses: [
|
|
pullRequest,
|
|
pullRequest,
|
|
headChanged
|
|
? { ...pullRequest, head: { ...pullRequest.head, sha: staleSha } }
|
|
: pullRequest,
|
|
],
|
|
},
|
|
[`GET ${pullPath}/files`]: [{ filename: "pnpm-lock.yaml" }],
|
|
[`GET ${issuePath}/comments`]: {
|
|
responses: [
|
|
[approvalNotice],
|
|
[approvalNotice],
|
|
lateApproval ? [approvalNotice, approval] : [approvalNotice],
|
|
],
|
|
},
|
|
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${staleSha}...${headSha}`]: [],
|
|
"GET /repos/openclaw/openclaw/contents/pnpm-lock.yaml": {
|
|
type: "file",
|
|
encoding: "base64",
|
|
content: Buffer.from("base lockfile").toString("base64"),
|
|
},
|
|
"POST /graphql": writeError
|
|
? { httpError: 403 }
|
|
: { data: { createCommitOnBranch: { commit: { oid: staleSha } } } },
|
|
},
|
|
"autoscrub",
|
|
);
|
|
expect(result.status, result.stderr).toBe(writeError ? 1 : 0);
|
|
if (writeError) {
|
|
expect(result.stderr).toContain("Fixture API failure");
|
|
expect(result.stdout).toContain(
|
|
"Auto-scrub was attempted, but GitHub rejected the cleanup commit",
|
|
);
|
|
}
|
|
const writes = result.calls.filter((call) => call.path === "/graphql");
|
|
expect(writes).toHaveLength(lateApproval || headChanged ? 0 : 1);
|
|
if (headChanged) {
|
|
expect(result.stdout).toContain("Superseded");
|
|
expect(result.calls.some((call) => call.body?.body)).toBe(false);
|
|
expect(result.stderr).not.toContain("Autoscrub failed");
|
|
}
|
|
if (!lateApproval && !headChanged) {
|
|
expect(writes[0]?.body?.variables?.input).toMatchObject({
|
|
expectedHeadOid: headSha,
|
|
fileChanges: {
|
|
additions: [
|
|
{ path: "pnpm-lock.yaml", contents: Buffer.from("base lockfile").toString("base64") },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
expect(result.statuses.map((call) => call.body?.state)).toEqual(["failure"]);
|
|
},
|
|
);
|
|
|
|
it("restores modified, deleted, and added lockfiles from the PR merge base after main advances", () => {
|
|
const content = (text: string) => ({
|
|
type: "file",
|
|
encoding: "base64",
|
|
content: Buffer.from(text).toString("base64"),
|
|
});
|
|
const result = runDependencyGuard(
|
|
{
|
|
[`GET ${pullPath}`]: { ...pullRequest, changed_files: 4 },
|
|
[`GET ${pullPath}/files`]: [
|
|
{ filename: "pnpm-lock.yaml", status: "modified" },
|
|
{ filename: "tools/package-lock.json", status: "removed" },
|
|
{ filename: "new/package-lock.json", status: "added" },
|
|
{ filename: "README.md", status: "modified" },
|
|
],
|
|
[`GET ${comparisonPath}`]: {
|
|
base_commit: { sha: staleSha },
|
|
merge_base_commit: { sha: mergeBaseSha },
|
|
},
|
|
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${staleSha}...${headSha}`]: [],
|
|
[`GET /repos/openclaw/openclaw/contents/pnpm-lock.yaml?ref=${mergeBaseSha}`]:
|
|
content("original lockfile"),
|
|
[`GET /repos/openclaw/openclaw/contents/pnpm-lock.yaml?ref=${staleSha}`]:
|
|
content("unrelated main update"),
|
|
[`GET /repos/openclaw/openclaw/contents/tools/package-lock.json?ref=${mergeBaseSha}`]:
|
|
content("original nested lockfile"),
|
|
[`GET /repos/openclaw/openclaw/contents/tools/package-lock.json?ref=${staleSha}`]: {
|
|
httpError: 404,
|
|
},
|
|
[`GET /repos/openclaw/openclaw/contents/new/package-lock.json?ref=${mergeBaseSha}`]: {
|
|
httpError: 404,
|
|
},
|
|
[`GET /repos/openclaw/openclaw/contents/new/package-lock.json?ref=${staleSha}`]: content(
|
|
"independently added on main",
|
|
),
|
|
"POST /graphql": { data: { createCommitOnBranch: { commit: { oid: rolloutSha } } } },
|
|
},
|
|
"autoscrub",
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const writes = result.calls.filter((call) => call.path === "/graphql");
|
|
expect(writes).toHaveLength(1);
|
|
expect(writes[0]?.body?.variables?.input).toEqual({
|
|
branch: { repositoryNameWithOwner: "openclaw/openclaw", branchName: "change" },
|
|
expectedHeadOid: headSha,
|
|
fileChanges: {
|
|
additions: [
|
|
{ path: "pnpm-lock.yaml", contents: content("original lockfile").content },
|
|
{
|
|
path: "tools/package-lock.json",
|
|
contents: content("original nested lockfile").content,
|
|
},
|
|
],
|
|
deletions: [{ path: "new/package-lock.json" }],
|
|
},
|
|
message: { headline: "chore: remove dependency lockfile change" },
|
|
});
|
|
expect(result.stdout).toContain(`Merge base: \`${mergeBaseSha}\``);
|
|
expect(result.stdout).not.toContain("Verification result:");
|
|
});
|
|
|
|
it("keeps dependency approval required when an editable fork has no autoscrub token", () => {
|
|
const routes = {
|
|
[`GET ${pullPath}`]: {
|
|
...pullRequest,
|
|
maintainer_can_modify: true,
|
|
head: { ...pullRequest.head, repo: { id: 2, full_name: "contributor/openclaw" } },
|
|
},
|
|
[`GET ${pullPath}/files`]: [{ filename: "pnpm-lock.yaml" }],
|
|
[`GET /repos/openclaw/openclaw/dependency-graph/compare/${staleSha}...${headSha}`]: [],
|
|
};
|
|
const autoscrub = runDependencyGuard(routes, "autoscrub", null);
|
|
expect(autoscrub.status, autoscrub.stderr).toBe(0);
|
|
expect(autoscrub.calls.some((call) => call.path === "/graphql")).toBe(false);
|
|
expect(autoscrub.statuses.map((call) => call.body?.state)).toEqual(["failure"]);
|
|
const notice = autoscrub.calls.find(
|
|
(call) => call.path === `${issuePath}/comments` && call.method === "POST",
|
|
);
|
|
expect(notice?.body?.body).toContain("Automatic lockfile cleanup is best effort.");
|
|
expect(notice?.body?.body).toContain("/allow-dependencies-change");
|
|
expect(notice?.body?.body).toContain("git restore");
|
|
|
|
const enforcement = runDependencyGuard(routes, "enforce", null);
|
|
expect(enforcement.status, enforcement.stderr).toBe(0);
|
|
expect(enforcement.statuses.at(-1)?.body?.state).toBe("failure");
|
|
expect(enforcement.stdout).toContain("/allow-dependencies-change");
|
|
expect(enforcement.stdout).toContain("Automatic lockfile cleanup is best effort.");
|
|
});
|
|
|
|
it.each(["detect", "autoscrub", "enforce"])(
|
|
"does not approve or autoscrub a grandfathered lockfile PR in %s mode",
|
|
(mode) => {
|
|
const result = runDependencyGuard(
|
|
{
|
|
[`GET ${pullPath}`]: { ...pullRequest, created_at: "2025-11-01T00:00:00Z" },
|
|
[`GET ${pullPath}/files`]: [{ filename: "pnpm-lock.yaml" }],
|
|
[`GET /repos/openclaw/openclaw/compare/${rolloutSha}...${headSha}`]: {
|
|
base_commit: { sha: rolloutSha },
|
|
merge_base_commit: { sha: staleSha },
|
|
status: "diverged",
|
|
},
|
|
},
|
|
mode,
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("grandfathered");
|
|
expect(result.statuses).toEqual([]);
|
|
expect(result.calls.every((call) => call.method === "GET")).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("detects dependency guard file surfaces", () => {
|
|
expect(isDependencyFile("pnpm-lock.yaml")).toBe(true);
|
|
expect(isDependencyFile("package.json")).toBe(false);
|
|
expect(isDependencyFile("ui/package.json")).toBe(false);
|
|
expect(isDependencyFile("packages/core/package.json")).toBe(false);
|
|
expect(isDependencyFile("qa/convex-credential-broker/package.json")).toBe(false);
|
|
expect(isDependencyFile("package-lock.json")).toBe(true);
|
|
expect(isDependencyFile("tools/nested/pnpm-lock.yaml")).toBe(true);
|
|
expect(isDependencyFile("src/index.ts")).toBe(false);
|
|
expect(isPackageLockfile("pnpm-lock.yaml")).toBe(true);
|
|
expect(isPackageLockfile("package-lock.json")).toBe(true);
|
|
expect(isPackageLockfile("package.json")).toBe(false);
|
|
});
|
|
|
|
it("compares package manifest fields that can affect dependency resolution", () => {
|
|
expect(isDependencyManifest("package.json")).toBe(true);
|
|
expect(isDependencyManifest("extensions/slack/package.json")).toBe(true);
|
|
expect(isDependencyManifest("qa/convex-credential-broker/package.json")).toBe(true);
|
|
expect(isDependencyManifest("src/index.ts")).toBe(false);
|
|
expect(
|
|
dependencyFieldChanges(
|
|
{ scripts: { test: "old" }, dependencies: { a: "1" } },
|
|
{ scripts: { test: "new" }, dependencies: { a: "1" } },
|
|
),
|
|
).toEqual([]);
|
|
expect(
|
|
dependencyFieldChanges(
|
|
{ dependencies: { a: "1" }, devDependencies: { b: "1" } },
|
|
{ dependencies: { a: "2" }, devDependencies: { b: "1", c: "1" } },
|
|
),
|
|
).toEqual(["dependencies", "devDependencies"]);
|
|
expect(
|
|
dependencyFieldChanges(
|
|
{
|
|
optionalDependencies: { a: "1" },
|
|
peerDependencies: { b: "1" },
|
|
overrides: { c: "1" },
|
|
packageManager: "pnpm@10.0.0",
|
|
pnpm: { patchedDependencies: { d: "patches/d.patch" } },
|
|
scripts: { test: "old" },
|
|
},
|
|
{
|
|
optionalDependencies: { a: "2" },
|
|
peerDependencies: { b: "2" },
|
|
overrides: { c: "2" },
|
|
packageManager: "pnpm@10.1.0",
|
|
pnpm: { patchedDependencies: { d: "patches/d2.patch" } },
|
|
scripts: { test: "new" },
|
|
},
|
|
),
|
|
).toEqual(["optionalDependencies", "peerDependencies", "overrides", "packageManager", "pnpm"]);
|
|
});
|
|
|
|
it("allows only dependency graph removals without approval", () => {
|
|
expect(
|
|
isRemovalOnlyDependencyGraphChange([
|
|
{ change_type: "removed", name: "a" },
|
|
{ change_type: "removed", name: "b" },
|
|
]),
|
|
).toBe(true);
|
|
expect(
|
|
isRemovalOnlyDependencyGraphChange([
|
|
{ change_type: "removed", name: "a" },
|
|
{ change_type: "added", name: "b" },
|
|
]),
|
|
).toBe(false);
|
|
expect(isRemovalOnlyDependencyGraphChange([{ change_type: "changed", name: "a" }])).toBe(false);
|
|
expect(isRemovalOnlyDependencyGraphChange([])).toBe(false);
|
|
});
|
|
|
|
it("renders dependency removals as informational", () => {
|
|
const body = renderRemovalOnlyDependencyComment({
|
|
dependencyGraphChanges: [
|
|
{
|
|
change_type: "removed",
|
|
manifest: "extensions/example/package.json",
|
|
name: "example-dependency",
|
|
},
|
|
],
|
|
headSha,
|
|
});
|
|
|
|
expect(body).toContain("Dependency removals noted");
|
|
expect(body).toContain("does not require additional maintainer approval");
|
|
expect(body).toContain("Removed `example-dependency`");
|
|
expect(body).toContain("`extensions/example/package.json`");
|
|
expect(body).toContain(headSha);
|
|
expect(body).not.toContain("changes are blocked");
|
|
});
|
|
|
|
it("trusts only configured dependency guard marker comment authors", () => {
|
|
const trustedAuthors = dependencyGuardCommentAuthors(
|
|
"github-actions[bot], openclaw-autoscrub[bot]",
|
|
);
|
|
expect(dependencyGuardCommentAuthors(undefined)).toEqual(new Set(["github-actions[bot]"]));
|
|
|
|
expect(
|
|
isDependencyGuardMarkerComment(
|
|
{
|
|
body: "<!-- openclaw:dependency-graph-guard -->",
|
|
user: { login: "openclaw-autoscrub[bot]" },
|
|
},
|
|
"<!-- openclaw:dependency-graph-guard -->",
|
|
trustedAuthors,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isDependencyGuardMarkerComment(
|
|
{
|
|
body: "<!-- openclaw:dependency-graph-guard -->",
|
|
user: { login: "contributor" },
|
|
},
|
|
"<!-- openclaw:dependency-graph-guard -->",
|
|
trustedAuthors,
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
isDependencyGuardMarkerComment(
|
|
{
|
|
body: "no marker",
|
|
user: { login: "github-actions[bot]" },
|
|
},
|
|
"<!-- openclaw:dependency-graph-guard -->",
|
|
trustedAuthors,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("renders deterministic removal guidance for blocked lockfile changes", () => {
|
|
const body = renderBlockedDependencyComment({
|
|
baseRepository: "openclaw/openclaw",
|
|
baseBranch: "main",
|
|
headSha,
|
|
lockfileChanges: ["pnpm-lock.yaml", "tools/nested/pnpm-lock.yaml"],
|
|
dependencyManifestChanges: [
|
|
{
|
|
path: "package.json",
|
|
fields: ["dependencies"],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(body).toContain("<!-- openclaw:dependency-graph-guard -->");
|
|
expect(body).toContain("Maintainer dependency review required");
|
|
expect(body).toContain("- `pnpm-lock.yaml`\n");
|
|
expect(body).toContain("- `tools/nested/pnpm-lock.yaml`\n");
|
|
expect(body).toContain("- `package.json`\n");
|
|
expect(body).toContain(
|
|
"git restore --source=\"$(git merge-base HEAD FETCH_HEAD)\" --staged --worktree -- 'pnpm-lock.yaml' 'tools/nested/pnpm-lock.yaml'",
|
|
);
|
|
expect(body).toContain("git fetch 'https://github.com/openclaw/openclaw.git' 'main'");
|
|
expect(body).toContain("```text\n/allow-dependencies-change\n```");
|
|
expect(body).toContain(`Current SHA: \`${headSha}\``);
|
|
expect(body).toContain("A later push requires a fresh approval comment.");
|
|
});
|
|
|
|
it("shell-quotes PR-controlled paths in removal guidance", () => {
|
|
const body = renderBlockedDependencyComment({
|
|
baseRepository: "openclaw/openclaw",
|
|
baseBranch: "release/canary branch",
|
|
headSha,
|
|
lockfileChanges: [
|
|
"dir with spaces/pnpm-lock.yaml",
|
|
"safe/quote'$(touch bad);/package-lock.json",
|
|
],
|
|
dependencyManifestChanges: [],
|
|
});
|
|
|
|
expect(body).toContain(
|
|
"git restore --source=\"$(git merge-base HEAD FETCH_HEAD)\" --staged --worktree -- 'dir with spaces/pnpm-lock.yaml' 'safe/quote'\\''$(touch bad);/package-lock.json'",
|
|
);
|
|
});
|
|
|
|
it("autoscrubs only lockfile changes with no dependency manifest changes", () => {
|
|
expect(
|
|
shouldAutoscrubDependencyLockfiles({
|
|
dependencyFiles: ["pnpm-lock.yaml"],
|
|
lockfileChanges: ["pnpm-lock.yaml"],
|
|
dependencyManifestChanges: [],
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldAutoscrubDependencyLockfiles({
|
|
dependencyFiles: ["pnpm-lock.yaml"],
|
|
lockfileChanges: ["pnpm-lock.yaml"],
|
|
dependencyManifestChanges: [{ path: "package.json", fields: ["dependencies"] }],
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldAutoscrubDependencyLockfiles({
|
|
dependencyFiles: [],
|
|
lockfileChanges: [],
|
|
dependencyManifestChanges: [],
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldAutoscrubDependencyLockfiles({
|
|
dependencyFiles: ["pnpm-lock.yaml", "patches/example.patch"],
|
|
lockfileChanges: ["pnpm-lock.yaml"],
|
|
dependencyManifestChanges: [],
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldAutoscrubDependencyLockfiles({
|
|
dependencyFiles: ["pnpm-lock.yaml", "pnpm-workspace.yaml"],
|
|
lockfileChanges: ["pnpm-lock.yaml"],
|
|
dependencyManifestChanges: [],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("attempts autoscrub on PR branches maintainers can modify", () => {
|
|
const sameRepoPullRequest = {
|
|
head: {
|
|
ref: "contributor/change",
|
|
repo: { full_name: "openclaw/openclaw" },
|
|
sha: headSha,
|
|
},
|
|
};
|
|
const forkPullRequest = {
|
|
head: {
|
|
ref: "contributor/change",
|
|
repo: { full_name: "external/openclaw" },
|
|
sha: headSha,
|
|
},
|
|
};
|
|
const editableForkPullRequest = {
|
|
maintainer_can_modify: true,
|
|
head: {
|
|
ref: "contributor/change",
|
|
repo: { full_name: "external/openclaw" },
|
|
sha: headSha,
|
|
},
|
|
};
|
|
|
|
expect(
|
|
canAutoscrubPullRequest({
|
|
owner: "openclaw",
|
|
repo: "openclaw",
|
|
pullRequest: sameRepoPullRequest,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
canAutoscrubPullRequest({
|
|
owner: "openclaw",
|
|
repo: "openclaw",
|
|
pullRequest: forkPullRequest,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
canAutoscrubPullRequest({
|
|
owner: "openclaw",
|
|
repo: "openclaw",
|
|
pullRequest: editableForkPullRequest,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("renders deterministic autoscrub success comments", () => {
|
|
const body = renderAutoscrubbedDependencyComment({
|
|
baseBranch: "main",
|
|
commitSha: staleSha,
|
|
mergeBaseSha,
|
|
lockfileChanges: ["pnpm-lock.yaml", "tools/nested/pnpm-lock.yaml"],
|
|
});
|
|
|
|
expect(body).toContain("<!-- openclaw:dependency-graph-guard -->");
|
|
expect(body).toContain("Dependency lockfile changes were removed");
|
|
expect(body).toContain("did not change dependency graph fields in package manifests");
|
|
expect(body).toContain("`pnpm-lock.yaml`");
|
|
expect(body).toContain("`tools/nested/pnpm-lock.yaml`");
|
|
expect(body).toContain(`Cleanup commit: \`${staleSha}\``);
|
|
expect(body).toContain(
|
|
"restored each listed lockfile to its merge-base state, removing files added by this PR",
|
|
);
|
|
expect(body).toContain(`Merge base: \`${mergeBaseSha}\``);
|
|
expect(body).not.toContain("Verification result:");
|
|
expect(isAutoscrubbedDependencyComment({ body })).toBe(true);
|
|
});
|
|
|
|
it("renders fork and dependency-manifest autoscrub guidance", () => {
|
|
const forkBody = renderBlockedDependencyComment({
|
|
baseRepository: "openclaw/openclaw",
|
|
baseBranch: "main",
|
|
headSha,
|
|
lockfileChanges: ["pnpm-lock.yaml"],
|
|
dependencyManifestChanges: [],
|
|
autoscrubStatus: { kind: "unavailable" },
|
|
});
|
|
const unsafeBody = renderBlockedDependencyComment({
|
|
baseRepository: "openclaw/openclaw",
|
|
baseBranch: "main",
|
|
headSha,
|
|
lockfileChanges: ["pnpm-lock.yaml"],
|
|
dependencyManifestChanges: [],
|
|
autoscrubStatus: {
|
|
kind: "blocked-by-dependency-manifest-fields",
|
|
changes: [{ path: "package.json", fields: ["dependencies"] }],
|
|
},
|
|
});
|
|
const mixedBody = renderBlockedDependencyComment({
|
|
baseRepository: "openclaw/openclaw",
|
|
baseBranch: "main",
|
|
headSha,
|
|
lockfileChanges: ["pnpm-lock.yaml"],
|
|
dependencyManifestChanges: [],
|
|
autoscrubStatus: {
|
|
kind: "blocked-by-other-dependency-files",
|
|
files: ["patches/example.patch", "pnpm-workspace.yaml"],
|
|
},
|
|
});
|
|
|
|
expect(forkBody).toContain("Automatic lockfile cleanup is best effort.");
|
|
expect(forkBody).toContain("These lockfile changes remain in this PR.");
|
|
expect(unsafeBody).toContain("changes package manifest dependency graph fields");
|
|
expect(unsafeBody).toContain("- `package.json`\n");
|
|
expect(unsafeBody).toContain("Dependency graph changes require maintainer review");
|
|
expect(mixedBody).toContain("also changes dependency-related files");
|
|
expect(mixedBody).toContain("`patches/example.patch`");
|
|
expect(mixedBody).toContain("`pnpm-workspace.yaml`");
|
|
});
|
|
|
|
it("reads base lockfiles with the base API before writing autoscrub commits", async () => {
|
|
const calls: Array<{ api: string; path: string; variables?: unknown }> = [];
|
|
const baseApi = {
|
|
request: async (requestPath: string) => {
|
|
calls.push({ api: "base", path: requestPath });
|
|
if (requestPath.includes("/compare/")) {
|
|
return { base_commit: { sha: "base-sha" }, merge_base_commit: { sha: mergeBaseSha } };
|
|
}
|
|
if (requestPath.includes("/contents/pnpm-lock.yaml?")) {
|
|
return {
|
|
content: Buffer.from("base lockfile").toString("base64"),
|
|
encoding: "base64",
|
|
sha: "base-file",
|
|
type: "file",
|
|
};
|
|
}
|
|
throw new Error(`unexpected base request: ${requestPath}`);
|
|
},
|
|
};
|
|
const writeApi = {
|
|
graphql: async (_query: string, variables: unknown) => {
|
|
calls.push({ api: "write", path: "graphql", variables });
|
|
return { createCommitOnBranch: { commit: { oid: staleSha } } };
|
|
},
|
|
};
|
|
|
|
const autoscrubPullRequest = {
|
|
user: { id: 1, login: "contributor", type: "User" },
|
|
base: { ref: "main", sha: "base-sha" },
|
|
head: { ref: "contributor/change", sha: headSha },
|
|
};
|
|
const guard = {
|
|
owner: "openclaw",
|
|
repo: "openclaw",
|
|
pullRequest: autoscrubPullRequest,
|
|
pullPath: "/repos/openclaw/openclaw/pulls/1",
|
|
issuePath: "/repos/openclaw/openclaw/issues/1",
|
|
commentMarker: "<!-- openclaw:dependency-graph-guard -->",
|
|
approvalCommand: "/allow-dependencies-change",
|
|
api: {
|
|
request: async (requestPath: string) =>
|
|
requestPath.endsWith("/permission") ? { role_name: "read" } : autoscrubPullRequest,
|
|
paginate: async (requestPath: string) => {
|
|
if (requestPath === "/repos/openclaw/openclaw/issues/1/comments") {
|
|
return [];
|
|
}
|
|
throw new Error(`unexpected guard request: ${requestPath}`);
|
|
},
|
|
},
|
|
};
|
|
const commit = await createAutoscrubCommit(
|
|
{ baseApi, writeApi, guard },
|
|
{
|
|
owner: "openclaw",
|
|
repo: "openclaw",
|
|
pullRequest: autoscrubPullRequest,
|
|
lockfileChanges: ["pnpm-lock.yaml"],
|
|
targetRepository: { owner: "contributor", repo: "openclaw" },
|
|
},
|
|
);
|
|
|
|
expect(commit).toEqual({ sha: staleSha, mergeBaseSha });
|
|
expect(calls.map((call) => `${call.api}:${call.path}`)).toEqual([
|
|
`base:/repos/openclaw/openclaw/compare/base-sha...${headSha}?per_page=1&page=2`,
|
|
`base:/repos/openclaw/openclaw/contents/pnpm-lock.yaml?ref=${mergeBaseSha}`,
|
|
"write:graphql",
|
|
]);
|
|
expect(calls[2]?.variables).toMatchObject({
|
|
input: {
|
|
branch: {
|
|
repositoryNameWithOwner: "contributor/openclaw",
|
|
branchName: "contributor/change",
|
|
},
|
|
expectedHeadOid: headSha,
|
|
fileChanges: {
|
|
additions: [
|
|
{
|
|
contents: Buffer.from("base lockfile").toString("base64"),
|
|
path: "pnpm-lock.yaml",
|
|
},
|
|
],
|
|
deletions: [],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("renders a cleared guard comment that preserves approval freshness", () => {
|
|
const body = renderClearedDependencyGuardComment({ headSha });
|
|
|
|
expect(body).toContain("<!-- openclaw:dependency-graph-guard -->");
|
|
expect(body).toContain("Dependency graph guard cleared");
|
|
expect(body).toContain(headSha);
|
|
expect(body).toContain("requires a maintainer's `/allow-dependencies-change` comment");
|
|
});
|
|
|
|
it("bounds GitHub error bodies by content-length", async () => {
|
|
const response = new Response("ignored", {
|
|
headers: { "content-length": String(GITHUB_ERROR_BODY_MAX_BYTES + 1) },
|
|
});
|
|
|
|
await expect(readBoundedGitHubErrorText(response)).rejects.toThrow(
|
|
`GitHub error response body exceeded ${GITHUB_ERROR_BODY_MAX_BYTES} bytes`,
|
|
);
|
|
});
|
|
|
|
it("preserves GitHub status when an error body exceeds the cap", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (() =>
|
|
Promise.resolve(
|
|
new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(GITHUB_ERROR_BODY_MAX_BYTES + 1));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 403, statusText: "Forbidden" },
|
|
),
|
|
)) as typeof fetch;
|
|
|
|
try {
|
|
await expect(githubApi("token").request("/repos/openclaw/openclaw")).rejects.toMatchObject({
|
|
message: `GitHub API GET /repos/openclaw/openclaw failed: 403 Forbidden: GitHub error response body exceeded ${GITHUB_ERROR_BODY_MAX_BYTES} bytes`,
|
|
status: 403,
|
|
});
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ method: "GET", status: 500 },
|
|
{ method: "HEAD", status: 500 },
|
|
{ method: "GET", status: 503 },
|
|
])("recovers from HTTP $status on $method requests", async ({ method, status }) => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(new Response("unicorn", { status, statusText: "Server Error" }))
|
|
.mockResolvedValueOnce(
|
|
method === "HEAD" ? new Response(null, { status: 204 }) : Response.json({ ok: true }),
|
|
);
|
|
|
|
await expect(
|
|
githubApi("token", { fetchImpl, retryDelaysMs: [0] }).request(
|
|
"/repos/openclaw/openclaw/pulls/1/files",
|
|
{ method },
|
|
),
|
|
).resolves.toEqual(method === "HEAD" ? null : { ok: true });
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each([
|
|
{ method: "POST", status: 500 },
|
|
{ method: "PATCH", status: 500 },
|
|
{ method: "DELETE", status: 500 },
|
|
{ method: "POST", status: 503 },
|
|
])("does not retry HTTP $status on $method writes", async ({ method, status }) => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response("unicorn", { status, statusText: "Server Error" }));
|
|
|
|
await expect(
|
|
githubApi("token", { fetchImpl, retryDelaysMs: [0] }).request(
|
|
"/repos/openclaw/openclaw/issues/1/comments",
|
|
{ method, body: "{}" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
status,
|
|
message: `GitHub API ${method} /repos/openclaw/openclaw/issues/1/comments failed: ${status} Server Error: unicorn`,
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([
|
|
{ method: "GET", code: "ECONNRESET", phase: "connection" },
|
|
{ method: "GET", code: "EAI_AGAIN", phase: "connection" },
|
|
{ method: "GET", code: "ENOTFOUND", phase: "connection" },
|
|
{ method: "HEAD", code: "UND_ERR_SOCKET", phase: "connection" },
|
|
{ method: "GET", code: "UND_ERR_SOCKET", phase: "body" },
|
|
{ method: "GET", code: "ECONNRESET", phase: "body" },
|
|
])("recovers from $code in the $phase of $method requests", async ({ method, code, phase }) => {
|
|
const error = new TypeError("terminated", { cause: Object.assign(new Error(), { code }) });
|
|
const fetchImpl = vi.fn<typeof fetch>().mockImplementationOnce(async () => {
|
|
if (phase === "body") {
|
|
return interruptedJsonResponse(error);
|
|
}
|
|
throw error;
|
|
});
|
|
fetchImpl.mockResolvedValueOnce(
|
|
method === "HEAD" ? new Response(null, { status: 204 }) : Response.json({ complete: true }),
|
|
);
|
|
|
|
await expect(
|
|
githubApi("token", { fetchImpl, retryDelaysMs: [0] }).request(pullPath, { method }),
|
|
).resolves.toEqual(method === "HEAD" ? null : { complete: true });
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("shares the bounded retry budget between connection, HTTP, and body failures", async () => {
|
|
const error = new TypeError("terminated", {
|
|
cause: Object.assign(new Error("connection reset"), { code: "ECONNRESET" }),
|
|
});
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockRejectedValueOnce(error)
|
|
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
|
.mockImplementation(async () => interruptedJsonResponse(error));
|
|
|
|
await expect(
|
|
githubApi("token", { fetchImpl, retryDelaysMs: [0, 0, 0] }).request(pullPath),
|
|
).rejects.toMatchObject({
|
|
message: expect.stringContaining(`GitHub API GET ${pullPath} failed: ECONNRESET`),
|
|
cause: error,
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it("does not replay a write whose response body was interrupted", async () => {
|
|
const error = new TypeError("terminated", {
|
|
cause: Object.assign(new Error(), { code: "UND_ERR_SOCKET" }),
|
|
});
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () => interruptedJsonResponse(error));
|
|
await expect(
|
|
githubApi("token", { fetchImpl }).request(issuePath, { method: "POST", body: "{}" }),
|
|
).rejects.toMatchObject({
|
|
message: expect.stringContaining(`GitHub API POST ${issuePath} failed: UND_ERR_SOCKET`),
|
|
cause: error,
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each(["invalid JSON", "unknown stream error"])(
|
|
"does not retry %s responses",
|
|
async (failure) => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () =>
|
|
failure === "invalid JSON"
|
|
? new Response("invalid JSON")
|
|
: interruptedJsonResponse(new Error("unknown stream error")),
|
|
);
|
|
await expect(githubApi("token", { fetchImpl }).request(pullPath)).rejects.toThrow();
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
|
|
it.each(["POST", "PATCH", "DELETE"])(
|
|
"does not retry connection failures on %s writes",
|
|
async (method) => {
|
|
const error = new TypeError("fetch failed", {
|
|
cause: Object.assign(new Error("connection reset"), { code: "ECONNRESET" }),
|
|
});
|
|
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(error);
|
|
await expect(
|
|
githubApi("token", { fetchImpl }).request(issuePath, { method, body: "{}" }),
|
|
).rejects.toMatchObject({
|
|
message: expect.stringContaining(`GitHub API ${method} ${issuePath} failed: ECONNRESET`),
|
|
cause: error,
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
new DOMException("aborted", "AbortError"),
|
|
new TypeError("fetch failed", {
|
|
cause: Object.assign(new Error("certificate expired"), { code: "CERT_HAS_EXPIRED" }),
|
|
}),
|
|
new TypeError("invalid request"),
|
|
])("does not retry non-transient fetch rejection %s", async (error) => {
|
|
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(error);
|
|
await expect(githubApi("token", { fetchImpl }).request(pullPath)).rejects.toMatchObject({
|
|
cause: error,
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([
|
|
{ method: "GET", phase: "connection" },
|
|
{ method: "POST", phase: "connection" },
|
|
{ method: "GET", phase: "body" },
|
|
{ method: "POST", phase: "body" },
|
|
])(
|
|
"does not retry or mark an aborted $method $phase error for recovery",
|
|
async ({ method, phase }) => {
|
|
const controller = new AbortController();
|
|
const error = new TypeError("fetch failed", {
|
|
cause: Object.assign(new Error(), { code: "ECONNRESET" }),
|
|
});
|
|
const fetchImpl = vi.fn<typeof fetch>().mockImplementation(async () => {
|
|
controller.abort(error);
|
|
if (phase === "body") {
|
|
return interruptedJsonResponse(error);
|
|
}
|
|
throw error;
|
|
});
|
|
const request = githubApi("token", { fetchImpl }).request(pullPath, {
|
|
method,
|
|
signal: controller.signal,
|
|
});
|
|
await expect(request).rejects.toMatchObject({ cause: error });
|
|
await expect(request).rejects.not.toHaveProperty("code");
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
|
|
it("bounds successful GitHub API response bodies", async () => {
|
|
const request = githubApi("token", {
|
|
responseMaxBodyBytes: 64,
|
|
fetchImpl: (() =>
|
|
Promise.resolve(
|
|
new Response("x".repeat(65), {
|
|
headers: { "content-length": "65" },
|
|
}),
|
|
)) as typeof fetch,
|
|
}).request("/repos/openclaw/openclaw");
|
|
|
|
await expect(request).rejects.toThrow("GitHub response body exceeded 64 bytes");
|
|
expect(GITHUB_RESPONSE_BODY_MAX_BYTES).toBeGreaterThan(64);
|
|
});
|
|
|
|
it.each(["connection", "body"])(
|
|
"keeps the original request timeout active during %s retry backoff",
|
|
async (phase) => {
|
|
vi.useFakeTimers();
|
|
let signal: AbortSignal | undefined;
|
|
const fetchImpl = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
|
signal = init?.signal ?? undefined;
|
|
const error = new TypeError("terminated", {
|
|
cause: Object.assign(new Error(), { code: "ECONNRESET" }),
|
|
});
|
|
if (phase === "body") {
|
|
return interruptedJsonResponse(error);
|
|
}
|
|
throw error;
|
|
});
|
|
const request = githubApi("token", {
|
|
fetchImpl,
|
|
timeoutMs: 5,
|
|
retryDelaysMs: [10_000],
|
|
}).request(pullPath);
|
|
const rejection = expect(request).rejects.toThrow(
|
|
`GitHub API GET ${pullPath} exceeded timeout 5ms`,
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(5);
|
|
await rejection;
|
|
expect(signal?.aborted).toBe(true);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
|
|
it("aborts stalled GitHub API fetches at the request timeout", async () => {
|
|
let signal: AbortSignal | undefined;
|
|
let markFetchStarted!: () => void;
|
|
const fetchStarted = new Promise<void>((resolve) => {
|
|
markFetchStarted = resolve;
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
const request = githubApi("token", {
|
|
timeoutMs: 5,
|
|
fetchImpl: ((_url, init) => {
|
|
signal = init?.signal ?? undefined;
|
|
markFetchStarted();
|
|
return new Promise(() => {});
|
|
}) as typeof fetch,
|
|
}).request("/repos/openclaw/openclaw");
|
|
const rejection = expect(request).rejects.toThrow(
|
|
/GitHub API GET \/repos\/openclaw\/openclaw exceeded timeout 5ms/u,
|
|
);
|
|
|
|
await fetchStarted;
|
|
await vi.advanceTimersByTimeAsync(5);
|
|
|
|
await rejection;
|
|
expect(signal?.aborted).toBe(true);
|
|
});
|
|
|
|
it("keeps the GitHub API timeout active while reading response bodies", async () => {
|
|
let signal: AbortSignal | undefined;
|
|
let markFetchStarted!: () => void;
|
|
const fetchStarted = new Promise<void>((resolve) => {
|
|
markFetchStarted = resolve;
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
const request = githubApi("token", {
|
|
timeoutMs: 5,
|
|
fetchImpl: ((_url, init) => {
|
|
signal = init?.signal ?? undefined;
|
|
markFetchStarted();
|
|
return Promise.resolve(
|
|
new Response(
|
|
new ReadableStream({
|
|
start() {},
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
),
|
|
);
|
|
}) as typeof fetch,
|
|
}).request("/repos/openclaw/openclaw");
|
|
const rejection = expect(request).rejects.toThrow(
|
|
/GitHub API GET \/repos\/openclaw\/openclaw exceeded timeout 5ms/u,
|
|
);
|
|
|
|
await fetchStarted;
|
|
await vi.advanceTimersByTimeAsync(5);
|
|
|
|
await rejection;
|
|
expect(signal?.aborted).toBe(true);
|
|
});
|
|
});
|