import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { FeishuRuntime } from "../../src/feishu/client.js"; import { findMarkdownImagesOutsideCode, maskMarkdownImagesForStreaming, materializeAnswerSegments, uploadMessageImage, } from "../../src/feishu/outboundImages.js"; import { buildAgentCard } from "../../src/feishu/card/builder.js"; const temps: string[] = []; afterEach(async () => { await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); describe("outbound markdown image parsing", () => { it("finds image refs outside fenced code blocks", () => { const text = [ "See diagram:", "![plot](https://cdn.example.com/a.png)", "", "```md", "![not-this](https://cdn.example.com/b.png)", "```", "![local](assets/x.png \"title\")", ].join("\n"); const refs = findMarkdownImagesOutsideCode(text); expect(refs.map((ref) => ref.src)).toEqual([ "https://cdn.example.com/a.png", "assets/x.png", ]); }); it("masks image urls for streaming cards", () => { expect(maskMarkdownImagesForStreaming("before ![alt text](https://x/y.png) after")).toBe( "before alt text after", ); expect(maskMarkdownImagesForStreaming("![](https://x/y.png)")).toBe("【图片】"); }); }); describe("materializeAnswerSegments", () => { it("uploads remote and workspace images and builds card segments", async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), "cph-img-root-")); temps.push(workspaceRoot); const workspaceDir = join(workspaceRoot, "project"); await mkdir(join(workspaceDir, "assets"), { recursive: true }); await writeFile( join(workspaceDir, "assets", "local.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), ); const imageCreate = vi .fn() .mockResolvedValueOnce({ image_key: "img_remote_1" }) .mockResolvedValueOnce({ image_key: "img_local_1" }); const rt = mockRuntime({ imageCreate }); const fetchImpl = vi.fn(async () => new Response(Buffer.from("remote-bytes"), { status: 200, headers: { "content-type": "image/png" }, }), ); const text = "Intro\n\n![remote](https://cdn.example.com/r.png)\n\nAnd local ![local](assets/local.png)\nDone."; const { segments, unresolved } = await materializeAnswerSegments(text, { rt, workspaceRoot, workspaceDir, fetchImpl: fetchImpl as unknown as typeof fetch, }); expect(unresolved).toEqual([]); expect(imageCreate).toHaveBeenCalledTimes(2); expect(segments).toEqual([ { type: "markdown", content: "Intro\n\n" }, { type: "image", imgKey: "img_remote_1", alt: "remote" }, { type: "markdown", content: "\n\nAnd local " }, { type: "image", imgKey: "img_local_1", alt: "local" }, { type: "markdown", content: "\nDone." }, ]); const card = buildAgentCard({ phase: "complete", text: "", contentSegments: segments, reasoningText: undefined, toolUseSteps: [], toolUseElapsedMs: undefined, isError: false, interrupted: false, runId: undefined, }); expect(card.elements).toEqual(expect.arrayContaining([ expect.objectContaining({ tag: "img", img_key: "img_remote_1" }), expect.objectContaining({ tag: "img", img_key: "img_local_1" }), expect.objectContaining({ tag: "markdown", content: "Intro\n\n" }), ])); }); it("rejects private remote hosts", async () => { const imageCreate = vi.fn(); const rt = mockRuntime({ imageCreate }); const fetchImpl = vi.fn(); const { segments, unresolved } = await materializeAnswerSegments( "![x](http://127.0.0.1/secret.png)", { rt, fetchImpl: fetchImpl as unknown as typeof fetch }, ); expect(fetchImpl).not.toHaveBeenCalled(); expect(imageCreate).not.toHaveBeenCalled(); expect(unresolved).toEqual(["http://127.0.0.1/secret.png"]); expect(segments).toEqual([{ type: "markdown", content: "x" }]); }); }); describe("uploadMessageImage", () => { it("returns image_key from Feishu upload", async () => { const imageCreate = vi.fn(async () => ({ data: { image_key: "img_nested" } })); const rt = mockRuntime({ imageCreate }); await expect(uploadMessageImage(rt, Buffer.from("png"))).resolves.toBe("img_nested"); expect(imageCreate).toHaveBeenCalledWith({ data: { image_type: "message", image: Buffer.from("png") }, }); }); }); function mockRuntime(options: { readonly imageCreate?: (payload: unknown) => Promise; }): FeishuRuntime { return { client: { im: { v1: { image: { create: options.imageCreate ?? vi.fn(), }, message: { create: vi.fn(), reply: vi.fn(), patch: vi.fn(), }, }, }, } as unknown as FeishuRuntime["client"], logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn(), child: vi.fn(), fatal: vi.fn(), trace: vi.fn(), silent: vi.fn(), level: "info", } as unknown as FeishuRuntime["logger"], }; }