Files
kandev/apps/cli/src/service/paths.test.ts
T
José Almeida 041a19f060 feat: service-managed UI self-update (#1210)
* 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
2026-06-01 17:49:15 +01:00

191 lines
6.4 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureLauncher, homebrewShimPath, resolveHomeDir, resolveServiceUser } from "./paths";
const originalArgv = [...process.argv];
const originalBundleDir = process.env.KANDEV_BUNDLE_DIR;
const originalVersion = process.env.KANDEV_VERSION;
describe("resolveServiceUser", () => {
const originalSudoUser = process.env.SUDO_USER;
beforeEach(() => {
delete process.env.SUDO_USER;
});
afterEach(() => {
if (originalSudoUser === undefined) delete process.env.SUDO_USER;
else process.env.SUDO_USER = originalSudoUser;
vi.restoreAllMocks();
});
it("returns the current user for user-mode installs", () => {
vi.spyOn(os, "userInfo").mockReturnValue({
username: "alice",
uid: 1000,
gid: 1000,
shell: null,
homedir: "/home/alice",
});
process.env.SUDO_USER = "bob"; // ignored in user mode
expect(resolveServiceUser(false)).toBe("alice");
});
it("prefers SUDO_USER for system-mode installs", () => {
vi.spyOn(os, "userInfo").mockReturnValue({
username: "root",
uid: 0,
gid: 0,
shell: null,
homedir: "/root",
});
process.env.SUDO_USER = "alice";
expect(resolveServiceUser(true)).toBe("alice");
});
it("falls back to current user when SUDO_USER is not set", () => {
vi.spyOn(os, "userInfo").mockReturnValue({
username: "root",
uid: 0,
gid: 0,
shell: null,
homedir: "/root",
});
expect(resolveServiceUser(true)).toBe("root");
});
it("ignores SUDO_USER=root and falls through to current user", () => {
// sudo never sets SUDO_USER=root in practice, but defend against it
// so we don't accidentally pin the daemon to root by accident.
vi.spyOn(os, "userInfo").mockReturnValue({
username: "alice",
uid: 1000,
gid: 1000,
shell: null,
homedir: "/home/alice",
});
process.env.SUDO_USER = "root";
expect(resolveServiceUser(true)).toBe("alice");
});
});
describe("captureLauncher", () => {
afterEach(() => {
process.argv.splice(0, process.argv.length, ...originalArgv);
if (originalBundleDir === undefined) delete process.env.KANDEV_BUNDLE_DIR;
else process.env.KANDEV_BUNDLE_DIR = originalBundleDir;
if (originalVersion === undefined) delete process.env.KANDEV_VERSION;
else process.env.KANDEV_VERSION = originalVersion;
vi.restoreAllMocks();
});
it("resolves npm global bin symlinks before detecting the install kind", () => {
delete process.env.KANDEV_BUNDLE_DIR;
delete process.env.KANDEV_VERSION;
process.argv[1] = "/tmp/kandev-test/npm-global/bin/kandev";
vi.spyOn(fs, "existsSync").mockReturnValue(true);
vi.spyOn(fs, "realpathSync").mockReturnValue(
"/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js",
);
expect(captureLauncher()).toMatchObject({
cliEntry: "/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js",
kind: "npm",
});
});
});
describe("resolveHomeDir", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns an absolute override as-is (no tilde)", () => {
expect(resolveHomeDir("/srv/kandev", false)).toBe("/srv/kandev");
});
it("expands a leading ~/ to os.homedir()", () => {
vi.spyOn(os, "homedir").mockReturnValue("/home/alice");
expect(resolveHomeDir("~/kandev", false)).toBe("/home/alice/kandev");
expect(resolveHomeDir("~/srv/nested", false)).toBe("/home/alice/srv/nested");
});
it("expands a bare ~ to os.homedir()", () => {
vi.spyOn(os, "homedir").mockReturnValue("/home/alice");
expect(resolveHomeDir("~", false)).toBe("/home/alice");
});
it("does not expand a tilde that isn't at the start", () => {
expect(resolveHomeDir("/srv/~/data", false)).toBe("/srv/~/data");
});
it("falls back to /var/lib/kandev for system mode with no override", () => {
expect(resolveHomeDir(undefined, true)).toBe("/var/lib/kandev");
});
it("falls back to ~/.kandev for user mode with no override", () => {
// KANDEV_HOME_DIR is the constant from ../constants, computed at import
// time. We just check it ends with .kandev and starts with the home dir.
const result = resolveHomeDir(undefined, false);
expect(result.endsWith(".kandev")).toBe(true);
});
});
describe("homebrewShimPath", () => {
it("derives the floating bin shim from a linuxbrew Cellar cli.js path", () => {
expect(
homebrewShimPath("/home/linuxbrew/.linuxbrew/Cellar/kandev/0.52.0/libexec/cli/bin/cli.js"),
).toBe("/home/linuxbrew/.linuxbrew/bin/kandev");
});
it("derives the floating bin shim from an Apple-silicon Cellar path", () => {
expect(homebrewShimPath("/opt/homebrew/Cellar/kandev/1.2.3/libexec/cli/bin/cli.js")).toBe(
"/opt/homebrew/bin/kandev",
);
});
it("returns undefined for a non-Cellar path (npm install)", () => {
expect(homebrewShimPath("/usr/local/lib/node_modules/kandev/bin/cli.js")).toBeUndefined();
});
});
describe("captureLauncher shim resolution", () => {
const originalArgv = process.argv;
afterEach(() => {
process.argv = originalArgv;
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("adopts the floating shim when it exists on disk", () => {
const cliEntry = "/opt/homebrew/Cellar/kandev/1.2.3/libexec/cli/bin/cli.js";
const shim = "/opt/homebrew/bin/kandev";
process.argv = ["/path/to/node", cliEntry];
vi.stubEnv("KANDEV_BUNDLE_DIR", "/opt/homebrew/Cellar/kandev/1.2.3/libexec");
// Both the cli.js entry and the shim resolve on disk.
vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry || p === shim);
vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry);
const info = captureLauncher();
expect(info.kind).toBe("homebrew");
expect(info.shimPath).toBe(shim);
});
it("falls back to version-pinned paths when the shim is absent on disk", () => {
const cliEntry = "/opt/homebrew/Cellar/kandev/1.2.3/libexec/cli/bin/cli.js";
process.argv = ["/path/to/node", cliEntry];
vi.stubEnv("KANDEV_BUNDLE_DIR", "/opt/homebrew/Cellar/kandev/1.2.3/libexec");
// The cli.js entry resolves, but the floating shim does not exist.
vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry);
vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry);
const info = captureLauncher();
expect(info.kind).toBe("homebrew");
expect(info.shimPath).toBeUndefined();
});
});