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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
pull_request:
branches: [master]
push:
branches: [master]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm test

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run build

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npx eslint .
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"start": "NODE_ENV=production next start",
"dev:bun": "bun --bun next dev --webpack --port 20128",
"build:bun": "NODE_ENV=production bun --bun next build --webpack",
"start:bun": "NODE_ENV=production bun ./.next/standalone/server.js"
"start:bun": "NODE_ENV=production bun ./.next/standalone/server.js",
"test": "vitest run --reporter=verbose --config tests/vitest.config.js",
"test:ci": "vitest run --reporter=verbose --config tests/vitest.config.js"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
Expand Down Expand Up @@ -46,6 +48,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.6",
"postcss": "^8.5.6",
"tailwindcss": "^4"
"tailwindcss": "^4",
"vitest": "^4.1.1"
}
}
12 changes: 1 addition & 11 deletions tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,5 @@
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Unit tests for 9router embeddings endpoint",
"scripts": {
"test": "NODE_PATH=/tmp/node_modules /tmp/node_modules/.bin/vitest run --reporter=verbose",
"test:watch": "NODE_PATH=/tmp/node_modules /tmp/node_modules/.bin/vitest --reporter=verbose"
},
"devDependencies": {
"vitest": "^4.0.0"
},
"engines": {
"node": ">=18"
}
"description": "Unit tests for 9router"
}
202 changes: 202 additions & 0 deletions tests/unit/data-dir.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* Data directory resolution tests
*
* Tests the getUserDataDir() logic for resolving the 9router data directory.
* This validates the behavior from the DATA_DIR / XDG_CONFIG_HOME fix (PR C).
*
* Covers:
* - DATA_DIR env var takes highest priority
* - XDG_CONFIG_HOME support on Linux/macOS
* - Backward compatibility: falls back to ~/.9router if XDG path doesn't exist
* - Windows: uses %APPDATA%/9router
* - Default: ~/.9router on Unix
*
* Strategy: tests the logic inline (the function is not exported on master,
* so we replicate the expected behavior and validate it here as a spec).
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import path from "node:path";

// Replicate the getUserDataDir logic from the fix branch (src/lib/dataDir.js)
// This serves as the test spec for expected behavior.
function getUserDataDir({ platform, homedir, env, existsSync }) {
const APP_NAME = "9router";

if (env.DATA_DIR) return env.DATA_DIR;

if (platform === "win32") {
return path.join(
env.APPDATA || path.join(homedir, "AppData", "Roaming"),
APP_NAME
);
}

// Unix (Linux & macOS): check XDG_CONFIG_HOME
const legacyDir = path.join(homedir, `.${APP_NAME}`);

if (env.XDG_CONFIG_HOME) {
const xdgDir = path.join(env.XDG_CONFIG_HOME, APP_NAME);
// Backward compat: prefer legacy path if it exists and XDG path doesn't
if (!existsSync(xdgDir) && existsSync(legacyDir)) {
return legacyDir;
}
return xdgDir;
}

return legacyDir;
}

