mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 08:01:38 +00:00
914 lines
31 KiB
TypeScript
914 lines
31 KiB
TypeScript
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
renameSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, test } from "vitest";
|
|
import {
|
|
acquireMaintenanceLock,
|
|
classifyActions,
|
|
findExactMacTarget,
|
|
inspectBuildState,
|
|
maintainMain,
|
|
originMatches,
|
|
parseGatewayLogAudit,
|
|
verifyGatewayReadiness,
|
|
} from "../../.agents/skills/openclaw-live-updater/scripts/update-main.mjs";
|
|
import {
|
|
BUILD_STAMP_FILE,
|
|
RUNTIME_POSTBUILD_STAMP_FILE,
|
|
} from "../../scripts/lib/local-build-metadata.mjs";
|
|
import { listCoreRuntimePostBuildOutputs } from "../../scripts/runtime-postbuild.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const script = path.join(repoRoot, ".agents/skills/openclaw-live-updater/scripts/update-main.mjs");
|
|
const fixtureOrigins = new Map<string, string>();
|
|
|
|
function git(cwd: string, ...args: string[]) {
|
|
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
}
|
|
|
|
function fetchFixtureMain(checkout: string, remote: string) {
|
|
const origin = fixtureOrigins.get(checkout);
|
|
if (!origin) {
|
|
throw new Error(`missing fixture origin for ${checkout}`);
|
|
}
|
|
git(checkout, "fetch", origin, `main:refs/remotes/${remote}/main`);
|
|
}
|
|
|
|
function maintainFixture(
|
|
options: Record<string, unknown>,
|
|
dependencies: Record<string, unknown> = {},
|
|
) {
|
|
return maintainMain(options, {
|
|
fetchMain: fetchFixtureMain,
|
|
auditGatewayLogs: () => ({
|
|
entries: 0,
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
errors: [],
|
|
warnings: [],
|
|
}),
|
|
...dependencies,
|
|
});
|
|
}
|
|
|
|
function makeFixture() {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-live-updater-"));
|
|
const origin = path.join(root, "origin.git");
|
|
const seed = path.join(root, "seed");
|
|
const mirror = path.join(root, "mirror");
|
|
mkdirSync(seed);
|
|
git(root, "init", "--bare", origin);
|
|
git(seed, "init", "-b", "main");
|
|
git(seed, "config", "user.name", "Test");
|
|
git(seed, "config", "user.email", "test@example.com");
|
|
writeFileSync(path.join(seed, "README.md"), "one\n");
|
|
writeFileSync(path.join(seed, ".gitignore"), "dist/\nnode_modules/\n");
|
|
git(seed, "add", "README.md", ".gitignore");
|
|
git(seed, "commit", "-m", "initial");
|
|
git(seed, "remote", "add", "origin", origin);
|
|
git(seed, "push", "-u", "origin", "main");
|
|
git(root, "--git-dir", origin, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
git(root, "clone", origin, mirror);
|
|
const canonicalOrigin = "https://github.com/openclaw/openclaw.git";
|
|
git(mirror, "remote", "set-url", "origin", canonicalOrigin);
|
|
fixtureOrigins.set(mirror, origin);
|
|
fixtureOrigins.set(realpathSync(mirror), origin);
|
|
return { root, mirror, origin, seed };
|
|
}
|
|
|
|
function writeBuild(mirror: string) {
|
|
mkdirSync(path.join(mirror, "dist/control-ui"), { recursive: true });
|
|
const head = git(mirror, "rev-parse", "HEAD");
|
|
writeFileSync(path.join(mirror, "dist/build-info.json"), `${JSON.stringify({ commit: head })}\n`);
|
|
writeFileSync(path.join(mirror, "dist/index.js"), "// built\n");
|
|
writeFileSync(path.join(mirror, "dist/entry.js"), "// built\n");
|
|
mkdirSync(path.join(mirror, "dist/control-ui/assets"), { recursive: true });
|
|
writeFileSync(
|
|
path.join(mirror, "dist/control-ui/index.html"),
|
|
'<script type="module" src="./assets/app.js"></script>\n',
|
|
);
|
|
writeFileSync(path.join(mirror, "dist/control-ui/assets/app.js"), "// ui\n");
|
|
writeFileSync(path.join(mirror, "dist", BUILD_STAMP_FILE), `${JSON.stringify({ head })}\n`);
|
|
writeFileSync(
|
|
path.join(mirror, "dist", RUNTIME_POSTBUILD_STAMP_FILE),
|
|
`${JSON.stringify({ head })}\n`,
|
|
);
|
|
for (const relativePath of listCoreRuntimePostBuildOutputs({ rootDir: mirror })) {
|
|
const outputPath = path.join(mirror, relativePath);
|
|
mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
writeFileSync(outputPath, "// runtime postbuild\n");
|
|
}
|
|
}
|
|
|
|
function fakeCommands(mirror: string) {
|
|
const calls: string[] = [];
|
|
return {
|
|
calls,
|
|
runCommand(command: string, args: string[]) {
|
|
calls.push([command, ...args].join(" "));
|
|
if (command === "pnpm" && args[0] === "install") {
|
|
mkdirSync(path.join(mirror, "node_modules"), { recursive: true });
|
|
}
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
writeBuild(mirror);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("openclaw live updater", () => {
|
|
test("audits only error and warning logs emitted after Gateway restart", () => {
|
|
const output = [
|
|
{ type: "meta", file: "/tmp/openclaw.log" },
|
|
{ type: "log", time: "2026-07-11T08:00:00.000Z", level: "error", message: "old" },
|
|
{ type: "log", time: "2026-07-11T08:00:02.000Z", level: "info", message: "ready" },
|
|
{
|
|
type: "log",
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
level: "warn",
|
|
subsystem: "gateway",
|
|
message: "degraded",
|
|
},
|
|
{
|
|
type: "log",
|
|
time: "2026-07-11T08:00:04.000Z",
|
|
level: "fatal",
|
|
subsystem: "gateway",
|
|
message: "failed",
|
|
},
|
|
{ type: "notice", message: "done" },
|
|
]
|
|
.map((entry) => JSON.stringify(entry))
|
|
.join("\n");
|
|
|
|
expect(parseGatewayLogAudit(output, Date.parse("2026-07-11T08:00:02.000Z"))).toEqual({
|
|
entries: 3,
|
|
errorCount: 1,
|
|
warningCount: 1,
|
|
errors: [
|
|
{
|
|
time: "2026-07-11T08:00:04.000Z",
|
|
level: "fatal",
|
|
subsystem: "gateway",
|
|
message: "failed",
|
|
},
|
|
],
|
|
warnings: [
|
|
{
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
level: "warn",
|
|
subsystem: "gateway",
|
|
message: "degraded",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("audits raw file logs when RPC log retrieval is unavailable", () => {
|
|
const output = [
|
|
{
|
|
"0": '{"subsystem":"gateway"}',
|
|
"1": "startup warning",
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
_meta: { date: "2026-07-11T08:00:03.000Z", logLevelName: "WARN" },
|
|
},
|
|
{
|
|
"0": '{"subsystem":"gateway"}',
|
|
"1": "startup failed",
|
|
time: "2026-07-11T08:00:04.000Z",
|
|
_meta: { date: "2026-07-11T08:00:04.000Z", logLevelName: "ERROR" },
|
|
},
|
|
]
|
|
.map((entry) => JSON.stringify(entry))
|
|
.join("\n");
|
|
|
|
expect(parseGatewayLogAudit(output, Date.parse("2026-07-11T08:00:02.000Z"))).toMatchObject({
|
|
entries: 2,
|
|
errorCount: 1,
|
|
warningCount: 1,
|
|
errors: [{ subsystem: "gateway", message: "startup failed" }],
|
|
warnings: [{ subsystem: "gateway", message: "startup warning" }],
|
|
});
|
|
});
|
|
|
|
test("retries bounded Gateway readiness after restart", () => {
|
|
const { mirror } = makeFixture();
|
|
writeBuild(mirror);
|
|
const calls: string[] = [];
|
|
const delays: number[] = [];
|
|
let statusAttempts = 0;
|
|
|
|
verifyGatewayReadiness(
|
|
(command: string, args: string[]) => {
|
|
const call = [command, ...args].join(" ");
|
|
calls.push(call);
|
|
if (call.includes("gateway status") && ++statusAttempts < 3) {
|
|
throw new Error("RPC warming up");
|
|
}
|
|
},
|
|
mirror,
|
|
git(mirror, "rev-parse", "HEAD"),
|
|
(ms: number) => delays.push(ms),
|
|
);
|
|
|
|
expect(delays).toEqual([5_000, 5_000]);
|
|
expect(calls).toEqual([
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("accepts supported OpenClaw GitHub origins", () => {
|
|
expect(originMatches("https://github.com/openclaw/openclaw.git")).toBe(true);
|
|
expect(originMatches("git@github.com:openclaw/openclaw.git")).toBe(true);
|
|
expect(originMatches("https://github.com/example/openclaw.git")).toBe(false);
|
|
});
|
|
|
|
test("production fetch refreshes the remote-tracking main ref", () => {
|
|
const source = readFileSync(script, "utf8");
|
|
expect(source).toContain("refs/heads/main:refs/remotes/${remoteName}/main");
|
|
expect(source).not.toContain('["fetch", "--prune", remoteName, "main"]');
|
|
});
|
|
|
|
test("rejects Git URL rewrites that change the effective fetch source", () => {
|
|
const { mirror, origin } = makeFixture();
|
|
git(mirror, "config", `url.${origin}.insteadOf`, "https://github.com/openclaw/openclaw.git");
|
|
|
|
const result = spawnSync(process.execPath, [script, "--checkout", mirror], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
ok: false,
|
|
error: { code: "rewritten_origin" },
|
|
});
|
|
});
|
|
|
|
test("rejects a symlinked Git directory", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const externalGitDir = path.join(root, "external-git-dir");
|
|
renameSync(path.join(mirror, ".git"), externalGitDir);
|
|
symlinkSync(externalGitDir, path.join(mirror, ".git"), "dir");
|
|
|
|
const result = spawnSync(process.execPath, [script, "--checkout", mirror], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
ok: false,
|
|
error: { code: "not_standalone_clone" },
|
|
});
|
|
});
|
|
|
|
test("classifies exact-head build, install, macOS rebuild, and native UI proof", () => {
|
|
expect(
|
|
classifyActions(["docs/index.md"], {
|
|
buildProvenanceKnown: true,
|
|
buildRequired: true,
|
|
nodeModulesPresent: true,
|
|
}),
|
|
).toEqual({
|
|
dependencyInstall: false,
|
|
gatewayBuild: true,
|
|
gatewayProbe: true,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: false,
|
|
macAppRebuild: false,
|
|
macUiVerification: false,
|
|
});
|
|
expect(
|
|
classifyActions(["package.json", "apps/macos/Sources/OpenClaw/AppDelegate.swift"], {
|
|
buildProvenanceKnown: true,
|
|
buildRequired: true,
|
|
nodeModulesPresent: true,
|
|
}),
|
|
).toEqual({
|
|
dependencyInstall: true,
|
|
gatewayBuild: true,
|
|
gatewayProbe: true,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: false,
|
|
macAppRebuild: true,
|
|
macUiVerification: true,
|
|
});
|
|
});
|
|
|
|
test("accepts only the delayed exact target bundle process", () => {
|
|
const executable = "/Users/steipete/openclaw/dist/OpenClaw.app/Contents/MacOS/OpenClaw";
|
|
const foreign = "41 /tmp/agent/OpenClaw.app/Contents/MacOS/OpenClaw";
|
|
expect(findExactMacTarget(foreign, executable)).toBeNull();
|
|
expect(findExactMacTarget(`${foreign}\n42 ${executable} --attach-only`, executable)).toEqual({
|
|
executable,
|
|
pid: 42,
|
|
});
|
|
});
|
|
|
|
test("rejects missing or mismatched canonical build stamps", () => {
|
|
const { mirror } = makeFixture();
|
|
writeBuild(mirror);
|
|
const head = git(mirror, "rev-parse", "HEAD");
|
|
const buildStamp = path.join(mirror, "dist", BUILD_STAMP_FILE);
|
|
const runtimeStamp = path.join(mirror, "dist", RUNTIME_POSTBUILD_STAMP_FILE);
|
|
|
|
writeFileSync(buildStamp, `${JSON.stringify({ head: "0".repeat(40) })}\n`);
|
|
expect(inspectBuildState(mirror, head)).toMatchObject({
|
|
current: false,
|
|
buildStampHead: "0".repeat(40),
|
|
requirements: { build: { shouldBuild: true, reason: "git_head_changed" } },
|
|
});
|
|
|
|
writeFileSync(buildStamp, `${JSON.stringify({ head })}\n`);
|
|
rmSync(runtimeStamp);
|
|
expect(inspectBuildState(mirror, head)).toMatchObject({
|
|
current: false,
|
|
runtimePostBuildStampHead: null,
|
|
requirements: {
|
|
runtimePostBuild: { shouldSync: true, reason: "missing_runtime_postbuild_stamp" },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("rejects a missing Control UI asset referenced by index", () => {
|
|
const { mirror } = makeFixture();
|
|
writeBuild(mirror);
|
|
const head = git(mirror, "rev-parse", "HEAD");
|
|
rmSync(path.join(mirror, "dist/control-ui/assets/app.js"));
|
|
|
|
expect(inspectBuildState(mirror, head)).toMatchObject({
|
|
current: false,
|
|
missingUiAssets: ["assets/*", "assets/app.js"],
|
|
});
|
|
});
|
|
|
|
test("fast-forwards, builds exact SHA, restarts Gateway, then proves exact Mac target", () => {
|
|
const { root, mirror, seed } = makeFixture();
|
|
mkdirSync(path.join(seed, "apps/macos/Sources/OpenClaw"), { recursive: true });
|
|
writeFileSync(path.join(seed, "apps/macos/Sources/OpenClaw/App.swift"), "// changed\n");
|
|
git(seed, "add", ".");
|
|
git(seed, "commit", "-m", "mac change");
|
|
git(seed, "push");
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{
|
|
checkout: mirror,
|
|
remote: "origin",
|
|
lockPath: path.join(root, "maintenance.lock"),
|
|
statePath: path.join(root, "maintenance-state.json"),
|
|
},
|
|
{
|
|
runCommand: commands.runCommand,
|
|
verifyMacTarget: () => ({
|
|
executable: path.join(mirror, "dist/OpenClaw.app/Contents/MacOS/OpenClaw"),
|
|
pid: 123,
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(output.updated).toBe(true);
|
|
expect(output.afterSha).toBe(git(seed, "rev-parse", "HEAD"));
|
|
expect(output.buildChangedPaths).toEqual(["apps/macos/Sources/OpenClaw/App.swift"]);
|
|
expect(output.actions).toEqual({
|
|
dependencyInstall: true,
|
|
gatewayBuild: true,
|
|
gatewayProbe: true,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: false,
|
|
macAppRebuild: true,
|
|
macUiVerification: true,
|
|
});
|
|
expect(output.gatewayLogAudit).toEqual({
|
|
entries: 0,
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
errors: [],
|
|
warnings: [],
|
|
});
|
|
expect(commands.calls).toEqual([
|
|
"pnpm install --frozen-lockfile",
|
|
`${process.execPath} dist/index.js gateway stop`,
|
|
"pnpm build",
|
|
"pnpm openclaw gateway restart",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
"env SKIP_TSC=1 SKIP_UI_BUILD=1 bash scripts/restart-mac.sh --sign --wait --target-only",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
expect(output.macTarget?.executable).toBe(
|
|
path.join(mirror, "dist/OpenClaw.app/Contents/MacOS/OpenClaw"),
|
|
);
|
|
});
|
|
|
|
test("rejects a local main that is ahead of origin main", () => {
|
|
const { root, mirror } = makeFixture();
|
|
git(mirror, "config", "user.name", "Test");
|
|
git(mirror, "config", "user.email", "test@example.com");
|
|
writeFileSync(path.join(mirror, "local-commit.txt"), "local\n");
|
|
git(mirror, "add", "local-commit.txt");
|
|
git(mirror, "commit", "-m", "local commit");
|
|
|
|
expect(() =>
|
|
maintainFixture({
|
|
checkout: mirror,
|
|
remote: "origin",
|
|
lockPath: path.join(root, "maintenance.lock"),
|
|
}),
|
|
).toThrow(/does not equal origin\/main/u);
|
|
});
|
|
|
|
test("builds and restarts when build output is missing without a new commit", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{ runCommand: commands.runCommand },
|
|
);
|
|
|
|
expect(output.updated).toBe(false);
|
|
expect(output.buildBefore.state).toBe("missing");
|
|
expect(output.actions.gatewayBuild).toBe(true);
|
|
expect(output.actions.dependencyInstall).toBe(true);
|
|
expect(commands.calls).toEqual([
|
|
"pnpm install --frozen-lockfile",
|
|
`${process.execPath} dist/index.js gateway stop`,
|
|
"pnpm build",
|
|
"pnpm openclaw gateway restart",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("preserves the signed Mac bundle while a Gateway build replaces dist", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const appBundle = path.join(mirror, "dist/OpenClaw.app");
|
|
const appMarker = path.join(appBundle, "Contents/signature-marker");
|
|
mkdirSync(path.dirname(appMarker), { recursive: true });
|
|
writeFileSync(appMarker, "signed\n");
|
|
const commands = fakeCommands(mirror);
|
|
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
expect(existsSync(appBundle)).toBe(false);
|
|
}
|
|
commands.runCommand(command, args);
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
|
|
expect(
|
|
readdirSync(path.join(mirror, ".git")).filter((entry) =>
|
|
entry.startsWith(".openclaw-live-mac-"),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("restores the Mac bundle when the Gateway build fails", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const appMarker = path.join(mirror, "dist/OpenClaw.app/Contents/signature-marker");
|
|
mkdirSync(path.dirname(appMarker), { recursive: true });
|
|
writeFileSync(appMarker, "signed\n");
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
throw new Error("build failed");
|
|
}
|
|
},
|
|
},
|
|
),
|
|
).toThrow("build failed");
|
|
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
|
|
});
|
|
|
|
test("accepts a delayed external restore of the exact preserved Mac bundle", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const appBundle = path.join(mirror, "dist/OpenClaw.app");
|
|
const appMarker = path.join(appBundle, "Contents/signature-marker");
|
|
mkdirSync(path.dirname(appMarker), { recursive: true });
|
|
writeFileSync(appMarker, "signed\n");
|
|
const commands = fakeCommands(mirror);
|
|
const delayedBundle = path.join(root, "delayed-openclaw.app");
|
|
let restored = false;
|
|
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
expect(existsSync(appBundle)).toBe(false);
|
|
}
|
|
commands.runCommand(command, args);
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
const preserved = readdirSync(path.join(mirror, ".git")).find((entry) =>
|
|
entry.startsWith(".openclaw-live-mac-"),
|
|
);
|
|
expect(preserved).toBeDefined();
|
|
renameSync(path.join(mirror, ".git", preserved!), delayedBundle);
|
|
}
|
|
},
|
|
sleep() {
|
|
if (restored) {
|
|
return;
|
|
}
|
|
renameSync(delayedBundle, appBundle);
|
|
restored = true;
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(restored).toBe(true);
|
|
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
|
|
expect(
|
|
readdirSync(path.join(mirror, ".git")).filter((entry) =>
|
|
entry.startsWith(".openclaw-live-mac-"),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("preserves a build failure after an external Mac bundle restore", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const appBundle = path.join(mirror, "dist/OpenClaw.app");
|
|
const appMarker = path.join(appBundle, "Contents/signature-marker");
|
|
mkdirSync(path.dirname(appMarker), { recursive: true });
|
|
writeFileSync(appMarker, "signed\n");
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
const preserved = readdirSync(path.join(mirror, ".git")).find((entry) =>
|
|
entry.startsWith(".openclaw-live-mac-"),
|
|
);
|
|
expect(preserved).toBeDefined();
|
|
renameSync(path.join(mirror, ".git", preserved!), appBundle);
|
|
throw new Error("build failed after external restore");
|
|
}
|
|
},
|
|
},
|
|
),
|
|
).toThrow("build failed after external restore");
|
|
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
|
|
});
|
|
|
|
test("proves a current exact-SHA Gateway on a no-op heartbeat", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{ runCommand: commands.runCommand },
|
|
);
|
|
|
|
expect(output.updated).toBe(false);
|
|
expect(output.actions).toMatchObject({
|
|
gatewayBuild: false,
|
|
gatewayProbe: true,
|
|
gatewayRestart: false,
|
|
gatewaySelfHeal: false,
|
|
});
|
|
expect(commands.calls).toEqual([
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("restores missing dependencies before probing a current build", () => {
|
|
const { root, mirror } = makeFixture();
|
|
writeBuild(mirror);
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{ runCommand: commands.runCommand },
|
|
);
|
|
|
|
expect(output.actions).toMatchObject({
|
|
dependencyInstall: true,
|
|
gatewayBuild: false,
|
|
gatewayProbe: true,
|
|
});
|
|
expect(commands.calls).toEqual([
|
|
"pnpm install --frozen-lockfile",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("restarts once when a current exact-SHA Gateway probe fails", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
const calls: string[] = [];
|
|
let failed = false;
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
const call = [command, ...args].join(" ");
|
|
calls.push(call);
|
|
if (!failed && call.includes("gateway status")) {
|
|
failed = true;
|
|
throw new Error("RPC unavailable");
|
|
}
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(output.actions).toMatchObject({
|
|
gatewayBuild: false,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: true,
|
|
});
|
|
expect(calls).toEqual([
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw gateway restart",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("keeps successful CLI stdout as one machine-readable JSON object", () => {
|
|
const { root, mirror, origin } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
const binDir = path.join(root, "bin");
|
|
const pnpm = path.join(binDir, "pnpm");
|
|
const gitShim = path.join(binDir, "git");
|
|
const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
|
|
mkdirSync(binDir);
|
|
writeFileSync(pnpm, "#!/bin/sh\necho child-output\n");
|
|
writeFileSync(
|
|
gitShim,
|
|
`#!/bin/sh\nif [ "$3" = "fetch" ]; then\n exec "${realGit}" -C "$2" fetch "${origin}" "main:refs/remotes/origin/main"\nfi\nexec "${realGit}" "$@"\n`,
|
|
);
|
|
chmodSync(pnpm, 0o755);
|
|
chmodSync(gitShim, 0o755);
|
|
|
|
const result = spawnSync(process.execPath, [script, "--checkout", mirror], {
|
|
encoding: "utf8",
|
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH}` },
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim().split("\n")).toHaveLength(1);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({ ok: true, updated: false });
|
|
expect(result.stderr).toContain("child-output");
|
|
});
|
|
|
|
test("does not restart Gateway when build provenance misses the exact SHA", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const calls: string[] = [];
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand: (command: string, args: string[]) => calls.push([command, ...args].join(" ")),
|
|
},
|
|
),
|
|
).toThrow(/build output does not match/u);
|
|
expect(calls).toEqual([
|
|
"pnpm install --frozen-lockfile",
|
|
`${process.execPath} dist/index.js gateway stop`,
|
|
"pnpm build",
|
|
]);
|
|
});
|
|
|
|
test("audits restart-window logs even when deep Gateway verification fails", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
let auditCalls = 0;
|
|
let statusCalls = 0;
|
|
|
|
expect(() =>
|
|
maintainMain(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
fetchMain: fetchFixtureMain,
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args.slice(0, 3).join(" ") === "openclaw gateway status") {
|
|
statusCalls += 1;
|
|
throw new Error("RPC unavailable");
|
|
}
|
|
},
|
|
auditGatewayLogs() {
|
|
auditCalls += 1;
|
|
return { entries: 1, errorCount: 0, warningCount: 0, errors: [], warnings: [] };
|
|
},
|
|
sleep() {},
|
|
},
|
|
),
|
|
).toThrow("RPC unavailable");
|
|
expect(statusCalls).toBe(4);
|
|
expect(auditCalls).toBe(1);
|
|
});
|
|
|
|
test("retains failed exact-bundle Mac proof for the next heartbeat", () => {
|
|
const { root, mirror, seed } = makeFixture();
|
|
mkdirSync(path.join(seed, "apps/macos/Sources/OpenClaw"), { recursive: true });
|
|
writeFileSync(path.join(seed, "apps/macos/Sources/OpenClaw/App.swift"), "// changed\n");
|
|
git(seed, "add", ".");
|
|
git(seed, "commit", "-m", "mac change");
|
|
git(seed, "push");
|
|
const lockPath = path.join(root, "maintenance.lock");
|
|
const statePath = path.join(root, "maintenance-state.json");
|
|
const firstCommands = fakeCommands(mirror);
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath, statePath },
|
|
{
|
|
runCommand: firstCommands.runCommand,
|
|
verifyMacTarget: () => {
|
|
throw new Error("exact target exited");
|
|
},
|
|
},
|
|
),
|
|
).toThrow("exact target exited");
|
|
expect(JSON.parse(readFileSync(statePath, "utf8"))).toMatchObject({
|
|
macPending: true,
|
|
attempts: 1,
|
|
lastFailure: "exact target exited",
|
|
});
|
|
|
|
const retryCommands = fakeCommands(mirror);
|
|
const retry = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath, statePath },
|
|
{
|
|
runCommand: retryCommands.runCommand,
|
|
verifyMacTarget: () => ({ executable: "exact", pid: 456 }),
|
|
},
|
|
);
|
|
expect(retry.updated).toBe(false);
|
|
expect(retry.actions.gatewayBuild).toBe(false);
|
|
expect(retry.actions.macAppRebuild).toBe(true);
|
|
expect(retryCommands.calls.slice(0, 3)).toEqual([
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
"env SKIP_TSC=1 SKIP_UI_BUILD=1 bash scripts/restart-mac.sh --sign --wait --target-only",
|
|
]);
|
|
expect(existsSync(statePath)).toBe(false);
|
|
});
|
|
|
|
test("records pending Mac work before Gateway maintenance can fail", () => {
|
|
const { root, mirror, seed } = makeFixture();
|
|
mkdirSync(path.join(seed, "apps/macos/Sources/OpenClaw"), { recursive: true });
|
|
writeFileSync(path.join(seed, "apps/macos/Sources/OpenClaw/App.swift"), "// changed\n");
|
|
git(seed, "add", ".");
|
|
git(seed, "commit", "-m", "mac change");
|
|
git(seed, "push");
|
|
const statePath = path.join(root, "maintenance-state.json");
|
|
const commands = fakeCommands(mirror);
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{
|
|
checkout: mirror,
|
|
remote: "origin",
|
|
lockPath: path.join(root, "maintenance.lock"),
|
|
statePath,
|
|
},
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
commands.runCommand(command, args);
|
|
if (command === "pnpm" && args.includes("status")) {
|
|
throw new Error("Gateway failed");
|
|
}
|
|
},
|
|
},
|
|
),
|
|
).toThrow("Gateway failed");
|
|
expect(JSON.parse(readFileSync(statePath, "utf8"))).toMatchObject({
|
|
macPending: true,
|
|
attempts: 0,
|
|
});
|
|
});
|
|
|
|
test("refuses a symlinked maintenance state file without touching its target", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const statePath = path.join(root, "maintenance-state.json");
|
|
const victimPath = path.join(root, "victim.txt");
|
|
writeFileSync(victimPath, "untouched\n");
|
|
symlinkSync(victimPath, statePath);
|
|
|
|
expect(() =>
|
|
maintainFixture({
|
|
checkout: mirror,
|
|
remote: "origin",
|
|
lockPath: path.join(root, "maintenance.lock"),
|
|
statePath,
|
|
}),
|
|
).toThrow(/maintenance state is unreadable/u);
|
|
expect(readFileSync(victimPath, "utf8")).toBe("untouched\n");
|
|
});
|
|
|
|
test("skips an overlapping heartbeat while the owner process is alive", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const lockPath = path.join(root, "maintenance.lock");
|
|
const held = acquireMaintenanceLock(mirror, lockPath);
|
|
try {
|
|
const output = maintainFixture({ checkout: mirror, remote: "origin", lockPath });
|
|
expect(output).toMatchObject({ ok: true, skipped: true, reason: "overlap" });
|
|
} finally {
|
|
held.release?.();
|
|
}
|
|
});
|
|
|
|
test("atomically recovers a dead maintenance lock", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const lockPath = path.join(root, "maintenance.lock");
|
|
mkdirSync(lockPath);
|
|
writeFileSync(
|
|
path.join(lockPath, "owner.json"),
|
|
`${JSON.stringify({ pid: 999_999_999, checkout: mirror, startedAt: "stale" })}\n`,
|
|
);
|
|
|
|
const held = acquireMaintenanceLock(mirror, lockPath);
|
|
try {
|
|
expect(held.acquired).toBe(true);
|
|
expect(held.owner.pid).toBe(process.pid);
|
|
expect(existsSync(`${lockPath}.stale-${process.pid}`)).toBe(false);
|
|
} finally {
|
|
held.release?.();
|
|
}
|
|
});
|
|
|
|
test("treats an owner file creation race as a normal overlap", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const lockPath = path.join(root, "maintenance.lock");
|
|
mkdirSync(lockPath);
|
|
const owner = { pid: process.pid, checkout: mirror, startedAt: "racing" };
|
|
const writer = spawn(
|
|
"sh",
|
|
["-c", 'sleep 0.03; printf "%s\\n" "$OWNER_JSON" > "$LOCK_PATH/owner.json"'],
|
|
{
|
|
env: { ...process.env, LOCK_PATH: lockPath, OWNER_JSON: JSON.stringify(owner) },
|
|
stdio: "ignore",
|
|
},
|
|
);
|
|
|
|
try {
|
|
expect(acquireMaintenanceLock(mirror, lockPath)).toMatchObject({
|
|
acquired: false,
|
|
owner,
|
|
});
|
|
} finally {
|
|
writer.kill();
|
|
}
|
|
});
|
|
|
|
test("refuses dirty work without moving HEAD", () => {
|
|
const { mirror } = makeFixture();
|
|
const before = git(mirror, "rev-parse", "HEAD");
|
|
writeFileSync(path.join(mirror, "local.txt"), "do not destroy\n");
|
|
|
|
const result = spawnSync(process.execPath, [script, "--checkout", mirror], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(JSON.parse(result.stdout.trim())).toMatchObject({
|
|
ok: false,
|
|
error: { code: "dirty_checkout" },
|
|
});
|
|
expect(git(mirror, "rev-parse", "HEAD")).toBe(before);
|
|
expect(git(mirror, "status", "--porcelain")).toContain("?? local.txt");
|
|
});
|
|
});
|