import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import type { Server } from "bun" import { app } from "../src/app" import { config } from "../src/config" import { all, run } from "../src/db" import { upgrade, websocket, type WsData } from "../src/ws" import { client, freshDb, makeTask, makeUser } from "./helpers" // 假的模型接口:每段之间停一下,方便测试中途断开 const DEFAULT_PIECES = ["好的", "```html\n

hi

\n```"] let PIECES = DEFAULT_PIECES // 最近一次发给模型的请求;分类(非流式)单独记,固定回“3” let lastRequest: any = null let lastClassify: any = null const llm = Bun.serve({ port: 0, async fetch(req) { const body: any = await req.json() if (!body.stream) { lastClassify = body return Response.json({ choices: [{ message: { content: "3" } }] }) } lastRequest = body const enc = new TextEncoder() const stream = new ReadableStream({ async start(ctrl) { for (const piece of PIECES) { ctrl.enqueue(enc.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: piece } }] })}\n\n`)) await Bun.sleep(150) } ctrl.enqueue(enc.encode("data: [DONE]\n\n")) ctrl.close() }, }) return new Response(stream, { headers: { "content-type": "text/event-stream" } }) }, }) let server: Server const saved = { ...config } beforeAll(() => { Object.assign(config, { llmApiKey: "k", llmBaseUrl: `http://localhost:${llm.port}` }) server = Bun.serve({ port: 0, async fetch(req, srv) { const res = await upgrade(req, srv) return res !== null ? res : app.fetch(req) }, websocket, }) }) afterAll(() => { Object.assign(config, saved) server.stop(true) llm.stop(true) }) beforeEach(() => { freshDb() PIECES = DEFAULT_PIECES }) interface Conn { ws: WebSocket msgs: any[] next: (type: string) => Promise } // 握手失败时返回 null function connect(path: string): Promise { return new Promise((resolve) => { const ws = new WebSocket(`ws://localhost:${server.port}${path}`) const msgs: any[] = [] const waiters: { type: string; resolve: (m: any) => void }[] = [] ws.onmessage = (e) => { const m = JSON.parse(String(e.data)) msgs.push(m) const i = waiters.findIndex((w) => w.type === m.type) if (i >= 0) waiters.splice(i, 1)[0]!.resolve(m) } const next = (type: string) => new Promise((res) => { const seen = msgs.find((m) => m.type === type && !m._taken) if (seen) { seen._taken = true res(seen) } else { waiters.push({ type, resolve: (m) => ((m._taken = true), res(m)) }) } }) ws.onopen = () => resolve({ ws, msgs, next }) ws.onerror = () => resolve(null) }) } async function setup() { makeUser("stu") const task = makeTask("挑战") const stu = await client("stu") return { task, token: stu.tokens.access_token } } describe("WebSocket 代码生成", () => { test("正常生成:流式返回并保存一问一答", async () => { const { task, token } = await setup() const c = (await connect(`/ws/prompt/${task}/?token=${token}`))! expect((await c.next("init")).messages).toEqual([]) c.ws.send(JSON.stringify({ type: "message", content: "做个页面" })) const done = await c.next("complete") expect(done.code.html).toBe("

hi

") const streamed = c.msgs.filter((m) => m.type === "stream").map((m) => m.content).join("") expect(streamed).toBe(PIECES.join("")) expect(all(`SELECT role FROM prompt_message ORDER BY id`).map((m) => m.role)).toEqual(["user", "assistant"]) c.ws.close() }) test("回复完后在后台给提问打层级", async () => { const { task, token } = await setup() const c = (await connect(`/ws/prompt/${task}/?token=${token}`))! await c.next("init") c.ws.send(JSON.stringify({ type: "message", content: "做个页面" })) await c.next("complete") const level = () => all(`SELECT prompt_level FROM prompt_message WHERE role = 'user'`)[0]?.prompt_level for (let i = 0; i < 50 && level() == null; i++) await Bun.sleep(20) expect(level()).toBe(3) expect(lastClassify.thinking).toEqual({ type: "disabled" }) c.ws.close() }) test("AI 回复中再发消息会被拒绝", async () => { const { task, token } = await setup() const c = (await connect(`/ws/prompt/${task}/?token=${token}`))! await c.next("init") c.ws.send(JSON.stringify({ type: "message", content: "第一条" })) c.ws.send(JSON.stringify({ type: "message", content: "第二条" })) expect((await c.next("error")).content).toContain("上一条消息还在处理") await c.next("complete") expect(all(`SELECT content FROM prompt_message WHERE role = 'user'`)).toEqual([{ content: "第一条" }]) c.ws.close() }) test("中途断开:删掉没有回复的提问", async () => { const { task, token } = await setup() const c = (await connect(`/ws/prompt/${task}/?token=${token}`))! await c.next("init") c.ws.send(JSON.stringify({ type: "message", content: "做个页面" })) await c.next("stream") c.ws.close() await Bun.sleep(400) expect(all(`SELECT * FROM prompt_message`)).toEqual([]) }) test("手改的版本进入对话,AI 下一轮在它上面改", async () => { makeUser("stu") const task = makeTask("挑战") const stu = await client("stu") const c = (await connect(`/ws/prompt/${task}/?token=${stu.tokens.access_token}`))! await c.next("init") c.ws.send(JSON.stringify({ type: "message", content: "做个页面" })) await c.next("complete") const saved = await stu("POST", "/api/prompt/versions", { task_id: task, html: "

