Files
writer-work-flow/apps/web/lib/stream/useDraftStream.test.ts
Yaojia Wang f4c2c4e8ec fix(frontend): 升级 Next→15.5.20 + React 19.2.7 修 RCE/补丁 + 版本下界守卫(CR-C1)
- next/eslint-config-next 15.1.3→15.5.20(backport 安全维护线),react/react-dom 19.0.0→19.2.7
- @types/react(-dom) →^19.2.0;pnpm audit --prod 无 next/react/react-dom 高危/严重告警
- 新增 lib/security/dependencyFloors 守卫 + 回归测试锁定安全下界
- 连带修 useDraftStream.test noUncheckedIndexedAccess(重锁失效增量缓存后暴露,对齐 useReviewStream 既有 ! 约定)
2026-07-08 11:04:55 +02:00

221 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStream } from "./useDraftStream";
// fetchSSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。
const fetchMock = vi.fn();
// 把若干 SSE 文本块封进 ReadableStream模拟后端逐块下发的 chunked body。
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
const enc = new TextEncoder();
return new ReadableStream({
start(c) {
for (const ch of chunks) c.enqueue(enc.encode(ch));
c.close();
},
});
}
// 构造一个流式成功响应200 + text/event-stream body
function sseResponse(chunks: string[]): Response {
return new Response(sseStream(chunks), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
describe("useDraftStream", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("初始为 idle、空文本、未在流式", () => {
const { result } = renderHook(() => useDraftStream());
expect(result.current.state.phase).toBe("idle");
expect(result.current.state.text).toBe("");
expect(result.current.isStreaming).toBe(false);
});
it("流式累积 token多个 token 帧拼成完整文本", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:token\ndata:{"text":"你好"}\n\n',
'event:token\ndata:{"text":",世界"}\n\n',
]),
);
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 1);
});
expect(result.current.state.text).toBe("你好,世界");
expect(result.current.state.error).toBeNull();
});
it("收到 done 帧后 phase 走到 done", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:token\ndata:{"text":"abc"}\n\n',
'event:done\ndata:{"length":3}\n\n',
]),
);
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 1);
});
expect(result.current.state.phase).toBe("done");
expect(result.current.state.text).toBe("abc");
});
it("带本章指令时以 JSON body POST 指令", async () => {
fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 2, " 写得热血一点 ");
});
const [, init] = fetchMock.mock.calls[0]!;
expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" });
});
it("指令为空白时退回裸 POST不带 JSON body", async () => {
fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 2, " ");
});
const [, init] = fetchMock.mock.calls[0]!;
expect(init.body).toBeUndefined();
expect(init.headers["Content-Type"]).toBeUndefined();
});
it("收到 error 帧phase=error 且带错误码与文案", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:error\ndata:{"code":"RATE_LIMIT","message":"配额不足"}\n\n',
]),
);
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 1);
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error).toMatchObject({
code: "RATE_LIMIT",
message: "配额不足",
});
});
it("流前错误(!res.ok解析 JSON 信封提取错误码与文案", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
{ status: 503 },
),
);
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 1);
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error).toMatchObject({
code: "LLM_UNAVAILABLE",
message: "无可用凭据",
});
});
it("流前错误且非 JSON 信封:沿用默认 STREAM_FAILED 文案", async () => {
fetchMock.mockResolvedValue(new Response("<html>oops</html>", { status: 500 }));
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 1);
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error?.code).toBe("STREAM_FAILED");
expect(result.current.state.error?.message).toContain("500");
});
it("网络抛异常(非 Abortphase=error 且 code=NETWORK", async () => {
fetchMock.mockRejectedValue(new Error("connection reset"));
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 1);
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error).toMatchObject({
code: "NETWORK",
message: "connection reset",
});
});
it("非 Error 抛出:回退为未知网络错误文案", async () => {
fetchMock.mockRejectedValue("boom-string");
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 1);
});
expect(result.current.state.error?.message).toBe("未知网络错误");
});
it("AbortError 被吞掉:不进入 errorphase 维持 streaming", async () => {
fetchMock.mockRejectedValue(new DOMException("aborted", "AbortError"));
const { result } = renderHook(() => useDraftStream());
await act(async () => {
await result.current.start("p1", 1);
});
expect(result.current.state.phase).toBe("streaming");
expect(result.current.state.error).toBeNull();
});
it("stop() 主动停止abort 连接并将 phase 置 aborted", async () => {
// 用一个永不结束的 body让流挂起后被 stop 中断(已生成部分保留)。
const hangingBody = new ReadableStream<Uint8Array>({});
fetchMock.mockResolvedValue(new Response(hangingBody, { status: 200 }));
const { result } = renderHook(() => useDraftStream());
act(() => {
void result.current.start("p1", 1);
});
await act(async () => {
result.current.stop();
});
expect(result.current.state.phase).toBe("aborted");
expect(result.current.isStreaming).toBe(false);
});
it("reset(text) 用给定文本重置回 idle", () => {
const { result } = renderHook(() => useDraftStream());
act(() => result.current.reset("已落草稿"));
expect(result.current.state.phase).toBe("idle");
expect(result.current.state.text).toBe("已落草稿");
});
});