feat(FN-3227): add startup model sync for opencode-go commands
- Add shared startup model sync helper and integrate it into dashboard, serve, and daemon command startup flows - Introduce opencode-go model sync setting in core schema/types and expose it in dashboard settings UI - Add CLI regression tests covering startup model sync behavior and command wiring - Document the new startup model sync setting and related CLI behavior updates Fusion-Task-Id: FN-3227
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
const { mockSyncStartupModels } = vi.hoisted(() => ({
|
||||
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../startup-model-sync.js", () => ({
|
||||
syncStartupModels: mockSyncStartupModels,
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
type ListenCall = {
|
||||
port: number;
|
||||
@@ -584,6 +592,11 @@ vi.mock("../task-lifecycle.js", () => ({
|
||||
const { runDaemon } = await import("../daemon.js");
|
||||
|
||||
describe("runDaemon", () => {
|
||||
it("invokes shared startup model sync", async () => {
|
||||
const { runDaemon } = await import("../daemon.js");
|
||||
await runDaemon({});
|
||||
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const originalCwd = process.cwd;
|
||||
const originalExit = process.exit;
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
|
||||
const { mockSyncStartupModels } = vi.hoisted(() => ({
|
||||
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../startup-model-sync.js", () => ({
|
||||
syncStartupModels: mockSyncStartupModels,
|
||||
}));
|
||||
|
||||
const CLI_PACKAGE_VERSION = (
|
||||
JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf-8")) as { version: string }
|
||||
).version;
|
||||
@@ -735,6 +743,13 @@ async function runDashboard(...args: Parameters<typeof runDashboardImpl>): Retur
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("runDashboard — startup model sync", () => {
|
||||
it("invokes shared startup model sync", async () => {
|
||||
await runDashboard(0, { open: false });
|
||||
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function resetGitHubMocks() {
|
||||
mockFindPrForBranch.mockReset();
|
||||
mockCreatePr.mockReset();
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
const { mockSyncStartupModels } = vi.hoisted(() => ({
|
||||
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../startup-model-sync.js", () => ({
|
||||
syncStartupModels: mockSyncStartupModels,
|
||||
}));
|
||||
|
||||
// ── Multi-project test fixtures ─────────────────────────────────────────
|
||||
//
|
||||
// Test fixtures model at least two registered projects with distinct IDs/paths
|
||||
@@ -649,6 +657,11 @@ vi.mock("../task-lifecycle.js", () => ({
|
||||
const { runServe } = await import("../serve.js");
|
||||
|
||||
describe("runServe", () => {
|
||||
it("invokes shared startup model sync", async () => {
|
||||
const { runServe } = await import("../serve.js");
|
||||
await runServe(4040, {});
|
||||
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const originalCwd = process.cwd;
|
||||
const originalOn = process.on;
|
||||
const originalExit = process.exit;
|
||||
|
||||
123
packages/cli/src/commands/__tests__/startup-model-sync.test.ts
Normal file
123
packages/cli/src/commands/__tests__/startup-model-sync.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockSpawn } = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: mockSpawn,
|
||||
}));
|
||||
|
||||
import { parseOpencodeModelsOutput, syncStartupModels } from "../startup-model-sync.js";
|
||||
|
||||
type MockProcess = EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createSpawnProcess(): MockProcess {
|
||||
const proc = new EventEmitter() as MockProcess;
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe("startup-model-sync", () => {
|
||||
beforeEach(() => {
|
||||
mockSpawn.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("syncs OpenRouter and opencode-go models", async () => {
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const proc = createSpawnProcess();
|
||||
queueMicrotask(() => {
|
||||
proc.stdout.emit("data", Buffer.from("Models cache refreshed\nopencode/gpt-5\nopencode-go/custom\n"));
|
||||
proc.emit("exit", 0);
|
||||
});
|
||||
return proc;
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: [{ id: "openai/gpt-4o", name: "GPT-4o", context_length: 128000 }],
|
||||
}),
|
||||
}));
|
||||
|
||||
const registerProvider = vi.fn();
|
||||
const log = vi.fn();
|
||||
const run = syncStartupModels({
|
||||
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: true }),
|
||||
authStorage: { getApiKey: vi.fn().mockResolvedValue("key") },
|
||||
modelRegistry: { registerProvider },
|
||||
log,
|
||||
});
|
||||
|
||||
await run;
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) }));
|
||||
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
|
||||
models: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "opencode-go/gpt-5" }),
|
||||
expect.objectContaining({ id: "opencode-go/custom" }),
|
||||
]),
|
||||
}));
|
||||
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced"));
|
||||
expect(log).toHaveBeenCalledWith("opencode-go", expect.stringContaining("Synced"));
|
||||
});
|
||||
|
||||
it("respects disabled settings", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
const registerProvider = vi.fn();
|
||||
|
||||
await syncStartupModels({
|
||||
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: false, opencodeGoModelSync: false }),
|
||||
authStorage: { getApiKey: vi.fn() },
|
||||
modelRegistry: { registerProvider },
|
||||
log: vi.fn(),
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(registerProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs failures and continues", async () => {
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const proc = createSpawnProcess();
|
||||
queueMicrotask(() => {
|
||||
proc.stderr.emit("data", Buffer.from("provider unavailable"));
|
||||
proc.emit("exit", 1);
|
||||
});
|
||||
return proc;
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
|
||||
const log = vi.fn();
|
||||
|
||||
const run = syncStartupModels({
|
||||
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: true }),
|
||||
authStorage: { getApiKey: vi.fn().mockResolvedValue(undefined) },
|
||||
modelRegistry: { registerProvider: vi.fn() },
|
||||
log,
|
||||
});
|
||||
|
||||
await run;
|
||||
|
||||
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Failed to sync models"));
|
||||
expect(log).toHaveBeenCalledWith("opencode-go", expect.stringContaining("Failed to sync models"));
|
||||
});
|
||||
|
||||
it("parses model ids from opencode CLI output", () => {
|
||||
expect(parseOpencodeModelsOutput("Models cache refreshed\nopencode/gpt-5\nfoo\nopencode-go/custom\n")).toEqual([
|
||||
"opencode/gpt-5",
|
||||
"opencode-go/custom",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user