mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 13:42:09 +03:00
Merge remote-tracking branch 'origin/release/v3.8.49' into fix/port-issue-2057-combo-custom-provider-models
# Conflicts: # src/app/(dashboard)/dashboard/combos/page.tsx
This commit is contained in:
23
tests/_setup/vitestUiPolyfills.ts
Normal file
23
tests/_setup/vitestUiPolyfills.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// jsdom (unlike real browsers) does not implement `window.matchMedia`. Several
|
||||
// dashboard components read the OS color-scheme preference via
|
||||
// `window.matchMedia("(prefers-color-scheme: dark)")` (see
|
||||
// `src/shared/hooks/useTheme.ts`), so any test that mounts a component using
|
||||
// that hook (directly or transitively, e.g. via `ProviderIcon`) crashes with
|
||||
// `TypeError: window.matchMedia is not a function` unless this polyfill runs
|
||||
// first. Keep this minimal — it only needs to satisfy the subset of the
|
||||
// MediaQueryList API this codebase actually calls.
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
|
||||
window.matchMedia = (query: string): MediaQueryList => {
|
||||
const mql = {
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
};
|
||||
return mql as unknown as MediaQueryList;
|
||||
};
|
||||
}
|
||||
41
tests/homolog/api/core.http
Normal file
41
tests/homolog/api/core.http
Normal file
@@ -0,0 +1,41 @@
|
||||
### GET /v1/models autenticado retorna catálogo
|
||||
GET {{baseUrl}}/v1/models
|
||||
Authorization: Bearer {{apiKey}}
|
||||
|
||||
?? status == 200
|
||||
?? js response.parsedBody.data.length > 0
|
||||
|
||||
### chat completions non-stream com modelo do tier crítico
|
||||
POST {{baseUrl}}/v1/chat/completions
|
||||
Authorization: Bearer {{apiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "{{smokeModel}}",
|
||||
"messages": [{ "role": "user", "content": "Reply with exactly: OK" }],
|
||||
"max_tokens": 5,
|
||||
"stream": false
|
||||
}
|
||||
|
||||
?? status == 200
|
||||
?? js response.parsedBody.choices[0].message.content.length > 0
|
||||
|
||||
### management sem credencial é rejeitado (401)
|
||||
# /v1/models pode ser público (REQUIRE_API_KEY off na VPS), então o teste de auth
|
||||
# usa a superfície de management, que exige credencial sempre.
|
||||
GET {{baseUrl}}/api/keys
|
||||
|
||||
?? status == 401
|
||||
|
||||
### management com bearer inválido é rejeitado (403)
|
||||
GET {{baseUrl}}/api/keys
|
||||
Authorization: Bearer or-invalid-key-homolog
|
||||
|
||||
?? status == 403
|
||||
|
||||
### health é público e saudável
|
||||
# httpYac trata o lado direito de `==` como literal — sem aspas.
|
||||
GET {{baseUrl}}/api/monitoring/health
|
||||
|
||||
?? status == 200
|
||||
?? js response.parsedBody.status == healthy
|
||||
31
tests/homolog/ui/api-key-flow.spec.ts
Normal file
31
tests/homolog/ui/api-key-flow.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Locators confirmados em
|
||||
// src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx (rota real da
|
||||
// tela de keys é /dashboard/api-manager, não /dashboard/api-keys):
|
||||
// - botão "Create API Key" (t("createKey")) abre o modal; o submit do modal tem o mesmo texto
|
||||
// - <Input placeholder={t("keyNamePlaceholder")}> = "e.g. Production Key"
|
||||
// - após criar, abre o "Created Key Modal" (t("keyCreated")) — fechar pelo botão t("done")="Done"
|
||||
// - cada key vira uma linha div.grid-cols-12; o botão de deletar tem title={t("deleteKey")}="Delete key"
|
||||
// - handleDeleteKey usa window.confirm(t("deleteConfirm")) — não é modal de UI,
|
||||
// precisa do listener page.on("dialog", ...).
|
||||
const KEY_NAME = `homolog-ui-${Date.now()}`;
|
||||
|
||||
test("cria e revoga uma API key pela UI", async ({ page }) => {
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
|
||||
await page.goto("/dashboard/api-manager");
|
||||
await page.getByRole("button", { name: "Create API Key" }).first().click();
|
||||
await page.getByPlaceholder("e.g. Production Key").fill(KEY_NAME);
|
||||
// segundo "Create API Key" é o submit do modal (o primeiro é o botão que o abriu)
|
||||
await page.getByRole("button", { name: "Create API Key" }).last().click();
|
||||
|
||||
// fecha o modal "API Key Created"
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
const row = page.locator("div.grid-cols-12", { hasText: KEY_NAME });
|
||||
await expect(row).toHaveCount(1);
|
||||
|
||||
// revoga a mesma key (cleanup — a suíte não deixa lixo na VPS)
|
||||
await row.getByTitle("Delete key").click();
|
||||
await expect(page.locator("div.grid-cols-12", { hasText: KEY_NAME })).toHaveCount(0);
|
||||
});
|
||||
13
tests/homolog/ui/auth.setup.ts
Normal file
13
tests/homolog/ui/auth.setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { test as setup, expect } from "@playwright/test";
|
||||
import { STORAGE_STATE } from "./playwright.config";
|
||||
|
||||
// Locators confirmados em src/app/login/page.tsx: <Input type="password"> dentro de um
|
||||
// <form onSubmit={handleLogin}> com <Button type="submit">{t("continue")}</Button>.
|
||||
setup("autentica e salva storageState", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.locator('input[type="password"]').fill(process.env.HOMOLOG_ADMIN_PASSWORD!);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL(/\/dashboard/);
|
||||
await expect(page).toHaveURL(/dashboard/);
|
||||
await page.context().storageState({ path: STORAGE_STATE });
|
||||
});
|
||||
42
tests/homolog/ui/playwright.config.ts
Normal file
42
tests/homolog/ui/playwright.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const STORAGE_STATE = path.join(HERE, ".auth", "admin.json");
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
timeout: 60_000,
|
||||
retries: 1,
|
||||
// Sem fullyParallel, os 98 testes de routes.spec.ts (mesmo arquivo) rodam
|
||||
// SERIALIZADOS num único worker (~10min); com ele, distribuem entre os workers.
|
||||
fullyParallel: true,
|
||||
workers: 8,
|
||||
reporter: [
|
||||
["list"],
|
||||
[
|
||||
// outputDir ABSOLUTO: o reporter resolve paths relativos contra o CWD do
|
||||
// processo (não contra o config) — um path relativo escapava do worktree.
|
||||
"playwright-ctrf-json-reporter",
|
||||
{
|
||||
outputDir: path.resolve(HERE, "..", "..", "..", "homolog-report"),
|
||||
outputFile: "ui-ctrf.json",
|
||||
},
|
||||
],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.HOMOLOG_BASE_URL || "http://192.168.0.15:20128",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{ name: "setup", testMatch: /auth\.setup\.ts/ },
|
||||
{
|
||||
name: "homolog",
|
||||
testMatch: /.*\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
use: { storageState: STORAGE_STATE },
|
||||
},
|
||||
],
|
||||
});
|
||||
40
tests/homolog/ui/routes.spec.ts
Normal file
40
tests/homolog/ui/routes.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Descobre as rotas estáticas do dashboard a partir do próprio repo:
|
||||
// cada page.tsx sob src/app/(dashboard)/dashboard vira uma rota; grupos (x) somem
|
||||
// do path e rotas dinâmicas [param] são puladas (sem dado real garantido).
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const BASE = path.join(ROOT, "src", "app", "(dashboard)", "dashboard");
|
||||
|
||||
function discoverRoutes(dir: string, prefix = "/dashboard"): string[] {
|
||||
const routes: string[] = [];
|
||||
if (fs.existsSync(path.join(dir, "page.tsx"))) routes.push(prefix || "/");
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!e.isDirectory() || e.name.startsWith("[") || e.name.startsWith("_")) continue;
|
||||
const seg = e.name.startsWith("(") ? "" : `/${e.name}`;
|
||||
routes.push(...discoverRoutes(path.join(dir, e.name), `${prefix}${seg}`));
|
||||
}
|
||||
return [...new Set(routes)];
|
||||
}
|
||||
|
||||
for (const route of discoverRoutes(BASE)) {
|
||||
test(`rota ${route} carrega sem crash`, async ({ page }) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
const res = await page.goto(route, { waitUntil: "domcontentloaded" });
|
||||
expect(res!.status(), `HTTP em ${route}`).toBeLessThan(400);
|
||||
// "networkidle" nunca assenta em telas com polling/websocket ao vivo (30s x 98 rotas
|
||||
// estourava o run inteiro) — "load" + um settle curto e suficiente para hidratar e
|
||||
// deixar um crash de client component (pageerror / error boundary) aparecer.
|
||||
await page.waitForLoadState("load", { timeout: 10_000 }).catch(() => {});
|
||||
await page.waitForTimeout(1_500);
|
||||
|
||||
// Error boundary do Next: nunca pode aparecer
|
||||
await expect(page.locator("text=Application error")).toHaveCount(0);
|
||||
expect(pageErrors, `pageerror em ${route}: ${pageErrors.join(" | ")}`).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
* Integration tests for Agent Skills content integrity.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. All 43 skill IDs from catalog have skills/{id}/ folder with SKILL.md.
|
||||
* 1. All 44 skill IDs from catalog have skills/{id}/ folder with SKILL.md.
|
||||
* 2. Zero omniroute-* folders remain (post-prune: old omniroute-* skill dirs were removed).
|
||||
* 3. 10 specific IDs have <!-- skill:custom-start --> ... <!-- skill:custom-end --> blocks:
|
||||
* 3. 12 specific IDs have <!-- skill:custom-start --> ... <!-- skill:custom-end --> blocks:
|
||||
* omni-mcp, omni-compression, cli-providers, cli-eval, omni-agents-a2a,
|
||||
* omni-combos-routing, omni-auth, omni-resilience, omni-inference, cli-serve.
|
||||
*
|
||||
@@ -22,6 +22,7 @@ const ALL_IDS = [...API_SKILL_IDS, ...CLI_SKILL_IDS, ...CONFIG_SKILL_IDS] as str
|
||||
|
||||
// IDs that must have a custom block
|
||||
const CUSTOM_BLOCK_IDS = [
|
||||
"cli-skill-collector",
|
||||
"omni-mcp",
|
||||
"omni-compression",
|
||||
"cli-providers",
|
||||
@@ -37,7 +38,7 @@ const CUSTOM_BLOCK_IDS = [
|
||||
|
||||
// ── §1: All 42 catalog IDs have skills/{id}/SKILL.md ─────────────────────────
|
||||
|
||||
test("all 43 catalog IDs have a skills/{id}/ directory", () => {
|
||||
test("all 44 catalog IDs have a skills/{id}/ directory", () => {
|
||||
const missing: string[] = [];
|
||||
for (const id of ALL_IDS) {
|
||||
const dirPath = path.join(SKILLS_DIR, id);
|
||||
@@ -48,7 +49,7 @@ test("all 43 catalog IDs have a skills/{id}/ directory", () => {
|
||||
assert.deepEqual(missing, [], `Missing skill directories: ${missing.join(", ")}`);
|
||||
});
|
||||
|
||||
test("all 43 catalog IDs have a skills/{id}/SKILL.md file", () => {
|
||||
test("all 44 catalog IDs have a skills/{id}/SKILL.md file", () => {
|
||||
const missing: string[] = [];
|
||||
for (const id of ALL_IDS) {
|
||||
const skillPath = path.join(SKILLS_DIR, id, "SKILL.md");
|
||||
@@ -113,7 +114,7 @@ for (const id of CUSTOM_BLOCK_IDS) {
|
||||
|
||||
// ── Additional integrity checks ───────────────────────────────────────────────
|
||||
|
||||
test("exactly 11 skills have custom blocks", () => {
|
||||
test("exactly 12 skills have custom blocks", () => {
|
||||
const withCustomBlocks: string[] = [];
|
||||
for (const id of ALL_IDS) {
|
||||
const skillPath = path.join(SKILLS_DIR, id, "SKILL.md");
|
||||
@@ -128,7 +129,7 @@ test("exactly 11 skills have custom blocks", () => {
|
||||
assert.deepEqual(
|
||||
withCustomBlocks.sort(),
|
||||
expectedIds,
|
||||
`Expected exactly these 11 custom-block IDs: ${expectedIds.join(", ")}\nActual: ${withCustomBlocks.join(", ")}`,
|
||||
`Expected exactly these 12 custom-block IDs: ${expectedIds.join(", ")}\nActual: ${withCustomBlocks.join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ test("every CLI skill ID has skills/<id>/SKILL.md on disk", () => {
|
||||
assert.deepEqual(missing, [], `Missing CLI SKILL.md files: ${missing.join(", ")}`);
|
||||
});
|
||||
|
||||
test("total skill count is exactly 43 (23 API + 20 CLI)", () => {
|
||||
assert.equal(API_SKILL_IDS.length + CLI_SKILL_IDS.length, 43);
|
||||
test("total skill count is exactly 44 (23 API + 21 CLI)", () => {
|
||||
assert.equal(API_SKILL_IDS.length + CLI_SKILL_IDS.length, 44);
|
||||
});
|
||||
|
||||
// ── §2: Frontmatter validation ────────────────────────────────────────────────
|
||||
@@ -120,11 +120,11 @@ test("each SKILL.md body is at least 100 chars", () => {
|
||||
|
||||
// ── §3: MCP tool omniroute_agent_skills_list ─────────────────────────────────
|
||||
|
||||
test("MCP omniroute_agent_skills_list handler returns count 44 (43 + config)", async () => {
|
||||
test("MCP omniroute_agent_skills_list handler returns count 45 (44 + config)", async () => {
|
||||
const result = await agentSkillTools.omniroute_agent_skills_list.handler({});
|
||||
assert.equal(result.count, 44, `Expected 44 but got ${result.count}`);
|
||||
assert.equal(result.count, 45, `Expected 45 but got ${result.count}`);
|
||||
assert.ok(Array.isArray(result.skills));
|
||||
assert.equal(result.skills.length, 44);
|
||||
assert.equal(result.skills.length, 45);
|
||||
});
|
||||
|
||||
test("MCP omniroute_agent_skills_list result has all 42 IDs", async () => {
|
||||
@@ -157,9 +157,9 @@ test("A2A list-capabilities artifact content contains 42 skill IDs as table rows
|
||||
assert.ok(rows.length >= 42, `Expected at least 42 data rows but got ${rows.length}`);
|
||||
});
|
||||
|
||||
test("A2A list-capabilities metadata.totalSkills === 44 (43 + config)", async () => {
|
||||
test("A2A list-capabilities metadata.totalSkills === 45 (44 + config)", async () => {
|
||||
const result = await executeListCapabilities(stubTask);
|
||||
assert.equal(result.metadata.totalSkills, 44);
|
||||
assert.equal(result.metadata.totalSkills, 45);
|
||||
});
|
||||
|
||||
test("A2A list-capabilities artifact contains all 42 skill IDs", async () => {
|
||||
|
||||
266
tests/integration/cli-settings-grok-build.test.ts
Normal file
266
tests/integration/cli-settings-grok-build.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Integration tests for /api/cli-tools/grok-build-settings
|
||||
*
|
||||
* Ported from decolua/9router#2571 ("feat(cli-tools): add Grok Build setup"),
|
||||
* rebuilt on top of OmniRoute's existing "custom" configType settings pattern
|
||||
* (auth guard, Zod validation, write-guard, backups, sanitized errors — see
|
||||
* forge-settings for the sibling implementation this mirrors).
|
||||
*
|
||||
* Unlike Forge's full-file overwrite, Grok Build's config.toml can hold other
|
||||
* user-defined `[model.*]` sections, so the handler surgically upserts only
|
||||
* the `[model.omniroute]` section and preserves the rest of the file.
|
||||
*/
|
||||
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-grok-build-settings-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret-grok-build";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-grok-build";
|
||||
|
||||
// Import DB reset helpers (must be before route import)
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
// Import route handlers
|
||||
const { GET, POST, DELETE } = await import(
|
||||
"../../src/app/api/cli-tools/grok-build-settings/route.ts"
|
||||
);
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function enableAuth() {
|
||||
process.env.INITIAL_PASSWORD = "test-bootstrap";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
// ── Test 1: GET without auth when auth is required → 401 ────────────────────
|
||||
|
||||
test("grok-build-settings GET: returns 401 when auth required and no token", async () => {
|
||||
await enableAuth();
|
||||
const res = await GET(new Request("http://localhost/api/cli-tools/grok-build-settings"));
|
||||
assert.equal(res.status, 401, `Expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── Test 2: GET with valid auth → 200 ────────────────────────────────────────
|
||||
|
||||
test("grok-build-settings GET: returns 200 with valid auth (grok not installed on CI)", async () => {
|
||||
const res = await GET(new Request("http://localhost/api/cli-tools/grok-build-settings"));
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.ok(
|
||||
"installed" in body || "config" in body,
|
||||
"Response should contain installed or config field"
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 3: POST with invalid body → 400 ─────────────────────────────────────
|
||||
|
||||
test("grok-build-settings POST: 400 when baseUrl is missing", async () => {
|
||||
const res = await POST(
|
||||
new Request("http://localhost/api/cli-tools/grok-build-settings", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ apiKey: "sk-test", model: "grok-4.5" }), // missing baseUrl
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 400, `Expected 400 for missing baseUrl, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error !== undefined, "Response should have error field");
|
||||
});
|
||||
|
||||
test("grok-build-settings POST: 400 when model is missing", async () => {
|
||||
const res = await POST(
|
||||
new Request("http://localhost/api/cli-tools/grok-build-settings", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test" }),
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 400, `Expected 400 for missing model, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── Test 4: POST with valid body → surgically upserts [model.omniroute] ─────
|
||||
|
||||
test("grok-build-settings POST: writes [model.omniroute] section and preserves existing content", async () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "grok-build-home-"));
|
||||
const origHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
|
||||
try {
|
||||
// Pre-seed a config.toml with an unrelated user model + a non-default value,
|
||||
// to prove the handler does not clobber content it does not own.
|
||||
const grokDir = path.join(tmpHome, ".grok");
|
||||
fs.mkdirSync(grokDir, { recursive: true });
|
||||
const preExisting = [
|
||||
"[models]",
|
||||
'default = "grok-build"',
|
||||
"",
|
||||
"[model.custom-thing]",
|
||||
'model = "some-other-model"',
|
||||
'base_url = "https://example.test/v1"',
|
||||
"",
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(grokDir, "config.toml"), preExisting);
|
||||
|
||||
const res = await POST(
|
||||
new Request("http://localhost/api/cli-tools/grok-build-settings", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test-grok-build-key",
|
||||
model: "grok-4.5",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// 200 = success; 403 = write guard active (test env); 500 = backup dir issue
|
||||
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
|
||||
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true, "success should be true on 200");
|
||||
|
||||
const configPath = path.join(tmpHome, ".grok", "config.toml");
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
|
||||
assert.ok(content.includes("[model.omniroute]"), "Config should have [model.omniroute]");
|
||||
assert.ok(content.includes("http://localhost:20128/v1"), "Config should contain base URL");
|
||||
assert.ok(content.includes('default = "omniroute"'), "Default should point at our slot");
|
||||
// The pre-existing unrelated model section must survive untouched.
|
||||
assert.ok(
|
||||
content.includes("[model.custom-thing]") &&
|
||||
content.includes("https://example.test/v1"),
|
||||
"Pre-existing unrelated [model.*] section must be preserved"
|
||||
);
|
||||
// The previous default must be remembered for Reset to restore.
|
||||
assert.ok(
|
||||
content.includes('omniroute-prev-default = "grok-build"'),
|
||||
"Previous default should be remembered as a marker comment"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test 5: DELETE → removes only our section and restores previous default ─
|
||||
|
||||
test("grok-build-settings DELETE: removes our section, preserves the rest, restores default", async () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "grok-build-home-del-"));
|
||||
const origHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
|
||||
try {
|
||||
const grokDir = path.join(tmpHome, ".grok");
|
||||
fs.mkdirSync(grokDir, { recursive: true });
|
||||
const preConfigured = [
|
||||
"[models]",
|
||||
'default = "omniroute"',
|
||||
"",
|
||||
"# omniroute-prev-default = \"grok-build\"",
|
||||
"[model.omniroute]",
|
||||
'model = "grok-4.5"',
|
||||
'base_url = "http://localhost:20128/v1"',
|
||||
'name = "OmniRoute"',
|
||||
'api_backend = "chat_completions"',
|
||||
'api_key = "sk-test"',
|
||||
"",
|
||||
"[model.custom-thing]",
|
||||
'model = "some-other-model"',
|
||||
'base_url = "https://example.test/v1"',
|
||||
"",
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(grokDir, "config.toml"), preConfigured);
|
||||
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost/api/cli-tools/grok-build-settings", { method: "DELETE" })
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
|
||||
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
|
||||
const configPath = path.join(tmpHome, ".grok", "config.toml");
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
assert.ok(!content.includes("[model.omniroute]"), "Our section should be removed");
|
||||
assert.ok(
|
||||
content.includes("[model.custom-thing]") && content.includes("https://example.test/v1"),
|
||||
"Unrelated section must survive"
|
||||
);
|
||||
assert.ok(content.includes('default = "grok-build"'), "Previous default should be restored");
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("grok-build-settings DELETE: no-op success when no config file exists", async () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "grok-build-home-noconfig-"));
|
||||
const origHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
|
||||
try {
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost/api/cli-tools/grok-build-settings", { method: "DELETE" })
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test 6: Error sanitization (Hard Rule #12) ───────────────────────────────
|
||||
|
||||
test("grok-build-settings: error responses do not leak stack traces", async () => {
|
||||
const badReq = new Request("http://localhost/api/cli-tools/grok-build-settings", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{ this is not json }",
|
||||
});
|
||||
const res = await POST(badReq);
|
||||
const bodyStr = JSON.stringify(await res.json());
|
||||
assert.ok(
|
||||
!bodyStr.match(/\s+at\s+\/[^\s]/),
|
||||
"Error response must not contain absolute-path stack traces"
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 7: Hard Rule #13 (no exec/spawn) ────────────────────────────────────
|
||||
|
||||
test("grok-build-settings route.ts: does not call exec() or spawn() directly", () => {
|
||||
const routePath = path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../src/app/api/cli-tools/grok-build-settings/route.ts"
|
||||
);
|
||||
const content = fs.readFileSync(routePath, "utf-8");
|
||||
assert.ok(!content.match(/\bexec\s*\(/), "Handler must not use exec()");
|
||||
assert.ok(!content.match(/\bspawn\s*\(/), "Handler must not use spawn()");
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.JWT_SECRET;
|
||||
});
|
||||
305
tests/integration/codex-chat-reasoning-http-e2e.test.ts
Normal file
305
tests/integration/codex-chat-reasoning-http-e2e.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
|
||||
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
||||
const ENCRYPTED_CONTENT_SENTINEL = "encrypted-codex-state:" + "A".repeat(910);
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-chat-http-"));
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
process.env.API_KEY_SECRET = "codex-chat-http-e2e-secret-123456";
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
process.env.OMNIROUTE_LOG_REQUEST_SHAPE = "0";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
type RecordedRequest = {
|
||||
url: string;
|
||||
method: string;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function responsesEvents() {
|
||||
const response = {
|
||||
id: "resp_reasoning_http",
|
||||
object: "response",
|
||||
status: "in_progress",
|
||||
model: "gpt-5.6-sol",
|
||||
output: [],
|
||||
};
|
||||
return [
|
||||
{ type: "response.created", response },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: "rs_reasoning_http",
|
||||
type: "reasoning",
|
||||
encrypted_content: ENCRYPTED_CONTENT_SENTINEL,
|
||||
summary: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: "rs_reasoning_http",
|
||||
type: "reasoning",
|
||||
encrypted_content: ENCRYPTED_CONTENT_SENTINEL,
|
||||
summary: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 1,
|
||||
item: { id: "msg_reasoning_http", type: "message", role: "assistant", content: [] },
|
||||
},
|
||||
{
|
||||
type: "response.content_part.added",
|
||||
item_id: "msg_reasoning_http",
|
||||
output_index: 1,
|
||||
content_index: 0,
|
||||
part: { type: "output_text", text: "", annotations: [] },
|
||||
},
|
||||
{
|
||||
type: "response.output_text.delta",
|
||||
item_id: "msg_reasoning_http",
|
||||
output_index: 1,
|
||||
content_index: 0,
|
||||
delta: "The answer is 42.",
|
||||
},
|
||||
{
|
||||
type: "response.output_text.done",
|
||||
item_id: "msg_reasoning_http",
|
||||
output_index: 1,
|
||||
content_index: 0,
|
||||
text: "The answer is 42.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 1,
|
||||
item: {
|
||||
id: "msg_reasoning_http",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "The answer is 42.", annotations: [] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
...response,
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
id: "rs_reasoning_http",
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "I checked the contract. " }],
|
||||
},
|
||||
{
|
||||
id: "msg_reasoning_http",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "The answer is 42.", annotations: [] }],
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 8, output_tokens: 9, total_tokens: 17 },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function mockResponsesSse() {
|
||||
const nativeFraming = process.env.CODEX_NATIVE_EVENT_FRAMING === "1";
|
||||
return responsesEvents()
|
||||
.map((event) => {
|
||||
const eventLine = nativeFraming ? `event: ${event.type}\n` : "";
|
||||
return `${eventLine}data: ${JSON.stringify(event)}\n\n`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function readIncomingBody(request: http.IncomingMessage) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request)
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function bridgeRouteResponse(response: Response, outgoing: http.ServerResponse) {
|
||||
outgoing.writeHead(response.status, Object.fromEntries(response.headers.entries()));
|
||||
if (!response.body) {
|
||||
outgoing.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!outgoing.write(value)) await once(outgoing, "drain");
|
||||
}
|
||||
outgoing.end();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRouteServer() {
|
||||
const server = http.createServer(async (incoming, outgoing) => {
|
||||
try {
|
||||
if (incoming.method !== "POST" || incoming.url !== "/v1/chat/completions") {
|
||||
outgoing.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readIncomingBody(incoming);
|
||||
const address = server.address();
|
||||
assert(address && typeof address !== "string");
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(incoming.headers)) {
|
||||
if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
|
||||
else if (value !== undefined) headers.set(name, value);
|
||||
}
|
||||
const request = new Request(`http://127.0.0.1:${address.port}${incoming.url}`, {
|
||||
method: incoming.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
await bridgeRouteResponse(await chatRoute.POST(request), outgoing);
|
||||
} catch {
|
||||
// Mock route bridge: static body only — CodeQL flags ANY error-derived value here,
|
||||
// including error.message / String(error) (js/stack-trace-exposure #736/#737). The test
|
||||
// only asserts status===200, so the 500 body is never inspected.
|
||||
outgoing.writeHead(500, { "content-type": "text/plain" });
|
||||
outgoing.end("internal test route error");
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
assert(address && typeof address !== "string");
|
||||
return { server, url: `http://127.0.0.1:${address.port}/v1/chat/completions` };
|
||||
}
|
||||
|
||||
function parseSse(raw: string) {
|
||||
return raw
|
||||
.split(/\n\n+/)
|
||||
.map((block) =>
|
||||
block
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data: "))
|
||||
?.slice(6)
|
||||
)
|
||||
.filter((data): data is string => Boolean(data));
|
||||
}
|
||||
|
||||
async function closeServer(server: http.Server) {
|
||||
if (!server.listening) return;
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
}
|
||||
|
||||
test("chat completions streams Codex Responses reasoning through real route HTTP", async () => {
|
||||
const recorded: RecordedRequest[] = [];
|
||||
let routeServer: http.Server | undefined;
|
||||
|
||||
try {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
name: "codex-http-reasoning",
|
||||
email: "codex-http@example.test",
|
||||
accessToken: "mock-codex-access-token",
|
||||
refreshToken: "mock-codex-refresh-token",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
const routeHarness = await startRouteServer();
|
||||
routeServer = routeHarness.server;
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
if (request.url !== CODEX_RESPONSES_URL) {
|
||||
throw new Error(`Unexpected external fetch in Codex HTTP test: ${request.url}`);
|
||||
}
|
||||
recorded.push({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
body: JSON.parse(await request.text()) as Record<string, unknown>,
|
||||
});
|
||||
return new Response(mockResponsesSse(), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await originalFetch(routeHarness.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "codex/gpt-5.6-sol",
|
||||
stream: true,
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "What is the answer?" }],
|
||||
}),
|
||||
});
|
||||
const raw = await response.text();
|
||||
|
||||
assert.equal(response.status, 200, raw);
|
||||
assert.match(response.headers.get("content-type") ?? "", /^text\/event-stream/);
|
||||
assert.equal(recorded.length, 1);
|
||||
assert.equal(recorded[0].url, CODEX_RESPONSES_URL);
|
||||
assert.equal(recorded[0].method, "POST");
|
||||
assert.deepEqual(recorded[0].body.reasoning, { effort: "high", summary: "auto" });
|
||||
assert.deepEqual(recorded[0].body.input, [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "What is the answer?" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const chunks = parseSse(raw);
|
||||
assert.equal(chunks.at(-1), "[DONE]");
|
||||
const payloads = chunks.slice(0, -1).map((chunk) => JSON.parse(chunk));
|
||||
const reasoningContentDeltas = payloads
|
||||
.map((payload) => payload.choices?.[0]?.delta?.reasoning_content)
|
||||
.filter((content): content is string => Boolean(content));
|
||||
assert.equal(reasoningContentDeltas.length, 1);
|
||||
const reasoningContent = reasoningContentDeltas.join("");
|
||||
assert.match(reasoningContent, /encrypted (?:state|private reasoning)/i);
|
||||
assert(!raw.includes(ENCRYPTED_CONTENT_SENTINEL), raw);
|
||||
assert(!reasoningContent.includes(ENCRYPTED_CONTENT_SENTINEL), reasoningContent);
|
||||
assert(
|
||||
payloads.some((payload) => payload.choices?.[0]?.delta?.content === "The answer is 42.")
|
||||
);
|
||||
assert(!raw.includes("response.reasoning_summary_text.delta"), raw);
|
||||
assert(!raw.includes('"type":"error"'), raw);
|
||||
assert(!raw.includes('"error"'), raw);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (routeServer) await closeServer(routeServer);
|
||||
core.closeDbInstance({ checkpointMode: null });
|
||||
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -658,6 +658,29 @@
|
||||
"stream": "https://chatgpt.com/backend-api/conversation"
|
||||
}
|
||||
},
|
||||
"chenzk": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"nonStream": "https://chenzk.top/v1/chat/completions",
|
||||
"stream": "https://chenzk.top/v1/chat/completions"
|
||||
}
|
||||
},
|
||||
"chipotle": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
|
||||
24
tests/unit/agent-bridge-dns-toggle-method-7157.test.ts
Normal file
24
tests/unit/agent-bridge-dns-toggle-method-7157.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const clientPath = path.resolve(
|
||||
__dirname,
|
||||
"../../src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx"
|
||||
);
|
||||
const source = readFileSync(clientPath, "utf8");
|
||||
|
||||
test("#7157: dns toggle fetch call uses method POST (route.ts only exports POST)", () => {
|
||||
const dnsCallMatch = source.match(
|
||||
/\/api\/tools\/agent-bridge\/agents\/\$\{agentId\}\/dns`,\s*\{\s*method:\s*"([A-Z]+)"/
|
||||
);
|
||||
assert.ok(dnsCallMatch, "expected to find the dns fetch call in AgentBridgePageClient.tsx");
|
||||
assert.equal(
|
||||
dnsCallMatch?.[1],
|
||||
"POST",
|
||||
"dns fetch call must use method: 'POST' to match the route.ts export (issue #7157)"
|
||||
);
|
||||
});
|
||||
@@ -58,11 +58,11 @@ test("each agentSkillTool has name, description, inputSchema, and handler", () =
|
||||
|
||||
// ─── omniroute_agent_skills_list ────────────────────────────────────────────
|
||||
|
||||
test("omniroute_agent_skills_list with no filters returns all 44 skills", async () => {
|
||||
test("omniroute_agent_skills_list with no filters returns all 45 skills", async () => {
|
||||
const result = await agentSkillTools.omniroute_agent_skills_list.handler({});
|
||||
assert.equal(result.count, 44, `Expected 44 but got ${result.count}`);
|
||||
assert.equal(result.count, 45, `Expected 45 but got ${result.count}`);
|
||||
assert.ok(Array.isArray(result.skills));
|
||||
assert.equal(result.skills.length, 44);
|
||||
assert.equal(result.skills.length, 45);
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_list({category:'api'}) returns exactly 23 entries", async () => {
|
||||
@@ -71,9 +71,9 @@ test("omniroute_agent_skills_list({category:'api'}) returns exactly 23 entries",
|
||||
assert.ok(result.skills.every((s: { category: string }) => s.category === "api"));
|
||||
});
|
||||
|
||||
test("omniroute_agent_skills_list({category:'cli'}) returns exactly 20 entries", async () => {
|
||||
test("omniroute_agent_skills_list({category:'cli'}) returns exactly 21 entries", async () => {
|
||||
const result = await agentSkillTools.omniroute_agent_skills_list.handler({ category: "cli" });
|
||||
assert.equal(result.count, 20, `Expected 20 cli skills but got ${result.count}`);
|
||||
assert.equal(result.count, 21, `Expected 21 cli skills but got ${result.count}`);
|
||||
assert.ok(result.skills.every((s: { category: string }) => s.category === "cli"));
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ test("omniroute_agent_skills_list result includes coverage shape", async () => {
|
||||
assert.ok(typeof result.coverage.api === "object");
|
||||
assert.ok(typeof result.coverage.cli === "object");
|
||||
assert.equal(result.coverage.api.total, 23);
|
||||
assert.equal(result.coverage.cli.total, 20);
|
||||
assert.equal(result.coverage.cli.total, 21);
|
||||
assert.ok(typeof result.coverage.totalSkills === "number");
|
||||
assert.ok(typeof result.coverage.generatedAt === "string");
|
||||
});
|
||||
@@ -168,11 +168,11 @@ test("omniroute_agent_skills_coverage({}) returns coverage shape", async () => {
|
||||
assert.ok(typeof result.api === "object");
|
||||
assert.ok(typeof result.cli === "object");
|
||||
assert.equal(result.api.total, 23);
|
||||
assert.equal(result.cli.total, 20);
|
||||
assert.equal(result.cli.total, 21);
|
||||
assert.ok(typeof result.api.have === "number");
|
||||
assert.ok(typeof result.cli.have === "number");
|
||||
assert.ok(result.api.have >= 0 && result.api.have <= 23);
|
||||
assert.ok(result.cli.have >= 0 && result.cli.have <= 20);
|
||||
assert.ok(result.cli.have >= 0 && result.cli.have <= 21);
|
||||
assert.ok(typeof result.totalSkills === "number");
|
||||
assert.equal(result.totalSkills, result.api.have + result.cli.have + (result.config?.have ?? 0));
|
||||
assert.ok(typeof result.generatedAt === "string");
|
||||
|
||||
@@ -15,10 +15,10 @@ const agentSkillsConstants = await import("../../src/shared/constants/agentSkill
|
||||
|
||||
// ─── Counts ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test("getCatalog() returns exactly 44 entries", () => {
|
||||
test("getCatalog() returns exactly 45 entries", () => {
|
||||
refreshCatalog();
|
||||
const catalog = getCatalog();
|
||||
assert.equal(catalog.length, 44, `Expected 44 but got ${catalog.length}`);
|
||||
assert.equal(catalog.length, 45, `Expected 45 but got ${catalog.length}`);
|
||||
});
|
||||
|
||||
test("API_SKILL_IDS has exactly 23 entries", () => {
|
||||
@@ -26,7 +26,7 @@ test("API_SKILL_IDS has exactly 23 entries", () => {
|
||||
});
|
||||
|
||||
test("CLI_SKILL_IDS has exactly 20 entries", () => {
|
||||
assert.equal(CLI_SKILL_IDS.length, 20);
|
||||
assert.equal(CLI_SKILL_IDS.length, 21);
|
||||
});
|
||||
|
||||
test("getCatalog() contains exactly 22 api skills", () => {
|
||||
@@ -34,9 +34,9 @@ test("getCatalog() contains exactly 22 api skills", () => {
|
||||
assert.equal(apiSkills.length, 23);
|
||||
});
|
||||
|
||||
test("getCatalog() contains exactly 20 cli skills", () => {
|
||||
test("getCatalog() contains exactly 21 cli skills", () => {
|
||||
const cliSkills = getCatalog().filter((s) => s.category === "cli");
|
||||
assert.equal(cliSkills.length, 20);
|
||||
assert.equal(cliSkills.length, 21);
|
||||
});
|
||||
|
||||
// ─── ID format ────────────────────────────────────────────────────────────────
|
||||
@@ -160,9 +160,9 @@ test("filterCatalog({ category: 'api' }) returns 23 api skills", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("filterCatalog({ category: 'cli' }) returns 20 cli skills", () => {
|
||||
test("filterCatalog({ category: 'cli' }) returns 21 cli skills", () => {
|
||||
const skills = filterCatalog({ category: "cli" });
|
||||
assert.equal(skills.length, 20);
|
||||
assert.equal(skills.length, 21);
|
||||
for (const s of skills) {
|
||||
assert.equal(s.category, "cli");
|
||||
}
|
||||
@@ -185,9 +185,9 @@ test("filterCatalog({ area: 'nonexistent' }) returns empty array", () => {
|
||||
assert.equal(skills.length, 0);
|
||||
});
|
||||
|
||||
test("filterCatalog({}) returns full catalog (44 entries)", () => {
|
||||
test("filterCatalog({}) returns full catalog (45 entries)", () => {
|
||||
const skills = filterCatalog({});
|
||||
assert.equal(skills.length, 44);
|
||||
assert.equal(skills.length, 45);
|
||||
});
|
||||
|
||||
// ─── refreshCatalog ───────────────────────────────────────────────────────────
|
||||
@@ -214,9 +214,9 @@ test("computeCoverage() returns valid SkillCoverage shape", () => {
|
||||
assert.ok(cov.api.have >= 0 && cov.api.have <= 23);
|
||||
|
||||
assert.ok(typeof cov.cli === "object");
|
||||
assert.equal(cov.cli.total, 20);
|
||||
assert.equal(cov.cli.total, 21);
|
||||
assert.ok(typeof cov.cli.have === "number");
|
||||
assert.ok(cov.cli.have >= 0 && cov.cli.have <= 20);
|
||||
assert.ok(cov.cli.have >= 0 && cov.cli.have <= 21);
|
||||
|
||||
assert.equal(cov.totalSkills, cov.api.have + cov.cli.have + (cov.config?.have ?? 0));
|
||||
|
||||
@@ -255,6 +255,6 @@ test("CLI_SKILL_IDS first entry is cli-serve", () => {
|
||||
assert.equal(CLI_SKILL_IDS[0], "cli-serve");
|
||||
});
|
||||
|
||||
test("CLI_SKILL_IDS last entry is cli-setup", () => {
|
||||
assert.equal(CLI_SKILL_IDS[CLI_SKILL_IDS.length - 1], "cli-setup");
|
||||
test("CLI_SKILL_IDS last entry is cli-skill-collector", () => {
|
||||
assert.equal(CLI_SKILL_IDS[CLI_SKILL_IDS.length - 1], "cli-skill-collector");
|
||||
});
|
||||
|
||||
@@ -61,11 +61,11 @@ test("dry-run (default) returns report without writing any files", async () => {
|
||||
outputDir: tmpDir,
|
||||
});
|
||||
|
||||
// All 44 skills should appear as generated (would-write) since dir is empty
|
||||
// All 45 skills should appear as generated (would-write) since dir is empty
|
||||
assert.equal(
|
||||
report.generated.length + report.unchanged.length,
|
||||
44,
|
||||
`Expected 44 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`,
|
||||
45,
|
||||
`Expected 45 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`,
|
||||
);
|
||||
assert.equal(report.errors.length, 0, `Unexpected errors: ${JSON.stringify(report.errors)}`);
|
||||
|
||||
@@ -81,7 +81,7 @@ test("dry-run (default) returns report without writing any files", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("dry-run generates report with 44 total (generated+unchanged)", async () => {
|
||||
test("dry-run generates report with 45 total (generated+unchanged)", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
@@ -91,7 +91,7 @@ test("dry-run generates report with 44 total (generated+unchanged)", async () =>
|
||||
outputDir: tmpDir,
|
||||
});
|
||||
const total = report.generated.length + report.unchanged.length;
|
||||
assert.equal(total, 44);
|
||||
assert.equal(total, 45);
|
||||
} finally {
|
||||
rmTmpDir(tmpDir);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ test("apply mode writes SKILL.md with valid frontmatter for omni-providers", asy
|
||||
}
|
||||
});
|
||||
|
||||
test("apply mode writes all 44 SKILL.md files when no onlyIds filter", async () => {
|
||||
test("apply mode writes all 45 SKILL.md files when no onlyIds filter", async () => {
|
||||
const tmpDir = mkTmpDir();
|
||||
try {
|
||||
refreshCatalog();
|
||||
@@ -145,7 +145,7 @@ test("apply mode writes all 44 SKILL.md files when no onlyIds filter", async ()
|
||||
});
|
||||
|
||||
assert.equal(report.errors.length, 0, `Errors: ${JSON.stringify(report.errors)}`);
|
||||
assert.equal(report.generated.length, 44);
|
||||
assert.equal(report.generated.length, 45);
|
||||
|
||||
// Verify all dirs exist
|
||||
const catalog = getCatalog();
|
||||
|
||||
@@ -101,15 +101,15 @@ test.after(() => {
|
||||
// GET /api/agent-skills
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test("GET /api/agent-skills — returns 44 skills with count and coverage", async () => {
|
||||
test("GET /api/agent-skills — returns 45 skills with count and coverage", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills");
|
||||
const res = await listRoute.GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { skills: unknown[]; count: number; coverage: unknown };
|
||||
assert.equal(body.count, 44, `Expected 44 skills but got ${body.count}`);
|
||||
assert.equal(body.count, 45, `Expected 45 skills but got ${body.count}`);
|
||||
assert.equal(Array.isArray(body.skills), true);
|
||||
assert.equal(body.skills.length, 44);
|
||||
assert.equal(body.skills.length, 45);
|
||||
assert.ok(body.coverage !== undefined, "coverage should be present");
|
||||
});
|
||||
|
||||
@@ -123,13 +123,13 @@ test("GET /api/agent-skills?category=api — returns 23 api skills", async () =>
|
||||
assert.ok(body.skills.every((s) => s.category === "api"), "All skills should be api category");
|
||||
});
|
||||
|
||||
test("GET /api/agent-skills?category=cli — returns 20 cli skills", async () => {
|
||||
test("GET /api/agent-skills?category=cli — returns 21 cli skills", async () => {
|
||||
const req = makeRequest("GET", "http://localhost/api/agent-skills?category=cli");
|
||||
const res = await listRoute.GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { skills: Array<{ category: string }>; count: number };
|
||||
assert.equal(body.count, 20);
|
||||
assert.equal(body.count, 21);
|
||||
assert.ok(body.skills.every((s) => s.category === "cli"), "All skills should be cli category");
|
||||
});
|
||||
|
||||
@@ -268,7 +268,7 @@ test("GET /api/agent-skills/coverage — returns valid SkillCoverage shape", asy
|
||||
};
|
||||
|
||||
assert.equal(body.api.total, 23, "api.total must be 23");
|
||||
assert.equal(body.cli.total, 20, "cli.total must be 20");
|
||||
assert.equal(body.cli.total, 21, "cli.total must be 21");
|
||||
assert.ok(typeof body.totalSkills === "number", "totalSkills must be a number");
|
||||
assert.ok(typeof body.generatedAt === "string", "generatedAt must be a string");
|
||||
// generatedAt must be a valid ISO datetime
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
import {
|
||||
clearAntigravityVersionCache,
|
||||
seedAntigravityVersionCache,
|
||||
} from "../../open-sse/services/antigravityVersion.ts";
|
||||
|
||||
// Ports decolua/9router#2461: a non-ok (e.g. 403) Antigravity upstream response in the
|
||||
// STREAMING path was piped straight through to the client via a raw pass-through
|
||||
// TransformStream, with no `response.ok` check at all — unlike the non-streaming path,
|
||||
// which already builds a sanitized error via buildAntigravityUpstreamError. When the
|
||||
// upstream 403 body is gzip-compressed (or otherwise binary/non-UTF8), those raw bytes
|
||||
// end up surfaced verbatim in the client-visible error message, corrupting it (reporters
|
||||
// saw literal control-byte garbage after "[ERROR] [403]:").
|
||||
test.afterEach(() => {
|
||||
clearAntigravityVersionCache();
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.execute (stream=true) sanitizes a non-ok upstream body instead of piping raw bytes", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
|
||||
// Simulate a gzip-compressed 403 body (magic bytes 0x1f 0x8b), the exact shape
|
||||
// reported upstream — reading it as text without decoding produces garbage.
|
||||
const binaryBody = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x02, 0xff, 0x52, 0x41, 0x4e]);
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(binaryBody, {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "antigravity/gemini-2.5-flash",
|
||||
body: { request: { contents: [] } },
|
||||
stream: true,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
log: { debug() {}, warn() {} },
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 403);
|
||||
|
||||
const bodyText = await result.response.text();
|
||||
|
||||
// The raw gzip magic bytes must never reach the client-visible error text.
|
||||
assert.ok(
|
||||
!bodyText.includes("\x1f\x8b"),
|
||||
`expected sanitized error body, got raw bytes leaking through: ${JSON.stringify(bodyText)}`
|
||||
);
|
||||
|
||||
// Must be routed through buildErrorBody()/buildAntigravityUpstreamError() — a clean,
|
||||
// parseable JSON error shape (hard rule #12), not an arbitrary pass-through stream.
|
||||
const parsed = JSON.parse(bodyText) as { error?: { message?: string } };
|
||||
assert.ok(parsed.error?.message, "expected a structured error.message");
|
||||
assert.match(parsed.error.message, /Antigravity upstream error \(403\)/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -79,9 +79,8 @@ test("bifrost route: returns 503 + fallback header when BIFROST_BASE_URL is unse
|
||||
delete process.env.BIFROST_STREAMING_ENABLED;
|
||||
|
||||
// Dynamic import after env is set so the module reads the empty value.
|
||||
const { POST } = await import(
|
||||
"../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts"
|
||||
);
|
||||
const { POST } =
|
||||
await import("../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts");
|
||||
|
||||
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
|
||||
method: "POST",
|
||||
@@ -191,12 +190,14 @@ test("bifrost route: records relay usage after SSE stream completion", async ()
|
||||
delete process.env.BIFROST_STREAMING_ENABLED;
|
||||
|
||||
const relayToken = seedRelayToken(`relay_bifrost_sse_${Date.now()}`);
|
||||
let forwardedRequestId: string | null = null;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
forwardedRequestId = new Headers(init?.headers).get("x-request-id");
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("data: {\"delta\":\"hi\"}\n\n"));
|
||||
controller.enqueue(new TextEncoder().encode('data: {"delta":"hi"}\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
@@ -205,6 +206,7 @@ test("bifrost route: records relay usage after SSE stream completion", async ()
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const { POST } = await import(
|
||||
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
|
||||
@@ -227,6 +229,7 @@ test("bifrost route: records relay usage after SSE stream completion", async ()
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("X-Routed-By"), "bifrost");
|
||||
assert.equal(forwardedRequestId, "bifrost-sse-lifecycle-test");
|
||||
assert.equal(getRelayLogs(relayToken.id, 10).length, 0);
|
||||
|
||||
assert.match(await res.text(), /delta/);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
getBifrostRoutingConfig,
|
||||
getRoutingFallbackHeader,
|
||||
getRoutingFallbackReasonHeader,
|
||||
resolveRelayRoutingBackend,
|
||||
shouldTryBifrost,
|
||||
shouldTryBifrostForRequest,
|
||||
@@ -152,3 +154,52 @@ test("relay routing backend strict bifrost bypasses manifest eligibility", () =>
|
||||
{ tryBifrost: true }
|
||||
);
|
||||
});
|
||||
|
||||
test("automatic relay keeps the Bifrost timeout active until an SSE stream finalizes", () => {
|
||||
const routeSource = readFileSync(
|
||||
new URL("../../../../src/app/api/v1/relay/chat/completions/route.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const forwardToBifrost = routeSource.slice(
|
||||
routeSource.indexOf("async function forwardToBifrost"),
|
||||
routeSource.indexOf("export async function OPTIONS")
|
||||
);
|
||||
const streamBranch = forwardToBifrost.slice(
|
||||
forwardToBifrost.indexOf("if (wantsStream && upstream.body)"),
|
||||
forwardToBifrost.indexOf("clearTimeout(tid);\n recordUsage(")
|
||||
);
|
||||
|
||||
assert.match(
|
||||
streamBranch,
|
||||
/finalizeReadableStream\(upstream\.body, \(error\) => \{\s*clearTimeout\(tid\)/
|
||||
);
|
||||
assert.match(streamBranch, /const statusCode = timedOut \? 504 : upstream\.status/);
|
||||
assert.match(streamBranch, /error && backend === "auto"/);
|
||||
assert.match(streamBranch, /recordBifrostFailure\(/);
|
||||
});
|
||||
|
||||
test("relay routing fallback reason header strips dynamic cooldown detail to the stable code", () => {
|
||||
assert.equal(
|
||||
getRoutingFallbackReasonHeader("bifrost-cooldown; remaining=1500"),
|
||||
"bifrost-cooldown"
|
||||
);
|
||||
});
|
||||
|
||||
test("relay routing fallback reason header passes already-stable reasons through unchanged", () => {
|
||||
assert.equal(getRoutingFallbackReasonHeader("bifrost-error"), "bifrost-error");
|
||||
assert.equal(getRoutingFallbackReasonHeader("bifrost-ineligible"), "bifrost-ineligible");
|
||||
assert.equal(
|
||||
getRoutingFallbackReasonHeader("bifrost-provider-unknown"),
|
||||
"bifrost-provider-unknown"
|
||||
);
|
||||
});
|
||||
|
||||
test("relay routing fallback reason header stays unset for the bare static legacy value", () => {
|
||||
assert.equal(getRoutingFallbackReasonHeader("bifrost"), undefined);
|
||||
});
|
||||
|
||||
test("relay routing fallback reason header stays unset for null/undefined/unrecognized input", () => {
|
||||
assert.equal(getRoutingFallbackReasonHeader(null), undefined);
|
||||
assert.equal(getRoutingFallbackReasonHeader(undefined), undefined);
|
||||
assert.equal(getRoutingFallbackReasonHeader("something-unrecognized"), undefined);
|
||||
});
|
||||
|
||||
@@ -90,6 +90,48 @@ test("extractApiKey accepts Anthropic-Version (TitleCase) header", () => {
|
||||
assert.equal(extractApiKey(req), "sk-titlecase-version");
|
||||
});
|
||||
|
||||
test("extractApiKey returns the key from x-goog-api-key when Authorization and x-api-key are absent (#7034)", () => {
|
||||
const req = makeRequest({ "x-goog-api-key": "sk-goog-native" });
|
||||
assert.equal(extractApiKey(req), "sk-goog-native");
|
||||
});
|
||||
|
||||
test("extractApiKey accepts uppercase X-Goog-Api-Key header casing (#7034)", () => {
|
||||
const req = makeRequest({ "X-Goog-Api-Key": "sk-goog-uppercase" });
|
||||
assert.equal(extractApiKey(req), "sk-goog-uppercase");
|
||||
});
|
||||
|
||||
test("extractApiKey trims surrounding whitespace from x-goog-api-key value (#7034)", () => {
|
||||
const req = makeRequest({ "x-goog-api-key": " sk-goog-padded " });
|
||||
assert.equal(extractApiKey(req), "sk-goog-padded");
|
||||
});
|
||||
|
||||
test("extractApiKey returns null when x-goog-api-key contains only whitespace (#7034)", () => {
|
||||
const req = makeRequest({ "x-goog-api-key": " " });
|
||||
assert.equal(extractApiKey(req), null);
|
||||
});
|
||||
|
||||
test("extractApiKey prefers Bearer over x-goog-api-key when both are present (#7034)", () => {
|
||||
const req = makeRequest({
|
||||
Authorization: "Bearer sk-bearer-wins",
|
||||
"x-goog-api-key": "sk-goog-loser",
|
||||
});
|
||||
assert.equal(extractApiKey(req), "sk-bearer-wins");
|
||||
});
|
||||
|
||||
test("extractApiKey prefers x-api-key (with anthropic-version) over x-goog-api-key when both are present (#7034)", () => {
|
||||
const req = makeRequest({
|
||||
"x-api-key": "sk-anthropic-wins",
|
||||
"x-goog-api-key": "sk-goog-loser",
|
||||
...ANTHROPIC,
|
||||
});
|
||||
assert.equal(extractApiKey(req), "sk-anthropic-wins");
|
||||
});
|
||||
|
||||
test("extractApiKey does not require anthropic-version for the x-goog-api-key fallback (#7034)", () => {
|
||||
const req = makeRequest({ "x-goog-api-key": "sk-goog-no-version-needed" });
|
||||
assert.equal(extractApiKey(req), "sk-goog-no-version-needed");
|
||||
});
|
||||
|
||||
test("extractApiKey extracts a path-scoped token from /api/v1/vscode/<token>/...", () => {
|
||||
const req = new Request("https://omniroute.test/api/v1/vscode/sk-test-path-token/models");
|
||||
assert.equal(extractApiKey(req), "sk-test-path-token");
|
||||
|
||||
@@ -237,6 +237,65 @@ test("clientApiPolicy: x-api-key header is accepted as client_api_key subject",
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: x-goog-api-key header is accepted as client_api_key subject (#7034)", async () => {
|
||||
const created = await apiKeysDb.createApiKey("policy-test-googkey", "machine-googkey-1234");
|
||||
assert.ok(created?.key, "createApiKey must return a key");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ "x-goog-api-key": created.key });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "client_api_key");
|
||||
assert.match(out.subject.id, /^key_/);
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: Authorization Bearer wins over x-goog-api-key when both present (#7034)", async () => {
|
||||
const created = await apiKeysDb.createApiKey("policy-test-goog-precedence", "machine-goog-2345");
|
||||
assert.ok(created?.key, "createApiKey must return a key");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({
|
||||
authorization: `Bearer ${created.key}`,
|
||||
"x-goog-api-key": "sk-goog-should-lose",
|
||||
});
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "client_api_key");
|
||||
assert.match(out.subject.id, /^key_/);
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: existing x-api-key still wins over x-goog-api-key when both present (#7034)", async () => {
|
||||
const created = await apiKeysDb.createApiKey("policy-test-xkey-precedence", "machine-xkey-2345");
|
||||
assert.ok(created?.key, "createApiKey must return a key");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({
|
||||
"x-api-key": created.key,
|
||||
"x-goog-api-key": "sk-goog-should-lose",
|
||||
});
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "client_api_key");
|
||||
assert.match(out.subject.id, /^key_/);
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: invalid x-goog-api-key is rejected with 401 AUTH_002 (#7034)", async () => {
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ "x-goog-api-key": "sk-invalid-goog-key" });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(out.code, "AUTH_002");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: ROUTER_API_KEY remains accepted for client API routes", async () => {
|
||||
process.env.ROUTER_API_KEY = "sk-router-policy-test";
|
||||
|
||||
|
||||
75
tests/unit/balance-e2e-shards.test.ts
Normal file
75
tests/unit/balance-e2e-shards.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { lptAssign, weightItems } from "../../scripts/quality/balance-e2e-shards.mjs";
|
||||
|
||||
// WS4.1 (v3.8.49 quality plan) — the E2E matrix skew was 14× (24m47s vs 1m47s)
|
||||
// because Playwright --shard distributes by count, not duration. These tests pin
|
||||
// the LPT packing invariants; the hard one is COMPLETENESS (a lost spec would
|
||||
// silently hollow the suite — the CLI self-checks it and falls back to --shard).
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
test("lptAssign puts the heaviest item alone before doubling up lighter shards", () => {
|
||||
const shards = lptAssign(
|
||||
[
|
||||
{ file: "huge.spec.ts", weight: 100 },
|
||||
{ file: "a.spec.ts", weight: 30 },
|
||||
{ file: "b.spec.ts", weight: 30 },
|
||||
{ file: "c.spec.ts", weight: 30 },
|
||||
],
|
||||
2
|
||||
);
|
||||
assert.deepEqual(shards[0].files, ["huge.spec.ts"]);
|
||||
assert.deepEqual(shards[1].files, ["a.spec.ts", "b.spec.ts", "c.spec.ts"]);
|
||||
assert.equal(shards[0].total, 100);
|
||||
assert.equal(shards[1].total, 90);
|
||||
});
|
||||
|
||||
test("lptAssign is deterministic on equal weights (filename tiebreak)", () => {
|
||||
const items = [
|
||||
{ file: "b.spec.ts", weight: 10 },
|
||||
{ file: "a.spec.ts", weight: 10 },
|
||||
];
|
||||
const s1 = lptAssign(items, 2);
|
||||
const s2 = lptAssign([...items].reverse(), 2);
|
||||
assert.deepEqual(s1, s2);
|
||||
});
|
||||
|
||||
test("completeness: every file lands in exactly one shard", () => {
|
||||
const items = Array.from({ length: 37 }, (_, i) => ({
|
||||
file: `f${String(i).padStart(2, "0")}.spec.ts`,
|
||||
weight: (i * 7) % 40,
|
||||
}));
|
||||
const shards = lptAssign(items, 9);
|
||||
const union = shards.flatMap((s) => s.files).sort();
|
||||
assert.deepEqual(union, items.map((i) => i.file).sort());
|
||||
});
|
||||
|
||||
test("weightItems gives unknown/new specs the median weight, not an extreme", () => {
|
||||
const items = weightItems(["new.spec.ts", "big.spec.ts", "small.spec.ts"], {
|
||||
_meta: "x",
|
||||
"big.spec.ts": 600,
|
||||
"small.spec.ts": 20,
|
||||
"other.spec.ts": 100,
|
||||
});
|
||||
const byFile = Object.fromEntries(items.map((i) => [i.file, i.weight]));
|
||||
assert.equal(byFile["big.spec.ts"], 600);
|
||||
assert.equal(byFile["small.spec.ts"], 20);
|
||||
assert.equal(byFile["new.spec.ts"], 100); // median of [20,100,600]
|
||||
});
|
||||
|
||||
test("the committed timings seed covers every current e2e spec (no drift)", () => {
|
||||
const timings = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, "config", "quality", "e2e-timings.json"), "utf8")
|
||||
);
|
||||
const specs = fs
|
||||
.readdirSync(path.join(ROOT, "tests", "e2e"))
|
||||
.filter((f) => f.endsWith(".spec.ts"));
|
||||
const missing = specs.filter((f) => !(f in timings));
|
||||
// Missing entries are tolerated at runtime (median fallback) — this assert keeps
|
||||
// the seed honest so balance quality does not silently rot as specs are added.
|
||||
assert.deepEqual(missing, [], `add to config/quality/e2e-timings.json: ${missing.join(", ")}`);
|
||||
});
|
||||
68
tests/unit/bedrock-image-log-redaction-7297.test.ts
Normal file
68
tests/unit/bedrock-image-log-redaction-7297.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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 crypto from "node:crypto";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7297-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { protectPayloadForLog } = await import("../../src/lib/logPayloads.ts");
|
||||
const bedrockExecutor = await import("../../open-sse/executors/bedrock.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function bedrockConverseBodyWithImages(nImages: number, imageBytes: number) {
|
||||
const content: unknown[] = [];
|
||||
for (let i = 0; i < nImages; i++) {
|
||||
const raw = crypto.randomBytes(imageBytes);
|
||||
content.push({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:image/png;base64,${raw.toString("base64")}` },
|
||||
});
|
||||
}
|
||||
content.push({ type: "text", text: "describe these images" });
|
||||
|
||||
const chatBody = {
|
||||
model: "us.anthropic.claude-opus-4-8",
|
||||
messages: [{ role: "user", content }],
|
||||
};
|
||||
|
||||
// Same call BedrockExecutor.execute() makes right before
|
||||
// prl.captureCurrentProviderRequest(url, headers, transformedBody, ...).
|
||||
return bedrockExecutor.openAIToBedrockConverse("us.anthropic.claude-opus-4-8", chatBody);
|
||||
}
|
||||
|
||||
test("#7297 protectPayloadForLog stays fast on a 3-image Bedrock Converse body", () => {
|
||||
const transformedBody = bedrockConverseBodyWithImages(3, 1_000_000);
|
||||
|
||||
const firstImageBlock = (
|
||||
transformedBody as { messages: Array<{ content: Array<Record<string, unknown>> }> }
|
||||
).messages[0].content[0] as { image?: { source?: { bytes?: unknown } } };
|
||||
assert.ok(firstImageBlock.image?.source?.bytes instanceof Uint8Array);
|
||||
|
||||
const start = Date.now();
|
||||
const result = protectPayloadForLog(transformedBody);
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
assert.ok(
|
||||
elapsedMs < 500,
|
||||
`protectPayloadForLog took ${elapsedMs}ms for a 3-image request — it is walking every ` +
|
||||
`decoded image byte as an object key instead of treating image.source.bytes as an ` +
|
||||
`opaque buffer (see #7297)`
|
||||
);
|
||||
|
||||
const redactedBytes = (
|
||||
result as { messages: Array<{ content: Array<Record<string, unknown>> }> }
|
||||
).messages[0].content[0] as { image?: { source?: { bytes?: unknown } } };
|
||||
assert.ok(
|
||||
!(redactedBytes.image?.source?.bytes instanceof Uint8Array) &&
|
||||
!Array.isArray(redactedBytes.image?.source?.bytes),
|
||||
"binary bytes must be replaced with an opaque placeholder, not expanded into per-byte keys"
|
||||
);
|
||||
});
|
||||
93
tests/unit/build-next-isolated-windows-home-2402.test.ts
Normal file
93
tests/unit/build-next-isolated-windows-home-2402.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const {
|
||||
ensureWindowsBuildProfileDirs,
|
||||
getWindowsBuildProfileDir,
|
||||
resolveNextBuildEnv,
|
||||
} = await import("../../scripts/build/build-next-isolated.mjs");
|
||||
|
||||
// Port of decolua/9router#2402 ("fix(build): isolate Windows HOME/AppData during
|
||||
// next build"). Upstream wraps `npm run build` in a new `scripts/build-app.js`
|
||||
// entrypoint; OmniRoute's build already routes through
|
||||
// `scripts/build/build-next-isolated.mjs` → resolveNextBuildEnv(), so the fix is
|
||||
// folded into that existing seam instead of adding a second build entrypoint.
|
||||
// `.github/workflows/electron-release.yml` already sanitizes USERPROFILE for one
|
||||
// CI job; this generalizes the isolation to every caller (local Windows builds,
|
||||
// other CI paths) and adds APPDATA/LOCALAPPDATA, which the CI-only patch does not
|
||||
// touch.
|
||||
|
||||
test("resolveNextBuildEnv leaves HOME/USERPROFILE/APPDATA untouched on non-Windows", () => {
|
||||
const env = resolveNextBuildEnv({ NODE_ENV: "test", HOME: "/home/dev" }, "linux");
|
||||
assert.equal(env.HOME, "/home/dev");
|
||||
assert.equal(env.USERPROFILE, undefined);
|
||||
assert.equal(env.APPDATA, undefined);
|
||||
assert.equal(env.LOCALAPPDATA, undefined);
|
||||
});
|
||||
|
||||
test("resolveNextBuildEnv isolates HOME/USERPROFILE/APPDATA/LOCALAPPDATA on win32", () => {
|
||||
const env = resolveNextBuildEnv(
|
||||
{ NODE_ENV: "test", USERPROFILE: "C:\\Users\\ci-runner" },
|
||||
"win32"
|
||||
);
|
||||
|
||||
assert.ok(env.HOME, "HOME must be set to an isolated profile dir on win32");
|
||||
assert.equal(env.HOME, env.USERPROFILE, "HOME and USERPROFILE must point at the same sandbox");
|
||||
assert.notEqual(
|
||||
env.USERPROFILE,
|
||||
"C:\\Users\\ci-runner",
|
||||
"the real USERPROFILE (with its junctions) must be replaced, not preserved"
|
||||
);
|
||||
assert.match(path.basename(env.APPDATA), /^Roaming$/);
|
||||
assert.match(path.basename(env.LOCALAPPDATA), /^Local$/);
|
||||
assert.equal(path.dirname(path.dirname(env.APPDATA)), env.HOME);
|
||||
assert.equal(path.dirname(path.dirname(env.LOCALAPPDATA)), env.HOME);
|
||||
});
|
||||
|
||||
test("resolveNextBuildEnv skips Windows isolation when a caller already sandboxed the build (NEXT_DIST_DIR)", () => {
|
||||
const env = resolveNextBuildEnv(
|
||||
{ NODE_ENV: "test", USERPROFILE: "C:\\Users\\ci-runner", NEXT_DIST_DIR: ".build/cli-next" },
|
||||
"win32"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
env.USERPROFILE,
|
||||
"C:\\Users\\ci-runner",
|
||||
"must not override a caller-provided sandbox (e.g. CLI packaging)"
|
||||
);
|
||||
assert.equal(env.APPDATA, undefined);
|
||||
assert.equal(env.LOCALAPPDATA, undefined);
|
||||
});
|
||||
|
||||
test("getWindowsBuildProfileDir is stable per-process (repeated calls return the same path)", () => {
|
||||
assert.equal(getWindowsBuildProfileDir(), getWindowsBuildProfileDir());
|
||||
});
|
||||
|
||||
test("ensureWindowsBuildProfileDirs is a no-op when the env has no APPDATA/LOCALAPPDATA", () => {
|
||||
let called = false;
|
||||
ensureWindowsBuildProfileDirs({ NODE_ENV: "test" }, () => {
|
||||
called = true;
|
||||
});
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("ensureWindowsBuildProfileDirs creates the isolated AppData directories", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-win-home-test-"));
|
||||
try {
|
||||
const env = {
|
||||
APPDATA: path.join(tempDir, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: path.join(tempDir, "AppData", "Local"),
|
||||
};
|
||||
|
||||
ensureWindowsBuildProfileDirs(env);
|
||||
|
||||
assert.equal(fsSync.existsSync(env.APPDATA), true);
|
||||
assert.equal(fsSync.existsSync(env.LOCALAPPDATA), true);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
111
tests/unit/build/check-dashboard-typecheck.test.ts
Normal file
111
tests/unit/build/check-dashboard-typecheck.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// tests/unit/build/check-dashboard-typecheck.test.ts
|
||||
// Unit tests for the pure parsing/diff helpers in check-dashboard-typecheck.mjs.
|
||||
// No child process is spawned — synthetic tsc-style output only, so the suite is
|
||||
// fast and hermetic. Proves the gate actually DETECTS the #6625/#6909 bug class
|
||||
// (an orphaned identifier — used but not declared — in a dashboard TSX file),
|
||||
// not just that the script runs.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
parseTscOutput,
|
||||
diffAgainstBaseline,
|
||||
} from "../../../scripts/check/check-dashboard-typecheck.mjs";
|
||||
|
||||
test("parseTscOutput: parses a TS2304 orphaned-identifier error (the #6625/#6909 bug class)", () => {
|
||||
const raw =
|
||||
`src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx(564,7): error TS2304: Cannot find name 'setPoolLoaded'.\n` +
|
||||
`src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx(1204,12): error TS2304: Cannot find name 'poolLoaded'.\n`;
|
||||
|
||||
const counts = parseTscOutput(raw);
|
||||
|
||||
assert.deepEqual(counts, {
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": {
|
||||
TS2304: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("parseTscOutput: ignores non-error lines (summary/info output)", () => {
|
||||
const raw =
|
||||
`Some info line that is not an error\n` +
|
||||
`src/app/(dashboard)/dashboard/foo.tsx(1,1): error TS2339: Property 'bar' does not exist.\n` +
|
||||
`Found 1 error in 1 file.\n`;
|
||||
|
||||
const counts = parseTscOutput(raw);
|
||||
|
||||
assert.deepEqual(counts, {
|
||||
"src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("parseTscOutput: returns empty map for clean output", () => {
|
||||
assert.deepEqual(parseTscOutput(""), {});
|
||||
assert.deepEqual(parseTscOutput("Found 0 errors.\n"), {});
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: flags a brand-new orphaned-identifier error as a regression", () => {
|
||||
const baseline = {};
|
||||
const live = {
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": {
|
||||
TS2304: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 1);
|
||||
assert.equal(
|
||||
regressions[0].file,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx"
|
||||
);
|
||||
assert.equal(regressions[0].code, "TS2304");
|
||||
assert.equal(regressions[0].liveCount, 5);
|
||||
assert.equal(regressions[0].baselineCount, 0);
|
||||
assert.equal(improvements.length, 0);
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: does NOT flag a frozen pre-existing error within its baselined count", () => {
|
||||
const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } };
|
||||
const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } };
|
||||
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 0);
|
||||
assert.equal(improvements.length, 0);
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: flags a count INCREASE beyond the frozen baseline as a regression", () => {
|
||||
const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 2 } };
|
||||
const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } };
|
||||
|
||||
const { regressions } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 1);
|
||||
assert.equal(regressions[0].baselineCount, 2);
|
||||
assert.equal(regressions[0].liveCount, 3);
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: reports (does not fail on) a count DECREASE as an improvement", () => {
|
||||
const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } };
|
||||
const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 1 } };
|
||||
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 0);
|
||||
assert.equal(improvements.length, 1);
|
||||
assert.equal(improvements[0].baselineCount, 3);
|
||||
assert.equal(improvements[0].liveCount, 1);
|
||||
});
|
||||
|
||||
test("diffAgainstBaseline: a baselined error that fully disappears is reported as an improvement, not a failure", () => {
|
||||
const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 2 } };
|
||||
const live = {};
|
||||
|
||||
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
|
||||
|
||||
assert.equal(regressions.length, 0);
|
||||
assert.equal(improvements.length, 1);
|
||||
assert.equal(improvements[0].liveCount, 0);
|
||||
assert.equal(improvements[0].baselineCount, 2);
|
||||
});
|
||||
174
tests/unit/bulk-add-keys-no-overwrite-2587.test.ts
Normal file
174
tests/unit/bulk-add-keys-no-overwrite-2587.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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";
|
||||
|
||||
type ConnectionRow = Record<string, unknown> & { id: string; name?: string | null };
|
||||
|
||||
// #2587 — bulk-add API keys must APPEND connections, never silently overwrite
|
||||
// an existing one. `createProviderConnection` upserts apikey connections BY
|
||||
// NAME (src/lib/db/providers.ts): same provider + auth_type "apikey" + same
|
||||
// `name` updates the existing row (replacing its apiKey/priority/testStatus)
|
||||
// instead of inserting a new one. Bulk-add auto-names unnamed lines
|
||||
// "Key 1", "Key 2", ... starting fresh on every request, blind to names
|
||||
// already saved for the provider — so re-running a bulk paste against a
|
||||
// provider that already has "Key 1" silently replaced it instead of adding a
|
||||
// new connection alongside it.
|
||||
//
|
||||
// The fix (POST /api/providers/bulk, src/app/api/providers/bulk/route.ts)
|
||||
// fetches existing connection names for the provider and runs
|
||||
// `resolveBulkNameCollisions` (src/shared/utils/bulkApiKeyParser.ts) before
|
||||
// calling createProviderConnection, gap-filling a free "<name> <n>" suffix so
|
||||
// every entry reaches createProviderConnection as a genuine insert. This test
|
||||
// exercises that exact production sequence — getProviderConnections ->
|
||||
// resolveBulkNameCollisions -> createProviderConnection — against a real
|
||||
// SQLite-backed db module.
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bulk-add-2587-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { resolveBulkNameCollisions } = await import("../../src/shared/utils/bulkApiKeyParser.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string } | undefined)?.code;
|
||||
if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("bulk-add appends N+M connections and preserves the existing connection's state (the #2587 fix)", async () => {
|
||||
// Existing connection carries live resilience state: an active rate-limit
|
||||
// cooldown, a recorded error, and a non-default backoff level/priority.
|
||||
const future = new Date(Date.now() + 60_000).toISOString();
|
||||
const created = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Key 1",
|
||||
apiKey: "sk-existing",
|
||||
priority: 3,
|
||||
testStatus: "unavailable",
|
||||
lastError: "429 rate limited",
|
||||
lastErrorType: "rate_limit",
|
||||
rateLimitedUntil: future,
|
||||
});
|
||||
assert.ok(created, "existing connection must be created");
|
||||
// `backoffLevel` is set by the resilience/cooldown path (not at creation
|
||||
// time) — apply it the same way a real cooldown escalation would.
|
||||
const existing = await providersDb.updateProviderConnection(created!.id as string, {
|
||||
backoffLevel: 2,
|
||||
});
|
||||
assert.ok(existing, "existing connection must be updatable");
|
||||
|
||||
const before = await providersDb.getProviderConnections({ provider: "openai" });
|
||||
assert.equal(before.length, 1);
|
||||
|
||||
// Simulates a fresh bulk-add paste (parseBulkApiKeys restarts auto-naming at
|
||||
// "Key 1" every request) — this is exactly what collides with the existing
|
||||
// connection above.
|
||||
const rawEntries = [
|
||||
{ name: "Key 1", apiKey: "sk-new-1" },
|
||||
{ name: "Key 2", apiKey: "sk-new-2" },
|
||||
];
|
||||
|
||||
// Reproduces the route's fix: fetch existing apikey names for the provider,
|
||||
// then resolve collisions before ever calling createProviderConnection.
|
||||
const existingApikeyConnections = (await providersDb.getProviderConnections({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
})) as ConnectionRow[];
|
||||
const existingNames = existingApikeyConnections
|
||||
.map((c) => (typeof c.name === "string" ? c.name : null))
|
||||
.filter((n): n is string => !!n);
|
||||
const resolvedEntries = resolveBulkNameCollisions(rawEntries, existingNames);
|
||||
|
||||
// Neither new entry may reuse the existing connection's name.
|
||||
assert.ok(!resolvedEntries.some((e) => e.name === "Key 1"));
|
||||
|
||||
for (const entry of resolvedEntries) {
|
||||
const created = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: entry.name,
|
||||
apiKey: entry.apiKey,
|
||||
priority: 1,
|
||||
testStatus: "unknown",
|
||||
});
|
||||
assert.ok(created);
|
||||
}
|
||||
|
||||
const after = (await providersDb.getProviderConnections({
|
||||
provider: "openai",
|
||||
})) as ConnectionRow[];
|
||||
// N (1 existing) + M (2 new) = 3 connections — never 2 (an insert masquerading
|
||||
// as an overwrite) or 1 (both new entries collapsing into the existing row).
|
||||
assert.equal(after.length, before.length + rawEntries.length);
|
||||
|
||||
const survivor = after.find((c) => c.id === (existing as ConnectionRow).id);
|
||||
assert.ok(survivor, "the pre-existing connection must still exist, unreplaced");
|
||||
assert.equal(survivor!.name, "Key 1");
|
||||
assert.equal(survivor!.apiKey, "sk-existing", "existing apiKey must not be overwritten");
|
||||
assert.equal(survivor!.priority, 3, "existing priority must survive the bulk-add");
|
||||
assert.equal(survivor!.testStatus, "unavailable", "existing testStatus must survive");
|
||||
assert.equal(survivor!.lastError, "429 rate limited", "existing lastError must survive");
|
||||
assert.equal(survivor!.rateLimitedUntil, future, "existing cooldown must survive");
|
||||
assert.equal(survivor!.backoffLevel, 2, "existing backoffLevel must survive");
|
||||
|
||||
const newNames = after
|
||||
.filter((c) => c.id !== (existing as ConnectionRow).id)
|
||||
.map((c) => c.name);
|
||||
assert.equal(new Set(newNames).size, newNames.length, "no duplicate names among new entries");
|
||||
assert.ok(!newNames.includes("Key 1"));
|
||||
});
|
||||
|
||||
test("without collision resolution, a colliding bulk entry silently replaces the existing connection (documents the pre-fix bug)", async () => {
|
||||
// This is the exact bug reported upstream: createProviderConnection's
|
||||
// name-based upsert is intentionally unchanged (other single-add/import
|
||||
// flows depend on it) — the guard lives one layer up, in the bulk route.
|
||||
// Skipping that guard reproduces the original data-loss behavior.
|
||||
const existing = await providersDb.createProviderConnection({
|
||||
provider: "anthropic",
|
||||
authType: "apikey",
|
||||
name: "Key 1",
|
||||
apiKey: "sk-existing",
|
||||
});
|
||||
|
||||
const collided = await providersDb.createProviderConnection({
|
||||
provider: "anthropic",
|
||||
authType: "apikey",
|
||||
name: "Key 1", // same name, no collision resolution applied
|
||||
apiKey: "sk-overwritten",
|
||||
});
|
||||
|
||||
assert.equal(collided!.id, existing!.id, "same name upserts into the same row");
|
||||
|
||||
const rows = (await providersDb.getProviderConnections({
|
||||
provider: "anthropic",
|
||||
})) as ConnectionRow[];
|
||||
assert.equal(rows.length, 1, "no new connection was inserted — this is the bug");
|
||||
assert.equal(rows[0].apiKey, "sk-overwritten", "the original key was overwritten");
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
parseBulkApiKeys,
|
||||
resolveBulkNameCollisions,
|
||||
BULK_API_KEY_MAX_LINES,
|
||||
} from "../../src/shared/utils/bulkApiKeyParser.ts";
|
||||
|
||||
@@ -87,3 +88,66 @@ test("input exceeding cap is truncated with warning", () => {
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /only the first/);
|
||||
});
|
||||
|
||||
// #2587 — resolveBulkNameCollisions: guards against createProviderConnection's
|
||||
// name-based upsert silently replacing an existing connection when bulk-add
|
||||
// auto-naming ("Key 1", "Key 2", ...) restarts from 1 and collides with a
|
||||
// name already saved for the provider.
|
||||
test("resolveBulkNameCollisions: no-op when nothing collides", () => {
|
||||
const entries = [{ name: "Key 1" }, { name: "Key 2" }];
|
||||
const out = resolveBulkNameCollisions(entries, ["Prod"]);
|
||||
assert.deepEqual(
|
||||
out.map((e) => e.name),
|
||||
["Key 1", "Key 2"]
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBulkNameCollisions: renames an auto-named entry colliding with an existing connection", () => {
|
||||
// Provider already has "Key 1" saved; a fresh bulk parse restarts auto-naming
|
||||
// at "Key 1" too. Without renaming, createProviderConnection would upsert
|
||||
// into the existing row instead of inserting.
|
||||
const entries = [{ name: "Key 1" }, { name: "Key 2" }];
|
||||
const out = resolveBulkNameCollisions(entries, ["Key 1"]);
|
||||
assert.deepEqual(
|
||||
out.map((e) => e.name),
|
||||
["Key 2", "Key 3"]
|
||||
);
|
||||
// Never reuses an existing name.
|
||||
assert.ok(!out.some((e) => e.name === "Key 1"));
|
||||
});
|
||||
|
||||
test("resolveBulkNameCollisions: renames a custom name colliding with an existing connection", () => {
|
||||
const entries = [{ name: "Prod" }];
|
||||
const out = resolveBulkNameCollisions(entries, ["Prod"]);
|
||||
assert.deepEqual(out.map((e) => e.name), ["Prod 1"]);
|
||||
});
|
||||
|
||||
test("resolveBulkNameCollisions: dedupes two identical custom names within the same batch", () => {
|
||||
// Two "Prod|apiKey" lines pasted in the same batch previously collided with
|
||||
// EACH OTHER (custom names get no auto-index), so the second entry would
|
||||
// upsert into the first instead of inserting a second connection.
|
||||
const entries = [{ name: "Prod" }, { name: "Prod" }];
|
||||
const out = resolveBulkNameCollisions(entries, []);
|
||||
const names = out.map((e) => e.name);
|
||||
assert.equal(new Set(names).size, names.length);
|
||||
assert.deepEqual(names, ["Prod", "Prod 1"]);
|
||||
});
|
||||
|
||||
test("resolveBulkNameCollisions: preserves non-name fields on renamed entries", () => {
|
||||
const entries = [{ name: "Key 1", apiKey: "sk-new" }];
|
||||
const out = resolveBulkNameCollisions(entries, ["Key 1"]);
|
||||
assert.equal(out[0].apiKey, "sk-new");
|
||||
assert.equal(out[0].name, "Key 2");
|
||||
});
|
||||
|
||||
test("resolveBulkNameCollisions: name comparison is case-insensitive", () => {
|
||||
const entries = [{ name: "key 1" }];
|
||||
const out = resolveBulkNameCollisions(entries, ["KEY 1"]);
|
||||
assert.equal(out[0].name, "key 2");
|
||||
});
|
||||
|
||||
test("resolveBulkNameCollisions: tolerates non-array existingNames", () => {
|
||||
const entries = [{ name: "Key 1" }];
|
||||
const out = resolveBulkNameCollisions(entries, null);
|
||||
assert.equal(out[0].name, "Key 1");
|
||||
});
|
||||
|
||||
74
tests/unit/bypass-response-claude-merge.test.ts
Normal file
74
tests/unit/bypass-response-claude-merge.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { mergeChunksToResponse } from "../../open-sse/utils/bypassResponse.ts";
|
||||
|
||||
/**
|
||||
* Regression guard for the Claude-format non-streaming bypass response bug:
|
||||
* mergeChunksToResponse() used to return `messageStart.message` as-is, which
|
||||
* the openai-to-claude translator always initializes with `content: []` —
|
||||
* the actual text only exists in the separate content_block_start/delta
|
||||
* events. A synthetic (non-streaming) Claude-format bypass response
|
||||
* therefore always came back with an empty `content` array, silently
|
||||
* dropping the bypass text ("CLI Command Execution: Clear Terminal", etc.)
|
||||
* from every Claude-format client (e.g. the Claude Code CLI).
|
||||
*/
|
||||
describe("mergeChunksToResponse (Claude format content reconstruction)", () => {
|
||||
const chunks = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_1",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "demo",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, cache_read_input_tokens: 2 },
|
||||
},
|
||||
},
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hello world" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { output_tokens: 3 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
|
||||
it("reconstructs the message content from content_block_start/delta chunks", () => {
|
||||
const result = mergeChunksToResponse(chunks, "claude") as Record<string, unknown>;
|
||||
|
||||
assert.equal(result.type, "message");
|
||||
assert.equal(result.role, "assistant");
|
||||
assert.deepEqual(result.content, [{ type: "text", text: "hello world" }]);
|
||||
});
|
||||
|
||||
it("merges start + delta usage and carries the final stop_reason", () => {
|
||||
const result = mergeChunksToResponse(chunks, "claude") as Record<string, unknown>;
|
||||
|
||||
assert.equal(result.stop_reason, "end_turn");
|
||||
assert.deepEqual(result.usage, {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 2,
|
||||
output_tokens: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the last chunk untouched for non-Claude formats", () => {
|
||||
const openaiChunks = [{ type: "chat.completion.chunk", choices: [] }];
|
||||
assert.equal(mergeChunksToResponse(openaiChunks, "openai"), openaiChunks[0]);
|
||||
});
|
||||
|
||||
it("falls back to a canned unknown response for an empty chunk list", () => {
|
||||
const result = mergeChunksToResponse([], "claude") as {
|
||||
model: string;
|
||||
choices: Array<{ message: { role: string } }>;
|
||||
};
|
||||
assert.equal(result.model, "unknown");
|
||||
assert.equal(result.choices[0].message.role, "assistant");
|
||||
});
|
||||
});
|
||||
@@ -274,7 +274,10 @@ test("chat completions route emits early keepalive while waiting for stream read
|
||||
assert.match(response.headers.get("content-type") || "", /text\/event-stream/);
|
||||
|
||||
const body = await readAll(response);
|
||||
assert.match(body, /: omniroute-keepalive/);
|
||||
assert.match(
|
||||
body,
|
||||
/data: \{"id":"omniroute-keepalive","object":"chat\.completion\.chunk"/
|
||||
);
|
||||
assert.match(body, /OK/);
|
||||
assert.match(body, /\[DONE\]/);
|
||||
});
|
||||
|
||||
227
tests/unit/chatgpt-web-async-image-ws-shapes-7357.test.ts
Normal file
227
tests/unit/chatgpt-web-async-image-ws-shapes-7357.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import(
|
||||
"../../open-sse/executors/chatgpt-web.ts"
|
||||
);
|
||||
const { __setTlsFetchOverrideForTesting } = await import(
|
||||
"../../open-sse/services/chatgptTlsClient.ts"
|
||||
);
|
||||
|
||||
function makeHeaders(map: Record<string, string> = {}) {
|
||||
const h = new Headers();
|
||||
for (const [k, v] of Object.entries(map)) h.set(k, String(v));
|
||||
return h;
|
||||
}
|
||||
|
||||
const CONVERSATION_ID = "conv-async-7357";
|
||||
const FINAL_POINTER = "file-service://file-final-7357";
|
||||
|
||||
// SSE stream: assistant starts, tool kicks off image_gen (the "Processing
|
||||
// image..." card via metadata.image_gen_task_id), stream ends WITHOUT any
|
||||
// resolved image_asset_pointer — the real async case where the image only
|
||||
// shows up later, over the celsius WebSocket.
|
||||
function asyncImageGenSseText(): string {
|
||||
const events = [
|
||||
{
|
||||
conversation_id: CONVERSATION_ID,
|
||||
message: {
|
||||
id: "msg-1",
|
||||
author: { role: "assistant" },
|
||||
content: { content_type: "text", parts: ["Generating your image..."] },
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
conversation_id: CONVERSATION_ID,
|
||||
message: {
|
||||
id: "tool-1",
|
||||
author: { role: "tool", name: "t2uay3k.sj1i4kz" },
|
||||
metadata: { image_gen_task_id: "task-7357" },
|
||||
content: { content_type: "text", parts: [] },
|
||||
},
|
||||
},
|
||||
];
|
||||
const chunks = events.map((e) => `data: ${JSON.stringify(e)}\r\n\r\n`);
|
||||
chunks.push("data: [DONE]\r\n\r\n");
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
// Fake global WebSocket: opens, then emits ONE frame shaped like chatgpt.com's
|
||||
// celsius wire format for the PLURAL case — payload.update_content.messages[]
|
||||
// — carrying the completed tool-role image_asset_pointer message. This is the
|
||||
// shape issue #7357 reports chatgpt.com sends and the current parser does not
|
||||
// recognize (it only reads update_content.message, singular).
|
||||
class FakeWebSocket extends EventEmitter {
|
||||
url: string;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onerror: ((ev: unknown) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
|
||||
constructor(url: string) {
|
||||
super();
|
||||
this.url = url;
|
||||
FakeWebSocket.instances.push(this);
|
||||
setTimeout(() => {
|
||||
this.onopen?.();
|
||||
setTimeout(() => {
|
||||
const frame = {
|
||||
type: "conversation-update",
|
||||
payload: {
|
||||
conversation_id: CONVERSATION_ID,
|
||||
update_content: {
|
||||
messages: [
|
||||
{
|
||||
message: {
|
||||
id: "img-msg-final",
|
||||
author: { role: "tool", name: "t2uay3k.sj1i4kz" },
|
||||
content: {
|
||||
content_type: "multimodal_text",
|
||||
parts: [
|
||||
{
|
||||
content_type: "image_asset_pointer",
|
||||
asset_pointer: FINAL_POINTER,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "finished_successfully",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
this.onmessage?.({ data: JSON.stringify(frame) });
|
||||
}, 5);
|
||||
}, 5);
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
test("#7357: async image_gen pointer delivered via update_content.messages[] should resolve to markdown (currently lost → 502)", async () => {
|
||||
__resetChatGptWebCachesForTesting();
|
||||
const previousWebSocket = (globalThis as Record<string, unknown>).WebSocket;
|
||||
const previousTimeout = process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS;
|
||||
process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = "300"; // keep the probe fast
|
||||
(globalThis as Record<string, unknown>).WebSocket = FakeWebSocket;
|
||||
|
||||
__setTlsFetchOverrideForTesting(async (url, opts = {}) => {
|
||||
const u = String(url);
|
||||
const method = opts.method || "GET";
|
||||
if ((u === "https://chatgpt.com/" || u === "https://chatgpt.com") && method === "GET") {
|
||||
return {
|
||||
status: 200,
|
||||
headers: makeHeaders({ "Content-Type": "text/html" }),
|
||||
text: '<html data-build="prod-test"><script src="https://cdn.oaistatic.com/main.js"></script></html>',
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
if (u.includes("/api/auth/session")) {
|
||||
return {
|
||||
status: 200,
|
||||
headers: makeHeaders({ "Content-Type": "application/json" }),
|
||||
text: JSON.stringify({
|
||||
accessToken: "jwt-7357",
|
||||
expires: new Date(Date.now() + 3600_000).toISOString(),
|
||||
user: { id: "u-7357" },
|
||||
}),
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
if (u.includes("/backend-api/sentinel/chat-requirements")) {
|
||||
return {
|
||||
status: 200,
|
||||
headers: makeHeaders({ "Content-Type": "application/json" }),
|
||||
text: JSON.stringify({ token: "t", proofofwork: { required: false } }),
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
if (u.endsWith("/backend-api/f/conversation") || u.endsWith("/backend-api/conversation")) {
|
||||
return {
|
||||
status: 200,
|
||||
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
|
||||
text: asyncImageGenSseText(),
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
if (u.includes("/backend-api/celsius/ws/user")) {
|
||||
return {
|
||||
status: 200,
|
||||
headers: makeHeaders({ "Content-Type": "application/json" }),
|
||||
text: JSON.stringify({ websocket_url: "wss://chatgpt.com/fake-celsius-socket" }),
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
// Resolution path for FINAL_POINTER, exercised ONLY if the WS listener
|
||||
// actually extracts the pointer from the update_content.messages[] frame.
|
||||
if (u.match(/\/backend-api\/files\/[^/]+\/download/)) {
|
||||
return {
|
||||
status: 200,
|
||||
headers: makeHeaders({ "Content-Type": "application/json" }),
|
||||
text: JSON.stringify({
|
||||
download_url: "https://chatgpt.com/backend-api/estuary/content?id=file-final-7357",
|
||||
}),
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
if (u.startsWith("https://chatgpt.com/backend-api/estuary/content")) {
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
return {
|
||||
status: 200,
|
||||
headers: makeHeaders({ "Content-Type": "image/png" }),
|
||||
text: `data:image/png;base64,${pngBytes.toString("base64")}`,
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
return { status: 404, headers: makeHeaders(), text: "not mocked", body: null };
|
||||
});
|
||||
|
||||
try {
|
||||
const executor = new ChatGptWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "gpt-5.5",
|
||||
body: { messages: [{ role: "user", content: "generate an image of a kitten" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test-session-cookie" },
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200, "executor itself does not error");
|
||||
const json = await result.response.json();
|
||||
const content = String(json?.choices?.[0]?.message?.content || "");
|
||||
|
||||
assert.ok(FakeWebSocket.instances.length >= 1, "a WebSocket connection was opened");
|
||||
|
||||
// Expected/correct behavior: the celsius WebSocket delivered a complete,
|
||||
// well-formed tool-role image_asset_pointer message via chatgpt.com's
|
||||
// update_content.messages[] (plural) shape. OmniRoute should extract it,
|
||||
// resolve it, and append image markdown — just like the already-covered
|
||||
// update_content.message (singular) case in tests/unit/chatgpt-web.test.ts.
|
||||
assert.match(
|
||||
content,
|
||||
/!\[image\]\([^)]*\/v1\/chatgpt-web\/image\/[a-f0-9]+\)/,
|
||||
"BUG #7357: image pointer delivered via update_content.messages[] (plural) was not " +
|
||||
"resolved into markdown — waitForImageViaWebSocket() only recognizes the singular " +
|
||||
"update_content.message / payload.message / data.message shapes and silently drops " +
|
||||
"this frame, losing an already-completed upstream image."
|
||||
);
|
||||
assert.equal(
|
||||
json.x_image_resolution_failed,
|
||||
undefined,
|
||||
"resolution succeeded — no unresolved-pointer flag expected"
|
||||
);
|
||||
} finally {
|
||||
__setTlsFetchOverrideForTesting(null);
|
||||
if (previousWebSocket === undefined) delete (globalThis as Record<string, unknown>).WebSocket;
|
||||
else (globalThis as Record<string, unknown>).WebSocket = previousWebSocket;
|
||||
if (previousTimeout === undefined) delete process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS;
|
||||
else process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = previousTimeout;
|
||||
}
|
||||
});
|
||||
57
tests/unit/check-pack-boot.test.ts
Normal file
57
tests/unit/check-pack-boot.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pickTarball, evaluateBoot, pickPort } from "../../scripts/check/check-pack-boot.mjs";
|
||||
|
||||
// WS1.2 (T1, v3.8.49 quality plan) — pure-function guards for the tarball boot-smoke
|
||||
// gate that kills the #7065 class (published artifact crashes on every boot because a
|
||||
// packaging list drifted; 3rd recurrence). The end-to-end path runs in CI's
|
||||
// package-artifact job; these tests pin the decision logic.
|
||||
|
||||
const SCRIPT_PATH = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../scripts/check/check-pack-boot.mjs"
|
||||
);
|
||||
|
||||
test("pickTarball extracts the filename from npm pack --json output", () => {
|
||||
assert.equal(pickTarball('[{"filename":"omniroute-3.8.49.tgz","size":1}]'), "omniroute-3.8.49.tgz");
|
||||
});
|
||||
|
||||
test("pickTarball normalizes scoped slashes to the on-disk dash form", () => {
|
||||
assert.equal(pickTarball('[{"filename":"@scope/pkg-1.0.0.tgz"}]'), "@scope-pkg-1.0.0.tgz");
|
||||
});
|
||||
|
||||
test("pickTarball throws on empty/odd npm output instead of booting garbage", () => {
|
||||
assert.throws(() => pickTarball("[]"));
|
||||
assert.throws(() => pickTarball("{}"));
|
||||
});
|
||||
|
||||
test("evaluateBoot passes on HTTP 200 + matching version, whatever the health status", () => {
|
||||
const r = evaluateBoot(200, { version: "3.8.49", status: "warning" }, "3.8.49");
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.failures, []);
|
||||
});
|
||||
|
||||
test("evaluateBoot fails on non-200, non-JSON body, and version mismatch", () => {
|
||||
assert.equal(evaluateBoot(503, { version: "3.8.49" }, "3.8.49").ok, false);
|
||||
assert.equal(evaluateBoot(200, null, "3.8.49").ok, false);
|
||||
const wrong = evaluateBoot(200, { version: "3.8.48" }, "3.8.49");
|
||||
assert.equal(wrong.ok, false);
|
||||
assert.match(wrong.failures[0], /3\.8\.48/);
|
||||
});
|
||||
|
||||
test("pickPort stays inside the reserved smoke range for any pid", () => {
|
||||
for (const seed of [0, 1, 4000, 65535, 123456]) {
|
||||
const p = pickPort(seed);
|
||||
assert.ok(p >= 23000 && p < 27000, `port ${p} out of range for seed ${seed}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("source guard: the gate polls the real health endpoint of the INSTALLED binary", () => {
|
||||
const src = readFileSync(SCRIPT_PATH, "utf8");
|
||||
assert.ok(src.includes('"install", "-g", "--prefix"'), "must install the packed tarball into a clean prefix");
|
||||
assert.ok(src.includes("/api/monitoring/health"), "must poll the health endpoint");
|
||||
assert.ok(src.indexOf("npm") < src.indexOf("spawn"), "pack+install must precede the boot spawn");
|
||||
});
|
||||
@@ -14,12 +14,14 @@
|
||||
* (`if (file.endsWith("check-test-masking.test.ts")) continue;` in
|
||||
* scripts/check/check-test-masking.mjs) for precisely this reason — this test
|
||||
* asserts evaluateMasking() now applies the same exclusion for its diff-based
|
||||
* tautology counters, using the real base(origin/main)/head(HEAD) diff of
|
||||
* tautology counters, against the REAL current source of
|
||||
* tests/unit/check-test-masking.test.ts.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
countTautologies,
|
||||
@@ -28,16 +30,17 @@ import {
|
||||
} from "../../scripts/check/check-test-masking.mjs";
|
||||
|
||||
const FILE = "tests/unit/check-test-masking.test.ts";
|
||||
|
||||
function git(args: string[]): string {
|
||||
return execFileSync("git", args, { encoding: "utf8" });
|
||||
}
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
test("#6634: check-test-masking.test.ts's own tautology fixtures must not self-flag as weakening", () => {
|
||||
// origin/main predates the #6404 fixtures (countBareTautologies/scanBareTautologies
|
||||
// tests) that legitimately embed tautology-pattern literals as string fixtures.
|
||||
const baseSrc = git(["show", "origin/main:" + FILE]);
|
||||
const headSrc = git(["show", "HEAD:" + FILE]);
|
||||
// Read the REAL current source from disk rather than a git ref: the Unit Tests
|
||||
// job checks out a shallow/single-ref tree with no origin/main, so `git show
|
||||
// origin/main:<file>` failed the shard before it ever exercised the masking
|
||||
// behavior under test. An empty base models the file's pre-#6404 state (no
|
||||
// fixtures), which maximizes headTaut - baseTaut — the strictest input for the
|
||||
// exclusion this test asserts.
|
||||
const baseSrc = "";
|
||||
const headSrc = fs.readFileSync(path.join(REPO_ROOT, FILE), "utf8");
|
||||
|
||||
const perFile = [
|
||||
{
|
||||
|
||||
49
tests/unit/chenzk-provider-2437.test.ts
Normal file
49
tests/unit/chenzk-provider-2437.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
const { PROVIDER_ENDPOINTS } = await import("../../src/shared/constants/config.ts");
|
||||
const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
|
||||
const CHENZK_CHAT_URL = "https://chenzk.top/v1/chat/completions";
|
||||
const CHENZK_MODELS_URL = "https://chenzk.top/v1/models";
|
||||
|
||||
// Port of decolua/9router#2437 ("feat: add Chenzk API provider"), adapted to
|
||||
// OmniRoute's directory-per-provider registry (`open-sse/config/providers/registry/`)
|
||||
// and the `src/shared/constants/providers/apikey/*` metadata catalog, instead of
|
||||
// upstream's flat `open-sse/providers/registry/*.js` + hardcoded model array. Chenzk
|
||||
// exposes a "New API"-style OpenAI-compatible gateway with a live /v1/models catalog,
|
||||
// so — matching the sibling kenari/x5lab/sumopod gateways already in this catalog —
|
||||
// models are resolved via passthrough rather than a speculative hardcoded list.
|
||||
test("Chenzk is registered as an OpenAI-compatible API-key gateway", () => {
|
||||
const entry = APIKEY_PROVIDERS.chenzk;
|
||||
assert.ok(entry, "APIKEY_PROVIDERS.chenzk must be defined");
|
||||
assert.equal(entry.id, "chenzk");
|
||||
assert.equal(entry.alias, "chenzk");
|
||||
assert.equal(entry.name, "Chenzk API");
|
||||
assert.equal(entry.website, "https://chenzk.top");
|
||||
assert.equal(entry.passthroughModels, true);
|
||||
});
|
||||
|
||||
test("Chenzk exposes the OpenAI-compatible chat completions endpoint", () => {
|
||||
assert.equal(PROVIDER_ENDPOINTS.chenzk, CHENZK_CHAT_URL);
|
||||
});
|
||||
|
||||
test("Chenzk registry entry uses OpenAI format with bearer API-key auth and passthrough models", () => {
|
||||
const entry = providerRegistry.chenzk;
|
||||
assert.ok(entry, "providerRegistry.chenzk must be defined");
|
||||
assert.equal(entry.id, "chenzk");
|
||||
assert.equal(entry.alias, "chenzk");
|
||||
assert.equal(entry.format, "openai");
|
||||
assert.equal(entry.executor, "default");
|
||||
assert.equal(entry.authType, "apikey");
|
||||
assert.equal(entry.authHeader, "bearer");
|
||||
assert.equal(entry.baseUrl, CHENZK_CHAT_URL);
|
||||
assert.equal(entry.modelsUrl, CHENZK_MODELS_URL);
|
||||
assert.equal(entry.passthroughModels, true);
|
||||
assert.deepEqual(
|
||||
entry.models,
|
||||
[],
|
||||
"Chenzk ships no speculative seeded models — live catalog via passthrough only"
|
||||
);
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import { classifyPaths } from "../../scripts/quality/classify-pr-changes.mjs";
|
||||
|
||||
test("pure docs PR → docs only (no code unit/lint bag)", () => {
|
||||
const c = classifyPaths(["docs/architecture/QUALITY_GATES.md", "README.md"]);
|
||||
assert.deepEqual(c, { code: false, docs: true, i18n: false, workflow: false });
|
||||
assert.deepEqual(c, { code: false, docs: true, i18n: false, workflow: false, testsOnly: false });
|
||||
});
|
||||
|
||||
test("openapi under docs/ → docs (contract gates live in docs-sync, not unit)", () => {
|
||||
@@ -26,7 +26,7 @@ test("openapi under docs/ → docs (contract gates live in docs-sync, not unit)"
|
||||
|
||||
test("pure message catalog → i18n only (not full unit suite)", () => {
|
||||
const c = classifyPaths(["src/i18n/messages/en.json", "src/i18n/messages/ko.json"]);
|
||||
assert.deepEqual(c, { code: false, docs: false, i18n: true, workflow: false });
|
||||
assert.deepEqual(c, { code: false, docs: false, i18n: true, workflow: false, testsOnly: false });
|
||||
});
|
||||
|
||||
test("i18n tooling/scripts → i18n + code (tooling can break runtime paths)", () => {
|
||||
@@ -49,7 +49,7 @@ test("workflow change → workflow + code (gates protect the gates)", () => {
|
||||
|
||||
test("production source → code", () => {
|
||||
const c = classifyPaths(["open-sse/handlers/chatCore.ts", "src/lib/db/core.ts"]);
|
||||
assert.deepEqual(c, { code: true, docs: false, i18n: false, workflow: false });
|
||||
assert.deepEqual(c, { code: true, docs: false, i18n: false, workflow: false, testsOnly: false });
|
||||
});
|
||||
|
||||
test("mixed docs + code → both flags (jobs union their filters)", () => {
|
||||
@@ -65,5 +65,31 @@ test("unknown path → code fail-safe (never skip heavy gates by accident)", ()
|
||||
|
||||
test("empty change list → all false (nothing to validate)", () => {
|
||||
const c = classifyPaths([]);
|
||||
assert.deepEqual(c, { code: false, docs: false, i18n: false, workflow: false });
|
||||
assert.deepEqual(c, { code: false, docs: false, i18n: false, workflow: false, testsOnly: false });
|
||||
});
|
||||
|
||||
// WS3.1 (v3.8.49 quality plan) — testsOnly powers the hotfix/test-only fast lane:
|
||||
// a diff touching ONLY tests/ (and no tests/e2e/ spec) does not change the served
|
||||
// app, so the 9-shard E2E matrix adds wall-time without coverage. e2e specs are
|
||||
// excluded from the shortcut — changing an e2e spec REQUIRES running e2e.
|
||||
|
||||
test("testsOnly: pure unit-test diff → true (still code)", () => {
|
||||
const c = classifyPaths(["tests/unit/foo.test.ts", "tests/integration/bar.test.ts"]);
|
||||
assert.equal(c.testsOnly, true);
|
||||
assert.equal(c.code, true);
|
||||
});
|
||||
|
||||
test("testsOnly: any non-test file flips it false", () => {
|
||||
const c = classifyPaths(["tests/unit/foo.test.ts", "src/lib/db/core.ts"]);
|
||||
assert.equal(c.testsOnly, false);
|
||||
});
|
||||
|
||||
test("testsOnly: touching an e2e spec is NOT tests-only (e2e must run)", () => {
|
||||
const c = classifyPaths(["tests/e2e/login.spec.ts"]);
|
||||
assert.equal(c.testsOnly, false);
|
||||
});
|
||||
|
||||
test("testsOnly: empty change list → false (fail-safe)", () => {
|
||||
const c = classifyPaths([]);
|
||||
assert.equal(c.testsOnly, false);
|
||||
});
|
||||
|
||||
103
tests/unit/claude-to-openai-system-role-6954.test.ts
Normal file
103
tests/unit/claude-to-openai-system-role-6954.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Tests for #6954 — mid-conversation system turns misattributed as assistant.
|
||||
*
|
||||
* `convertClaudeMessage` mapped any role that wasn't "user" or "tool" to
|
||||
* "assistant", so a Claude message with `role: "system"` (e.g. an injected
|
||||
* system reminder mid-conversation) was forwarded to OpenAI-format upstreams
|
||||
* as an assistant turn — polluting the conversation history.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { claudeToOpenAIRequest } =
|
||||
await import("../../open-sse/translator/request/claude-to-openai.ts");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. system message mid-conversation keeps role: "system"
|
||||
// ---------------------------------------------------------------------------
|
||||
test("mid-conversation system message preserves role:system (not assistant)", () => {
|
||||
const result = claudeToOpenAIRequest(
|
||||
"gpt-4o",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "hi" },
|
||||
{ role: "system", content: "Reminder: be concise." },
|
||||
{ role: "user", content: "ok" },
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const roles = result.messages.map((m: { role: string }) => m.role);
|
||||
assert.deepEqual(roles, ["user", "assistant", "system", "user"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. system message with array content keeps role: "system"
|
||||
// ---------------------------------------------------------------------------
|
||||
test("system message with array content preserves role:system", () => {
|
||||
const result = claudeToOpenAIRequest(
|
||||
"gpt-4o",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "System reminder text" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const sysMsg = result.messages.find((m: { role: string }) => m.role === "system");
|
||||
assert.ok(sysMsg, "expected a system message in output");
|
||||
// Array content with text blocks is flattened to a string for system role
|
||||
assert.equal(
|
||||
typeof sysMsg.content === "string" ? sysMsg.content : JSON.stringify(sysMsg.content),
|
||||
"System reminder text"
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. top-level body.system still produces role: "system" (regression check)
|
||||
// ---------------------------------------------------------------------------
|
||||
test("body.system still produces role:system at index 0", () => {
|
||||
const result = claudeToOpenAIRequest(
|
||||
"gpt-4o",
|
||||
{
|
||||
system: "You are helpful.",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(result.messages[0].role, "system");
|
||||
assert.equal(result.messages[1].role, "user");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. assistant with tool_use still maps to assistant (regression check)
|
||||
// ---------------------------------------------------------------------------
|
||||
test("assistant role still maps to assistant (no regression)", () => {
|
||||
const result = claudeToOpenAIRequest(
|
||||
"gpt-4o",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "use the tool" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "calling tool" },
|
||||
{ type: "tool_use", id: "t1", name: "foo", input: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const roles = result.messages.map((m: { role: string }) => m.role);
|
||||
assert.ok(roles.includes("assistant"), "assistant role must be preserved");
|
||||
});
|
||||
@@ -30,7 +30,7 @@ test(`CLI_TOOLS has exactly ${EXPECTED_AGENT_COUNT} agent entries`, () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("CLI_TOOLS total code entries (including none) equals 24 (20 visible + 4 none)", () => {
|
||||
test("CLI_TOOLS total code entries (including none) equals 25 (21 visible + 4 none)", () => {
|
||||
// code-none entries: antigravity, kiro, cursor (app), hermes (simple guide)
|
||||
const codeNone = codeAll.filter((t) => t.baseUrlSupport === "none");
|
||||
assert.equal(
|
||||
@@ -38,11 +38,11 @@ test("CLI_TOOLS total code entries (including none) equals 24 (20 visible + 4 no
|
||||
4,
|
||||
`Expected 4 code entries with baseUrlSupport='none', got ${codeNone.length}: ${codeNone.map((t) => t.id).join(", ")}`
|
||||
);
|
||||
assert.equal(codeAll.length, 24, `Expected 24 total code entries, got ${codeAll.length}`);
|
||||
assert.equal(codeAll.length, 25, `Expected 25 total code entries, got ${codeAll.length}`);
|
||||
});
|
||||
|
||||
test("CLI_TOOLS total (code + agent) = 32", () => {
|
||||
assert.equal(all.length, 32, `Expected 32 total entries, got ${all.length}`);
|
||||
test("CLI_TOOLS total (code + agent) = 33", () => {
|
||||
assert.equal(all.length, 33, `Expected 33 total entries, got ${all.length}`);
|
||||
});
|
||||
|
||||
test("All code-none entries have configType mitm OR are legacy excluded entries", () => {
|
||||
@@ -66,7 +66,7 @@ test("All agent entries have baseUrlSupport 'full' or 'partial' (no agent is 'no
|
||||
}
|
||||
});
|
||||
|
||||
test("The 20 visible code entries match D15 list exactly (+ crush + codewhale)", () => {
|
||||
test("The 21 visible code entries match D15 list exactly (+ crush + codewhale + grok-build)", () => {
|
||||
const d15List = new Set([
|
||||
"claude",
|
||||
"codex",
|
||||
@@ -88,6 +88,7 @@ test("The 20 visible code entries match D15 list exactly (+ crush + codewhale)",
|
||||
"pi",
|
||||
"custom",
|
||||
"crush",
|
||||
"grok-build",
|
||||
]);
|
||||
const visibleIds = new Set(codeVisible.map((t) => t.id));
|
||||
for (const id of d15List) {
|
||||
|
||||
75
tests/unit/cli-dashboard-port.test.ts
Normal file
75
tests/unit/cli-dashboard-port.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* Replicate the parsePort + port resolution logic from bin/cli/commands/dashboard.mjs
|
||||
* to verify that PORT env var is respected when --port is not passed (mirrors
|
||||
* tests/unit/cli-serve-port.test.ts's convention for serve.mjs).
|
||||
*/
|
||||
function parsePort(value: string | undefined, fallback: number): number {
|
||||
const parsed = parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolvePort(optsPort: string | undefined, envPort: string | undefined): number {
|
||||
return parsePort(optsPort ?? envPort ?? "20128", 20128);
|
||||
}
|
||||
|
||||
test("dashboard port: uses --port flag when explicitly provided, overriding env", () => {
|
||||
const port = resolvePort("3000", "9999");
|
||||
assert.equal(port, 3000);
|
||||
});
|
||||
|
||||
test("dashboard port: falls back to PORT env var when --port is not provided", () => {
|
||||
const port = resolvePort(undefined, "20129");
|
||||
assert.equal(port, 20129);
|
||||
});
|
||||
|
||||
test("dashboard port: falls back to 20128 when neither --port nor PORT env var is set", () => {
|
||||
const port = resolvePort(undefined, undefined);
|
||||
assert.equal(port, 20128);
|
||||
});
|
||||
|
||||
test("dashboard port: invalid --port (non-numeric) falls back to 20128", () => {
|
||||
const port = resolvePort("abc", undefined);
|
||||
assert.equal(port, 20128);
|
||||
});
|
||||
|
||||
test("dashboard port: --port 0 (out of range) falls back to 20128", () => {
|
||||
const port = resolvePort("0", undefined);
|
||||
assert.equal(port, 20128);
|
||||
});
|
||||
|
||||
test("dashboard port: --port 70000 (out of range) falls back to 20128", () => {
|
||||
const port = resolvePort("70000", undefined);
|
||||
assert.equal(port, 20128);
|
||||
});
|
||||
|
||||
test("dashboard URL generation: http://localhost:<port> built correctly for a custom port", () => {
|
||||
const port = resolvePort(undefined, "31337");
|
||||
assert.equal(`http://localhost:${port}`, "http://localhost:31337");
|
||||
});
|
||||
|
||||
test("dashboard command: --port option has no Commander default", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../bin/cli/commands/dashboard.mjs"),
|
||||
"utf-8",
|
||||
);
|
||||
// Ensure the option does NOT carry a baked-in Commander default (third arg).
|
||||
assert.match(
|
||||
dashboardSource,
|
||||
/\.option\("--port <port>",\s*"Port the server is running on"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("dashboard command: source references process.env.PORT (env-fallback regression guard)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, "../../bin/cli/commands/dashboard.mjs"),
|
||||
"utf-8",
|
||||
);
|
||||
assert.match(dashboardSource, /process\.env\.PORT/);
|
||||
});
|
||||
80
tests/unit/cli-helper/tool-detector-win32-7279.test.ts
Normal file
80
tests/unit/cli-helper/tool-detector-win32-7279.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import * as toolDetector from "../../../src/lib/cli-helper/tool-detector.ts";
|
||||
|
||||
// #7279 (re-drift of #968) — detectBinary() in tool-detector.ts never checked
|
||||
// process.platform and never passed shell:true, so on native Windows an
|
||||
// installed CLI (npm installs claude/codex/opencode as .cmd shims) was reported
|
||||
// as NOT installed:
|
||||
// 1. execFileImpl(binary, ["--version"]) fails without shell:true for .cmd shims
|
||||
// (Node's CVE-2024-27980 hardening).
|
||||
// 2. the `which` fallback doesn't exist on native Windows (no WSL/git-bash).
|
||||
// Both throw, both are swallowed by empty catches, detectBinary returns
|
||||
// { installed: false }. cliRuntime.ts::locateCommand already solved this for
|
||||
// the runtime-spawn path (#968); this fix reuses it here.
|
||||
//
|
||||
// Methodological note (see plan-file): the `which` fallback previously called
|
||||
// the RAW execFileAsync, not the injected __setExecFileImpl hook, so it wasn't
|
||||
// mockable and could silently "pass" using the real system `which`. Uses
|
||||
// `hermes` (confirmed absent from PATH) to avoid that trap; also uses a
|
||||
// dedicated __setLocateCommandImpl hook (mirrors __setExecFileImpl) so the
|
||||
// win32 existence probe is deterministic here instead of depending on a real
|
||||
// `where.exe`.
|
||||
|
||||
describe("tool-detector — win32 (#7279)", () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value });
|
||||
}
|
||||
|
||||
before(() => {
|
||||
setPlatform("win32");
|
||||
|
||||
toolDetector.__setLocateCommandImpl(async (command: string) => {
|
||||
if (command === "hermes") {
|
||||
return {
|
||||
installed: true,
|
||||
commandPath: "C:\\Users\\dev\\AppData\\Roaming\\npm\\hermes.cmd",
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
return { installed: false, commandPath: null, reason: "not_found" };
|
||||
});
|
||||
|
||||
// @ts-expect-error - internal test hook
|
||||
toolDetector.__setExecFileImpl(async (_cmd: string, _args: string[], opts?: { shell?: boolean }) => {
|
||||
// Reproduces the real-world failure: without shell:true, spawning the
|
||||
// .cmd shim throws (Node's CVE-2024-27980 hardening on Windows).
|
||||
if (opts?.shell === true) {
|
||||
return { stdout: "v0.75.3\n" };
|
||||
}
|
||||
throw new Error("spawn hermes.cmd ENOENT (shell:true required on win32 for .cmd shims)");
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// This is the only test file exercising these hooks — node:test isolates
|
||||
// each file's module cache, so no further reset is needed for other suites.
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, "platform", originalPlatformDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports an installed CLI as installed on native Windows (.cmd shim probed with shell:true)", async () => {
|
||||
const result = await toolDetector.detectTool("hermes");
|
||||
assert.ok(result !== null);
|
||||
assert.strictEqual(
|
||||
result!.installed,
|
||||
true,
|
||||
"expected hermes to be detected as installed via locateCommand + shell:true probe on win32"
|
||||
);
|
||||
assert.strictEqual(result!.version, "0.75.3");
|
||||
});
|
||||
|
||||
it("reports a genuinely absent CLI as not installed on native Windows", async () => {
|
||||
const result = await toolDetector.detectTool("openclaw");
|
||||
assert.ok(result !== null);
|
||||
assert.strictEqual(result!.installed, false);
|
||||
});
|
||||
});
|
||||
@@ -71,20 +71,60 @@ test("buildEnvWithRuntime preserva NODE_PATH existente", async () => {
|
||||
assert.ok(env.NODE_PATH.includes("/existing/path"), "NODE_PATH original deve ser preservado");
|
||||
});
|
||||
|
||||
test("isBetterSqliteBinaryValid detecta ELF magic bytes (Linux)", async () => {
|
||||
test("isBetterSqliteBinaryValid rejeita binário com magic bytes válidos mas ABI incompatível (regressão #2493)", async () => {
|
||||
// Regression for upstream 9router#2493: a binary that only "looks" native (correct ELF/Mach-O/PE
|
||||
// header) but was built for a different Node ABI (NODE_MODULE_VERSION) must NOT be reported as
|
||||
// valid — loading it crashes the process (segfault) instead of triggering a rebuild.
|
||||
const { getRuntimeNodeModules, isBetterSqliteBinaryValid } =
|
||||
await import("../../bin/cli/runtime/nativeDeps.mjs");
|
||||
const nm = getRuntimeNodeModules();
|
||||
const buildDir = join(nm, "better-sqlite3", "build", "Release");
|
||||
mkdirSync(buildDir, { recursive: true });
|
||||
const binary = join(buildDir, "better_sqlite3.node");
|
||||
const buf = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]);
|
||||
const { platform } = await import("node:os");
|
||||
const os = platform();
|
||||
// Correct file-format magic bytes for the current OS, but not a real, loadable native addon —
|
||||
// this is exactly what the old magic-bytes-only check let through.
|
||||
const magicByPlatform = {
|
||||
linux: [0x7f, 0x45, 0x4c, 0x46],
|
||||
darwin: [0xcf, 0xfa, 0xed, 0xfe],
|
||||
win32: [0x4d, 0x5a],
|
||||
};
|
||||
const magic = magicByPlatform[os] ?? magicByPlatform.linux;
|
||||
const buf = Buffer.concat([Buffer.from(magic), Buffer.alloc(64, 0)]);
|
||||
writeFileSync(binary, buf);
|
||||
const result = isBetterSqliteBinaryValid();
|
||||
const { platform } = await import("node:os");
|
||||
if (platform() === "linux") {
|
||||
assert.equal(result, true, "ELF magic bytes devem ser válidos no Linux");
|
||||
assert.equal(
|
||||
result,
|
||||
false,
|
||||
"binário com header válido mas ABI/conteúdo incompatível deve ser inválido"
|
||||
);
|
||||
rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("isBetterSqliteBinaryValid aceita um binário nativo real e carregável", async () => {
|
||||
const { getRuntimeNodeModules, isBetterSqliteBinaryValid } =
|
||||
await import("../../bin/cli/runtime/nativeDeps.mjs");
|
||||
const { existsSync, copyFileSync } = await import("node:fs");
|
||||
const realBinary = join(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
if (!existsSync(realBinary)) {
|
||||
// Ambient runtime without a compiled better-sqlite3 binary — nothing to assert here.
|
||||
return;
|
||||
}
|
||||
const nm = getRuntimeNodeModules();
|
||||
const buildDir = join(nm, "better-sqlite3", "build", "Release");
|
||||
mkdirSync(buildDir, { recursive: true });
|
||||
const binary = join(buildDir, "better_sqlite3.node");
|
||||
copyFileSync(realBinary, binary);
|
||||
const result = isBetterSqliteBinaryValid();
|
||||
assert.equal(result, true, "um binário real, compatível com o Node atual, deve ser válido");
|
||||
rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
test("CLI_TOOLS registry contains all expected tools (plan 14 — 32 total + crush + codewhale + omp + letta)", async () => {
|
||||
test("CLI_TOOLS registry contains all expected tools (plan 14 — 33 total + crush + codewhale + omp + letta + grok-build)", async () => {
|
||||
const { CLI_TOOLS } = await import("../../src/shared/constants/cliTools.ts");
|
||||
// windsurf and amp removed per plan 14 D17 (MITM backlog plan 11)
|
||||
// New entries added: roo, jcode, deepseek-tui, smelt, pi, aider, forge,
|
||||
@@ -10,6 +10,7 @@ test("CLI_TOOLS registry contains all expected tools (plan 14 — 32 total + cru
|
||||
// codewhale added 2026-07-02 as a dual entry alongside deepseek-tui
|
||||
// (CodeWhale is the actively-maintained successor to DeepSeek TUI).
|
||||
// omp + letta added by #6318 (agent-category CLI integrations).
|
||||
// grok-build added — xAI Grok Build TUI coding agent (ported from upstream decolua/9router#2571).
|
||||
const expected = [
|
||||
"claude",
|
||||
"codex",
|
||||
@@ -43,6 +44,7 @@ test("CLI_TOOLS registry contains all expected tools (plan 14 — 32 total + cru
|
||||
"letta",
|
||||
"agent-deck",
|
||||
"crush",
|
||||
"grok-build",
|
||||
];
|
||||
for (const id of expected) {
|
||||
assert.ok(id in CLI_TOOLS, `Missing tool: ${id}`);
|
||||
|
||||
56
tests/unit/cli-version-fastpath.test.ts
Normal file
56
tests/unit/cli-version-fastpath.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isVersionFastPath } from "../../bin/cli/utils/versionFastPath.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// argv shape is [node, script, ...args]
|
||||
const argv = (...args: string[]) => ["node", "omniroute", ...args];
|
||||
|
||||
test("fast-path selector: bare --version/-V select the fast path", () => {
|
||||
assert.equal(isVersionFastPath(argv("--version")), true);
|
||||
assert.equal(isVersionFastPath(argv("-V")), true);
|
||||
});
|
||||
|
||||
test("fast-path selector: --help does NOT select the fast path (help text is dynamic)", () => {
|
||||
assert.equal(isVersionFastPath(argv("--help")), false);
|
||||
assert.equal(isVersionFastPath(argv("-h")), false);
|
||||
});
|
||||
|
||||
test("fast-path selector: extra args or a subcommand alongside --version fall through", () => {
|
||||
assert.equal(isVersionFastPath(argv("serve", "--version")), false);
|
||||
assert.equal(isVersionFastPath(argv("--version", "extra")), false);
|
||||
assert.equal(isVersionFastPath(argv("--lang", "en", "--version")), false);
|
||||
});
|
||||
|
||||
test("fast-path selector: no args or a real command do not select the fast path", () => {
|
||||
assert.equal(isVersionFastPath(argv()), false);
|
||||
assert.equal(isVersionFastPath(argv("serve")), false);
|
||||
});
|
||||
|
||||
test("fast-path selector: defensive on non-array input", () => {
|
||||
// @ts-expect-error intentional bad input
|
||||
assert.equal(isVersionFastPath(undefined), false);
|
||||
});
|
||||
|
||||
test("omniroute CLI --version fast-path prints ONLY the version, skipping bootstrap output", async () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(process.cwd(), "package.json"), "utf8")
|
||||
) as { version: string };
|
||||
|
||||
const { stdout } = await execFileAsync(process.execPath, ["bin/omniroute.mjs", "--version"], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, DATA_DIR: "" },
|
||||
});
|
||||
|
||||
// Before the fast-path, env-file loading (loadEnvFile) runs ahead of Commander and
|
||||
// prints "Loaded env from ..." lines interleaved with the version — proving the full
|
||||
// bootstrap (tsx/esm polyfill, env loading, ~70-command Commander registration) ran
|
||||
// for a plain --version query. The fast-path must short-circuit before any of that,
|
||||
// so stdout is EXACTLY the version string and nothing else.
|
||||
assert.equal(stdout.trim(), pkg.version);
|
||||
});
|
||||
108
tests/unit/cloudflare-ai-neuron-exhaustion-6980.test.ts
Normal file
108
tests/unit/cloudflare-ai-neuron-exhaustion-6980.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Issue #6980 — Cloudflare Workers AI daily neuron exhaustion 429 must be
|
||||
* classified as quota_exhausted (not transient rate_limit).
|
||||
*
|
||||
* Two layers of defense:
|
||||
* 1. Provider-specific rule in providerErrorRules.ts → getProviderErrorRuleMatch
|
||||
* 2. Global QUOTA_PATTERNS in classify429.ts → looksLikeQuotaExhausted
|
||||
*
|
||||
* Without these, the 429 body "you have used up your daily free allocation of
|
||||
* 10,000 neurons" matches no keyword, falls through to rate_limit (~60s cooldown),
|
||||
* and the combo router keeps cycling through every cloudflare model on retry
|
||||
* against a budget that only resets at UTC midnight.
|
||||
*/
|
||||
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getProviderErrorRuleMatch,
|
||||
providerRuleRegistry,
|
||||
} from "../../open-sse/config/providerErrorRules.ts";
|
||||
import { classify429, looksLikeQuotaExhausted } from "../../src/shared/utils/classify429.ts";
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CF_NEURON_BODY =
|
||||
"you have used up your daily free allocation of 10,000 neurons, please upgrade to Cloudflare's Workers Paid plan";
|
||||
|
||||
const CF_NEURON_BODY_JSON = {
|
||||
errors: [
|
||||
{
|
||||
code: 4006,
|
||||
message:
|
||||
"you have used up your daily free allocation of 10,000 neurons, please upgrade to Cloudflare's Workers Paid plan",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Tests: provider-specific rule (primary path) ───────────────────────────
|
||||
|
||||
describe("#6980 provider rule: cloudflare-ai neuron exhaustion", () => {
|
||||
test("cloudflare-ai is registered in providerRuleRegistry", () => {
|
||||
assert.ok(providerRuleRegistry.has("cloudflare-ai"));
|
||||
});
|
||||
|
||||
test("429 with plain-string neuron body → quota_exhausted, scope connection", () => {
|
||||
const result = getProviderErrorRuleMatch("cloudflare-ai", 429, {}, CF_NEURON_BODY);
|
||||
assert.ok(result, "expected a match");
|
||||
assert.equal(result!.reason, "quota_exhausted");
|
||||
assert.equal(result!.scope, "connection");
|
||||
// No explicit cooldownMs — recordModelLockoutFailure resolves to next UTC midnight.
|
||||
assert.equal(result!.cooldownMs, undefined);
|
||||
});
|
||||
|
||||
test("429 with JSON-structured neuron body → quota_exhausted", () => {
|
||||
const result = getProviderErrorRuleMatch("cloudflare-ai", 429, {}, CF_NEURON_BODY_JSON);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.reason, "quota_exhausted");
|
||||
assert.equal(result!.scope, "connection");
|
||||
});
|
||||
|
||||
test("non-429 status does not match even with neuron body", () => {
|
||||
const result = getProviderErrorRuleMatch("cloudflare-ai", 500, {}, CF_NEURON_BODY);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("429 with unrelated body does not match", () => {
|
||||
const result = getProviderErrorRuleMatch(
|
||||
"cloudflare-ai",
|
||||
429,
|
||||
{},
|
||||
{
|
||||
error: "rate limited, try again later",
|
||||
}
|
||||
);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("provider name matching is case-insensitive", () => {
|
||||
const result = getProviderErrorRuleMatch("Cloudflare-AI", 429, {}, CF_NEURON_BODY);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.reason, "quota_exhausted");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: classify429 defense-in-depth (fallback path) ────────────────────
|
||||
|
||||
describe("#6980 classify429: daily free allocation pattern", () => {
|
||||
test("looksLikeQuotaExhausted matches neuron body string", () => {
|
||||
assert.ok(looksLikeQuotaExhausted(CF_NEURON_BODY));
|
||||
});
|
||||
|
||||
test("looksLikeQuotaExhausted matches neuron body JSON-stringified", () => {
|
||||
assert.ok(looksLikeQuotaExhausted(CF_NEURON_BODY_JSON));
|
||||
});
|
||||
|
||||
test("classify429 returns quota_exhausted for neuron body", () => {
|
||||
assert.equal(classify429({ status: 429, body: CF_NEURON_BODY }), "quota_exhausted");
|
||||
});
|
||||
|
||||
test("classify429 returns quota_exhausted for neuron JSON body", () => {
|
||||
assert.equal(classify429({ status: 429, body: CF_NEURON_BODY_JSON }), "quota_exhausted");
|
||||
});
|
||||
|
||||
test("classify429 returns rate_limit for generic 429 without quota keywords", () => {
|
||||
assert.equal(classify429({ status: 429, body: "Too many requests" }), "rate_limit");
|
||||
});
|
||||
});
|
||||
172
tests/unit/codex-import-refresh-validation-7522.test.ts
Normal file
172
tests/unit/codex-import-refresh-validation-7522.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// Regression test for #7522: POST /api/oauth/codex/import must validate the
|
||||
// imported refresh_token BEFORE persisting a connection. Previously a payload
|
||||
// carrying an already-invalidated refresh_token (e.g. `refresh_token_invalidated`
|
||||
// / a dead-on-arrival `auth.json`) was imported as `active` and only failed
|
||||
// confusingly on first real use.
|
||||
//
|
||||
// This test mocks global.fetch so `refreshCodexToken()` (open-sse/services/
|
||||
// tokenRefresh.ts) talks to a fake OpenAI OAuth token endpoint instead of the
|
||||
// network — the refresh exchange itself is reused, not reimplemented.
|
||||
//
|
||||
// DB handles are released in test.after (CLAUDE.md learning: unreleased
|
||||
// SQLite handles hang node:test).
|
||||
|
||||
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-import-refresh-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const route = await import("../../src/app/api/oauth/codex/import/route.ts");
|
||||
|
||||
test.before(async () => {
|
||||
await settingsDb.updateSettings({ requireLogin: false });
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function withMockedFetch<T>(impl: typeof fetch, fn: () => Promise<T>): Promise<T> {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = impl;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
async function postImport(body: unknown) {
|
||||
const request = new Request("http://localhost:20128/api/oauth/codex/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const response = await route.POST(request);
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
const BASE_RECORD = {
|
||||
access_token: "seed-access-token",
|
||||
refresh_token: "seed-refresh-token-2026-07-10",
|
||||
email: "operator@example.com",
|
||||
};
|
||||
|
||||
test("import: rejects a record whose refresh_token is already invalidated upstream (#7522)", async () => {
|
||||
await withMockedFetch(
|
||||
(async () =>
|
||||
jsonResponse({ error: { code: "refresh_token_invalidated" } }, 401)) as unknown as typeof fetch,
|
||||
async () => {
|
||||
const { status, body } = await postImport({ accounts: BASE_RECORD });
|
||||
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.success, false);
|
||||
assert.equal(body.imported, 0);
|
||||
assert.equal(body.failed, 1);
|
||||
assert.equal(body.results[0].ok, false);
|
||||
assert.match(body.results[0].error, /expired|codex login/i);
|
||||
|
||||
const rows = await providersDb.getProviderConnections({ provider: "codex" });
|
||||
const created = rows.find((r) => r.email === BASE_RECORD.email);
|
||||
assert.equal(created, undefined, "no connection should be persisted for a dead refresh_token");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("import: rejects a record whose refresh_token was already consumed (refresh_token_reused)", async () => {
|
||||
await withMockedFetch(
|
||||
(async () =>
|
||||
jsonResponse({ error: { code: "refresh_token_reused" } }, 400)) as unknown as typeof fetch,
|
||||
async () => {
|
||||
const { status, body } = await postImport({
|
||||
accounts: { ...BASE_RECORD, email: "reused@example.com" },
|
||||
});
|
||||
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.success, false);
|
||||
assert.equal(body.failed, 1);
|
||||
|
||||
const rows = await providersDb.getProviderConnections({ provider: "codex" });
|
||||
const created = rows.find((r) => r.email === "reused@example.com");
|
||||
assert.equal(created, undefined);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("import: creates the connection (with rotated tokens) when the refresh_token is still valid", async () => {
|
||||
await withMockedFetch(
|
||||
(async () =>
|
||||
jsonResponse({
|
||||
access_token: "rotated-access-token",
|
||||
refresh_token: "rotated-refresh-token",
|
||||
expires_in: 3600,
|
||||
})) as unknown as typeof fetch,
|
||||
async () => {
|
||||
const { status, body } = await postImport({
|
||||
accounts: { ...BASE_RECORD, email: "valid@example.com" },
|
||||
});
|
||||
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.success, true);
|
||||
assert.equal(body.imported, 1);
|
||||
assert.equal(body.failed, 0);
|
||||
assert.equal(body.results[0].ok, true);
|
||||
|
||||
const rows = await providersDb.getProviderConnections({ provider: "codex" });
|
||||
const created = rows.find((r) => r.email === "valid@example.com");
|
||||
assert.ok(created, "connection should be persisted for a valid refresh_token");
|
||||
assert.equal(created?.accessToken, "rotated-access-token");
|
||||
assert.equal(created?.refreshToken, "rotated-refresh-token");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("import: a transient network error validating the refresh_token does not block the import", async () => {
|
||||
await withMockedFetch(
|
||||
(async () => {
|
||||
throw new Error("ECONNRESET");
|
||||
}) as unknown as typeof fetch,
|
||||
async () => {
|
||||
const { status, body } = await postImport({
|
||||
accounts: { ...BASE_RECORD, email: "transient@example.com" },
|
||||
});
|
||||
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.success, true);
|
||||
assert.equal(body.imported, 1);
|
||||
|
||||
const rows = await providersDb.getProviderConnections({ provider: "codex" });
|
||||
const created = rows.find((r) => r.email === "transient@example.com");
|
||||
assert.ok(created, "import should proceed with the original tokens on a transient failure");
|
||||
assert.equal(created?.accessToken, BASE_RECORD.access_token);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("import: error responses never leak a stack trace", async () => {
|
||||
await withMockedFetch(
|
||||
(async () => jsonResponse({ error: { code: "refresh_token_invalidated" } }, 401)) as unknown as typeof fetch,
|
||||
async () => {
|
||||
const { body } = await postImport({
|
||||
accounts: { ...BASE_RECORD, email: "leak-check@example.com" },
|
||||
});
|
||||
assert.ok(!JSON.stringify(body).includes("at /"), "must not leak a stack trace");
|
||||
assert.ok(!JSON.stringify(body).includes(".ts:"), "must not leak a source location");
|
||||
}
|
||||
);
|
||||
});
|
||||
42
tests/unit/codex-oauth-remote-host-hint-7523.test.ts
Normal file
42
tests/unit/codex-oauth-remote-host-hint-7523.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Regression test for #7523: the Codex (and Windsurf/Devin) PKCE OAuth callback
|
||||
// server binds the SERVER's loopback (localhost:PORT). When OmniRoute runs on a
|
||||
// remote host (e.g. the VPS) and the operator drives the browser from a different
|
||||
// machine, the provider redirects to the operator's OWN localhost:PORT — the
|
||||
// login confirmation screen hangs forever with no explanation.
|
||||
//
|
||||
// buildRemoteOAuthHint() detects a non-loopback Host and surfaces the
|
||||
// reverse-tunnel instruction so the start-callback-server response carries it
|
||||
// (the UI shows it instead of a silent hang). Loopback access is unaffected.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildRemoteOAuthHint } from "../../src/app/api/oauth/[provider]/[action]/remoteOAuthHint.ts";
|
||||
|
||||
test("loopback Host → no remote hint (local access is unaffected)", () => {
|
||||
for (const host of ["localhost", "localhost:20128", "127.0.0.1:20128", "[::1]:20128", "::1"]) {
|
||||
const hint = buildRemoteOAuthHint(host, 1455);
|
||||
assert.equal(hint.remoteHost, false, `expected no hint for loopback host ${host}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("null Host → no remote hint (fail-open: never block a local flow on a missing header)", () => {
|
||||
const hint = buildRemoteOAuthHint(null, 1455);
|
||||
assert.equal(hint.remoteHost, false);
|
||||
});
|
||||
|
||||
test("remote Host → returns the reverse-tunnel hint with the exact callback port", () => {
|
||||
const hint = buildRemoteOAuthHint("192.168.0.15:20128", 1455);
|
||||
assert.equal(hint.remoteHost, true);
|
||||
assert.ok(hint.remoteHost === true); // narrow the union
|
||||
// The tunnel must forward the SAME port the callback server bound, both sides.
|
||||
assert.equal(hint.tunnelCommand, "ssh -L 1455:127.0.0.1:1455 <user>@<omniroute-host>");
|
||||
assert.match(hint.message, /remote host \(192\.168\.0\.15:20128\)/);
|
||||
assert.match(hint.message, /hang/i);
|
||||
});
|
||||
|
||||
test("remote Host honours a random callback port (Windsurf/Devin OS-assigned port)", () => {
|
||||
const hint = buildRemoteOAuthHint("omniroute.example.com", 54321);
|
||||
assert.ok(hint.remoteHost === true);
|
||||
assert.equal(hint.tunnelCommand, "ssh -L 54321:127.0.0.1:54321 <user>@<omniroute-host>");
|
||||
});
|
||||
73
tests/unit/codex-peek-nonsse-body-untouched-7536.test.ts
Normal file
73
tests/unit/codex-peek-nonsse-body-untouched-7536.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// #7536: Codex non-stream chat 502'd with "Response body is already used".
|
||||
//
|
||||
// Root cause (confirmed live on the VPS via fs-instrumentation): the Codex HTTP
|
||||
// transport uses the wreq-js TLS-fingerprint client, whose Response is backed by
|
||||
// a native body handle. On that response, merely *accessing* `response.body`
|
||||
// disturbs the handle so a later `.text()` throws
|
||||
// `TypeError: Response body is already used`. The Codex non-stream upstream
|
||||
// response arrives with an EMPTY content-type, so `peekCodexSseTransientError`
|
||||
// early-returns — but its guard evaluated `!response.body` (touching `.body`)
|
||||
// BEFORE the content-type check. That single `.body` access consumed the body,
|
||||
// and chatCore's `readNonStreamingResponseBody` → `.text()` then 502'd. Streaming
|
||||
// was unaffected because the peek genuinely wants the body for SSE responses.
|
||||
//
|
||||
// The fix reorders the guard so the content-type is checked before `.body` is
|
||||
// touched. This test locks that in: peek must NOT access `.body` for a non-SSE
|
||||
// response, and the body must remain readable downstream.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { peekCodexSseTransientError } from "../../open-sse/executors/codex.ts";
|
||||
|
||||
/**
|
||||
* Mimic a wreq-js native-handle Response: reading `.body` is destructive — once
|
||||
* accessed, `.text()` throws exactly like the live 502. This is what the real
|
||||
* bug looked like end-to-end.
|
||||
*/
|
||||
function makeDestructiveBodyResponse(contentType: string) {
|
||||
let bodyAccessCount = 0;
|
||||
let disturbed = false;
|
||||
const response = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(contentType ? { "content-type": contentType } : {}),
|
||||
get body() {
|
||||
bodyAccessCount += 1;
|
||||
disturbed = true; // native handle is now consumed
|
||||
return new ReadableStream<Uint8Array>();
|
||||
},
|
||||
async text() {
|
||||
if (disturbed) throw new TypeError("Response body is already used");
|
||||
return "downstream still works";
|
||||
},
|
||||
} as unknown as Response;
|
||||
return { response, bodyAccessCount: () => bodyAccessCount };
|
||||
}
|
||||
|
||||
test("peekCodexSseTransientError does not touch response.body for an empty-content-type response (#7536)", async () => {
|
||||
const { response, bodyAccessCount } = makeDestructiveBodyResponse("");
|
||||
|
||||
const result = await peekCodexSseTransientError(response);
|
||||
|
||||
assert.equal(result.matched, null);
|
||||
assert.equal(result.replacementBody, null);
|
||||
assert.equal(
|
||||
bodyAccessCount(),
|
||||
0,
|
||||
"peek must not access .body when content-type is not text/event-stream"
|
||||
);
|
||||
// The real regression: the body had to stay readable for the non-stream path.
|
||||
assert.equal(await response.text(), "downstream still works");
|
||||
});
|
||||
|
||||
test("peekCodexSseTransientError does not touch response.body for a non-SSE (application/json) response (#7536)", async () => {
|
||||
const { response, bodyAccessCount } = makeDestructiveBodyResponse("application/json");
|
||||
|
||||
const result = await peekCodexSseTransientError(response);
|
||||
|
||||
assert.equal(result.matched, null);
|
||||
assert.equal(result.replacementBody, null);
|
||||
assert.equal(bodyAccessCount(), 0, "non-SSE content-type must short-circuit before .body");
|
||||
assert.equal(await response.text(), "downstream still works");
|
||||
});
|
||||
50
tests/unit/codex-plan-label-2570.test.ts
Normal file
50
tests/unit/codex-plan-label-2570.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Port of upstream decolua/9router PR #2570 (feat(ui): show Codex plan labels
|
||||
// in provider and quota views).
|
||||
//
|
||||
// Two independent gaps this closes:
|
||||
//
|
||||
// 1. providerPageHelpers.getCodexPlanLabel — the provider-detail ConnectionRow
|
||||
// never surfaced the Codex subscription plan (persisted at OAuth import
|
||||
// time in providerSpecificData.chatgptPlanType — see
|
||||
// src/lib/oauth/services/codexImport.ts) anywhere in the row UI.
|
||||
//
|
||||
// 2. ProviderLimits/utils.resolvePlanValue — the quota-view plan badge
|
||||
// machinery already existed (tierByConnection / QuotaCardHeader), but its
|
||||
// persisted-metadata fallback list did not include chatgptPlanType. When
|
||||
// the live Codex usage endpoint does not return a plan_type field (usage
|
||||
// service falls back to the literal string "unknown" — see
|
||||
// open-sse/services/usage/codex.ts), the badge fell through to "Unknown"
|
||||
// instead of the plan captured at login.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getCodexPlanLabel } from "@/app/(dashboard)/dashboard/providers/[id]/codexPlanLabel";
|
||||
import { resolvePlanValue } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils";
|
||||
|
||||
test("getCodexPlanLabel returns the trimmed chatgptPlanType for codex connections", () => {
|
||||
assert.equal(getCodexPlanLabel(true, { chatgptPlanType: " Pro " }), "Pro");
|
||||
});
|
||||
|
||||
test("getCodexPlanLabel returns empty string when not a codex connection", () => {
|
||||
assert.equal(getCodexPlanLabel(false, { chatgptPlanType: "Pro" }), "");
|
||||
});
|
||||
|
||||
test("getCodexPlanLabel returns empty string when chatgptPlanType is missing/blank", () => {
|
||||
assert.equal(getCodexPlanLabel(true, {}), "");
|
||||
assert.equal(getCodexPlanLabel(true, { chatgptPlanType: " " }), "");
|
||||
assert.equal(getCodexPlanLabel(true, undefined), "");
|
||||
});
|
||||
|
||||
test("resolvePlanValue falls back to the persisted Codex chatgptPlanType when the live plan is unknown", () => {
|
||||
// Reproduces the exact shape open-sse/services/usage/codex.ts returns when
|
||||
// the upstream Codex usage endpoint omits plan_type/planType.
|
||||
assert.equal(resolvePlanValue("unknown", { chatgptPlanType: "Pro" }), "Pro");
|
||||
});
|
||||
|
||||
test("resolvePlanValue still prefers a real live plan over the persisted Codex fallback", () => {
|
||||
assert.equal(resolvePlanValue("Team", { chatgptPlanType: "Pro" }), "Team");
|
||||
});
|
||||
|
||||
test("resolvePlanValue returns null when neither live nor persisted Codex plan is available", () => {
|
||||
assert.equal(resolvePlanValue("unknown", {}), null);
|
||||
assert.equal(resolvePlanValue(null, null), null);
|
||||
});
|
||||
136
tests/unit/codex-sse-peek-body-double-read.test.ts
Normal file
136
tests/unit/codex-sse-peek-body-double-read.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// Live-VPS bug (2026-07-16, release/v3.8.49): a Codex (ChatGPT account) NON-STREAM
|
||||
// chat request fails 100% of the time with `[502]: Response body is already used
|
||||
// (reset after 1m)`, returned almost instantly (not a real network/timeout error).
|
||||
// The streaming (playground) path is unaffected.
|
||||
//
|
||||
// Root cause: `peekCodexSseTransientError` (open-sse/executors/codex.ts) peeks the
|
||||
// first bytes of the upstream SSE response by calling `response.body.getReader()`,
|
||||
// then — when no transient error is found — calls `reader.releaseLock()` followed
|
||||
// by a SECOND `response.body.getReader()` on the very same underlying body to
|
||||
// "continue draining" it into a replacement stream. Re-acquiring a reader on a
|
||||
// response body that has already been disturbed is exactly the pattern undici's
|
||||
// fetch/Response implementation guards against ("Body is unusable: Body has
|
||||
// already been read" / surfaced upstream as "Response body is already used").
|
||||
// Any runtime/build where the second `getReader()` call on the SAME response.body
|
||||
// throws turns every single non-streaming Codex request into an uncaught
|
||||
// TypeError, which chatCore's generic upstream-error handling then classifies as
|
||||
// a transient failure and stamps with a default 60s cooldown ("reset after 1m") —
|
||||
// masking a pure code defect as a rate limit.
|
||||
//
|
||||
// This test proves the defect directly against `peekCodexSseTransientError`: it
|
||||
// installs a `getReader` spy on the *original* response body that throws on any
|
||||
// call after the first (reproducing the "double-acquire" hazard precisely), then
|
||||
// asserts the function must complete without ever needing a second reader on the
|
||||
// original body — i.e. it must not throw, and the replacement body it hands back
|
||||
// must be byte-identical to the original upstream SSE payload.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { peekCodexSseTransientError } from "../../open-sse/executors/codex.ts";
|
||||
|
||||
function sseStreamFromChunks(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
let i = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (i >= chunks.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(chunks[i]));
|
||||
i++;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a ReadableStream so that `getReader()` throws on every call after the
|
||||
* first — reproducing, at the unit level, a runtime that refuses to re-acquire
|
||||
* a reader on a body it already considers disturbed (the exact "Response body
|
||||
* is already used" failure mode observed live).
|
||||
*/
|
||||
function withSingleUseGetReader(stream: ReadableStream<Uint8Array>): {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
getReaderCallCount: () => number;
|
||||
} {
|
||||
let calls = 0;
|
||||
const originalGetReader = stream.getReader.bind(stream);
|
||||
Object.defineProperty(stream, "getReader", {
|
||||
value: (...args: unknown[]) => {
|
||||
calls++;
|
||||
if (calls > 1) {
|
||||
throw new TypeError("Body is unusable: Body has already been read");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (originalGetReader as any)(...args);
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
return { stream, getReaderCallCount: () => calls };
|
||||
}
|
||||
|
||||
async function drainStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let out = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
out += decoder.decode(value, { stream: true });
|
||||
}
|
||||
out += decoder.decode();
|
||||
return out;
|
||||
}
|
||||
|
||||
test("peekCodexSseTransientError does not re-acquire a reader on the original body for a normal 200-OK SSE response", async () => {
|
||||
const normalSse =
|
||||
'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hello"}\n\n' +
|
||||
'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n';
|
||||
|
||||
const { stream, getReaderCallCount } = withSingleUseGetReader(sseStreamFromChunks([normalSse]));
|
||||
const response = new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
|
||||
const peek = await peekCodexSseTransientError(response);
|
||||
|
||||
assert.equal(peek.matched, null, "must not classify a normal reply as a transient error");
|
||||
assert.ok(peek.replacementBody, "must hand back a replacement body to continue draining");
|
||||
|
||||
const drained = await drainStream(peek.replacementBody!);
|
||||
assert.equal(drained, normalSse, "replacement body must be byte-identical to the upstream SSE payload");
|
||||
|
||||
// The regression: the OLD implementation calls response.body.getReader() a
|
||||
// SECOND time (after releaseLock()) to "continue" reading the same body. A
|
||||
// runtime that refuses that second acquisition throws — which is exactly
|
||||
// what withSingleUseGetReader reproduces. The fix must never need more than
|
||||
// one reader on the ORIGINAL body.
|
||||
assert.ok(
|
||||
getReaderCallCount() <= 1,
|
||||
`expected at most 1 getReader() call on the original response body, got ${getReaderCallCount()}`
|
||||
);
|
||||
});
|
||||
|
||||
test("peekCodexSseTransientError still detects a 200-OK transient-error SSE payload without touching the original body twice", async () => {
|
||||
const { stream, getReaderCallCount } = withSingleUseGetReader(
|
||||
sseStreamFromChunks([
|
||||
'event: error\ndata: {"error":{"message":"Selected model is at capacity. Please try a different model."}}\n\n',
|
||||
])
|
||||
);
|
||||
const response = new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
|
||||
const peek = await peekCodexSseTransientError(response);
|
||||
|
||||
assert.equal(peek.matched, "selected model is at capacity");
|
||||
assert.match(peek.message ?? "", /at capacity/i);
|
||||
assert.equal(peek.replacementBody, null);
|
||||
assert.ok(
|
||||
getReaderCallCount() <= 1,
|
||||
`expected at most 1 getReader() call on the original response body, got ${getReaderCallCount()}`
|
||||
);
|
||||
});
|
||||
74
tests/unit/codex-tools-regex-lookaround.test.ts
Normal file
74
tests/unit/codex-tools-regex-lookaround.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizeCodexTools } from "../../open-sse/executors/codex/tools.ts";
|
||||
|
||||
// Port of 9router#1556: OpenAI/Codex Responses API rejects JSON Schema `pattern`
|
||||
// fields containing regex lookaround (lookahead/lookbehind) with:
|
||||
// "Invalid JSON schema: regex lookaround is not supported. Found at $.properties.email.pattern."
|
||||
// Clients (e.g. IDE agent harnesses) commonly emit lookahead patterns such as
|
||||
// `^(?=.*@).+$` for "must contain an @". These must be stripped before the
|
||||
// tool schema reaches the Codex/OpenAI Responses API.
|
||||
test("normalizeCodexTools strips regex lookaround from function tool parameter patterns", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "send_email",
|
||||
description: "Send an email",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
pattern: "^(?=.*@).+$",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
normalizeCodexTools(body);
|
||||
|
||||
const tools = body.tools as Array<Record<string, unknown>>;
|
||||
const parameters = tools[0].parameters as Record<string, unknown>;
|
||||
const properties = parameters.properties as Record<string, unknown>;
|
||||
const emailSchema = properties.email as Record<string, unknown>;
|
||||
|
||||
assert.equal(
|
||||
emailSchema.pattern,
|
||||
undefined,
|
||||
"lookaround pattern must be stripped, not forwarded upstream"
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeCodexTools preserves plain (non-lookaround) regex patterns", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "send_email",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
zip: { type: "string", pattern: "^[0-9]{5}$" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
normalizeCodexTools(body);
|
||||
|
||||
const tools = body.tools as Array<Record<string, unknown>>;
|
||||
const parameters = tools[0].parameters as Record<string, unknown>;
|
||||
const properties = parameters.properties as Record<string, unknown>;
|
||||
const zipSchema = properties.zip as Record<string, unknown>;
|
||||
|
||||
assert.equal(zipSchema.pattern, "^[0-9]{5}$");
|
||||
});
|
||||
@@ -79,3 +79,41 @@ test("combo diagnostics: secret containment — non-whitelisted fields never sur
|
||||
assert.ok(!serialized.includes("accessToken"), "no accessToken KEY survives");
|
||||
assert.ok(!serialized.includes("token"), "no token KEY survives");
|
||||
});
|
||||
|
||||
test("combo diagnostics: terminalReason with a non-Latin1 char (em dash) must not crash Response construction (#6612)", () => {
|
||||
const terminalReason = "reasoning consumed 5/5 tokens — no content output";
|
||||
assert.doesNotThrow(() => {
|
||||
const res = errorResponseWithComboDiagnostics(
|
||||
502,
|
||||
`Upstream response failed quality validation: ${terminalReason}`,
|
||||
{
|
||||
poolSize: 4,
|
||||
attempted: 1,
|
||||
excluded: [{ provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" }],
|
||||
attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }],
|
||||
terminalReason,
|
||||
}
|
||||
);
|
||||
assert.equal(res.status, 502);
|
||||
});
|
||||
});
|
||||
|
||||
test("combo diagnostics: JSON body keeps the original non-Latin1 text even though headers are ASCII-sanitized (#6612)", async () => {
|
||||
const terminalReason = "reasoning consumed 5/5 tokens — no content output";
|
||||
const res = errorResponseWithComboDiagnostics(
|
||||
502,
|
||||
`Upstream response failed quality validation: ${terminalReason}`,
|
||||
{
|
||||
poolSize: 1,
|
||||
attempted: 1,
|
||||
excluded: [],
|
||||
attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }],
|
||||
terminalReason,
|
||||
}
|
||||
);
|
||||
// Header value must be a valid Latin1 ByteString — em dash (U+2014) replaced.
|
||||
assert.equal(res.headers.get("x-omniroute-combo-terminal-reason"), terminalReason.replace("—", "?"));
|
||||
const body = await res.json();
|
||||
// JSON body keeps the original, readable (unsanitized) em dash.
|
||||
assert.equal(body.diagnostics.terminalReason, terminalReason);
|
||||
});
|
||||
|
||||
190
tests/unit/combo-fusion-strategy-comboref-6764.test.ts
Normal file
190
tests/unit/combo-fusion-strategy-comboref-6764.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* #6764 — fusion strategy silently dropped `combo-ref` panel members.
|
||||
*
|
||||
* Every other combo strategy resolves a `{kind:"combo-ref", comboName}` panel
|
||||
* member through the shared execute-mode machinery (see
|
||||
* `open-sse/services/combo/runtimeUnits.ts::executeComboRefUnit`); the fusion
|
||||
* branch in `open-sse/services/combo.ts` only recognized plain `string` or
|
||||
* `{model: string}` entries, so a combo-ref member had neither field and was
|
||||
* filtered out (`.filter(Boolean)`) — no error, no warning. This suite proves
|
||||
* the fix: a combo-ref fusion panel member is dispatched as ONE black-box
|
||||
* panel voice (a recursive `handleComboChat` call into the referenced combo),
|
||||
* not dropped, not fanned out into the referenced combo's own targets.
|
||||
*/
|
||||
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-combo-fusion-ref-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-fusion-ref-test-secret";
|
||||
|
||||
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
|
||||
|
||||
const noop = () => {};
|
||||
const log = { info: noop, warn: noop, debug: noop, error: noop };
|
||||
|
||||
type Body = Record<string, unknown>;
|
||||
|
||||
function okResponse(content: string): Response {
|
||||
const body = JSON.stringify({ choices: [{ message: { role: "assistant", content } }] });
|
||||
return new Response(body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
function fusionCombo(models: unknown[], extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "test-fusion-combo-ref",
|
||||
strategy: "fusion",
|
||||
models,
|
||||
config: extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("fusion: a combo-ref panel member is dispatched, not silently dropped", async () => {
|
||||
const seen: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
seen.push(m);
|
||||
if (m === "p/judge") return okResponse("FINAL");
|
||||
return okResponse(`ans-${m}`);
|
||||
};
|
||||
const nestedPriority = {
|
||||
name: "nested-priority",
|
||||
strategy: "priority",
|
||||
models: ["openai/nested-a"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
};
|
||||
const combo = fusionCombo(
|
||||
[{ kind: "combo-ref", comboName: "nested-priority" }, { model: "p/plain" }],
|
||||
{ judgeModel: "p/judge" }
|
||||
);
|
||||
|
||||
const res = await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
combo,
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [combo, nestedPriority],
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
// Proves the combo-ref member was actually dispatched (its nested target
|
||||
// model reached handleSingleModel) instead of being silently filtered out.
|
||||
assert.ok(
|
||||
seen.includes("openai/nested-a"),
|
||||
`expected nested combo's target model to be dispatched, saw: ${seen.join(", ")}`
|
||||
);
|
||||
assert.ok(seen.includes("p/plain"), "plain panel member must still dispatch alongside combo-ref");
|
||||
});
|
||||
|
||||
test("fusion: combo-ref-only panel resolves normally (not a 400 empty-panel error)", async () => {
|
||||
const seen: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
seen.push(m);
|
||||
return okResponse(`ans-${m}`);
|
||||
};
|
||||
const nestedPriority = {
|
||||
name: "solo-nested",
|
||||
strategy: "priority",
|
||||
models: ["openai/solo-target"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
};
|
||||
const combo = fusionCombo([{ kind: "combo-ref", comboName: "solo-nested" }]);
|
||||
|
||||
const res = await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
combo,
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [combo, nestedPriority],
|
||||
});
|
||||
|
||||
assert.notEqual(res.status, 400);
|
||||
assert.ok(seen.includes("openai/solo-target"));
|
||||
});
|
||||
|
||||
test("fusion: self-referencing combo-ref fails that panel member gracefully, not an infinite loop", async () => {
|
||||
const seen: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
seen.push(m);
|
||||
return okResponse(`ans-${m}`);
|
||||
};
|
||||
const combo = fusionCombo([
|
||||
{ kind: "combo-ref", comboName: "test-fusion-combo-ref" },
|
||||
{ model: "p/other" },
|
||||
]);
|
||||
|
||||
const res = await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
combo,
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [combo],
|
||||
});
|
||||
|
||||
// Overall request still degrades gracefully because "p/other" survives.
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(seen.includes("p/other"));
|
||||
assert.ok(!seen.includes("test-fusion-combo-ref"));
|
||||
});
|
||||
|
||||
test("fusion: combo-ref pointing at a nonexistent combo fails only that panel member", async () => {
|
||||
const seen: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
seen.push(m);
|
||||
return okResponse(`ans-${m}`);
|
||||
};
|
||||
const combo = fusionCombo([
|
||||
{ kind: "combo-ref", comboName: "does-not-exist" },
|
||||
{ model: "p/other" },
|
||||
]);
|
||||
|
||||
const res = await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
combo,
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [combo],
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(seen.includes("p/other"));
|
||||
});
|
||||
|
||||
test("fusion: mixed plain string / auto-style / combo-ref panel members all dispatch together", async () => {
|
||||
const seen: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
seen.push(m);
|
||||
return okResponse(`ans-${m}`);
|
||||
};
|
||||
const nestedPriority = {
|
||||
name: "mixed-nested",
|
||||
strategy: "priority",
|
||||
models: ["openai/mixed-target"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
};
|
||||
const combo = fusionCombo([
|
||||
"auto/best-coding",
|
||||
{ model: "p/direct" },
|
||||
{ kind: "combo-ref", comboName: "mixed-nested" },
|
||||
]);
|
||||
|
||||
const res = await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
combo,
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [combo, nestedPriority],
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(seen.includes("auto/best-coding"));
|
||||
assert.ok(seen.includes("p/direct"));
|
||||
assert.ok(seen.includes("openai/mixed-target"));
|
||||
});
|
||||
@@ -79,7 +79,6 @@ test("fusion: fans out to the panel then routes a synthesis turn to the judge",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "Q" }],
|
||||
stream: true,
|
||||
tools: [{ name: "x" }],
|
||||
},
|
||||
combo: fusionCombo(["p/a", "p/b", "p/c"], { judgeModel: "p/judge" }),
|
||||
handleSingleModel,
|
||||
|
||||
32
tests/unit/combo-proxy-assignments-parse-7149.test.ts
Normal file
32
tests/unit/combo-proxy-assignments-parse-7149.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseComboProxyAssignmentIds } from "../../src/app/(dashboard)/dashboard/combos/useComboProxyAssignments.ts";
|
||||
|
||||
test("#7149: parseComboProxyAssignmentIds extracts scopeIds from valid combo assignments", () => {
|
||||
const data = {
|
||||
items: [
|
||||
{ scopeId: "combo-1", proxyId: "proxy-1", scope: "combo" },
|
||||
{ scopeId: "combo-2", proxyId: "proxy-2", scope: "combo" },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(parseComboProxyAssignmentIds(data), ["combo-1", "combo-2"]);
|
||||
});
|
||||
|
||||
test("#7149: parseComboProxyAssignmentIds drops entries missing scopeId or proxyId", () => {
|
||||
const data = {
|
||||
items: [
|
||||
{ scopeId: "combo-1", proxyId: "proxy-1" },
|
||||
{ scopeId: "combo-2", proxyId: null },
|
||||
{ scopeId: null, proxyId: "proxy-3" },
|
||||
{},
|
||||
],
|
||||
};
|
||||
assert.deepEqual(parseComboProxyAssignmentIds(data), ["combo-1"]);
|
||||
});
|
||||
|
||||
test("#7149: parseComboProxyAssignmentIds returns [] for missing/malformed items", () => {
|
||||
assert.deepEqual(parseComboProxyAssignmentIds(null), []);
|
||||
assert.deepEqual(parseComboProxyAssignmentIds(undefined), []);
|
||||
assert.deepEqual(parseComboProxyAssignmentIds({}), []);
|
||||
assert.deepEqual(parseComboProxyAssignmentIds({ items: "not-an-array" }), []);
|
||||
});
|
||||
87
tests/unit/combo-scope-proxy-dead-7149.test.ts
Normal file
87
tests/unit/combo-scope-proxy-dead-7149.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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-combo-proxy-7149-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const proxiesDb = await import("../../src/lib/db/proxies.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
|
||||
type ProxyResolutionLike = {
|
||||
proxy?: { host?: string } | null;
|
||||
level?: string;
|
||||
levelId?: string | null;
|
||||
} | null;
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#7149: a proxy assigned to a Combo via the dashboard (registry scope='combo') is honored when resolving the proxy for a request routed through that combo", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const comboProxy = await proxiesDb.createProxy({
|
||||
name: "Combo-Assigned Proxy",
|
||||
type: "http",
|
||||
host: "10.20.30.40",
|
||||
port: 8888,
|
||||
});
|
||||
assert.ok(comboProxy?.id);
|
||||
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "diy_deepseek-v4-flash",
|
||||
strategy: "round-robin",
|
||||
models: ["openai/gpt-4"],
|
||||
});
|
||||
const comboRecord = combo as Record<string, unknown>;
|
||||
assert.ok(comboRecord?.id);
|
||||
const comboId = comboRecord.id as string;
|
||||
|
||||
const assignment = await proxiesDb.assignProxyToScope("combo", comboId, comboProxy!.id);
|
||||
assert.ok(assignment, "assignProxyToScope('combo', ...) should persist the assignment");
|
||||
|
||||
const directRegistryLookup = (await proxiesDb.resolveProxyForScopeFromRegistry(
|
||||
"combo",
|
||||
comboId
|
||||
)) as ProxyResolutionLike;
|
||||
assert.ok(
|
||||
directRegistryLookup?.proxy,
|
||||
"the registry must be able to answer a direct combo-scope lookup"
|
||||
);
|
||||
assert.equal(directRegistryLookup?.proxy?.host, "10.20.30.40");
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
apiKey: "sk-test-1234",
|
||||
name: "openai-account-1",
|
||||
});
|
||||
const connectionRecord = connection as Record<string, unknown> | null;
|
||||
const connectionId = connectionRecord?.id as string;
|
||||
assert.ok(connectionId, "test setup requires a real connection id");
|
||||
|
||||
const resolved = (await settingsDb.resolveProxyForConnection(
|
||||
connectionId
|
||||
)) as ProxyResolutionLike;
|
||||
|
||||
assert.equal(
|
||||
resolved?.level,
|
||||
"combo",
|
||||
`expected the combo-assigned proxy to be resolved (level="combo"), got level="${resolved?.level}" — the registry-based combo proxy assignment is never consulted by resolveProxyForConnection()`
|
||||
);
|
||||
assert.equal(resolved?.proxy?.host, "10.20.30.40");
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { validateResponseQuality } = await import("../../open-sse/services/combo.ts");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const silentLog = { warn: () => {} };
|
||||
|
||||
function sseStream(body: string): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(body));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// OpenAI-shape stream: single role-only delta chunk, then the connection
|
||||
// closes. No finish_reason anywhere, no `data: [DONE]` sentinel.
|
||||
function makeTruncatedOpenAiStream(): Response {
|
||||
const body =
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-test-truncated",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})}\n\n`;
|
||||
return new Response(sseStream(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
// Healthy OpenAI-shape stream: content delta + a chunk carrying
|
||||
// finish_reason: "stop" — must keep passing through (#3399/#3685 contract).
|
||||
function makeHealthyOpenAiStream(): Response {
|
||||
const chunks = [
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-test-healthy",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-test-healthy",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
}),
|
||||
];
|
||||
const body = chunks.map((c) => `data: ${c}\n\n`).join("") + "data: [DONE]\n\n";
|
||||
return new Response(sseStream(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
test("#7285 RED: OpenAI-shape stream with role-only delta and NO finish_reason should fail over but currently passes as valid", async () => {
|
||||
const res = makeTruncatedOpenAiStream();
|
||||
const out = await validateResponseQuality(res, true, silentLog);
|
||||
assert.equal(out.valid, false, "expected failover (valid:false)");
|
||||
});
|
||||
|
||||
test("#7285 control: a healthy OpenAI stream ending with finish_reason still passes through (#3399/#3685 no-regression)", async () => {
|
||||
const res = makeHealthyOpenAiStream();
|
||||
const out = await validateResponseQuality(res, true, silentLog);
|
||||
assert.equal(out.valid, true, "expected valid:true for a properly terminated stream");
|
||||
});
|
||||
211
tests/unit/comfyui-baseurl-override-6928.test.ts
Normal file
211
tests/unit/comfyui-baseurl-override-6928.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
// Unit tests for #6928: expose an editable base-URL field on the ComfyUI
|
||||
// provider connection + wire the per-connection override through the shared
|
||||
// resolveComfyUiBaseUrl helper (used by image/video/music generation handlers).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-comfyui-baseurl-"));
|
||||
|
||||
import { resolveComfyUiBaseUrl } from "../../open-sse/utils/comfyuiClient.ts";
|
||||
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
|
||||
const { handleVideoGeneration } = await import("../../open-sse/handlers/videoGeneration.ts");
|
||||
const { handleMusicGeneration } = await import("../../open-sse/handlers/musicGeneration.ts");
|
||||
|
||||
const FALLBACK = "http://localhost:8188";
|
||||
const OVERRIDE = "http://comfyui:8188";
|
||||
|
||||
function immediateTimeout(callback, _ms, ...args) {
|
||||
if (typeof callback === "function") callback(...args);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mockComfyFetch(promptId: string, seenUrls: string[]) {
|
||||
return async (url: string, options: { body?: unknown } = {}) => {
|
||||
const stringUrl = String(url);
|
||||
seenUrls.push(stringUrl);
|
||||
|
||||
if (stringUrl.endsWith("/prompt")) {
|
||||
return new Response(JSON.stringify({ prompt_id: promptId }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (stringUrl.includes(`/history/${promptId}`)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
[promptId]: {
|
||||
outputs: {
|
||||
1: { images: [{ filename: "out.png", subfolder: "", type: "output" }] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
if (stringUrl.includes("/view?")) {
|
||||
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
}
|
||||
|
||||
test("handleImageGeneration uses the connection's providerSpecificData.baseUrl override for ComfyUI", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const seenUrls: string[] = [];
|
||||
globalThis.setTimeout = immediateTimeout;
|
||||
globalThis.fetch = mockComfyFetch("img-override", seenUrls);
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
body: { model: "comfyui/flux-dev", prompt: "override test" },
|
||||
credentials: { providerSpecificData: { baseUrl: OVERRIDE } },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(
|
||||
seenUrls.every((u) => u.startsWith(OVERRIDE)),
|
||||
`expected all requests to use ${OVERRIDE}, got ${seenUrls.join(", ")}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleImageGeneration falls back to localhost:8188 for ComfyUI when credentials is null (no regression)", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const seenUrls: string[] = [];
|
||||
globalThis.setTimeout = immediateTimeout;
|
||||
globalThis.fetch = mockComfyFetch("img-default", seenUrls);
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
body: { model: "comfyui/flux-dev", prompt: "default test" },
|
||||
credentials: null,
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(seenUrls.every((u) => u.startsWith(FALLBACK)));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleVideoGeneration uses the connection's providerSpecificData.baseUrl override for ComfyUI", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const seenUrls: string[] = [];
|
||||
globalThis.setTimeout = immediateTimeout;
|
||||
globalThis.fetch = mockComfyFetch("vid-override", seenUrls);
|
||||
|
||||
try {
|
||||
const result = await handleVideoGeneration({
|
||||
body: { model: "comfyui/animatediff", prompt: "override video" },
|
||||
credentials: { providerSpecificData: { baseUrl: OVERRIDE } },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(seenUrls.every((u) => u.startsWith(OVERRIDE)));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleMusicGeneration uses the connection's providerSpecificData.baseUrl override for ComfyUI", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const seenUrls: string[] = [];
|
||||
globalThis.setTimeout = immediateTimeout;
|
||||
globalThis.fetch = mockComfyFetch("music-override", seenUrls);
|
||||
|
||||
try {
|
||||
const result = await handleMusicGeneration({
|
||||
body: { model: "comfyui/musicgen-medium", prompt: "override music" },
|
||||
credentials: { providerSpecificData: { baseUrl: OVERRIDE } },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(seenUrls.every((u) => u.startsWith(OVERRIDE)));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl returns the fallback for null credentials", () => {
|
||||
assert.equal(resolveComfyUiBaseUrl(null, FALLBACK), FALLBACK);
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl returns the fallback for undefined credentials", () => {
|
||||
assert.equal(resolveComfyUiBaseUrl(undefined, FALLBACK), FALLBACK);
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl returns the fallback when providerSpecificData is absent", () => {
|
||||
assert.equal(resolveComfyUiBaseUrl({}, FALLBACK), FALLBACK);
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl returns the fallback when providerSpecificData is null", () => {
|
||||
assert.equal(
|
||||
resolveComfyUiBaseUrl({ providerSpecificData: null }, FALLBACK),
|
||||
FALLBACK
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl returns the fallback when baseUrl is absent", () => {
|
||||
assert.equal(
|
||||
resolveComfyUiBaseUrl({ providerSpecificData: {} }, FALLBACK),
|
||||
FALLBACK
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl returns the fallback when baseUrl is not a string", () => {
|
||||
assert.equal(
|
||||
resolveComfyUiBaseUrl(
|
||||
{ providerSpecificData: { baseUrl: 12345 as unknown as string } },
|
||||
FALLBACK
|
||||
),
|
||||
FALLBACK
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl returns the fallback when baseUrl is whitespace-only", () => {
|
||||
assert.equal(
|
||||
resolveComfyUiBaseUrl({ providerSpecificData: { baseUrl: " " } }, FALLBACK),
|
||||
FALLBACK
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl returns the trimmed override when set", () => {
|
||||
assert.equal(
|
||||
resolveComfyUiBaseUrl(
|
||||
{ providerSpecificData: { baseUrl: " http://comfyui:8188 " } },
|
||||
FALLBACK
|
||||
),
|
||||
"http://comfyui:8188"
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl accepts a bare Docker-network hostname override", () => {
|
||||
assert.equal(
|
||||
resolveComfyUiBaseUrl({ providerSpecificData: { baseUrl: "http://comfyui:8188" } }, FALLBACK),
|
||||
"http://comfyui:8188"
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveComfyUiBaseUrl ignores a top-level credentials.baseUrl (not providerSpecificData)", () => {
|
||||
const credentials = {
|
||||
baseUrl: "http://should-be-ignored:8188",
|
||||
} as { baseUrl: string; providerSpecificData?: { baseUrl?: unknown } | null };
|
||||
assert.equal(resolveComfyUiBaseUrl(credentials, FALLBACK), FALLBACK);
|
||||
});
|
||||
@@ -214,3 +214,27 @@ test("feedStreamingChunk: noop after done state", () => {
|
||||
assert.equal(out.safeDelta, "");
|
||||
assert.equal(out.ready, false);
|
||||
});
|
||||
|
||||
// ─── Regression: space-separated arg name/value (9router#1811) ───────────────
|
||||
// Cursor's real Composer/Auto output has been observed using a single space
|
||||
// (instead of a newline) between the arg name and its value inside a
|
||||
// <|tool▁sep|> segment, e.g. "<|tool▁sep|>path /Users/.../test". The parser
|
||||
// must still extract {path: "/Users/.../test"} rather than treating the whole
|
||||
// segment as the (empty-valued) arg name.
|
||||
test("parseComposerToolCalls: parses args separated by a space instead of a newline (Cursor Composer live capture)", () => {
|
||||
const text =
|
||||
"<|tool▁calls▁begin|><|tool▁call▁begin|> Write " +
|
||||
"<|tool▁sep|>path /Users/kabawagang/Desktop/Code/iOS_Review/test " +
|
||||
"<|tool▁sep|>contents 22\n\n<|tool▁call▁end|><|tool▁calls▁end|>";
|
||||
|
||||
const result = parseComposerToolCalls(text);
|
||||
|
||||
assert.equal(result.toolCalls.length, 1);
|
||||
const tc = result.toolCalls[0];
|
||||
assert.equal(tc.function.name, "Write");
|
||||
const args = JSON.parse(tc.function.arguments);
|
||||
assert.deepEqual(args, {
|
||||
path: "/Users/kabawagang/Desktop/Code/iOS_Review/test",
|
||||
contents: 22,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Regression test for #7005 — adaptive context-budget dial not configurable.
|
||||
//
|
||||
// The compute engine for the adaptive context-budget ("dial") shipped in PR #4716
|
||||
// (Phase 4C), but it was never wired to persistence or the API: the PUT schema
|
||||
// rejected any `contextBudget` payload (strict schema, no such key) and the DB-backed
|
||||
// GET path never surfaced a `contextBudget` field. This test proves both halves of
|
||||
// the wiring: the Zod schema accepts a `contextBudget` write, and the DB read/write
|
||||
// path round-trips it.
|
||||
import { describe, it, beforeEach, afterEach, after } 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-adaptive-context-budget-db-")
|
||||
);
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const { getCompressionSettings, updateCompressionSettings } = await import(
|
||||
"../../../src/lib/db/compression.ts"
|
||||
);
|
||||
const { compressionSettingsUpdateSchema } = await import(
|
||||
"../../../src/shared/validation/compressionConfigSchemas.ts"
|
||||
);
|
||||
const { DEFAULT_CONTEXT_BUDGET } = await import(
|
||||
"../../../open-sse/services/compression/adaptiveCompression/types.ts"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
core.resetDbInstance();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_DATA_DIR === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
describe("bug #7005: adaptive context-budget dial is configurable", () => {
|
||||
it("compressionSettingsUpdateSchema accepts a contextBudget write", () => {
|
||||
const result = compressionSettingsUpdateSchema.safeParse({
|
||||
contextBudget: {
|
||||
mode: "floor",
|
||||
policy: "percentage",
|
||||
outputReserve: 2048,
|
||||
safetyMargin: 512,
|
||||
pct: 0.75,
|
||||
absoluteBudget: 0,
|
||||
},
|
||||
});
|
||||
assert.equal(result.success, true, JSON.stringify("error" in result ? result.error : null));
|
||||
});
|
||||
|
||||
it("getCompressionSettings() defaults contextBudget to DEFAULT_CONTEXT_BUDGET when absent", async () => {
|
||||
const settings = await getCompressionSettings();
|
||||
assert.deepEqual(settings.contextBudget, DEFAULT_CONTEXT_BUDGET);
|
||||
});
|
||||
|
||||
it("updateCompressionSettings() persists a partial contextBudget merge", async () => {
|
||||
await updateCompressionSettings({
|
||||
contextBudget: { ...DEFAULT_CONTEXT_BUDGET, mode: "floor", policy: "absolute", absoluteBudget: 8000 },
|
||||
});
|
||||
const settings = await getCompressionSettings();
|
||||
assert.equal(settings.contextBudget?.mode, "floor");
|
||||
assert.equal(settings.contextBudget?.policy, "absolute");
|
||||
assert.equal(settings.contextBudget?.absoluteBudget, 8000);
|
||||
// Untouched fields keep their defaults (this is a JSON-column replace like ultra/aggressive,
|
||||
// not a deep merge — the caller sends the full object, mirroring the existing pattern).
|
||||
assert.equal(settings.contextBudget?.outputReserve, DEFAULT_CONTEXT_BUDGET.outputReserve);
|
||||
});
|
||||
});
|
||||
82
tests/unit/compression/codestripper-lazy-ts-7096.test.ts
Normal file
82
tests/unit/compression/codestripper-lazy-ts-7096.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Regression for #7096 — the RTK code stripper imported the `typescript`
|
||||
* package eagerly at module top level (`import ts from "typescript"`), but
|
||||
* `typescript` lives in devDependencies. After a production-lean deploy
|
||||
* (`npm run build && npm prune --omit=dev`, recommended in Discussion #6956)
|
||||
* the package is gone, so merely importing `codeStripper.ts` — which every
|
||||
* Compression Context page (Lite/Aggressive/Ultra/CCR) pulls in — threw a
|
||||
* module-not-found error and broke the whole feature.
|
||||
*
|
||||
* The fix resolves `typescript` lazily and only when AST-based comment
|
||||
* stripping is actually requested (opt-in, default off), degrading to a no-op
|
||||
* when the package is unavailable instead of crashing at import time.
|
||||
*
|
||||
* Run: node --import tsx/esm --test tests/unit/compression/codestripper-lazy-ts-7096.test.ts
|
||||
*/
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Namespace import so a missing named export does not crash the whole test
|
||||
// module at load — it simply shows up as `undefined` (clean granular red).
|
||||
import * as codeStripper from "../../../open-sse/services/compression/engines/rtk/codeStripper.ts";
|
||||
|
||||
const CODE_STRIPPER_SOURCE = fileURLToPath(
|
||||
new URL("../../../open-sse/services/compression/engines/rtk/codeStripper.ts", import.meta.url)
|
||||
);
|
||||
|
||||
describe("RTK codeStripper — lazy TypeScript loading (#7096)", () => {
|
||||
afterEach(() => {
|
||||
// Always restore the default loader if the seam exists.
|
||||
codeStripper.__setTypeScriptModuleLoaderForTests?.(null);
|
||||
});
|
||||
|
||||
it("does not import the `typescript` package eagerly at module top level", () => {
|
||||
const source = fs.readFileSync(CODE_STRIPPER_SOURCE, "utf8");
|
||||
// A top-level *value* import of typescript (`import ts from "typescript"`)
|
||||
// is what broke every compression page after `npm prune --omit=dev`.
|
||||
// Type-only imports (`import type ... from "typescript"`) are erased at
|
||||
// build time and are fine.
|
||||
const eagerValueImport =
|
||||
/^\s*import\s+(?!type\b)[^;\n]*\bfrom\s+["']typescript["']/m.test(source);
|
||||
assert.equal(
|
||||
eagerValueImport,
|
||||
false,
|
||||
"codeStripper.ts must not import `typescript` eagerly at module top level (use a lazy require instead)"
|
||||
);
|
||||
});
|
||||
|
||||
it("still strips comments when `typescript` is available (opt-in)", () => {
|
||||
const code = [
|
||||
"const x = 1; // inline note",
|
||||
"// full line comment",
|
||||
"const y = 2;",
|
||||
].join("\n");
|
||||
const out = codeStripper.stripCode(code, "typescript", { removeComments: true });
|
||||
assert.ok(!out.text.includes("inline note"), "line comment should be removed");
|
||||
assert.ok(!out.text.includes("full line comment"), "full-line comment should be removed");
|
||||
assert.ok(out.text.includes("const x = 1"), "code should survive");
|
||||
assert.ok(out.text.includes("const y = 2"), "code should survive");
|
||||
});
|
||||
|
||||
it("degrades to a no-op (no throw) when `typescript` cannot be resolved", () => {
|
||||
assert.equal(
|
||||
typeof codeStripper.__setTypeScriptModuleLoaderForTests,
|
||||
"function",
|
||||
"codeStripper must expose a lazy TypeScript loader seam so it can degrade gracefully"
|
||||
);
|
||||
// Simulate `npm prune --omit=dev`: typescript is not resolvable.
|
||||
codeStripper.__setTypeScriptModuleLoaderForTests(() => null);
|
||||
|
||||
const code = ["const x = 1; // keep me", "const y = 2;"].join("\n");
|
||||
let out: ReturnType<typeof codeStripper.stripCode>;
|
||||
assert.doesNotThrow(() => {
|
||||
out = codeStripper.stripCode(code, "typescript", { removeComments: true });
|
||||
}, "stripCode must not throw when typescript is unavailable");
|
||||
// Comment stripping is skipped, but the code passes through intact.
|
||||
assert.ok(out!.text.includes("const x = 1"), "code passes through");
|
||||
assert.ok(out!.text.includes("keep me"), "comment left intact under graceful degradation");
|
||||
});
|
||||
});
|
||||
91
tests/unit/compression/headroom-developer-role-2132.test.ts
Normal file
91
tests/unit/compression/headroom-developer-role-2132.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Regression test for upstream 9router#2132 (ported): "Token saver Headroom ruins plan mode
|
||||
* in Codex CLI".
|
||||
*
|
||||
* Root cause: SmartCrusher's system-message guard only checked `role === "system"`. Codex CLI
|
||||
* (open-sse/executors/codex.ts) sends its instructions/tool-schema turn with role "developer"
|
||||
* (the Responses-API equivalent of "system" used by newer models). Every other guard in this
|
||||
* codebase that excludes "system" also excludes "developer" (see roleNormalizer.ts,
|
||||
* contextManager.ts, claudeUpstreamMessages.ts, etc.) — SmartCrusher was the exception, so it
|
||||
* happily tabular-compacted JSON arrays (e.g. the update_plan tool schema/examples) embedded in
|
||||
* the developer-role turn, corrupting the instructions the model needs to call the plan tool.
|
||||
*/
|
||||
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
let crushMessages: typeof import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts").crushMessages;
|
||||
let collectCompactableArrays: typeof import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts").collectCompactableArrays;
|
||||
let headroomEngine: import("../../../open-sse/services/compression/engines/headroom/index.ts").headroomEngine;
|
||||
|
||||
before(async () => {
|
||||
const mod = await import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts");
|
||||
crushMessages = mod.crushMessages;
|
||||
collectCompactableArrays = mod.collectCompactableArrays;
|
||||
|
||||
const engineMod = await import("../../../open-sse/services/compression/engines/headroom/index.ts");
|
||||
headroomEngine = engineMod.headroomEngine;
|
||||
});
|
||||
|
||||
/** A homogeneous array big enough (>= default minRows=8) to trigger compaction. */
|
||||
function makePlanSchemaExample(): Record<string, unknown>[] {
|
||||
return Array.from({ length: 10 }, (_, i) => ({
|
||||
step: `step-${i + 1}`,
|
||||
status: i === 0 ? "in_progress" : "pending",
|
||||
}));
|
||||
}
|
||||
|
||||
describe("headroom SmartCrusher — developer-role guard (9router#2132)", () => {
|
||||
it("does NOT compact JSON arrays embedded in a developer-role message (crushMessages)", () => {
|
||||
const json = JSON.stringify(makePlanSchemaExample());
|
||||
const messages = [
|
||||
{
|
||||
role: "developer",
|
||||
content: `Use the update_plan tool. Example plan:\n\`\`\`json\n${json}\n\`\`\``,
|
||||
},
|
||||
{ role: "user", content: "Refactor the auth module." },
|
||||
];
|
||||
|
||||
const { messages: result, changed } = crushMessages(messages, 8);
|
||||
|
||||
assert.equal(changed, false, "developer-role content must not be touched");
|
||||
assert.equal(result[0].content, messages[0].content);
|
||||
});
|
||||
|
||||
it("still compacts the same payload when placed under role: system (control case)", () => {
|
||||
// Sanity check: this proves the array itself WOULD be compactable — the guard, not the
|
||||
// shape of the payload, is what must change.
|
||||
const json = JSON.stringify(makePlanSchemaExample());
|
||||
const messages = [{ role: "user", content: `\`\`\`json\n${json}\n\`\`\`` }];
|
||||
|
||||
const { changed } = crushMessages(messages, 8);
|
||||
assert.equal(changed, true, "control case: user-role content of the same shape IS compacted");
|
||||
});
|
||||
|
||||
it("collectCompactableArrays does not surface arrays from developer-role messages", () => {
|
||||
const json = JSON.stringify(makePlanSchemaExample());
|
||||
const messages = [
|
||||
{ role: "developer", content: `\`\`\`json\n${json}\n\`\`\`` },
|
||||
];
|
||||
const found = collectCompactableArrays(messages, 8);
|
||||
assert.equal(found.length, 0);
|
||||
});
|
||||
|
||||
it("headroomEngine.apply leaves a Codex-CLI-shaped developer turn untouched end-to-end", () => {
|
||||
const json = JSON.stringify(makePlanSchemaExample());
|
||||
const body: Record<string, unknown> = {
|
||||
model: "gpt-5-codex",
|
||||
messages: [
|
||||
{
|
||||
role: "developer",
|
||||
content: `Instructions with an embedded schema example:\n\`\`\`json\n${json}\n\`\`\``,
|
||||
},
|
||||
{ role: "user", content: "Implement the feature." },
|
||||
],
|
||||
};
|
||||
|
||||
const result = headroomEngine.apply(body);
|
||||
assert.equal(result.compressed, false);
|
||||
assert.deepEqual(result.body, body);
|
||||
});
|
||||
});
|
||||
207
tests/unit/connection-cache-override-6880.test.ts
Normal file
207
tests/unit/connection-cache-override-6880.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
providerSupportsCaching,
|
||||
providerHonorsOpenAIFormatCacheControl,
|
||||
resolveConnectionCacheOverride,
|
||||
shouldPreserveCacheControl,
|
||||
} from "../../open-sse/utils/cacheControlPolicy.ts";
|
||||
import {
|
||||
detectCachingContext,
|
||||
getCacheAwareStrategy,
|
||||
} from "../../open-sse/services/compression/cachingAware.ts";
|
||||
import { validateProviderSpecificData } from "../../src/shared/validation/providerSpecificData.ts";
|
||||
import { normalizeProviderSpecificData } from "../../src/lib/providers/requestDefaults.ts";
|
||||
|
||||
// Regression for #6880: a custom/openai-compatible connection (provider id like
|
||||
// `openai-compatible-chat-<uuid>`) can never match the hardcoded CACHING_PROVIDERS /
|
||||
// OPENAI_FORMAT_CACHE_CONTROL_PROVIDERS name sets in cacheControlPolicy.ts, so cache
|
||||
// behaviors (prompt_cache_key injection, the compression cache-aware guard, and
|
||||
// cache_control passthrough) are permanently disabled for that class of connections with
|
||||
// no way to opt in. This adds a per-connection `cache` capability override consulted
|
||||
// first by the policy functions, defaulting to today's hardcoded-set behavior.
|
||||
|
||||
function collectIssues(): { ctx: z.RefinementCtx; issues: Array<{ path: (string | number)[]; message: string }> } {
|
||||
const issues: Array<{ path: (string | number)[]; message: string }> = [];
|
||||
const ctx = {
|
||||
addIssue: (issue: { path?: (string | number)[]; message: string }) => {
|
||||
issues.push({ path: issue.path ?? [], message: issue.message });
|
||||
},
|
||||
} as unknown as z.RefinementCtx;
|
||||
return { ctx, issues };
|
||||
}
|
||||
|
||||
describe("#6880 resolveConnectionCacheOverride", () => {
|
||||
test("returns null for undefined/non-object/empty cache", () => {
|
||||
assert.equal(resolveConnectionCacheOverride(undefined), null);
|
||||
assert.equal(resolveConnectionCacheOverride(null), null);
|
||||
assert.equal(resolveConnectionCacheOverride("nope"), null);
|
||||
assert.equal(resolveConnectionCacheOverride({}), null);
|
||||
assert.equal(resolveConnectionCacheOverride({ cache: null }), null);
|
||||
assert.equal(resolveConnectionCacheOverride({ cache: [] }), null);
|
||||
assert.equal(resolveConnectionCacheOverride({ cache: {} }), null);
|
||||
});
|
||||
|
||||
test("extracts valid fields and drops invalid/unknown values", () => {
|
||||
const result = resolveConnectionCacheOverride({
|
||||
cache: {
|
||||
supportsPromptCaching: true,
|
||||
cacheControlPassthrough: "openai-format",
|
||||
unknownField: "ignored",
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
supportsPromptCaching: true,
|
||||
cacheControlPassthrough: "openai-format",
|
||||
});
|
||||
|
||||
const invalid = resolveConnectionCacheOverride({
|
||||
cache: { supportsPromptCaching: "yes", cacheControlPassthrough: "bogus" },
|
||||
});
|
||||
assert.equal(invalid, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#6880 providerSupportsCaching override", () => {
|
||||
test("unblocks a custom openai-compatible connection when the override opts in", () => {
|
||||
assert.equal(
|
||||
providerSupportsCaching("openai-compatible-chat-abc123", undefined, {
|
||||
supportsPromptCaching: true,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("no override -> default hardcoded-set behavior is unchanged", () => {
|
||||
assert.equal(providerSupportsCaching("openai-compatible-chat-abc123"), false);
|
||||
});
|
||||
|
||||
test("explicit opt-out overrides the hardcoded set", () => {
|
||||
assert.equal(providerSupportsCaching("claude", undefined, { supportsPromptCaching: false }), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#6880 providerHonorsOpenAIFormatCacheControl override", () => {
|
||||
test("openai-format override enables passthrough for a non-hardcoded provider", () => {
|
||||
assert.equal(
|
||||
providerHonorsOpenAIFormatCacheControl("grok-custom", { cacheControlPassthrough: "openai-format" }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("strip override disables passthrough", () => {
|
||||
assert.equal(
|
||||
providerHonorsOpenAIFormatCacheControl("grok-custom", { cacheControlPassthrough: "strip" }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("no override -> default hardcoded-set behavior is unchanged", () => {
|
||||
assert.equal(providerHonorsOpenAIFormatCacheControl("grok-custom"), false);
|
||||
assert.equal(providerHonorsOpenAIFormatCacheControl("alibaba"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#6880 shouldPreserveCacheControl override", () => {
|
||||
test("preserves cache_control for a non-hardcoded provider when override opts in", () => {
|
||||
const result = shouldPreserveCacheControl({
|
||||
userAgent: "claude-code/1.0",
|
||||
isCombo: false,
|
||||
targetProvider: "openai-compatible-chat-abc123",
|
||||
targetFormat: "openai",
|
||||
connectionCacheOverride: { supportsPromptCaching: true },
|
||||
});
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test("no override -> non-hardcoded provider still not preserved", () => {
|
||||
const result = shouldPreserveCacheControl({
|
||||
userAgent: "claude-code/1.0",
|
||||
isCombo: false,
|
||||
targetProvider: "openai-compatible-chat-abc123",
|
||||
targetFormat: "openai",
|
||||
});
|
||||
assert.equal(result, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#6880 compression cache-aware guard", () => {
|
||||
test("detectCachingContext reports isCachingProvider=true when the override opts in", () => {
|
||||
const ctx = detectCachingContext(
|
||||
{ messages: [{ role: "user", content: "hi" }] },
|
||||
{
|
||||
provider: "openai-compatible-chat-xyz",
|
||||
targetFormat: "openai",
|
||||
connectionCacheOverride: { supportsPromptCaching: true },
|
||||
}
|
||||
);
|
||||
assert.equal(ctx.isCachingProvider, true);
|
||||
});
|
||||
|
||||
test("detectCachingContext without override keeps default (non-caching) behavior", () => {
|
||||
const ctx = detectCachingContext(
|
||||
{ messages: [{ role: "user", content: "hi" }] },
|
||||
{ provider: "openai-compatible-chat-xyz", targetFormat: "openai" }
|
||||
);
|
||||
assert.equal(ctx.isCachingProvider, false);
|
||||
});
|
||||
|
||||
test("getCacheAwareStrategy protects the cacheable prefix for an overridden context", () => {
|
||||
const ctx = detectCachingContext(
|
||||
{ messages: [{ role: "user", content: "hi" }] },
|
||||
{
|
||||
provider: "openai-compatible-chat-xyz",
|
||||
targetFormat: "openai",
|
||||
connectionCacheOverride: { supportsPromptCaching: true },
|
||||
}
|
||||
);
|
||||
const strategy = getCacheAwareStrategy("aggressive", ctx);
|
||||
assert.equal(strategy.skipSystemPrompt, true);
|
||||
assert.equal(strategy.deterministicOnly, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#6880 validateProviderSpecificData cache block", () => {
|
||||
test("accepts a well-formed cache block", () => {
|
||||
const { ctx, issues } = collectIssues();
|
||||
validateProviderSpecificData(
|
||||
{ cache: { supportsPromptCaching: true, cacheControlPassthrough: "openai-format" } },
|
||||
ctx
|
||||
);
|
||||
assert.deepEqual(issues, []);
|
||||
});
|
||||
|
||||
test("rejects a non-object cache", () => {
|
||||
const { ctx, issues } = collectIssues();
|
||||
validateProviderSpecificData({ cache: "nope" }, ctx);
|
||||
assert.equal(issues.length, 1);
|
||||
assert.deepEqual(issues[0]?.path, ["cache"]);
|
||||
});
|
||||
|
||||
test("rejects an invalid cacheControlPassthrough value", () => {
|
||||
const { ctx, issues } = collectIssues();
|
||||
validateProviderSpecificData({ cache: { cacheControlPassthrough: "bogus" } }, ctx);
|
||||
assert.equal(issues.length, 1);
|
||||
assert.deepEqual(issues[0]?.path, ["cache", "cacheControlPassthrough"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#6880 normalizeProviderSpecificData cache block", () => {
|
||||
test("strips an invalid cache sub-object down to nothing (key deleted)", () => {
|
||||
const normalized = normalizeProviderSpecificData("openai-compatible-chat-xyz", {
|
||||
cache: { supportsPromptCaching: "yes", cacheControlPassthrough: "bogus" },
|
||||
});
|
||||
assert.equal(normalized?.cache, undefined);
|
||||
});
|
||||
|
||||
test("preserves a valid cache sub-object", () => {
|
||||
const normalized = normalizeProviderSpecificData("openai-compatible-chat-xyz", {
|
||||
cache: { supportsPromptCaching: true, cacheControlPassthrough: "openai-format", junk: 1 },
|
||||
});
|
||||
assert.deepEqual(normalized?.cache, {
|
||||
supportsPromptCaching: true,
|
||||
cacheControlPassthrough: "openai-format",
|
||||
});
|
||||
});
|
||||
});
|
||||
87
tests/unit/connection-reorder-by-availability.test.ts
Normal file
87
tests/unit/connection-reorder-by-availability.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
sortConnectionsByAvailability,
|
||||
isConnectionAvailable,
|
||||
getConnectionEffectiveStatus,
|
||||
} from "../../src/app/(dashboard)/dashboard/providers/[id]/components/connectionRowHelpers";
|
||||
|
||||
// Reorder-by-availability — upstream 9router PR #2558 ported to OmniRoute's
|
||||
// resilience model (rateLimitedUntil cooldown + testStatus), not the
|
||||
// upstream `modelLock_*` field convention. See CLAUDE.md "Resilience Runtime
|
||||
// State" → Connection Cooldown.
|
||||
|
||||
test("sortConnectionsByAvailability moves available connections to the top", () => {
|
||||
const connections = [
|
||||
{ id: "a", testStatus: "error" },
|
||||
{ id: "b", testStatus: "active" },
|
||||
{ id: "c", testStatus: "success" },
|
||||
{ id: "d", testStatus: "expired" },
|
||||
];
|
||||
|
||||
const sorted = sortConnectionsByAvailability(connections);
|
||||
|
||||
assert.deepEqual(
|
||||
sorted.map((c) => c.id),
|
||||
["b", "c", "a", "d"]
|
||||
);
|
||||
});
|
||||
|
||||
test("sortConnectionsByAvailability is a stable sort (preserves relative order within each group)", () => {
|
||||
const connections = [
|
||||
{ id: "1", testStatus: "error" },
|
||||
{ id: "2", testStatus: "active" },
|
||||
{ id: "3", testStatus: "error" },
|
||||
{ id: "4", testStatus: "success" },
|
||||
{ id: "5", testStatus: "unknown" },
|
||||
];
|
||||
|
||||
const sorted = sortConnectionsByAvailability(connections);
|
||||
|
||||
// Available group (2, 4) keeps its original relative order, then the
|
||||
// unavailable group (1, 3, 5) keeps its original relative order.
|
||||
assert.deepEqual(
|
||||
sorted.map((c) => c.id),
|
||||
["2", "4", "1", "3", "5"]
|
||||
);
|
||||
});
|
||||
|
||||
test("sortConnectionsByAvailability does not mutate the input array", () => {
|
||||
const connections = [{ id: "a", testStatus: "error" }, { id: "b", testStatus: "active" }];
|
||||
const original = [...connections];
|
||||
|
||||
sortConnectionsByAvailability(connections);
|
||||
|
||||
assert.deepEqual(connections, original);
|
||||
});
|
||||
|
||||
test("a testStatus: 'unavailable' connection past its cooldown counts as available (lazy recovery)", () => {
|
||||
const pastCooldown = new Date(Date.now() - 60_000).toISOString();
|
||||
const connection = { testStatus: "unavailable", rateLimitedUntil: pastCooldown };
|
||||
|
||||
assert.equal(getConnectionEffectiveStatus(connection), "active");
|
||||
assert.equal(isConnectionAvailable(connection), true);
|
||||
});
|
||||
|
||||
test("a testStatus: 'unavailable' connection still within cooldown stays unavailable", () => {
|
||||
const futureCooldown = new Date(Date.now() + 60_000).toISOString();
|
||||
const connection = { testStatus: "unavailable", rateLimitedUntil: futureCooldown };
|
||||
|
||||
assert.equal(getConnectionEffectiveStatus(connection), "unavailable");
|
||||
assert.equal(isConnectionAvailable(connection), false);
|
||||
});
|
||||
|
||||
test("sortConnectionsByAvailability treats an active cooldown as unavailable even ahead of a hard error", () => {
|
||||
const futureCooldown = new Date(Date.now() + 60_000).toISOString();
|
||||
const connections = [
|
||||
{ id: "cooling", testStatus: "unavailable", rateLimitedUntil: futureCooldown },
|
||||
{ id: "recovered", testStatus: "active" },
|
||||
];
|
||||
|
||||
const sorted = sortConnectionsByAvailability(connections);
|
||||
|
||||
assert.deepEqual(
|
||||
sorted.map((c) => c.id),
|
||||
["recovered", "cooling"]
|
||||
);
|
||||
});
|
||||
62
tests/unit/connection-status-filter-active-2526.test.ts
Normal file
62
tests/unit/connection-status-filter-active-2526.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { filterActiveConnections, filterUsableConnections } from "@/shared/utils/connectionStatus";
|
||||
|
||||
// Ported from decolua/9router#2526 — the combos builder listed provider
|
||||
// connections the user had explicitly disabled, because the page only
|
||||
// filtered on the connection's last `testStatus` and ignored `isActive`.
|
||||
// A disabled connection can still carry a stale "active"/"success"
|
||||
// testStatus from before it was disabled.
|
||||
|
||||
test("filterActiveConnections excludes explicitly disabled connections", () => {
|
||||
const active = { id: "active", isActive: true };
|
||||
const legacyActive = { id: "legacy" }; // no isActive field -> treated as active
|
||||
const disabled = { id: "disabled", isActive: false };
|
||||
|
||||
assert.deepEqual(filterActiveConnections([active, disabled, legacyActive]), [
|
||||
active,
|
||||
legacyActive,
|
||||
]);
|
||||
});
|
||||
|
||||
test("filterActiveConnections returns an empty list for invalid input", () => {
|
||||
assert.deepEqual(filterActiveConnections(undefined), []);
|
||||
assert.deepEqual(filterActiveConnections(null), []);
|
||||
});
|
||||
|
||||
test("filterActiveConnections drops nullish entries instead of passing them through", () => {
|
||||
// A nullish element must not survive: callers read properties off the
|
||||
// result (e.g. `connection.testStatus`) and would throw a TypeError.
|
||||
const active = { id: "active", isActive: true };
|
||||
|
||||
assert.deepEqual(filterActiveConnections([null, active, undefined]), [active]);
|
||||
assert.doesNotThrow(() => filterUsableConnections([null, undefined]));
|
||||
assert.deepEqual(filterUsableConnections([null, { id: "ok", testStatus: "active" }]), [
|
||||
{ id: "ok", testStatus: "active" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("filterUsableConnections applies the isActive gate before the testStatus gate", () => {
|
||||
// Regression for the exact bug: a disabled connection with a stale
|
||||
// "active" testStatus must NOT survive the combined filter that
|
||||
// src/app/(dashboard)/dashboard/combos/page.tsx fetchData() calls.
|
||||
const connections = [
|
||||
{ id: "healthy", isActive: true, testStatus: "active" },
|
||||
{ id: "healthy-success", isActive: true, testStatus: "success" },
|
||||
{ id: "disabled-but-stale-status", isActive: false, testStatus: "active" },
|
||||
{ id: "disabled-success-status", isActive: false, testStatus: "success" },
|
||||
{ id: "enabled-not-tested", isActive: true, testStatus: "untested" },
|
||||
{ id: "legacy-no-isActive", testStatus: "active" },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
filterUsableConnections(connections).map((c) => c.id),
|
||||
["healthy", "healthy-success", "legacy-no-isActive"]
|
||||
);
|
||||
});
|
||||
|
||||
test("filterUsableConnections returns an empty list for invalid input", () => {
|
||||
assert.deepEqual(filterUsableConnections(undefined), []);
|
||||
assert.deepEqual(filterUsableConnections(null), []);
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import type { RegistryModel } from "../../open-sse/config/providerRegistry.ts";
|
||||
|
||||
const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
|
||||
const RESPONSES_URL = "https://api.githubcopilot.com/responses";
|
||||
const MESSAGES_URL = "https://api.githubcopilot.com/v1/messages";
|
||||
|
||||
function getGithubModel(modelId: string): RegistryModel {
|
||||
const model = PROVIDER_MODELS["gh"]?.find((entry) => entry.id === modelId);
|
||||
@@ -35,7 +36,7 @@ function getGithubModel(modelId: string): RegistryModel {
|
||||
}
|
||||
|
||||
describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9router#1536)", () => {
|
||||
it("routes registered Claude/Gemini Copilot models to chat/completions", () => {
|
||||
it("routes registered Claude Copilot models to the native /v1/messages shim (port decolua/9router#2608)", () => {
|
||||
const exec = new GithubExecutor();
|
||||
for (const id of [
|
||||
"claude-haiku-4.5",
|
||||
@@ -43,14 +44,18 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout
|
||||
"claude-sonnet-4.6",
|
||||
"claude-sonnet-5",
|
||||
"claude-fable-5",
|
||||
"claude-opus-4.6",
|
||||
"claude-opus-4.7",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4.8-fast",
|
||||
"claude-opus-4.5",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
]) {
|
||||
assert.equal(exec.buildUrl(id, false), MESSAGES_URL, `${id} must route to /v1/messages`);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes registered Gemini Copilot models to chat/completions", () => {
|
||||
const exec = new GithubExecutor();
|
||||
for (const id of ["gemini-3.1-pro-preview", "gemini-3.5-flash"]) {
|
||||
assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -174,6 +174,51 @@ describe("cors/origins.applyCorsHeaders", () => {
|
||||
assert.match(res.headers.get("Vary") || "", /Origin/);
|
||||
});
|
||||
|
||||
it("CLIENT_API: appends Vary: Accept-Encoding on a 2xx relaxForTokenAuth response (#6737)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models");
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.match(res.headers.get("Vary") || "", /Accept-Encoding/);
|
||||
});
|
||||
|
||||
it("CLIENT_API: combines with Vary: Origin into a single comma-joined header (#6737)", () => {
|
||||
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { Origin: "https://app.example.com" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
const varyValues = res.headers.getSetCookie ? res.headers.get("Vary") : res.headers.get("Vary");
|
||||
assert.equal(varyValues, "Origin, Accept-Encoding");
|
||||
assert.equal([...res.headers.entries()].filter(([k]) => k.toLowerCase() === "vary").length, 1);
|
||||
});
|
||||
|
||||
it("MANAGEMENT: does not append Vary: Accept-Encoding (relax off) (#6737)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/keys");
|
||||
applyCorsHeaders(res, req);
|
||||
assert.doesNotMatch(res.headers.get("Vary") || "", /Accept-Encoding/);
|
||||
applyCorsHeaders(res, req, false);
|
||||
assert.doesNotMatch(res.headers.get("Vary") || "", /Accept-Encoding/);
|
||||
});
|
||||
|
||||
it("204 response: does not append Vary: Accept-Encoding even with relaxForTokenAuth (#6737)", () => {
|
||||
const res = new NextResponse(null, { status: 204 });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.doesNotMatch(res.headers.get("Vary") || "", /Accept-Encoding/);
|
||||
});
|
||||
|
||||
it("CLIENT_API: appends Vary: Accept-Encoding even without an Origin header (#6737)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models");
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*");
|
||||
assert.match(res.headers.get("Vary") || "", /Accept-Encoding/);
|
||||
});
|
||||
|
||||
it("reflects requested headers from Access-Control-Request-Headers preflight", () => {
|
||||
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
|
||||
const res = NextResponse.json({ ok: true });
|
||||
|
||||
@@ -35,9 +35,12 @@ test("resolveRequestedModel maps cursor-agent's client-side aliases", () => {
|
||||
modelId: "composer-2",
|
||||
parameters: [{ id: "fast", value: "true" }],
|
||||
});
|
||||
// #7289: pinned Claude ids with an effort suffix split into the base id +
|
||||
// an "effort" ModelParameter — cursor's server has no route for the
|
||||
// suffixed id verbatim (see cursor-model-effort-suffix-7289.test.ts).
|
||||
assert.deepEqual(resolveRequestedModel("claude-4.6-sonnet-medium"), {
|
||||
modelId: "claude-4.6-sonnet-medium",
|
||||
parameters: [],
|
||||
modelId: "claude-4.6-sonnet",
|
||||
parameters: [{ id: "effort", value: "medium" }],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("composer-2"), { modelId: "composer-2", parameters: [] });
|
||||
});
|
||||
@@ -148,15 +151,21 @@ test("encodeAgentRunRequest sends ModelDetails for pinned thinking models (#3714
|
||||
// #3714: pinned Claude/GPT thinking variants returned an empty turn when sent only via
|
||||
// RequestedModel (field 9, bare model_id). cursor-agent's working wire format also
|
||||
// carries a ModelDetails envelope with model_id + display_model_id + display_name.
|
||||
// #7289: the trailing effort suffix ("-xhigh") is now split off into a separate
|
||||
// ModelParameter — the BASE id is what's shared across RequestedModel + ModelDetails.
|
||||
const modelId = "claude-opus-4-7-thinking-xhigh";
|
||||
const baseModelId = "claude-opus-4-7-thinking";
|
||||
const buf = encodeAgentRunRequest({ modelId, userText: "hi" });
|
||||
const occurrences = buf.toString("latin1").split(modelId).length - 1;
|
||||
const text = buf.toString("latin1");
|
||||
const occurrences = text.split(baseModelId).length - 1;
|
||||
// RequestedModel.model_id (1) + ModelDetails {model_id, display_model_id, display_name}
|
||||
// (3) → the id must now appear at least 4 times (it appeared once before the fix).
|
||||
// (3) → the base id must appear at least 4 times.
|
||||
assert.ok(
|
||||
occurrences >= 4,
|
||||
`pinned model id must be encoded in both RequestedModel and ModelDetails (got ${occurrences})`
|
||||
`base model id must be encoded in both RequestedModel and ModelDetails (got ${occurrences})`
|
||||
);
|
||||
assert.ok(text.includes("effort"), "effort parameter id present (#7289)");
|
||||
assert.ok(text.includes("xhigh"), "effort parameter value present (#7289)");
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest keeps RequestedModel + parameters alongside ModelDetails (#3714)", () => {
|
||||
|
||||
50
tests/unit/cursor-model-effort-suffix-7289.test.ts
Normal file
50
tests/unit/cursor-model-effort-suffix-7289.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveRequestedModel } from "../../open-sse/utils/cursorAgentProtobuf";
|
||||
|
||||
// Issue #7289: pinned Claude/GPT models carrying an effort/reasoning suffix
|
||||
// (e.g. "claude-opus-4-8-high") return an empty turn from cursor's server.
|
||||
//
|
||||
// Ground truth captured from the real cursor-agent 2026.07.09 (Node) client
|
||||
// via an http2/fetch preload hook: the wire request for a pinned model with
|
||||
// an effort suffix carries the BASE model id (suffix stripped) plus a
|
||||
// separate ModelParameter — "effort" for Claude models, "reasoning" for GPT
|
||||
// models — not the full suffixed id crammed into model_id.
|
||||
test("resolveRequestedModel splits the effort suffix off pinned Claude model ids (#7289)", () => {
|
||||
assert.deepEqual(resolveRequestedModel("claude-opus-4-8-high"), {
|
||||
modelId: "claude-opus-4-8",
|
||||
parameters: [{ id: "effort", value: "high" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel splits the effort suffix off pinned Claude sonnet model ids (#7289)", () => {
|
||||
assert.deepEqual(resolveRequestedModel("claude-sonnet-5-high"), {
|
||||
modelId: "claude-sonnet-5",
|
||||
parameters: [{ id: "effort", value: "high" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel splits the reasoning suffix off pinned GPT model ids (#7289)", () => {
|
||||
assert.deepEqual(resolveRequestedModel("gpt-5.5-high"), {
|
||||
modelId: "gpt-5.5",
|
||||
parameters: [{ id: "reasoning", value: "high" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel does not touch the composer -fast toggle (#7289 regression guard)", () => {
|
||||
assert.deepEqual(resolveRequestedModel("composer-2-fast"), {
|
||||
modelId: "composer-2",
|
||||
parameters: [{ id: "fast", value: "true" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel does not rewrite ids with no recognized effort suffix (#7289 regression guard)", () => {
|
||||
assert.deepEqual(resolveRequestedModel("claude-2.5"), {
|
||||
modelId: "claude-2.5",
|
||||
parameters: [],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("gpt-4o"), {
|
||||
modelId: "gpt-4o",
|
||||
parameters: [],
|
||||
});
|
||||
});
|
||||
122
tests/unit/db-cleanup-xp-audit-log.test.ts
Normal file
122
tests/unit/db-cleanup-xp-audit-log.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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-xp-audit-cleanup-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const databaseSettings = await import("../../src/lib/db/databaseSettings.ts");
|
||||
const databaseSettingsRoute = await import("../../src/app/api/settings/database/route.ts");
|
||||
const cleanup = await import("../../src/lib/db/cleanup.ts");
|
||||
|
||||
type CountRow = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function makeJsonRequest(method: string, body?: unknown): Request {
|
||||
return new Request("http://localhost/api/settings/database", {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function insertXpAuditLogRow(createdAt: string) {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT INTO xp_audit_log (api_key_id, action, xp_earned, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run("test-api-key", "test-action", 10, null, createdAt);
|
||||
}
|
||||
|
||||
function countXpAuditLogRows(): number {
|
||||
const db = core.getDbInstance();
|
||||
const row = db.prepare("SELECT COUNT(*) AS count FROM xp_audit_log").get() as CountRow;
|
||||
return row.count;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
resetStorage();
|
||||
});
|
||||
|
||||
test("cleanupXpAuditLog deletes rows older than the retention window and keeps recent rows", async () => {
|
||||
const oldCreatedAt = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const recentCreatedAt = new Date().toISOString();
|
||||
|
||||
insertXpAuditLogRow(oldCreatedAt);
|
||||
insertXpAuditLogRow(recentCreatedAt);
|
||||
|
||||
const result = await cleanup.cleanupXpAuditLog();
|
||||
|
||||
assert.equal(result.errors, 0);
|
||||
assert.equal(result.deleted, 1);
|
||||
assert.equal(countXpAuditLogRows(), 1);
|
||||
});
|
||||
|
||||
test("runAutoCleanup includes an xpAuditLog result with numeric deleted/errors fields", async () => {
|
||||
const oldCreatedAt = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString();
|
||||
insertXpAuditLogRow(oldCreatedAt);
|
||||
|
||||
const result = await cleanup.runAutoCleanup();
|
||||
|
||||
assert.ok(result.results.xpAuditLog);
|
||||
assert.equal(typeof result.results.xpAuditLog.deleted, "number");
|
||||
assert.equal(typeof result.results.xpAuditLog.errors, "number");
|
||||
assert.equal(result.results.xpAuditLog.deleted, 1);
|
||||
assert.equal(countXpAuditLogRows(), 0);
|
||||
});
|
||||
|
||||
test("cleanupXpAuditLog honors a configurable retention.xpAuditLog value", async () => {
|
||||
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||
insertXpAuditLogRow(tenDaysAgo);
|
||||
|
||||
const current = databaseSettings.getUserDatabaseSettings();
|
||||
databaseSettings.updateDatabaseSettings({
|
||||
retention: { ...current.retention, xpAuditLog: 15 },
|
||||
});
|
||||
|
||||
let result = await cleanup.cleanupXpAuditLog();
|
||||
assert.equal(result.deleted, 0);
|
||||
assert.equal(countXpAuditLogRows(), 1);
|
||||
|
||||
databaseSettings.updateDatabaseSettings({
|
||||
retention: { ...databaseSettings.getUserDatabaseSettings().retention, xpAuditLog: 5 },
|
||||
});
|
||||
|
||||
result = await cleanup.cleanupXpAuditLog();
|
||||
assert.equal(result.deleted, 1);
|
||||
assert.equal(countXpAuditLogRows(), 0);
|
||||
});
|
||||
|
||||
test("PATCH /api/settings/database round-trips retention.xpAuditLog without stripping it", async () => {
|
||||
const current = databaseSettings.getUserDatabaseSettings();
|
||||
const response = await databaseSettingsRoute.PATCH(
|
||||
makeJsonRequest("PATCH", {
|
||||
retention: { ...current.retention, xpAuditLog: 45 },
|
||||
}) as never
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.retention.xpAuditLog, 45);
|
||||
|
||||
const getResponse = await databaseSettingsRoute.GET(makeJsonRequest("GET") as never);
|
||||
const getBody = await getResponse.json();
|
||||
|
||||
assert.equal(getResponse.status, 200);
|
||||
assert.equal(getBody.retention.xpAuditLog, 45);
|
||||
});
|
||||
200
tests/unit/db-sqljs-preinit-ordering-gap-7288.test.ts
Normal file
200
tests/unit/db-sqljs-preinit-ordering-gap-7288.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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 { fileURLToPath } from "node:url";
|
||||
|
||||
// Regression guard for #7288 / #7494 — a startup step reaching
|
||||
// `getDbInstance()` before `preInitSqlJs()` had run threw the misleading
|
||||
// "sql.js WASM ainda não foi pré-inicializado" error for an EXISTING DB file
|
||||
// when both synchronous drivers (better-sqlite3, node:sqlite) failed.
|
||||
//
|
||||
// NOTE on approach: an earlier version of this fix added a top-level
|
||||
// `await preInitSqlJs(...)` barrier that used to sit at the bottom of
|
||||
// `src/lib/db/core.ts` so merely *importing* core.ts guaranteed the
|
||||
// pre-init. That made core.ts an async ES module — esbuild's CJS bundling
|
||||
// path (used by `tsx`'s CJS require hook, and hit by several other test
|
||||
// files that `require("../../src/lib/db/core.ts")` for cleanup, e.g.
|
||||
// tests/unit/stmt-cache-lru.test.ts) rejects any `require()` of a module
|
||||
// whose dependency graph contains a top-level await ("This require call is
|
||||
// not allowed because the transitive dependency ... contains a top-level
|
||||
// await"), and even where esbuild didn't hard-fail, sharing that pending
|
||||
// top-level-await Promise across node:test's process broke unrelated tests'
|
||||
// event-loop bookkeeping ("Promise resolution is still pending but the
|
||||
// event loop has already resolved" — reproduced with
|
||||
// tests/unit/api/compression/compression-api.test.ts run in the same
|
||||
// process as any of the `require(".../core.ts")` cleanup helpers above).
|
||||
//
|
||||
// The fix instead closes the ordering gap at the real startup entrypoint:
|
||||
// `registerNodejs()` (src/instrumentation-node.ts) now calls
|
||||
// `ensureDbReadyForBoot()` — which pre-initializes sql.js when needed —
|
||||
// BEFORE any other startup step (ensureSecrets(), clearStaleCrashCooldowns(),
|
||||
// getSettings(), initAuditLog()) can reach `getDbInstance()`. No top-level
|
||||
// await anywhere in core.ts.
|
||||
|
||||
async function importFreshCore() {
|
||||
const url = new URL("../../src/lib/db/core.ts", import.meta.url).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
let dataDir: string;
|
||||
let prevDataDir: string | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let coreModule: any;
|
||||
|
||||
test.after(() => {
|
||||
try {
|
||||
coreModule?.resetDbInstance?.();
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
if (prevDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = prevDataDir;
|
||||
if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("src/lib/db/core.ts has no top-level await (breaks esbuild's CJS require() bundling — #7288 hotfix)", () => {
|
||||
const corePath = fileURLToPath(new URL("../../src/lib/db/core.ts", import.meta.url));
|
||||
const source = fs.readFileSync(corePath, "utf8");
|
||||
|
||||
// A bare `await <expr>;` at column 0 (module top-level scope, not inside
|
||||
// any function) is the exact pattern that broke esbuild's CJS bundling for
|
||||
// every transitive `require()` of this module (tsx's CJS require hook,
|
||||
// used by tests/unit/stmt-cache-lru.test.ts and friends).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/^await\s/m,
|
||||
"core.ts must not contain a top-level `await` — it makes the module " +
|
||||
"un-require()-able via esbuild's CJS bundling path and breaks other " +
|
||||
"tests' event-loop bookkeeping when required in the same process"
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"registerNodejs() calls ensureDbReadyForBoot() before any startup step that " +
|
||||
"reaches getDbInstance() (ensureSecrets/clearStaleCrashCooldowns/getSettings/" +
|
||||
"initAuditLog) — closes the #7288/#7494 ordering gap at the real entrypoint",
|
||||
() => {
|
||||
const instrumentationPath = fileURLToPath(
|
||||
new URL("../../src/instrumentation-node.ts", import.meta.url)
|
||||
);
|
||||
const source = fs.readFileSync(instrumentationPath, "utf8");
|
||||
|
||||
const registerStart = source.indexOf("export async function registerNodejs(");
|
||||
assert.ok(registerStart >= 0, "registerNodejs() must exist in instrumentation-node.ts");
|
||||
|
||||
const dbReadyIndex = source.indexOf("await ensureDbReadyForBoot();", registerStart);
|
||||
assert.ok(
|
||||
dbReadyIndex >= 0,
|
||||
"registerNodejs() must call `await ensureDbReadyForBoot();` — it is the only " +
|
||||
"caller of preInitSqlJs()"
|
||||
);
|
||||
|
||||
for (const laterDbTouch of [
|
||||
"await ensureSecrets();",
|
||||
"clearStaleCrashCooldowns()",
|
||||
"await getSettings();",
|
||||
"initAuditLog();",
|
||||
]) {
|
||||
const touchIndex = source.indexOf(laterDbTouch, registerStart);
|
||||
assert.ok(touchIndex >= 0, `expected to find \`${laterDbTouch}\` in registerNodejs()`);
|
||||
assert.ok(
|
||||
dbReadyIndex < touchIndex,
|
||||
`\`await ensureDbReadyForBoot();\` (index ${dbReadyIndex}) must run before ` +
|
||||
`\`${laterDbTouch}\` (index ${touchIndex}) — otherwise that step can reach ` +
|
||||
"getDbInstance() before sql.js has had a chance to pre-initialize (#7288 / #7494)"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"getDbInstance() called after the REAL ensureDbReadyForBoot() warm-up (the one " +
|
||||
"registerNodejs() now runs ahead of every other startup step) no longer throws " +
|
||||
"the ordering-gap 'sql.js WASM ainda não foi pré-inicializado' error when both " +
|
||||
"sync drivers fail on an EXISTING db file (#7288 / #7494)",
|
||||
async () => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7288-"));
|
||||
const sqliteFile = path.join(dataDir, "storage.sqlite");
|
||||
// A directory in place of the sqlite file makes BOTH better-sqlite3 and
|
||||
// node:sqlite fail to open it for real (no mocking needed), while
|
||||
// fs.existsSync(sqliteFile) stays true — the same shape of failure a
|
||||
// real ABI mismatch would produce for the two sync drivers.
|
||||
fs.mkdirSync(sqliteFile);
|
||||
|
||||
prevDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
coreModule = await importFreshCore();
|
||||
|
||||
// Exercise the REAL production warm-up, not a stand-in: registerNodejs()
|
||||
// awaits ensureDbReadyForBoot() -> ensureDbInitialized() (which itself
|
||||
// calls preInitSqlJs() when the sync drivers can't open the file) BEFORE
|
||||
// any other startup step (ensureSecrets() / clearStaleCrashCooldowns() /
|
||||
// getSettings() / initAuditLog()) reaches getDbInstance().
|
||||
const { ensureDbReadyForBoot } = await import("../../src/instrumentation-node");
|
||||
try {
|
||||
await ensureDbReadyForBoot(coreModule.ensureDbInitialized);
|
||||
} catch {
|
||||
// A literal directory can never become a valid DB for ANY driver, so the
|
||||
// warm-up itself is expected to fail here. What matters is only WHICH
|
||||
// error getDbInstance() reports afterwards — see the assertion below.
|
||||
}
|
||||
|
||||
let thrownMessage: string | null = null;
|
||||
try {
|
||||
coreModule.getDbInstance();
|
||||
} catch (err) {
|
||||
thrownMessage = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// Acceptance criterion (#7288): "an existing storage.sqlite still boots
|
||||
// via the sql.js fallback (no 'ainda não foi pré-inicializado')". A
|
||||
// literal directory can't be opened by ANY driver — including sql.js's
|
||||
// own fs.readFileSync — so a residual, *different* I/O error here (e.g.
|
||||
// EISDIR) is expected and is not the ordering-gap bug under test: what
|
||||
// this test proves is that preInitSqlJs() is actually attempted ahead of
|
||||
// getDbInstance() (the fix), not that a synthetic directory becomes a
|
||||
// valid database (impossible for any driver).
|
||||
assert.ok(
|
||||
thrownMessage === null || !/ainda não foi pré-inicializado/.test(thrownMessage),
|
||||
"expected the fix to make preInitSqlJs() run ahead of getDbInstance() instead of " +
|
||||
"throwing the 'not pre-initialized yet' error when both sync drivers fail on an " +
|
||||
`existing DB file — got: ${thrownMessage}`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"the warm-up costs nothing on the happy path: sql.js stays un-initialized when a " +
|
||||
"sync driver can already open the file",
|
||||
async () => {
|
||||
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7288-happy-"));
|
||||
const file2 = path.join(dir2, "storage.sqlite");
|
||||
try {
|
||||
const { tryOpenSync, getSqlJsAdapter } = await import(
|
||||
"../../src/lib/db/adapters/driverFactory"
|
||||
);
|
||||
const { default: Database } = await import("better-sqlite3");
|
||||
const seed = new Database(file2);
|
||||
seed.exec("CREATE TABLE t (id INTEGER)");
|
||||
seed.close();
|
||||
|
||||
// The sync-driver probe is what gates the sql.js/WASM fallback: when it
|
||||
// succeeds, nothing downstream should ever reach preInitSqlJs().
|
||||
const probe = tryOpenSync(file2, { readonly: true });
|
||||
assert.ok(probe, "sanity: a sync driver must be able to open a healthy sqlite file here");
|
||||
probe!.close();
|
||||
|
||||
assert.equal(
|
||||
getSqlJsAdapter(file2),
|
||||
null,
|
||||
"sql.js must NOT be pre-initialized when a sync driver can already open the file — " +
|
||||
"otherwise every boot would pay the WASM-load cost even on the happy path"
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir2, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
99
tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts
Normal file
99
tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, before, after } 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-6996-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { DuckDuckGoWebExecutor, STATUS_URL } = await import(
|
||||
"../../open-sse/executors/duckduckgo-web.ts"
|
||||
);
|
||||
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
const executeInputBase = {
|
||||
model: "gpt-4o-mini",
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
stream: false,
|
||||
},
|
||||
stream: false,
|
||||
credentials: {},
|
||||
};
|
||||
|
||||
describe("#6996 DuckDuckGo VQD 429 misclassification", () => {
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
before(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("propagates upstream 429 instead of masking it as a generic 503", async () => {
|
||||
// Set the mock AFTER the module import so it wins over
|
||||
// open-sse/utils/proxyFetch.ts's own module-load-time
|
||||
// `globalThis.fetch = patchedFetch` side effect.
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : (input as URL | Request).toString();
|
||||
if (url === STATUS_URL) {
|
||||
return new Response("", {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "30" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/duckchat/v1/chat")) {
|
||||
throw new Error("unexpected chat POST reached without a VQD token");
|
||||
}
|
||||
return new Response("<html></html>", { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const executor = new DuckDuckGoWebExecutor();
|
||||
const response = await executor.execute(executeInputBase);
|
||||
|
||||
const httpResponse =
|
||||
response instanceof Response
|
||||
? response
|
||||
: (response as { response: Response }).response;
|
||||
const bodyText = await httpResponse.text();
|
||||
|
||||
assert.equal(
|
||||
httpResponse.status,
|
||||
429,
|
||||
`expected the executor to surface DuckDuckGo's real 429 rate-limit status, got ${httpResponse.status} (body: ${bodyText})`
|
||||
);
|
||||
});
|
||||
|
||||
it("still returns 503 fallback for a genuine 5xx status on the VQD endpoint", async () => {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : (input as URL | Request).toString();
|
||||
if (url === STATUS_URL) {
|
||||
return new Response("", { status: 500 });
|
||||
}
|
||||
if (url.includes("/duckchat/v1/chat")) {
|
||||
throw new Error("unexpected chat POST reached without a VQD token");
|
||||
}
|
||||
return new Response("<html></html>", { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const executor = new DuckDuckGoWebExecutor();
|
||||
const response = await executor.execute(executeInputBase);
|
||||
|
||||
const httpResponse =
|
||||
response instanceof Response
|
||||
? response
|
||||
: (response as { response: Response }).response;
|
||||
const bodyText = await httpResponse.text();
|
||||
|
||||
assert.equal(
|
||||
httpResponse.status,
|
||||
503,
|
||||
`expected the executor to keep the 503 fallback for a genuine upstream 5xx, got ${httpResponse.status} (body: ${bodyText})`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
withEarlyStreamKeepalive,
|
||||
ANTHROPIC_PING_FRAME,
|
||||
OPENAI_KEEPALIVE_FRAME,
|
||||
} from "../../open-sse/utils/earlyStreamKeepalive.ts";
|
||||
|
||||
async function readAll(response: Response): Promise<string> {
|
||||
@@ -68,9 +69,40 @@ test("ANTHROPIC_PING_FRAME is a real Anthropic ping event (not a comment)", () =
|
||||
assert.doesNotMatch(decoded, /^:/, "must not be an SSE comment");
|
||||
});
|
||||
|
||||
test("OPENAI_KEEPALIVE_FRAME is a JSON-parseable OpenAI streaming chunk", () => {
|
||||
const decoded = new TextDecoder().decode(OPENAI_KEEPALIVE_FRAME);
|
||||
assert.match(decoded, /^data: /);
|
||||
assert.doesNotMatch(decoded, /^:/, "must not be an SSE comment");
|
||||
|
||||
const payload = JSON.parse(decoded.slice("data: ".length).trim());
|
||||
assert.equal(payload.object, "chat.completion.chunk");
|
||||
assert.deepEqual(payload.choices, [{ index: 0, delta: {}, finish_reason: null }]);
|
||||
});
|
||||
|
||||
test("slow handler emits the custom OpenAI keepalive chunk before the body", async () => {
|
||||
const slow = new Promise<Response>((resolve) => {
|
||||
setTimeout(() => resolve(sseResponse("data: [DONE]\n\n")), 120);
|
||||
});
|
||||
|
||||
const result = await withEarlyStreamKeepalive(slow, {
|
||||
thresholdMs: 25,
|
||||
intervalMs: 20,
|
||||
keepaliveFrame: OPENAI_KEEPALIVE_FRAME,
|
||||
});
|
||||
|
||||
const body = await readAll(result);
|
||||
assert.doesNotMatch(body, /: omniroute-keepalive/);
|
||||
const firstFrame = body.split("\n\n")[0];
|
||||
assert.doesNotThrow(() => JSON.parse(firstFrame.slice("data: ".length)));
|
||||
assert.match(body, /data: \[DONE\]/);
|
||||
});
|
||||
|
||||
test("slow handler emits the custom keepaliveFrame (Anthropic ping) before the body", async () => {
|
||||
const slow = new Promise<Response>((resolve) => {
|
||||
setTimeout(() => resolve(sseResponse("event: message_start\ndata: {}\n\ndata: [DONE]\n\n")), 120);
|
||||
setTimeout(
|
||||
() => resolve(sseResponse("event: message_start\ndata: {}\n\ndata: [DONE]\n\n")),
|
||||
120
|
||||
);
|
||||
});
|
||||
|
||||
const result = await withEarlyStreamKeepalive(slow, {
|
||||
|
||||
@@ -87,15 +87,21 @@ test("dropTrailingAssistantPrefill is null/empty safe", () => {
|
||||
|
||||
test("GithubExecutor.transformRequest drops the trailing assistant prefill end-to-end", () => {
|
||||
const executor = new GithubExecutor();
|
||||
// Use an unregistered claude-* id so getModelTargetFormat("gh", ...) resolves
|
||||
// to null and this stays on the /chat/completions path this test targets.
|
||||
// Registered claude-* ids (e.g. "claude-sonnet-4.6") now carry
|
||||
// targetFormat:"claude" (native /v1/messages, which supports prefill — port
|
||||
// of decolua/9router#2608, see github-copilot-claude-native-messages.test.ts)
|
||||
// and intentionally skip this drop.
|
||||
const body = {
|
||||
model: "claude-sonnet-4.6",
|
||||
model: "claude-sonnet-4",
|
||||
messages: [
|
||||
{ role: "user", content: "Hi" },
|
||||
{ role: "assistant", content: "Here is the answer:" },
|
||||
],
|
||||
};
|
||||
|
||||
const out = executor.transformRequest("claude-sonnet-4.6", body, false, {});
|
||||
const out = executor.transformRequest("claude-sonnet-4", body, false, {});
|
||||
|
||||
assert.equal(out.messages.length, 1);
|
||||
assert.equal(out.messages[0].role, "user");
|
||||
|
||||
@@ -161,7 +161,13 @@ test("GithubExecutor.transformRequest sanitizes Anthropic-shape content parts (t
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.transformRequest("claude-sonnet-4.6", body, true, {});
|
||||
// Use an unregistered claude-* id (not "claude-sonnet-4.6"/etc.) so
|
||||
// getModelTargetFormat("gh", ...) resolves to null and this stays on the
|
||||
// /chat/completions path this test targets. Registered claude-* ids now
|
||||
// carry targetFormat:"claude" (native /v1/messages — port of
|
||||
// decolua/9router#2608, see github-copilot-claude-native-messages.test.ts)
|
||||
// and intentionally skip this sanitization.
|
||||
const result = executor.transformRequest("claude-sonnet-4", body, true, {});
|
||||
|
||||
// user message keeps text + image_url parts untouched
|
||||
assert.equal(result.messages[0].content[0].type, "text");
|
||||
|
||||
@@ -91,13 +91,13 @@ afterEach(() => {
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FreePoolTab source toggles", () => {
|
||||
it("renders a toggle group with exactly 3 buttons", async () => {
|
||||
it("renders a toggle group with exactly 4 buttons", async () => {
|
||||
const el = renderTab();
|
||||
await waitForCondition(() => el.querySelector("[role='group']") !== null);
|
||||
const bar = el.querySelector("[role='group']")!;
|
||||
expect(bar).toBeTruthy();
|
||||
const buttons = bar.querySelectorAll("button");
|
||||
expect(buttons.length).toBe(3);
|
||||
expect(buttons.length).toBe(4);
|
||||
});
|
||||
|
||||
it("all toggles start enabled (aria-pressed=true)", async () => {
|
||||
@@ -160,7 +160,7 @@ describe("FreePoolTab source toggles", () => {
|
||||
expect(stored).toContain("1proxy");
|
||||
});
|
||||
|
||||
it("button labels are 1proxy, Proxifly, IPLocate", async () => {
|
||||
it("button labels are 1proxy, Proxifly, IPLocate, Webshare", async () => {
|
||||
const el = renderTab();
|
||||
await waitForCondition(() => el.querySelector("[role='group']") !== null);
|
||||
const texts = Array.from(el.querySelector("[role='group']")!.querySelectorAll("button")).map(
|
||||
@@ -169,6 +169,7 @@ describe("FreePoolTab source toggles", () => {
|
||||
expect(texts).toContain("1proxy");
|
||||
expect(texts).toContain("Proxifly");
|
||||
expect(texts).toContain("IPLocate");
|
||||
expect(texts).toContain("Webshare");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
117
tests/unit/freeProviderRankings-authtype-6915.test.ts
Normal file
117
tests/unit/freeProviderRankings-authtype-6915.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Unit tests for #6915 — Sort/filter Free Provider Rankings by auth Type.
|
||||
*
|
||||
* Targets the PURE helpers `filterRankingsByAuthType` + `sortRankingsAuthTypeFirst`
|
||||
* (no DB, no I/O) so the new filter/sort logic is exercised in isolation, mirroring
|
||||
* `tests/unit/freeProviderRankings-filters.test.ts`.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
filterRankingsByAuthType,
|
||||
sortRankingsAuthTypeFirst,
|
||||
type FreeProviderRanking,
|
||||
type ProviderAuthType,
|
||||
} from "../../src/lib/freeProviderRankings.ts";
|
||||
|
||||
function ranking(id: string, category: ProviderAuthType, score: number): FreeProviderRanking {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
icon: "",
|
||||
color: "#000",
|
||||
category,
|
||||
topModel: null,
|
||||
averageScore: score,
|
||||
modelCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────── filterRankingsByAuthType ────────────────
|
||||
|
||||
test("filterRankingsByAuthType: 'noauth' keeps only NOAUTH rows", () => {
|
||||
const rows = [
|
||||
ranking("a", "noauth", 0.9),
|
||||
ranking("b", "oauth", 0.8),
|
||||
ranking("c", "apikey", 0.7),
|
||||
ranking("d", "noauth", 0.6),
|
||||
];
|
||||
const result = filterRankingsByAuthType(rows, "noauth");
|
||||
assert.deepEqual(
|
||||
result.map((r) => r.id),
|
||||
["a", "d"]
|
||||
);
|
||||
});
|
||||
|
||||
test("filterRankingsByAuthType: 'oauth' keeps only OAUTH rows", () => {
|
||||
const rows = [ranking("a", "noauth", 0.9), ranking("b", "oauth", 0.8)];
|
||||
const result = filterRankingsByAuthType(rows, "oauth");
|
||||
assert.deepEqual(
|
||||
result.map((r) => r.id),
|
||||
["b"]
|
||||
);
|
||||
});
|
||||
|
||||
test("filterRankingsByAuthType: 'apikey' keeps only APIKEY rows", () => {
|
||||
const rows = [ranking("a", "apikey", 0.9), ranking("b", "oauth", 0.8)];
|
||||
const result = filterRankingsByAuthType(rows, "apikey");
|
||||
assert.deepEqual(
|
||||
result.map((r) => r.id),
|
||||
["a"]
|
||||
);
|
||||
});
|
||||
|
||||
test("filterRankingsByAuthType: empty-string type returns input unchanged (identity — 'All')", () => {
|
||||
const rows = [ranking("a", "noauth", 0.9), ranking("b", "oauth", 0.8)];
|
||||
const result = filterRankingsByAuthType(rows, "");
|
||||
assert.equal(result, rows);
|
||||
});
|
||||
|
||||
test("filterRankingsByAuthType: undefined type returns input unchanged (identity — 'All')", () => {
|
||||
const rows = [ranking("a", "noauth", 0.9), ranking("b", "oauth", 0.8)];
|
||||
const result = filterRankingsByAuthType(rows);
|
||||
assert.equal(result, rows);
|
||||
});
|
||||
|
||||
// ──────────────── sortRankingsAuthTypeFirst ────────────────
|
||||
|
||||
test("sortRankingsAuthTypeFirst: groups NOAUTH < OAUTH < APIKEY", () => {
|
||||
const rows = [
|
||||
ranking("apikey-1", "apikey", 0.95),
|
||||
ranking("oauth-1", "oauth", 0.9),
|
||||
ranking("noauth-1", "noauth", 0.5),
|
||||
];
|
||||
const result = sortRankingsAuthTypeFirst(rows);
|
||||
assert.deepEqual(
|
||||
result.map((r) => r.category),
|
||||
["noauth", "oauth", "apikey"]
|
||||
);
|
||||
});
|
||||
|
||||
test("sortRankingsAuthTypeFirst: preserves relative (score) order within each group (stable-sort proof)", () => {
|
||||
// Input already sorted by score across mixed types (simulating computeFreeProviderRankings output).
|
||||
const rows = [
|
||||
ranking("apikey-best", "apikey", 0.95),
|
||||
ranking("oauth-best", "oauth", 0.9),
|
||||
ranking("noauth-best", "noauth", 0.85),
|
||||
ranking("apikey-worst", "apikey", 0.8),
|
||||
ranking("oauth-worst", "oauth", 0.6),
|
||||
ranking("noauth-worst", "noauth", 0.4),
|
||||
];
|
||||
const result = sortRankingsAuthTypeFirst(rows);
|
||||
assert.deepEqual(
|
||||
result.map((r) => r.id),
|
||||
["noauth-best", "noauth-worst", "oauth-best", "oauth-worst", "apikey-best", "apikey-worst"]
|
||||
);
|
||||
});
|
||||
|
||||
test("sortRankingsAuthTypeFirst: does not mutate the input array", () => {
|
||||
const rows = [ranking("apikey-1", "apikey", 0.95), ranking("noauth-1", "noauth", 0.5)];
|
||||
const original = [...rows];
|
||||
sortRankingsAuthTypeFirst(rows);
|
||||
assert.deepEqual(rows, original);
|
||||
});
|
||||
|
||||
test("sortRankingsAuthTypeFirst: empty input returns empty output", () => {
|
||||
assert.deepEqual(sortRankingsAuthTypeFirst([]), []);
|
||||
});
|
||||
79
tests/unit/fusion-panel-size-cap-1905.test.ts
Normal file
79
tests/unit/fusion-panel-size-cap-1905.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Regression test for upstream issue decolua/9router#1905.
|
||||
*
|
||||
* Reported symptom: a fusion combo populated with ~70+ panel models fans every
|
||||
* member out in parallel (`open-sse/services/fusion.ts::handleFusionChat` →
|
||||
* `Promise.all`-style fan-out via `collectPanel`), buffering each model's full
|
||||
* response text in memory at once. With the runtime heap capped at 1024MB
|
||||
* (Dockerfile `OMNIROUTE_MEMORY_MB`), a large panel with sizable concurrent
|
||||
* responses can exceed the heap ceiling and crash the whole container with
|
||||
* "FATAL ERROR: Ineffective mark-compacts near heap limit — JavaScript heap
|
||||
* out of memory" instead of failing one request gracefully.
|
||||
*
|
||||
* Fix: `handleFusionChat` now rejects panels above a configurable hard cap
|
||||
* (`FUSION_DEFAULTS.maxPanel`, overridable via `fusionTuning.maxPanel`) with a
|
||||
* clean 400 *before* fan-out, rather than let an unbounded panel size drive
|
||||
* the process into an OOM crash.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { handleFusionChat, FUSION_DEFAULTS } from "../../open-sse/services/fusion.ts";
|
||||
|
||||
const noop = () => {};
|
||||
const log = { info: noop, warn: noop, debug: noop, error: noop };
|
||||
|
||||
type Body = Record<string, unknown>;
|
||||
|
||||
test("fusion #1905: an oversized panel (73 models) is rejected before fan-out instead of OOM-crashing", async () => {
|
||||
let calls = 0;
|
||||
const handleSingleModel = (_b: Body, _m: string) => {
|
||||
calls++;
|
||||
const body = JSON.stringify({
|
||||
choices: [{ message: { role: "assistant", content: "x".repeat(1000) } }],
|
||||
});
|
||||
return Promise.resolve(
|
||||
new Response(body, { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
);
|
||||
};
|
||||
|
||||
const panel = Array.from({ length: 73 }, (_, i) => `provider/model-${i}`);
|
||||
|
||||
const res = await handleFusionChat({
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
models: panel,
|
||||
handleSingleModel,
|
||||
log,
|
||||
comboName: "auto",
|
||||
});
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
// Must reject BEFORE fan-out — no per-model calls should have happened.
|
||||
assert.equal(calls, 0, "panel fan-out must not start once the size cap is exceeded");
|
||||
|
||||
const json = (await res.json()) as { error?: { message?: string } };
|
||||
assert.match(json.error?.message ?? "", /panel/i);
|
||||
});
|
||||
|
||||
test("fusion #1905: a panel at or under the cap still fans out normally", async () => {
|
||||
const handleSingleModel = (_b: Body, _m: string) => {
|
||||
const body = JSON.stringify({
|
||||
choices: [{ message: { role: "assistant", content: "ok" } }],
|
||||
});
|
||||
return Promise.resolve(
|
||||
new Response(body, { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
);
|
||||
};
|
||||
|
||||
const panel = Array.from({ length: FUSION_DEFAULTS.maxPanel }, (_, i) => `provider/model-${i}`);
|
||||
|
||||
const res = await handleFusionChat({
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
models: panel,
|
||||
handleSingleModel,
|
||||
log,
|
||||
comboName: "auto",
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
153
tests/unit/fusion-tools-bypass-6771.test.ts
Normal file
153
tests/unit/fusion-tools-bypass-6771.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Regression guard for #6771 — fusion combos stripping tools/tool_choice for
|
||||
* tool-bearing requests via panel-fan-out-then-judge synthesis.
|
||||
*
|
||||
* Root cause: panel members answer tool-shaped prompts with no tool access
|
||||
* (degraded prose), and the judge's injected synthesis directive ("produce
|
||||
* ONE authoritative final answer" from anonymized panel sources) steers even
|
||||
* a tools-capable judge away from emitting a real tool call.
|
||||
*
|
||||
* Fix: detect a tool-bearing request up front (non-empty `tools`, and
|
||||
* `tool_choice` not explicitly "none") and bypass the panel fan-out +
|
||||
* judge-synthesis path entirely — route the full, unmodified body straight
|
||||
* to a single model (the configured judgeModel, or panel[0]).
|
||||
*/
|
||||
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-fusion-6771-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "fusion-6771-test-secret";
|
||||
|
||||
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
|
||||
|
||||
type Body = Record<string, unknown>;
|
||||
|
||||
function jsonResponse(model: string, content: string): Response {
|
||||
const body = JSON.stringify({ model, choices: [{ message: { role: "assistant", content } }] });
|
||||
return new Response(body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
const log = { info: noop, warn: noop, debug: noop, error: noop };
|
||||
|
||||
function fusionCombo(models: string[], extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "fusion-tools",
|
||||
strategy: "fusion",
|
||||
models: models.map((m) => ({ model: m })),
|
||||
config: extra,
|
||||
};
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "get_weather", description: "Get the weather", parameters: {} },
|
||||
},
|
||||
];
|
||||
|
||||
test("6771: tool-bearing request bypasses panel fan-out — single call, tools intact, targets configured judge", async () => {
|
||||
const calls: Array<{ model: string; body: Body }> = [];
|
||||
const handleSingleModel = async (b: Body, m: string) => {
|
||||
calls.push({ model: m, body: b });
|
||||
return jsonResponse(m, "tool decision");
|
||||
};
|
||||
|
||||
const requestBody: Body = {
|
||||
messages: [{ role: "user", content: "what's the weather?" }],
|
||||
tools: TOOLS,
|
||||
tool_choice: "auto",
|
||||
};
|
||||
|
||||
const res = await handleComboChat({
|
||||
body: requestBody,
|
||||
combo: fusionCombo(["panel/a", "panel/b"], { judgeModel: "judge/model" }),
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
|
||||
// Exactly one call — not once per panel member + once for the judge.
|
||||
assert.equal(calls.length, 1, `expected exactly 1 call, got: ${calls.map((c) => c.model).join(", ")}`);
|
||||
assert.equal(calls[0].model, "judge/model");
|
||||
|
||||
// The forwarded body still contains the original tools/tool_choice unmodified.
|
||||
assert.deepEqual(calls[0].body.tools, TOOLS);
|
||||
assert.equal(calls[0].body.tool_choice, "auto");
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
test("6771: tool-bearing request with no explicit judgeModel targets panel[0]", async () => {
|
||||
const calls: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
calls.push(m);
|
||||
return jsonResponse(m, "tool decision");
|
||||
};
|
||||
|
||||
await handleComboChat({
|
||||
body: {
|
||||
messages: [{ role: "user", content: "what's the weather?" }],
|
||||
tools: TOOLS,
|
||||
},
|
||||
combo: fusionCombo(["panel/a", "panel/b"]), // no judgeModel — defaults to panel[0]
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["panel/a"]);
|
||||
});
|
||||
|
||||
test("6771: tools present but tool_choice:\"none\" still goes through normal fan-out+judge path", async () => {
|
||||
const calls: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
calls.push(m);
|
||||
return jsonResponse(m, "prose answer");
|
||||
};
|
||||
|
||||
await handleComboChat({
|
||||
body: {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: TOOLS,
|
||||
tool_choice: "none",
|
||||
},
|
||||
combo: fusionCombo(["panel/a", "panel/b"], { judgeModel: "judge/model" }),
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
|
||||
// panel.length (2) fan-out calls + 1 judge call = 3.
|
||||
assert.equal(calls.length, 3, `expected 3 calls (fan-out+judge), got: ${calls.join(", ")}`);
|
||||
assert.deepEqual(calls.slice(0, 2).sort(), ["panel/a", "panel/b"]);
|
||||
assert.equal(calls[2], "judge/model");
|
||||
});
|
||||
|
||||
test("6771: no regression — request without tools still goes through normal fan-out+judge path", async () => {
|
||||
const calls: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
calls.push(m);
|
||||
return jsonResponse(m, "prose answer");
|
||||
};
|
||||
|
||||
await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
combo: fusionCombo(["panel/a", "panel/b"], { judgeModel: "judge/model" }),
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 3, `expected 3 calls (fan-out+judge), got: ${calls.join(", ")}`);
|
||||
assert.deepEqual(calls.slice(0, 2).sort(), ["panel/a", "panel/b"]);
|
||||
assert.equal(calls[2], "judge/model");
|
||||
});
|
||||
@@ -266,15 +266,20 @@ test("#2832: GeminiWebExecutor catch block sanitizes Playwright launch errors (i
|
||||
|
||||
// ─── StreamGenerate parsing ─────────────────────────────────────────────────
|
||||
|
||||
test("parseStreamResponse concatenates Gemini Web text from multiple wrb.fr chunks", () => {
|
||||
test("parseStreamResponse keeps only the final cumulative StreamGenerate snapshot (no duplication) — regression for #7163", () => {
|
||||
const makeChunk = (text: string) => {
|
||||
const inner = new Array(80).fill(null);
|
||||
inner[4] = [[null, [text]]];
|
||||
return `[["wrb.fr", null, ${JSON.stringify(JSON.stringify(inner))}]]`;
|
||||
};
|
||||
|
||||
const raw = `)]}'\n10\n${makeChunk("First ")}\n5\n${makeChunk("chunk")}`;
|
||||
assert.equal(parseStreamResponse(raw), "First chunk");
|
||||
// Gemini's StreamGenerate frames are CUMULATIVE snapshots: each later frame
|
||||
// repeats the full answer generated so far, not just the new characters.
|
||||
const frame1 = "Hello!";
|
||||
const frame2 = "Hello! How can I";
|
||||
const frame3 = "Hello! How can I help you out today?";
|
||||
const raw = `)]}'\n10\n${makeChunk(frame1)}\n5\n${makeChunk(frame2)}\n5\n${makeChunk(frame3)}`;
|
||||
assert.equal(parseStreamResponse(raw), frame3);
|
||||
});
|
||||
|
||||
test("parseStreamResponse ignores wrb.fr lines whose first entry is not an array", () => {
|
||||
|
||||
131
tests/unit/github-copilot-claude-native-messages.test.ts
Normal file
131
tests/unit/github-copilot-claude-native-messages.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// GitHub Copilot exposes an Anthropic-native `/v1/messages` shim alongside its
|
||||
// OpenAI-shape `/chat/completions` and `/responses` endpoints. Only the native
|
||||
// shim surfaces prompt-cache token counts (`cached_tokens`) for Claude models —
|
||||
// /chat/completions silently drops them, and round-tripping Claude tool_use /
|
||||
// tool_result / thinking content blocks through the OpenAI shape is lossy.
|
||||
//
|
||||
// Port of upstream decolua/9router#2608 (author: yidecode), adapted to
|
||||
// OmniRoute's architecture: instead of the executor doing its own
|
||||
// translateRequest/translateResponse + manual SSE TransformStream (9router has
|
||||
// no generic per-model targetFormat mechanism), OmniRoute already has a
|
||||
// registry-driven `targetFormat` field (see opencode/zen's Qwen entries,
|
||||
// opencode/go) that makes chatCore.ts translate the request to Claude shape
|
||||
// *before* the executor ever sees it, and translate the response back
|
||||
// generically afterwards. So the actual port is: (1) tag the github registry's
|
||||
// claude-* models with targetFormat:"claude", (2) teach the github executor's
|
||||
// buildUrl()/buildHeaders() to route those models at the new messagesUrl with
|
||||
// an anthropic-version header, and (3) gate the executor's /chat/completions-only
|
||||
// request transforms (content-part flattening, trailing-assistant-prefill drop,
|
||||
// response_format-as-system-prompt workaround) off for the native path, since
|
||||
// they either don't apply to Claude-shape bodies or actively corrupt them.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { GithubExecutor } = await import("../../open-sse/executors/github.ts");
|
||||
const { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts");
|
||||
|
||||
test("registry: claude-* github models resolve targetFormat 'claude'", () => {
|
||||
for (const model of ["claude-opus-4.8", "claude-sonnet-4.6", "claude-haiku-4.5"]) {
|
||||
assert.equal(
|
||||
getModelTargetFormat("gh", model),
|
||||
"claude",
|
||||
`${model} must resolve to the claude target format so chatCore translates natively`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("registry: non-claude github models keep their existing targetFormat", () => {
|
||||
assert.equal(getModelTargetFormat("gh", "gpt-5.4"), "openai-responses");
|
||||
assert.equal(getModelTargetFormat("gh", "gpt-4o-mini"), null);
|
||||
});
|
||||
|
||||
test("buildUrl: claude models route to the native /v1/messages endpoint", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const url = executor.buildUrl("claude-opus-4.8", true);
|
||||
assert.equal(url, "https://api.githubcopilot.com/v1/messages");
|
||||
});
|
||||
|
||||
test("buildUrl: gpt codex/responses models still route to /responses", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const url = executor.buildUrl("gpt-5.4", true);
|
||||
assert.match(url, /\/responses$/);
|
||||
});
|
||||
|
||||
test("buildUrl: plain gpt models still route to /chat/completions", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const url = executor.buildUrl("gpt-4o-mini", true);
|
||||
assert.equal(url, executor.config.baseUrl);
|
||||
assert.match(url, /\/chat\/completions$/);
|
||||
});
|
||||
|
||||
test("buildHeaders: claude-native requests carry anthropic-version", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const headers = executor.buildHeaders({ accessToken: "tok" }, true, null, "claude-opus-4.8");
|
||||
assert.equal(headers["anthropic-version"], "2023-06-01");
|
||||
});
|
||||
|
||||
test("buildHeaders: non-claude requests do not carry anthropic-version", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const headers = executor.buildHeaders({ accessToken: "tok" }, true, null, "gpt-4o-mini");
|
||||
assert.equal(headers["anthropic-version"], undefined);
|
||||
});
|
||||
|
||||
test("transformRequest: claude-native path preserves native tool_use/tool_result content blocks", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const body = {
|
||||
model: "claude-opus-4.8",
|
||||
system: "you are a helpful assistant",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "t1", name: "search", input: { q: "hi" } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "t1", content: "result text" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = executor.transformRequest("claude-opus-4.8", body, true, {});
|
||||
// Pre-port: sanitizeChatCompletionsMessage flattened every non-text/image_url
|
||||
// part to {type:"text", text: ...}, destroying the tool_use/tool_result blocks
|
||||
// Anthropic's native /v1/messages endpoint actually needs.
|
||||
assert.equal((result.messages[0].content[0] as { type: string }).type, "tool_use");
|
||||
assert.equal((result.messages[1].content[0] as { type: string }).type, "tool_result");
|
||||
});
|
||||
|
||||
test("transformRequest: claude-native path keeps a trailing assistant message (prefill)", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const body = {
|
||||
model: "claude-opus-4.8",
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "Sure, here is" },
|
||||
],
|
||||
};
|
||||
const result = executor.transformRequest("claude-opus-4.8", body, true, {});
|
||||
// Pre-port: dropTrailingAssistantPrefill removed the trailing assistant turn
|
||||
// because Copilot's /chat/completions rejects prefill — but the native
|
||||
// /v1/messages endpoint is real Anthropic-compatible and supports it.
|
||||
assert.equal(result.messages.length, 2);
|
||||
assert.equal(result.messages[1].role, "assistant");
|
||||
});
|
||||
|
||||
test("transformRequest: non-claude (chat/completions) path still flattens tool_use content and drops prefill", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const body = {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "t1", name: "search", input: {} }],
|
||||
},
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "trailing prefill" },
|
||||
],
|
||||
};
|
||||
const result = executor.transformRequest("gpt-4o-mini", body, true, {});
|
||||
assert.equal((result.messages[0].content[0] as { type: string }).type, "text");
|
||||
assert.equal(result.messages.length, 2, "trailing assistant message must still be dropped");
|
||||
});
|
||||
44
tests/unit/glm-executor-max-tokens-clamp-7364.test.ts
Normal file
44
tests/unit/glm-executor-max-tokens-clamp-7364.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// tests/unit/glm-executor-max-tokens-clamp-7364.test.ts
|
||||
// #7364 Defect B: GlmExecutor.execute() drives its own fetch flow (executeTransport /
|
||||
// transformForTransport) and never runs through DefaultExecutor.execute()'s
|
||||
// stripUnsupportedParams() call site — so a STRIP_RULES clamp entry for provider "glm"
|
||||
// was dead code until transformForTransport() called it directly. This proves the wiring,
|
||||
// not just the STRIP_RULES entry (see zai-glm-max-tokens-clamp-7364.test.ts for that).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { GlmExecutor } from "../../open-sse/executors/glm.ts";
|
||||
|
||||
test("GlmExecutor.transformForTransport clamps an oversized client max_tokens for glm-4.6v (openai transport)", () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const body = { messages: [{ role: "user", content: "describe this image" }], max_tokens: 65536 };
|
||||
|
||||
const transformed = executor.transformForTransport(
|
||||
"glm-4.6v",
|
||||
body,
|
||||
false,
|
||||
{ apiKey: "glm-key" },
|
||||
"openai"
|
||||
) as { max_tokens?: number };
|
||||
|
||||
assert.equal(
|
||||
transformed.max_tokens,
|
||||
32768,
|
||||
"#7364: glm-4.6v max_tokens above the catalog ceiling must be clamped by the real GlmExecutor transform path"
|
||||
);
|
||||
});
|
||||
|
||||
test("GlmExecutor.transformForTransport leaves an in-range max_tokens for glm-4.6v untouched", () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const body = { messages: [{ role: "user", content: "describe this image" }], max_tokens: 2048 };
|
||||
|
||||
const transformed = executor.transformForTransport(
|
||||
"glm-4.6v",
|
||||
body,
|
||||
false,
|
||||
{ apiKey: "glm-key" },
|
||||
"openai"
|
||||
) as { max_tokens?: number };
|
||||
|
||||
assert.equal(transformed.max_tokens, 2048);
|
||||
});
|
||||
35
tests/unit/glob-pattern-6540.test.ts
Normal file
35
tests/unit/glob-pattern-6540.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { globToRegex } from "@/shared/utils/globPattern";
|
||||
|
||||
test("globToRegex — * matches any sequence of characters", () => {
|
||||
const re = globToRegex("claude-sonnet*");
|
||||
assert.equal(re.test("claude-sonnet-4"), true);
|
||||
assert.equal(re.test("claude-sonnet"), true);
|
||||
assert.equal(re.test("claude-opus-4"), false);
|
||||
});
|
||||
|
||||
test("globToRegex — ? matches exactly one character", () => {
|
||||
const re = globToRegex("gpt-?");
|
||||
assert.equal(re.test("gpt-4"), true);
|
||||
assert.equal(re.test("gpt-40"), false);
|
||||
assert.equal(re.test("gpt-"), false);
|
||||
});
|
||||
|
||||
test("globToRegex — case-insensitive", () => {
|
||||
const re = globToRegex("Claude-Sonnet*");
|
||||
assert.equal(re.test("claude-sonnet-4"), true);
|
||||
assert.equal(re.test("CLAUDE-SONNET-4"), true);
|
||||
});
|
||||
|
||||
test("globToRegex — anchored (no partial match)", () => {
|
||||
const re = globToRegex("sonnet");
|
||||
assert.equal(re.test("claude-sonnet-4"), false);
|
||||
assert.equal(re.test("sonnet"), true);
|
||||
});
|
||||
|
||||
test("globToRegex — escapes regex special characters", () => {
|
||||
const re = globToRegex("gpt-4.1");
|
||||
assert.equal(re.test("gpt-4.1"), true);
|
||||
assert.equal(re.test("gpt-4X1"), false); // literal dot, not "any char"
|
||||
});
|
||||
172
tests/unit/gpt5-tools-reasoning-guard.test.ts
Normal file
172
tests/unit/gpt5-tools-reasoning-guard.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* GPT-5 tools+reasoning guard — `stripGpt5ReasoningWhenTools`.
|
||||
*
|
||||
* On the raw `openai` Chat Completions surface, GPT-5.x reasoning models reject a
|
||||
* request that carries BOTH function tools and an active `reasoning_effort` with
|
||||
* HTTP 400: "Function tools with reasoning_effort are not supported for
|
||||
* <model> in /v1/chat/completions. Please use /v1/responses instead."
|
||||
* (port of 9router#2540). OmniRoute's `forceResponsesUpstream` guard only fires
|
||||
* for `openai-compatible-*` connections carrying MCP/tool_search tool shapes —
|
||||
* the plain `openai` provider has no equivalent guard, so this scenario still
|
||||
* reaches the upstream 400 today. Strip `reasoning_effort`/`reasoning` when
|
||||
* function tools are present so the request succeeds on /v1/chat/completions.
|
||||
*
|
||||
* The guard is passed the request's already-resolved `targetFormat` (chatCore
|
||||
* resolves it once via `resolveChatCoreTargetFormat` before this guard runs) so it
|
||||
* gates on the actual upstream surface for THIS request rather than a model-name
|
||||
* list. This matters because #7242 (closes #2540 upstream / 9router#2547) tags the
|
||||
* public GPT-5.6 family with `targetFormat: "openai-responses"` and routes it to
|
||||
* `/v1/responses` instead — an endpoint that accepts tools + reasoning natively —
|
||||
* so stripping must NOT fire for GPT-5.6 requests once that routing is in effect.
|
||||
* Without this composition, #7101's strip and #7242's reroute would combine into
|
||||
* the worst of both worlds: routed to the endpoint that supports reasoning, but
|
||||
* with reasoning silently dropped anyway.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { stripGpt5ReasoningWhenTools } from "../../open-sse/services/gpt5SamplingGuard.ts";
|
||||
|
||||
// Chat Completions models in this suite use gpt-5.4/gpt-5.5 (targetFormat "openai"),
|
||||
// which stay on /chat/completions and must keep being stripped. gpt-5.6-sol is reserved
|
||||
// for the /v1/responses composition cases below, where stripping must NOT happen.
|
||||
|
||||
test("strips reasoning_effort for openai gpt-5.x on /chat/completions when function tools are present", () => {
|
||||
const body = {
|
||||
model: "gpt-5.4-sol",
|
||||
reasoning_effort: "high",
|
||||
tools: [{ type: "function", function: { name: "read_file" } }],
|
||||
messages: [],
|
||||
};
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.4-sol", "openai");
|
||||
assert.equal(result.reasoning_effort, undefined);
|
||||
});
|
||||
|
||||
test("strips nested reasoning.effort for openai gpt-5.x on /chat/completions when function tools are present", () => {
|
||||
const body = {
|
||||
model: "gpt-5.4-sol",
|
||||
reasoning: { effort: "medium" },
|
||||
tools: [{ type: "function", function: { name: "read_file" } }],
|
||||
};
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.4-sol", "openai");
|
||||
assert.equal(result.reasoning, undefined);
|
||||
});
|
||||
|
||||
test("keeps reasoning_effort=none untouched (already non-reasoning mode)", () => {
|
||||
const body = {
|
||||
model: "gpt-5.4-sol",
|
||||
reasoning_effort: "none",
|
||||
tools: [{ type: "function", function: { name: "read_file" } }],
|
||||
};
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.4-sol", "openai");
|
||||
assert.equal(result.reasoning_effort, "none");
|
||||
});
|
||||
|
||||
test("keeps reasoning_effort when there are no tools", () => {
|
||||
const body = { model: "gpt-5.4-sol", reasoning_effort: "high", messages: [] };
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.4-sol", "openai");
|
||||
assert.equal(result.reasoning_effort, "high");
|
||||
});
|
||||
|
||||
test("keeps reasoning_effort when tools array is empty", () => {
|
||||
const body = { model: "gpt-5.4-sol", reasoning_effort: "high", tools: [] };
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.4-sol", "openai");
|
||||
assert.equal(result.reasoning_effort, "high");
|
||||
});
|
||||
|
||||
test("non-openai provider is untouched", () => {
|
||||
const body = {
|
||||
model: "gpt-5.4-sol",
|
||||
reasoning_effort: "high",
|
||||
tools: [{ type: "function", function: { name: "x" } }],
|
||||
};
|
||||
const result = stripGpt5ReasoningWhenTools(body, "codex", "gpt-5.4-sol", "openai");
|
||||
assert.equal(result.reasoning_effort, "high");
|
||||
});
|
||||
|
||||
test("non-gpt-5 openai model is untouched", () => {
|
||||
const body = {
|
||||
model: "gpt-4o",
|
||||
reasoning_effort: "high",
|
||||
tools: [{ type: "function", function: { name: "x" } }],
|
||||
};
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-4o", "openai");
|
||||
assert.equal(result.reasoning_effort, "high");
|
||||
});
|
||||
|
||||
test("returns the same reference when nothing to strip", () => {
|
||||
const body = { model: "gpt-5.4-sol", tools: [{ type: "function" }], messages: [] };
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.4-sol", "openai");
|
||||
assert.equal(result, body);
|
||||
});
|
||||
|
||||
test("logs the stripped fields when a logger is provided", () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const log = { warn: (tag: string, message: string) => calls.push([tag, message]) };
|
||||
stripGpt5ReasoningWhenTools(
|
||||
{
|
||||
model: "gpt-5.4-sol",
|
||||
reasoning_effort: "high",
|
||||
tools: [{ type: "function", function: { name: "x" } }],
|
||||
},
|
||||
"openai",
|
||||
"gpt-5.4-sol",
|
||||
"openai",
|
||||
log
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], "PARAMS");
|
||||
assert.match(calls[0][1], /reasoning_effort/);
|
||||
});
|
||||
|
||||
// --- Composition with #7242 (GPT-5.6 → /v1/responses) ---
|
||||
//
|
||||
// #7242 tags the public GPT-5.6 family with targetFormat "openai-responses" so it is
|
||||
// routed to /v1/responses, which natively supports tools + reasoning together. If this
|
||||
// guard ignored targetFormat and only looked at provider+model-name (the pre-#7242
|
||||
// shape), a GPT-5.6 request with tools + reasoning_effort would still get its reasoning
|
||||
// silently stripped even though it is no longer going to /chat/completions — the worst
|
||||
// of both worlds. These cases prove the composition holds.
|
||||
|
||||
test("gpt-5.6 routed to /v1/responses (targetFormat openai-responses) keeps reasoning_effort", () => {
|
||||
const body = {
|
||||
model: "gpt-5.6-sol",
|
||||
reasoning_effort: "high",
|
||||
tools: [{ type: "function", function: { name: "read_file" } }],
|
||||
messages: [],
|
||||
};
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-sol", "openai-responses");
|
||||
assert.equal(result.reasoning_effort, "high");
|
||||
assert.equal(result, body, "no-op path should return the same reference");
|
||||
});
|
||||
|
||||
test("gpt-5.6 routed to /v1/responses keeps nested reasoning.effort too", () => {
|
||||
const body = {
|
||||
model: "gpt-5.6-sol",
|
||||
reasoning: { effort: "medium" },
|
||||
tools: [{ type: "function", function: { name: "read_file" } }],
|
||||
};
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-sol", "openai-responses");
|
||||
assert.deepEqual(result.reasoning, { effort: "medium" });
|
||||
});
|
||||
|
||||
test("gpt-5.4/gpt-5.5 stay on /chat/completions (targetFormat openai) and keep stripping", () => {
|
||||
for (const model of ["gpt-5.4-sol", "gpt-5.5-pro"]) {
|
||||
const body = {
|
||||
model,
|
||||
reasoning_effort: "high",
|
||||
tools: [{ type: "function", function: { name: "read_file" } }],
|
||||
};
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", model, "openai");
|
||||
assert.equal(result.reasoning_effort, undefined, `${model} should still be stripped`);
|
||||
}
|
||||
});
|
||||
|
||||
test("if gpt-5.6 were ever NOT routed to /v1/responses, the strip would still apply (defense in depth)", () => {
|
||||
const body = {
|
||||
model: "gpt-5.6-sol",
|
||||
reasoning_effort: "high",
|
||||
tools: [{ type: "function", function: { name: "read_file" } }],
|
||||
};
|
||||
const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-sol", "openai");
|
||||
assert.equal(result.reasoning_effort, undefined);
|
||||
});
|
||||
56
tests/unit/grok-cli-proxy-selection.test.ts
Normal file
56
tests/unit/grok-cli-proxy-selection.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Regression test for: Grok Build (grok-cli) inference/refresh requests bypassed the
|
||||
// operator's configured proxy entirely. The executor talks to Grok's upstream via raw
|
||||
// Node `https.request()` (forced IPv4, to dodge Cloudflare blocking on the direct path)
|
||||
// instead of the process-wide patched `fetch()` that every other executor uses — so a
|
||||
// proxy pinned to the connection/provider/global scope was silently ignored, leaking the
|
||||
// real egress IP and defeating account-isolation/anonymity setups. Mirrors the class of
|
||||
// bug fixed upstream in decolua/9router#2343 ("fix(oauth): honor proxy selection during
|
||||
// OAuth login"), adapted to OmniRoute's actual grok-cli architecture (import-token flow,
|
||||
// no device-code polling) where the leak lives in `resolveGrokRequestDispatch()`.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveGrokRequestDispatch } from "../../open-sse/executors/grok-cli.ts";
|
||||
|
||||
const TARGET_URL = "https://grok.x.ai/rest/app-chat/conversations/new";
|
||||
|
||||
test("resolveGrokRequestDispatch: no proxy configured -> direct IPv4 dispatch (unchanged behavior)", () => {
|
||||
const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({
|
||||
source: "direct",
|
||||
proxyUrl: null,
|
||||
}));
|
||||
|
||||
assert.equal(dispatch.family, 4);
|
||||
assert.equal(dispatch.agent, undefined);
|
||||
});
|
||||
|
||||
test("resolveGrokRequestDispatch: HTTP proxy configured -> request is dispatched through a proxy agent, not direct", () => {
|
||||
const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({
|
||||
source: "context",
|
||||
proxyUrl: "http://proxy.internal:8080",
|
||||
}));
|
||||
|
||||
// The fix: an agent bound to the configured proxy must be present, and the
|
||||
// direct-IPv4 workaround must NOT be applied (it would race the proxy tunnel).
|
||||
assert.ok(dispatch.agent, "expected a proxy agent to be constructed");
|
||||
assert.notEqual(dispatch.family, 4);
|
||||
});
|
||||
|
||||
test("resolveGrokRequestDispatch: HTTPS proxy configured -> request is dispatched through a proxy agent", () => {
|
||||
const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({
|
||||
source: "context",
|
||||
proxyUrl: "https://user:pass@proxy.internal:8443",
|
||||
}));
|
||||
|
||||
assert.ok(dispatch.agent, "expected a proxy agent to be constructed");
|
||||
});
|
||||
|
||||
test("resolveGrokRequestDispatch: unsupported proxy protocol (socks5) fails closed instead of leaking direct", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveGrokRequestDispatch(TARGET_URL, () => ({
|
||||
source: "context",
|
||||
proxyUrl: "socks5://proxy.internal:1080",
|
||||
})),
|
||||
/proxy/i
|
||||
);
|
||||
});
|
||||
17
tests/unit/homolog-admin-client.test.ts
Normal file
17
tests/unit/homolog-admin-client.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { extractJwtCookie, extractApiKey } from "../../scripts/homolog/lib/adminClient.mjs";
|
||||
|
||||
test("extrai o cookie JWT do set-cookie do login", () => {
|
||||
const jwt = extractJwtCookie(["auth_token=abc.def.ghi; Path=/; HttpOnly; SameSite=Lax"]);
|
||||
assert.equal(jwt, "auth_token=abc.def.ghi");
|
||||
});
|
||||
|
||||
test("retorna null sem set-cookie de token", () => {
|
||||
assert.equal(extractJwtCookie(["other=1; Path=/"]), null);
|
||||
});
|
||||
|
||||
test("extrai key e id do POST /api/keys", () => {
|
||||
const r = extractApiKey({ key: "or-abc123", id: "k1", name: "homolog-run" });
|
||||
assert.deepEqual(r, { key: "or-abc123", id: "k1" });
|
||||
});
|
||||
29
tests/unit/homolog-parity.test.ts
Normal file
29
tests/unit/homolog-parity.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { evaluateParity } from "../../scripts/homolog/lib/parity.mjs";
|
||||
|
||||
test("parity OK quando health bate com a versão esperada", () => {
|
||||
const r = evaluateParity(
|
||||
{ status: "healthy", version: "3.8.49" },
|
||||
{ expectedVersion: "3.8.49", httpStatus: 200 }
|
||||
);
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.failures, []);
|
||||
});
|
||||
|
||||
test("parity falha listando cada divergência", () => {
|
||||
const r = evaluateParity(
|
||||
{ status: "degraded", version: "3.8.47" },
|
||||
{ expectedVersion: "3.8.49", httpStatus: 200 }
|
||||
);
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.failures.length, 2); // status!=healthy, version mismatch
|
||||
});
|
||||
|
||||
test("parity falha em HTTP não-200 mesmo com body bom", () => {
|
||||
const r = evaluateParity(
|
||||
{ status: "healthy", version: "3.8.49" },
|
||||
{ expectedVersion: "3.8.49", httpStatus: 503 }
|
||||
);
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
18
tests/unit/homolog-promptfoo-ctrf.test.ts
Normal file
18
tests/unit/homolog-promptfoo-ctrf.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { promptfooToCtrf } from "../../scripts/homolog/lib/promptfooToCtrf.mjs";
|
||||
|
||||
test("mapeia resultados do promptfoo para tests CTRF", () => {
|
||||
const ctrf = promptfooToCtrf({
|
||||
results: {
|
||||
results: [
|
||||
{ provider: { label: "openai" }, success: true, latencyMs: 812 },
|
||||
{ provider: { label: "grok" }, success: false, latencyMs: 30000, error: "timeout" },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(ctrf.results.summary.tests, 2);
|
||||
assert.equal(ctrf.results.summary.passed, 1);
|
||||
assert.equal(ctrf.results.tests[1].status, "failed");
|
||||
assert.equal(ctrf.results.tests[1].name, "provider-smoke: grok");
|
||||
});
|
||||
24
tests/unit/homolog-provider-tiers.test.ts
Normal file
24
tests/unit/homolog-provider-tiers.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pickSmokeModels } from "../../scripts/homolog/lib/providerTiers.mjs";
|
||||
|
||||
const CATALOG = [
|
||||
{ id: "openai/gpt-5-mini" },
|
||||
{ id: "openai/gpt-5" },
|
||||
{ id: "anthropic/claude-sonnet-5" },
|
||||
{ id: "mistral/mistral-small" },
|
||||
{ id: "grok/grok-4-fast" },
|
||||
];
|
||||
|
||||
test("1 modelo por provider crítico (o primeiro do catálogo)", () => {
|
||||
const picks = pickSmokeModels(CATALOG, ["openai", "anthropic", "grok"]);
|
||||
assert.deepEqual(
|
||||
picks.map((p) => p.model),
|
||||
["openai/gpt-5-mini", "anthropic/claude-sonnet-5", "grok/grok-4-fast"]
|
||||
);
|
||||
});
|
||||
|
||||
test("provider crítico ausente do catálogo vira miss reportável", () => {
|
||||
const picks = pickSmokeModels(CATALOG, ["openai", "nvidia"]);
|
||||
assert.equal(picks.find((p) => p.provider === "nvidia").model, null);
|
||||
});
|
||||
28
tests/unit/homolog-sse-parser.test.ts
Normal file
28
tests/unit/homolog-sse-parser.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseSseChunk, summarizeStream } from "../../scripts/homolog/lib/sseCheck.mjs";
|
||||
|
||||
test("parseSseChunk separa eventos data: e detecta [DONE]", () => {
|
||||
const events = parseSseChunk('data: {"choices":[{"delta":{"content":"O"}}]}\n\ndata: [DONE]\n\n');
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(events[1], "[DONE]");
|
||||
});
|
||||
|
||||
test("parseSseChunk acha data: mesmo precedido de comment-lines SSE no mesmo bloco", () => {
|
||||
// Formato real da VPS (v3.8.47): trailers de telemetria como comments (`: x-omniroute-*`)
|
||||
// no MESMO bloco do data: [DONE] — o parser não pode olhar só o início do bloco.
|
||||
const chunk =
|
||||
'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n' +
|
||||
": x-omniroute-cache-hit=false\n: x-omniroute-latency-ms=67\ndata: [DONE]\n\n";
|
||||
const events = parseSseChunk(chunk);
|
||||
assert.deepEqual(events, ['{"choices":[{"delta":{"content":"OK"}}]}', "[DONE]"]);
|
||||
});
|
||||
|
||||
test("summarizeStream exige >=1 delta de conteúdo e terminador [DONE]", () => {
|
||||
const good = summarizeStream(['{"choices":[{"delta":{"content":"OK"}}]}', "[DONE]"]);
|
||||
assert.equal(good.ok, true);
|
||||
const noDone = summarizeStream(['{"choices":[{"delta":{"content":"OK"}}]}']);
|
||||
assert.equal(noDone.ok, false);
|
||||
const noContent = summarizeStream(["[DONE]"]);
|
||||
assert.equal(noContent.ok, false);
|
||||
});
|
||||
123
tests/unit/i18n-missing-placeholder-fallback.test.ts
Normal file
123
tests/unit/i18n-missing-placeholder-fallback.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Regression test for #7258 — zh-TW (and other locales) rendering the raw
|
||||
* `__MISSING__:<english>` sentinel written by `scripts/i18n/sync-ui-keys.mjs`
|
||||
* instead of falling back to the clean English value.
|
||||
*
|
||||
* `deepMergeFallback` (src/i18n/request.ts) previously only substituted the
|
||||
* EN value when a key was entirely `undefined`; a key that existed but still
|
||||
* carried the untranslated placeholder passed through untouched and was
|
||||
* rendered verbatim to the user.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { deepMergeFallback, PLACEHOLDER_PREFIX } from "../../src/i18n/request.ts";
|
||||
|
||||
const messagesDir = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"i18n",
|
||||
"messages"
|
||||
);
|
||||
|
||||
function loadLocale(locale: string): Record<string, unknown> {
|
||||
const raw = readFileSync(path.join(messagesDir, `${locale}.json`), "utf8");
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function collectPlaceholderLeaves(
|
||||
node: unknown,
|
||||
pathPrefix: string,
|
||||
out: string[]
|
||||
): void {
|
||||
if (node === null || typeof node !== "object") {
|
||||
if (typeof node === "string" && node.startsWith(PLACEHOLDER_PREFIX)) {
|
||||
out.push(pathPrefix);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) return;
|
||||
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
||||
collectPlaceholderLeaves(value, pathPrefix ? `${pathPrefix}.${key}` : key, out);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Focused repro: the exact keys from the issue report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("#7258 repro: zh-TW keys carry a raw __MISSING__: placeholder before the fix is exercised", () => {
|
||||
const zhTW = loadLocale("zh-TW");
|
||||
const leaves: string[] = [];
|
||||
collectPlaceholderLeaves(zhTW, "", leaves);
|
||||
assert.ok(
|
||||
leaves.length > 0,
|
||||
"expected zh-TW.json to still contain __MISSING__: placeholders (translation content backlog)"
|
||||
);
|
||||
});
|
||||
|
||||
test("#7258: deepMergeFallback replaces an untranslated __MISSING__ placeholder with the EN fallback value", () => {
|
||||
const target: Record<string, unknown> = {
|
||||
localUsageCommand: `${PLACEHOLDER_PREFIX}Run this command locally`,
|
||||
};
|
||||
const source: Record<string, unknown> = {
|
||||
localUsageCommand: "Run this command locally",
|
||||
};
|
||||
const result = deepMergeFallback(target, source);
|
||||
assert.equal(result.localUsageCommand, "Run this command locally");
|
||||
assert.ok(!(result.localUsageCommand as string).startsWith(PLACEHOLDER_PREFIX));
|
||||
});
|
||||
|
||||
test("#7258: deepMergeFallback still lets a real (non-placeholder) locale value win", () => {
|
||||
const target: Record<string, unknown> = { greeting: "Hola" };
|
||||
const source: Record<string, unknown> = { greeting: "Hello" };
|
||||
const result = deepMergeFallback(target, source);
|
||||
assert.equal(result.greeting, "Hola");
|
||||
});
|
||||
|
||||
test("#7258: deepMergeFallback replaces nested placeholder leaves too", () => {
|
||||
const target: Record<string, unknown> = {
|
||||
ns: { a: `${PLACEHOLDER_PREFIX}English A`, b: "translated B" },
|
||||
};
|
||||
const source: Record<string, unknown> = {
|
||||
ns: { a: "English A", b: "English B" },
|
||||
};
|
||||
const result = deepMergeFallback(target, source);
|
||||
const ns = result.ns as Record<string, unknown>;
|
||||
assert.equal(ns.a, "English A");
|
||||
assert.equal(ns.b, "translated B", "already-translated sibling key is untouched");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. General regression: for every shipped locale, the REAL production merge
|
||||
// (locale ⟵ EN fallback) leaves zero raw __MISSING__: leaves.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("#7258: after the real EN-fallback merge, no locale has a raw __MISSING__: leaf", () => {
|
||||
const en = loadLocale("en");
|
||||
const locales = readdirSync(messagesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.replace(/\.json$/, ""))
|
||||
.filter((locale) => locale !== "en");
|
||||
|
||||
assert.ok(locales.length > 0, "expected at least one non-EN locale file");
|
||||
|
||||
const offenders: Record<string, string[]> = {};
|
||||
for (const locale of locales) {
|
||||
const localeMessages = loadLocale(locale);
|
||||
const merged = deepMergeFallback({ ...localeMessages }, en);
|
||||
const leaves: string[] = [];
|
||||
collectPlaceholderLeaves(merged, "", leaves);
|
||||
if (leaves.length > 0) offenders[locale] = leaves;
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
{},
|
||||
`expected zero __MISSING__: leaves after EN fallback merge, found: ${JSON.stringify(offenders)}`
|
||||
);
|
||||
});
|
||||
@@ -11,10 +11,10 @@
|
||||
// `type: "image"` by the imageRegistry loop — and catalogDedupe.ts keys on
|
||||
// (id, type, subtype), so the two distinct-`type` entries both survived.
|
||||
//
|
||||
// Fix: skip a synced model in the chat-catalog loop when it is already a registered
|
||||
// image model for that exact provider (open-sse/config/imageRegistry.ts
|
||||
// isRegisteredImageModel()) — the imageRegistry loop still adds the correctly-typed
|
||||
// `type: "image"` entry.
|
||||
// Fix: skip an exact-provider registered image model from the chat-catalog loop only
|
||||
// when synced metadata does not explicitly advertise `chat` or `responses`. The image
|
||||
// registry loop still adds the correctly typed image entry, while multi-capability
|
||||
// models keep both entries.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -38,6 +38,7 @@ async function resetStorage() {
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
@@ -46,19 +47,19 @@ test.after(async () => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedHuggingFaceConnection() {
|
||||
async function seedProviderConnection(provider: string) {
|
||||
return providersDb.createProviderConnection({
|
||||
provider: "huggingface",
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: `huggingface-${Math.random().toString(16).slice(2, 8)}`,
|
||||
apiKey: "hf-key",
|
||||
name: `${provider}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
apiKey: `${provider}-key`,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
}
|
||||
|
||||
test("#6457 image/diffusion model discovered via live sync is NOT listed as a chat model", async () => {
|
||||
const connection = await seedHuggingFaceConnection();
|
||||
const connection = await seedProviderConnection("huggingface");
|
||||
|
||||
// Simulate what HuggingFace's live `/v1/models` discovery persists for an
|
||||
// image/diffusion model: no supportedEndpoints/modality info at all — the exact
|
||||
@@ -100,3 +101,36 @@ test("#6457 image/diffusion model discovered via live sync is NOT listed as a ch
|
||||
assert.equal(entry.type, undefined, "the real chat model must not carry a non-chat type");
|
||||
}
|
||||
});
|
||||
|
||||
test("registered image model with explicit chat endpoints keeps both catalog entries", async () => {
|
||||
const connection = await seedProviderConnection("codex");
|
||||
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [
|
||||
{
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT 5.6 Sol",
|
||||
supportedEndpoints: ["responses"],
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models?prefix=alias")
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: Array<{ id: string; type?: string; supported_endpoints?: string[] }>;
|
||||
};
|
||||
const entries = body.data.filter((model) => model.id.endsWith("/gpt-5.6-sol"));
|
||||
|
||||
assert.ok(
|
||||
entries.some(
|
||||
(model) => model.type !== "image" && model.supported_endpoints?.includes("responses")
|
||||
),
|
||||
"explicit responses support must keep the synced chat entry"
|
||||
);
|
||||
assert.ok(
|
||||
entries.some((model) => model.type === "image"),
|
||||
"the registered image entry must remain available under the same model id"
|
||||
);
|
||||
});
|
||||
|
||||
103
tests/unit/issue-7071-ollama-session-quota.test.ts
Normal file
103
tests/unit/issue-7071-ollama-session-quota.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Issue #7071 — Ollama Cloud's 5-hour "session" usage-limit 429 is never
|
||||
* recognized as quota-exhausted. The upstream returns a body like:
|
||||
* "you (<account>) have reached your session usage limit"
|
||||
*
|
||||
* This exactly mirrors the already-fixed "weekly usage limit" gap (#3709,
|
||||
* #6638): ollama-cloud is an apikey-category provider (not oauth), so the
|
||||
* oauth-only `shouldUseQuotaSignal` gate in checkFallbackError skips the
|
||||
* generic subscription-quota-text branch (#2321) for its 429s. Without a
|
||||
* dedicated, ungated session check the account fell through to the generic
|
||||
* 429 backoff (~3s, capped low) and got retried within the same 5-hour
|
||||
* session window instead of cooling down for the session's duration —
|
||||
* combo/LKGP routing cycled back to the "exhausted" account instead of
|
||||
* advancing to the next one.
|
||||
*
|
||||
* This test proves: (1) the session-usage-limit text is classified as
|
||||
* QUOTA_EXHAUSTED with a cooldown far longer than the generic backoff cap,
|
||||
* for BOTH apikey and oauth provider categories, and (2) unrelated
|
||||
* session-expired/auth wording and the sibling weekly-quota text are
|
||||
* unaffected.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
|
||||
const { isSessionUsageLimitText, buildSessionQuotaFallback, isWeeklyUsageLimitText } =
|
||||
await import("../../open-sse/services/quotaTextCooldowns.ts");
|
||||
const { RateLimitReason, BACKOFF_CONFIG } = await import("../../open-sse/config/constants.ts");
|
||||
const { BACKOFF_CONFIG: ERROR_BACKOFF_CONFIG } = await import("../../open-sse/config/errorConfig.ts");
|
||||
|
||||
const SESSION_BODY = "you (acme-corp) have reached your session usage limit";
|
||||
const SESSION_COOLDOWN_MS = 5 * 60 * 60 * 1000; // 5 hours
|
||||
|
||||
test("#7071 sanity: weekly text IS recognized (already fixed by #3709/#6638)", () => {
|
||||
assert.equal(isWeeklyUsageLimitText("you (acme-corp) have reached your weekly usage limit"), true);
|
||||
});
|
||||
|
||||
test("#7071 isSessionUsageLimitText matches the ollama-cloud 429 body", () => {
|
||||
assert.equal(isSessionUsageLimitText(SESSION_BODY.toLowerCase()), true);
|
||||
assert.equal(isSessionUsageLimitText("session limit reached, try later"), true);
|
||||
assert.equal(isSessionUsageLimitText("rate_limit_exceeded: too many requests"), false);
|
||||
// Must not false-positive on unrelated "session expired" auth errors.
|
||||
assert.equal(isSessionUsageLimitText("your session has expired, please log in again"), false);
|
||||
assert.equal(isSessionUsageLimitText("session token invalid"), false);
|
||||
});
|
||||
|
||||
test("#7071 buildSessionQuotaFallback returns a 5h QUOTA_EXHAUSTED cooldown, far above the generic backoff cap", () => {
|
||||
const result = buildSessionQuotaFallback(SESSION_BODY);
|
||||
assert.ok(result, "expected a non-null fallback for session-usage-limit text");
|
||||
assert.equal(result!.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
assert.equal(result!.cooldownMs, SESSION_COOLDOWN_MS);
|
||||
assert.ok(result!.cooldownMs > (ERROR_BACKOFF_CONFIG.max ?? BACKOFF_CONFIG.max));
|
||||
});
|
||||
|
||||
test("#7071 buildSessionQuotaFallback returns null for unrelated error text", () => {
|
||||
assert.equal(buildSessionQuotaFallback("rate_limit_exceeded: too many requests"), null);
|
||||
assert.equal(buildSessionQuotaFallback("your session has expired, please log in again"), null);
|
||||
});
|
||||
|
||||
test("#7071 BUG: checkFallbackError misclassifies ollama-cloud session-quota 429 as generic RATE_LIMIT_EXCEEDED instead of QUOTA_EXHAUSTED", () => {
|
||||
const out = checkFallbackError(
|
||||
429,
|
||||
SESSION_BODY,
|
||||
0, // backoffLevel
|
||||
null, // model
|
||||
"ollama-cloud", // provider (apikey category)
|
||||
null, // headers
|
||||
null, // profileOverride
|
||||
null // structuredError
|
||||
);
|
||||
|
||||
assert.equal(out.shouldFallback, true);
|
||||
assert.equal(
|
||||
out.reason,
|
||||
RateLimitReason.QUOTA_EXHAUSTED,
|
||||
`expected QUOTA_EXHAUSTED for session-usage-limit text, got reason=${out.reason} cooldownMs=${out.cooldownMs}`
|
||||
);
|
||||
assert.equal(out.cooldownMs, SESSION_COOLDOWN_MS);
|
||||
});
|
||||
|
||||
test("#7071 checkFallbackError: oauth-category provider with session-limit text also gets the long cooldown", () => {
|
||||
const out = checkFallbackError(429, SESSION_BODY, 0, null, "claude", null, null, null);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
assert.equal(out.cooldownMs, SESSION_COOLDOWN_MS);
|
||||
});
|
||||
|
||||
test("#7071 checkFallbackError: ollama-cloud generic rate-limit body is unaffected (no false positive)", () => {
|
||||
const out = checkFallbackError(
|
||||
429,
|
||||
"rate_limit_exceeded: too many requests",
|
||||
0,
|
||||
null,
|
||||
"ollama-cloud",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
assert.equal(out.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
assert.ok(
|
||||
out.cooldownMs <= 2 * 60 * 1000,
|
||||
"generic rate limit text must keep the normal short backoff, not the 5h session cooldown"
|
||||
);
|
||||
});
|
||||
48
tests/unit/issue-7284-connection-test-masks-429.test.ts
Normal file
48
tests/unit/issue-7284-connection-test-masks-429.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { validateOpenAILikeProvider } = await import(
|
||||
"../../src/lib/providers/validation/openaiFormat.ts"
|
||||
);
|
||||
|
||||
test("#7284: a 429 chat-probe response is reported with a rate-limit warning, not plain valid", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let callCount = 0;
|
||||
|
||||
globalThis.fetch = (async (url: string | URL | Request) => {
|
||||
callCount += 1;
|
||||
const href =
|
||||
typeof url === "string" ? url : "url" in url ? url.url : url instanceof URL ? url.href : "";
|
||||
if (href.includes("/models")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify({ error: { message: "Too Many Requests" } }), {
|
||||
status: 429,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await validateOpenAILikeProvider({
|
||||
provider: "opencode-zen",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://opencode.ai/zen/v1",
|
||||
modelId: "test-model",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
assert.equal(callCount, 2, "expected a /models probe followed by a chat probe");
|
||||
|
||||
const typedResult = result as { valid: boolean; error: string | null; warning?: string };
|
||||
|
||||
assert.equal(typedResult.valid, true, "429 on the chat probe should still be treated as valid");
|
||||
assert.equal(typedResult.error, null);
|
||||
assert.equal(
|
||||
typeof typedResult.warning,
|
||||
"string",
|
||||
"429 response must carry a warning field signaling the rate limit"
|
||||
);
|
||||
assert.match(typedResult.warning as string, /rate limit/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -29,6 +29,11 @@ const REAL_KIRO_IDS = [
|
||||
"minimax-m2.5", // proven 200
|
||||
"minimax-m2.1", // proven 200
|
||||
"qwen3-coder-next", // proven 200
|
||||
// Kiro's first OpenAI-family models, per kiro.dev/changelog/models
|
||||
// (2026-07-14) — not yet independently live-VPS-verified like the ids above.
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-luna",
|
||||
];
|
||||
|
||||
test("kiro registry exposes no fabricated model ids", () => {
|
||||
|
||||
39
tests/unit/kiro-gpt56-family-registration.test.ts
Normal file
39
tests/unit/kiro-gpt56-family-registration.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providers/index.ts";
|
||||
|
||||
const { getResolvedModelCapabilities } = await import("../../src/lib/modelCapabilities.ts");
|
||||
|
||||
// Kiro's first OpenAI-family models, announced 2026-07-14
|
||||
// (kiro.dev/changelog/models): GPT-5.6 Sol / Terra / Luna, all sharing a
|
||||
// 272k context window and a 128k max-output budget on the Kiro backend.
|
||||
const GPT_5_6_KIRO_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const;
|
||||
|
||||
test("kiro registry exposes the GPT-5.6 Sol/Terra/Luna model ids", () => {
|
||||
const ids = new Set((REGISTRY.kiro?.models || []).map((m) => m.id));
|
||||
for (const id of GPT_5_6_KIRO_MODELS) {
|
||||
assert.ok(ids.has(id), `kiro registry must expose "${id}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test("kiro GPT-5.6 models resolve the announced 272k context window", () => {
|
||||
for (const model of GPT_5_6_KIRO_MODELS) {
|
||||
const caps = getResolvedModelCapabilities({ provider: "kiro", model });
|
||||
assert.equal(caps.contextWindow, 272000, `${model} must resolve a 272k context window`);
|
||||
}
|
||||
});
|
||||
|
||||
test("kiro GPT-5.6 models resolve a 128k max output budget", () => {
|
||||
for (const model of GPT_5_6_KIRO_MODELS) {
|
||||
const caps = getResolvedModelCapabilities({ provider: "kiro", model });
|
||||
assert.equal(caps.maxOutputTokens, 128000, `${model} must resolve a 128k max output`);
|
||||
}
|
||||
});
|
||||
|
||||
test("kiro GPT-5.6 models resolve through the 'kr' provider alias too", () => {
|
||||
for (const model of GPT_5_6_KIRO_MODELS) {
|
||||
const caps = getResolvedModelCapabilities({ provider: "kr", model });
|
||||
assert.equal(caps.contextWindow, 272000, `${model} must resolve via the 'kr' alias`);
|
||||
}
|
||||
});
|
||||
211
tests/unit/kiro-sso-cache-direct-clientid-1253.test.ts
Normal file
211
tests/unit/kiro-sso-cache-direct-clientid-1253.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* TDD for upstream 9router#1253 — Kiro auto-import "Bad credentials" when the
|
||||
* cached AWS SSO token carries a direct `clientId` field (no `clientIdHash`).
|
||||
*
|
||||
* Newer kiro-auth-token.json files omit `clientIdHash` and instead store the
|
||||
* OIDC `clientId` directly on the token object. Two related bugs combined to
|
||||
* break refresh for these tokens:
|
||||
*
|
||||
* 1. `tryAwsSsoCache()` (auto-import/route.ts) only ever resolved
|
||||
* clientId/clientSecret via `data.clientIdHash` -> `<hash>.json`. When the
|
||||
* token instead carries a top-level `clientId`, this lookup silently does
|
||||
* nothing, so the auto-import response comes back with clientId/clientSecret
|
||||
* both null even though a matching client-registration file exists in the
|
||||
* same cache dir.
|
||||
* 2. Because auto-import lost the clientId/clientSecret pair, the dashboard's
|
||||
* "Import Token" POST is sent as a plain (non-IDC) import, which routes
|
||||
* through `KiroService.validateImportToken()` ->
|
||||
* `readCachedClientCredentials()`. That helper scans *all* client
|
||||
* registration files in `~/.aws/sso/cache` and picks one by
|
||||
* region + latest-expiry, ignoring the token's own `clientId` entirely. On
|
||||
* a machine with multiple stale SSO client registrations this can return a
|
||||
* clientId/clientSecret pair that does not match the token's actual
|
||||
* clientId, producing "Bad credentials" on refresh.
|
||||
*
|
||||
* Fix: both resolution paths must prefer the client-registration file whose
|
||||
* `clientId` matches the token's own `clientId`, instead of a
|
||||
* latest-expiry/region heuristic.
|
||||
*/
|
||||
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";
|
||||
|
||||
// ── Hermetic DATA_DIR so DB setup / requireLogin does not hit real disk ──────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-1253-data-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || "test-jwt-secret-1253";
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-api-key-secret-1253";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
|
||||
const { GET } = await import("../../src/app/api/oauth/kiro/auto-import/route.ts");
|
||||
const { KiroService } = await import("../../src/lib/oauth/services/kiro.ts");
|
||||
|
||||
const ORIGINAL_HOME = process.env.HOME;
|
||||
const ORIGINAL_APPDATA = process.env.APPDATA;
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
|
||||
let tmpHome: string;
|
||||
|
||||
test.beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-1253-"));
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
process.env.HOME = tmpHome;
|
||||
delete process.env.APPDATA;
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
process.env.HOME = ORIGINAL_HOME;
|
||||
if (ORIGINAL_APPDATA !== undefined) {
|
||||
process.env.APPDATA = ORIGINAL_APPDATA;
|
||||
} else {
|
||||
delete process.env.APPDATA;
|
||||
}
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function cacheDirFor(home: string) {
|
||||
return path.join(home, ".aws/sso/cache");
|
||||
}
|
||||
|
||||
function writeJson(dir: string, file: string, data: Record<string, unknown>) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), JSON.stringify(data));
|
||||
}
|
||||
|
||||
async function callGet(): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const request = new Request("http://localhost/api/oauth/kiro/auto-import");
|
||||
const response = await GET(request);
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
return { status: response.status, body };
|
||||
}
|
||||
|
||||
// ── tryAwsSsoCache() (auto-import route) ─────────────────────────────────────
|
||||
|
||||
test("auto-import: resolves clientId/clientSecret from a direct `clientId` field (no clientIdHash) via matching registration file", async () => {
|
||||
const cacheDir = cacheDirFor(tmpHome);
|
||||
|
||||
// The token file itself: no clientIdHash, only a direct `clientId`.
|
||||
writeJson(cacheDir, "kiro-auth-token.json", {
|
||||
accessToken: "aoa-access",
|
||||
refreshToken: "aorAAAAAGrefresh-token",
|
||||
clientId: "correct-client-id",
|
||||
region: "us-east-1",
|
||||
provider: "BuilderId",
|
||||
authMethod: "IdC",
|
||||
});
|
||||
|
||||
// Two STALE client registration files with a LATER expiresAt than the correct one —
|
||||
// the old latest-expiry heuristic would wrongly prefer these.
|
||||
writeJson(cacheDir, "stale-registration-1.json", {
|
||||
clientId: "stale-client-id-1",
|
||||
clientSecret: "stale-secret-1",
|
||||
region: "us-east-1",
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
});
|
||||
writeJson(cacheDir, "stale-registration-2.json", {
|
||||
clientId: "stale-client-id-2",
|
||||
clientSecret: "stale-secret-2",
|
||||
region: "us-east-1",
|
||||
expiresAt: "2098-01-01T00:00:00Z",
|
||||
});
|
||||
|
||||
// The registration file that actually matches the token's own clientId,
|
||||
// deliberately given the OLDEST expiry so the heuristic must lose to the match.
|
||||
writeJson(cacheDir, "correct-registration.json", {
|
||||
clientId: "correct-client-id",
|
||||
clientSecret: "correct-secret",
|
||||
region: "us-east-1",
|
||||
expiresAt: "2020-01-01T00:00:00Z",
|
||||
});
|
||||
|
||||
const fetchedUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const u = String(input);
|
||||
fetchedUrls.push(u);
|
||||
if (u.includes("oidc.") && u.endsWith("/token")) {
|
||||
const bodyStr = String(init?.body || "{}");
|
||||
const parsed = JSON.parse(bodyStr);
|
||||
// Refresh must be attempted with the CORRECT client credentials.
|
||||
assert.equal(parsed.clientId, "correct-client-id");
|
||||
assert.equal(parsed.clientSecret, "correct-secret");
|
||||
return new Response(
|
||||
JSON.stringify({ accessToken: "access-refreshed", refreshToken: "aorAAAAAGrefreshed", expiresIn: 3600 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
throw new Error(`[kiro-1253 test] unexpected fetch to ${u}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const { body } = await callGet();
|
||||
|
||||
assert.equal(body.found, true, `expected found:true, got: ${JSON.stringify(body)}`);
|
||||
assert.equal(
|
||||
fetchedUrls.some((u) => u.includes("oidc.") && u.endsWith("/token")),
|
||||
true,
|
||||
`expected OIDC refresh to be attempted with resolved client creds, fetched: ${JSON.stringify(fetchedUrls)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── KiroService.readCachedClientCredentials() (via validateImportToken) ─────
|
||||
|
||||
test("KiroService.validateImportToken: prefers the client registration matching the token's own clientId over the latest-expiry heuristic", async () => {
|
||||
const cacheDir = cacheDirFor(tmpHome);
|
||||
|
||||
writeJson(cacheDir, "stale-registration-1.json", {
|
||||
clientId: "stale-client-id-1",
|
||||
clientSecret: "stale-secret-1",
|
||||
region: "us-east-1",
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
});
|
||||
writeJson(cacheDir, "correct-registration.json", {
|
||||
clientId: "correct-client-id",
|
||||
clientSecret: "correct-secret",
|
||||
region: "us-east-1",
|
||||
expiresAt: "2020-01-01T00:00:00Z",
|
||||
});
|
||||
|
||||
const fetchedBodies: Record<string, unknown>[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const u = String(input);
|
||||
if (u.includes("oidc.") && u.endsWith("/token")) {
|
||||
const parsed = JSON.parse(String(init?.body || "{}"));
|
||||
fetchedBodies.push(parsed);
|
||||
if (parsed.clientId === "correct-client-id" && parsed.clientSecret === "correct-secret") {
|
||||
return new Response(
|
||||
JSON.stringify({ accessToken: "ok-access", refreshToken: "aorAAAAAGok", expiresIn: 3600 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ message: "Bad credentials" }), { status: 400 });
|
||||
}
|
||||
throw new Error(`[kiro-1253 test] unexpected fetch to ${u}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const kiroService = new KiroService();
|
||||
const result = await kiroService.validateImportToken(
|
||||
"aorAAAAAGrefresh-token",
|
||||
"us-east-1",
|
||||
"correct-client-id"
|
||||
);
|
||||
|
||||
assert.equal(result.accessToken, "ok-access");
|
||||
assert.ok(
|
||||
fetchedBodies.some(
|
||||
(b) => b.clientId === "correct-client-id" && b.clientSecret === "correct-secret"
|
||||
),
|
||||
`expected a refresh attempt using the matching client credentials, got: ${JSON.stringify(fetchedBodies)}`
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Verifies:
|
||||
* - Return shape matches §3.7 contract
|
||||
* - Markdown table contains all 43 skill IDs
|
||||
* - Markdown table contains all 44 skill IDs
|
||||
* - Coverage bounds are within declared totals
|
||||
* - metadata.source === "agent-skills-catalog"
|
||||
* - metadata.generatedAt is an ISO datetime string
|
||||
@@ -31,7 +31,7 @@ test("executeListCapabilities returns shape matching §3.7 contract", async () =
|
||||
const { metadata } = result;
|
||||
assert.ok(metadata, "metadata exists");
|
||||
assert.equal(metadata.source, "agent-skills-catalog", "metadata.source matches");
|
||||
assert.equal(metadata.totalSkills, 44, "metadata.totalSkills === 44 (43 + config)");
|
||||
assert.equal(metadata.totalSkills, 45, "metadata.totalSkills === 45 (44 + config)");
|
||||
assert.ok(metadata.coverage, "metadata.coverage exists");
|
||||
assert.ok(metadata.coverage.api, "metadata.coverage.api exists");
|
||||
assert.ok(metadata.coverage.cli, "metadata.coverage.cli exists");
|
||||
@@ -39,12 +39,12 @@ test("executeListCapabilities returns shape matching §3.7 contract", async () =
|
||||
assert.equal(metadata.coverage.cli.total, 20, "cli.total === 20");
|
||||
});
|
||||
|
||||
test("executeListCapabilities markdown table contains all 43 API+CLI skill IDs", async () => {
|
||||
test("executeListCapabilities markdown table contains all 44 API+CLI skill IDs", async () => {
|
||||
const result = await executeListCapabilities(stubTask);
|
||||
const content = result.artifacts[0].content;
|
||||
|
||||
const allIds = [...API_SKILL_IDS, ...CLI_SKILL_IDS] as string[];
|
||||
assert.equal(allIds.length, 43, "API+CLI catalog declares 43 skill IDs");
|
||||
assert.equal(allIds.length, 44, "API+CLI catalog declares 44 skill IDs");
|
||||
|
||||
for (const id of allIds) {
|
||||
assert.ok(content.includes(id), `Markdown table missing skill ID: ${id}`);
|
||||
@@ -58,11 +58,11 @@ test("metadata.coverage.api.have is within [0, 23]", async () => {
|
||||
assert.ok(api.have <= 23, "api.have <= 23");
|
||||
});
|
||||
|
||||
test("metadata.coverage.cli.have is within [0, 20]", async () => {
|
||||
test("metadata.coverage.cli.have is within [0, 21]", async () => {
|
||||
const result = await executeListCapabilities(stubTask);
|
||||
const { cli } = result.metadata.coverage;
|
||||
assert.ok(cli.have >= 0, "cli.have >= 0");
|
||||
assert.ok(cli.have <= 20, "cli.have <= 20");
|
||||
assert.ok(cli.have <= 21, "cli.have <= 21");
|
||||
});
|
||||
|
||||
test("metadata.generatedAt is a valid ISO datetime", async () => {
|
||||
|
||||
202
tests/unit/main-server-keepalive-timeout-7003.test.ts
Normal file
202
tests/unit/main-server-keepalive-timeout-7003.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts";
|
||||
|
||||
// #7003 — JetBrains AI Assistant ("Test Connection" / completions) reported
|
||||
// "HTTP/1.1 header parser received no bytes". The main OmniRoute server
|
||||
// (scripts/dev/run-next.mjs) boots a bare `http.createServer(...)` and never
|
||||
// configures `keepAliveTimeout`/`headersTimeout`, leaving Node's http.Server
|
||||
// default of keepAliveTimeout=5_000ms with no `Keep-Alive: timeout=N` response
|
||||
// hint. JetBrains AI Assistant's JVM `java.net.http.HttpClient` connection pool
|
||||
// can reuse a socket idle for longer than that window; the server has already
|
||||
// torn the socket down, so the client gets 0 response bytes back instead of a
|
||||
// fresh HTTP response.
|
||||
//
|
||||
// This spec proves both halves:
|
||||
// 1. `getMainServerTimeoutConfig()` raises the defaults well above Node's
|
||||
// unconfigured 5_000ms window (the actual fix wired into run-next.mjs).
|
||||
// 2. A bare http.Server left at Node's defaults drops a socket reused after
|
||||
// an idle gap past 5s, while the same server configured via
|
||||
// `getMainServerTimeoutConfig()` keeps serving the reused connection.
|
||||
|
||||
describe("#7003 getMainServerTimeoutConfig", () => {
|
||||
it("defaults keepAliveTimeout/headersTimeout well above Node's 5_000ms default", () => {
|
||||
const config = getMainServerTimeoutConfig({});
|
||||
assert.equal(config.keepAliveTimeoutMs, 65_000);
|
||||
assert.equal(config.headersTimeoutMs, 66_000);
|
||||
assert.ok(config.keepAliveTimeoutMs > 5_000, "must exceed Node's unconfigured default");
|
||||
assert.ok(
|
||||
config.headersTimeoutMs > config.keepAliveTimeoutMs,
|
||||
"headersTimeout must stay above keepAliveTimeout per Node's own requirement"
|
||||
);
|
||||
});
|
||||
|
||||
it("honors env overrides and keeps headersTimeout coherent with a raised keepAliveTimeout", () => {
|
||||
const config = getMainServerTimeoutConfig({
|
||||
MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "120000",
|
||||
MAIN_SERVER_HEADERS_TIMEOUT_MS: "121000",
|
||||
});
|
||||
assert.equal(config.keepAliveTimeoutMs, 120_000);
|
||||
assert.equal(config.headersTimeoutMs, 121_000);
|
||||
});
|
||||
|
||||
it("bumps an inconsistent explicit headersTimeout override above keepAliveTimeout", () => {
|
||||
const config = getMainServerTimeoutConfig({
|
||||
MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "120000",
|
||||
MAIN_SERVER_HEADERS_TIMEOUT_MS: "1000",
|
||||
});
|
||||
assert.equal(config.keepAliveTimeoutMs, 120_000);
|
||||
assert.equal(config.headersTimeoutMs, 121_000);
|
||||
});
|
||||
|
||||
it("falls back to defaults on invalid env values", () => {
|
||||
const config = getMainServerTimeoutConfig({
|
||||
MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "not-a-number",
|
||||
});
|
||||
assert.equal(config.keepAliveTimeoutMs, 65_000);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Sends a raw HTTP/1.1 GET over an already-connected keep-alive socket and
|
||||
* resolves with whatever bytes arrive within a short settle window (empty
|
||||
* string if nothing comes back — the exact "0 bytes back" failure mode
|
||||
* JetBrains AI Assistant surfaces as "header parser received no bytes").
|
||||
*
|
||||
* The socket is opened with `allowHalfOpen: true` so it faithfully mimics a
|
||||
* JVM/OkHttp-style client: Node's default `allowHalfOpen: false` proactively
|
||||
* ends the writable side the instant it processes an incoming FIN, turning
|
||||
* the reused write into a synchronous "socket has been ended" error instead
|
||||
* of the real-world race — a write that is accepted locally (the server
|
||||
* already destroyed the connection, so it never arrives) whose response
|
||||
* settles as 0 bytes.
|
||||
*/
|
||||
function sendKeepAliveRequest(socket: net.Socket, port: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let received = "";
|
||||
let settleTimer: NodeJS.Timeout;
|
||||
const finish = () => {
|
||||
socket.off("data", onData);
|
||||
clearTimeout(settleTimer);
|
||||
resolve(received);
|
||||
};
|
||||
// A short settle window once the full chunked response has arrived (fast
|
||||
// path); a generous cap in case nothing ever comes back — the torn-down
|
||||
// connection case this test proves, and a safety margin against first-run
|
||||
// JIT/module-load jitter under the test runner.
|
||||
const onData = (chunk: Buffer) => {
|
||||
received += chunk.toString("utf8");
|
||||
if (received.endsWith("0\r\n\r\n")) {
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(finish, 50);
|
||||
}
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: keep-alive\r\n\r\n`);
|
||||
settleTimer = setTimeout(finish, 3_000);
|
||||
});
|
||||
}
|
||||
|
||||
function startEchoServer(configure: (server: http.Server) => void): Promise<http.Server> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("ok");
|
||||
});
|
||||
configure(server);
|
||||
server.listen(0, "127.0.0.1", () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function withServer(
|
||||
configure: (server: http.Server) => void,
|
||||
run: (port: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
const server = await startEchoServer(configure);
|
||||
try {
|
||||
const address = server.address();
|
||||
if (typeof address !== "object" || address === null) {
|
||||
throw new Error("expected server to bind a TCP address");
|
||||
}
|
||||
await run(address.port);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
// Node's default keepAliveTimeout is 5_000ms, but the server only starts that
|
||||
// timer once the response has fully flushed and there is a small amount of
|
||||
// internal scheduling overhead before the socket is actually torn down —
|
||||
// empirically ~5.8-6s end-to-end on loopback. 6.5s reliably clears that
|
||||
// window without relying on a hair-trigger race.
|
||||
const IDLE_GAP_MS = 6_500;
|
||||
|
||||
describe("#7003 keep-alive socket reuse across an idle gap", () => {
|
||||
it(
|
||||
"current Node defaults (keepAliveTimeout=5000ms): a pooled socket reused after 6.5s idle gets 0 bytes back",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
await withServer(
|
||||
() => {
|
||||
/* leave Node's http.Server defaults untouched (keepAliveTimeout=5000ms) */
|
||||
},
|
||||
async (port) => {
|
||||
const socket = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", () => resolve());
|
||||
socket.once("error", reject);
|
||||
});
|
||||
|
||||
const first = await sendKeepAliveRequest(socket, port);
|
||||
assert.match(first, /200/, "first request on a fresh socket must succeed");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, IDLE_GAP_MS));
|
||||
|
||||
const second = await sendKeepAliveRequest(socket, port);
|
||||
assert.equal(
|
||||
second,
|
||||
"",
|
||||
"reusing the idle-torn-down socket must get exactly 0 bytes back (the reported bug)"
|
||||
);
|
||||
socket.destroy();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"fixed config (getMainServerTimeoutConfig): the same reused connection stays alive past 6.5s idle",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const fixedTimeouts = getMainServerTimeoutConfig({});
|
||||
await withServer(
|
||||
(server) => {
|
||||
server.keepAliveTimeout = fixedTimeouts.keepAliveTimeoutMs;
|
||||
server.headersTimeout = fixedTimeouts.headersTimeoutMs;
|
||||
},
|
||||
async (port) => {
|
||||
const socket = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", () => resolve());
|
||||
socket.once("error", reject);
|
||||
});
|
||||
|
||||
const first = await sendKeepAliveRequest(socket, port);
|
||||
assert.match(first, /200/, "first request on a fresh socket must succeed");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, IDLE_GAP_MS));
|
||||
|
||||
const second = await sendKeepAliveRequest(socket, port);
|
||||
assert.match(
|
||||
second,
|
||||
/200/,
|
||||
"the reused connection must still get a valid response after the fix"
|
||||
);
|
||||
socket.destroy();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
39
tests/unit/main-server-timeouts-parity.test.ts
Normal file
39
tests/unit/main-server-timeouts-parity.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { getMainServerTimeoutConfig as mjsImpl } from "../../scripts/dev/main-server-timeouts.mjs";
|
||||
import { getMainServerTimeoutConfig as tsImpl } from "../../src/shared/utils/runtimeTimeouts.ts";
|
||||
|
||||
// The shipped server-ws.mjs uses the SIBLING scripts/dev/main-server-timeouts.mjs
|
||||
// (a ../../src import escapes the package after the dist copy — 2026-07-15 boot
|
||||
// crash, #7065 class). This parity matrix is the anti-drift guard between the
|
||||
// sibling and the canonical src/shared/utils/runtimeTimeouts.ts implementation.
|
||||
const ENV_MATRIX: Record<string, string | undefined>[] = [
|
||||
{},
|
||||
{ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "70000" },
|
||||
{ MAIN_SERVER_HEADERS_TIMEOUT_MS: "80000" },
|
||||
{ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "90000", MAIN_SERVER_HEADERS_TIMEOUT_MS: "10000" },
|
||||
{ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "0", MAIN_SERVER_HEADERS_TIMEOUT_MS: "0" },
|
||||
{ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "abc" },
|
||||
{ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: " " },
|
||||
{ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "-5" },
|
||||
{ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "1234.9" },
|
||||
];
|
||||
|
||||
test("sibling main-server-timeouts.mjs stays in parity with runtimeTimeouts.ts", () => {
|
||||
for (const env of ENV_MATRIX) {
|
||||
assert.deepStrictEqual(
|
||||
mjsImpl(env),
|
||||
tsImpl(env),
|
||||
`divergence for env ${JSON.stringify(env)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid values log through the provided logger in both implementations", () => {
|
||||
const logsA: string[] = [];
|
||||
const logsB: string[] = [];
|
||||
mjsImpl({ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "bogus" }, (m) => logsA.push(m));
|
||||
tsImpl({ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "bogus" }, (m) => logsB.push(m));
|
||||
assert.strictEqual(logsA.length, 1);
|
||||
assert.deepStrictEqual(logsA, logsB);
|
||||
});
|
||||
225
tests/unit/materialize-bundled-symlinks.test.ts
Normal file
225
tests/unit/materialize-bundled-symlinks.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
// @ts-expect-error - JS build helper without type declarations
|
||||
import {
|
||||
materializeBundledSymlinks,
|
||||
syncRebuiltNativeModuleIntoHashedEntries,
|
||||
} from "../../scripts/build/assembleStandalone.mjs";
|
||||
|
||||
function makePkg(dir: string, name: string, marker: string) {
|
||||
const pkgDir = join(dir, name);
|
||||
mkdirSync(pkgDir, { recursive: true });
|
||||
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name, marker }));
|
||||
return pkgDir;
|
||||
}
|
||||
|
||||
test("materializeBundledSymlinks dereferences a live symlink into a real directory", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "mbs-live-"));
|
||||
try {
|
||||
const realPkgHome = join(root, "external");
|
||||
mkdirSync(realPkgHome, { recursive: true });
|
||||
makePkg(realPkgHome, "ws", "real-ws");
|
||||
|
||||
const nm = join(root, "bundle", "node_modules");
|
||||
mkdirSync(nm, { recursive: true });
|
||||
symlinkSync(join(realPkgHome, "ws"), join(nm, "ws-a972e7ffa40ff725"), "dir");
|
||||
|
||||
const summary = materializeBundledSymlinks(nm);
|
||||
|
||||
assert.equal(summary.materialized, 1);
|
||||
const target = join(nm, "ws-a972e7ffa40ff725");
|
||||
assert.equal(lstatSync(target).isSymbolicLink(), false);
|
||||
assert.equal(lstatSync(target).isDirectory(), true);
|
||||
assert.equal(JSON.parse(readFileSync(join(target, "package.json"), "utf8")).marker, "real-ws");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("materializeBundledSymlinks relinks a dangling hashed symlink to its sibling real package", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "mbs-dangle-"));
|
||||
try {
|
||||
const nm = join(root, "node_modules");
|
||||
mkdirSync(nm, { recursive: true });
|
||||
// Sibling real package (as copied by copyNativeAssetsAndExtraModules).
|
||||
makePkg(nm, "better-sqlite3", "real-bsq");
|
||||
// Dangling absolute link into a build machine that does not exist here.
|
||||
symlinkSync(
|
||||
"/Users/runner/work/OmniRoute/OmniRoute/.build/next/standalone/node_modules/better-sqlite3",
|
||||
join(nm, "better-sqlite3-90e2652d1716b047"),
|
||||
"dir"
|
||||
);
|
||||
|
||||
const summary = materializeBundledSymlinks(nm);
|
||||
|
||||
assert.equal(summary.relinked, 1);
|
||||
const target = join(nm, "better-sqlite3-90e2652d1716b047");
|
||||
assert.equal(lstatSync(target).isSymbolicLink(), false);
|
||||
assert.equal(JSON.parse(readFileSync(join(target, "package.json"), "utf8")).marker, "real-bsq");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("materializeBundledSymlinks drops a dangling link with no resolvable sibling", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "mbs-drop-"));
|
||||
try {
|
||||
const nm = join(root, "node_modules");
|
||||
mkdirSync(nm, { recursive: true });
|
||||
symlinkSync(
|
||||
"/nonexistent/build/machine/path/mystery",
|
||||
join(nm, "mystery-deadbeefcafe0001"),
|
||||
"dir"
|
||||
);
|
||||
|
||||
const summary = materializeBundledSymlinks(nm);
|
||||
|
||||
assert.equal(summary.removed, 1);
|
||||
assert.equal(existsSync(join(nm, "mystery-deadbeefcafe0001")), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("materializeBundledSymlinks handles scoped-package symlinks", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "mbs-scope-"));
|
||||
try {
|
||||
const realPkgHome = join(root, "external");
|
||||
mkdirSync(join(realPkgHome, "@huggingface"), { recursive: true });
|
||||
makePkg(join(realPkgHome, "@huggingface"), "transformers", "real-hf");
|
||||
|
||||
const nm = join(root, "node_modules");
|
||||
mkdirSync(join(nm, "@huggingface"), { recursive: true });
|
||||
symlinkSync(
|
||||
join(realPkgHome, "@huggingface", "transformers"),
|
||||
join(nm, "@huggingface", "transformers-abc1234567890def"),
|
||||
"dir"
|
||||
);
|
||||
|
||||
const summary = materializeBundledSymlinks(nm);
|
||||
|
||||
assert.equal(summary.materialized, 1);
|
||||
const target = join(nm, "@huggingface", "transformers-abc1234567890def");
|
||||
assert.equal(lstatSync(target).isSymbolicLink(), false);
|
||||
assert.equal(JSON.parse(readFileSync(join(target, "package.json"), "utf8")).marker, "real-hf");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("materializeBundledSymlinks leaves real directories untouched and no-ops on missing dir", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "mbs-noop-"));
|
||||
try {
|
||||
const nm = join(root, "node_modules");
|
||||
mkdirSync(nm, { recursive: true });
|
||||
makePkg(nm, "pino", "real-pino");
|
||||
|
||||
const summary = materializeBundledSymlinks(nm);
|
||||
assert.deepEqual(summary, { materialized: 0, relinked: 0, removed: 0 });
|
||||
assert.equal(existsSync(join(nm, "pino", "package.json")), true);
|
||||
|
||||
const missing = materializeBundledSymlinks(join(root, "does-not-exist"));
|
||||
assert.deepEqual(missing, { materialized: 0, relinked: 0, removed: 0 });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("syncRebuiltNativeModuleIntoHashedEntries overwrites a hashed entry with the rebuilt root module", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "sync-hashed-"));
|
||||
try {
|
||||
const rootModule = makePkg(root, "better-sqlite3", "electron-abi-rebuilt");
|
||||
|
||||
const nm = join(root, "nested", "node_modules");
|
||||
makePkg(nm, "better-sqlite3-90e2652d1716b047", "stale-node-abi");
|
||||
|
||||
const summary = syncRebuiltNativeModuleIntoHashedEntries(rootModule, nm);
|
||||
|
||||
assert.equal(summary.synced, 1);
|
||||
const target = join(nm, "better-sqlite3-90e2652d1716b047");
|
||||
assert.equal(
|
||||
JSON.parse(readFileSync(join(target, "package.json"), "utf8")).marker,
|
||||
"electron-abi-rebuilt"
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("syncRebuiltNativeModuleIntoHashedEntries overwrites a plain-named entry too", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "sync-plain-"));
|
||||
try {
|
||||
const rootModule = makePkg(root, "better-sqlite3", "electron-abi-rebuilt");
|
||||
|
||||
const nm = join(root, "nested", "node_modules");
|
||||
makePkg(nm, "better-sqlite3", "stale-node-abi");
|
||||
|
||||
const summary = syncRebuiltNativeModuleIntoHashedEntries(rootModule, nm);
|
||||
|
||||
assert.equal(summary.synced, 1);
|
||||
assert.equal(
|
||||
JSON.parse(readFileSync(join(nm, "better-sqlite3", "package.json"), "utf8")).marker,
|
||||
"electron-abi-rebuilt"
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("syncRebuiltNativeModuleIntoHashedEntries no-ops when root module or nested node_modules is missing", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "sync-noop-"));
|
||||
try {
|
||||
const rootModule = join(root, "does-not-exist", "better-sqlite3");
|
||||
const nm = join(root, "nested", "node_modules");
|
||||
makePkg(nm, "better-sqlite3", "stale-node-abi");
|
||||
|
||||
const missingRoot = syncRebuiltNativeModuleIntoHashedEntries(rootModule, nm);
|
||||
assert.deepEqual(missingRoot, { synced: 0 });
|
||||
|
||||
const realRootModule = makePkg(root, "better-sqlite3", "electron-abi-rebuilt");
|
||||
const missingNm = syncRebuiltNativeModuleIntoHashedEntries(
|
||||
realRootModule,
|
||||
join(root, "does-not-exist-nm")
|
||||
);
|
||||
assert.deepEqual(missingNm, { synced: 0 });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("syncRebuiltNativeModuleIntoHashedEntries leaves unrelated entries untouched", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "sync-unrelated-"));
|
||||
try {
|
||||
const rootModule = makePkg(root, "better-sqlite3", "electron-abi-rebuilt");
|
||||
|
||||
const nm = join(root, "nested", "node_modules");
|
||||
makePkg(nm, "pino", "real-pino");
|
||||
makePkg(nm, "better-sqlite3-helper", "unrelated-package");
|
||||
|
||||
const summary = syncRebuiltNativeModuleIntoHashedEntries(rootModule, nm);
|
||||
|
||||
assert.deepEqual(summary, { synced: 0 });
|
||||
assert.equal(
|
||||
JSON.parse(readFileSync(join(nm, "pino", "package.json"), "utf8")).marker,
|
||||
"real-pino"
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(readFileSync(join(nm, "better-sqlite3-helper", "package.json"), "utf8")).marker,
|
||||
"unrelated-package"
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
102
tests/unit/minimax-image-provider-2482.test.ts
Normal file
102
tests/unit/minimax-image-provider-2482.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// 9router#2482: MiniMax Text-to-Image returns "404 page not found".
|
||||
// MiniMax already has entries in musicRegistry.ts/audioRegistry.ts/videoRegistry.ts,
|
||||
// but no entry at all in imageRegistry.ts (nor a dedicated provider handler under
|
||||
// open-sse/handlers/imageGeneration/providers/), so a MiniMax image-model request
|
||||
// falls through the format dispatch in imageGeneration.ts to a 400/unmatched-format
|
||||
// path instead of reaching MiniMax's image_generation endpoint.
|
||||
//
|
||||
// handleImageGeneration is imported statically (not dynamically inside a test) so
|
||||
// its transitive imports (e.g. the proxy-aware fetch dispatcher) finish installing
|
||||
// their own globalThis.fetch wrapper before any test reassigns it for mocking —
|
||||
// a dynamic import after the mock assignment would let that wrapper silently
|
||||
// clobber the test's mock and hit the real network.
|
||||
const { getImageProvider } = await import("../../open-sse/config/imageRegistry.ts");
|
||||
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
|
||||
|
||||
test("MiniMax is registered as an image provider with a dedicated minimax-image format", () => {
|
||||
const cfg = getImageProvider("minimax");
|
||||
assert.ok(cfg, "expected an IMAGE_PROVIDERS entry for minimax");
|
||||
assert.equal(cfg.id, "minimax");
|
||||
assert.equal(
|
||||
cfg.format,
|
||||
"minimax-image",
|
||||
"MiniMax image_generation is not OpenAI-compatible, must use its own format"
|
||||
);
|
||||
assert.equal(cfg.authType, "apikey");
|
||||
assert.equal(cfg.authHeader, "bearer");
|
||||
assert.match(
|
||||
cfg.baseUrl,
|
||||
/api\.minimax\.io\/v1\/image_generation$/,
|
||||
"image baseUrl must target MiniMax's image_generation endpoint"
|
||||
);
|
||||
});
|
||||
|
||||
test("MiniMax image provider exposes at least one text-to-image model", () => {
|
||||
const cfg = getImageProvider("minimax");
|
||||
const ids = (cfg?.models || []).map((m) => m.id);
|
||||
assert.ok(ids.length > 0, `expected at least one MiniMax image model, got: ${ids.join(", ")}`);
|
||||
assert.ok(
|
||||
Array.isArray(cfg?.supportedSizes) && cfg.supportedSizes.length > 0,
|
||||
"image provider must declare at least one supported size"
|
||||
);
|
||||
});
|
||||
|
||||
test("handleImageGeneration dispatches minimax-image format to the MiniMax handler and normalizes the response", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = (async (url: string) => {
|
||||
fetchCalled = true;
|
||||
assert.match(String(url), /api\.minimax\.io\/v1\/image_generation$/);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
id: "abc123",
|
||||
data: { image_urls: ["https://cdn.minimax.io/generated/one.png"] },
|
||||
base_resp: { status_code: 0, status_msg: "success" },
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await handleImageGeneration({
|
||||
body: { model: "minimax/image-01", prompt: "a red panda in the snow", n: 1 },
|
||||
credentials: { apiKey: "test-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(fetchCalled, true, "expected the MiniMax handler to call fetch");
|
||||
assert.equal(result.success, true, `expected success, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(Array.isArray(result.data?.data) && result.data.data.length === 1);
|
||||
assert.equal(result.data.data[0].url, "https://cdn.minimax.io/generated/one.png");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleImageGeneration surfaces MiniMax upstream errors without a network 404", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = (async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => "login fail: invalid API key",
|
||||
} as unknown as Response;
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await handleImageGeneration({
|
||||
body: { model: "minimax/image-01", prompt: "a red panda in the snow", n: 1 },
|
||||
credentials: { apiKey: "bad-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 401);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -29,13 +29,11 @@ describe("MiniMax M3 model registration (#3110)", () => {
|
||||
assert.equal(m3.contextLength, 1_048_576);
|
||||
});
|
||||
|
||||
it("opencode provider has minimax-m3-free with 1M context", () => {
|
||||
it("opencode provider does NOT list minimax-m3-free (#6998 — delisted upstream, 401)", () => {
|
||||
const entry = REGISTRY.opencode;
|
||||
assert.ok(entry, "opencode registry entry must exist");
|
||||
const m3 = entry.models.find((m) => m.id === "minimax-m3-free");
|
||||
assert.ok(m3, "minimax-m3-free must be in opencode models");
|
||||
assert.equal(m3.name, "MiniMax M3 Free");
|
||||
assert.equal(m3.contextLength, 1_048_576);
|
||||
assert.equal(m3, undefined, "minimax-m3-free was delisted from OpenCode Zen's free tier (#6998)");
|
||||
});
|
||||
|
||||
it("opencode-go provider has minimax-m3 with Claude targetFormat", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user