Клиент — замороженный объект с семью методами GET, без универсального запроса и тела; денежные поля проверяются на строковый тип (fail-closed). Каталог джойнит /api/models и /v1/models: модальность и рубли из первого, USD и доступность ключу из второго. Инструменты: list_models, pick_model, estimate_cost, get_balance, get_usage, list_api_keys. Точка входа stdio уводит console.log/info в stderr первым импортом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
2.9 KiB
TypeScript
79 lines
2.9 KiB
TypeScript
// Собранный сервер отвечает на initialize и tools/list, а в stdout не
|
||
// пишет ничего, кроме JSON-RPC. В сеть не ходит: tools/list её не требует.
|
||
|
||
import { test } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { execFileSync, spawn } from "node:child_process";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||
|
||
test("dist/index.js: stdout — только валидный JSON-RPC, шесть read-only инструментов", { timeout: 60_000 }, async () => {
|
||
execFileSync(process.execPath, ["node_modules/typescript/bin/tsc", "-p", "tsconfig.build.json"], {
|
||
cwd: root,
|
||
stdio: "inherit",
|
||
});
|
||
|
||
const child = spawn(process.execPath, ["dist/index.js"], {
|
||
cwd: root,
|
||
env: { PATH: process.env.PATH ?? "", OPENRUSROUTER_API_KEY: "test", OPENRUSROUTER_BASE_URL: "http://localhost:1" },
|
||
stdio: ["pipe", "pipe", "pipe"],
|
||
});
|
||
|
||
const lines: string[] = [];
|
||
let buf = "";
|
||
const waiters = new Map<number, (msg: Record<string, unknown>) => void>();
|
||
child.stdout.setEncoding("utf8");
|
||
child.stdout.on("data", (chunk: string) => {
|
||
buf += chunk;
|
||
let i: number;
|
||
while ((i = buf.indexOf("\n")) >= 0) {
|
||
const line = buf.slice(0, i);
|
||
buf = buf.slice(i + 1);
|
||
lines.push(line);
|
||
try {
|
||
const msg = JSON.parse(line) as Record<string, unknown>;
|
||
if (typeof msg.id === "number") waiters.get(msg.id)?.(msg);
|
||
} catch {
|
||
// невалидная строка будет поймана проверкой ниже
|
||
}
|
||
}
|
||
});
|
||
child.stderr.resume();
|
||
|
||
const send = (msg: object) => child.stdin.write(`${JSON.stringify(msg)}\n`);
|
||
const request = (id: number, method: string, params: object) =>
|
||
new Promise<Record<string, unknown>>((resolve) => {
|
||
waiters.set(id, resolve);
|
||
send({ jsonrpc: "2.0", id, method, params });
|
||
});
|
||
|
||
try {
|
||
const init = await request(1, "initialize", {
|
||
protocolVersion: "2025-06-18",
|
||
capabilities: {},
|
||
clientInfo: { name: "test", version: "0" },
|
||
});
|
||
assert.equal((init.result as { serverInfo: { name: string } }).serverInfo.name, "openrusrouter");
|
||
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
||
|
||
const list = await request(2, "tools/list", {});
|
||
const tools = (list.result as { tools: { name: string; annotations?: Record<string, unknown> }[] }).tools;
|
||
assert.deepEqual(
|
||
tools.map((t) => t.name).sort(),
|
||
["estimate_cost", "get_balance", "get_usage", "list_api_keys", "list_models", "pick_model"],
|
||
);
|
||
for (const t of tools) {
|
||
assert.equal(t.annotations?.readOnlyHint, true, t.name);
|
||
assert.equal(t.annotations?.destructiveHint, false, t.name);
|
||
}
|
||
} finally {
|
||
child.kill();
|
||
}
|
||
|
||
assert.ok(lines.length >= 2);
|
||
for (const line of lines) {
|
||
assert.doesNotThrow(() => JSON.parse(line), `не JSON в stdout: ${line.slice(0, 100)}`);
|
||
}
|
||
});
|