339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
import { test, describe } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
// ─── Model alias resolution (windsurf) ───────────────────────────────────────
|
|
// We exercise the alias map indirectly through the exported class because
|
|
// resolveWsModelId is not exported. WindsurfExecutor.buildRequest() calls it.
|
|
|
|
describe("Windsurf MODEL_ALIAS_MAP", () => {
|
|
const ALIAS_CASES: [string, string][] = [
|
|
// SWE dot→dash conversions
|
|
["swe-1.6-fast", "swe-1-6-fast"],
|
|
["swe-1.6", "swe-1-6"],
|
|
["swe-1.5", "swe-1p5"],
|
|
["swe-1.5-fast", "swe-1p5"],
|
|
// GPT-5.5 default effort
|
|
["gpt-5.5", "gpt-5-5-medium"],
|
|
// GPT-5.4 default effort
|
|
["gpt-5.4", "gpt-5-4-medium"],
|
|
// GPT-5.3-codex default
|
|
["gpt-5.3-codex", "gpt-5-3-codex-medium"],
|
|
// Claude aliases
|
|
["claude-sonnet-4.6", "claude-sonnet-4-6"],
|
|
["claude-opus-4.7-max", "claude-opus-4-7-max"],
|
|
// Gemini aliases
|
|
["gemini-2.5-pro", "MODEL_GOOGLE_GEMINI_2_5_PRO"],
|
|
];
|
|
|
|
const PASSTHROUGH_CASES = [
|
|
"gpt-5",
|
|
"gpt-5-codex",
|
|
"grok-code-fast-1",
|
|
"deepseek-v4",
|
|
"some-unknown-model",
|
|
// These aliases were removed from MODEL_ALIAS_MAP in v3.8.x — pass through unchanged:
|
|
"claude-3.7-sonnet-thinking",
|
|
"gemini-3.0-pro",
|
|
"kimi-k2",
|
|
];
|
|
|
|
// Load the alias map from the module source — we parse it at test time to
|
|
// avoid importing the full executor (which would require provider registry).
|
|
let aliasMap: Record<string, string>;
|
|
|
|
test("setup: parse MODEL_ALIAS_MAP from executor source", async () => {
|
|
const fs = await import("node:fs/promises");
|
|
const src = await fs.readFile(
|
|
new URL("../../open-sse/executors/windsurf.ts", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const match = src.match(/const MODEL_ALIAS_MAP[^=]*=\s*(\{[\s\S]*?\n\})/);
|
|
assert.ok(match, "MODEL_ALIAS_MAP block should be found in source");
|
|
// Safe eval via Function constructor replacement — build a JS object literal
|
|
const objSrc = match[1]
|
|
.replace(/\/\/[^\n]*/g, "") // strip line comments
|
|
.trim();
|
|
// Parse using JSON after stripping trailing commas (simple approach)
|
|
const jsonLike = objSrc
|
|
.replace(/,\s*([\]}])/g, "$1") // trailing commas
|
|
.replace(/'/g, '"'); // single → double quotes
|
|
aliasMap = JSON.parse(jsonLike);
|
|
assert.ok(typeof aliasMap === "object");
|
|
});
|
|
|
|
for (const [input, expected] of ALIAS_CASES) {
|
|
test(`alias: "${input}" → "${expected}"`, () => {
|
|
const result = aliasMap[input] ?? input;
|
|
assert.equal(result, expected);
|
|
});
|
|
}
|
|
|
|
for (const model of PASSTHROUGH_CASES) {
|
|
test(`passthrough: "${model}" has no alias (returns itself)`, () => {
|
|
const result = aliasMap[model] ?? model;
|
|
assert.equal(result, model);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── openAIMessagesToWs message conversion ────────────────────────────────────
|
|
// Tests the message role/content extraction logic. Since the function is not
|
|
// exported, we test via a re-implementation that mirrors the source exactly.
|
|
|
|
function openAIMessagesToWsLocal(
|
|
messages: Array<{ role?: string; content?: unknown; tool_call_id?: string }>
|
|
): Array<{ role: string; content: string; toolCallId?: string }> {
|
|
const out: Array<{ role: string; content: string; toolCallId?: string }> = [];
|
|
for (const m of messages) {
|
|
const role = String(m.role || "user");
|
|
let content = "";
|
|
if (typeof m.content === "string") {
|
|
content = m.content;
|
|
} else if (Array.isArray(m.content)) {
|
|
for (const part of m.content) {
|
|
if (part && typeof part === "object" && (part as Record<string, unknown>).type === "text") {
|
|
content += String((part as Record<string, unknown>).text || "");
|
|
}
|
|
}
|
|
}
|
|
out.push({ role, content, toolCallId: m.tool_call_id });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("openAIMessagesToWs", () => {
|
|
test("string content is passed through", () => {
|
|
const result = openAIMessagesToWsLocal([{ role: "user", content: "Hello" }]);
|
|
assert.equal(result[0].content, "Hello");
|
|
assert.equal(result[0].role, "user");
|
|
});
|
|
|
|
test("multi-part array content: only text parts are concatenated", () => {
|
|
const result = openAIMessagesToWsLocal([
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Part A " },
|
|
{ type: "image_url", url: "https://example.com/img.png" },
|
|
{ type: "text", text: "Part B" },
|
|
],
|
|
},
|
|
]);
|
|
assert.equal(result[0].content, "Part A Part B");
|
|
});
|
|
|
|
test("missing role defaults to 'user'", () => {
|
|
const result = openAIMessagesToWsLocal([{ content: "Hi" }]);
|
|
assert.equal(result[0].role, "user");
|
|
});
|
|
|
|
test("tool_call_id is mapped to toolCallId", () => {
|
|
const result = openAIMessagesToWsLocal([
|
|
{ role: "tool", content: "result", tool_call_id: "call_abc" },
|
|
]);
|
|
assert.equal(result[0].toolCallId, "call_abc");
|
|
});
|
|
|
|
test("null/undefined content yields empty string", () => {
|
|
const result = openAIMessagesToWsLocal([{ role: "assistant", content: undefined }]);
|
|
assert.equal(result[0].content, "");
|
|
});
|
|
});
|
|
|
|
// ─── gRPC-web frame parser (Windsurf) ────────────────────────────────────────
|
|
|
|
function* parseGrpcWebFramesLocal(
|
|
buf: Uint8Array
|
|
): Generator<{ flag: number; payload: Uint8Array }> {
|
|
let offset = 0;
|
|
while (offset + 5 <= buf.length) {
|
|
const flag = buf[offset];
|
|
const len =
|
|
(buf[offset + 1] << 24) | (buf[offset + 2] << 16) | (buf[offset + 3] << 8) | buf[offset + 4];
|
|
offset += 5;
|
|
if (len < 0 || offset + len > buf.length) break;
|
|
yield { flag, payload: buf.slice(offset, offset + len) };
|
|
offset += len;
|
|
}
|
|
}
|
|
|
|
describe("parseGrpcWebFrames", () => {
|
|
function makeFrame(flag: number, payload: Uint8Array): Uint8Array {
|
|
const header = new Uint8Array(5);
|
|
header[0] = flag;
|
|
const len = payload.length;
|
|
header[1] = (len >>> 24) & 0xff;
|
|
header[2] = (len >>> 16) & 0xff;
|
|
header[3] = (len >>> 8) & 0xff;
|
|
header[4] = len & 0xff;
|
|
const frame = new Uint8Array(5 + payload.length);
|
|
frame.set(header);
|
|
frame.set(payload, 5);
|
|
return frame;
|
|
}
|
|
|
|
test("parses a single data frame (flag=0x00)", () => {
|
|
const payload = new TextEncoder().encode("hello");
|
|
const buf = makeFrame(0x00, payload);
|
|
const frames = [...parseGrpcWebFramesLocal(buf)];
|
|
assert.equal(frames.length, 1);
|
|
assert.equal(frames[0].flag, 0x00);
|
|
assert.deepEqual(frames[0].payload, payload);
|
|
});
|
|
|
|
test("parses multiple frames in sequence", () => {
|
|
const p1 = new TextEncoder().encode("frame1");
|
|
const p2 = new TextEncoder().encode("frame2");
|
|
const buf = new Uint8Array([...makeFrame(0x00, p1), ...makeFrame(0x80, p2)]);
|
|
const frames = [...parseGrpcWebFramesLocal(buf)];
|
|
assert.equal(frames.length, 2);
|
|
assert.equal(frames[0].flag, 0x00);
|
|
assert.equal(frames[1].flag, 0x80);
|
|
});
|
|
|
|
test("returns empty for truncated frame header", () => {
|
|
const buf = new Uint8Array([0x00, 0x00]); // only 2 bytes, needs 5
|
|
const frames = [...parseGrpcWebFramesLocal(buf)];
|
|
assert.equal(frames.length, 0);
|
|
});
|
|
|
|
test("stops if payload length exceeds buffer", () => {
|
|
// Frame claims 100 bytes of payload but buf only has 10
|
|
const buf = new Uint8Array([0x00, 0x00, 0x00, 0x00, 100, 0, 1, 2, 3, 4]);
|
|
const frames = [...parseGrpcWebFramesLocal(buf)];
|
|
assert.equal(frames.length, 0);
|
|
});
|
|
});
|
|
|
|
// ─── Devin CLI binary resolution ─────────────────────────────────────────────
|
|
// resolveDevinBin() is not exported, but its contract is simple:
|
|
// - CLI_DEVIN_BIN env var overrides everything
|
|
// We verify the env-override via a tiny wrapper that mirrors its logic.
|
|
|
|
describe("DevinCli binary resolution", () => {
|
|
test("CLI_DEVIN_BIN env override is returned when set", () => {
|
|
const original = process.env.CLI_DEVIN_BIN;
|
|
try {
|
|
process.env.CLI_DEVIN_BIN = "/custom/path/devin";
|
|
const bin = process.env.CLI_DEVIN_BIN?.trim() ?? "";
|
|
assert.equal(bin, "/custom/path/devin");
|
|
} finally {
|
|
if (original === undefined) delete process.env.CLI_DEVIN_BIN;
|
|
else process.env.CLI_DEVIN_BIN = original;
|
|
}
|
|
});
|
|
|
|
test("CLI_DEVIN_BIN is unset when env var not present", () => {
|
|
const original = process.env.CLI_DEVIN_BIN;
|
|
try {
|
|
delete process.env.CLI_DEVIN_BIN;
|
|
const bin = process.env.CLI_DEVIN_BIN?.trim();
|
|
assert.equal(bin, undefined);
|
|
} finally {
|
|
if (original !== undefined) process.env.CLI_DEVIN_BIN = original;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Phase 1 hotfix: windsurf/devin-cli import-token flow ────────────────────
|
|
import { generateAuthData, getProvider } from "@/lib/oauth/providers";
|
|
|
|
test("windsurf provider: flowType is import_token (PKCE disabled post-rebrand)", () => {
|
|
const provider = getProvider("windsurf");
|
|
assert.equal(provider.flowType, "import_token");
|
|
});
|
|
|
|
test("devin-cli provider: flowType is import_token (shares windsurf config)", () => {
|
|
const provider = getProvider("devin-cli");
|
|
assert.equal(provider.flowType, "import_token");
|
|
});
|
|
|
|
test("windsurf provider: generateAuthData returns no authUrl (PKCE flow disabled)", () => {
|
|
const data = generateAuthData("windsurf", "http://localhost:0/auth/callback");
|
|
assert.equal(data.authUrl, undefined);
|
|
assert.equal(data.supported, false);
|
|
assert.match(data.error ?? "", /import-token|disabled|app\.devin\.ai/i);
|
|
});
|
|
|
|
test("devin-cli provider: generateAuthData returns no authUrl", () => {
|
|
const data = generateAuthData("devin-cli", "http://localhost:0/auth/callback");
|
|
assert.equal(data.authUrl, undefined);
|
|
assert.equal(data.supported, false);
|
|
});
|
|
|
|
// ─── Phase 1 hotfix: retired PKCE actions return 410 Gone ────────────────────
|
|
import { GET as oauthGet, POST as oauthPost } from "@/app/api/oauth/[provider]/[action]/route";
|
|
|
|
test("OAuth route: GET windsurf/start-callback-server returns 410 Gone", async () => {
|
|
const url = "http://localhost:20128/api/oauth/windsurf/start-callback-server";
|
|
const request = new Request(url, { method: "GET" });
|
|
const response = await oauthGet(request, {
|
|
params: Promise.resolve({ provider: "windsurf", action: "start-callback-server" }),
|
|
} as never);
|
|
assert.equal(response.status, 410);
|
|
const body = await response.json();
|
|
assert.match(body.error, /import-token|disabled|410|show-auth-token/i);
|
|
});
|
|
|
|
test("OAuth route: GET devin-cli/authorize returns 410 Gone", async () => {
|
|
const url = "http://localhost:20128/api/oauth/devin-cli/authorize";
|
|
const request = new Request(url, { method: "GET" });
|
|
const response = await oauthGet(request, {
|
|
params: Promise.resolve({ provider: "devin-cli", action: "authorize" }),
|
|
} as never);
|
|
assert.equal(response.status, 410);
|
|
const body = await response.json();
|
|
assert.match(body.error, /import-token|disabled|410|show-auth-token/i);
|
|
});
|
|
|
|
test("OAuth route: GET windsurf/poll-callback returns 410 Gone", async () => {
|
|
const url = "http://localhost:20128/api/oauth/windsurf/poll-callback";
|
|
const request = new Request(url, { method: "GET" });
|
|
const response = await oauthGet(request, {
|
|
params: Promise.resolve({ provider: "windsurf", action: "poll-callback" }),
|
|
} as never);
|
|
assert.equal(response.status, 410);
|
|
});
|
|
|
|
test("OAuth route: POST windsurf/poll-callback returns 410 Gone", async () => {
|
|
const url = "http://localhost:20128/api/oauth/windsurf/poll-callback";
|
|
const request = new Request(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const response = await oauthPost(request, {
|
|
params: Promise.resolve({ provider: "windsurf", action: "poll-callback" }),
|
|
} as never);
|
|
assert.equal(response.status, 410);
|
|
});
|
|
|
|
test("OAuth route: GET codex/authorize is NOT retired (regression check)", async () => {
|
|
const url = "http://localhost:20128/api/oauth/codex/authorize";
|
|
const request = new Request(url, { method: "GET" });
|
|
const response = await oauthGet(request, {
|
|
params: Promise.resolve({ provider: "codex", action: "authorize" }),
|
|
} as never);
|
|
assert.notEqual(response.status, 410);
|
|
});
|
|
|
|
// ─── Regression: mapTokens accepts {accessToken} object, returns string accessToken ─
|
|
// Earlier signature was `mapTokens(token: string)` which crashed the SQLite
|
|
// bind layer when the route called `mapTokens({ accessToken })`: the object
|
|
// got stored as accessToken and SQLite rejected it with
|
|
// "SQLite3 can only bind numbers, strings, bigints, buffers, and null".
|
|
test("windsurf mapTokens: accepts object {accessToken} and returns string accessToken", () => {
|
|
const provider = getProvider("windsurf");
|
|
const mapped = provider.mapTokens({ accessToken: "sk-ws-test-token-1234567890" });
|
|
assert.equal(typeof mapped.accessToken, "string");
|
|
assert.equal(mapped.accessToken, "sk-ws-test-token-1234567890");
|
|
assert.equal(mapped.refreshToken, null);
|
|
});
|
|
|
|
test("devin-cli mapTokens: accepts object {accessToken} and returns string accessToken", () => {
|
|
const provider = getProvider("devin-cli");
|
|
const mapped = provider.mapTokens({ accessToken: "sk-devin-test-token-1234567890" });
|
|
assert.equal(typeof mapped.accessToken, "string");
|
|
assert.equal(mapped.accessToken, "sk-devin-test-token-1234567890");
|
|
});
|