Files
kandev/apps/cli/src/run.test.ts
T
2026-08-07 23:32:04 +01:00

249 lines
8.5 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,
buildReleaseLaunchOptions,
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("");
});
});
describe("buildReleaseLaunchOptions", () => {
it("preserves the explicit backend port source for release launches", () => {
expect(
buildReleaseLaunchOptions({
runtimeVersion: "v1.2.3",
backendPort: 18080,
backendPortSource: "KANDEV_BACKEND_PORT",
verbose: true,
debug: true,
}),
).toEqual({
runtimeVersion: "v1.2.3",
backendPort: 18080,
backendPortSource: "KANDEV_BACKEND_PORT",
verbose: true,
debug: true,
});
});
});