mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 22:15:22 +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
1577 lines
54 KiB
TypeScript
1577 lines
54 KiB
TypeScript
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { beforeAll, expect, it, vi } from "vitest";
|
|
import { runCiGitStep, type FetchResult } from "./ci-git-owner.test-support.js";
|
|
|
|
beforeAll(() => {
|
|
vi.setConfig({ maxConcurrency: 2 });
|
|
return () => vi.resetConfig();
|
|
});
|
|
|
|
const candidate = "a".repeat(40);
|
|
const harness = "b".repeat(40);
|
|
const base = "c".repeat(40);
|
|
const moved = "d".repeat(40);
|
|
const merge = "e".repeat(40);
|
|
const linuxIt = it.skipIf(process.platform !== "linux").concurrent;
|
|
// Raw owner lifecycle checks use the shared POSIX census on Linux and macOS.
|
|
const posixIt = it.skipIf(process.platform === "win32").concurrent;
|
|
|
|
const resetProfiles = [
|
|
{
|
|
job: "android",
|
|
step: "Checkout",
|
|
target: `+${candidate}:refs/remotes/origin/ci-target`,
|
|
remote: "fixture/checkout",
|
|
},
|
|
{
|
|
job: "check-docs",
|
|
step: "Checkout ClawHub docs source",
|
|
target: "+refs/heads/main:refs/remotes/origin/checkout",
|
|
remote: "openclaw/clawhub",
|
|
},
|
|
];
|
|
const resetCases: { label: string; fetchResults: FetchResult[]; code: number; attempts: number }[] =
|
|
[
|
|
{ label: "leader exit", fetchResults: [0], code: 0, attempts: 1 },
|
|
{ label: "timeout recovery", fetchResults: ["hang", 0], code: 0, attempts: 2 },
|
|
{ label: "timeouts exhausted", fetchResults: Array(5).fill("hang"), code: 1, attempts: 5 },
|
|
{ label: "unverified cleanup", fetchResults: ["cleanup-failure"], code: 125, attempts: 1 },
|
|
];
|
|
linuxIt.each(resetProfiles.flatMap((profile) => resetCases.map((entry) => ({ profile, entry }))))(
|
|
"$profile.job drains descendants before reset/reuse ($entry.label)",
|
|
async ({ profile: { job, step, target, remote }, entry: { fetchResults, code, attempts } }) => {
|
|
const report = await runCiGitStep({ job, step, fetchResults });
|
|
expect(report.code).toBe(code);
|
|
expect(report.readyAttempts).toHaveLength(attempts);
|
|
expect(report.fetches).toHaveLength(attempts);
|
|
expect(report.boundaries.filter(({ name }) => name === "delete")).toHaveLength(attempts);
|
|
expect(report.checkouts).toHaveLength(code === 0 ? 1 : 0);
|
|
for (const fetch of report.fetches) {
|
|
expect(fetch.args).toEqual(
|
|
expect.arrayContaining([target, "--depth=1", "--no-tags", "--no-recurse-submodules"]),
|
|
);
|
|
expect(fetch.cwd).toBe(
|
|
job === "android" ? report.workspace : path.join(report.workspace, "clawhub-source"),
|
|
);
|
|
}
|
|
expect(
|
|
report.commands
|
|
.filter(({ args }) => args[0] === "remote")
|
|
.every(({ args }) => args.at(-1) === `https://github.com/${remote}.git`),
|
|
).toBe(true);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
linuxIt.each([
|
|
{ label: "timeout recovery", fetchResults: ["hang", 0], code: 0, attempts: 2 },
|
|
{ label: "timeouts exhausted", fetchResults: ["hang", "hang", "hang"], code: 124, attempts: 3 },
|
|
{ label: "ordinary Git failure", fetchResults: [23], code: 23, attempts: 1 },
|
|
] satisfies { label: string; fetchResults: FetchResult[]; code: number; attempts: number }[])(
|
|
"skills preserves exact-SHA retries without a fallback ($label)",
|
|
async ({ fetchResults, code, attempts }) => {
|
|
const report = await runCiGitStep({ job: "skills-python", fetchResults });
|
|
expect(report.code).toBe(code);
|
|
expect(report.fetches).toHaveLength(attempts);
|
|
expect(
|
|
report.fetches.every(
|
|
({ args }) =>
|
|
args.includes(`+${candidate}:refs/remotes/origin/checkout`) && args.includes("--depth=1"),
|
|
),
|
|
).toBe(true);
|
|
expect(report.checkouts).toHaveLength(code === 0 ? 1 : 0);
|
|
expect(report.boundaries.some(({ name }) => name === "delete")).toBe(false);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
linuxIt.each([
|
|
{ phase: "fetch", fetchResults: [23, 0], checkoutResults: [], firstCheckout: false },
|
|
{ phase: "checkout", fetchResults: [0, 0], checkoutResults: [23, 0], firstCheckout: true },
|
|
])(
|
|
"Android resets only after safely joined $phase failure",
|
|
async ({ fetchResults, checkoutResults, firstCheckout }) => {
|
|
const report = await runCiGitStep({ job: "android", fetchResults, checkoutResults });
|
|
expect(report.code).toBe(0);
|
|
expect(report.readyAttempts).toEqual([1, 2]);
|
|
expect(report.fetches.map(({ args }) => args.at(-1))).toEqual([
|
|
`+${candidate}:refs/remotes/origin/ci-target`,
|
|
`+${candidate}:refs/remotes/origin/ci-target`,
|
|
]);
|
|
expect(
|
|
report.boundaries
|
|
.filter(({ name }) => name === "delete" || name === "checkout" || name.startsWith("fetch:"))
|
|
.map(({ name }) => name),
|
|
).toEqual([
|
|
"delete",
|
|
"fetch:1",
|
|
...(firstCheckout ? ["checkout"] : []),
|
|
"delete",
|
|
"fetch:2",
|
|
"checkout",
|
|
]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
const manualProfiles = [
|
|
{ job: "preflight", step: "Checkout", depth: 1 },
|
|
{ job: "security-fast", step: "Checkout manual target", depth: 2 },
|
|
];
|
|
linuxIt.each(
|
|
manualProfiles.flatMap((profile) => [
|
|
{ ...profile, label: "missing branch", fetchResults: [128, 0] as FetchResult[], code: 0 },
|
|
{
|
|
...profile,
|
|
label: "timeout is not missing",
|
|
fetchResults: ["hang", "hang", "hang"] as FetchResult[],
|
|
code: 124,
|
|
},
|
|
{
|
|
...profile,
|
|
label: "cleanup is not missing",
|
|
fetchResults: ["cleanup-failure"] as FetchResult[],
|
|
code: 125,
|
|
},
|
|
]),
|
|
)(
|
|
"$job only falls back after a safely joined unavailable target ($label)",
|
|
async ({ job, step, depth, fetchResults, code }) => {
|
|
const report = await runCiGitStep({
|
|
job,
|
|
step,
|
|
fetchResults,
|
|
env: { GITHUB_EVENT_NAME: "workflow_dispatch", CHECKOUT_REF: "refs/heads/missing" },
|
|
});
|
|
expect(report.code).toBe(code);
|
|
const targetFetches = report.fetches.filter(({ args }) =>
|
|
args.some((arg) => arg.endsWith(":refs/remotes/origin/checkout")),
|
|
);
|
|
expect(targetFetches.map(({ args }) => args.at(-1))).toEqual(
|
|
code === 0
|
|
? [
|
|
"+refs/heads/missing:refs/remotes/origin/checkout",
|
|
`+${candidate}:refs/remotes/origin/checkout`,
|
|
]
|
|
: fetchResults.map(() => "+refs/heads/missing:refs/remotes/origin/checkout"),
|
|
);
|
|
expect(targetFetches.every(({ args }) => args.includes(`--depth=${depth}`))).toBe(true);
|
|
expect(report.fetches).toHaveLength(
|
|
targetFetches.length + (job === "preflight" && code === 0 ? 1 : 0),
|
|
);
|
|
expect(report.checkouts).toHaveLength(code === 0 ? 1 : 0);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
linuxIt(
|
|
"preflight pins a moved exact SHA and retries only its parent metadata",
|
|
async () => {
|
|
const report = await runCiGitStep({
|
|
job: "preflight",
|
|
fetchResults: [0, 0, 23, 0],
|
|
env: { GITHUB_EVENT_NAME: "workflow_dispatch" },
|
|
poisonPython: true,
|
|
});
|
|
expect(report.code).toBe(0);
|
|
expect(report.fetches.map(({ args }) => args.at(-1))).toEqual([
|
|
"+refs/heads/main:refs/remotes/origin/checkout",
|
|
`+${candidate}:refs/remotes/origin/checkout`,
|
|
candidate,
|
|
candidate,
|
|
]);
|
|
for (const fetch of report.fetches.slice(2)) {
|
|
expect(fetch.args).toEqual(expect.arrayContaining(["--depth=2", "--filter=blob:none"]));
|
|
}
|
|
expect(report.checkouts.map(({ args }) => args)).toEqual([
|
|
["checkout", "--detach", "refs/remotes/origin/checkout"],
|
|
]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
linuxIt(
|
|
"manual security never refetches an unavailable equal fallback",
|
|
async () => {
|
|
const report = await runCiGitStep({
|
|
job: "security-fast",
|
|
step: "Checkout manual target",
|
|
env: { GITHUB_EVENT_NAME: "workflow_dispatch" },
|
|
fetchResults: [128],
|
|
});
|
|
expect(report.code).toBe(128);
|
|
expect(report.fetches.map(({ args }) => args.at(-1))).toEqual([
|
|
`+${candidate}:refs/remotes/origin/checkout`,
|
|
]);
|
|
expect(report.checkouts).toEqual([]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
linuxIt(
|
|
"preflight rejects a fallback that cannot satisfy the requested exact SHA",
|
|
async () => {
|
|
const report = await runCiGitStep({
|
|
job: "preflight",
|
|
env: { GITHUB_EVENT_NAME: "workflow_dispatch", CHECKOUT_REF: moved },
|
|
fetchResults: [128, 0],
|
|
});
|
|
expect(report.code).toBe(1);
|
|
expect(report.fetches.map(({ args }) => args.at(-1))).toEqual([
|
|
`+${moved}:refs/remotes/origin/checkout`,
|
|
`+${candidate}:refs/remotes/origin/checkout`,
|
|
]);
|
|
expect(report.checkouts).toEqual([]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
const preflightCases: {
|
|
label: string;
|
|
env: Record<string, string>;
|
|
fetchResults: FetchResult[];
|
|
code: number;
|
|
}[] = [
|
|
{
|
|
label: "push never substitutes another ref",
|
|
env: { GITHUB_EVENT_NAME: "push", CHECKOUT_REF: "refs/heads/missing" },
|
|
fetchResults: [128],
|
|
code: 128,
|
|
},
|
|
{
|
|
label: "unavailable fallback does not recurse",
|
|
env: { GITHUB_EVENT_NAME: "workflow_dispatch" },
|
|
fetchResults: [128],
|
|
code: 128,
|
|
},
|
|
{
|
|
label: "parent metadata failure prevents checkout",
|
|
env: {},
|
|
fetchResults: [0, 23, 23, 23],
|
|
code: 1,
|
|
},
|
|
];
|
|
linuxIt.each(preflightCases)(
|
|
"preflight fails closed: $label",
|
|
async ({ env, fetchResults, code }) => {
|
|
const report = await runCiGitStep({ job: "preflight", env, fetchResults });
|
|
expect(report.code).toBe(code);
|
|
expect(report.fetches).toHaveLength(fetchResults.length);
|
|
expect(report.checkouts).toEqual([]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
const historyProfiles: {
|
|
job: string;
|
|
step: string;
|
|
env: Record<string, string>;
|
|
target: string;
|
|
}[] = [
|
|
{
|
|
job: "preflight",
|
|
step: "Resolve exact diff base",
|
|
env: { GITHUB_EVENT_NAME: "workflow_dispatch", RELEASE_GATE: "true" },
|
|
target: "+refs/pull/17/merge:refs/remotes/origin/release-gate-merge",
|
|
},
|
|
{
|
|
job: "checks-fast-core",
|
|
step: "Prepare release-gate ratchet merge tree",
|
|
env: {},
|
|
target: "+refs/pull/17/merge:refs/remotes/origin/ci-ratchet-merge",
|
|
},
|
|
];
|
|
|
|
linuxIt.each(
|
|
historyProfiles.flatMap((profile) => [
|
|
{ ...profile, label: "successful leader exit", fetchResults: [0] as FetchResult[], code: 0 },
|
|
{
|
|
...profile,
|
|
label: "unverified cleanup",
|
|
fetchResults: ["cleanup-failure"] as FetchResult[],
|
|
code: 125,
|
|
},
|
|
]),
|
|
)(
|
|
"$job/$step joins supplemental history before consumption ($label, $target)",
|
|
async ({ job, step, env, target, fetchResults, code }) => {
|
|
const report = await runCiGitStep({
|
|
job,
|
|
step,
|
|
env,
|
|
fetchResults,
|
|
prepare: true,
|
|
poisonPython: true,
|
|
});
|
|
expect(report.code).toBe(code);
|
|
expect(report.fetches).toHaveLength(1);
|
|
expect(report.fetches[0]?.args).toEqual(expect.arrayContaining([target, "--depth=2"]));
|
|
if (step === "Resolve exact diff base") {
|
|
expect(report.githubOutput).toBe(code === 0 ? `sha=${base}\nhead_sha=${merge}\n` : "");
|
|
}
|
|
if (step === "Prepare release-gate ratchet merge tree") {
|
|
expect(report.githubEnv).toBe(code === 0 ? `RATCHET_BASE_REF=${base}\n` : "");
|
|
expect(report.checkouts.map(({ args }) => args.at(-1))).toEqual(code === 0 ? [merge] : []);
|
|
}
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
linuxIt(
|
|
"ratchet retries a stale merge parent before checkout and base publication",
|
|
async () => {
|
|
const report = await runCiGitStep({
|
|
job: "checks-fast-core",
|
|
step: "Prepare release-gate ratchet merge tree",
|
|
fetchResults: [0, 0],
|
|
mergeSnapshots: [
|
|
{ sha: "f".repeat(40), head: moved },
|
|
{ sha: merge, head: candidate },
|
|
],
|
|
prepare: true,
|
|
});
|
|
expect(report.code).toBe(0);
|
|
expect(report.fetches.map(({ args }) => args.at(-1))).toEqual([
|
|
"+refs/pull/17/merge:refs/remotes/origin/ci-ratchet-merge",
|
|
"+refs/pull/17/merge:refs/remotes/origin/ci-ratchet-merge",
|
|
]);
|
|
expect(
|
|
report.boundaries
|
|
.filter(
|
|
({ name }) => name.startsWith("fetch:") || name === "show-parents" || name === "checkout",
|
|
)
|
|
.map(({ name }) => name),
|
|
).toEqual(["fetch:1", "show-parents", "fetch:2", "show-parents", "checkout"]);
|
|
expect(report.checkouts.map(({ args }) => args.at(-1))).toEqual([merge]);
|
|
expect(report.githubEnv).toBe(`RATCHET_BASE_REF=${base}\n`);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt(
|
|
"fetches the CI harness without a second full-repository snapshot",
|
|
async () => {
|
|
const report = await runCiGitStep({ job: "checks-fast-core", fetchResults: [0, 0] });
|
|
expect(report.code).toBe(0);
|
|
const harnessDirectory = path.join(report.workspace, ".ci-harness");
|
|
const harnessCommands = report.commands.filter(
|
|
({ tool, cwd }) => tool === "git" && cwd === harnessDirectory,
|
|
);
|
|
// The harness supplies only .github/actions: narrowing must be in place before the
|
|
// fetch runs, so it never downloads the blobs the sparse checkout discards.
|
|
expect(harnessCommands.map(({ args }) => args[0])).toEqual([
|
|
"init",
|
|
"remote",
|
|
"sparse-checkout",
|
|
"fetch",
|
|
"checkout",
|
|
]);
|
|
const harnessFetch = expectDefined(
|
|
harnessCommands.find(({ args }) => args[0] === "fetch"),
|
|
"harness fetch",
|
|
);
|
|
expect(harnessFetch.args).toEqual(expect.arrayContaining(["--filter=blob:none"]));
|
|
expect(harnessFetch.args.at(-1)).toBe(`+${harness}:refs/remotes/origin/ci-harness`);
|
|
const sparseCheckout = expectDefined(
|
|
harnessCommands.find(({ args }) => args[0] === "sparse-checkout"),
|
|
"harness sparse checkout",
|
|
);
|
|
for (const file of [
|
|
"scripts/ci-npm-lock-admission.mjs",
|
|
"scripts/generate-npm-package-lock.mjs",
|
|
"scripts/generate-npm-package-lock.mts",
|
|
"scripts/changed-lanes.mts",
|
|
"scripts/lib/merge-head-diff-base.mjs",
|
|
]) {
|
|
expect(sparseCheckout.args).toContain(`/${file}`);
|
|
}
|
|
// The selected checkout still needs real file contents, so it must stay unfiltered.
|
|
const workspaceFetch = expectDefined(
|
|
report.fetches.find(({ cwd }) => cwd === report.workspace),
|
|
"workspace fetch",
|
|
);
|
|
expect(workspaceFetch.args).not.toContain("--filter=blob:none");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
type QaGitCase = {
|
|
label: string;
|
|
job: string;
|
|
step: string;
|
|
env?: Record<string, string>;
|
|
fetches: string[][];
|
|
readbacks: string[];
|
|
checkout?: string;
|
|
selected?: boolean;
|
|
reason?: string;
|
|
};
|
|
const qaMainFetch = ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"];
|
|
const qaBootstrapFetch = ["fetch", "--no-tags", "--no-recurse-submodules", "--depth=1", "origin"];
|
|
const qaGitCases: QaGitCase[] = [
|
|
{
|
|
label: "main validation",
|
|
job: "validate_selected_ref",
|
|
step: "Validate selected ref",
|
|
fetches: [qaMainFetch],
|
|
readbacks: ["HEAD", `${candidate}^1`],
|
|
reason: "main-ancestor",
|
|
},
|
|
{
|
|
label: "release tag validation",
|
|
job: "validate_selected_ref",
|
|
step: "Validate selected ref",
|
|
env: { INPUT_REF: "refs/tags/v2026.8.1" },
|
|
fetches: [
|
|
qaMainFetch,
|
|
["fetch", "--no-tags", "origin", "+refs/tags/v2026.8.1:refs/tags/v2026.8.1"],
|
|
],
|
|
readbacks: ["HEAD", "refs/tags/v2026.8.1^{commit}"],
|
|
reason: "release-tag",
|
|
},
|
|
{
|
|
label: "release branch validation",
|
|
job: "validate_selected_ref",
|
|
step: "Validate selected ref",
|
|
env: { INPUT_REF: "refs/heads/release/2026.8.1" },
|
|
fetches: [
|
|
qaMainFetch,
|
|
[
|
|
"fetch",
|
|
"--no-tags",
|
|
"origin",
|
|
"+refs/heads/release/2026.8.1:refs/remotes/origin/release/2026.8.1",
|
|
],
|
|
],
|
|
readbacks: ["HEAD", "refs/remotes/origin/release/2026.8.1"],
|
|
reason: "release-branch-head",
|
|
},
|
|
{
|
|
label: "trusted harness restore",
|
|
job: "plan_qa_profile",
|
|
step: "Restore trusted QA harness revision",
|
|
fetches: [[...qaBootstrapFetch, harness]],
|
|
readbacks: ["HEAD"],
|
|
checkout: harness,
|
|
},
|
|
{
|
|
label: "selected checkout",
|
|
job: "plan_qa_profile",
|
|
step: "Checkout selected ref",
|
|
fetches: [[...qaBootstrapFetch, candidate]],
|
|
readbacks: ["HEAD"],
|
|
checkout: "FETCH_HEAD",
|
|
selected: true,
|
|
},
|
|
{
|
|
label: "protocol comparison base",
|
|
job: "run_qa_profile_shard",
|
|
step: "Fetch protocol comparison base",
|
|
fetches: [[...qaBootstrapFetch, `+${base}:refs/remotes/origin/qa-protocol-base`]],
|
|
readbacks: ["refs/remotes/origin/qa-protocol-base^{commit}"],
|
|
selected: true,
|
|
},
|
|
];
|
|
|
|
function runQaGitCase(profile: QaGitCase, fetchResults: FetchResult[]) {
|
|
return runCiGitStep({
|
|
workflow: {
|
|
file: ".github/workflows/qa-profile-evidence.yml",
|
|
job: profile.job,
|
|
step: profile.step,
|
|
},
|
|
fetchResults,
|
|
// Keep real command deadlines and ready descendant cleanup; these boundary
|
|
// checks do not need the TERM grace covered by the owner lifecycle tests.
|
|
realClock: true,
|
|
realDrain: false,
|
|
poisonPython: true,
|
|
env: {
|
|
EXPECTED_SHA: candidate,
|
|
EXPECTED_WORKFLOW_SHA: harness,
|
|
INPUT_REF: "main",
|
|
GITHUB_SERVER_URL: "https://github.com",
|
|
...profile.env,
|
|
},
|
|
revisions: {
|
|
[`${candidate}^1`]: base,
|
|
"refs/tags/v2026.8.1": candidate,
|
|
"refs/tags/v2026.8.1^{commit}": candidate,
|
|
"refs/heads/release/2026.8.1": candidate,
|
|
"refs/remotes/origin/qa-protocol-base^{commit}": base,
|
|
},
|
|
mergeBase: { ancestor: true, revision: base },
|
|
baseAvailableAfter: 0,
|
|
});
|
|
}
|
|
|
|
posixIt.each(qaGitCases)(
|
|
"QA Git owner drains descendants before the next boundary: $label",
|
|
async (profile) => {
|
|
const report = await runQaGitCase(
|
|
profile,
|
|
profile.fetches.map(() => 0),
|
|
);
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.readyAttempts).toEqual(profile.fetches.map((_, index) => index + 1));
|
|
expect(report.fetches.map(({ args }) => args)).toEqual(profile.fetches);
|
|
const cwd = profile.selected ? path.join(report.workspace, "selected") : report.workspace;
|
|
expect(
|
|
report.fetches.every((fetch) => fetch.cwd === cwd && fetch.configuration?.length === 0),
|
|
).toBe(true);
|
|
expect(
|
|
report.commands
|
|
.filter(({ args }) => args[0] === "rev-parse")
|
|
.map(({ args, cwd: commandCwd }) => ({ args, cwd: commandCwd })),
|
|
).toEqual(profile.readbacks.map((ref) => ({ args: ["rev-parse", ref], cwd })));
|
|
expect(
|
|
report.checkouts.map(({ args, cwd: commandCwd }) => ({ args, cwd: commandCwd })),
|
|
).toEqual(profile.checkout ? [{ args: ["checkout", "--detach", profile.checkout], cwd }] : []);
|
|
if (profile.step === "Checkout selected ref") {
|
|
expect(report.commands.map(({ args }) => args[0])).toEqual([
|
|
"init",
|
|
"remote",
|
|
"fetch",
|
|
"checkout",
|
|
"rev-parse",
|
|
]);
|
|
expect(report.commands.slice(0, 2)).toMatchObject([
|
|
{ cwd: report.workspace, args: ["init", "selected"] },
|
|
{ cwd, args: ["remote", "add", "origin", "https://github.com/fixture/checkout"] },
|
|
]);
|
|
}
|
|
expect(report.githubOutput).toBe(
|
|
profile.reason
|
|
? `protocol_base_revision=${base}\nselected_revision=${candidate}\ntrusted_reason=${profile.reason}\n`
|
|
: "",
|
|
);
|
|
if (profile.reason) {
|
|
expect(report.githubSummary).toContain(`Trust reason: \`${profile.reason}\``);
|
|
expect(report.githubSummary).toContain(`Protocol base: \`${base}\``);
|
|
}
|
|
expect(report.githubEnv).toBe("");
|
|
expect(report.githubPath).toBe("");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each(qaGitCases.filter(({ label, reason }) => !reason || label === "main validation"))(
|
|
"QA Git owner stops without downstream work after cleanup failure: $label",
|
|
async (profile) => {
|
|
const report = await runQaGitCase(profile, ["cleanup-failure"]);
|
|
expect(report.code, report.output).toBe(125);
|
|
expect(report.readyAttempts).toEqual([1]);
|
|
expect(report.fetches.map(({ args }) => args)).toEqual([profile.fetches[0]]);
|
|
expect(report.commands.at(-1)?.args[0]).toBe("fetch");
|
|
expect(report.checkouts).toEqual([]);
|
|
expect(
|
|
report.commands.filter(({ args }) => args[0] === "rev-parse").map(({ args }) => args),
|
|
).toEqual(profile.reason ? [["rev-parse", "HEAD"]] : []);
|
|
expect(report.githubOutput).toBe("");
|
|
expect(report.githubEnv).toBe("");
|
|
expect(report.githubSummary).toBe("");
|
|
expect(report.githubPath).toBe("");
|
|
expect(report.output).toContain("Git ownership/setup failed");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
const mantisReleaseRef = "release/2026.8.1";
|
|
const mantisReleaseFetch = [
|
|
"fetch",
|
|
"--no-tags",
|
|
"origin",
|
|
`+refs/heads/${mantisReleaseRef}:refs/remotes/origin/${mantisReleaseRef}`,
|
|
];
|
|
const mantisCases = [
|
|
{ label: "candidate main ancestor", shared: true },
|
|
{ label: "baseline before candidate", shared: true, baseline: true },
|
|
{ label: "Discord main ancestor", shared: false },
|
|
{ label: "Discord exact release branch", shared: false, release: true },
|
|
{
|
|
label: "Discord release mismatch never consults PRs",
|
|
shared: false,
|
|
release: true,
|
|
mismatch: true,
|
|
},
|
|
] satisfies {
|
|
label: string;
|
|
shared: boolean;
|
|
baseline?: boolean;
|
|
release?: boolean;
|
|
mismatch?: boolean;
|
|
}[];
|
|
|
|
posixIt.each([
|
|
...mantisCases.map((entry) => Object.assign({}, entry, { failure: 0 as FetchResult })),
|
|
...[true, false].flatMap((shared) =>
|
|
(["cleanup-failure", 23] satisfies FetchResult[]).map((failure) => ({
|
|
label: `${shared ? "shared action" : "Discord"} terminal ${failure}`,
|
|
shared,
|
|
failure,
|
|
})),
|
|
),
|
|
])(
|
|
"Mantis ref Git owner drains before trust probes and publication: $label",
|
|
async (profile) => {
|
|
const { shared, failure } = profile;
|
|
const baseline = "baseline" in profile && profile.baseline;
|
|
const release = "release" in profile && profile.release;
|
|
const mismatch = "mismatch" in profile && profile.mismatch;
|
|
const fetches = release ? [qaMainFetch, mantisReleaseFetch] : [qaMainFetch];
|
|
const report = await runCiGitStep({
|
|
...(shared
|
|
? ({ action: "mantis-validate-trusted-ref", step: "Validate refs are trusted" } as const)
|
|
: {
|
|
workflow: {
|
|
file: ".github/workflows/mantis-discord-smoke.yml",
|
|
job: "validate_selected_ref",
|
|
step: "Validate selected ref",
|
|
},
|
|
}),
|
|
fetchResults: failure ? [failure] : fetches.map(() => 0),
|
|
realClock: true,
|
|
realDrain: false,
|
|
poisonPython: true,
|
|
env: {
|
|
BASELINE_REF: baseline ? "baseline" : "",
|
|
CANDIDATE_REF: "candidate",
|
|
INPUT_REF: release ? mantisReleaseRef : "main",
|
|
},
|
|
revisions: {
|
|
"baseline^{commit}": base,
|
|
"candidate^{commit}": candidate,
|
|
[`refs/heads/${mantisReleaseRef}`]: mismatch ? moved : candidate,
|
|
},
|
|
mergeBase: { ancestor: !release, revision: base },
|
|
});
|
|
const code = failure === "cleanup-failure" ? 125 : failure || (mismatch ? 1 : 0);
|
|
expect(report.code, report.output).toBe(code);
|
|
expect(report.readyAttempts).toEqual(fetches.map((_, index) => index + 1));
|
|
expect(report.fetches.map(({ args }) => args)).toEqual(fetches);
|
|
expect(
|
|
report.fetches.every(
|
|
({ cwd, configuration }) => cwd === report.workspace && configuration?.length === 0,
|
|
),
|
|
).toBe(true);
|
|
expect(report.commands.filter(({ tool }) => tool === "gh")).toEqual([]);
|
|
const probes = failure
|
|
? []
|
|
: [
|
|
...(baseline
|
|
? [
|
|
["rev-parse", "baseline^{commit}"],
|
|
["merge-base", "--is-ancestor", base, "refs/remotes/origin/main"],
|
|
]
|
|
: []),
|
|
...(shared ? [["rev-parse", "candidate^{commit}"]] : []),
|
|
["merge-base", "--is-ancestor", candidate, "refs/remotes/origin/main"],
|
|
...(release
|
|
? [
|
|
["tag", "--points-at", candidate],
|
|
mantisReleaseFetch,
|
|
["rev-parse", `refs/remotes/origin/${mantisReleaseRef}`],
|
|
]
|
|
: []),
|
|
];
|
|
expect(report.commands.map(({ args }) => args)).toEqual([
|
|
...(!shared ? [["rev-parse", "HEAD"]] : []),
|
|
qaMainFetch,
|
|
...probes,
|
|
]);
|
|
const reason = release ? "release-branch-head" : "main-ancestor";
|
|
expect(report.githubOutput).toBe(
|
|
code !== 0
|
|
? ""
|
|
: shared
|
|
? `${baseline ? `baseline_revision=${base}\n` : ""}candidate_revision=${candidate}\n`
|
|
: `selected_revision=${candidate}\ntrusted_reason=${reason}\n`,
|
|
);
|
|
expect(report.githubSummary).toBe(
|
|
code !== 0
|
|
? ""
|
|
: shared
|
|
? `${baseline ? `baseline: \`baseline\`\nbaseline SHA: \`${base}\`\nbaseline trust reason: \`main-ancestor\`\n` : ""}candidate: \`candidate\`\ncandidate SHA: \`${candidate}\`\ncandidate trust reason: \`main-ancestor\`\n`
|
|
: `Validated ref: \`${release ? mantisReleaseRef : "main"}\`\nResolved SHA: \`${candidate}\`\nTrust reason: \`${reason}\`\n`,
|
|
);
|
|
expect(report.githubEnv).toBe("");
|
|
expect(report.githubPath).toBe("");
|
|
if (failure === "cleanup-failure") {
|
|
expect(report.output).toContain("Git ownership/setup failed");
|
|
}
|
|
if (mismatch) {
|
|
expect(report.output).toContain("not trusted for this secret-bearing Mantis run");
|
|
}
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
const mantisWorktrees = [
|
|
{
|
|
workflow: "discord-status-reactions",
|
|
job: "run_status_reactions",
|
|
lanes: ["baseline", "candidate"],
|
|
offline: false,
|
|
build: true,
|
|
},
|
|
{
|
|
workflow: "slack-desktop-smoke",
|
|
job: "run_slack_desktop",
|
|
lanes: ["candidate"],
|
|
offline: true,
|
|
build: true,
|
|
},
|
|
{
|
|
workflow: "web-ui-chat-proof",
|
|
job: "run_web_ui_chat",
|
|
lanes: ["candidate"],
|
|
offline: true,
|
|
build: false,
|
|
},
|
|
];
|
|
|
|
posixIt.each([
|
|
...mantisWorktrees.map((profile) => ({ ...profile, failure: false })),
|
|
{ ...mantisWorktrees[0]!, failure: true },
|
|
])(
|
|
"Mantis worktree Git owner drains before next worktree/install/build: $workflow (cleanup failure=$failure)",
|
|
async ({ workflow, job, lanes, offline, build, failure }) => {
|
|
const report = await runCiGitStep({
|
|
workflow: {
|
|
file: `.github/workflows/mantis-${workflow}.yml`,
|
|
job,
|
|
step:
|
|
lanes.length === 2
|
|
? "Prepare baseline and candidate worktrees"
|
|
: "Prepare candidate worktree",
|
|
},
|
|
fetchResults: [],
|
|
worktreeResults: failure ? ["cleanup-failure"] : lanes.map(() => 0),
|
|
// The runner hands off the earlier allocation's output; keep setup logs
|
|
// inside this fixture's fresh invocation directory.
|
|
stepOutputs:
|
|
workflow === "web-ui-chat-proof"
|
|
? { prepare_evidence: { output_dir: "${RUNNER_TEMP}" } }
|
|
: undefined,
|
|
realClock: true,
|
|
realDrain: false,
|
|
poisonPython: true,
|
|
env: { BASELINE_SHA: base, CANDIDATE_SHA: candidate },
|
|
});
|
|
expect(report.code, report.output).toBe(failure ? 125 : 0);
|
|
const attempted = failure ? lanes.slice(0, 1) : lanes;
|
|
expect(report.readyAttempts).toEqual(attempted.map((_, index) => index + 1));
|
|
const lanePath = (lane: string) => `.artifacts/qa-e2e/mantis/${workflow}-worktrees/${lane}`;
|
|
expect(report.worktrees).toEqual(
|
|
attempted.map((lane) => ({
|
|
tool: "git",
|
|
cwd: report.workspace,
|
|
configuration: [],
|
|
args: [
|
|
"worktree",
|
|
"add",
|
|
"--detach",
|
|
lanePath(lane),
|
|
lane === "baseline" ? base : candidate,
|
|
],
|
|
})),
|
|
);
|
|
expect(report.commands.filter(({ tool }) => tool === "pnpm")).toEqual(
|
|
failure
|
|
? []
|
|
: lanes.flatMap((lane) => [
|
|
{
|
|
tool: "pnpm",
|
|
cwd: report.workspace,
|
|
args: [
|
|
"--dir",
|
|
lanePath(lane),
|
|
"install",
|
|
"--frozen-lockfile",
|
|
...(offline ? ["--prefer-offline"] : []),
|
|
],
|
|
},
|
|
...(build
|
|
? [{ tool: "pnpm", cwd: report.workspace, args: ["--dir", lanePath(lane), "build"] }]
|
|
: []),
|
|
]),
|
|
);
|
|
expect(report.commands).toHaveLength(
|
|
attempted.length + (failure ? 0 : lanes.length * (build ? 2 : 1)),
|
|
);
|
|
expect(report.clones).toEqual([]);
|
|
expect(report.fetches).toEqual([]);
|
|
expect(report.boundaries.map(({ name }) => name)).toEqual([
|
|
...attempted.map((_, index) => `worktree:${index + 1}`),
|
|
...(failure
|
|
? []
|
|
: lanes.flatMap(() => (build ? ["consumer:pnpm", "consumer:pnpm"] : ["consumer:pnpm"]))),
|
|
"exit",
|
|
]);
|
|
expect(report.githubPath).toBe("");
|
|
expect(report.githubOutput).toBe("");
|
|
expect(report.githubEnv).toBe("");
|
|
expect(report.githubSummary).toBe("");
|
|
if (failure) {
|
|
expect(report.output).toContain("Git ownership/setup failed");
|
|
}
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
const newer = "d".repeat(40);
|
|
const sourceObject = "refs/remotes/origin/main:.openclaw-sync/source.json";
|
|
const fetch = ["fetch", "origin", "main:refs/remotes/origin/main"];
|
|
const show = ["show", sourceObject];
|
|
const rebase = ["rebase", "-X", "theirs", "origin/main"];
|
|
const push = ["push", "origin", "HEAD:main"];
|
|
const abort = ["rebase", "--abort"];
|
|
const diff = [
|
|
"diff",
|
|
"--quiet",
|
|
"--",
|
|
"docs",
|
|
".openclaw-sync",
|
|
"package.json",
|
|
"package-lock.json",
|
|
];
|
|
const dependencyReads = [
|
|
["show", "refs/remotes/origin/main:package.json"],
|
|
["show", "refs/remotes/origin/main:package-lock.json"],
|
|
];
|
|
const commit = [
|
|
["config", "user.name", "openclaw-docs-sync[bot]"],
|
|
["config", "user.email", "openclaw-docs-sync[bot]@users.noreply.github.com"],
|
|
["add", "docs", ".openclaw-sync", "package.json", "package-lock.json"],
|
|
["commit", "-m", `chore(sync): mirror docs from fixture/checkout@${candidate}`],
|
|
];
|
|
|
|
function runDocs(step: string, options: Partial<Parameters<typeof runCiGitStep>[0]> = {}) {
|
|
return runCiGitStep({
|
|
workflow: { file: ".github/workflows/docs-sync-publish.yml", job: "sync-publish-repo", step },
|
|
fetchResults: [],
|
|
objects: { [sourceObject]: { text: JSON.stringify({ sha: candidate }) } },
|
|
poisonPython: true,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function gitArgs(report: Awaited<ReturnType<typeof runDocs>>) {
|
|
return report.commands.filter(({ tool }) => tool === "git").map(({ args }) => args);
|
|
}
|
|
|
|
function backoffs(report: Awaited<ReturnType<typeof runDocs>>) {
|
|
return [...report.output.matchAll(/fixture backoff: (\d+)/gu)].map((match) => Number(match[1]));
|
|
}
|
|
|
|
posixIt.each(["directory", "file", "symlink"] as const)(
|
|
"docs clone drains an ordinary failure before deleting/retrying (%s)",
|
|
async (publishPath) => {
|
|
const report = await runDocs("Clone publish repo", { cloneResults: [23, 0], publishPath });
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.readyAttempts).toEqual([1, 2]);
|
|
expect(gitArgs(report)).toEqual(
|
|
[1, 2].map(() => [
|
|
"clone",
|
|
"https://x-access-token:fixture-docs-token@github.com/openclaw/docs.git",
|
|
path.join(report.workspace, "publish"),
|
|
]),
|
|
);
|
|
expect(report.boundaries.map(({ name }) => name)).toEqual([
|
|
"delete",
|
|
"clone:1",
|
|
"delete",
|
|
"clone:2",
|
|
"exit",
|
|
]);
|
|
expect(backoffs(report)).toEqual([2]);
|
|
expect(report.output).toContain("Clone attempt 1 failed; retrying.");
|
|
expect(report.output).not.toContain("fixture-docs-token");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt(
|
|
"docs clone cleanup uncertainty is terminal before another deletion or clone",
|
|
async () => {
|
|
const report = await runDocs("Clone publish repo", { cloneResults: ["cleanup-failure"] });
|
|
expect(report.code, report.output).toBe(125);
|
|
expect(report.clones).toHaveLength(1);
|
|
expect(report.boundaries.map(({ name }) => name)).toEqual(["delete", "clone:1", "exit"]);
|
|
expect(backoffs(report)).toEqual([]);
|
|
expect(report.output).toContain("Git ownership/setup failed");
|
|
expect(report.output).not.toContain("fixture-docs-token");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([125, "hang"] satisfies FetchResult[])(
|
|
"docs advisory fetch drains before config/add/commit and still continues (%s)",
|
|
async (failure) => {
|
|
const report = await runDocs("Commit publish repo sync", { fetchResults: [failure, 0] });
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(gitArgs(report)).toEqual([
|
|
diff,
|
|
fetch,
|
|
...commit,
|
|
fetch,
|
|
show,
|
|
rebase,
|
|
...dependencyReads,
|
|
push,
|
|
]);
|
|
expect(backoffs(report)).toEqual([]);
|
|
expect(
|
|
report.commands.every(
|
|
({ tool, args, cwd }) =>
|
|
cwd ===
|
|
(tool === "node" && args[0] === "--input-type=module"
|
|
? report.workspace
|
|
: path.join(report.workspace, "publish")),
|
|
),
|
|
).toBe(true);
|
|
expect(report.commands.filter(({ tool }) => tool === "node")).toHaveLength(2);
|
|
expect(report.boundaries.map(({ name }) => name)).toEqual([
|
|
"diff",
|
|
"fetch:1",
|
|
"config",
|
|
"config",
|
|
"add",
|
|
"commit",
|
|
"fetch:2",
|
|
`show:${sourceObject}`,
|
|
"rebase:1",
|
|
...dependencyReads.map((args) => `show:${args[1]}`),
|
|
"consumer:node",
|
|
"consumer:npm",
|
|
"consumer:node",
|
|
"push:1",
|
|
"exit",
|
|
]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([
|
|
{ operation: "push", failure: 23, lockChange: true },
|
|
{ operation: "rebase", failure: 125, lockChange: false },
|
|
{ operation: "push", failure: 143, lockChange: false },
|
|
])(
|
|
"docs publication drains failed $operation ($failure) before abort/next fetch and then succeeds",
|
|
async ({ operation, failure, lockChange }) => {
|
|
const report = await runDocs("Commit publish repo sync", {
|
|
env: lockChange ? { FIXTURE_DOCS_LOCK_AFTER_REBASE: "1" } : {},
|
|
rebaseResults: operation === "rebase" ? [failure, 0] : [],
|
|
pushResults: operation === "push" ? [failure, 0] : [],
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(gitArgs(report)).toEqual([
|
|
diff,
|
|
fetch,
|
|
show,
|
|
...commit,
|
|
fetch,
|
|
show,
|
|
rebase,
|
|
...(operation === "push" ? [...dependencyReads, push] : []),
|
|
abort,
|
|
fetch,
|
|
show,
|
|
rebase,
|
|
...dependencyReads,
|
|
push,
|
|
]);
|
|
expect(backoffs(report)).toEqual([2]);
|
|
expect(report.output).toContain("Publish sync attempt 1 failed; retrying.");
|
|
expect(report.pushes).toHaveLength(operation === "push" ? 2 : 1);
|
|
expect(report.commands.filter(({ tool }) => tool === "npm")).toHaveLength(lockChange ? 2 : 1);
|
|
if (operation === "push" && !lockChange) {
|
|
expect(report.output).toContain("Reused 1 unchanged successful page check(s).");
|
|
}
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt(
|
|
"docs publication rejects invalid content introduced by the final rebase",
|
|
async () => {
|
|
const report = await runDocs("Commit publish repo sync", {
|
|
env: { FIXTURE_DOCS_MDX_AFTER_REBASE: "# Rebased page\n\n{unfinished\n" },
|
|
});
|
|
expect(report.code, report.output).toBe(125);
|
|
expect(report.output).toContain("Docs MDX check failed");
|
|
expect(report.pushes).toEqual([]);
|
|
expect(report.rebases.map(({ args }) => args)).toEqual([rebase]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each(["advisory fetch", "fetch", "rebase", "manifest", "lock", "push"] as const)(
|
|
"docs publication cleanup uncertainty at %s prevents abort/retry/next Git",
|
|
async (operation) => {
|
|
const report = await runDocs("Commit publish repo sync", {
|
|
fetchResults:
|
|
operation === "advisory fetch"
|
|
? ["cleanup-failure"]
|
|
: operation === "fetch"
|
|
? [0, "cleanup-failure"]
|
|
: [],
|
|
rebaseResults: operation === "rebase" ? ["cleanup-failure"] : [],
|
|
pushResults: operation === "push" ? ["cleanup-failure"] : [],
|
|
commandResults:
|
|
operation === "manifest" || operation === "lock"
|
|
? {
|
|
[dependencyReads[operation === "manifest" ? 0 : 1]!.join(" ")]: {
|
|
code: "cleanup-failure",
|
|
},
|
|
}
|
|
: {},
|
|
});
|
|
expect(report.code, report.output).toBe(125);
|
|
expect(gitArgs(report)).toEqual([
|
|
diff,
|
|
fetch,
|
|
...(operation === "advisory fetch"
|
|
? []
|
|
: [
|
|
show,
|
|
...commit,
|
|
fetch,
|
|
...(operation === "fetch"
|
|
? []
|
|
: [
|
|
show,
|
|
rebase,
|
|
...dependencyReads.slice(
|
|
0,
|
|
operation === "rebase" ? 0 : operation === "manifest" ? 1 : 2,
|
|
),
|
|
...(operation === "push" ? [push] : []),
|
|
]),
|
|
]),
|
|
]);
|
|
expect(report.rebases.some(({ args }) => args.includes("--abort"))).toBe(false);
|
|
expect(backoffs(report)).toEqual([]);
|
|
expect(report.output).toContain("Git ownership/setup failed");
|
|
expect(report.output).not.toContain("retrying");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each(["Clone publish repo", "Commit publish repo sync"])(
|
|
"docs %s preserves five attempts and every backoff including the terminal one",
|
|
async (step) => {
|
|
const cloning = step === "Clone publish repo";
|
|
const report = await runDocs(step, {
|
|
cloneResults: cloning ? Array<FetchResult>(5).fill(23) : [],
|
|
fetchResults: cloning ? [] : [0, ...Array<FetchResult>(5).fill(23)],
|
|
});
|
|
expect(report.code, report.output).toBe(1);
|
|
expect(backoffs(report)).toEqual([2, 4, 6, 8, 10]);
|
|
expect(report.clones).toHaveLength(cloning ? 5 : 0);
|
|
expect(report.fetches).toHaveLength(cloning ? 0 : 6);
|
|
expect(report.rebases.map(({ args }) => args)).toEqual(
|
|
cloning ? [] : Array.from({ length: 5 }, () => [...abort]),
|
|
);
|
|
expect(report.pushes).toEqual([]);
|
|
expect(
|
|
report.output
|
|
.trim()
|
|
.endsWith(
|
|
cloning
|
|
? "Failed to clone publish repo after retries."
|
|
: "Failed to push publish-repo sync after retries.",
|
|
),
|
|
).toBe(true);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([
|
|
{ label: "no changes", diffResult: 0, stale: false },
|
|
{ label: "stale source", diffResult: 1, stale: true },
|
|
])(
|
|
"docs publication exits successfully without committing for $label",
|
|
async ({ diffResult, stale }) => {
|
|
const report = await runDocs("Commit publish repo sync", {
|
|
diffResult,
|
|
objects: { [sourceObject]: { text: JSON.stringify({ sha: newer }) } },
|
|
mergeBase: { ancestor: true, revision: candidate },
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(gitArgs(report)).toEqual(
|
|
stale ? [diff, fetch, show, ["merge-base", "--is-ancestor", candidate, newer]] : [diff],
|
|
);
|
|
expect(report.output).toContain(
|
|
stale
|
|
? `Skipping stale publish sync for ${candidate}; origin/main already mirrors ${newer}.`
|
|
: "No publish-repo changes.",
|
|
);
|
|
if (stale) {
|
|
expect(report.commands.at(-1)?.cwd).toBe(report.workspace);
|
|
}
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([
|
|
{ label: "missing metadata", text: "", code: 128, ancestor: false },
|
|
{ label: "malformed JSON", text: "{", code: 0, ancestor: false },
|
|
{ label: "non-JSON constant", text: `{"sha":"${newer}","value":NaN}`, code: 0, ancestor: true },
|
|
{ label: "JSON with BOM", text: `\uFEFF{"sha":"${newer}"}`, code: 0, ancestor: true },
|
|
{ label: "empty source", text: '{"sha":""}', code: 0, ancestor: false },
|
|
{ label: "unrelated source", text: JSON.stringify({ sha: newer }), code: 0, ancestor: false },
|
|
])(
|
|
"docs publication retains the changed path for $label",
|
|
async ({ text, code, ancestor, label }) => {
|
|
const report = await runDocs("Commit publish repo sync", {
|
|
objects: { [sourceObject]: { text, code } },
|
|
mergeBase: { ancestor, revision: candidate },
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
const staleCheck =
|
|
label === "unrelated source" ? [["merge-base", "--is-ancestor", candidate, newer]] : [];
|
|
expect(gitArgs(report)).toEqual([
|
|
diff,
|
|
fetch,
|
|
show,
|
|
...staleCheck,
|
|
...commit,
|
|
fetch,
|
|
show,
|
|
...staleCheck,
|
|
rebase,
|
|
...dependencyReads,
|
|
push,
|
|
]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([
|
|
{
|
|
label: "malformed remote manifest",
|
|
text: "{",
|
|
validates: false,
|
|
error: "Git ownership/setup failed (unknown); refusing reuse or retry",
|
|
},
|
|
{
|
|
label: "unrelated remote dependency update lost during rebase",
|
|
text: JSON.stringify({
|
|
name: "docs-fixture",
|
|
private: true,
|
|
devDependencies: { "@sindresorhus/slugify": "2.2.0", "markdown-it": "15.0.1" },
|
|
}),
|
|
validates: true,
|
|
error: "docs sync changed unrelated publisher dependencies",
|
|
},
|
|
])(
|
|
"docs publication rejects $label before push without Git retries",
|
|
async ({ text, validates, error }) => {
|
|
const report = await runDocs("Commit publish repo sync", {
|
|
objects: {
|
|
[sourceObject]: { text: JSON.stringify({ sha: candidate }) },
|
|
[dependencyReads[0]![1]!]: { text },
|
|
},
|
|
});
|
|
expect(report.code, report.output).toBe(125);
|
|
expect(gitArgs(report)).toEqual([
|
|
diff,
|
|
fetch,
|
|
show,
|
|
...commit,
|
|
fetch,
|
|
show,
|
|
rebase,
|
|
...dependencyReads.slice(0, validates ? 2 : 1),
|
|
]);
|
|
expect(report.commands.filter(({ tool }) => tool === "node")).toHaveLength(validates ? 1 : 0);
|
|
expect(report.output).toContain(error);
|
|
expect(report.pushes).toEqual([]);
|
|
expect(report.rebases.map(({ args }) => args)).toEqual([rebase]);
|
|
expect(backoffs(report)).toEqual([]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([0, 23, "cleanup-failure"] satisfies FetchResult[])(
|
|
"docs ClawHub HEAD is owned before Node consumption (%s)",
|
|
async (revParseResult) => {
|
|
const report = await runDocs("Sync docs into publish repo", { revParseResult });
|
|
expect(report.code, report.output).toBe(
|
|
revParseResult === "cleanup-failure" ? 125 : revParseResult,
|
|
);
|
|
expect(gitArgs(report)).toEqual([["rev-parse", "HEAD"]]);
|
|
expect(report.commands[0]?.cwd).toBe(path.join(report.workspace, "clawhub-source"));
|
|
expect(report.commands.filter(({ tool }) => tool === "node").map(({ args }) => args)).toEqual(
|
|
revParseResult === 0
|
|
? [
|
|
[
|
|
"scripts/docs-sync-publish.mjs",
|
|
"--target",
|
|
path.join(report.workspace, "publish"),
|
|
"--source-repo",
|
|
"fixture/checkout",
|
|
"--source-sha",
|
|
candidate,
|
|
"--clawhub-repo",
|
|
path.join(report.workspace, "clawhub-source"),
|
|
"--clawhub-source-repo",
|
|
"openclaw/clawhub",
|
|
"--clawhub-source-sha",
|
|
candidate,
|
|
],
|
|
]
|
|
: [],
|
|
);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each(["Clone publish repo", "Commit publish repo sync"])(
|
|
"docs %s cancellation never reaches retry, abort, or the next Git call",
|
|
async (step) => {
|
|
const report = await runDocs(step, {
|
|
scenario: "cancel-SIGTERM",
|
|
cloneResults: ["hang"],
|
|
fetchResults: ["hang"],
|
|
cooperativeTrees: true,
|
|
realClock: true,
|
|
});
|
|
expect(report.code, report.output).toBe(143);
|
|
expect(report.readyAttempts).toEqual([1]);
|
|
expect(report.commands.filter(({ tool }) => tool === "git")).toHaveLength(
|
|
step === "Clone publish repo" ? 1 : 2,
|
|
);
|
|
expect(report.rebases).toEqual([]);
|
|
expect(report.pushes).toEqual([]);
|
|
expect(report.output).not.toContain("retrying");
|
|
expect(report.boundaries.filter(({ name }) => name === "delete")).toHaveLength(
|
|
step === "Clone publish repo" ? 1 : 0,
|
|
);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
const agentGate = "Gate trusted main activity and hourly cadence";
|
|
const agentCommit = "Commit docs updates";
|
|
const agentFetch = ["fetch", "--no-tags", "origin", "main"];
|
|
const agentPush = [
|
|
"push",
|
|
"https://x-access-token:fixture-docs-agent-token@github.com/fixture/checkout.git",
|
|
"HEAD:main",
|
|
];
|
|
const agentCommitCommands = [
|
|
["diff", "HEAD", "--quiet"],
|
|
["config", "user.name", "openclaw-docs-agent[bot]"],
|
|
["config", "user.email", "openclaw-docs-agent[bot]@users.noreply.github.com"],
|
|
["add", "docs", "README.md", "CHANGELOG"],
|
|
["commit", "--no-verify", "-m", "docs: refresh documentation"],
|
|
];
|
|
const agentOutput = (reviewBase = base) =>
|
|
`run_agent=true\nbase_sha=${candidate}\nreview_base_sha=${reviewBase}\nreview_head_sha=${candidate}\n`;
|
|
|
|
function runDocsAgent(step: string, options: Partial<Parameters<typeof runCiGitStep>[0]> = {}) {
|
|
return runCiGitStep({
|
|
...options,
|
|
workflow: { file: ".github/workflows/docs-agent.yml", job: "update-docs", step },
|
|
fetchResults: options.fetchResults ?? [],
|
|
poisonPython: true,
|
|
env: {
|
|
EVENT_NAME: "workflow_run",
|
|
WORKFLOW_HEAD_SHA: candidate,
|
|
GITHUB_RUN_ID: "123",
|
|
GITHUB_TOKEN: "",
|
|
BASE_SHA: candidate,
|
|
...options.env,
|
|
},
|
|
revisions: { "origin/main": candidate, [`${candidate}^`]: base, ...options.revisions },
|
|
});
|
|
}
|
|
|
|
posixIt.each([0, 128, 125])(
|
|
"Docs Agent manual gate owns HEAD and parent before exact outputs (parent=%s)",
|
|
async (code) => {
|
|
const report = await runDocsAgent(agentGate, {
|
|
env: { EVENT_NAME: "workflow_dispatch" },
|
|
commandResults: { "rev-parse HEAD": { code: 0 }, [`rev-parse ${candidate}^`]: { code } },
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(gitArgs(report)).toEqual([
|
|
["rev-parse", "HEAD"],
|
|
["rev-parse", `${candidate}^`],
|
|
]);
|
|
expect(report.commands.filter(({ tool }) => tool === "gh")).toEqual([]);
|
|
expect(report.githubOutput).toBe(agentOutput(code === 0 ? base : candidate));
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([125, "hang"] satisfies FetchResult[])(
|
|
"Docs Agent gate drains failed fetch before retry, remote read, gh and output (%s)",
|
|
async (failure) => {
|
|
const report = await runDocsAgent(agentGate, { fetchResults: [failure, 0] });
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches.map(({ args }) => args)).toEqual([agentFetch, agentFetch]);
|
|
expect(backoffs(report)).toEqual([2]);
|
|
expect(report.output).toContain("Fetch attempt 1 failed; retrying.");
|
|
expect(report.githubOutput).toBe(agentOutput());
|
|
expect(report.commands.filter(({ tool }) => tool === "gh").map(({ args }) => args)).toEqual([
|
|
[
|
|
"api",
|
|
"--method",
|
|
"GET",
|
|
"repos/fixture/checkout/actions/workflows/docs-agent.yml/runs",
|
|
"-f",
|
|
"branch=main",
|
|
"-f",
|
|
"event=workflow_run",
|
|
"-f",
|
|
"per_page=100",
|
|
],
|
|
]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([false, true])(
|
|
"Docs Agent gate stops before gh/output/retry on fatal cleanup (cancel=%s)",
|
|
async (cancel) => {
|
|
const report = await runDocsAgent(agentGate, {
|
|
fetchResults: cancel ? ["hang"] : ["cleanup-failure"],
|
|
...(cancel ? { scenario: "cancel-SIGTERM", cooperativeTrees: true, realClock: true } : {}),
|
|
});
|
|
expect(report.code, report.output).toBe(cancel ? 143 : 125);
|
|
expect(gitArgs(report)).toEqual([agentFetch]);
|
|
expect(report.commands.filter(({ tool }) => tool === "gh")).toEqual([]);
|
|
expect(report.githubOutput).toBe("");
|
|
expect(backoffs(report)).toEqual([]);
|
|
expect(report.output).not.toContain("retrying");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt(
|
|
"Docs Agent superseded gate drains before false output without gh",
|
|
async () => {
|
|
const report = await runDocsAgent(agentGate, { revisions: { "origin/main": moved } });
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(gitArgs(report)).toEqual([agentFetch, ["rev-parse", "origin/main"]]);
|
|
expect(report.githubOutput).toBe("run_agent=false\n");
|
|
expect(report.commands.filter(({ tool }) => tool === "gh")).toEqual([]);
|
|
expect(report.output).toContain(
|
|
`CI run is superseded by ${moved}; skipping docs agent for ${candidate}.`,
|
|
);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([
|
|
{ probe: 128, parent: 0, code: 0, reviewBase: base },
|
|
{ probe: 128, parent: 128, code: 0, reviewBase: candidate },
|
|
{ probe: 0, parent: 0, code: 0, reviewBase: moved },
|
|
{ probe: "cleanup-failure", parent: 0, code: 125, reviewBase: "" },
|
|
{ probe: 128, parent: "cleanup-failure", code: 125, reviewBase: "" },
|
|
] satisfies { probe: FetchResult; parent: FetchResult; code: number; reviewBase: string }[])(
|
|
"Docs Agent review base only falls back after ordinary Git failure ($probe/$parent)",
|
|
async ({ probe, parent, code, reviewBase }) => {
|
|
const report = await runDocsAgent(agentGate, {
|
|
workflowRuns: [
|
|
{
|
|
id: 122,
|
|
run_attempt: 1,
|
|
created_at: "2026-08-28T20:00:00Z",
|
|
status: "completed",
|
|
conclusion: "success",
|
|
head_sha: moved,
|
|
},
|
|
],
|
|
workflowJobs: [
|
|
{
|
|
runId: 122,
|
|
runAttempt: 1,
|
|
jobs: [
|
|
{
|
|
name: "update-docs",
|
|
status: "completed",
|
|
conclusion: "success",
|
|
steps: [{ name: "Run Codex docs agent", status: "completed", conclusion: "success" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
commandResults: {
|
|
[`cat-file -e ${moved}^{commit}`]: { code: probe },
|
|
[`rev-parse ${candidate}^`]: { code: parent },
|
|
},
|
|
});
|
|
expect(report.code, report.output).toBe(code);
|
|
expect(gitArgs(report)).toEqual([
|
|
agentFetch,
|
|
["rev-parse", "origin/main"],
|
|
["cat-file", "-e", `${moved}^{commit}`],
|
|
...(probe === 128 ? [["rev-parse", `${candidate}^`]] : []),
|
|
]);
|
|
expect(report.githubOutput).toBe(code === 0 ? agentOutput(reviewBase) : "");
|
|
expect(report.commands.filter(({ tool }) => tool === "gh").map(({ args }) => args)).toEqual([
|
|
[
|
|
"api",
|
|
"--method",
|
|
"GET",
|
|
"repos/fixture/checkout/actions/workflows/docs-agent.yml/runs",
|
|
"-f",
|
|
"branch=main",
|
|
"-f",
|
|
"event=workflow_run",
|
|
"-f",
|
|
"per_page=100",
|
|
],
|
|
[
|
|
"api",
|
|
"--paginate",
|
|
"--slurp",
|
|
"repos/fixture/checkout/actions/runs/122/attempts/1/jobs?per_page=100",
|
|
],
|
|
]);
|
|
expect(backoffs(report)).toEqual([]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt(
|
|
"Docs Agent no-change commit owns diff before successful exit",
|
|
async () => {
|
|
const report = await runDocsAgent(agentCommit, {
|
|
commandResults: { "diff HEAD --quiet": { code: 0 } },
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(gitArgs(report)).toEqual([["diff", "HEAD", "--quiet"]]);
|
|
expect(report.output).toBe("No docs changes.\n");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([125, "hang"] satisfies FetchResult[])(
|
|
"Docs Agent commit drains diff before config/commit and failed fetch before retry (%s)",
|
|
async (failure) => {
|
|
const report = await runDocsAgent(agentCommit, {
|
|
commandResults: { "diff HEAD --quiet": { code: failure === 125 ? 125 : 1 } },
|
|
fetchResults: [failure, 0],
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(gitArgs(report)).toEqual([...agentCommitCommands, agentFetch, agentFetch, agentPush]);
|
|
expect(backoffs(report)).toEqual([2]);
|
|
expect(report.output).toContain("Fetch attempt 1 failed; retrying.");
|
|
expect(report.output).not.toContain("fixture-docs-agent-token");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each([false, true])(
|
|
"Docs Agent push failure drains before owned read and retry/stale success (advanced=%s)",
|
|
async (advanced) => {
|
|
const report = await runDocsAgent(agentCommit, {
|
|
pushResults: [143, 0],
|
|
revParseResult: 0,
|
|
revisions: { "origin/main": advanced ? moved : candidate },
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(gitArgs(report)).toEqual([
|
|
...agentCommitCommands,
|
|
agentFetch,
|
|
agentPush,
|
|
["rev-parse", "origin/main"],
|
|
...(advanced ? [] : [agentFetch, agentPush]),
|
|
]);
|
|
expect(backoffs(report)).toEqual(advanced ? [] : [2]);
|
|
expect(report.output).toContain(
|
|
advanced
|
|
? `main advanced from ${candidate} to ${moved}; skipping stale docs update.`
|
|
: "Docs update attempt 1 failed; retrying.",
|
|
);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each(["diff", "config", "commit", "fetch", "push", "read"])(
|
|
"Docs Agent commit cleanup failure at %s is terminal before retry/stale success",
|
|
async (operation) => {
|
|
const index = operation === "diff" ? 0 : operation === "config" ? 1 : 4;
|
|
const report = await runDocsAgent(agentCommit, {
|
|
commandResults: ["diff", "config", "commit"].includes(operation)
|
|
? { [agentCommitCommands[index]!.join(" ")]: { code: "cleanup-failure" } }
|
|
: {},
|
|
fetchResults: operation === "fetch" ? ["cleanup-failure"] : [],
|
|
pushResults: operation === "push" ? ["cleanup-failure"] : operation === "read" ? [23] : [],
|
|
revParseResult: operation === "read" ? "cleanup-failure" : undefined,
|
|
revisions: { "origin/main": moved },
|
|
});
|
|
expect(report.code, report.output).toBe(125);
|
|
expect(gitArgs(report)).toEqual(
|
|
["diff", "config", "commit"].includes(operation)
|
|
? agentCommitCommands.slice(0, index + 1)
|
|
: [
|
|
...agentCommitCommands,
|
|
agentFetch,
|
|
...(operation === "fetch" ? [] : [agentPush]),
|
|
...(operation === "read" ? [["rev-parse", "origin/main"]] : []),
|
|
],
|
|
);
|
|
expect(backoffs(report)).toEqual([]);
|
|
expect(report.output).not.toMatch(/retrying|skipping stale|No docs changes/u);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.each(["gate", "commit fetch", "commit push"])(
|
|
"Docs Agent %s preserves five attempts and terminal backoff contract",
|
|
async (phase) => {
|
|
const gate = phase === "gate";
|
|
const report = await runDocsAgent(gate ? agentGate : agentCommit, {
|
|
fetchResults: phase === "commit push" ? [] : Array<FetchResult>(5).fill(23),
|
|
pushResults: phase === "commit push" ? Array<FetchResult>(5).fill(23) : [],
|
|
});
|
|
expect(report.code, report.output).toBe(1);
|
|
expect(report.fetches).toHaveLength(5);
|
|
expect(report.pushes).toHaveLength(phase === "commit push" ? 5 : 0);
|
|
expect(backoffs(report)).toEqual(gate ? [2, 4, 6, 8] : [2, 4, 6, 8, 10]);
|
|
const diagnostic = phase === "commit push" ? "Docs update" : "Fetch";
|
|
expect(report.output.match(/(?:Fetch|Docs update) attempt \d failed; retrying\./gu)).toEqual(
|
|
(gate ? [1, 2, 3, 4] : [1, 2, 3, 4, 5]).map(
|
|
(attempt) => `${diagnostic} attempt ${attempt} failed; retrying.`,
|
|
),
|
|
);
|
|
expect(
|
|
report.output
|
|
.trim()
|
|
.endsWith(
|
|
gate
|
|
? "Failed to fetch main after retries."
|
|
: "Failed to push docs updates after retries.",
|
|
),
|
|
).toBe(true);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
const agentProducers = [
|
|
["ls-files", "--others", "--exclude-standard"],
|
|
["diff", "HEAD", "--name-status", "--diff-filter=AD"],
|
|
["diff", "--cached", "HEAD", "--name-status", "--diff-filter=AD"],
|
|
["diff", "HEAD", "--name-only"],
|
|
["diff", "--cached", "HEAD", "--name-only"],
|
|
];
|
|
posixIt.each(agentProducers.map((args, index) => ({ args, index })))(
|
|
"Docs Agent enforcement stops on failed producer $args before consuming partial output",
|
|
async ({ args, index }) => {
|
|
const report = await runDocsAgent("Enforce existing-docs-only patch", {
|
|
commandResults: { [args.join(" ")]: { code: 23, output: "src/forbidden.ts\n" } },
|
|
});
|
|
expect(report.code, report.output).toBe(23);
|
|
expect(gitArgs(report)).toEqual(agentProducers.slice(0, index + 1));
|
|
expect(report.output).not.toContain("forbidden");
|
|
},
|
|
55_000,
|
|
);
|