|
1 | 1 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; |
2 | | -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; |
| 2 | +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; |
3 | 3 | import { spawnSync } from "node:child_process"; |
4 | 4 | import { tmpdir } from "node:os"; |
5 | 5 | import { dirname, join, resolve } from "node:path"; |
6 | 6 | import { fileURLToPath } from "node:url"; |
7 | 7 |
|
8 | 8 | const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); |
9 | 9 |
|
10 | | -function runScript(codexHome: string, opencodexHome: string, script: string): { stdout: string; status: number; stderr: string } { |
| 10 | +function runScript( |
| 11 | + codexHome: string, |
| 12 | + opencodexHome: string, |
| 13 | + script: string, |
| 14 | + extraEnv: Record<string, string> = {}, |
| 15 | +): { stdout: string; status: number; stderr: string } { |
11 | 16 | const result = spawnSync(process.execPath, ["--eval", script], { |
12 | 17 | cwd: repoRoot, |
13 | | - env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome }, |
| 18 | + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, ...extraEnv }, |
14 | 19 | encoding: "utf8", |
15 | 20 | }); |
16 | 21 | return { stdout: result.stdout?.trim() ?? "", stderr: result.stderr ?? "", status: result.status ?? 1 }; |
17 | 22 | } |
18 | 23 |
|
| 24 | +function createCodexCatalogFixture(dir: string): string { |
| 25 | + const scriptPath = join(dir, "codex-catalog-fixture.js"); |
| 26 | + const bundled = JSON.stringify({ models: [nativeEntry("gpt-5.5", 0)] }); |
| 27 | + writeFileSync(scriptPath, [ |
| 28 | + 'if (process.argv.includes("--version")) {', |
| 29 | + ' console.log("codex-cli 0.999.0");', |
| 30 | + '} else {', |
| 31 | + ` process.stdout.write(${JSON.stringify(bundled)});`, |
| 32 | + '}', |
| 33 | + ].join("\n"), "utf8"); |
| 34 | + |
| 35 | + if (process.platform === "win32") { |
| 36 | + const commandPath = join(dir, "codex-catalog-fixture.cmd"); |
| 37 | + writeFileSync(commandPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`, "utf8"); |
| 38 | + return commandPath; |
| 39 | + } |
| 40 | + |
| 41 | + const commandPath = join(dir, "codex-catalog-fixture"); |
| 42 | + writeFileSync(commandPath, `#!/bin/sh\nexec "${process.execPath}" "${scriptPath}" "$@"\n`, "utf8"); |
| 43 | + chmodSync(commandPath, 0o755); |
| 44 | + return commandPath; |
| 45 | +} |
| 46 | + |
19 | 47 | function nativeEntry(slug: string, priority: number): Record<string, unknown> { |
20 | 48 | return { |
21 | 49 | slug, |
@@ -118,6 +146,35 @@ describe("Codex catalog sync hardening", () => { |
118 | 146 | expect(slugs).toContain("gpt-5.5"); |
119 | 147 | }); |
120 | 148 |
|
| 149 | + test("default catalog path merges from disk instead of replacing it with bundled rows", () => { |
| 150 | + const catalogPath = join(codexHome, "opencodex-catalog.json"); |
| 151 | + writeFileSync(join(codexHome, "config.toml"), 'openai_base_url = "http://127.0.0.1:10100/v1"\n', "utf8"); |
| 152 | + writeFileSync(catalogPath, JSON.stringify({ |
| 153 | + models: [ |
| 154 | + nativeEntry("gpt-5.5", 0), |
| 155 | + nativeEntry("user-native", 4), |
| 156 | + routedEntry("kiro/claude-opus-4.8", 5), |
| 157 | + routedEntry("opencode-go/glm-5.2", 6), |
| 158 | + ], |
| 159 | + }, null, 2) + "\n"); |
| 160 | + |
| 161 | + // Force the default-path bundled shortcut to succeed. The fixture intentionally returns only |
| 162 | + // a native row so this test fails if sync uses the bundled catalog as its merge input. |
| 163 | + const codexCliPath = createCodexCatalogFixture(opencodexHome); |
| 164 | + const r = runScript(codexHome, opencodexHome, ` |
| 165 | + const { syncCatalogModels } = require("./src/codex/catalog"); |
| 166 | + syncCatalogModels({ providers: {} }).then(res => console.log(JSON.stringify(res))); |
| 167 | + `, { CODEX_CLI_PATH: codexCliPath }); |
| 168 | + expect(r.status).toBe(0); |
| 169 | + expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries"); |
| 170 | + |
| 171 | + const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); |
| 172 | + expect(slugs).toContain("gpt-5.5"); |
| 173 | + expect(slugs).toContain("user-native"); |
| 174 | + expect(slugs).toContain("kiro/claude-opus-4.8"); |
| 175 | + expect(slugs).toContain("opencode-go/glm-5.2"); |
| 176 | + }); |
| 177 | + |
121 | 178 | test("empty routed refresh drops compatibility-excluded rows while preserving other routed entries", () => { |
122 | 179 | const catalogPath = join(codexHome, "catalog.json"); |
123 | 180 | writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); |
|
0 commit comments