describe("getUserDataDir", () => {
const defaultCtx = {
platform: "linux",
homedir: "/home/testuser",
env: {},
existsSync: () => false,
};

describe("DATA_DIR env var", () => {
it("uses DATA_DIR when set", () => {
const result = getUserDataDir({
...defaultCtx,
env: { DATA_DIR: "/custom/data" },
});
expect(result).toBe("/custom/data");
});

it("DATA_DIR takes priority over XDG_CONFIG_HOME", () => {
const result = getUserDataDir({
...defaultCtx,
env: {
DATA_DIR: "/custom/data",
XDG_CONFIG_HOME: "/home/testuser/.config",
},
});
expect(result).toBe("/custom/data");
});

it("DATA_DIR takes priority on Windows", () => {
const result = getUserDataDir({
platform: "win32",
homedir: "C:\\Users\\test",
env: { DATA_DIR: "D:\\custom\\data", APPDATA: "C:\\Users\\test\\AppData\\Roaming" },
existsSync: () => false,
});
expect(result).toBe("D:\\custom\\data");
});
});

describe("XDG_CONFIG_HOME support (Linux/macOS)", () => {
it("uses XDG_CONFIG_HOME/9router when set", () => {
const result = getUserDataDir({
...defaultCtx,
env: { XDG_CONFIG_HOME: "/home/testuser/.config" },
existsSync: () => false,
});
expect(result).toBe(
path.join("/home/testuser/.config", "9router")
);
});

it("falls back to legacy dir if XDG path does not exist but legacy does", () => {
const existsSync = (p) => {
if (p === path.join("/home/testuser", ".9router")) return true;
return false; // XDG path doesn't exist
};
const result = getUserDataDir({
...defaultCtx,
env: { XDG_CONFIG_HOME: "/home/testuser/.config" },
existsSync,
});
expect(result).toBe(
path.join("/home/testuser", ".9router")
);
});

it("uses XDG path when both XDG and legacy dirs exist", () => {
const existsSync = () => true; // Both exist
const result = getUserDataDir({
...defaultCtx,
env: { XDG_CONFIG_HOME: "/home/testuser/.config" },
existsSync,
});
expect(result).toBe(
path.join("/home/testuser/.config", "9router")
);
});

it("uses XDG path when neither exists (new install)", () => {
const existsSync = () => false;
const result = getUserDataDir({
...defaultCtx,
env: { XDG_CONFIG_HOME: "/home/testuser/.config" },
existsSync,
});
expect(result).toBe(
path.join("/home/testuser/.config", "9router")
);
});
});

describe("Windows platform", () => {
it("uses APPDATA on Windows", () => {
const result = getUserDataDir({
platform: "win32",
homedir: "C:\\Users\\test",
env: { APPDATA: "C:\\Users\\test\\AppData\\Roaming" },
existsSync: () => false,
});
expect(result).toBe(
path.join("C:\\Users\\test\\AppData\\Roaming", "9router")
);
});

it("falls back to homedir/AppData/Roaming on Windows without APPDATA", () => {
const result = getUserDataDir({
platform: "win32",
homedir: "C:\\Users\\test",
env: {},
existsSync: () => false,
});
expect(result).toBe(
path.join("C:\\Users\\test", "AppData", "Roaming", "9router")
);
});

it("does not use XDG_CONFIG_HOME on Windows", () => {
const result = getUserDataDir({
platform: "win32",
homedir: "C:\\Users\\test",
env: {
APPDATA: "C:\\Users\\test\\AppData\\Roaming",
XDG_CONFIG_HOME: "C:\\Users\\test\\.config",
},
existsSync: () => false,
});
// Should use APPDATA, not XDG
expect(result).toBe(
path.join("C:\\Users\\test\\AppData\\Roaming", "9router")
);
});
});

describe("default (no env vars)", () => {
it("uses ~/.9router on Linux", () => {
const result = getUserDataDir(defaultCtx);
expect(result).toBe(
path.join("/home/testuser", ".9router")
);
});

it("uses ~/.9router on macOS", () => {
const result = getUserDataDir({
...defaultCtx,
platform: "darwin",
homedir: "/Users/testuser",
});
expect(result).toBe(
path.join("/Users/testuser", ".9router")
);
});
});
});
101 changes: 101 additions & 0 deletions tests/unit/format-detection.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Format detection tests — detectFormatByEndpoint, FORMATS constants
*
* Covers:
* - /v1/chat/completions endpoint detection
* - /v1/responses endpoint detection (OPENAI_RESPONSES format)
* - /v1/chat/completions with Cursor CLI input[] body → treated as OPENAI
* - Null return for unrecognized endpoints (fallback to body-based detection)
* - FORMATS constant values
*/

import { describe, it, expect } from "vitest";
import {
detectFormatByEndpoint,
FORMATS,
} from "../../open-sse/translator/formats.js";

describe("FORMATS constants", () => {
it("defines expected format identifiers", () => {
expect(FORMATS.OPENAI).toBe("openai");
expect(FORMATS.CLAUDE).toBe("claude");
expect(FORMATS.GEMINI).toBe("gemini");
expect(FORMATS.GEMINI_CLI).toBe("gemini-cli");
expect(FORMATS.ANTIGRAVITY).toBe("antigravity");
expect(FORMATS.OPENAI_RESPONSES).toBe("openai-responses");
expect(FORMATS.OLLAMA).toBe("ollama");
});
});

describe("detectFormatByEndpoint", () => {
describe("/v1/responses endpoint", () => {
it("returns OPENAI_RESPONSES for /v1/responses", () => {
const result = detectFormatByEndpoint("/v1/responses", {});
expect(result).toBe(FORMATS.OPENAI_RESPONSES);
});

it("returns OPENAI_RESPONSES regardless of body content", () => {
const result = detectFormatByEndpoint("/v1/responses", {
model: "gpt-4o",
input: [{ role: "user", content: "hi" }],
});
expect(result).toBe(FORMATS.OPENAI_RESPONSES);
});

it("returns OPENAI_RESPONSES for nested path containing /v1/responses", () => {
const result = detectFormatByEndpoint(
"/proxy/v1/responses",
{}
);
expect(result).toBe(FORMATS.OPENAI_RESPONSES);
});
});

describe("/v1/chat/completions with Cursor CLI body", () => {
it("returns OPENAI when chat/completions has input[] array (Cursor CLI)", () => {
const body = {
model: "gpt-4o",
input: [{ role: "user", content: "hi" }],
};
const result = detectFormatByEndpoint(
"/v1/chat/completions",
body
);
expect(result).toBe(FORMATS.OPENAI);
});

it("returns null for normal chat/completions without input[]", () => {
const body = {
model: "gpt-4o",
messages: [{ role: "user", content: "hi" }],
};
const result = detectFormatByEndpoint(
"/v1/chat/completions",
body
);
expect(result).toBeNull();
});
});

describe("fallback behavior", () => {
it("returns null for /v1/messages (Claude native endpoint)", () => {
const result = detectFormatByEndpoint("/v1/messages", {});
expect(result).toBeNull();
});

it("returns null for unknown endpoints", () => {
const result = detectFormatByEndpoint("/api/custom", {});
expect(result).toBeNull();
});

it("returns null when body is undefined", () => {
const result = detectFormatByEndpoint("/v1/chat/completions", undefined);
expect(result).toBeNull();
});

it("returns null when body is null", () => {
const result = detectFormatByEndpoint("/v1/chat/completions", null);
expect(result).toBeNull();
});
});
});
Loading
Loading