我改过的

", css: "p { color: red }", js: "", }) expect(saved.json.source).toBe("edit") c.ws.send(JSON.stringify({ type: "message", content: "再加个按钮" })) await c.next("complete") const sent = lastRequest.messages.map((m: any) => `${m.role}:${m.content}`) // system、第一轮、手改那一轮、这一轮的提问 expect(sent.at(-3)).toBe("user:(手动修改了代码)") expect(sent.at(-2)).toContain("

我改过的

") expect(sent.at(-2)).toContain("p { color: red }") expect(sent.at(-1)).toBe("user:再加个按钮") c.ws.close() }) test("只回答问题、没给代码时 code 是 null", async () => { const { task, token } = await setup() PIECES = ["因为加了 ", "border-radius。"] const c = (await connect(`/ws/prompt/${task}/?token=${token}`))! await c.next("init") c.ws.send(JSON.stringify({ type: "message", content: "为什么按钮是圆的?" })) const done = await c.next("complete") expect(done.code).toBeNull() expect(done.message_id).toBeGreaterThan(0) c.ws.close() }) test("挑战的题目要求作为对照清单发给 AI,自由创作不发", async () => { const { task, token } = await setup() run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器,有开始和暂停按钮", id: task }) const c = (await connect(`/ws/prompt/${task}/?token=${token}`))! await c.next("init") c.ws.send(JSON.stringify({ type: "message", content: "做个页面" })) await c.next("complete") expect(lastRequest.messages[0].content).toContain("它是对照清单") expect(lastRequest.messages[0].content).toContain("题目要求:做一个计时器,有开始和暂停按钮") c.ws.close() const creation = makeTask("我的作品", "creation", 2) run(`UPDATE task_task SET content = '我想做一个相册' WHERE id = $id`, { id: creation }) const d = (await connect(`/ws/prompt/${creation}/?token=${token}`))! await d.next("init") d.ws.send(JSON.stringify({ type: "message", content: "做个页面" })) await d.next("complete") expect(lastRequest.messages[0].content).not.toContain("题目要求") d.ws.close() }) test("token 无效或任务不存在时握手失败", async () => { const { task, token } = await setup() expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull() expect(await connect(`/ws/prompt/9999/?token=${token}`)).toBeNull() }) }) describe("WebSocket 帮我写清楚", () => { test("教练能看到题目要求、之前的提问和 AI 上一次的说明", async () => { const { task, token } = await setup() run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器,![图](a.png) 有开始和暂停按钮", id: task }) const p = (await connect(`/ws/prompt/${task}/?token=${token}`))! await p.next("init") p.ws.send(JSON.stringify({ type: "message", content: "做个计时器" })) await p.next("complete") p.ws.close() const g = (await connect(`/ws/guidance/${task}/?token=${token}`))! await g.next("init") g.ws.send(JSON.stringify({ type: "message", content: "改成粉色" })) await g.next("complete") const system = lastRequest.messages[0] expect(system.role).toBe("system") expect(system.content).toContain("题目要求:做一个计时器, 有开始和暂停按钮") expect(system.content).toContain("1. 做个计时器") expect(system.content).toContain("AI 上一次的说明:好的") expect(system.content).not.toContain("

hi

") expect(lastRequest.messages.at(-1)).toEqual({ role: "user", content: "改成粉色" }) // 等后台打完层级再结束,不然假接口关了它会报错 const level = () => all(`SELECT prompt_level FROM prompt_message WHERE source = 'guidance' AND role = 'user'`)[0]?.prompt_level for (let i = 0; i < 50 && level() == null; i++) await Bun.sleep(20) g.ws.close() }) })