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
224 lines
7.9 KiB
TypeScript
224 lines
7.9 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getBinaryName } from "./platform";
|
|
import { attachRingBuffer, cleanOldReleases, findCachedRelease } from "./run";
|
|
|
|
// Mock CACHE_DIR to use a temp directory.
|
|
const mockCacheDir = { value: "" };
|
|
vi.mock("./constants", () => ({
|
|
get CACHE_DIR() {
|
|
return mockCacheDir.value;
|
|
},
|
|
resolveCacheDir: () => mockCacheDir.value,
|
|
DATA_DIR: "/tmp/kandev-test-data",
|
|
resolveDataDir: () => "/tmp/kandev-test-data",
|
|
resolveDatabasePath: () => "/tmp/kandev-test-data/kandev.db",
|
|
DEFAULT_BACKEND_PORT: 38429,
|
|
DEFAULT_WEB_PORT: 37429,
|
|
DEFAULT_AGENTCTL_PORT: 39429,
|
|
HEALTH_TIMEOUT_MS_RELEASE: 15000,
|
|
RANDOM_PORT_MIN: 10000,
|
|
RANDOM_PORT_MAX: 60000,
|
|
RANDOM_PORT_RETRIES: 10,
|
|
}));
|
|
|
|
const PLATFORM = "macos-arm64";
|
|
|
|
function createFakeCachedRelease(cacheDir: string, tag: string, platform: string) {
|
|
const binDir = path.join(cacheDir, tag, platform, "kandev", "bin");
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
// findCachedRelease looks up the binary by host-platform name (kandev on
|
|
// Unix, kandev.exe on Windows) via getBinaryName, so the fixture must match
|
|
// or the lookup never resolves on Windows.
|
|
fs.writeFileSync(path.join(binDir, getBinaryName("kandev")), "fake-binary");
|
|
}
|
|
|
|
describe("findCachedRelease", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-test-"));
|
|
mockCacheDir.value = tmpDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("with specific version", () => {
|
|
it("returns cache info when version exists", () => {
|
|
createFakeCachedRelease(tmpDir, "v0.2", PLATFORM);
|
|
const result = findCachedRelease(PLATFORM, "v0.2");
|
|
expect(result).not.toBeNull();
|
|
expect(result!.tag).toBe("v0.2");
|
|
expect(result!.cacheDir).toBe(path.join(tmpDir, "v0.2", PLATFORM));
|
|
});
|
|
|
|
it("returns null when version does not exist", () => {
|
|
const result = findCachedRelease(PLATFORM, "v9.9");
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when tag dir exists but no binary", () => {
|
|
fs.mkdirSync(path.join(tmpDir, "v0.1", PLATFORM), { recursive: true });
|
|
const result = findCachedRelease(PLATFORM, "v0.1");
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("without version (latest)", () => {
|
|
it("picks the highest semver tag", () => {
|
|
createFakeCachedRelease(tmpDir, "v0.1", PLATFORM);
|
|
createFakeCachedRelease(tmpDir, "v0.9", PLATFORM);
|
|
createFakeCachedRelease(tmpDir, "v1.2", PLATFORM);
|
|
const result = findCachedRelease(PLATFORM);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.tag).toBe("v1.2");
|
|
});
|
|
|
|
it("sorts major version correctly: v2.0 > v1.9", () => {
|
|
createFakeCachedRelease(tmpDir, "v1.9", PLATFORM);
|
|
createFakeCachedRelease(tmpDir, "v2.0", PLATFORM);
|
|
const result = findCachedRelease(PLATFORM);
|
|
expect(result!.tag).toBe("v2.0");
|
|
});
|
|
|
|
it("sorts numerically not lexicographically: v1.10 > v1.9", () => {
|
|
createFakeCachedRelease(tmpDir, "v1.9", PLATFORM);
|
|
createFakeCachedRelease(tmpDir, "v1.10", PLATFORM);
|
|
const result = findCachedRelease(PLATFORM);
|
|
expect(result!.tag).toBe("v1.10");
|
|
});
|
|
|
|
it("skips tags without extracted binary", () => {
|
|
createFakeCachedRelease(tmpDir, "v0.1", PLATFORM);
|
|
// v0.2 exists as dir but no binary inside
|
|
fs.mkdirSync(path.join(tmpDir, "v0.2", PLATFORM), { recursive: true });
|
|
const result = findCachedRelease(PLATFORM);
|
|
expect(result!.tag).toBe("v0.1");
|
|
});
|
|
|
|
it("returns null when cache dir does not exist", () => {
|
|
mockCacheDir.value = path.join(tmpDir, "nonexistent");
|
|
const result = findCachedRelease(PLATFORM);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when cache dir is empty", () => {
|
|
const result = findCachedRelease(PLATFORM);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("ignores non-v prefixed directories", () => {
|
|
fs.mkdirSync(path.join(tmpDir, "latest", PLATFORM), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, "temp", PLATFORM), { recursive: true });
|
|
const result = findCachedRelease(PLATFORM);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("picks valid release when highest tag has no binary", () => {
|
|
createFakeCachedRelease(tmpDir, "v0.1", PLATFORM);
|
|
createFakeCachedRelease(tmpDir, "v0.2", PLATFORM);
|
|
// v1.0 dir exists but is empty
|
|
fs.mkdirSync(path.join(tmpDir, "v1.0", PLATFORM), { recursive: true });
|
|
const result = findCachedRelease(PLATFORM);
|
|
expect(result!.tag).toBe("v0.2");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("cleanOldReleases", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-clean-test-"));
|
|
mockCacheDir.value = tmpDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("keeps the 2 most recent tags and removes the rest", () => {
|
|
for (const tag of ["v0.1", "v0.2", "v0.3", "v0.4"]) {
|
|
fs.mkdirSync(path.join(tmpDir, tag), { recursive: true });
|
|
}
|
|
cleanOldReleases("v0.4");
|
|
const remaining = fs.readdirSync(tmpDir).sort();
|
|
expect(remaining).toEqual(["v0.3", "v0.4"]);
|
|
});
|
|
|
|
it("does nothing when 2 or fewer tags exist", () => {
|
|
fs.mkdirSync(path.join(tmpDir, "v0.1"), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, "v0.2"), { recursive: true });
|
|
cleanOldReleases("v0.2");
|
|
const remaining = fs.readdirSync(tmpDir).sort();
|
|
expect(remaining).toEqual(["v0.1", "v0.2"]);
|
|
});
|
|
|
|
it("always keeps currentTag even if not in top 2 by semver", () => {
|
|
for (const tag of ["v0.1", "v1.0", "v2.0"]) {
|
|
fs.mkdirSync(path.join(tmpDir, tag), { recursive: true });
|
|
}
|
|
// currentTag is v0.1 (old), but top 2 by semver are v2.0, v1.0
|
|
cleanOldReleases("v0.1");
|
|
const remaining = fs.readdirSync(tmpDir).sort();
|
|
expect(remaining).toEqual(["v0.1", "v1.0", "v2.0"]);
|
|
});
|
|
|
|
it("does nothing when cache dir does not exist", () => {
|
|
mockCacheDir.value = path.join(tmpDir, "nonexistent");
|
|
expect(() => cleanOldReleases("v1.0")).not.toThrow();
|
|
});
|
|
|
|
it("ignores non-v prefixed directories", () => {
|
|
fs.mkdirSync(path.join(tmpDir, "latest"), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, "temp"), { recursive: true });
|
|
for (const tag of ["v0.1", "v0.2", "v0.3"]) {
|
|
fs.mkdirSync(path.join(tmpDir, tag), { recursive: true });
|
|
}
|
|
cleanOldReleases("v0.3");
|
|
const remaining = fs.readdirSync(tmpDir).sort();
|
|
expect(remaining).toEqual(["latest", "temp", "v0.2", "v0.3"]);
|
|
});
|
|
|
|
it("sorts numerically: keeps v1.10 over v1.9 when both are old", () => {
|
|
for (const tag of ["v1.1", "v1.9", "v1.10", "v2.0"]) {
|
|
fs.mkdirSync(path.join(tmpDir, tag), { recursive: true });
|
|
}
|
|
cleanOldReleases("v2.0");
|
|
const remaining = fs.readdirSync(tmpDir).sort();
|
|
expect(remaining).toEqual(["v1.10", "v2.0"]);
|
|
});
|
|
});
|
|
|
|
describe("attachRingBuffer", () => {
|
|
it("accumulates data chunks as utf8", () => {
|
|
const stream = new EventEmitter() as EventEmitter & NodeJS.ReadableStream;
|
|
const read = attachRingBuffer(stream, 1024);
|
|
stream.emit("data", Buffer.from("hello "));
|
|
stream.emit("data", "world");
|
|
expect(read()).toBe("hello world");
|
|
});
|
|
|
|
it("trims old data once maxChars is exceeded, keeping the tail", () => {
|
|
const stream = new EventEmitter() as EventEmitter & NodeJS.ReadableStream;
|
|
const read = attachRingBuffer(stream, 10);
|
|
stream.emit("data", "0123456789");
|
|
stream.emit("data", "ABCDE");
|
|
const buf = read();
|
|
expect(buf.length).toBeLessThanOrEqual(10);
|
|
expect(buf.endsWith("ABCDE")).toBe(true);
|
|
expect(buf).not.toContain("0123");
|
|
});
|
|
|
|
it("returns empty string when stream is null", () => {
|
|
const read = attachRingBuffer(null);
|
|
expect(read()).toBe("");
|
|
});
|
|
});
|