fix(mac): scope restart app cleanup

This commit is contained in:
Vincent Koc
2026-06-07 06:58:46 +02:00
parent ab645aca31
commit 1d2bebbb41
2 changed files with 197 additions and 31 deletions
+141 -13
View File
@@ -1,6 +1,15 @@
// Restart Mac tests cover restart mac script behavior.
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -36,15 +45,15 @@ function runGatewayPortCheck(fakeLsof: string) {
);
}
function runCleanupFunction(fakePgrep: string) {
function runCleanupFunction(fakePs: string) {
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-"));
tempRoots.push(root);
const binDir = join(root, "bin");
const killCallsPath = join(root, "kill-calls.txt");
mkdirSync(binDir);
for (const [name, body] of [
["pgrep", fakePgrep],
["pkill", "#!/usr/bin/env bash\nexit 0\n"],
["ps", fakePs],
["sleep", "#!/usr/bin/env bash\nexit 0\n"],
] as const) {
const toolPath = join(binDir, name);
@@ -63,22 +72,64 @@ function runCleanupFunction(fakePgrep: string) {
[
"#!/usr/bin/env bash",
cleanupFunction,
'APP_PROCESS_PATTERN="OpenClaw.app/Contents/MacOS/OpenClaw"',
'ROOT_DIR="/worktree"',
'APP_BUNDLE=""',
'APP_EXECUTABLE_RELATIVE_PATH="Contents/MacOS/OpenClaw"',
'DEBUG_PROCESS_PATTERN="/worktree/apps/macos/.build/debug/OpenClaw"',
'LOCAL_PROCESS_PATTERN="/worktree/apps/macos/.build-local/debug/OpenClaw"',
'RELEASE_PROCESS_PATTERN="/worktree/apps/macos/.build/release/OpenClaw"',
"kill() {",
' printf "%s\\n" "$*" >> "$OPENCLAW_TEST_KILL_CALLS"',
" return 0",
"}",
"kill_all_openclaw",
].join("\n"),
);
chmodSync(harnessPath, 0o755);
return spawnSync("bash", [harnessPath], {
const result = spawnSync("bash", [harnessPath], {
encoding: "utf8",
env: {
...process.env,
OPENCLAW_TEST_KILL_CALLS: killCallsPath,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
},
});
const killCalls = existsSync(killCallsPath) ? readFileSync(killCallsPath, "utf8") : "";
return { killCalls, result };
}
function runCanonicalizeAppBundle(appBundle: string) {
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-"));
tempRoots.push(root);
const script = readFileSync(restartScriptPath, "utf8");
const canonicalizeFunction = script.slice(
script.indexOf("canonicalize_app_bundle()"),
script.indexOf("trap cleanup"),
);
const harnessPath = join(root, "canonicalize-harness.sh");
writeFileSync(
harnessPath,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
canonicalizeFunction,
'APP_BUNDLE="$1"',
"fail() {",
" printf 'ERROR: %s\\n' \"$*\" >&2",
" exit 1",
"}",
"canonicalize_app_bundle",
'printf "%s\\n" "$APP_BUNDLE"',
].join("\n"),
);
chmodSync(harnessPath, 0o755);
return {
result: spawnSync("bash", [harnessPath, appBundle], { cwd: root, encoding: "utf8" }),
root,
};
}
afterEach(() => {
@@ -136,7 +187,8 @@ describe("scripts/restart-mac.sh", () => {
script.indexOf("choose_app_bundle", script.indexOf("choose_app_bundle()") + 1),
);
expect(chooseBlock).toContain('fail "OPENCLAW_APP_BUNDLE does not exist: ${APP_BUNDLE}"');
expect(script).toContain('fail "OPENCLAW_APP_BUNDLE does not exist: ${APP_BUNDLE}"');
expect(chooseBlock).toContain("canonicalize_app_bundle");
expect(chooseBlock.indexOf("${ROOT_DIR}/dist/OpenClaw.app")).toBeGreaterThan(-1);
expect(chooseBlock.indexOf("/Applications/OpenClaw.app")).toBeGreaterThan(-1);
expect(chooseBlock.indexOf("${ROOT_DIR}/dist/OpenClaw.app")).toBeLessThan(
@@ -151,11 +203,18 @@ describe("scripts/restart-mac.sh", () => {
script.indexOf("stop_launch_agent()"),
);
expect(cleanupBlock).toContain('pkill -f "${APP_PROCESS_PATTERN}"');
expect(cleanupBlock).toContain('pkill -f "${DEBUG_PROCESS_PATTERN}"');
expect(cleanupBlock).toContain('pkill -f "${LOCAL_PROCESS_PATTERN}"');
expect(cleanupBlock).toContain('pkill -f "${RELEASE_PROCESS_PATTERN}"');
expect(cleanupBlock).toContain("ps axww -o pid=,command=");
expect(cleanupBlock).toContain(
'"${ROOT_DIR}/dist/OpenClaw.app/${APP_EXECUTABLE_RELATIVE_PATH}"',
);
expect(cleanupBlock).toContain('"/Applications/OpenClaw.app/${APP_EXECUTABLE_RELATIVE_PATH}"');
expect(cleanupBlock).toContain('"${DEBUG_PROCESS_PATTERN}"');
expect(cleanupBlock).toContain('"${LOCAL_PROCESS_PATTERN}"');
expect(cleanupBlock).toContain('"${RELEASE_PROCESS_PATTERN}"');
expect(cleanupBlock).not.toContain("APP_PROCESS_PATTERN");
expect(cleanupBlock).not.toContain("pkill");
expect(cleanupBlock).not.toContain('pkill -x "OpenClaw"');
expect(cleanupBlock).not.toContain("pgrep");
expect(cleanupBlock).not.toContain('pgrep -x "OpenClaw"');
});
@@ -169,18 +228,87 @@ describe("scripts/restart-mac.sh", () => {
expect(stopIndex).toBeLessThan(killIndex);
});
it("verifies the launched app through the chosen bundle executable", () => {
const script = readFileSync(restartScriptPath, "utf8");
const verifyBlock = script.slice(script.indexOf("# 5) Verify the app is alive."));
expect(verifyBlock).toContain(
'process_pids_matching "${APP_BUNDLE}/${APP_EXECUTABLE_RELATIVE_PATH}"',
);
expect(verifyBlock).not.toContain("APP_PROCESS_PATTERN");
expect(verifyBlock).not.toContain("pgrep");
});
it("forces LaunchServices to start the selected app bundle", () => {
const script = readFileSync(restartScriptPath, "utf8");
expect(script).toContain('/usr/bin/open -n "${APP_BUNDLE}"');
expect(script).not.toContain('/usr/bin/open "${APP_BUNDLE}"');
});
it("normalizes custom app bundle paths before process matching", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-"));
tempRoots.push(root);
const appBundle = join(root, "dist", "OpenClaw.app");
mkdirSync(appBundle, { recursive: true });
const { result } = runCanonicalizeAppBundle(`${appBundle}/../OpenClaw.app/`);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe(realpathSync(appBundle));
expect(result.stderr).toBe("");
});
it("fails restart cleanup when scoped processes survive every kill attempt", () => {
const result = runCleanupFunction("#!/usr/bin/env bash\nexit 0\n");
const { killCalls, result } = runCleanupFunction(
[
"#!/usr/bin/env bash",
"printf '%s\\n' ' 321 /worktree/dist/OpenClaw.app/Contents/MacOS/OpenClaw --attach-only'",
].join("\n"),
);
expect(result.status).toBe(1);
expect(killCalls).toContain("321\n");
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
});
it("passes restart cleanup when the final kill attempt clears the process", () => {
const { killCalls, result } = runCleanupFunction(
[
"#!/usr/bin/env bash",
'kill_count="$(wc -l < "$OPENCLAW_TEST_KILL_CALLS" 2>/dev/null || echo 0)"',
'if [[ "$kill_count" -lt 10 ]]; then',
" printf '%s\\n' ' 321 /worktree/dist/OpenClaw.app/Contents/MacOS/OpenClaw --attach-only'",
"fi",
].join("\n"),
);
expect(result.status).toBe(0);
expect(killCalls.trim().split(/\r?\n/u)).toHaveLength(10);
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
});
it("passes restart cleanup when scoped processes are gone", () => {
const result = runCleanupFunction("#!/usr/bin/env bash\nexit 1\n");
const { killCalls, result } = runCleanupFunction("#!/usr/bin/env bash\nexit 0\n");
expect(result.status).toBe(0);
expect(killCalls).toBe("");
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
});
it("does not kill unrelated OpenClaw app bundles", () => {
const { killCalls, result } = runCleanupFunction(
[
"#!/usr/bin/env bash",
"printf '%s\\n' ' 654 /tmp/Other/OpenClaw.app/Contents/MacOS/OpenClaw'",
].join("\n"),
);
expect(result.status).toBe(0);
expect(killCalls).toBe("");
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
});