Files
rea/tests/process-global/cliUpdateAlias.test.ts
T
morluto 4f7b5fb602 fix(cli): route update flag through verified updater
Normalize the unambiguous root --update alias before output handling and
through direct createCli().serve calls. Reuse the existing output-value scan
and canonical update command, including selected environment, installation
ownership, Windows-aware launch, verification, maintenance and failure facts.
Retain option data and help/check precedence. Reject remaining Incur install
requests: update:false does not disable its explicit installer in0.6.1.
Remove the superseded output-only normalizer name and update its consumers.

Validation:123focused checks/7files pass, including compiled CLI with an owned
rejecting fake npm and direct serve across5formats. Original argument owner
fails6new controls/7pass; isolated baseline fails8workflow checks/2controls
pass. Static/docs/discovery1561 pass. Both scoped source reviews are clear.
Six completed equivalent current-version workflows compare baseline canonical
update with the alias; complete replies identical. Native Windows upgrade,
real package-manager installation and full install costs remain unverified.

The first baseline escaped a Vitest-only builtin mock and installed REA in the
pinned Node global prefix. Removed the newly added package and verified original
Node/npm/Corepack tools. Re-ran using the real builtin spy with synchronized
ESM exports, an independently rejecting PATH shim and owned npm prefix/cache.
Keep heavy tests/profiling stopped. Independent updater output bounds remain
tracked in #4888 rather than changing collection policy in this routing fix.

Fixes #4883
2026-10-18 05:33:21 +08:00

167 lines
5.6 KiB
TypeScript

import childProcess from "node:child_process";
import { syncBuiltinESMExports } from "node:module";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { createCli } from "../../src/cli.js";
import { PRODUCT_IDENTITY } from "../../src/identity.js";
const mocks = vi.hoisted(() => ({
run: vi.fn(),
host: vi.fn(() => ({ owner: "selected-update-host" })),
dependencyInstall: vi.fn(() => {
throw new Error("Dependency installer must not run in this test.");
}),
}));
vi.mock("../../src/application/Update.js", async (original) => ({
...(await original<typeof import("../../src/application/Update.js")>()),
runUpdate: mocks.run,
}));
vi.mock("../../src/application/UpdateRuntime.js", () => ({
systemUpdateHost: mocks.host,
}));
const initialExitCode = process.exitCode;
const current = {
status: "current",
currentVersion: PRODUCT_IDENTITY.packageVersion,
latestVersion: PRODUCT_IDENTITY.packageVersion,
installMethod: "unknown",
};
beforeEach(async () => {
vi.clearAllMocks();
const root = await createTestTempDirectory("rea-update-api-sandbox-");
await writeFile(
join(root, process.platform === "win32" ? "npm.cmd" : "npm"),
process.platform === "win32"
? "@echo off\r\necho Test package manager refuses installation 1>&2\r\nexit /b 97\r\n"
: "#!/bin/sh\nprintf '%s\\n' 'Test package manager refuses installation' >&2\nexit 97\n",
{ mode: 0o700 },
);
vi.stubEnv("PATH", root);
vi.stubEnv("npm_config_user_agent", "npm/test");
vi.stubEnv("npm_config_prefix", join(root, "global"));
vi.stubEnv("npm_config_cache", join(root, "cache"));
// Externalized dependencies bypass Vitest module factories for Node builtins.
// Patch the real builtin and synchronize named ESM exports; PATH/prefix above
// independently prevent any real installation if this guard stops working.
vi.spyOn(childProcess, "execFile").mockImplementation(
mocks.dependencyInstall,
);
syncBuiltinESMExports();
vi.stubEnv("CI", "1");
vi.stubEnv("NO_UPDATE_NOTIFIER", "1");
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("No network in this test."))),
);
mocks.run.mockResolvedValue(current);
});
afterEach(() => {
vi.restoreAllMocks();
syncBuiltinESMExports();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
process.exitCode = initialExitCode;
});
const serve = async (
cli: ReturnType<typeof createCli>,
arguments_: string[],
) => {
process.exitCode = 0;
let stdout = "";
let exitCode = 0;
await cli.serve(arguments_, {
env: {},
stdout: (text) => {
stdout += text;
},
exit: (code) => {
exitCode = code;
},
});
return { stdout, exitCode: exitCode || Number(process.exitCode ?? 0) };
};
it.each(["json", "jsonl", "yaml", "toon", "md"])(
"uses the verified update command and selected environment through direct serve with %s output",
async (format) => {
const selected = { REA_SELECTED_UPDATE: "original" };
const cli = createCli(selected);
selected.REA_SELECTED_UPDATE = "changed";
const expected = await serve(cli, ["update", "--format", format]);
const actual = await serve(cli, ["--format", format, "--update"]);
expect(actual).toEqual(expected);
expect(actual.exitCode).toBe(0);
expect(actual.stdout).toContain(PRODUCT_IDENTITY.packageVersion);
expect(mocks.run).toHaveBeenCalledTimes(2);
expect(mocks.run).toHaveBeenLastCalledWith(
PRODUCT_IDENTITY.packageVersion,
{ owner: "selected-update-host" },
"structured",
);
expect(mocks.host).toHaveBeenLastCalledWith(undefined, undefined, {
REA_SELECTED_UPDATE: "original",
});
expect(mocks.dependencyInstall).not.toHaveBeenCalled();
},
);
it("preserves filtering, full output and unsuccessful update exit status", async () => {
const cli = createCli({});
const flags = ["--json", "--full-output", "--filter-output", "status"];
const filtered = await serve(cli, ["--update", ...flags]);
const canonical = await serve(cli, ["update", ...flags]);
expect(filtered.exitCode).toBe(canonical.exitCode);
for (const output of [filtered, canonical])
expect(JSON.parse(output.stdout)).toMatchObject({
ok: true,
data: "current",
meta: { command: expect.stringContaining("update") },
});
mocks.run.mockResolvedValue({
status: "failed",
currentVersion: PRODUCT_IDENTITY.packageVersion,
latestVersion: "99.0.0",
reason: "install",
remediation: "EACCES owned installation",
});
const failed = await serve(cli, ["--update", "--json"]);
expect(failed.exitCode).toBe(1);
expect(JSON.parse(failed.stdout)).toMatchObject({
status: "failed",
reason: "install",
remediation: "EACCES owned installation",
});
expect(mocks.dependencyInstall).not.toHaveBeenCalled();
});
it.each([
["--update", "--help"],
["--incur-update-check", "--update"],
])("preserves non-installing precedence for %j", async (...arguments_) => {
await serve(createCli({}), arguments_);
expect(mocks.run).not.toHaveBeenCalled();
expect(mocks.dependencyInstall).not.toHaveBeenCalled();
});
it("refuses ambiguous built-in installation rather than selecting another updater", async () => {
const result = await serve(createCli({}), [
"providers",
"--update",
"--json",
]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
code: "UPDATE_FAILED",
message: expect.stringContaining("rea update"),
});
expect(mocks.run).not.toHaveBeenCalled();
expect(mocks.dependencyInstall).not.toHaveBeenCalled();
});