132 lines
9.2 KiB
TypeScript
132 lines
9.2 KiB
TypeScript
import { describe, expect, spyOn, test } from "bun:test";
|
||
import { consumeJsonEvents } from "../src/lib/json-event-stream";
|
||
import { consumeChatCompletionStreamText, consumeGeminiStreamText, consumeResponseStreamText, parseGeminiImagePayload, parseGeminiToolResponse, parseToolResponse, readFetchError, readJsonPayload, responseErrorMessage } from "../src/services/api/image-response";
|
||
import { blobToDataUrl, delay } from "../src/services/api/video-response";
|
||
import type { ChatCompletionStreamState, GeminiStreamState, ResponseStreamState } from "../src/services/api/image-contracts";
|
||
|
||
const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\r\n\r\n`;
|
||
|
||
describe("provider response replacement contracts", () => {
|
||
test("JSON event framing survives every chunk boundary, comments, multiple data lines and EOF", () => {
|
||
const stream = ': heartbeat\r\nevent: message\r\ndata: {"text":\r\ndata: "你好"}\r\n\r\ndata: [DONE]\r\n\r\ndata: {"last":true}';
|
||
for (let split = 0; split <= stream.length; split++) {
|
||
const state = { buffer: "" };
|
||
const result: unknown[] = [];
|
||
consumeJsonEvents(state, stream.slice(0, split), (value) => result.push(value));
|
||
consumeJsonEvents(state, stream.slice(split), (value) => result.push(value), true);
|
||
expect(result).toEqual([{ text: "你好" }, { last: true }]);
|
||
expect(state.buffer).toBe("");
|
||
}
|
||
});
|
||
|
||
test("invalid JSON fails without dropping the failed frame or its unprocessed tail", () => {
|
||
const state = { buffer: "" };
|
||
const result: unknown[] = [];
|
||
const invalid = 'data: not-json\n\ndata: {"after":true}\n\n';
|
||
expect(() => consumeJsonEvents(state, frame({ before: true }) + invalid, (value) => result.push(value))).toThrow();
|
||
expect(result).toEqual([{ before: true }]);
|
||
expect(state.buffer).toBe(invalid);
|
||
});
|
||
|
||
test("Responses delta, done and completion preserve separate channels for every split", () => {
|
||
const payload = { output_text: "AB" };
|
||
const stream = [
|
||
{ type: "response.output_text.delta", delta: "A" },
|
||
{ type: "response.reasoning_summary_text.delta", delta: "why" },
|
||
{ type: "response.output_text.delta", delta: "B" },
|
||
{ type: "response.output_text.done", text: "AB" },
|
||
{ type: "response.reasoning_summary_text.done", text: "why" },
|
||
{ type: "constructor.delta", delta: "ignore" },
|
||
{ type: "response.completed", response: payload },
|
||
].map(frame).join("");
|
||
for (let split = 0; split <= stream.length; split++) {
|
||
const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" };
|
||
const visible: string[] = [], reasoning: string[] = [];
|
||
consumeResponseStreamText(state, stream.slice(0, split), (value) => visible.push(value), (value) => reasoning.push(value));
|
||
consumeResponseStreamText(state, stream.slice(split), (value) => visible.push(value), (value) => reasoning.push(value), true);
|
||
expect(visible).toEqual(["A", "AB"]);
|
||
expect(reasoning).toEqual(["why"]);
|
||
expect(state.payload).toEqual(payload);
|
||
expect(state.buffer).toBe("");
|
||
}
|
||
const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" };
|
||
consumeResponseStreamText(state, frame({ type: "response.output_text.done", text: "complete" }) + frame({ type: "response.reasoning.done", text: "reason" }));
|
||
expect(state).toMatchObject({ text: "complete", reasoning: "reason" });
|
||
consumeResponseStreamText(state, frame({ type: "response.failed", response: { error: { message: "quota" } } }));
|
||
expect(state.error).toBe("quota");
|
||
});
|
||
|
||
test("Chat Completions interleaves indexed tool fragments and reasoning aliases", () => {
|
||
const stream = [
|
||
{ choices: [{ delta: { content: "OK", reasoning: "r", tool_calls: [{ index: 3, id: "three", function: { name: "search", arguments: '{"q":' } }, { index: 1, id: "one", function: { name: "read", arguments: '{"id":' } }] } }] },
|
||
{ choices: [{ delta: { reasoning_text: "s", tool_calls: [{ index: 1, function: { arguments: '1}' } }, { index: 3, function: { arguments: '"画布"}' } }] } }] },
|
||
].map(frame).join("");
|
||
for (let split = 0; split <= stream.length; split++) {
|
||
const state: ChatCompletionStreamState = { buffer: "", text: "", reasoning: "", toolCalls: new Map() };
|
||
consumeChatCompletionStreamText(state, stream.slice(0, split));
|
||
consumeChatCompletionStreamText(state, stream.slice(split), undefined, undefined, true);
|
||
expect(state.text).toBe("OK");
|
||
expect(state.reasoning).toBe("rs");
|
||
expect(state.toolCalls.get(1)).toEqual({ id: "one", name: "read", arguments: '{"id":1}' });
|
||
expect(state.toolCalls.get(3)).toEqual({ id: "three", name: "search", arguments: '{"q":"画布"}' });
|
||
}
|
||
});
|
||
|
||
test("Gemini preserves thought signatures and tool order without mixing thought text", () => {
|
||
const payload = { candidates: [{ content: { parts: [{ thought: true, text: "plan" }, { text: "answer" }, { functionCall: { id: "a", name: "read", args: { id: 1 } }, thought_signature: "sig-a" }] } }, { content: { parts: [{ functionCall: { id: "b", name: "search" }, thoughtSignature: "sig-b" }] } }] };
|
||
const result = parseGeminiToolResponse(payload);
|
||
expect(result).toEqual({ content: "answer", reasoning: "plan", toolCalls: [
|
||
{ id: "a", type: "function", function: { name: "read", arguments: '{"id":1}' }, thoughtSignature: "sig-a" },
|
||
{ id: "b", type: "function", function: { name: "search", arguments: '{}' }, thoughtSignature: "sig-b" },
|
||
] });
|
||
const stream = frame(payload);
|
||
const state: GeminiStreamState = { buffer: "", text: "", reasoning: "", toolCalls: [] };
|
||
for (const character of stream) consumeGeminiStreamText(state, character);
|
||
expect(state).toMatchObject({ text: "answer", reasoning: "plan", toolCalls: result.toolCalls });
|
||
expect(() => parseGeminiToolResponse({ promptFeedback: { blockReason: "BLOCKED" } })).toThrow("Gemini 拒绝了本次请求:BLOCKED");
|
||
});
|
||
|
||
test("image sources preserve MIME and source order; Responses chooses explicit output text", () => {
|
||
expect(parseGeminiImagePayload({ candidates: [{ content: { parts: [
|
||
{ inlineData: { mimeType: "image/jpeg", data: "AAA=" } },
|
||
{ inline_data: { mime_type: "image/webp", data: "BBB=" } },
|
||
{ fileData: { fileUri: "https://example.test/image" } },
|
||
{ text: "ignored" },
|
||
] } }] }).map((image) => image.dataUrl)).toEqual(["data:image/jpeg;base64,AAA=", "data:image/webp;base64,BBB=", "https://example.test/image"]);
|
||
expect(() => parseGeminiImagePayload({ candidates: [] })).toThrow("Gemini 接口没有返回图片");
|
||
expect(parseToolResponse({ output_text: "explicit", output: [{ type: "message", content: [{ text: "fallback" }] }, { type: "function_call", id: "item", call_id: "call", name: "read" }] })).toEqual({
|
||
content: "explicit", toolCalls: [{ id: "call", type: "function", function: { name: "read", arguments: "{}" } }],
|
||
});
|
||
});
|
||
|
||
test("HTTP and JSON errors retain precedence and distinguish proxy HTML", async () => {
|
||
expect(responseErrorMessage({ msg: "first", error: { message: "second" }, response: { error: { message: "third" } } })).toBe("first");
|
||
expect(await readFetchError(new Response('{"error":{"message":"denied"}}', { status: 403 }), "失败")).toBe("denied");
|
||
expect(await readFetchError(new Response('{}', { status: 429 }), "失败")).toBe("请求被限流或额度不足,请稍后重试");
|
||
expect(await readFetchError(new Response('x'.repeat(400), { status: 500 }), "失败")).toHaveLength(300);
|
||
await expect(readJsonPayload(new Response('<!doctype html><html>'), "失败")).rejects.toThrow("后端代理返回了前端网页");
|
||
await expect(readJsonPayload(new Response('not-json'), "失败")).rejects.toThrow("接口没有返回有效 JSON");
|
||
});
|
||
|
||
test("video polling removes abort listeners on completion and cancellation", async () => {
|
||
const controller = new AbortController();
|
||
const remove = spyOn(controller.signal, "removeEventListener");
|
||
try {
|
||
await delay(0, controller.signal);
|
||
expect(remove).toHaveBeenCalledTimes(1);
|
||
const pending = delay(60_000, controller.signal);
|
||
controller.abort();
|
||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||
expect(remove).toHaveBeenCalledTimes(2);
|
||
await expect(delay(0, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
|
||
} finally { remove.mockRestore(); }
|
||
});
|
||
|
||
test("video data URLs preserve raw bytes and MIME without FileReader", async () => {
|
||
const data = Uint8Array.from({ length: 256 }, (_, i) => i);
|
||
const url = await blobToDataUrl(new Blob([data], { type: "video/webm" }));
|
||
expect(url.startsWith("data:video/webm;base64,")).toBe(true);
|
||
expect(new Uint8Array(await (await fetch(url)).arrayBuffer())).toEqual(data);
|
||
});
|
||
});
|