mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 05:02:15 +03:00
Merge remote-tracking branch 'origin/release/v3.8.50' into fix/release-v3.8.50-basered-stream-dup-import
This commit is contained in:
@@ -595,11 +595,10 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call
|
||||
assert.equal(callLog.tokens.reasoning, 13);
|
||||
});
|
||||
|
||||
test("chat pipeline applies global Codex priority service tier inside combos", async () => {
|
||||
await seedConnection("codex", { apiKey: "sk-codex-combo-priority" });
|
||||
await settingsDb.updateSettings({
|
||||
codexServiceTier: { enabled: true, tier: "priority" },
|
||||
});
|
||||
test("chat pipeline applies Codex OAuth fingerprint and priority tier inside combos", async () => {
|
||||
setCliCompatProviders(["codex"]);
|
||||
await seedConnection("codex", { authType: "oauth", accessToken: "codex-combo-oauth-token" });
|
||||
await settingsDb.updateSettings({ codexServiceTier: { enabled: true, tier: "priority" } });
|
||||
await combosDb.createCombo({
|
||||
name: "codex-priority-combo",
|
||||
strategy: "priority",
|
||||
@@ -607,10 +606,8 @@ test("chat pipeline applies global Codex priority service tier inside combos", a
|
||||
models: ["codex/gpt-5.5"],
|
||||
});
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
globalThis.fetch = async (_url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
@@ -619,21 +616,24 @@ test("chat pipeline applies global Codex priority service tier inside combos", a
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
url: "http://localhost/v1/responses",
|
||||
headers: { "session-id": "combo-client-session" },
|
||||
body: {
|
||||
model: "codex-priority-combo",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Use Codex combo priority" }],
|
||||
input: "Use Codex combo priority",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = (await response.json()) as any;
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.object, "response");
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /\/responses$/);
|
||||
assert.equal(fetchCalls[0].headers.Authorization, "Bearer sk-codex-combo-priority");
|
||||
assert.equal(fetchCalls[0].body.service_tier, "priority");
|
||||
assert.equal(json.choices[0].message.content, "combo priority ok");
|
||||
const [call] = fetchCalls;
|
||||
assert.equal(call.headers.Authorization, "Bearer codex-combo-oauth-token");
|
||||
assert.notEqual(call.headers["session-id"], "combo-client-session");
|
||||
assert.equal(call.headers["session-id"], call.body.client_metadata.session_id);
|
||||
assert.equal(call.body.service_tier, "priority");
|
||||
});
|
||||
|
||||
test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests", async () => {
|
||||
@@ -696,7 +696,11 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests",
|
||||
call.headers["User-Agent"],
|
||||
`codex-cli/${getCodexClientVersion()} (Windows 10.0.26200; x64)`
|
||||
);
|
||||
assert.equal(call.headers["x-codex-window-id"], "conv_codex_fingerprint:0");
|
||||
// Session convergence derives a fresh session/thread id instead of passing the
|
||||
// client's raw conversation_id straight through, so the window id must be derived
|
||||
// from the (converged) request id header, not the original client-supplied literal.
|
||||
assert.notEqual(call.headers["session_id"], "conv_codex_fingerprint");
|
||||
assert.equal(call.headers["x-codex-window-id"], `${call.headers["x-client-request-id"]}:0`);
|
||||
assert.ok(call.headers["x-client-request-id"], "expected Codex request id header");
|
||||
assert.ok(call.headers["x-codex-turn-metadata"], "expected Codex turn metadata header");
|
||||
|
||||
|
||||
@@ -62,6 +62,18 @@ test("invalid JSON → invalid_json with short message + detailed error", async
|
||||
assert.equal(res.looksLikeSSE, false);
|
||||
});
|
||||
|
||||
test("valid JSON with a non-object root → invalid_json", async () => {
|
||||
for (const body of ["null", '"text"', "[]"]) {
|
||||
const res = await parseNonStreamingResponseBody({
|
||||
...baseOpts,
|
||||
providerResponse: makeResponse(body, "application/json"),
|
||||
});
|
||||
assert.equal(res.kind, "invalid_json");
|
||||
if (res.kind !== "invalid_json") continue;
|
||||
assert.match(res.detailedError, /expected an object payload/);
|
||||
}
|
||||
});
|
||||
|
||||
test("valid SSE payload (by content-type) → ok with SSE-derived format", async () => {
|
||||
const sse =
|
||||
'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}\n\n' +
|
||||
|
||||
@@ -528,6 +528,43 @@ test("chatCore honors providerSpecificData.apiType for legacy openai-compatible
|
||||
assert.equal("messages" in call.body, false);
|
||||
assert.equal(payload.choices[0].message.content, "ok");
|
||||
});
|
||||
test("chatCore translates a streaming Responses upstream for a Chat client", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "openai-compatible-sp-openai",
|
||||
model: "gpt-5.4",
|
||||
endpoint: "/v1/chat/completions",
|
||||
accept: "text/event-stream",
|
||||
credentials: {
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
apiType: "responses",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
prefix: "sp-openai",
|
||||
},
|
||||
},
|
||||
body: {
|
||||
model: "gpt-5.4",
|
||||
stream: true,
|
||||
messages: [{ role: "user", content: "Reply with OK only." }],
|
||||
},
|
||||
responseFactory: () =>
|
||||
new Response(
|
||||
[
|
||||
'data: {"type":"response.output_text.delta","delta":"ok"}',
|
||||
"",
|
||||
'data: {"type":"response.completed","response":{"id":"resp_stream","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
|
||||
"",
|
||||
].join("\n"),
|
||||
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
||||
),
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.match(call.url, /\/responses$/);
|
||||
const streamed = await result.response.text();
|
||||
assert.match(streamed, /"content":"ok"/);
|
||||
assert.match(streamed, /data: \[DONE\]/);
|
||||
});
|
||||
test("chatCore applies Responses input policy to openai-compatible targets", async () => {
|
||||
const reasoningItems = [
|
||||
{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" },
|
||||
|
||||
@@ -49,3 +49,11 @@ test("normalizeExecutorResult wraps bare Response and passes through rich result
|
||||
assert.equal(rich.url, "u");
|
||||
assert.equal(rich.headers.a, "b");
|
||||
});
|
||||
|
||||
test("normalizeExecutorResult rejects malformed executor output", () => {
|
||||
assert.throws(() => normalizeExecutorResult({}), /must contain a Response/);
|
||||
assert.throws(
|
||||
() => normalizeExecutorResult({ response: "not-a-response" }),
|
||||
/must contain a Response/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parseEnvValue } from "../../bin/cli/utils/parseEnvValue.mjs";
|
||||
|
||||
// #10100 — the .env loader kept inline comments inside values, so the shipped
|
||||
// `QUOTA_STORE_DRIVER=sqlite # sqlite | redis` line produced the literal value
|
||||
@@ -9,36 +10,6 @@ import path from "node:path";
|
||||
// user annotating `QUOTA_STORE_DRIVER=redis # ...` silently got SQLite with no
|
||||
// warning (the existing warning lives inside the `redis` branch).
|
||||
|
||||
const LOADER = path.resolve("bin/omniroute.mjs");
|
||||
|
||||
/**
|
||||
* The loader is a CLI entrypoint with side effects on import, so exercise the
|
||||
* pure helper by extracting it from source rather than importing the module.
|
||||
*/
|
||||
function loadParseEnvValue(): (raw: string) => string {
|
||||
const source = fs.readFileSync(LOADER, "utf8");
|
||||
const start = source.indexOf("function parseEnvValue(");
|
||||
assert.ok(start > -1, "parseEnvValue should exist in bin/omniroute.mjs");
|
||||
// Walk to the end of the function body.
|
||||
let depth = 0;
|
||||
let end = start;
|
||||
for (let i = source.indexOf("{", start); i < source.length; i++) {
|
||||
if (source[i] === "{") depth++;
|
||||
else if (source[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Function(`${source.slice(start, end)}; return parseEnvValue;`)() as (
|
||||
raw: string
|
||||
) => string;
|
||||
}
|
||||
|
||||
const parseEnvValue = loadParseEnvValue();
|
||||
|
||||
test("an unquoted inline comment is stripped", () => {
|
||||
assert.equal(parseEnvValue("sqlite # sqlite | redis"), "sqlite");
|
||||
assert.equal(parseEnvValue("redis # sqlite | redis"), "redis");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs, { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse } from "jsonc-parser";
|
||||
import * as generator from "../../../src/lib/cli-helper/config-generator/index.ts";
|
||||
|
||||
// The UI's HERMES_ROLES catalog (HermesAgentToolCard.tsx) is a "use client" component
|
||||
@@ -261,7 +262,7 @@ describe("config-generator", () => {
|
||||
{ role: "delegation", model: "claude-3-5-sonnet" },
|
||||
{ role: "vision", model: "gpt-4o" },
|
||||
],
|
||||
});
|
||||
} as any);
|
||||
|
||||
assert.ok(!result.error);
|
||||
assert.ok(typeof result.yaml === "string");
|
||||
@@ -291,7 +292,7 @@ describe("config-generator", () => {
|
||||
const result = await hermesAgent.generateHermesAgentConfig({
|
||||
baseUrl: "",
|
||||
selections: [{ role: "default", model: "x" }],
|
||||
} as any);
|
||||
});
|
||||
|
||||
assert.ok(result.error);
|
||||
assert.ok(result.error.includes("baseUrl"));
|
||||
@@ -513,9 +514,8 @@ describe("config-generator", () => {
|
||||
])
|
||||
);
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
const { generateOpencodeConfig } =
|
||||
await import("../../../src/lib/cli-helper/config-generator/opencode.ts");
|
||||
const out = await generateOpencodeConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
@@ -653,5 +653,70 @@ describe("config-generator", () => {
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads comments and trailing commas from opencode.jsonc and returns its real path (#10227)", async () => {
|
||||
const existingJsonc = `{
|
||||
// preserve this native OpenCode file instead of ignoring it
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"custom": {
|
||||
// keep comments inside unrelated providers too
|
||||
"name": "Custom Provider"
|
||||
},
|
||||
"omniroute": {
|
||||
"models": {
|
||||
"manual-model": { "name": "Manual", "limit": { "context": 77777, }, },
|
||||
},
|
||||
},
|
||||
},
|
||||
}\n`;
|
||||
let readPath = "";
|
||||
mock.method(fs, "existsSync", (candidate) => String(candidate).endsWith("opencode.jsonc"));
|
||||
mock.method(fs, "readFileSync", (candidate) => {
|
||||
readPath = String(candidate);
|
||||
return existingJsonc;
|
||||
});
|
||||
const stub = stubFetchOnce(
|
||||
makeCatalogResponse([{ id: "manual-model", context_length: 131072 }])
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await generator.generateConfig("opencode", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.match(result.configPath, /opencode\.jsonc$/);
|
||||
assert.strictEqual(readPath, result.configPath);
|
||||
assert.match(result.content || "", /preserve this native OpenCode file/);
|
||||
assert.match(result.content || "", /keep comments inside unrelated providers too/);
|
||||
const config = parse(result.content || "");
|
||||
assert.deepStrictEqual(config.provider.custom, { name: "Custom Provider" });
|
||||
assert.strictEqual(config.provider.omniroute.models["manual-model"].limit.context, 77777);
|
||||
} finally {
|
||||
stub.restore();
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to replace an invalid existing opencode.jsonc (#10227)", async () => {
|
||||
mock.method(fs, "existsSync", (candidate) => String(candidate).endsWith("opencode.jsonc"));
|
||||
mock.method(fs, "readFileSync", () => "{ invalid jsonc");
|
||||
const stub = stubFetchOnce(makeCatalogResponse([{ id: "catalog-model", context_length: 8 }]));
|
||||
|
||||
try {
|
||||
const result = await generator.generateConfig("opencode", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.match(result.error || "", /invalid.*JSONC|refus/i);
|
||||
} finally {
|
||||
stub.restore();
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import * as toolDetector from "../../../src/lib/cli-helper/tool-detector.ts";
|
||||
|
||||
test("detectTool reports an existing opencode.jsonc as the real config path (#10227)", async () => {
|
||||
const xdgRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-detector-jsonc-"));
|
||||
const configDir = path.join(xdgRoot, "opencode");
|
||||
const configPath = path.join(configDir, "opencode.jsonc");
|
||||
const previousXdg = process.env.XDG_CONFIG_HOME;
|
||||
|
||||
toolDetector.__setExecFileImpl(async () => ({ stdout: "v1.0.0\n", stderr: "" }));
|
||||
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
// OpenCode accepts JSONC
|
||||
"provider": {
|
||||
"omniroute": { "options": { "baseURL": "http://localhost:20128/v1" } },
|
||||
},
|
||||
}\n`
|
||||
);
|
||||
process.env.XDG_CONFIG_HOME = xdgRoot;
|
||||
|
||||
const result = await toolDetector.detectTool("opencode");
|
||||
|
||||
assert.ok(result !== null);
|
||||
assert.equal(result.configPath, configPath);
|
||||
assert.equal(result.configured, true);
|
||||
assert.match(result.configContents || "", /OpenCode accepts JSONC/);
|
||||
} finally {
|
||||
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||
else process.env.XDG_CONFIG_HOME = previousXdg;
|
||||
fs.rmSync(xdgRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -322,4 +322,38 @@ describe("resolveOpencodeConfigPath — cross-platform", () => {
|
||||
);
|
||||
assert.equal(result, path.join("D:\\xdg", "opencode", "opencode.json"));
|
||||
});
|
||||
|
||||
it("selects an existing opencode.jsonc instead of inventing opencode.json (#10227)", () => {
|
||||
const xdgRoot = createTempDir();
|
||||
const configDir = path.join(xdgRoot, "opencode");
|
||||
const jsoncPath = path.join(configDir, "opencode.jsonc");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(jsoncPath, "{\n // native OpenCode config\n}\n");
|
||||
|
||||
const result = resolveOpencodeConfigPathFn(
|
||||
process.platform,
|
||||
{ XDG_CONFIG_HOME: xdgRoot },
|
||||
os.homedir()
|
||||
);
|
||||
|
||||
assert.equal(result, jsoncPath);
|
||||
});
|
||||
|
||||
it("prefers opencode.jsonc when both native filenames exist (#10227)", () => {
|
||||
const xdgRoot = createTempDir();
|
||||
const configDir = path.join(xdgRoot, "opencode");
|
||||
const jsonPath = path.join(configDir, "opencode.json");
|
||||
const jsoncPath = path.join(configDir, "opencode.jsonc");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(jsonPath, "{}\n");
|
||||
fs.writeFileSync(jsoncPath, "{}\n");
|
||||
|
||||
const result = resolveOpencodeConfigPathFn(
|
||||
process.platform,
|
||||
{ XDG_CONFIG_HOME: xdgRoot },
|
||||
os.homedir()
|
||||
);
|
||||
|
||||
assert.equal(result, jsoncPath);
|
||||
});
|
||||
});
|
||||
|
||||
139
tests/unit/cli-tools-apply-opencode-jsonc.test.ts
Normal file
139
tests/unit/cli-tools-apply-opencode-jsonc.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SignJWT } from "jose";
|
||||
import { parse } from "jsonc-parser";
|
||||
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const databaseRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-apply-jsonc-db-"));
|
||||
process.env.DATA_DIR = databaseRoot;
|
||||
const applyRoute = await import("../../src/app/api/cli-tools/apply/route.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
const originalApiKeySecret = process.env.API_KEY_SECRET;
|
||||
const originalXdg = process.env.XDG_CONFIG_HOME;
|
||||
const testRoots = new Set<string>();
|
||||
|
||||
async function createAuthCookie(): Promise<string> {
|
||||
process.env.JWT_SECRET = "test-cli-tools-apply-secret";
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ sub: "test-user" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
return `auth_token=${token}`;
|
||||
}
|
||||
|
||||
async function postApply(): Promise<Response> {
|
||||
const cookie = await createAuthCookie();
|
||||
return applyRoute.POST(
|
||||
new Request("http://localhost/api/cli-tools/apply", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", cookie },
|
||||
body: JSON.stringify({
|
||||
toolId: "opencode",
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "catalog-model",
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-apply-jsonc-"));
|
||||
testRoots.add(root);
|
||||
process.env.XDG_CONFIG_HOME = root;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "catalog-model",
|
||||
context_length: 131072,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = originalJwtSecret;
|
||||
if (originalApiKeySecret === undefined) delete process.env.API_KEY_SECRET;
|
||||
else process.env.API_KEY_SECRET = originalApiKeySecret;
|
||||
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||
else process.env.XDG_CONFIG_HOME = originalXdg;
|
||||
for (const root of testRoots) await fs.rm(root, { recursive: true, force: true });
|
||||
testRoots.clear();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
await fs.rm(databaseRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("apply writes back to the selected opencode.jsonc and does not create opencode.json (#10227)", async () => {
|
||||
const configDir = path.join(process.env.XDG_CONFIG_HOME!, "opencode");
|
||||
const jsoncPath = path.join(configDir, "opencode.jsonc");
|
||||
const jsonPath = path.join(configDir, "opencode.json");
|
||||
const original = `{
|
||||
// this comment must survive the merge
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"custom": {
|
||||
// keep comments inside unrelated providers too
|
||||
"name": "Custom Provider"
|
||||
},
|
||||
},
|
||||
}\n`;
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(jsoncPath, original, "utf-8");
|
||||
|
||||
const response = await postApply();
|
||||
const body = (await response.json()) as {
|
||||
success?: boolean;
|
||||
configPath?: string;
|
||||
backupPath?: string;
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.success, true);
|
||||
assert.equal(body.configPath, jsoncPath);
|
||||
assert.equal(body.backupPath, path.join(configDir, ".omniroute.bak", "opencode.jsonc.bak"));
|
||||
await assert.rejects(fs.access(jsonPath));
|
||||
|
||||
const updatedText = await fs.readFile(jsoncPath, "utf-8");
|
||||
assert.match(updatedText, /this comment must survive the merge/);
|
||||
assert.match(updatedText, /keep comments inside unrelated providers too/);
|
||||
const updated = parse(updatedText);
|
||||
assert.deepEqual(updated.provider.custom, { name: "Custom Provider" });
|
||||
assert.equal(updated.provider.omniroute.models["catalog-model"].limit.context, 131072);
|
||||
assert.equal(await fs.readFile(body.backupPath!, "utf-8"), original);
|
||||
});
|
||||
|
||||
test("apply leaves an invalid opencode.jsonc untouched instead of overwriting it (#10227)", async () => {
|
||||
const configDir = path.join(process.env.XDG_CONFIG_HOME!, "opencode");
|
||||
const jsoncPath = path.join(configDir, "opencode.jsonc");
|
||||
const jsonPath = path.join(configDir, "opencode.json");
|
||||
const invalid = "{ invalid jsonc\n";
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(jsoncPath, invalid, "utf-8");
|
||||
|
||||
const response = await postApply();
|
||||
const body = (await response.json()) as { error?: string };
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(body.error || "", /invalid.*JSONC|refus/i);
|
||||
assert.equal(await fs.readFile(jsoncPath, "utf-8"), invalid);
|
||||
await assert.rejects(fs.access(jsonPath));
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parse } from "jsonc-parser";
|
||||
import {
|
||||
postProcessOpencodeConfig,
|
||||
resolveOpencodeTarget,
|
||||
@@ -50,6 +51,31 @@ test("postProcessOpencodeConfig preserves $schema, provider name and npm", () =>
|
||||
assert.equal(cfg.provider.omniroute.npm, "@ai-sdk/openai-compatible");
|
||||
});
|
||||
|
||||
test("postProcessOpencodeConfig preserves JSONC comments outside managed fields", () => {
|
||||
const rawJsonc = `{
|
||||
// top-level user comment
|
||||
"provider": {
|
||||
"custom": {
|
||||
// nested provider comment
|
||||
"npm": "@ai-sdk/custom",
|
||||
},
|
||||
"omniroute": {
|
||||
"options": { "apiKey": "sk-secret-literal" },
|
||||
"models": { "openai/gpt-4o": { "name": "GPT-4o" } },
|
||||
},
|
||||
},
|
||||
}`;
|
||||
|
||||
const { json, modelCount } = postProcessOpencodeConfig(rawJsonc);
|
||||
|
||||
assert.match(json, /\/\/ top-level user comment/);
|
||||
assert.match(json, /\/\/ nested provider comment/);
|
||||
const config = parse(json);
|
||||
assert.equal(config.provider.custom.npm, "@ai-sdk/custom");
|
||||
assert.equal(config.provider.omniroute.options.apiKey, "{env:OMNIROUTE_API_KEY}");
|
||||
assert.equal(modelCount, 1);
|
||||
});
|
||||
|
||||
test("resolveOpencodeTarget: --remote wins and trailing slashes are trimmed", () => {
|
||||
const { baseUrl } = resolveOpencodeTarget({ remote: "http://vps:20128/" });
|
||||
assert.equal(baseUrl, "http://vps:20128");
|
||||
|
||||
@@ -40,9 +40,8 @@ process.env.JWT_SECRET = "test-jwt-secret-codex-edit-6562";
|
||||
process.env.INITIAL_PASSWORD = "admin-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { createProviderConnection, getProviderConnectionById } = await import(
|
||||
"../../src/lib/db/providers.ts"
|
||||
);
|
||||
const { createProviderConnection, getProviderConnectionById } =
|
||||
await import("../../src/lib/db/providers.ts");
|
||||
const providerByIdRoute = await import("../../src/app/api/providers/[id]/route.ts");
|
||||
|
||||
function resetDb() {
|
||||
@@ -60,7 +59,11 @@ test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createCodexConnection(priority: number) {
|
||||
async function createCodexConnection(
|
||||
priority: number,
|
||||
authType = "oauth",
|
||||
providerSpecificData: Record<string, unknown> = {}
|
||||
) {
|
||||
// Mirrors createConnectionFromAuthFile()'s real Codex-import shape
|
||||
// (src/lib/oauth/utils/codexAuthImport.ts) — an OAuth connection whose
|
||||
// providerSpecificData already carries a normalized `requestDefaults`
|
||||
@@ -70,7 +73,7 @@ async function createCodexConnection(priority: number) {
|
||||
// Nth bulk-imported Codex account would already carry.
|
||||
return createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
authType,
|
||||
name: "Codex (imported)",
|
||||
email: "user@example.com",
|
||||
priority,
|
||||
@@ -85,6 +88,7 @@ async function createCodexConnection(priority: number) {
|
||||
chatgptUserId: "user-123",
|
||||
importedAt: new Date().toISOString(),
|
||||
requestDefaults: { reasoningEffort: "medium", serviceTier: "fast" },
|
||||
...providerSpecificData,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -155,6 +159,37 @@ test("PUT /api/providers/[id] persists a Codex OAuth edit when priority already
|
||||
assert.deepEqual(persistedPsd.requestDefaults, { reasoningEffort: "high" });
|
||||
});
|
||||
|
||||
test("PUT /api/providers/[id] removes fingerprint mode from Codex API-key connections", async () => {
|
||||
const connection = (await createCodexConnection(5, "apikey", {
|
||||
codexFingerprintMode: "full",
|
||||
codex_fingerprint_mode: "device",
|
||||
})) as Record<string, unknown>;
|
||||
const existingPsd = connection.providerSpecificData as Record<string, unknown>;
|
||||
assert.equal(existingPsd.codexFingerprintMode, "full");
|
||||
assert.equal(existingPsd.codex_fingerprint_mode, "device");
|
||||
|
||||
const payload = buildCodexEditPayload(connection);
|
||||
payload.providerSpecificData.codexFingerprintMode = null;
|
||||
payload.providerSpecificData.codex_fingerprint_mode = null;
|
||||
|
||||
const request = await makeManagementSessionRequest(
|
||||
`http://localhost/api/providers/${connection.id}`,
|
||||
{ method: "PUT", body: payload }
|
||||
);
|
||||
const response = await providerByIdRoute.PUT(request, {
|
||||
params: Promise.resolve({ id: connection.id as string }),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const persisted = (await getProviderConnectionById(connection.id as string)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const persistedPsd = persisted.providerSpecificData as Record<string, unknown>;
|
||||
assert.equal(persistedPsd.codexFingerprintMode, undefined);
|
||||
assert.equal(persistedPsd.codex_fingerprint_mode, undefined);
|
||||
});
|
||||
|
||||
test("PUT /api/providers/[id] still rejects a genuinely invalid priority (control)", async () => {
|
||||
const connection = (await createCodexConnection(5)) as Record<string, unknown>;
|
||||
|
||||
|
||||
373
tests/unit/codex-fingerprint-convergence.test.ts
Normal file
373
tests/unit/codex-fingerprint-convergence.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyCodexClientIdentityHeaders,
|
||||
applyCodexClientMetadata,
|
||||
applyCodexOriginalIdentityHeaders,
|
||||
createCodexClientIdentity,
|
||||
getCodexClientSessionId,
|
||||
getCodexConvergedSessionId,
|
||||
getCodexConvergedThreadId,
|
||||
getCodexFingerprintMode,
|
||||
getCodexInstallationId,
|
||||
resolveCodexFingerprintIdentity,
|
||||
resolveCodexOriginalIdentityHeaders,
|
||||
withCodexFingerprintCredentials,
|
||||
} from "../../open-sse/config/codexIdentity.ts";
|
||||
|
||||
const oauthCredentials = {
|
||||
accessToken: "oauth-token",
|
||||
connectionId: "connection-42",
|
||||
providerSpecificData: { workspaceId: "workspace-42" },
|
||||
};
|
||||
|
||||
test("Codex fingerprint mode defaults to session and only explicit off disables it", () => {
|
||||
assert.equal(getCodexFingerprintMode(undefined), "session");
|
||||
assert.equal(getCodexFingerprintMode({ codexFingerprintMode: "invalid" }), "session");
|
||||
assert.equal(getCodexFingerprintMode({ codexFingerprintMode: "off" }), "off");
|
||||
assert.equal(
|
||||
resolveCodexFingerprintIdentity({
|
||||
credentials: { ...oauthCredentials, providerSpecificData: { codexFingerprintMode: "off" } },
|
||||
clientHeaders: { "session-id": "client-session" },
|
||||
body: {},
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex off mode preserves original OAuth identity headers", () => {
|
||||
const clientHeaders = {
|
||||
"session-id": "client-session",
|
||||
"thread-id": "client-thread",
|
||||
"x-client-request-id": "client-request",
|
||||
"x-codex-window-id": "client-thread:0",
|
||||
"x-codex-turn-metadata": '{"turn_id":"client-turn"}',
|
||||
};
|
||||
const credentials = {
|
||||
...oauthCredentials,
|
||||
providerSpecificData: { codexFingerprintMode: "off" },
|
||||
};
|
||||
const original = resolveCodexOriginalIdentityHeaders({
|
||||
credentials,
|
||||
clientHeaders,
|
||||
});
|
||||
const wrapped = withCodexFingerprintCredentials(credentials, clientHeaders, {});
|
||||
assert.deepEqual(wrapped.providerSpecificData.codexOriginalIdentityHeaders, original);
|
||||
assert.equal(wrapped.providerSpecificData.codexClientIdentity, undefined);
|
||||
|
||||
const resolvedAgain = resolveCodexOriginalIdentityHeaders({
|
||||
credentials,
|
||||
clientHeaders,
|
||||
});
|
||||
assert.ok(resolvedAgain);
|
||||
|
||||
const headers: Record<string, string> = { session_id: "generated-session" };
|
||||
applyCodexOriginalIdentityHeaders(headers, resolvedAgain);
|
||||
assert.equal(headers["session-id"], "client-session");
|
||||
assert.equal(headers["thread-id"], "client-thread");
|
||||
assert.equal(headers["x-client-request-id"], "client-request");
|
||||
assert.equal(headers["x-codex-window-id"], "client-thread:0");
|
||||
assert.equal(headers["x-codex-turn-metadata"], '{"turn_id":"client-turn"}');
|
||||
assert.equal(
|
||||
resolveCodexOriginalIdentityHeaders({
|
||||
credentials: {
|
||||
...oauthCredentials,
|
||||
requestEndpointPath: "/responses/compact",
|
||||
providerSpecificData: { codexFingerprintMode: "off" },
|
||||
},
|
||||
clientHeaders: { "session-id": "compact-session" },
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex device/session/full modes preserve their Sub2API convergence boundaries", () => {
|
||||
const providerSpecificData = { workspaceId: "workspace-42" };
|
||||
const device = createCodexClientIdentity("client-session-a", providerSpecificData, {
|
||||
mode: "device",
|
||||
});
|
||||
const sessionA = createCodexClientIdentity("client-session-a", providerSpecificData, {
|
||||
mode: "session",
|
||||
accountKey: "connection-42",
|
||||
});
|
||||
const sessionB = createCodexClientIdentity("client-session-b", providerSpecificData, {
|
||||
mode: "session",
|
||||
accountKey: "connection-42",
|
||||
});
|
||||
const fullA = createCodexClientIdentity("client-session-a", providerSpecificData, {
|
||||
mode: "full",
|
||||
accountKey: "connection-42",
|
||||
});
|
||||
const fullB = createCodexClientIdentity("client-session-b", providerSpecificData, {
|
||||
mode: "full",
|
||||
accountKey: "connection-42",
|
||||
});
|
||||
|
||||
assert.ok(device && sessionA && sessionB && fullA && fullB);
|
||||
assert.equal(device.installationId, getCodexInstallationId(providerSpecificData, undefined));
|
||||
assert.notEqual(
|
||||
getCodexInstallationId({}, "connection-a"),
|
||||
getCodexInstallationId({}, "connection-b")
|
||||
);
|
||||
assert.equal(device.sessionId, "");
|
||||
assert.equal(sessionA.sessionId, sessionB.sessionId);
|
||||
assert.notEqual(sessionA.threadId, sessionB.threadId);
|
||||
assert.equal(sessionA.windowId, `${sessionA.threadId}:0`);
|
||||
assert.equal(fullA.sessionId, fullB.sessionId);
|
||||
assert.equal(fullA.threadId, fullA.sessionId);
|
||||
assert.equal(fullA.threadId, fullB.threadId);
|
||||
assert.notEqual(sessionA.turnId, sessionB.turnId);
|
||||
assert.equal(
|
||||
getCodexConvergedSessionId(providerSpecificData, "connection-42"),
|
||||
sessionA.sessionId
|
||||
);
|
||||
assert.equal(
|
||||
getCodexConvergedThreadId("client-session-a", providerSpecificData, "connection-42"),
|
||||
sessionA.threadId
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex client session extraction prefers hyphenated session-id and rejects unsafe values", () => {
|
||||
assert.equal(
|
||||
getCodexClientSessionId({ "session-id": "hyphen", session_id: "underscore" }),
|
||||
"hyphen"
|
||||
);
|
||||
assert.equal(getCodexClientSessionId({ session_id: "underscore" }), "underscore");
|
||||
assert.equal(getCodexClientSessionId({ "session-id": "bad\\r\\nheader" }), null);
|
||||
});
|
||||
|
||||
test("One Codex identity is shared by headers, body metadata, and nested turn metadata", () => {
|
||||
const identity = resolveCodexFingerprintIdentity({
|
||||
credentials: oauthCredentials,
|
||||
clientHeaders: { "session-id": "client-session" },
|
||||
body: {},
|
||||
});
|
||||
assert.ok(identity);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
applyCodexClientIdentityHeaders(headers, identity);
|
||||
const body: Record<string, unknown> = {
|
||||
client_metadata: { "x-codex-turn-metadata": '{"sandbox":"none"}' },
|
||||
};
|
||||
applyCodexClientMetadata(body, identity);
|
||||
|
||||
const headerMetadata = JSON.parse(headers["x-codex-turn-metadata"]);
|
||||
const clientMetadata = body.client_metadata as Record<string, unknown>;
|
||||
const bodyMetadata = JSON.parse(clientMetadata["x-codex-turn-metadata"] as string);
|
||||
|
||||
assert.equal(headers["session-id"], clientMetadata.session_id);
|
||||
assert.equal(headers["thread-id"], clientMetadata.thread_id);
|
||||
assert.equal(headers["x-codex-window-id"], clientMetadata["x-codex-window-id"]);
|
||||
assert.equal(identity.installationId, headers["x-codex-installation-id"]);
|
||||
assert.equal(identity.turnId, clientMetadata.turn_id);
|
||||
assert.equal(identity.turnId, headerMetadata.turn_id);
|
||||
assert.equal(identity.turnId, bodyMetadata.turn_id);
|
||||
assert.equal(bodyMetadata.sandbox, "none");
|
||||
});
|
||||
|
||||
test("Codex compact requests do not resolve a fingerprint identity", () => {
|
||||
assert.equal(
|
||||
resolveCodexFingerprintIdentity({
|
||||
credentials: {
|
||||
...oauthCredentials,
|
||||
requestEndpointPath: "/responses/compact",
|
||||
},
|
||||
clientHeaders: { "session-id": "client-session" },
|
||||
body: {},
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex HTTP off mode preserves original identity headers and body metadata", async () => {
|
||||
const { CodexExecutor } = await import("../../open-sse/executors/codex.ts");
|
||||
const executor = new CodexExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let upstreamHeaders = new Headers();
|
||||
let upstreamBody: Record<string, unknown> = {};
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
upstreamHeaders = new Headers(init?.headers);
|
||||
upstreamBody = JSON.parse(String(init?.body || "{}"));
|
||||
return new Response(JSON.stringify({ id: "resp-off", object: "response" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await executor.execute({
|
||||
model: "gpt-5.5",
|
||||
body: {
|
||||
model: "gpt-5.5",
|
||||
input: [{ role: "user", content: "hello" }],
|
||||
client_metadata: {
|
||||
session_id: "client-session",
|
||||
thread_id: "client-thread",
|
||||
turn_id: "client-turn",
|
||||
"x-codex-window-id": "client-thread:0",
|
||||
"x-codex-turn-metadata": '{"turn_id":"client-turn"}',
|
||||
},
|
||||
_nativeCodexPassthrough: true,
|
||||
},
|
||||
stream: true,
|
||||
clientHeaders: {
|
||||
"session-id": "client-session",
|
||||
"thread-id": "client-thread",
|
||||
"x-client-request-id": "client-request",
|
||||
"x-codex-window-id": "client-thread:0",
|
||||
"x-codex-turn-metadata": '{"turn_id":"client-turn"}',
|
||||
},
|
||||
credentials: {
|
||||
accessToken: "codex-token",
|
||||
connectionId: "conn-http-off",
|
||||
providerSpecificData: { workspaceId: "http-off", codexFingerprintMode: "off" },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
const metadata = upstreamBody.client_metadata as Record<string, unknown>;
|
||||
assert.equal(upstreamHeaders.get("session-id"), "client-session");
|
||||
assert.equal(upstreamHeaders.get("thread-id"), "client-thread");
|
||||
assert.equal(upstreamHeaders.get("x-client-request-id"), "client-request");
|
||||
assert.equal(upstreamHeaders.get("x-codex-window-id"), "client-thread:0");
|
||||
assert.equal(upstreamHeaders.get("x-codex-turn-metadata"), '{"turn_id":"client-turn"}');
|
||||
assert.equal(metadata.session_id, "client-session");
|
||||
assert.equal(metadata.thread_id, "client-thread");
|
||||
assert.equal(metadata.turn_id, "client-turn");
|
||||
assert.equal(metadata["x-codex-window-id"], "client-thread:0");
|
||||
assert.equal(metadata["x-codex-turn-metadata"], '{"turn_id":"client-turn"}');
|
||||
});
|
||||
|
||||
test("Codex websocket off mode preserves original identity headers and body metadata", async () => {
|
||||
const { CodexExecutor, __setCodexWebSocketTransportForTesting } =
|
||||
await import("../../open-sse/executors/codex.ts");
|
||||
const executor = new CodexExecutor();
|
||||
let sent: string | null = null;
|
||||
let wsHeaders: Record<string, string> = {};
|
||||
__setCodexWebSocketTransportForTesting(async (_url, opts) => {
|
||||
wsHeaders = (opts?.headers as Record<string, string>) || {};
|
||||
return {
|
||||
send(data: string) {
|
||||
sent = data;
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: { status: "completed" },
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
close() {},
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
onclose: null,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-5.5",
|
||||
body: {
|
||||
model: "gpt-5.5",
|
||||
input: [{ role: "user", content: "hello" }],
|
||||
client_metadata: {
|
||||
session_id: "client-session",
|
||||
thread_id: "client-thread",
|
||||
turn_id: "client-turn",
|
||||
},
|
||||
},
|
||||
stream: true,
|
||||
clientHeaders: {
|
||||
"session-id": "client-session",
|
||||
"thread-id": "client-thread",
|
||||
"x-client-request-id": "client-request",
|
||||
"x-codex-window-id": "client-thread:0",
|
||||
"x-codex-turn-metadata": '{"turn_id":"client-turn"}',
|
||||
},
|
||||
credentials: {
|
||||
accessToken: "codex-token",
|
||||
connectionId: "conn-ws-off",
|
||||
providerSpecificData: {
|
||||
workspaceId: "ws-off",
|
||||
codexTransport: "websocket",
|
||||
codexFingerprintMode: "off",
|
||||
},
|
||||
},
|
||||
});
|
||||
await result.response.text();
|
||||
} finally {
|
||||
__setCodexWebSocketTransportForTesting(undefined);
|
||||
}
|
||||
|
||||
assert.ok(sent);
|
||||
const payload = JSON.parse(sent as string) as Record<string, unknown>;
|
||||
const metadata = payload.client_metadata as Record<string, unknown>;
|
||||
assert.equal(wsHeaders["session-id"], "client-session");
|
||||
assert.equal(wsHeaders["thread-id"], "client-thread");
|
||||
assert.equal(wsHeaders["x-client-request-id"], "client-request");
|
||||
assert.equal(wsHeaders["x-codex-window-id"], "client-thread:0");
|
||||
assert.equal(wsHeaders["x-codex-turn-metadata"], '{"turn_id":"client-turn"}');
|
||||
assert.equal(metadata.session_id, "client-session");
|
||||
assert.equal(metadata.thread_id, "client-thread");
|
||||
assert.equal(metadata.turn_id, "client-turn");
|
||||
});
|
||||
|
||||
test("Codex websocket headers and payload share one fingerprint identity", async () => {
|
||||
const { CodexExecutor, __setCodexWebSocketTransportForTesting } =
|
||||
await import("../../open-sse/executors/codex.ts");
|
||||
const executor = new CodexExecutor();
|
||||
let sent: string | null = null;
|
||||
let wsHeaders: Record<string, string> = {};
|
||||
__setCodexWebSocketTransportForTesting(async (_url, opts) => {
|
||||
wsHeaders = (opts?.headers as Record<string, string>) || {};
|
||||
return {
|
||||
send(data: string) {
|
||||
sent = data;
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: { status: "completed" },
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
close() {},
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
onclose: null,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-5.5",
|
||||
body: {
|
||||
model: "gpt-5.5",
|
||||
session_id: "client-ws",
|
||||
input: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
stream: true,
|
||||
credentials: {
|
||||
accessToken: "codex-token",
|
||||
connectionId: "conn-ws",
|
||||
providerSpecificData: { workspaceId: "ws-ws", codexTransport: "websocket" },
|
||||
},
|
||||
});
|
||||
await result.response.text();
|
||||
} finally {
|
||||
__setCodexWebSocketTransportForTesting(undefined);
|
||||
}
|
||||
|
||||
assert.ok(sent);
|
||||
const payload = JSON.parse(sent as string) as Record<string, unknown>;
|
||||
const metadata = (payload.client_metadata as Record<string, unknown>) || {};
|
||||
assert.equal(wsHeaders.session_id, metadata.session_id);
|
||||
assert.equal(wsHeaders["x-client-request-id"], metadata.thread_id);
|
||||
assert.equal(wsHeaders["x-codex-window-id"], metadata["x-codex-window-id"]);
|
||||
assert.equal(payload.type, "response.create");
|
||||
});
|
||||
59
tests/unit/codex-responses-ws-fingerprint.test.ts
Normal file
59
tests/unit/codex-responses-ws-fingerprint.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-ws-fingerprint-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.APP_LOG_TO_FILE = "false";
|
||||
process.env.OMNIROUTE_WS_BRIDGE_SECRET = "bridge-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { createProviderConnection } = await import("../../src/lib/db/providers.ts");
|
||||
const { POST } = await import("../../src/app/api/internal/codex-responses-ws/route.ts");
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(resetDb);
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Codex internal websocket bridge prepare preserves original OAuth identity in off mode", async () => {
|
||||
await createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
name: "Codex WS off",
|
||||
accessToken: "oauth-token",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: { codexFingerprintMode: "off" },
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://omniroute.local/api/internal/codex-responses-ws", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-omniroute-ws-bridge-secret": "bridge-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "prepare",
|
||||
requestUrl: "http://omniroute.local/v1/responses",
|
||||
headers: { "session-id": "client-session", "thread-id": "client-thread" },
|
||||
response: { model: "codex/gpt-5.5", input: "hello" },
|
||||
}),
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200, JSON.stringify(body));
|
||||
assert.equal(body.headers["session-id"], "client-session");
|
||||
assert.equal(body.headers["thread-id"], "client-thread");
|
||||
});
|
||||
@@ -103,6 +103,7 @@ test("combo builder options route aggregates providers, connections, models and
|
||||
});
|
||||
|
||||
await modelsDb.addCustomModel("openai", "custom-ops", "Custom Ops");
|
||||
await modelsDb.addCustomModel("openai", "gpt-4.1", "Operator GPT");
|
||||
// #6975: embeddings-only models (supportedEndpoints without "chat") are no longer
|
||||
// dropped from the combo builder — they must appear like any other model.
|
||||
await modelsDb.addCustomModel(
|
||||
@@ -142,9 +143,12 @@ test("combo builder options route aggregates providers, connections, models and
|
||||
assert.equal(openai.displayName, "OpenAI");
|
||||
assert.equal(openai.connectionCount, 2);
|
||||
assert.equal(openai.activeConnectionCount, 1);
|
||||
assert.ok(openai.models.some((model) => model.id === "gpt-4.1"));
|
||||
assert.equal(openai.models.find((model) => model.id === "gpt-4.1").outputTokenLimit, 32768);
|
||||
assert.equal(openai.models.find((model) => model.id === "gpt-4.1").supportsThinking, false);
|
||||
const sharedModel = openai.models.find((model) => model.id === "gpt-4.1");
|
||||
assert.ok(sharedModel);
|
||||
assert.equal(sharedModel.name, "Operator GPT");
|
||||
assert.equal(sharedModel.contextLength, 1047576);
|
||||
assert.equal(sharedModel.outputTokenLimit, 32768);
|
||||
assert.equal(sharedModel.supportsThinking, false);
|
||||
assert.equal(
|
||||
openai.models.some((model) => model.id === "gpt-4o-mini"),
|
||||
false
|
||||
|
||||
@@ -76,4 +76,31 @@ describe("mergeProviderModelListing (cursor exclusive)", () => {
|
||||
const ids = models.map((m) => m.id);
|
||||
assert.deepEqual(ids, ["gpt-4o", "gpt-4o-mini"]);
|
||||
});
|
||||
|
||||
it("overlays same-id custom metadata without erasing discovered fields", () => {
|
||||
const models = mergeProviderModelListing({
|
||||
providerId: "openai-compatible-chat-test",
|
||||
registryModels: [],
|
||||
syncedModels: [
|
||||
{
|
||||
id: "shared",
|
||||
name: "Discovered name",
|
||||
supportsVision: true,
|
||||
contextLength: 128000,
|
||||
},
|
||||
],
|
||||
customModels: [{ id: "shared", name: "Operator name", supportsVision: false }],
|
||||
usesCuratedModelsOnly: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(models, [
|
||||
{
|
||||
id: "shared",
|
||||
name: "Operator name",
|
||||
source: "custom",
|
||||
supportsVision: false,
|
||||
contextLength: 128000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,7 +108,6 @@ test("host db/models.ts preserves its full public API after the split", async ()
|
||||
"getModelPreserveOpenAIDeveloperRole",
|
||||
"getModelIsHidden",
|
||||
"getHiddenModelsByProvider",
|
||||
"getModelIsDeleted",
|
||||
"setModelIsHidden",
|
||||
"getModelUpstreamExtraHeaders",
|
||||
]) {
|
||||
|
||||
@@ -89,7 +89,7 @@ test("replace invalidates only for canonical persisted changes", async () => {
|
||||
assert.equal(catalogVersion(), beforeIdenticalVersion + 2, "content changes are persisted");
|
||||
});
|
||||
|
||||
test("replace handles empty and deleted-model normalization without phantom writes", async () => {
|
||||
test("replace handles empty and normalized models without phantom writes", async () => {
|
||||
const startVersion = catalogVersion();
|
||||
const startChanges = totalChanges();
|
||||
|
||||
@@ -97,18 +97,15 @@ test("replace handles empty and deleted-model normalization without phantom writ
|
||||
assert.equal(totalChanges(), startChanges, "absent-to-empty replacement must be a no-op");
|
||||
assert.equal(catalogVersion(), startVersion);
|
||||
|
||||
models.mergeModelCompatOverride("openai", "trashed", {
|
||||
isDeleted: true,
|
||||
isHidden: true,
|
||||
});
|
||||
|
||||
const beforeDeletedAbsent = totalChanges();
|
||||
const beforeDeletedVersion = catalogVersion();
|
||||
await models.replaceSyncedAvailableModelsForConnection("openai", "absent", [
|
||||
{ id: "trashed", name: "Trashed" },
|
||||
{ id: "restored", name: "Restored" },
|
||||
]);
|
||||
assert.equal(totalChanges(), beforeDeletedAbsent, "filtered deleted models must stay absent");
|
||||
assert.equal(catalogVersion(), beforeDeletedVersion);
|
||||
assert.deepEqual(
|
||||
(await models.getSyncedAvailableModelsForConnection("openai", "absent")).map(
|
||||
(model) => model.id
|
||||
),
|
||||
["restored"]
|
||||
);
|
||||
|
||||
seedSynced("openai", "present", [{ id: "existing", name: "Existing" }]);
|
||||
const beforeDeleteVersion = catalogVersion();
|
||||
|
||||
@@ -871,34 +871,6 @@ test("CodexExecutor.transformRequest passes GPT 5.6 Luna xhigh reasoning through
|
||||
assert.equal(sanitized.reasoning_effort, undefined);
|
||||
});
|
||||
|
||||
test("CodexExecutor.transformRequest merges Codex installation metadata", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const result = executor.transformRequest(
|
||||
"gpt-5.5",
|
||||
{
|
||||
model: "gpt-5.5",
|
||||
input: [],
|
||||
client_metadata: { existing: "keep" },
|
||||
},
|
||||
true,
|
||||
{
|
||||
providerSpecificData: {
|
||||
codexClientIdentity: {
|
||||
sessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
windowId: "session-1:0",
|
||||
installationId: "11111111-1111-4111-a111-111111111111",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(result.client_metadata, {
|
||||
existing: "keep",
|
||||
"x-codex-installation-id": "11111111-1111-4111-a111-111111111111",
|
||||
});
|
||||
});
|
||||
|
||||
test("CodexExecutor.transformRequest omits client metadata for compact requests", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const result = executor.transformRequest(
|
||||
@@ -1043,20 +1015,25 @@ test("CodexExecutor.execute adds CLI-like session identity headers without chang
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(capturedHeaders?.get("session_id"), "conversation-1");
|
||||
assert.equal(capturedHeaders?.get("x-client-request-id"), "conversation-1");
|
||||
assert.equal(capturedHeaders?.get("x-codex-window-id"), "conversation-1:0");
|
||||
const meta = (capturedBody?.client_metadata as Record<string, unknown>) || {};
|
||||
const turnMetadata = JSON.parse(capturedHeaders?.get("x-codex-turn-metadata") || "{}");
|
||||
assert.equal(turnMetadata.session_id, "conversation-1");
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.notEqual(capturedHeaders?.get("session_id"), "conversation-1");
|
||||
assert.equal(capturedHeaders?.get("session-id"), capturedHeaders?.get("session_id"));
|
||||
assert.equal(capturedHeaders?.get("thread-id"), capturedHeaders?.get("x-client-request-id"));
|
||||
assert.equal(
|
||||
capturedHeaders?.get("x-codex-window-id"),
|
||||
`${capturedHeaders?.get("x-client-request-id")}:0`
|
||||
);
|
||||
assert.equal(turnMetadata.session_id, capturedHeaders?.get("session_id"));
|
||||
assert.equal(turnMetadata.thread_id, capturedHeaders?.get("thread-id"));
|
||||
assert.equal(turnMetadata.turn_id, meta.turn_id);
|
||||
assert.equal(turnMetadata.window_id, capturedHeaders?.get("x-codex-window-id"));
|
||||
assert.equal(turnMetadata.thread_source, "user");
|
||||
assert.equal(turnMetadata.sandbox, "none");
|
||||
assert.equal(typeof turnMetadata.turn_id, "string");
|
||||
assert.equal(capturedBody?.prompt_cache_key, "conversation-1");
|
||||
assert.equal(
|
||||
(capturedBody?.client_metadata as Record<string, unknown>)?.["x-codex-installation-id"],
|
||||
"7f06a8ee-2981-4c81-a4ca-e443b5400a63"
|
||||
);
|
||||
assert.equal(meta["x-codex-installation-id"], "7f06a8ee-2981-4c81-a4ca-e443b5400a63");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
@@ -1087,9 +1064,11 @@ test("CodexExecutor.execute skips identity headers for unsafe session ids", asyn
|
||||
credentials: { accessToken: "codex-token" },
|
||||
});
|
||||
|
||||
assert.equal(capturedHeaders?.get("x-client-request-id"), null);
|
||||
assert.equal(capturedHeaders?.get("x-codex-window-id"), null);
|
||||
assert.equal(capturedHeaders?.get("x-codex-turn-metadata"), null);
|
||||
assert.notEqual(capturedHeaders?.get("session_id"), "bad\r\nheader");
|
||||
assert.ok(capturedHeaders?.get("session_id"));
|
||||
assert.ok(capturedHeaders?.get("x-client-request-id"));
|
||||
assert.ok(capturedHeaders?.get("x-codex-window-id"));
|
||||
assert.ok(capturedHeaders?.get("x-codex-turn-metadata"));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -48,8 +48,22 @@ for (const { entry, id, alias, chatUrl, modelsUrl } of providers) {
|
||||
assert.equal(entry.modelsUrl, modelsUrl);
|
||||
assert.equal(entry.passthroughModels, true);
|
||||
});
|
||||
}
|
||||
|
||||
// zylo-api and unorouter rely on the live catalog with no static seed (#9085).
|
||||
for (const { entry, id } of providers.filter((p) => p.id !== "poolside")) {
|
||||
test(`${id} relies on its live catalog without invented static model ids`, () => {
|
||||
assert.deepEqual(entry.models, []);
|
||||
});
|
||||
}
|
||||
|
||||
// Poolside ships the two authenticated-probe models (#10216) as static seeds —
|
||||
// they are the exact IDs the live catalog returns (authenticated probe 2026-08-07,
|
||||
// #9085), not invented. Assert them explicitly so a future catalog change is a
|
||||
// deliberate update, not a silent drift.
|
||||
test("poolside ships the probed Laguna Preview models, not invented ids", () => {
|
||||
const models = poolsideProvider.models;
|
||||
assert.ok(Array.isArray(models) && models.length > 0, "poolside should seed its probed catalog");
|
||||
const ids = models.map((m) => m.id);
|
||||
assert.deepEqual(ids, ["poolside/laguna-xs-2.1", "poolside/laguna-s-2.1"]);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const guideSettingsRoute =
|
||||
|
||||
const DUMMY_HOME = path.join(os.tmpdir(), "omniroute-guide-settings-test-" + Date.now());
|
||||
const OPENCODE_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "opencode", "opencode.json");
|
||||
const OPENCODE_JSONC_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "opencode", "opencode.jsonc");
|
||||
// cliRuntime.ts hermes entry maps to .config/hermes/config.json (not .hermes/config.yaml)
|
||||
const HERMES_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "hermes", "config.json");
|
||||
const originalXDG = process.env.XDG_CONFIG_HOME;
|
||||
@@ -201,3 +202,24 @@ test("guide-settings POST preserves existing OpenCode config fields while only u
|
||||
"opencode-go/kimi-k2.6": { name: "Kimi K2.6" },
|
||||
});
|
||||
});
|
||||
|
||||
test("guide-settings POST refuses to overwrite an invalid opencode.jsonc (#10227)", async () => {
|
||||
const invalidJsonc = "{ invalid jsonc\n";
|
||||
await fs.mkdir(path.dirname(OPENCODE_JSONC_CONFIG_PATH), { recursive: true });
|
||||
await fs.writeFile(OPENCODE_JSONC_CONFIG_PATH, invalidJsonc, "utf-8");
|
||||
|
||||
const req = await buildRequest("opencode", {
|
||||
baseUrl: "http://my-omni/v1",
|
||||
apiKey: "sk-123",
|
||||
models: ["cx/gpt-5.6-sol"],
|
||||
});
|
||||
const response = (await guideSettingsRoute.POST(req, {
|
||||
params: { toolId: "opencode" },
|
||||
})) as Response;
|
||||
const data = (await response.json()) as { error?: string };
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.match(data.error || "", /invalid JSONC.*refusing to overwrite/i);
|
||||
assert.equal(await fs.readFile(OPENCODE_JSONC_CONFIG_PATH, "utf-8"), invalidJsonc);
|
||||
await assert.rejects(fs.access(OPENCODE_CONFIG_PATH));
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ const capabilityOverrides = await import("../../src/lib/db/modelCapabilityOverri
|
||||
const models = await import("../../src/lib/db/models.ts");
|
||||
const providers = await import("../../src/lib/db/providers.ts");
|
||||
const catalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
const overrideRoute = await import("../../src/app/api/model-capability-overrides/route.ts");
|
||||
|
||||
const TARGET = "openai/gpt-5.6";
|
||||
const LIMITS = { context: 372000, input: 353400, output: 128000 };
|
||||
@@ -162,3 +163,109 @@ test("v1 model catalog projects an exact raw-alias context override", async () =
|
||||
"the catalog entry keeps its raw alias and must project that exact override"
|
||||
);
|
||||
});
|
||||
|
||||
test("v1 model catalog projects a compatible provider prefix override stored under its node id", async () => {
|
||||
const nodeId = "openai-compatible-chat-context-override";
|
||||
const prefix = "wawapi-openai";
|
||||
const modelId = "grok-4.6";
|
||||
const contextWindow = 500000;
|
||||
await providers.createProviderNode({
|
||||
id: nodeId,
|
||||
type: "openai-compatible",
|
||||
prefix,
|
||||
name: "WawAPI (OpenAI)",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/v1",
|
||||
});
|
||||
const connection = await providers.createProviderConnection({
|
||||
provider: nodeId,
|
||||
authType: "api_key",
|
||||
name: "compatible-provider-token-limit-catalog",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
assert.equal(typeof connection.id, "string");
|
||||
await models.replaceSyncedAvailableModelsForConnection(nodeId, connection.id as string, [
|
||||
{ id: modelId, name: modelId, source: "imported" },
|
||||
]);
|
||||
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${prefix}/${modelId}`,
|
||||
key: "context_length",
|
||||
value: contextWindow,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
assert.equal(
|
||||
contextOverrides.getModelContextOverrideRecord(nodeId, modelId)?.realContext,
|
||||
contextWindow,
|
||||
"the public prefix target must persist under the internal provider node id"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(await getModel(`${prefix}/${modelId}`))?.context_length,
|
||||
contextWindow,
|
||||
"the public catalog row must read the override stored under the internal provider node id"
|
||||
);
|
||||
});
|
||||
|
||||
test("v1 model catalog overlays same-id custom metadata before final overrides", async () => {
|
||||
const providerId = "openai-compatible-chat-custom-precedence";
|
||||
const prefix = "custom-precedence";
|
||||
const modelId = "shared-model";
|
||||
await providers.createProviderNode({
|
||||
id: providerId,
|
||||
type: "openai-compatible",
|
||||
prefix,
|
||||
name: "Custom Precedence",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/v1",
|
||||
});
|
||||
const connection = await providers.createProviderConnection({
|
||||
provider: providerId,
|
||||
authType: "api_key",
|
||||
name: "custom-precedence-catalog",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
await models.replaceSyncedAvailableModelsForConnection(providerId, connection.id as string, [
|
||||
{
|
||||
id: modelId,
|
||||
name: "Discovered name",
|
||||
source: "imported",
|
||||
inputTokenLimit: 128000,
|
||||
supportsVision: true,
|
||||
},
|
||||
]);
|
||||
await models.addCustomModel(
|
||||
providerId,
|
||||
modelId,
|
||||
"Operator name",
|
||||
"manual",
|
||||
"chat-completions",
|
||||
["chat"],
|
||||
undefined,
|
||||
{ outputTokenLimit: 32000 },
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
capabilityOverrides.setModelCapabilityOverride(
|
||||
`${prefix}/${modelId}`,
|
||||
"max_output_tokens",
|
||||
64000
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(contextOverrides.setModelContextOverride(providerId, modelId, 500000), true);
|
||||
|
||||
const projected = await getModel(`${prefix}/${modelId}`);
|
||||
assert.ok(projected);
|
||||
assert.equal(projected.name, "Operator name");
|
||||
assert.equal(projected.context_length, 500000);
|
||||
assert.equal(projected.max_output_tokens, 64000);
|
||||
assert.equal((projected.capabilities as Record<string, unknown>)?.vision, false);
|
||||
assert.deepEqual(projected.input_modalities, ["text"]);
|
||||
});
|
||||
|
||||
@@ -201,6 +201,7 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball",
|
||||
"bin/cli/data-dir.mjs",
|
||||
"bin/cli/program.mjs",
|
||||
"bin/cli/utils/ensureAndroidCacheDir.mjs",
|
||||
"bin/cli/utils/parseEnvValue.mjs",
|
||||
"bin/cli/utils/storageKeyProvision.mjs",
|
||||
"bin/cli/utils/versionFastPath.mjs",
|
||||
"bin/mcp-server.mjs",
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isValidProviderIconUrl } from "../../src/shared/validation/iconUrl.ts";
|
||||
import {
|
||||
isValidProviderIconUrl,
|
||||
MAX_ICON_DATA_URL_LENGTH,
|
||||
MAX_ICON_URL_LENGTH,
|
||||
} from "../../src/shared/validation/iconUrl.ts";
|
||||
import {
|
||||
createProviderNodeSchema,
|
||||
updateProviderNodeSchema,
|
||||
@@ -19,7 +23,7 @@ const VALID_SVG_DATA_URL =
|
||||
const VALID_XICON_DATA_URL = "data:image/x-icon;base64,QUJDRA==";
|
||||
const VALID_JPEG_DATA_URL = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
|
||||
const VALID_HTTP = "https://example.com/logo.png";
|
||||
const VALID_HTTP_2000 = "https://example.com/" + "a".repeat(1970) + ".png";
|
||||
const VALID_HTTP_2000 = "https://example.com/" + "a".repeat(1976) + ".png";
|
||||
|
||||
// ---- shared validator ----
|
||||
test("isValidProviderIconUrl accepts empty and http(s)", () => {
|
||||
@@ -220,3 +224,84 @@ test("updateProviderNodeSchema accepts a valid data:image/*;base64 iconUrl", ()
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
function dataIconUrlNearLength(maxLength: number): string {
|
||||
const prefix = "data:image/png;base64,";
|
||||
const payloadLength = maxLength - prefix.length;
|
||||
const aligned = payloadLength - (payloadLength % 4);
|
||||
return prefix + "A".repeat(aligned);
|
||||
}
|
||||
|
||||
function dataIconUrlOverLimit(maxLength: number): string {
|
||||
const atLimit = dataIconUrlNearLength(maxLength);
|
||||
return atLimit + "AAAA";
|
||||
}
|
||||
|
||||
test("create/update schemas accept a data:image iconUrl up to the shared 256 KiB cap", () => {
|
||||
const iconUrl = dataIconUrlNearLength(MAX_ICON_DATA_URL_LENGTH);
|
||||
assert.ok(iconUrl.length > MAX_ICON_URL_LENGTH);
|
||||
assert.ok(iconUrl.length <= MAX_ICON_DATA_URL_LENGTH);
|
||||
assert.equal(isValidProviderIconUrl(iconUrl), true);
|
||||
|
||||
const created = createProviderNodeSchema.safeParse({
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
apiType: "chat",
|
||||
iconUrl,
|
||||
});
|
||||
assert.equal(created.success, true, created.success ? "" : JSON.stringify(created.error.issues));
|
||||
|
||||
const updated = updateProviderNodeSchema.safeParse({
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
baseUrl: "https://test.com",
|
||||
iconUrl,
|
||||
});
|
||||
assert.equal(updated.success, true, updated.success ? "" : JSON.stringify(updated.error.issues));
|
||||
});
|
||||
|
||||
test("create/update schemas reject an over-limit data:image iconUrl", () => {
|
||||
const iconUrl = dataIconUrlOverLimit(MAX_ICON_DATA_URL_LENGTH);
|
||||
assert.ok(iconUrl.length > MAX_ICON_DATA_URL_LENGTH);
|
||||
assert.ok(iconUrl.length <= MAX_ICON_DATA_URL_LENGTH + 4);
|
||||
assert.equal(isValidProviderIconUrl(iconUrl), false);
|
||||
|
||||
const created = createProviderNodeSchema.safeParse({
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
apiType: "chat",
|
||||
iconUrl,
|
||||
});
|
||||
assert.equal(created.success, false);
|
||||
|
||||
const updated = updateProviderNodeSchema.safeParse({
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
baseUrl: "https://test.com",
|
||||
iconUrl,
|
||||
});
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
test("create/update schemas keep the 2000-char cap for http(s) iconUrl", () => {
|
||||
const tooLongHttp = VALID_HTTP + "a".repeat(MAX_ICON_URL_LENGTH);
|
||||
assert.ok(tooLongHttp.length > MAX_ICON_URL_LENGTH);
|
||||
assert.equal(
|
||||
createProviderNodeSchema.safeParse({
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
apiType: "chat",
|
||||
iconUrl: VALID_HTTP_2000,
|
||||
}).success,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
createProviderNodeSchema.safeParse({
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
apiType: "chat",
|
||||
iconUrl: tooLongHttp,
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
@@ -288,7 +288,7 @@ test("hidden provider models are filtered from per-model quota rows", () => {
|
||||
});
|
||||
const hidden = providerLimitUtils.collectHiddenQuotaModelIds("antigravity", {
|
||||
models: [{ id: "antigravity/gpt-oss-120b-medium", isHidden: true }],
|
||||
modelCompatOverrides: [{ id: "gemini-3.5-flash", isDeleted: true }],
|
||||
modelCompatOverrides: [{ id: "gemini-3.5-flash", isHidden: true }],
|
||||
});
|
||||
const visible = providerLimitUtils.filterHiddenModelQuotas("antigravity", quotas, hidden);
|
||||
|
||||
|
||||
@@ -99,6 +99,35 @@ test("per-connection models route includes user-added custom models on the local
|
||||
assert.equal(custom.owned_by, "aimlapi", "custom model must be stamped owned_by = provider");
|
||||
});
|
||||
|
||||
test("per-connection models route overlays same-id custom metadata", async () => {
|
||||
const connection = await seedConnection("aimlapi", { apiKey: "aiml-key" });
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("aimlapi", connection.id, [
|
||||
{ id: "shared-model", name: "Discovered name", supportsVision: true },
|
||||
]);
|
||||
await modelsDb.addCustomModel(
|
||||
"aimlapi",
|
||||
"shared-model",
|
||||
"Operator name",
|
||||
"manual",
|
||||
"chat-completions",
|
||||
["chat"],
|
||||
undefined,
|
||||
{},
|
||||
false
|
||||
);
|
||||
globalThis.fetch = (async () => new Response("upstream down", { status: 500 })) as typeof fetch;
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as {
|
||||
models?: Array<{ id: string; name?: string; supportsVision?: boolean }>;
|
||||
};
|
||||
const model = body.models?.find((entry) => entry.id === "shared-model");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(model?.name, "Operator name");
|
||||
assert.equal(model?.supportsVision, false);
|
||||
});
|
||||
|
||||
test("per-connection models route can exclude response-only custom models for sync", async () => {
|
||||
const connection = await seedConnection("aimlapi", { apiKey: "aiml-key" });
|
||||
|
||||
|
||||
@@ -129,6 +129,58 @@ test("provider nodes route creates an OpenAI-compatible node with iconUrl", asyn
|
||||
assert.equal(body.node.iconUrl, "https://cdn.example.com/icons/custom.png");
|
||||
});
|
||||
|
||||
const LONG_DATA_ICON_URL = "data:image/png;base64," + "A".repeat(2500);
|
||||
|
||||
type ProviderNodeResponse = {
|
||||
node?: { id?: string; iconUrl?: string | null };
|
||||
error?: { message?: string; details?: unknown };
|
||||
};
|
||||
|
||||
test("provider nodes route creates a node with a data:image iconUrl longer than 2000 chars", async () => {
|
||||
const response = await providerNodesRoute.POST(
|
||||
makeRequest({
|
||||
name: "Data Icon Node",
|
||||
prefix: "data-icon",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://dataicon.example.com/v1",
|
||||
iconUrl: LONG_DATA_ICON_URL,
|
||||
})
|
||||
);
|
||||
const body = (await response.json()) as ProviderNodeResponse;
|
||||
|
||||
assert.equal(response.status, 201, JSON.stringify(body));
|
||||
assert.equal(body.node?.iconUrl, LONG_DATA_ICON_URL);
|
||||
});
|
||||
|
||||
test("provider nodes route update accepts a data:image iconUrl longer than 2000 chars", async () => {
|
||||
const createResponse = await providerNodesRoute.POST(
|
||||
makeRequest({
|
||||
name: "Data Icon Update Node",
|
||||
prefix: "data-icon-update",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://dataicon-update.example.com/v1",
|
||||
})
|
||||
);
|
||||
const created = (await createResponse.json()) as ProviderNodeResponse;
|
||||
const nodeId = created.node?.id;
|
||||
assert.ok(nodeId);
|
||||
|
||||
const updateResponse = await providerNodesIdRoute.PUT(
|
||||
makeUpdateRequest(nodeId, {
|
||||
name: "Data Icon Update Node",
|
||||
prefix: "data-icon-update",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://dataicon-update.example.com/v1",
|
||||
iconUrl: LONG_DATA_ICON_URL,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: nodeId }) }
|
||||
);
|
||||
const updated = (await updateResponse.json()) as ProviderNodeResponse;
|
||||
|
||||
assert.equal(updateResponse.status, 200, JSON.stringify(updated));
|
||||
assert.equal(updated.node?.iconUrl, LONG_DATA_ICON_URL);
|
||||
});
|
||||
|
||||
test("provider nodes route creates nodes without iconUrl (null)", async () => {
|
||||
const response = await providerNodesRoute.POST(
|
||||
makeRequest({
|
||||
|
||||
@@ -163,6 +163,27 @@ test("provider schemas accept max but reject ultra as a server-side Codex defaul
|
||||
assert.equal(ultra.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas accept Codex fingerprint modes and reject unknown values", () => {
|
||||
for (const mode of ["off", "device", "session", "full"]) {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "codex",
|
||||
apiKey: "token",
|
||||
name: "Codex",
|
||||
providerSpecificData: { codexFingerprintMode: mode },
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: { codexFingerprintMode: mode },
|
||||
});
|
||||
assert.equal(created.success, true, mode);
|
||||
assert.equal(updated.success, true, mode);
|
||||
}
|
||||
|
||||
const rejected = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: { codexFingerprintMode: "aggressive" },
|
||||
});
|
||||
assert.equal(rejected.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas reject unknown Codex service tiers", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "codex",
|
||||
|
||||
@@ -109,4 +109,6 @@ test("wasRefreshTokenRotated: true only when the DB token changed to a non-empty
|
||||
assert.equal(wasRefreshTokenRotated("rt-old", ""), false, "empty DB token = deactivate");
|
||||
assert.equal(wasRefreshTokenRotated(null, "rt-new"), false, "unknown attempted = deactivate");
|
||||
assert.equal(wasRefreshTokenRotated(undefined, undefined), false);
|
||||
assert.equal(wasRefreshTokenRotated("rt-old", { token: "rt-new" }), false);
|
||||
assert.equal(wasRefreshTokenRotated(42, "rt-new"), false);
|
||||
});
|
||||
|
||||
@@ -117,6 +117,13 @@ test("extractApiKey parses bearer headers and isValidApiKey validates persisted
|
||||
assert.equal(await auth.isValidApiKey(""), false);
|
||||
});
|
||||
|
||||
test("getProviderCredentials identifies synthetic no-auth credentials", async () => {
|
||||
const credentials = await auth.getProviderCredentials("opencode");
|
||||
|
||||
assert.equal(credentials?.connectionId, "noauth");
|
||||
assert.equal(credentials?.authType, "none");
|
||||
});
|
||||
|
||||
test("getProviderCredentials reports rate limiting when only inactive suppressed records remain", async () => {
|
||||
const retryAfter = futureIso();
|
||||
await seedConnection("openai", {
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
/**
|
||||
* Deleting a CUSTOM model must not tombstone a same-id SYNCED model.
|
||||
* Model deletion is physical, while persistent exclusion uses isHidden.
|
||||
*
|
||||
* Reported flow (provider `deepseek`, models `deepseek-v4-flash` / `deepseek-v4-pro`):
|
||||
* 1. Eye-hide both synced models -> `isHidden:true` (no `isDeleted`)
|
||||
* 2. Manually ADD custom models with the SAME ids
|
||||
* 3. DELETE the custom models just created
|
||||
* 4. The ORIGINAL synced models are gone too, yet the UI still lists them
|
||||
*
|
||||
* Step 3 is the bug. `DELETE /api/provider-models` resolves `provider` + `model`
|
||||
* only — it has no notion of WHICH of the two same-id rows the operator clicked.
|
||||
* It unconditionally runs both removals and then, because the synced removal
|
||||
* reports `true`, writes the `isDeleted:true` tombstone. From then on
|
||||
* `replaceSyncedAvailableModelsForConnection` filters the id out of every
|
||||
* re-import (`getModelIsDeleted`), so the provider can never resync: the sync
|
||||
* endpoint keeps reporting `added: N` while the catalog stays empty, and
|
||||
* `/v1/models` never lists the models again.
|
||||
*
|
||||
* The eye-hide in step 1 is what makes this reachable in practice — a hidden
|
||||
* model stays in the synced store (#3782), so the id exists in BOTH stores at
|
||||
* the same time and one DELETE hits both.
|
||||
*
|
||||
* Guards: A = deleting a custom model leaves a same-id synced sibling intact and
|
||||
* re-importable; B = deleting a synced-only model still tombstones (#3199 must
|
||||
* not regress).
|
||||
* A custom and synced row can share one id. Deleting that id prefers the custom
|
||||
* row and leaves its synced sibling intact. Deleting a synced-only row removes
|
||||
* the current discovery result, but a later upstream sync may restore it.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -30,11 +11,8 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Hermetic DB: this test writes into the `customModels`, `syncedAvailableModels`
|
||||
// and `modelCompatOverrides` namespaces. Without an isolated DATA_DIR it would
|
||||
// leak that state into the shared dev/CI database, so a SECOND run would see
|
||||
// stale tombstones and the preconditions would fail. Point DATA_DIR at a
|
||||
// throwaway dir before any import that opens the SQLite handle.
|
||||
// Hermetic DB: this test writes into the customModels and
|
||||
// syncedAvailableModels namespaces.
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-delete-sibling-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
// The DELETE route is auth-gated; with no INITIAL_PASSWORD and no stored
|
||||
@@ -70,17 +48,12 @@ async function deleteProviderModel(provider: string, modelId: string) {
|
||||
test("A: deleting a custom model leaves a same-id synced sibling re-importable", async () => {
|
||||
core.resetDbInstance();
|
||||
|
||||
// Step 1 — provider sync brings both models in; operator eye-hides one.
|
||||
// Provider sync brings both models in; operator eye-hides one.
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(PROVIDER, CONNECTION, [
|
||||
{ id: FLASH, name: FLASH },
|
||||
{ id: PRO, name: PRO },
|
||||
]);
|
||||
modelsDb.mergeModelCompatOverride(PROVIDER, FLASH, { isHidden: true });
|
||||
assert.equal(
|
||||
modelsDb.getModelIsDeleted(PROVIDER, FLASH),
|
||||
false,
|
||||
"precondition: eye-hide must not mark the model deleted"
|
||||
);
|
||||
|
||||
// Step 2 — operator manually adds a custom model with the SAME id.
|
||||
await modelsDb.addCustomModel(PROVIDER, FLASH, FLASH);
|
||||
@@ -88,11 +61,12 @@ test("A: deleting a custom model leaves a same-id synced sibling re-importable",
|
||||
// Step 3 — operator deletes the custom model they just created.
|
||||
await deleteProviderModel(PROVIDER, FLASH);
|
||||
|
||||
// Step 4 — the synced sibling must NOT have been tombstoned.
|
||||
assert.equal(
|
||||
modelsDb.getModelIsDeleted(PROVIDER, FLASH),
|
||||
false,
|
||||
"deleting the custom model must not write an isDeleted tombstone for the synced sibling"
|
||||
// The synced sibling remains present; deleting the custom definition does not
|
||||
// reinterpret the operation as a synced-model deletion.
|
||||
assert.ok(
|
||||
(await modelsDb.getSyncedAvailableModels(PROVIDER)).some(
|
||||
(model: { id: string }) => model.id === FLASH
|
||||
)
|
||||
);
|
||||
|
||||
// The decisive assertion: a later re-sync must bring the model back.
|
||||
@@ -107,29 +81,28 @@ test("A: deleting a custom model leaves a same-id synced sibling re-importable",
|
||||
);
|
||||
});
|
||||
|
||||
test("B: deleting a synced-only model still tombstones it (#3199 must not regress)", async () => {
|
||||
test("B: deleting a synced-only model is temporary and re-sync restores it", async () => {
|
||||
core.resetDbInstance();
|
||||
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(PROVIDER, CONNECTION, [
|
||||
{ id: PRO, name: PRO },
|
||||
]);
|
||||
assert.equal(modelsDb.getModelIsDeleted(PROVIDER, PRO), false, "precondition: not yet deleted");
|
||||
|
||||
// No custom row exists for this id — this is a real trash/delete.
|
||||
await deleteProviderModel(PROVIDER, PRO);
|
||||
|
||||
assert.equal(
|
||||
modelsDb.getModelIsDeleted(PROVIDER, PRO),
|
||||
true,
|
||||
"a synced-only delete must still write the isDeleted tombstone"
|
||||
const result = await deleteProviderModel(PROVIDER, PRO);
|
||||
assert.equal(result.removed, true);
|
||||
assert.ok(
|
||||
!(await modelsDb.getSyncedAvailableModels(PROVIDER)).some(
|
||||
(model: { id: string }) => model.id === PRO
|
||||
)
|
||||
);
|
||||
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(PROVIDER, CONNECTION, [
|
||||
{ id: PRO, name: PRO },
|
||||
]);
|
||||
const ids = (await modelsDb.getSyncedAvailableModels(PROVIDER)).map((m: { id: string }) => m.id);
|
||||
assert.ok(
|
||||
!ids.includes(PRO),
|
||||
`a deleted model must stay dropped across re-import; got [${ids.join(", ")}]`
|
||||
(await modelsDb.getSyncedAvailableModels(PROVIDER)).some(
|
||||
(model: { id: string }) => model.id === PRO
|
||||
),
|
||||
"upstream re-sync restores a physically deleted synced model"
|
||||
);
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* #3199 follow-up to #3204 — a deleted synced (fetched) model must STAY deleted
|
||||
* across an auto-fetch re-import.
|
||||
*
|
||||
* #3204 added `removeSyncedAvailableModel`, but the DELETE route did not mark the
|
||||
* model deleted and `replaceSyncedAvailableModelsForConnection` did not skip
|
||||
* deleted ids — so the next `/models` sync re-imported the model and it
|
||||
* reappeared. This test guards that a deleted id is filtered out on re-import.
|
||||
*
|
||||
* #3782 update: the delete marker is now the DISTINCT `isDeleted` flag (the route
|
||||
* sets `isDeleted` + `isHidden`), separate from the EYE/visibility toggle which
|
||||
* sets `isHidden` only and must be preserved across re-syncs. The sync filter
|
||||
* keys on `isDeleted`, so this test now simulates the route via `isDeleted`.
|
||||
*/
|
||||
import test, { before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// Hermetic DB: this test marks a model hidden (an override persisted in the
|
||||
// `modelCompatOverrides` key_value namespace). Without an isolated DATA_DIR it
|
||||
// would write that override into the shared dev/CI database and never clean it
|
||||
// up, so the SECOND run would see `model-del` already hidden and the first-sync
|
||||
// precondition would fail. Point DATA_DIR at a throwaway dir before any import
|
||||
// that opens the SQLite connection.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-synced-del-"));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
const {
|
||||
replaceSyncedAvailableModelsForConnection,
|
||||
getSyncedAvailableModels,
|
||||
mergeModelCompatOverride,
|
||||
} = await import("../../src/lib/localDb.ts");
|
||||
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
|
||||
before(() => {
|
||||
resetDbInstance();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
resetDbInstance();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a hidden (deleted) synced model is not re-added on re-import", async () => {
|
||||
const provider = "llama-cpp";
|
||||
const connectionId = "conn-3199";
|
||||
|
||||
// Initial sync brings in two models.
|
||||
await replaceSyncedAvailableModelsForConnection(provider, connectionId, [
|
||||
{ id: "model-keep", name: "Keep" },
|
||||
{ id: "model-del", name: "Delete me" },
|
||||
]);
|
||||
let synced = (await getSyncedAvailableModels(provider)).map((m) => m.id);
|
||||
assert.ok(synced.includes("model-del"), "both models present after first sync");
|
||||
|
||||
// Operator deletes model-del → the DELETE route marks it deleted.
|
||||
// #3782: the route now writes the DISTINCT `isDeleted` marker (plus `isHidden`
|
||||
// for back-compat) instead of bare `isHidden`, so an eye/visibility-hidden
|
||||
// model — which sets `isHidden` only — is preserved across a re-sync while a
|
||||
// genuinely-deleted one stays dropped. The sync filter keys on `isDeleted`.
|
||||
mergeModelCompatOverride(provider, "model-del", { isDeleted: true, isHidden: true });
|
||||
|
||||
// Auto-fetch re-imports the SAME upstream list (still advertising model-del).
|
||||
await replaceSyncedAvailableModelsForConnection(provider, connectionId, [
|
||||
{ id: "model-keep", name: "Keep" },
|
||||
{ id: "model-del", name: "Delete me" },
|
||||
]);
|
||||
|
||||
synced = (await getSyncedAvailableModels(provider)).map((m) => m.id);
|
||||
assert.ok(synced.includes("model-keep"), "non-deleted model stays");
|
||||
assert.ok(
|
||||
!synced.includes("model-del"),
|
||||
"deleted (hidden) model must NOT be re-added by the re-import"
|
||||
);
|
||||
});
|
||||
55
tests/unit/synced-model-delete-resync.test.ts
Normal file
55
tests/unit/synced-model-delete-resync.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* A synced model delete removes the current discovery row only. If the upstream
|
||||
* provider advertises that model again, the next sync restores it. Persistent
|
||||
* exclusion is represented by isHidden, not a separate deletion tombstone.
|
||||
*/
|
||||
import test, { before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// Hermetic DB: this test mutates the syncedAvailableModels key_value namespace.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-synced-del-"));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
const { replaceSyncedAvailableModelsForConnection, getSyncedAvailableModels } =
|
||||
await import("../../src/lib/localDb.ts");
|
||||
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
|
||||
before(() => {
|
||||
resetDbInstance();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
resetDbInstance();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a deleted synced model is restored when upstream advertises it again", async () => {
|
||||
const provider = "llama-cpp";
|
||||
const connectionId = "conn-3199";
|
||||
|
||||
// Initial sync brings in two models.
|
||||
await replaceSyncedAvailableModelsForConnection(provider, connectionId, [
|
||||
{ id: "model-keep", name: "Keep" },
|
||||
{ id: "model-del", name: "Delete me" },
|
||||
]);
|
||||
let synced = (await getSyncedAvailableModels(provider)).map((m) => m.id);
|
||||
assert.ok(synced.includes("model-del"), "both models present after first sync");
|
||||
|
||||
const { removeSyncedAvailableModel } = await import("../../src/lib/localDb.ts");
|
||||
assert.equal(await removeSyncedAvailableModel(provider, "model-del"), true);
|
||||
synced = (await getSyncedAvailableModels(provider)).map((m) => m.id);
|
||||
assert.ok(!synced.includes("model-del"), "delete removes the current synced row");
|
||||
|
||||
// Auto-fetch re-imports the SAME upstream list (still advertising model-del).
|
||||
await replaceSyncedAvailableModelsForConnection(provider, connectionId, [
|
||||
{ id: "model-keep", name: "Keep" },
|
||||
{ id: "model-del", name: "Delete me" },
|
||||
]);
|
||||
|
||||
synced = (await getSyncedAvailableModels(provider)).map((m) => m.id);
|
||||
assert.ok(synced.includes("model-keep"), "unrelated model stays");
|
||||
assert.ok(synced.includes("model-del"), "upstream re-sync restores the deleted model");
|
||||
});
|
||||
@@ -1,22 +1,9 @@
|
||||
/**
|
||||
* #3782 — "Auto Sync Enabling all Models".
|
||||
*
|
||||
* The user hides models with the EYE/visibility toggle (writes `isHidden:true`
|
||||
* via `mergeModelCompatOverride`) to keep only their combo's models. Before this
|
||||
* fix, `replaceSyncedAvailableModelsForConnection` dropped EVERY hidden id on a
|
||||
* re-sync (it could not tell an eye-hidden model from a DELETED one), so the
|
||||
* model fell out of the synced store and then churned back through the managed
|
||||
* alias path — the reported "all models turn back on".
|
||||
*
|
||||
* The fix separates the two signals:
|
||||
* - DELETE (trash) marks `isDeleted:true` (+ keeps `isHidden:true` for back-compat).
|
||||
* - The EYE toggle sets only `isHidden:true`.
|
||||
* - The sync filter drops a model only when it is DELETED, so eye-hidden models
|
||||
* stay listed-but-hidden across re-syncs.
|
||||
*
|
||||
* This test guards that an eye-hidden model survives re-import (Test A), that a
|
||||
* genuinely-new model defaults to visible (Test B), and that the DELETE path
|
||||
* still drops on re-import (Test C — mirrors the #3199 delete flow).
|
||||
* The eye toggle persists isHidden independently of synced discovery data.
|
||||
* Re-sync therefore keeps a hidden model in the synced list without making it
|
||||
* visible again, while genuinely new models default to visible.
|
||||
*/
|
||||
import test, { before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -104,29 +91,3 @@ test("B: a genuinely-new model on re-sync defaults to VISIBLE", async () => {
|
||||
assert.ok(synced.includes("B"), "eye-hidden B still present");
|
||||
assert.equal(getModelIsHidden(PROVIDER, "B"), true, "eye-hidden B still hidden");
|
||||
});
|
||||
|
||||
test("C: a DELETED synced model still stays out on re-import (delete signal)", async () => {
|
||||
const provider = "llama-cpp-del";
|
||||
const connection = "conn-3782-del";
|
||||
|
||||
await replaceSyncedAvailableModelsForConnection(provider, connection, [
|
||||
{ id: "keep", name: "Keep" },
|
||||
{ id: "del", name: "Delete me" },
|
||||
]);
|
||||
let synced = (await getSyncedAvailableModels(provider)).map((m) => m.id);
|
||||
assert.ok(synced.includes("del"), "both present after first sync");
|
||||
|
||||
// Operator DELETES (trash) `del` → the route marks it deleted. Mirror the real
|
||||
// DELETE route: it sets BOTH the distinct delete marker and (back-compat) hidden.
|
||||
mergeModelCompatOverride(provider, "del", { isDeleted: true, isHidden: true });
|
||||
|
||||
// Auto-fetch re-imports the SAME upstream list (still advertising `del`).
|
||||
await replaceSyncedAvailableModelsForConnection(provider, connection, [
|
||||
{ id: "keep", name: "Keep" },
|
||||
{ id: "del", name: "Delete me" },
|
||||
]);
|
||||
|
||||
synced = (await getSyncedAvailableModels(provider)).map((m) => m.id);
|
||||
assert.ok(synced.includes("keep"), "non-deleted model stays");
|
||||
assert.ok(!synced.includes("del"), "DELETED model must NOT be re-added by the re-import");
|
||||
});
|
||||
|
||||
@@ -20,7 +20,9 @@ test("T40: OpenCode card documents config paths and --variant usage", () => {
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
assert.match(notesText, /\.config\/opencode\/opencode\.json/);
|
||||
assert.match(notesText, /or opencode\.json\b/);
|
||||
assert.match(notesText, /\.config\/opencode\/opencode\.jsonc/);
|
||||
assert.match(notesText, /preferred when present/);
|
||||
// #3330: OpenCode uses ~/.config on all platforms (incl. Windows) — the note
|
||||
// must no longer point Windows users at %APPDATA%.
|
||||
assert.doesNotMatch(notesText, /%appdata%/);
|
||||
|
||||
@@ -40,3 +40,34 @@ test("countTextTokens is additive-ish and monotonic for longer text", () => {
|
||||
assert.ok(long > short);
|
||||
assert.ok(short > 0);
|
||||
});
|
||||
|
||||
test("countTextTokens fast-paths strings over 50k chars without tokenizing (worker wedge regression)", () => {
|
||||
const big = "user: please review the attached patch\ntext: ".repeat(40_000);
|
||||
const start = performance.now();
|
||||
const tokens = countTextTokens(big);
|
||||
const elapsed = performance.now() - start;
|
||||
assert.equal(tokens, Math.ceil(big.length / 4));
|
||||
assert.ok(elapsed < 1000, `fast path took ${elapsed.toFixed(0)}ms`);
|
||||
});
|
||||
|
||||
test("countTextTokens strips base64 data URIs before tokenizing (images not counted as text)", () => {
|
||||
const png =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
const b64 = png.repeat(60);
|
||||
const withImage = countTextTokens(
|
||||
`{"image_url":{"url":"data:image/png;base64,${b64}"}}`,
|
||||
{ provider: "codex" }
|
||||
);
|
||||
const stripped = countTextTokens('{"image_url":{"url":""}}', { provider: "codex" });
|
||||
assert.equal(withImage, stripped);
|
||||
});
|
||||
|
||||
test("countTextTokens does not tokenize huge base64 image payloads (wedge repro)", () => {
|
||||
const b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
const body = `{"image_url":{"url":"data:image/png;base64,${b64.repeat(14_000)}"}}`;
|
||||
const start = performance.now();
|
||||
const tokens = countTextTokens(body);
|
||||
const elapsed = performance.now() - start;
|
||||
assert.ok(tokens < 1000, `base64 payload inflates token count to ${tokens}`);
|
||||
assert.ok(elapsed < 1000, `took ${elapsed.toFixed(0)}ms`);
|
||||
});
|
||||
|
||||
@@ -17,23 +17,28 @@ const { default: AddCompatibleProviderModal } =
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
type ModalProps = React.ComponentProps<typeof AddCompatibleProviderModal>;
|
||||
|
||||
function render(props: Partial<ModalProps>) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AddCompatibleProviderModal
|
||||
isOpen
|
||||
mode="openai"
|
||||
onClose={() => {}}
|
||||
onCreated={() => {}}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const renderProps = (nextProps: Partial<ModalProps>) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<AddCompatibleProviderModal
|
||||
isOpen
|
||||
mode="openai"
|
||||
onClose={() => {}}
|
||||
onCreated={() => {}}
|
||||
{...nextProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
renderProps(props);
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
return { el, rerender: renderProps };
|
||||
}
|
||||
|
||||
function inputByLabel(el: Element, label: string): HTMLInputElement {
|
||||
@@ -84,7 +89,7 @@ afterEach(() => {
|
||||
|
||||
describe("AddCompatibleProviderModal — iconUrl field-level validation", () => {
|
||||
it("shows an inline error for an unsafe scheme and does NOT submit", async () => {
|
||||
const el = render({});
|
||||
const { el } = render({});
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
@@ -102,7 +107,7 @@ describe("AddCompatibleProviderModal — iconUrl field-level validation", () =>
|
||||
});
|
||||
|
||||
it("shows an inline error for a non-image data URL and does NOT submit", async () => {
|
||||
const el = render({});
|
||||
const { el } = render({});
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
@@ -119,7 +124,7 @@ describe("AddCompatibleProviderModal — iconUrl field-level validation", () =>
|
||||
});
|
||||
|
||||
it("accepts a valid data:image/*;base64 iconUrl and submits", async () => {
|
||||
const el = render({});
|
||||
const { el } = render({});
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
@@ -137,4 +142,98 @@ describe("AddCompatibleProviderModal — iconUrl field-level validation", () =>
|
||||
const body = JSON.parse(String(call[1].body));
|
||||
expect(body.iconUrl).toBe("data:image/png;base64,iVBORw0KGgo=");
|
||||
});
|
||||
|
||||
it("shows an inline error for an over-limit data URL and does NOT submit", async () => {
|
||||
const { el } = render({});
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
const tooLong = "data:image/png;base64," + "A".repeat(256 * 1024);
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), tooLong);
|
||||
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const addBtn = buttons.find((b) => b.textContent === "add");
|
||||
act(() => addBtn!.click());
|
||||
await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false);
|
||||
|
||||
expect(modal.textContent).toContain("iconUrlInvalid");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a server non-2xx validation error instead of staying silent", async () => {
|
||||
const onCreated = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ error: { message: "Icon URL too large" } }),
|
||||
} as Response)
|
||||
)
|
||||
);
|
||||
const { el } = render({ onCreated });
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
|
||||
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const addBtn = buttons.find((b) => b.textContent === "add");
|
||||
act(() => addBtn!.click());
|
||||
await waitFor(() => modal.textContent?.includes("Icon URL too large") ?? false);
|
||||
|
||||
expect(modal.textContent).toContain("Icon URL too large");
|
||||
expect(onCreated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a network error when create fetch fails", async () => {
|
||||
const onCreated = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new Error("offline")))
|
||||
);
|
||||
const { el } = render({ onCreated });
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
|
||||
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const addBtn = buttons.find((b) => b.textContent === "add");
|
||||
act(() => addBtn!.click());
|
||||
await waitFor(() => modal.textContent?.includes("Network error") ?? false);
|
||||
|
||||
const alert = modal.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain("Network error");
|
||||
expect(alert?.getAttribute("aria-live")).toBe("assertive");
|
||||
expect(onCreated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears a previous save error when the modal reopens", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new Error("offline")))
|
||||
);
|
||||
const { el, rerender } = render({});
|
||||
let modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
|
||||
act(() =>
|
||||
Array.from(modal.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === "add")!
|
||||
.click()
|
||||
);
|
||||
await waitFor(() => modal.textContent?.includes("Network error") ?? false);
|
||||
|
||||
rerender({ isOpen: false });
|
||||
rerender({ isOpen: true });
|
||||
modal = el.querySelector('[role="dialog"]')!;
|
||||
expect(modal.textContent).not.toContain("Network error");
|
||||
expect(modal.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,22 +17,30 @@ const { default: EditCompatibleNodeModal } =
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(node: Record<string, unknown>, onSave?: () => Promise<void>) {
|
||||
type ModalProps = React.ComponentProps<typeof EditCompatibleNodeModal>;
|
||||
|
||||
type ModalNode = NonNullable<ModalProps["node"]>;
|
||||
|
||||
function render(node: ModalNode, onSave?: ModalProps["onSave"]) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<EditCompatibleNodeModal
|
||||
isOpen
|
||||
node={node as any}
|
||||
onSave={onSave || (async () => {})}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const renderProps = (props: Partial<ModalProps>) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<EditCompatibleNodeModal
|
||||
isOpen
|
||||
node={node}
|
||||
onSave={onSave || (async () => {})}
|
||||
onClose={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
renderProps({});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
return { el, rerender: renderProps };
|
||||
}
|
||||
|
||||
function inputByLabel(el: Element, label: string): HTMLInputElement {
|
||||
@@ -85,7 +93,7 @@ const NODE = {
|
||||
describe("EditCompatibleNodeModal — iconUrl field-level validation", () => {
|
||||
it("shows an inline error for an unsafe scheme and does NOT call onSave", async () => {
|
||||
const onSave = vi.fn(async () => {});
|
||||
const el = render({ ...NODE, iconUrl: "javascript:alert(1)" });
|
||||
const { el } = render({ ...NODE, iconUrl: "javascript:alert(1)" });
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "javascript:alert(1)");
|
||||
@@ -100,7 +108,7 @@ describe("EditCompatibleNodeModal — iconUrl field-level validation", () => {
|
||||
|
||||
it("shows an inline error for a non-image data URL and does NOT call onSave", async () => {
|
||||
const onSave = vi.fn(async () => {});
|
||||
const el = render({ ...NODE, iconUrl: "data:text/html;base64,QUJD" });
|
||||
const { el } = render({ ...NODE, iconUrl: "data:text/html;base64,QUJD" });
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:text/html;base64,QUJD");
|
||||
@@ -115,7 +123,7 @@ describe("EditCompatibleNodeModal — iconUrl field-level validation", () => {
|
||||
|
||||
it("accepts a valid data:image/*;base64 iconUrl and calls onSave with it", async () => {
|
||||
const onSave = vi.fn(async () => {});
|
||||
const el = render({ ...NODE, iconUrl: "" }, onSave);
|
||||
const { el } = render({ ...NODE, iconUrl: "" }, onSave);
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
|
||||
@@ -128,4 +136,60 @@ describe("EditCompatibleNodeModal — iconUrl field-level validation", () => {
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.iconUrl).toBe("data:image/png;base64,iVBORw0KGgo=");
|
||||
});
|
||||
|
||||
it("shows an inline error for an over-limit data URL and does NOT call onSave", async () => {
|
||||
const onSave = vi.fn(async () => {});
|
||||
const { el } = render({ ...NODE, iconUrl: "" }, onSave);
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
const tooLong = "data:image/png;base64," + "A".repeat(256 * 1024);
|
||||
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), tooLong);
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const saveBtn = buttons.find((b) => b.textContent === "save");
|
||||
act(() => saveBtn!.click());
|
||||
await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false);
|
||||
|
||||
expect(modal.textContent).toContain("iconUrlInvalid");
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces an error thrown by onSave instead of staying silent", async () => {
|
||||
const onSave = vi.fn(async () => {
|
||||
throw new Error("server said no");
|
||||
});
|
||||
const { el } = render({ ...NODE, iconUrl: "" }, onSave);
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const saveBtn = buttons.find((b) => b.textContent === "save");
|
||||
act(() => saveBtn!.click());
|
||||
await waitFor(() => modal.textContent?.includes("server said no") ?? false);
|
||||
|
||||
const alert = modal.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain("server said no");
|
||||
expect(alert?.getAttribute("aria-live")).toBe("assertive");
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears a previous save error when the same node modal reopens", async () => {
|
||||
const onSave = vi.fn(async () => {
|
||||
throw new Error("server said no");
|
||||
});
|
||||
const { el, rerender } = render({ ...NODE, iconUrl: "" }, onSave);
|
||||
let modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
act(() =>
|
||||
Array.from(modal.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === "save")!
|
||||
.click()
|
||||
);
|
||||
await waitFor(() => modal.textContent?.includes("server said no") ?? false);
|
||||
|
||||
rerender({ isOpen: false });
|
||||
rerender({ isOpen: true });
|
||||
modal = el.querySelector('[role="dialog"]')!;
|
||||
expect(modal.textContent).not.toContain("server said no");
|
||||
expect(modal.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
98
tests/unit/ui/use-provider-node-actions.test.tsx
Normal file
98
tests/unit/ui/use-provider-node-actions.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useProviderNodeActions } from "../../../src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderNodeActions";
|
||||
|
||||
type Hook = ReturnType<typeof useProviderNodeActions>;
|
||||
|
||||
const t = ((key: string) => key) as Parameters<typeof useProviderNodeActions>[0]["t"];
|
||||
|
||||
function response(ok: boolean, body: unknown): Response {
|
||||
return {
|
||||
ok,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("useProviderNodeActions.handleUpdateNode", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
let captured: Hook | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
captured = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderHook(overrides: Partial<Parameters<typeof useProviderNodeActions>[0]> = {}) {
|
||||
const params: Parameters<typeof useProviderNodeActions>[0] = {
|
||||
providerId: "node-1",
|
||||
fetchConnections: vi.fn(async () => {}),
|
||||
selectedConnection: null,
|
||||
setProviderNode: vi.fn(),
|
||||
setShowEditNodeModal: vi.fn(),
|
||||
setShowEditModal: vi.fn(),
|
||||
t,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
const hook = useProviderNodeActions(params);
|
||||
React.useEffect(() => {
|
||||
captured = hook;
|
||||
}, [hook]);
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<Probe />);
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
it("throws the server message and keeps the modal open when PUT fails", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<typeof fetch>(async () => response(false, { error: { message: "Icon URL too large" } }))
|
||||
);
|
||||
const params = await renderHook();
|
||||
|
||||
await expect(captured!.handleUpdateNode({ iconUrl: "bad" })).rejects.toThrow(
|
||||
"Icon URL too large"
|
||||
);
|
||||
expect(params.setProviderNode).not.toHaveBeenCalled();
|
||||
expect(params.fetchConnections).not.toHaveBeenCalled();
|
||||
expect(params.setShowEditNodeModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report a successful PUT as a save failure when refresh rejects", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<typeof fetch>(async () => response(true, { node: { id: "node-1" } }))
|
||||
);
|
||||
const fetchConnections = vi.fn(async () => {
|
||||
throw new Error("refresh failed");
|
||||
});
|
||||
const params = await renderHook({ fetchConnections });
|
||||
|
||||
await expect(
|
||||
captured!.handleUpdateNode({ iconUrl: "data:image/png;base64,QUJD" })
|
||||
).resolves.toBe(undefined);
|
||||
expect(params.setProviderNode).toHaveBeenCalledWith({ id: "node-1" });
|
||||
expect(params.setShowEditNodeModal).toHaveBeenCalledWith(false);
|
||||
expect(fetchConnections).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user