Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.4",
"version": "1.0.5",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
88 changes: 45 additions & 43 deletions packages/cli/src/__tests__/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ExecFileSyncOptions } from "node:child_process";

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import { tryCatch } from "@openrouter/spawn-shared";
Expand Down Expand Up @@ -94,12 +94,11 @@ describe("update-check", () => {
});

it("should check for updates on every run", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));

// Mock execFileSync to prevent actual update + re-exec
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => {});
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from(""));

const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
Expand All @@ -110,12 +109,11 @@ describe("update-check", () => {
});

it("should auto-update when newer version is available", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));

// Mock execFileSync to prevent actual update + re-exec
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => {});
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from(""));

const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
Expand All @@ -137,12 +135,13 @@ describe("update-check", () => {
});

it("should not update when up to date", async () => {
const mockFetch = mock(() => Promise.resolve(new Response(`${pkg.version}\n`)));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() =>
Promise.resolve(new Response(`${pkg.version}\n`)),
);

// Mock executor to prevent actual commands
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => {});
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => Buffer.from(""));

const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
Expand All @@ -156,8 +155,7 @@ describe("update-check", () => {
});

it("should handle network errors gracefully", async () => {
const mockFetch = mock(() => Promise.reject(new Error("Network error")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.reject(new Error("Network error")));

const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
Expand All @@ -169,8 +167,7 @@ describe("update-check", () => {
});

it("should handle update failures gracefully", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));

// Mock execFileSync to throw an error (curl fetch fails)
const { executor } = await import("../update-check.js");
Expand All @@ -193,14 +190,13 @@ describe("update-check", () => {
});

it("should handle bad response format", async () => {
const mockFetch = mock(() =>
const fetchSpy = spyOn(global, "fetch").mockImplementation(() =>
Promise.resolve(
new Response("Not Found", {
status: 404,
}),
),
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);

const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
Expand All @@ -212,8 +208,7 @@ describe("update-check", () => {
});

it("should redirect install script stdout to stderr when jsonOutput=true", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));

const { executor } = await import("../update-check.js");
const execFileSyncCalls: {
Expand All @@ -228,6 +223,7 @@ describe("update-check", () => {
args,
options,
});
return Buffer.from("");
},
);

Expand All @@ -249,8 +245,7 @@ describe("update-check", () => {
});

it("should use inherit stdio for install script when jsonOutput=false", async () => {
const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));

const { executor } = await import("../update-check.js");
const execFileSyncCalls: {
Expand All @@ -265,6 +260,7 @@ describe("update-check", () => {
args,
options,
});
return Buffer.from("");
},
);

Expand All @@ -289,20 +285,24 @@ describe("update-check", () => {
"sprite",
];

const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));

const { executor } = await import("../update-check.js");
const execFileSyncCalls: {
file: string;
args: string[];
options?: ExecFileSyncOptions;
}[] = [];
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation((file: string, args: string[]) => {
execFileSyncCalls.push({
file,
args,
});
});
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(
(file: string, args: string[], options?: ExecFileSyncOptions) => {
execFileSyncCalls.push({
file,
args,
options,
});
return Buffer.from("");
},
);

const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
Expand All @@ -312,7 +312,7 @@ describe("update-check", () => {
// 1. curl to fetch install script
expect(execFileSyncCalls[0].file).toBe("curl");
expect(execFileSyncCalls[0].args).toContain("-fsSL");
expect(execFileSyncCalls[0].args.some((a) => a.includes("install.sh"))).toBe(true);
expect(execFileSyncCalls[0].args.some((a: string) => a.includes("install.sh"))).toBe(true);
// 2. bash to execute fetched script
expect(execFileSyncCalls[1].file).toBe("bash");
expect(execFileSyncCalls[1].args[0]).toBe("-c");
Expand All @@ -328,13 +328,13 @@ describe("update-check", () => {
]);

// Should show rerunning message
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
const output = consoleErrorSpy.mock.calls.map((call: unknown[]) => call[0]).join("\n");
expect(output).toContain("Rerunning");

// Should set SPAWN_NO_UPDATE_CHECK=1 to prevent infinite loop
const reexecCall = execFileSyncSpy.mock.calls[3];
expect(reexecCall[2]).toHaveProperty("env");
expect(reexecCall[2].env.SPAWN_NO_UPDATE_CHECK).toBe("1");
const reexecCall = execFileSyncCalls[3];
expect(reexecCall.options).toHaveProperty("env");
expect(reexecCall.options?.env?.SPAWN_NO_UPDATE_CHECK).toBe("1");

expect(processExitSpy).toHaveBeenCalledWith(0);

Expand All @@ -352,12 +352,11 @@ describe("update-check", () => {
"sprite",
];

const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));

const { executor } = await import("../update-check.js");
let callCount = 0;
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation((file: string) => {
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation((): Buffer => {
callCount++;
// First 3 calls succeed (curl, bash, which), 4th call (re-exec) fails
if (callCount >= 4) {
Expand All @@ -367,6 +366,7 @@ describe("update-check", () => {
});
throw err;
}
return Buffer.from("");
});

const { checkForUpdates } = await import("../update-check.js");
Expand Down Expand Up @@ -397,8 +397,9 @@ describe("update-check", () => {
// Write an old timestamp (2 hours ago)
writeUpdateChecked(Date.now() - 2 * 60 * 60 * 1000);

const mockFetch = mock(() => Promise.resolve(new Response(`${pkg.version}\n`)));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() =>
Promise.resolve(new Response(`${pkg.version}\n`)),
);

const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
Expand All @@ -408,8 +409,9 @@ describe("update-check", () => {
});

it("should write cache file after successful version fetch", async () => {
const mockFetch = mock(() => Promise.resolve(new Response(`${pkg.version}\n`)));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() =>
Promise.resolve(new Response(`${pkg.version}\n`)),
);

const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
Expand All @@ -429,8 +431,7 @@ describe("update-check", () => {
"/usr/local/bin/spawn",
];

const mockFetch = mock(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));

const { executor } = await import("../update-check.js");
const execFileSyncCalls: {
Expand All @@ -442,6 +443,7 @@ describe("update-check", () => {
file,
args,
});
return Buffer.from("");
});

const { checkForUpdates } = await import("../update-check.js");
Expand All @@ -456,7 +458,7 @@ describe("update-check", () => {
expect(execFileSyncCalls[3].args).toEqual([]);

// Should show restarting message
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
const output = consoleErrorSpy.mock.calls.map((call: unknown[]) => call[0]).join("\n");
expect(output).toContain("Restarting spawn");

expect(processExitSpy).toHaveBeenCalledWith(0);
Expand Down
Loading