327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
needsVersionPreflight,
|
|
preflightVersion,
|
|
resolveInstalledVersion,
|
|
} from "../../lib/version-update";
|
|
|
|
const updaterUrl = new URL("../../lib/version-update.ts", import.meta.url).href;
|
|
const entryUrl = new URL("../../index.ts", import.meta.url).href;
|
|
const mainUrl = new URL("../../command/main.ts", import.meta.url).href;
|
|
const contextUrl = new URL("../../lib/context.ts", import.meta.url).href;
|
|
const errorUrl = new URL("../../lib/error.ts", import.meta.url).href;
|
|
|
|
test("replayed command receives parent signals and preserves its resulting status", async () => {
|
|
const cwd = await mkdtemp(join(tmpdir(), "kuber-version-signal-"));
|
|
try {
|
|
const executable = join(cwd, "kuber");
|
|
await writeFile(
|
|
executable,
|
|
`#!${process.execPath}\nprocess.on('SIGTERM', () => process.exit(23)); console.log('ready'); setInterval(() => {}, 1000);`,
|
|
);
|
|
await chmod(executable, 0o755);
|
|
const script = `const {rerunCommand,propagateCommandExit} = await import(${JSON.stringify(updaterUrl)}); propagateCommandExit(await rerunCommand(${JSON.stringify(executable)},[]));`;
|
|
const child = Bun.spawn([process.execPath, "-e", script], {
|
|
cwd,
|
|
stdin: "ignore",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const timer = setTimeout(() => child.kill("SIGKILL"), 5_000);
|
|
try {
|
|
const reader = child.stdout.getReader();
|
|
const first = await reader.read();
|
|
expect(new TextDecoder().decode(first.value)).toContain("ready");
|
|
child.kill("SIGTERM");
|
|
expect(await child.exited).toBe(23);
|
|
expect(await new Response(child.stderr).text()).toBe("");
|
|
reader.releaseLock();
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
} finally {
|
|
await rm(cwd, { recursive: true, force: true });
|
|
}
|
|
}, 10_000);
|
|
|
|
test("packaged CLI preflights, installs and replays before importing a user config", async () => {
|
|
const cwd = await mkdtemp(join(tmpdir(), "kuber-packaged-version-"));
|
|
try {
|
|
const bundle = join(cwd, "index.js");
|
|
const built = await Bun.build({
|
|
entrypoints: [new URL("../../index.ts", import.meta.url).pathname],
|
|
target: "bun",
|
|
outdir: cwd,
|
|
});
|
|
expect(built.success).toBe(true);
|
|
const executable = join(cwd, "kuber");
|
|
await writeFile(
|
|
executable,
|
|
`#!${process.execPath}\nif (process.argv[2] === '--version') {console.log('1.0.0');} else {console.log(JSON.stringify({args:process.argv.slice(2),marker:process.env.KUBER_VERSION_REEXEC}));process.exitCode=19;}`,
|
|
);
|
|
await chmod(executable, 0o755);
|
|
const preload = join(cwd, "preload.ts");
|
|
await writeFile(
|
|
preload,
|
|
`
|
|
import {mock} from 'bun:test';
|
|
import {spawn as realSpawn} from 'node:child_process';
|
|
const spawn = realSpawn;
|
|
mock.module('node:child_process', () => ({spawn: (exe,args,options) => {
|
|
if (args.join(' ') === 'pm bin -g') return spawn(process.execPath,['-e',${JSON.stringify(`console.log(${JSON.stringify(cwd)})`)}],options);
|
|
return spawn(exe,args,options);
|
|
}}));
|
|
const originalSpawn = Bun.spawn;
|
|
Bun.spawn = (args,options) => {
|
|
if (args[1] === 'i' && args[2] === '-g') {console.error('INSTALL:' + args.at(-1)); return {exited:Promise.resolve(0),kill(){}};}
|
|
return originalSpawn(args,options);
|
|
};
|
|
globalThis.fetch = async (url,options) => {
|
|
console.error('HEALTH:' + url + ':' + options.method + ':' + !!options.headers);
|
|
return new Response('',{headers:{'X-Kuber-Version':'1.0.0'}});
|
|
};
|
|
`,
|
|
);
|
|
await writeFile(
|
|
join(cwd, "custom.ts"),
|
|
"throw Error('user config ran before replay');",
|
|
);
|
|
const child = Bun.spawn(
|
|
[
|
|
process.execPath,
|
|
"--preload",
|
|
preload,
|
|
bundle,
|
|
"--config",
|
|
"custom.ts",
|
|
"fuck",
|
|
],
|
|
{
|
|
cwd,
|
|
stdin: "ignore",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: "production",
|
|
KUBER_VERSION_REEXEC: "",
|
|
},
|
|
},
|
|
);
|
|
const [code, out, err] = await Promise.all([
|
|
child.exited,
|
|
new Response(child.stdout).text(),
|
|
new Response(child.stderr).text(),
|
|
]);
|
|
expect({ code, out, err }).toMatchObject({ code: 19 });
|
|
expect(JSON.parse(out)).toEqual({
|
|
args: ["--config", "custom.ts", "fuck"],
|
|
marker: "1",
|
|
});
|
|
expect(err).toBe(
|
|
"HEALTH:https://kuber.astrxl.dev/api/v2/health:GET:false\nINSTALL:@dmgnr/[email protected]\n\x1b[90m+ Updated to 1.0.0\x1b[0m\n",
|
|
);
|
|
} finally {
|
|
await rm(cwd, { recursive: true, force: true });
|
|
}
|
|
}, 20_000);
|
|
|
|
test("offline and informational commands skip the health check", () => {
|
|
for (const args of [
|
|
[],
|
|
["complete"],
|
|
["--version"],
|
|
["up", "-h"],
|
|
["users", "--help"],
|
|
["exec", "-v"],
|
|
["export"],
|
|
["add"],
|
|
["db", "ls"],
|
|
["s3", "ls"],
|
|
["ui"],
|
|
])
|
|
expect(needsVersionPreflight(args)).toBe(false);
|
|
for (const args of [
|
|
["login"],
|
|
["init"],
|
|
["exec"],
|
|
["ci"],
|
|
["up"],
|
|
["down"],
|
|
["trust"],
|
|
["users"],
|
|
["--config", "custom.ts", "fuck"],
|
|
])
|
|
expect(needsVersionPreflight(args)).toBe(true);
|
|
});
|
|
|
|
test("preflight has a bounded deadline even if a fetch seam ignores abort", async () => {
|
|
let signal: AbortSignal | null | undefined;
|
|
expect(
|
|
await preflightVersion(["up"], {
|
|
runner: async () => {
|
|
throw Error("unexpected install");
|
|
},
|
|
fetch: ((_url: unknown, options: RequestInit) => {
|
|
signal = options.signal;
|
|
return new Promise(() => {});
|
|
}) as typeof fetch,
|
|
timeoutMs: 5,
|
|
}),
|
|
).toBeUndefined();
|
|
expect(signal?.aborted).toBe(true);
|
|
});
|
|
|
|
test("global resolver uses Bun's configured bin and verifies the exact version", async () => {
|
|
for (const reported of [undefined, "1.0.0", "2.7.0+other", "2.7.0"]) {
|
|
const calls: string[][] = [];
|
|
const result = await resolveInstalledVersion("2.7.0", async (args) => {
|
|
calls.push(args);
|
|
return calls.length === 1 ? "/custom/global/bin" : reported;
|
|
});
|
|
expect(calls).toEqual([
|
|
[process.execPath, "pm", "bin", "-g"],
|
|
["/custom/global/bin/kuber", "--version"],
|
|
]);
|
|
expect(result).toBe(
|
|
reported === "2.7.0" ? "/custom/global/bin/kuber" : undefined,
|
|
);
|
|
}
|
|
});
|
|
|
|
test.each([
|
|
"success",
|
|
"older",
|
|
"metadata",
|
|
"failure",
|
|
"rejection",
|
|
"wrong executable",
|
|
"spawn failure",
|
|
"equal",
|
|
"invalid",
|
|
"absent",
|
|
"unavailable",
|
|
"marker",
|
|
"signal",
|
|
])(
|
|
"startup synchronization: %s",
|
|
async (scenario) => {
|
|
const cwd = await mkdtemp(join(tmpdir(), "kuber-version-process-"));
|
|
const executable = join(cwd, "kuber");
|
|
const version =
|
|
scenario === "older"
|
|
? "1.0.0"
|
|
: scenario === "metadata"
|
|
? "2.6.1+server"
|
|
: scenario === "equal"
|
|
? "2.6.1"
|
|
: scenario === "invalid"
|
|
? "v2.7.0"
|
|
: "2.7.0";
|
|
const success = ["success", "older", "metadata", "signal"].includes(
|
|
scenario,
|
|
);
|
|
try {
|
|
await writeFile(
|
|
executable,
|
|
`#!${process.execPath}\n
|
|
const { preflightVersion } = await import(${JSON.stringify(updaterUrl)});
|
|
await preflightVersion(process.argv.slice(2), { runner: async () => { throw Error('recursive install'); } });
|
|
console.log('child:' + JSON.stringify({args:process.argv.slice(2),cwd:process.cwd(),marker:process.env.KUBER_VERSION_REEXEC,path:process.env.PATH,custom:process.env.KUBER_TEST_CUSTOM}));
|
|
${scenario === "signal" ? "process.kill(process.pid, 'SIGTERM')" : "process.exitCode = 17"};
|
|
`,
|
|
);
|
|
await chmod(executable, 0o755);
|
|
const script = `
|
|
import { mock } from 'bun:test';
|
|
const original = await import(${JSON.stringify(updaterUrl)});
|
|
const preflight = original.preflightVersion;
|
|
mock.module(${JSON.stringify(updaterUrl)}, () => ({ ...original,
|
|
preflightVersion: args => preflight(args, {
|
|
currentVersion: '2.6.1',
|
|
fetch: async (url, options) => {
|
|
console.log('health:' + url + ':' + options.method + ':' + !!options.headers);
|
|
if (${JSON.stringify(scenario)} === 'unavailable') throw Error('offline');
|
|
return new Response('', {headers:${scenario === "absent" ? "{}" : `{'X-Kuber-Version':${JSON.stringify(version)}}`}});
|
|
},
|
|
runner: async version => { console.log('install:' + version); ${scenario === "rejection" ? "throw Error('failed')" : `return ${scenario !== "failure"}`}; },
|
|
resolveExecutable: async version => {console.log('verify:' + version); return ${scenario === "wrong executable" ? "undefined" : JSON.stringify(scenario === "spawn failure" ? join(cwd, "missing") : executable)};},
|
|
})
|
|
}));
|
|
mock.module(${JSON.stringify(mainUrl)}, () => ({main:{run:({rawArgs}) => {console.log('mutate:' + JSON.stringify(rawArgs)); process.exitCode = 7;}},initializeCompletion:async () => {}}));
|
|
mock.module(${JSON.stringify(contextUrl)}, () => ({provideContext:async (run, config) => {console.log('context:' + config); await run();}}));
|
|
mock.module(${JSON.stringify(errorUrl)}, () => ({formatUnknownError:String,wrapCommandErrors:() => {}}));
|
|
process.argv = [process.execPath, 'test-cli.js', '--config', 'custom.ts', 'fuck'];
|
|
await import(${JSON.stringify(entryUrl)});
|
|
`;
|
|
const child = Bun.spawn([process.execPath, "-e", script], {
|
|
cwd,
|
|
stdin: "ignore",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: {
|
|
...process.env,
|
|
KUBER_TEST_CUSTOM: "preserved",
|
|
KUBER_VERSION_REEXEC: scenario === "marker" ? "1" : "",
|
|
},
|
|
});
|
|
const [code, out, err] = await Promise.all([
|
|
child.exited,
|
|
new Response(child.stdout).text(),
|
|
new Response(child.stderr).text(),
|
|
]);
|
|
if (success) {
|
|
expect(code).toBe(scenario === "signal" ? 143 : 17);
|
|
expect(out).not.toContain("context:");
|
|
expect(out).not.toContain("mutate:");
|
|
expect(out.split("child:")).toHaveLength(2);
|
|
const payload = JSON.parse(out.split("child:")[1]!);
|
|
expect(payload).toEqual({
|
|
args: ["--config", "custom.ts", "fuck"],
|
|
cwd,
|
|
marker: "1",
|
|
path: process.env.PATH,
|
|
custom: "preserved",
|
|
});
|
|
expect(err).toBe(`\x1b[90m+ Updated to ${version}\x1b[0m\n`);
|
|
} else {
|
|
expect({ code, out, err }).toMatchObject({ code: 7 });
|
|
expect(out.split("mutate:")).toHaveLength(2);
|
|
expect(out).toContain('context:custom.ts\nmutate:["rollback"]');
|
|
expect(out).not.toContain("child:");
|
|
expect(err).toBe(
|
|
[
|
|
"failure",
|
|
"rejection",
|
|
"wrong executable",
|
|
"spawn failure",
|
|
].includes(scenario)
|
|
? `\x1b[90m+ New version available: ${version}\x1b[0m\n`
|
|
: "",
|
|
);
|
|
}
|
|
expect(out.split("install:")).toHaveLength(
|
|
success ||
|
|
[
|
|
"failure",
|
|
"rejection",
|
|
"wrong executable",
|
|
"spawn failure",
|
|
].includes(scenario)
|
|
? 2
|
|
: 1,
|
|
);
|
|
if (scenario !== "marker")
|
|
expect(out).toStartWith(
|
|
"health:https://kuber.astrxl.dev/api/v2/health:GET:false\n",
|
|
);
|
|
} finally {
|
|
await rm(cwd, { recursive: true, force: true });
|
|
}
|
|
},
|
|
10_000,
|
|
);
|