041a19f060
* feat: add service-managed self-update * chore: add temporary self-update test scripts * chore: simplify temporary script markdown fences * chore: run frontend in temporary self-update scripts * fix: show self-update job progress after apply * chore: add real npm self-update test script * fix: isolate npm self-update prefix * fix: detect npm service installs from bin shim * chore: reset service before real npm update test * chore: expand real npm status diagnostics * fix: preserve service home during self-update * fix(cli): tee self-update helper output to a log file * fix(cli): avoid launchd bootstrap race when reloading the macOS service * fix(backend): run macOS self-update helper once via transient launchd plist * fix(web): show self-update progress and lock the apply button until the update lands * fix(cli): reinstall via the kandev wrapper on Homebrew self-update * chore: remove temp self-update test scripts * fix(web): drop unused apply path from useUpdates hook * fix(backend): snapshot install state once in apply preflight * docs: align self-update docs with transient LaunchAgent flow * chore(release): run cli bundle from apps workspace root * fix(backend): guard against concurrent self-update applies * fix(backend): check scheme and helper paths in self-update apply * fix(backend): drop duplicate binary name from npx manual command * fix(cli): allow --help for service self-update without --intent * fix(cli): harden self-update dir perms and darwin uid resolution * fix(web): set required POST fields last in applyUpdate * docs: use JSON body notation for the apply endpoint * fix(backend): expire self-update guard so a stuck helper cannot wedge apply * refactor(cli): collapse redundant writeSync branch in self-update log
123 lines
3.8 KiB
TypeScript
123 lines
3.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
bootoutAndWait,
|
|
bootstrapWithRetry,
|
|
reloadService,
|
|
type LaunchctlResult,
|
|
} from "./launchctl";
|
|
|
|
type Call = { args: string[]; stdio: string };
|
|
|
|
function recordingRun(statusFor: (call: Call, index: number) => number | null): {
|
|
run: (args: string[], stdio: "inherit" | "ignore") => LaunchctlResult;
|
|
calls: Call[];
|
|
} {
|
|
const calls: Call[] = [];
|
|
const run = (args: string[], stdio: "inherit" | "ignore"): LaunchctlResult => {
|
|
const call = { args, stdio };
|
|
const status = statusFor(call, calls.length);
|
|
calls.push(call);
|
|
return { status };
|
|
};
|
|
return { run, calls };
|
|
}
|
|
|
|
describe("bootoutAndWait", () => {
|
|
it("polls `print` until the job is gone before returning", () => {
|
|
// print returns 0 (still loaded) twice, then non-zero (gone).
|
|
let printCount = 0;
|
|
const { run, calls } = recordingRun((call) => {
|
|
if (call.args[0] === "bootout") return 0;
|
|
if (call.args[0] === "print") {
|
|
printCount += 1;
|
|
return printCount <= 2 ? 0 : 1;
|
|
}
|
|
return 0;
|
|
});
|
|
const sleep = vi.fn();
|
|
|
|
bootoutAndWait("gui/501/com.kdlbs.kandev", { run, sleep });
|
|
|
|
expect(calls[0].args).toEqual(["bootout", "gui/501/com.kdlbs.kandev"]);
|
|
expect(calls.filter((c) => c.args[0] === "print")).toHaveLength(3);
|
|
expect(sleep).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("returns immediately when nothing is loaded", () => {
|
|
const { run, calls } = recordingRun((call) => (call.args[0] === "print" ? 1 : 0));
|
|
const sleep = vi.fn();
|
|
|
|
bootoutAndWait("gui/501/com.kdlbs.kandev", { run, sleep });
|
|
|
|
expect(calls.filter((c) => c.args[0] === "print")).toHaveLength(1);
|
|
expect(sleep).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("bootstrapWithRetry", () => {
|
|
it("retries through the transient EIO and succeeds", () => {
|
|
// Reproduces the launchd teardown race: bootstrap returns 5 twice, then 0.
|
|
let bootstrapCount = 0;
|
|
const { run, calls } = recordingRun(() => {
|
|
bootstrapCount += 1;
|
|
return bootstrapCount < 3 ? 5 : 0;
|
|
});
|
|
const sleep = vi.fn();
|
|
|
|
expect(() =>
|
|
bootstrapWithRetry("gui/501", "/Users/a/Library/LaunchAgents/x.plist", { run, sleep }),
|
|
).not.toThrow();
|
|
expect(calls).toHaveLength(3);
|
|
expect(sleep).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("throws with the last exit code after exhausting retries", () => {
|
|
const { run, calls } = recordingRun(() => 5);
|
|
const sleep = vi.fn();
|
|
|
|
expect(() =>
|
|
bootstrapWithRetry("gui/501", "/Users/a/Library/LaunchAgents/x.plist", { run, sleep }),
|
|
).toThrow(/failed with code 5/);
|
|
expect(calls).toHaveLength(5);
|
|
});
|
|
|
|
it("succeeds on the first attempt without sleeping", () => {
|
|
const { run, calls } = recordingRun(() => 0);
|
|
const sleep = vi.fn();
|
|
|
|
bootstrapWithRetry("gui/501", "/x.plist", { run, sleep });
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(sleep).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("reloadService", () => {
|
|
it("waits for teardown, then bootstraps — surviving an EIO mid-reload", () => {
|
|
// Live-service reload: print stays loaded once, bootstrap hits EIO once.
|
|
let printCount = 0;
|
|
let bootstrapCount = 0;
|
|
const { run, calls } = recordingRun((call) => {
|
|
if (call.args[0] === "print") {
|
|
printCount += 1;
|
|
return printCount <= 1 ? 0 : 1;
|
|
}
|
|
if (call.args[0] === "bootstrap") {
|
|
bootstrapCount += 1;
|
|
return bootstrapCount < 2 ? 5 : 0;
|
|
}
|
|
return 0;
|
|
});
|
|
const sleep = vi.fn();
|
|
|
|
reloadService("gui/501/com.kdlbs.kandev", "gui/501", "/x.plist", { run, sleep });
|
|
|
|
const order = calls.map((c) => c.args[0]);
|
|
expect(order[0]).toBe("bootout");
|
|
// bootout precedes the first bootstrap
|
|
expect(order.indexOf("bootout")).toBeLessThan(order.indexOf("bootstrap"));
|
|
expect(calls.filter((c) => c.args[0] === "bootstrap")).toHaveLength(2);
|
|
});
|
|
});
|