Files
hyperframes/packages/cli/src/utils/updateCheck.test.ts
T
Miguel Ángel 693c385508 fix(cli): commands exit right away even when the network or DNS is broken (#4604)
* fix(cli): commands exit without waiting on update checks or telemetry

The update and skills checks run in a detached child that refreshes the
caches for the next run; the parent only reads the caches. finalizeCli no
longer awaits the telemetry flush: the exit handler flushSync() delivers
what is queued. The events beacon, feedback and figma error paths stop
awaiting flush() too.

* fix(cli): retry a failed background check at most once an hour

Before starting the background worker, the CLI stamps a per-check attempt
time and skips a check it attempted within the hour, so a dead network no
longer starts a worker on every command. The stale-pin notice writes onto a
fresh read, detached spawns set windowsHide, and a test ties the worker path
to its build entry.

* fix(cli): a failed-check stamp dated in the future no longer silences update checks
2026-09-28 02:22:12 +00:00

425 lines
14 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { isSafeVersion, printDeprecationNotice, withMeta } from "./updateCheck.js";
const dns = vi.hoisted(() => ({ answers: true }));
vi.mock("./hostAnswers.js", () => ({ hostAnswers: async () => dns.answers }));
describe("isSafeVersion", () => {
it("accepts strict semver, incl. prerelease/build metadata", () => {
expect(isSafeVersion("1.2.3")).toBe(true);
expect(isSafeVersion("0.7.28")).toBe(true);
expect(isSafeVersion("1.2.3-beta.1")).toBe(true);
expect(isSafeVersion("1.2.3+build.5")).toBe(true);
});
it("rejects anything that could carry shell metacharacters or isn't semver", () => {
expect(isSafeVersion("")).toBe(false);
expect(isSafeVersion("latest")).toBe(false);
expect(isSafeVersion("1.2")).toBe(false);
expect(isSafeVersion("1.2.3; rm -rf /")).toBe(false);
expect(isSafeVersion("1.2.3 && curl evil")).toBe(false);
expect(isSafeVersion("$(whoami)")).toBe(false);
});
});
/**
* Drive printUpdateNotice under controlled mocks. isDevMode() is true under
* vitest (the module path ends in .ts), which would suppress the notice, so we
* mock ./env.js. detectInstaller and readConfig are mocked to pick the branch.
*/
async function noticeWith(opts: {
installerCommand: string | null;
latestVersion?: string;
isTTY?: boolean;
env?: Record<string, string | undefined>;
}): Promise<string> {
vi.resetModules();
vi.doMock("./env.js", () => ({ isDevMode: () => false }));
vi.doMock("./installerDetection.js", () => ({
detectInstaller: () => ({
kind: opts.installerCommand ? "npm" : "skip",
installCommand: () => opts.installerCommand,
reason: "test",
}),
}));
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({ latestVersion: opts.latestVersion ?? "9.9.9" }),
writeConfig: () => {},
}));
const origEnv = { ...process.env };
for (const [k, v] of Object.entries(opts.env ?? {})) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
// Default to a non-CI interactive terminal unless the test overrides env.
for (const name of ["CI", "HYPERFRAMES_NO_UPDATE_CHECK"]) {
if (!(name in (opts.env ?? {}))) delete process.env[name];
}
const origTTY = process.stderr.isTTY;
Object.defineProperty(process.stderr, "isTTY", {
value: opts.isTTY ?? true,
configurable: true,
});
const writes: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: unknown) => {
writes.push(String(chunk));
return true;
}) as typeof process.stderr.write;
try {
const mod = await import("./updateCheck.js");
mod.printUpdateNotice();
} finally {
process.stderr.write = origWrite;
Object.defineProperty(process.stderr, "isTTY", { value: origTTY, configurable: true });
process.env = origEnv;
}
return writes.join("");
}
describe("printUpdateNotice — install-method-aware command", () => {
afterEach(() => {
vi.doUnmock("./env.js");
vi.doUnmock("./installerDetection.js");
vi.doUnmock("../telemetry/config.js");
vi.resetModules();
});
it("shows the detected manager's command for an owned global install", async () => {
const out = await noticeWith({ installerCommand: "brew upgrade hyperframes" });
expect(out).toContain("Update available");
expect(out).toContain("brew upgrade hyperframes");
expect(out).not.toContain("npx hyperframes@latest");
});
it("falls back to npx hyperframes@latest when the install method is skip/unknown", async () => {
const out = await noticeWith({ installerCommand: null });
expect(out).toContain("npx hyperframes@latest");
});
it("is suppressed on a non-TTY stderr", async () => {
const out = await noticeWith({ installerCommand: "brew upgrade hyperframes", isTTY: false });
expect(out).toBe("");
});
it("is suppressed in CI", async () => {
const out = await noticeWith({
installerCommand: "brew upgrade hyperframes",
env: { CI: "true" },
});
expect(out).toBe("");
});
it("is suppressed by the HYPERFRAMES_NO_UPDATE_CHECK opt-out", async () => {
const out = await noticeWith({
installerCommand: "brew upgrade hyperframes",
env: { HYPERFRAMES_NO_UPDATE_CHECK: "1" },
});
expect(out).toBe("");
});
});
/**
* The registry-boundary guard: a poisoned or non-string data.version must
* never be cached, because it flows into the auto-updater's install command.
* This closes the injection class for every downstream consumer at one point.
*/
async function checkWith(
registryVersion: unknown,
force = true,
): Promise<{
latest: string;
wroteVersion: string | undefined;
fetched: boolean;
}> {
vi.resetModules();
vi.doMock("./env.js", () => ({ isDevMode: () => false }));
const writes: Array<Record<string, unknown>> = [];
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({}),
readConfigFresh: () => ({}),
writeConfig: (c: Record<string, unknown>) => writes.push({ ...c }),
}));
const origFetch = globalThis.fetch;
let fetched = false;
globalThis.fetch = (async () => {
fetched = true;
return { ok: true, json: async () => ({ version: registryVersion }) };
}) as unknown as typeof fetch;
try {
const mod = await import("./updateCheck.js");
const result = await mod.checkForUpdate(force);
const lastWrite = writes.at(-1);
return {
latest: result.latest,
wroteVersion: lastWrite ? (lastWrite["latestVersion"] as string | undefined) : undefined,
fetched,
};
} finally {
globalThis.fetch = origFetch;
}
}
async function checkAcrossConcurrentConfigWrite(): Promise<Record<string, unknown>> {
vi.resetModules();
const initial = {
telemetryEnabled: true,
anonymousId: "test-install",
telemetryNoticeShown: true,
commandCount: 0,
renderSuccessCount: 0,
lastFeedbackPromptAt: 0,
};
let persisted: Record<string, unknown> = { ...initial };
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({ ...initial }),
readConfigFresh: () => ({ ...persisted }),
writeConfig: (config: Record<string, unknown>) => {
persisted = { ...config };
return true;
},
}));
const origFetch = globalThis.fetch;
let releaseResponse: (() => void) | undefined;
const responseReady = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
globalThis.fetch = (async () => {
await responseReady;
return {
ok: true,
json: async () => ({ version: "9.9.9" }),
};
}) as unknown as typeof fetch;
try {
const mod = await import("./updateCheck.js");
const check = mod.checkForUpdate(true);
persisted = { ...persisted, telemetryEnabled: false };
releaseResponse?.();
await check;
return persisted;
} finally {
globalThis.fetch = origFetch;
}
}
/**
* U5: validate/inspect/layout are deprecated in favor of `check`. withMeta's
* optional `{ deprecated: true }` is the single place that adds `_meta.deprecated`
* to a --json envelope; every other command (check, lint, ...) calls withMeta
* with no second argument and must never see the key at all — not even `false`.
*/
describe("withMeta — deprecated flag", () => {
it("omits _meta.deprecated entirely when no options are passed (check/lint et al.)", () => {
const wrapped = withMeta({ ok: true });
expect("deprecated" in wrapped._meta).toBe(false);
});
it("omits _meta.deprecated when options.deprecated is false", () => {
const wrapped = withMeta({ ok: true }, { deprecated: false });
expect("deprecated" in wrapped._meta).toBe(false);
});
it("sets _meta.deprecated === true when requested (validate/inspect/layout)", () => {
const wrapped = withMeta({ ok: true }, { deprecated: true });
expect(wrapped._meta.deprecated).toBe(true);
});
it("preserves the rest of the _meta envelope alongside the deprecated flag", () => {
const wrapped = withMeta({ ok: true }, { deprecated: true });
expect(wrapped._meta.version).toEqual(expect.any(String));
expect(typeof wrapped._meta.updateAvailable).toBe("boolean");
});
});
/**
* The stderr-only deprecation notice: printed once per invocation, never on
* stdout, so --json output stays pure JSON while humans still see the notice.
*/
describe("printDeprecationNotice", () => {
it("writes exactly one line to stderr, never stdout", () => {
const stderrWrites: string[] = [];
const stdoutWrites: string[] = [];
const origErrWrite = process.stderr.write.bind(process.stderr);
const origOutWrite = process.stdout.write.bind(process.stdout);
process.stderr.write = ((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
}) as typeof process.stderr.write;
process.stdout.write = ((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
printDeprecationNotice("validate");
} finally {
process.stderr.write = origErrWrite;
process.stdout.write = origOutWrite;
}
expect(stdoutWrites).toEqual([]);
expect(stderrWrites).toHaveLength(1);
expect(stderrWrites[0]).toContain("hyperframes validate");
expect(stderrWrites[0]).toContain("hyperframes check");
});
});
describe("checkForUpdate — registry boundary guard", () => {
afterEach(() => {
vi.doUnmock("../telemetry/config.js");
vi.doUnmock("./env.js");
vi.resetModules();
});
function clearOptOuts(): void {
vi.stubEnv("CI", "");
vi.stubEnv("HYPERFRAMES_NO_UPDATE_CHECK", "");
}
it("still asks the registry from a run without a terminal", async () => {
clearOptOuts();
const origTTY = process.stderr.isTTY;
Object.defineProperty(process.stderr, "isTTY", { value: false, configurable: true });
try {
expect((await checkWith("99.0.0", false)).fetched).toBe(true);
} finally {
Object.defineProperty(process.stderr, "isTTY", { value: origTTY, configurable: true });
vi.unstubAllEnvs();
}
});
it("skips the background registry request when DNS does not answer", async () => {
clearOptOuts();
dns.answers = false;
try {
expect((await checkWith("99.0.0", false)).fetched).toBe(false);
expect((await checkWith("99.0.0")).fetched).toBe(true);
} finally {
dns.answers = true;
vi.unstubAllEnvs();
}
});
it.each(["CI", "HYPERFRAMES_NO_UPDATE_CHECK"])(
"skips the background registry request when %s=1",
async (name) => {
clearOptOuts();
vi.stubEnv(name, "1");
try {
expect((await checkWith("99.0.0", false)).fetched).toBe(false);
expect((await checkWith("99.0.0")).fetched).toBe(true);
} finally {
vi.unstubAllEnvs();
}
},
);
it("caches and returns a valid semver from the registry", async () => {
const { latest, wroteVersion } = await checkWith("9.9.9");
expect(latest).toBe("9.9.9");
expect(wroteVersion).toBe("9.9.9");
});
it("rejects a version carrying shell metacharacters (no cache, no surface)", async () => {
const { latest, wroteVersion } = await checkWith("1.2.3; rm -rf /");
expect(latest).not.toContain(";");
expect(latest).not.toBe("1.2.3; rm -rf /");
expect(wroteVersion).toBeUndefined(); // never written to config
});
it("rejects a non-string data.version", async () => {
const { latest, wroteVersion } = await checkWith({ evil: true });
expect(typeof latest).toBe("string");
expect(wroteVersion).toBeUndefined();
});
it("leaves no timer behind when the registry request fails fast", async () => {
vi.resetModules();
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({}),
readConfigFresh: () => ({}),
writeConfig: () => {},
}));
const origFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new TypeError("fetch failed");
}) as unknown as typeof fetch;
vi.useFakeTimers();
try {
const mod = await import("./updateCheck.js");
await mod.checkForUpdate(true);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
globalThis.fetch = origFetch;
}
});
it("merges update metadata into a fresh snapshot without re-enabling telemetry", async () => {
const persisted = await checkAcrossConcurrentConfigWrite();
expect(persisted).toMatchObject({
telemetryEnabled: false,
latestVersion: "9.9.9",
});
});
});
describe("updateCheckDue / cachedUpdateCheck — what the parent reads without fetching", () => {
afterEach(() => {
vi.doUnmock("../telemetry/config.js");
vi.doUnmock("./env.js");
vi.resetModules();
vi.unstubAllEnvs();
});
async function load(config: Record<string, unknown>) {
vi.resetModules();
vi.stubEnv("CI", "");
vi.stubEnv("HYPERFRAMES_NO_UPDATE_CHECK", "");
vi.doMock("./env.js", () => ({ isDevMode: () => false }));
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({ ...config }),
readConfigFresh: () => ({ ...config }),
writeConfig: () => {},
}));
return import("./updateCheck.js");
}
const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
it.each([
["no cache", {}, true],
["a cache from an hour ago", { lastUpdateCheck: hoursAgo(1), latestVersion: "9.9.9" }, false],
["a cache from 25 hours ago", { lastUpdateCheck: hoursAgo(25), latestVersion: "9.9.9" }, true],
["an unreadable timestamp", { lastUpdateCheck: "garbage", latestVersion: "9.9.9" }, true],
[
"an unsafe cached version",
{ lastUpdateCheck: hoursAgo(1), latestVersion: "1.2.3; rm" },
true,
],
])("with %s, a check is due: %s", async (_, config, due) => {
const mod = await load(config);
expect(mod.updateCheckDue()).toBe(due);
});
it("is never due under CI", async () => {
const mod = await load({});
vi.stubEnv("CI", "1");
expect(mod.updateCheckDue()).toBe(false);
});
it("reports a cached newer version, and never surfaces an unsafe one", async () => {
expect((await load({ latestVersion: "999.0.0" })).cachedUpdateCheck()).toMatchObject({
latest: "999.0.0",
updateAvailable: true,
});
const poisoned = (await load({ latestVersion: "999.0.0; rm -rf /" })).cachedUpdateCheck();
expect(poisoned.updateAvailable).toBe(false);
expect(poisoned.latest).not.toContain(";");
});
});