646 lines
25 KiB
TypeScript
646 lines
25 KiB
TypeScript
/**
|
|
* `penguin update`'s pure pieces: install-kind detection over the three real layouts, package-
|
|
* manager identification for global installs, version normalisation/comparison, the installer
|
|
* argv/env built for each combination of install dir and bundled runtime, and the decision the
|
|
* command makes before it touches anything (planUpdate) plus its confirmation gate.
|
|
*
|
|
* No real network and no filesystem mutation — every I/O helper takes its inputs as arguments.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import { Command } from "commander";
|
|
import {
|
|
buildInstallerInvocation,
|
|
compareVersions,
|
|
confirmationMode,
|
|
configuredInstallerCandidate,
|
|
detectInstall,
|
|
detectPackageManager,
|
|
downloadInstaller,
|
|
globalInstallCommand,
|
|
installerCandidates,
|
|
installerUrl,
|
|
normalizeVersion,
|
|
normalizeHttpsBaseUrl,
|
|
parseDownloadSource,
|
|
parseOssLatestManifest,
|
|
planUpdate,
|
|
registerUpdateCommand,
|
|
resolveRelease,
|
|
} from "../src/commands/update.js";
|
|
import { getMessages } from "../src/i18n.js";
|
|
|
|
// POSIX-only: the fixtures are POSIX install layouts and detectInstall normalizes through
|
|
// path.* (backslashes on Windows) — where in-place `penguin update` is refused anyway
|
|
// (the documented Windows upgrade path is re-running install.ps1).
|
|
describe.skipIf(process.platform === "win32")(
|
|
"detectInstall (how this CLI was installed, from its own real path)",
|
|
() => {
|
|
it("tarball: <installDir>/lib/dist/index.js, the layout install.sh unpacks", () => {
|
|
expect(detectInstall("/home/me/.penguin/lib/dist/index.js")).toEqual({
|
|
kind: "tarball",
|
|
installDir: "/home/me/.penguin",
|
|
});
|
|
});
|
|
|
|
it("tarball: a non-default PENGUIN_INSTALL_DIR is read off the path, not the environment", () => {
|
|
expect(detectInstall("/opt/tools/penguin/lib/dist/index.js")).toEqual({
|
|
kind: "tarball",
|
|
installDir: "/opt/tools/penguin",
|
|
});
|
|
});
|
|
|
|
it("npm global: npm's own prefix layout", () => {
|
|
expect(
|
|
detectInstall("/usr/local/lib/node_modules/@prismshadow/penguin-cli/dist/index.js"),
|
|
).toEqual({ kind: "npm", globalRoot: "/usr/local/lib/node_modules" });
|
|
});
|
|
|
|
it("npm global: pnpm's global store, through the .pnpm virtual dir", () => {
|
|
const p =
|
|
"/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/@prismshadow+penguin-cli@0.1.1/node_modules/@prismshadow/penguin-cli/dist/index.js";
|
|
const info = detectInstall(p);
|
|
expect(info.kind).toBe("npm");
|
|
expect(info.globalRoot).toContain(".pnpm");
|
|
});
|
|
|
|
it("source checkout: the built dist inside the monorepo", () => {
|
|
expect(detectInstall("/home/me/code/penguin-harness/packages/cli/dist/index.js")).toEqual({
|
|
kind: "source",
|
|
});
|
|
});
|
|
|
|
it("source checkout: tsx running src directly", () => {
|
|
expect(detectInstall("/home/me/code/penguin-harness/packages/cli/src/index.ts")).toEqual({
|
|
kind: "source",
|
|
});
|
|
});
|
|
|
|
it("a checkout wins over the tarball shape, so a repo under a lib/ dir is never mistaken for an install", () => {
|
|
expect(detectInstall("/srv/lib/penguin-harness/packages/cli/dist/index.js")).toEqual({
|
|
kind: "source",
|
|
});
|
|
});
|
|
|
|
it("anything else is unknown rather than guessed", () => {
|
|
expect(detectInstall("/random/place/index.js").kind).toBe("unknown");
|
|
expect(detectInstall("/home/me/.penguin/bin/penguin").kind).toBe("unknown");
|
|
});
|
|
},
|
|
);
|
|
|
|
describe("detectPackageManager (which manager owns a global node_modules root)", () => {
|
|
it("pnpm: the global store or the .pnpm virtual dir", () => {
|
|
expect(detectPackageManager("/home/me/.local/share/pnpm/global/5/node_modules")).toBe("pnpm");
|
|
expect(
|
|
detectPackageManager("/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/x/node_modules"),
|
|
).toBe("pnpm");
|
|
});
|
|
it("npm: the usual prefixes", () => {
|
|
expect(detectPackageManager("/usr/local/lib/node_modules")).toBe("npm");
|
|
expect(detectPackageManager("/home/me/.npm-global/lib/node_modules")).toBe("npm");
|
|
});
|
|
it("yarn and bun", () => {
|
|
expect(detectPackageManager("/home/me/.config/yarn/global/node_modules")).toBe("yarn");
|
|
expect(detectPackageManager("/home/me/.bun/install/global/node_modules")).toBe("bun");
|
|
});
|
|
it("returns null when unrecognizable, so the caller prints a command instead of guessing", () => {
|
|
expect(detectPackageManager("/weird/place")).toBeNull();
|
|
expect(detectPackageManager("")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("globalInstallCommand", () => {
|
|
it("uses each manager's own global-install spelling", () => {
|
|
expect(globalInstallCommand("pnpm", "0.1.2")).toEqual({
|
|
command: "pnpm",
|
|
args: ["add", "-g", "@prismshadow/penguin-cli@0.1.2"],
|
|
});
|
|
expect(globalInstallCommand("npm", "0.1.2")).toEqual({
|
|
command: "npm",
|
|
args: ["install", "-g", "@prismshadow/penguin-cli@0.1.2"],
|
|
});
|
|
expect(globalInstallCommand("yarn", "0.1.2")).toEqual({
|
|
command: "yarn",
|
|
args: ["global", "add", "@prismshadow/penguin-cli@0.1.2"],
|
|
});
|
|
expect(globalInstallCommand("bun", "0.1.2")).toEqual({
|
|
command: "bun",
|
|
args: ["add", "-g", "@prismshadow/penguin-cli@0.1.2"],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("normalizeVersion / compareVersions", () => {
|
|
it("accepts both v-prefixed and bare tags", () => {
|
|
expect(normalizeVersion("v0.1.2")).toBe("0.1.2");
|
|
expect(normalizeVersion("0.1.2")).toBe("0.1.2");
|
|
expect(normalizeVersion(" V0.1.2 ")).toBe("0.1.2");
|
|
});
|
|
it("equal versions compare equal regardless of the v prefix", () => {
|
|
expect(compareVersions("v0.1.1", "0.1.1")).toBe(0);
|
|
});
|
|
it("orders newer above older, including across component widths", () => {
|
|
expect(compareVersions("0.1.2", "0.1.1")).toBe(1);
|
|
expect(compareVersions("0.1.1", "0.1.2")).toBe(-1);
|
|
expect(compareVersions("0.2.0", "0.1.9")).toBe(1);
|
|
expect(compareVersions("1.0.0", "0.99.99")).toBe(1);
|
|
expect(compareVersions("0.1.10", "0.1.9")).toBe(1);
|
|
});
|
|
it("treats a missing component as 0", () => {
|
|
expect(compareVersions("1", "1.0.0")).toBe(0);
|
|
expect(compareVersions("1.1", "1.0.9")).toBe(1);
|
|
});
|
|
it("a malformed tag can never look like an available upgrade", () => {
|
|
expect(compareVersions("not-a-version", "0.1.1")).toBe(-1);
|
|
expect(compareVersions("", "0.1.1")).toBe(-1);
|
|
});
|
|
it("reads each component as far as it is numeric, which is what the docblock promises", () => {
|
|
// parseInt semantics, pinned so the comment and the behaviour cannot drift apart again:
|
|
// leading digits win, and a component with none counts as 0.
|
|
expect(compareVersions("0.1.2abc", "0.1.2")).toBe(0);
|
|
expect(compareVersions("0.1.x", "0.1.0")).toBe(0);
|
|
expect(compareVersions("0.1.x", "0.1.1")).toBe(-1);
|
|
});
|
|
it("suffixes are invisible, so a pre-release compares equal to its release", () => {
|
|
// Documented limitation rather than a bug: this project publishes plain vX.Y.Z tags only.
|
|
expect(compareVersions("0.1.2-rc1", "0.1.2")).toBe(0);
|
|
expect(compareVersions("0.1.2-rc1", "0.1.1")).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("installerUrl", () => {
|
|
it("resolves the latest release when no version is pinned", () => {
|
|
expect(installerUrl()).toBe(
|
|
"https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh",
|
|
);
|
|
});
|
|
it("pins a tag, normalising the v prefix", () => {
|
|
const expected =
|
|
"https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.1.2/install.sh";
|
|
expect(installerUrl("0.1.2")).toBe(expected);
|
|
expect(installerUrl("v0.1.2")).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("release source selection", () => {
|
|
const ossOrigin = "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com";
|
|
const githubApi = "https://api.github.com/repos/Prism-Shadow/penguin-harness/releases/latest";
|
|
const manifest = {
|
|
schemaVersion: 1,
|
|
tag: "v0.2.1",
|
|
version: "0.2.1",
|
|
releaseBaseUrl: `${ossOrigin}/releases/v0.2.1`,
|
|
};
|
|
const t = getMessages("en");
|
|
|
|
it("accepts the same auto/oss/github environment contract as the installers", () => {
|
|
expect(parseDownloadSource(undefined)).toBe("auto");
|
|
expect(parseDownloadSource("auto")).toBe("auto");
|
|
expect(parseDownloadSource("oss")).toBe("oss");
|
|
expect(parseDownloadSource("github")).toBe("github");
|
|
expect(parseDownloadSource("OSS")).toBeNull();
|
|
expect(parseDownloadSource("mirror")).toBeNull();
|
|
});
|
|
|
|
it("accepts only absolute HTTPS mirror bases and removes trailing slashes", () => {
|
|
expect(normalizeHttpsBaseUrl("https://mirror.example/releases/v0.2.1/")).toBe(
|
|
"https://mirror.example/releases/v0.2.1",
|
|
);
|
|
expect(normalizeHttpsBaseUrl("http://mirror.example/releases/v0.2.1")).toBeNull();
|
|
expect(normalizeHttpsBaseUrl("/releases/v0.2.1")).toBeNull();
|
|
expect(normalizeHttpsBaseUrl(undefined)).toBeNull();
|
|
});
|
|
|
|
it("an explicit mirror is one strict installer candidate with its configured fallback", () => {
|
|
expect(
|
|
configuredInstallerCandidate(
|
|
"https://mirror.example/releases/v0.2.1",
|
|
"https://backup.example/releases/v0.2.1",
|
|
),
|
|
).toEqual({
|
|
source: "configured",
|
|
baseUrl: "https://mirror.example/releases/v0.2.1",
|
|
url: "https://mirror.example/releases/v0.2.1/install.sh",
|
|
fallbackBaseUrl: "https://backup.example/releases/v0.2.1",
|
|
});
|
|
});
|
|
|
|
it("validates latest.json's schema, tag, and fixed OSS release base", () => {
|
|
expect(parseOssLatestManifest(manifest)).toEqual({
|
|
version: "0.2.1",
|
|
tag: "v0.2.1",
|
|
discoveredFrom: "oss",
|
|
});
|
|
expect(parseOssLatestManifest({ ...manifest, schemaVersion: 2 })).toBeNull();
|
|
expect(parseOssLatestManifest({ ...manifest, tag: "../bad" })).toBeNull();
|
|
expect(
|
|
parseOssLatestManifest({ ...manifest, releaseBaseUrl: "https://example.com/v0.2.1" }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("auto discovers latest through OSS without touching GitHub when metadata is valid", async () => {
|
|
const calls: string[] = [];
|
|
const fetcher = async (url: string) => {
|
|
calls.push(url);
|
|
return new Response(JSON.stringify(manifest), { status: 200 });
|
|
};
|
|
await expect(resolveRelease("auto", undefined, t, fetcher)).resolves.toMatchObject({
|
|
tag: "v0.2.1",
|
|
discoveredFrom: "oss",
|
|
});
|
|
expect(calls).toEqual([`${ossOrigin}/latest.json`]);
|
|
});
|
|
|
|
it("auto falls back to GitHub discovery when OSS metadata is unavailable", async () => {
|
|
const calls: string[] = [];
|
|
const fetcher = async (url: string) => {
|
|
calls.push(url);
|
|
if (url === `${ossOrigin}/latest.json`) return new Response("unavailable", { status: 503 });
|
|
return new Response(JSON.stringify({ tag_name: "v0.2.2" }), { status: 200 });
|
|
};
|
|
await expect(resolveRelease("auto", undefined, t, fetcher)).resolves.toEqual({
|
|
version: "0.2.2",
|
|
tag: "v0.2.2",
|
|
discoveredFrom: "github",
|
|
});
|
|
expect(calls).toEqual([`${ossOrigin}/latest.json`, githubApi]);
|
|
});
|
|
|
|
it("forced oss is strict, while forced github skips OSS", async () => {
|
|
const ossCalls: string[] = [];
|
|
const unavailable = async (url: string) => {
|
|
ossCalls.push(url);
|
|
return new Response("unavailable", { status: 503 });
|
|
};
|
|
await expect(resolveRelease("oss", undefined, t, unavailable)).rejects.toThrow(
|
|
t.update.ossUnavailable(),
|
|
);
|
|
expect(ossCalls).toEqual([`${ossOrigin}/latest.json`]);
|
|
|
|
const githubCalls: string[] = [];
|
|
const github = async (url: string) => {
|
|
githubCalls.push(url);
|
|
return new Response(JSON.stringify({ tag_name: "v0.2.2" }), { status: 200 });
|
|
};
|
|
await expect(resolveRelease("github", undefined, t, github)).resolves.toMatchObject({
|
|
tag: "v0.2.2",
|
|
discoveredFrom: "github",
|
|
});
|
|
expect(githubCalls).toEqual([githubApi]);
|
|
});
|
|
|
|
it("a requested release skips discovery and produces same-tag source candidates", async () => {
|
|
let fetched = false;
|
|
const shouldNotFetch = async () => {
|
|
fetched = true;
|
|
throw new Error("unexpected fetch");
|
|
};
|
|
const release = await resolveRelease("auto", "0.2.0", t, shouldNotFetch);
|
|
expect(fetched).toBe(false);
|
|
expect(installerCandidates("auto", release)).toEqual([
|
|
{
|
|
source: "oss",
|
|
baseUrl: `${ossOrigin}/releases/v0.2.0`,
|
|
url: `${ossOrigin}/releases/v0.2.0/install.sh`,
|
|
fallbackBaseUrl: "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
|
|
},
|
|
{
|
|
source: "github",
|
|
baseUrl: "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
|
|
url: "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0/install.sh",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("auto stays on GitHub when OSS latest discovery failed", () => {
|
|
const release = {
|
|
version: "0.2.2",
|
|
tag: "v0.2.2",
|
|
discoveredFrom: "github" as const,
|
|
};
|
|
expect(installerCandidates("auto", release).map((candidate) => candidate.source)).toEqual([
|
|
"github",
|
|
]);
|
|
expect(installerCandidates("github", release).map((candidate) => candidate.source)).toEqual([
|
|
"github",
|
|
]);
|
|
expect(installerCandidates("oss", release).map((candidate) => candidate.source)).toEqual([
|
|
"oss",
|
|
]);
|
|
});
|
|
|
|
it("installer transport failure falls back to the matching GitHub tag", async () => {
|
|
const release = parseOssLatestManifest(manifest);
|
|
expect(release).not.toBeNull();
|
|
const candidates = installerCandidates("auto", release!);
|
|
const calls: string[] = [];
|
|
const fetcher = async (url: string) => {
|
|
calls.push(url);
|
|
return url.includes("aliyuncs.com")
|
|
? new Response("unavailable", { status: 503 })
|
|
: new Response("#!/bin/sh\n", { status: 200 });
|
|
};
|
|
const downloaded = await downloadInstaller(candidates, fetcher);
|
|
expect(downloaded?.candidate.source).toBe("github");
|
|
expect(downloaded?.script).toBe("#!/bin/sh\n");
|
|
expect(calls).toEqual(candidates.map((candidate) => candidate.url));
|
|
});
|
|
});
|
|
|
|
describe("buildInstallerInvocation (preserves the shape of the install being upgraded)", () => {
|
|
const base = {
|
|
scriptPath: "/tmp/penguin-install-1.sh",
|
|
defaultInstallDir: "/home/me/.penguin",
|
|
};
|
|
|
|
it("default dir + bundled runtime: no flags, no env", () => {
|
|
expect(
|
|
buildInstallerInvocation({
|
|
...base,
|
|
installDir: "/home/me/.penguin",
|
|
hasBundledNode: true,
|
|
}),
|
|
).toEqual({ args: ["/tmp/penguin-install-1.sh"], env: {} });
|
|
});
|
|
|
|
it("no bundled node/ means the universal package: pass --universal so the runtime is not silently added", () => {
|
|
expect(
|
|
buildInstallerInvocation({
|
|
...base,
|
|
installDir: "/home/me/.penguin",
|
|
hasBundledNode: false,
|
|
}),
|
|
).toEqual({ args: ["/tmp/penguin-install-1.sh", "--universal"], env: {} });
|
|
});
|
|
|
|
it("a non-default install dir is passed through, or the upgrade would relocate the install", () => {
|
|
expect(
|
|
buildInstallerInvocation({
|
|
...base,
|
|
installDir: "/opt/penguin",
|
|
hasBundledNode: true,
|
|
}),
|
|
).toEqual({
|
|
args: ["/tmp/penguin-install-1.sh"],
|
|
env: { PENGUIN_INSTALL_DIR: "/opt/penguin" },
|
|
});
|
|
});
|
|
|
|
it("a pinned version becomes PENGUIN_VERSION, always v-prefixed for the release tag", () => {
|
|
expect(
|
|
buildInstallerInvocation({
|
|
...base,
|
|
installDir: "/home/me/.penguin",
|
|
hasBundledNode: true,
|
|
version: "0.1.2",
|
|
}).env,
|
|
).toEqual({ PENGUIN_VERSION: "v0.1.2" });
|
|
expect(
|
|
buildInstallerInvocation({
|
|
...base,
|
|
installDir: "/home/me/.penguin",
|
|
hasBundledNode: true,
|
|
version: "v0.1.2",
|
|
}).env,
|
|
).toEqual({ PENGUIN_VERSION: "v0.1.2" });
|
|
});
|
|
|
|
it("all three at once: custom dir, universal package, pinned version", () => {
|
|
expect(
|
|
buildInstallerInvocation({
|
|
...base,
|
|
installDir: "/opt/penguin",
|
|
hasBundledNode: false,
|
|
version: "v0.2.0",
|
|
}),
|
|
).toEqual({
|
|
args: ["/tmp/penguin-install-1.sh", "--universal"],
|
|
env: { PENGUIN_INSTALL_DIR: "/opt/penguin", PENGUIN_VERSION: "v0.2.0" },
|
|
});
|
|
});
|
|
|
|
it("pins the selected payload source and same-version fallback for the child installer", () => {
|
|
expect(
|
|
buildInstallerInvocation({
|
|
...base,
|
|
installDir: "/home/me/.penguin",
|
|
hasBundledNode: true,
|
|
version: "0.2.0",
|
|
downloadBaseUrl:
|
|
"https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.2.0",
|
|
downloadFallbackBaseUrl:
|
|
"https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
|
|
}).env,
|
|
).toEqual({
|
|
PENGUIN_VERSION: "v0.2.0",
|
|
PENGUIN_DOWNLOAD_BASE_URL:
|
|
"https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.2.0",
|
|
PENGUIN_DOWNLOAD_FALLBACK_BASE_URL:
|
|
"https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
|
|
});
|
|
});
|
|
|
|
it("explicitly clears an inherited fallback when the selected source has none", () => {
|
|
expect(
|
|
buildInstallerInvocation({
|
|
...base,
|
|
installDir: "/home/me/.penguin",
|
|
hasBundledNode: true,
|
|
version: "0.2.0",
|
|
downloadBaseUrl: "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
|
|
downloadFallbackBaseUrl: "",
|
|
}).env,
|
|
).toEqual({
|
|
PENGUIN_VERSION: "v0.2.0",
|
|
PENGUIN_DOWNLOAD_BASE_URL:
|
|
"https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
|
|
PENGUIN_DOWNLOAD_FALLBACK_BASE_URL: "",
|
|
});
|
|
});
|
|
|
|
it("localizes the source list and connector in installer download failures", () => {
|
|
expect(getMessages("en").update.installerFetchFailed(["oss", "github"])).toBe(
|
|
"Could not download the installer from the OSS mirror or GitHub. Check your network and retry.",
|
|
);
|
|
expect(getMessages("zh").update.installerFetchFailed(["oss"])).toBe(
|
|
"无法从 OSS 镜像下载安装脚本。请检查网络后重试。",
|
|
);
|
|
expect(getMessages("zh").update.installerFetchFailed(["github"])).toBe(
|
|
"无法从 GitHub 下载安装脚本。请检查网络后重试。",
|
|
);
|
|
expect(getMessages("zh").update.installerFetchFailed(["oss", "github"])).toBe(
|
|
"无法从 OSS 镜像或 GitHub 下载安装脚本。请检查网络后重试。",
|
|
);
|
|
expect(getMessages("zh").update.installerFetchFailed(["configured"])).toBe(
|
|
"无法从配置的镜像下载安装脚本。请检查网络后重试。",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("planUpdate (what the command decides before it touches anything)", () => {
|
|
const base = {
|
|
current: "0.1.1",
|
|
target: "0.1.2",
|
|
modulePath: "/home/me/.penguin/lib/dist/index.js",
|
|
platform: "linux",
|
|
defaultInstallDir: "/home/me/.penguin",
|
|
};
|
|
const tarball = { kind: "tarball", installDir: "/home/me/.penguin" } as const;
|
|
const npmGlobal = { kind: "npm", globalRoot: "/usr/local/lib/node_modules" } as const;
|
|
|
|
it("--check reports the comparison and never upgrades, even when one is available", () => {
|
|
expect(planUpdate({ ...base, check: true, install: tarball })).toEqual({
|
|
action: "report",
|
|
current: "0.1.1",
|
|
target: "0.1.2",
|
|
comparison: 1,
|
|
});
|
|
});
|
|
|
|
it("--check reports a downgrade and an up-to-date install without upgrading either", () => {
|
|
expect(planUpdate({ ...base, target: "0.1.0", check: true, install: tarball })).toMatchObject({
|
|
action: "report",
|
|
comparison: -1,
|
|
});
|
|
expect(planUpdate({ ...base, target: "0.1.1", check: true, install: tarball })).toMatchObject({
|
|
action: "report",
|
|
comparison: 0,
|
|
});
|
|
});
|
|
|
|
it("--check reports even from a source checkout, where an upgrade would be refused", () => {
|
|
// Reporting changes nothing, so the layout must not gate it.
|
|
expect(planUpdate({ ...base, check: true, install: { kind: "source" } }).action).toBe("report");
|
|
});
|
|
|
|
it("an install already on the target exits without touching anything", () => {
|
|
expect(planUpdate({ ...base, target: "0.1.1", install: tarball })).toEqual({
|
|
action: "up-to-date",
|
|
current: "0.1.1",
|
|
});
|
|
// Decided before the layout matters: even an unknown install is simply up to date.
|
|
expect(planUpdate({ ...base, target: "v0.1.1", install: { kind: "unknown" } }).action).toBe(
|
|
"up-to-date",
|
|
);
|
|
});
|
|
|
|
it("a source checkout is refused, because overwriting a working tree destroys work", () => {
|
|
expect(planUpdate({ ...base, install: { kind: "source" } })).toEqual({
|
|
action: "refuse",
|
|
reason: "source",
|
|
});
|
|
});
|
|
|
|
it("an unrecognised layout is refused and names the path it was running from", () => {
|
|
expect(
|
|
planUpdate({ ...base, modulePath: "/random/place/index.js", install: { kind: "unknown" } }),
|
|
).toEqual({
|
|
action: "refuse",
|
|
reason: "unknown-install",
|
|
modulePath: "/random/place/index.js",
|
|
});
|
|
});
|
|
|
|
it("a global install upgrades through its own manager", () => {
|
|
expect(planUpdate({ ...base, install: npmGlobal })).toEqual({
|
|
action: "npm",
|
|
manager: "npm",
|
|
command: "npm",
|
|
args: ["install", "-g", "@prismshadow/penguin-cli@0.1.2"],
|
|
});
|
|
});
|
|
|
|
it("a global install with an unidentifiable manager is refused rather than guessed", () => {
|
|
expect(planUpdate({ ...base, install: { kind: "npm", globalRoot: "/weird/place" } })).toEqual({
|
|
action: "refuse",
|
|
reason: "unknown-manager",
|
|
globalRoot: "/weird/place",
|
|
target: "0.1.2",
|
|
});
|
|
});
|
|
|
|
it("a tarball install re-runs the installer at the dir it was detected in", () => {
|
|
expect(planUpdate({ ...base, install: tarball })).toEqual({
|
|
action: "tarball",
|
|
installDir: "/home/me/.penguin",
|
|
});
|
|
expect(
|
|
planUpdate({ ...base, install: { kind: "tarball", installDir: "/opt/penguin" } }),
|
|
).toEqual({ action: "tarball", installDir: "/opt/penguin" });
|
|
// No installDir on the info (shouldn't happen, but the fallback is the default install dir).
|
|
expect(planUpdate({ ...base, install: { kind: "tarball" } })).toEqual({
|
|
action: "tarball",
|
|
installDir: "/home/me/.penguin",
|
|
});
|
|
});
|
|
|
|
it("Windows is refused on the tarball path: the installer is a POSIX shell script", () => {
|
|
expect(planUpdate({ ...base, platform: "win32", install: tarball })).toEqual({
|
|
action: "refuse",
|
|
reason: "windows-installer",
|
|
});
|
|
});
|
|
|
|
it("Windows is refused on the npm path too, handing over the exact command to run", () => {
|
|
// spawn() cannot run a .cmd shim without a shell, so this must not reach the spawn and
|
|
// fail with a generic message.
|
|
expect(planUpdate({ ...base, platform: "win32", install: npmGlobal })).toEqual({
|
|
action: "refuse",
|
|
reason: "windows-global",
|
|
command: "npm install -g @prismshadow/penguin-cli@0.1.2",
|
|
});
|
|
});
|
|
|
|
it("every refusal has a message in both languages", () => {
|
|
const plans = [
|
|
planUpdate({ ...base, install: { kind: "source" } }),
|
|
planUpdate({ ...base, install: { kind: "unknown" } }),
|
|
planUpdate({ ...base, install: { kind: "npm", globalRoot: "/weird/place" } }),
|
|
planUpdate({ ...base, platform: "win32", install: tarball }),
|
|
planUpdate({ ...base, platform: "win32", install: npmGlobal }),
|
|
];
|
|
for (const lang of ["en", "zh"] as const) {
|
|
const t = getMessages(lang);
|
|
expect(plans.map((p) => p.action)).toEqual(Array(plans.length).fill("refuse"));
|
|
expect(t.update.sourceCheckout()).toBeTruthy();
|
|
expect(t.update.unknownInstall("/x")).toContain("/x");
|
|
expect(t.update.npmUnknownManager("/weird/place", "0.1.2")).toContain("/weird/place");
|
|
expect(t.update.windowsUnsupported()).toBeTruthy();
|
|
// The Windows global-install message is only useful if it carries the command verbatim.
|
|
expect(t.update.windowsGlobalInstall("pnpm add -g pkg@1")).toContain("pnpm add -g pkg@1");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("confirmationMode (the gate in front of every upgrade)", () => {
|
|
it("--yes proceeds without a prompt, interactive or not", () => {
|
|
expect(confirmationMode(true, true)).toBe("proceed");
|
|
expect(confirmationMode(true, false)).toBe("proceed");
|
|
});
|
|
it("a non-TTY without --yes demands --yes instead of hanging on a prompt nobody can answer", () => {
|
|
expect(confirmationMode(undefined, false)).toBe("needs-yes");
|
|
expect(confirmationMode(false, false)).toBe("needs-yes");
|
|
expect(getMessages("en").update.needsYes()).toContain("--yes");
|
|
expect(getMessages("zh").update.needsYes()).toContain("--yes");
|
|
});
|
|
it("a terminal without --yes gets the interactive prompt", () => {
|
|
expect(confirmationMode(undefined, true)).toBe("prompt");
|
|
});
|
|
});
|
|
|
|
describe("command registration", () => {
|
|
it("registers `update` with its three options in both languages", () => {
|
|
for (const lang of ["en", "zh"] as const) {
|
|
const program = new Command();
|
|
registerUpdateCommand(program, getMessages(lang));
|
|
const cmd = program.commands.find((c) => c.name() === "update");
|
|
expect(cmd).toBeDefined();
|
|
const flags = cmd?.options.map((o) => o.flags) ?? [];
|
|
expect(flags).toContain("--check");
|
|
expect(flags).toContain("--release <tag>");
|
|
expect(flags).toContain("-y, --yes");
|
|
expect(cmd?.description()).toBeTruthy();
|
|
}
|
|
});
|
|
});
|