import { afterEach, describe, expect, test } from "bun:test" import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import type { TranscriptEntry } from "../shared/types" import { writeStandaloneTranscriptExport } from "./standalone-export" const tempDirs: string[] = [] async function createTempDir(prefix: string) { const dir = await mkdtemp(path.join(tmpdir(), prefix)) tempDirs.push(dir) return dir } afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) async function createViewerDist() { const viewerDistDir = await createTempDir("kanna-viewer-") await mkdir(path.join(viewerDistDir, "assets"), { recursive: true }) await writeFile(path.join(viewerDistDir, "index.html"), "
\n", "utf8") await writeFile(path.join(viewerDistDir, "assets", "viewer.js"), "console.log('viewer')\n", "utf8") return viewerDistDir } function createMessages(attachmentAbsolutePath: string): TranscriptEntry[] { return [ { _id: "user-1", createdAt: Date.now(), kind: "user_prompt", messageId: "message-1", content: "Please review this attachment.", attachments: [{ id: "attachment-1", kind: "image", displayName: "mock.png", absolutePath: attachmentAbsolutePath, relativePath: "./.kanna/uploads/mock.png", contentUrl: "/api/projects/project-1/uploads/mock.png/content", mimeType: "image/png", size: 4, }], }, { _id: "assistant-1", createdAt: Date.now(), kind: "assistant_text", messageId: "message-2", text: `Looks good in ${attachmentAbsolutePath}.`, }, ] } describe("writeStandaloneTranscriptExport", () => { test("writes a metadata-only export with viewer assets and sanitized attachments", async () => { const viewerDistDir = await createViewerDist() const projectDir = await createTempDir("kanna-project-") const uploadsDir = path.join(projectDir, ".kanna", "uploads") await mkdir(uploadsDir, { recursive: true }) const attachmentPath = path.join(uploadsDir, "mock.png") await writeFile(attachmentPath, "mock", "utf8") const uploadedRequests = new Map() const result = await writeStandaloneTranscriptExport({ chatId: "chat-1", title: "Release Review", localPath: projectDir, theme: "dark", attachmentMode: "metadata", messages: createMessages(attachmentPath), }, { fetch: async (input, init) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url const body = init?.body const bodyText = typeof body === "string" ? body : body instanceof Uint8Array ? new TextDecoder().decode(body) : "" uploadedRequests.set(url, bodyText) expect(init?.method).toBe("PUT") return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json", }, }) }, sharePublicBaseUrl: "https://share.example.com", shareSlugSuffix: "ax71234ka", shareUploadBaseUrl: "https://upload.example.com/api/share", viewerDistDir, now: new Date("2026-04-23T12:34:56.000Z"), }) expect(result.ok).toBe(true) if (!result.ok) { throw new Error(result.error) } expect(await Bun.file(result.indexHtmlPath).exists()).toBe(true) expect(await Bun.file(path.join(result.outputDir, "assets", "viewer.js")).exists()).toBe(true) expect(result.totalAttachmentCount).toBe(1) expect(result.bundledAttachmentCount).toBe(0) expect(result.shareSlug).toBe("release-review-ax71234ka") expect(result.shareUrl).toBe("https://share.example.com/release-review-ax71234ka") expect(result.uploadedFileCount).toBe(1) const bundle = await Bun.file(result.transcriptJsonPath).json() expect(bundle.title).toBe("Release Review") expect(bundle.viewerVersion).toBeDefined() expect(bundle.theme).toBe("dark") expect(bundle.attachmentMode).toBe("metadata") expect(bundle.localPath).toBe("/workspace") expect(bundle.messages[0].attachments[0].contentUrl).toBe("") expect(bundle.messages[0].attachments[0].absolutePath).toBe("") expect(bundle.messages[0].attachments[0].relativePath).toBe("") expect(JSON.stringify(bundle)).not.toContain(projectDir) expect([...uploadedRequests.keys()]).toEqual([ "https://upload.example.com/api/share/release-review-ax71234ka/transcript.json", ]) }) test("copies attachments into the export when bundle mode is selected", async () => { const viewerDistDir = await createViewerDist() const projectDir = await createTempDir("kanna-project-") const uploadsDir = path.join(projectDir, ".kanna", "uploads") await mkdir(uploadsDir, { recursive: true }) const attachmentPath = path.join(uploadsDir, "mock.png") await writeFile(attachmentPath, "mock", "utf8") const uploadedPaths: string[] = [] const result = await writeStandaloneTranscriptExport({ chatId: "chat-1", title: "Release Review", localPath: projectDir, theme: "light", attachmentMode: "bundle", messages: createMessages(attachmentPath), }, { fetch: async (input) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url uploadedPaths.push(url) return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json", }, }) }, sharePublicBaseUrl: "https://share.example.com", shareSlugSuffix: "bundle123", shareUploadBaseUrl: "https://upload.example.com/api/share", viewerDistDir, now: new Date("2026-04-23T12:34:56.000Z"), }) expect(result.ok).toBe(true) if (!result.ok) { throw new Error(result.error) } expect(result.totalAttachmentCount).toBe(1) expect(result.bundledAttachmentCount).toBe(1) expect(result.shareUrl).toBe("https://share.example.com/release-review-bundle123") expect(result.uploadedFileCount).toBe(2) const bundle = await Bun.file(result.transcriptJsonPath).json() const exportedAttachment = bundle.messages[0].attachments[0] expect(bundle.viewerVersion).toBeDefined() expect(exportedAttachment.contentUrl).toStartWith("./attachments/") expect(exportedAttachment.absolutePath).toStartWith("./attachments/") expect(exportedAttachment.relativePath).toStartWith("./attachments/") expect(await Bun.file(path.join(result.outputDir, exportedAttachment.contentUrl.replace(/^\.\//u, ""))).text()).toBe("mock") expect(uploadedPaths).toEqual([ "https://upload.example.com/api/share/release-review-bundle123/transcript.json", expect.stringContaining("https://upload.example.com/api/share/release-review-bundle123/attachments/"), ]) }) test("returns transcript json for download when share upload fails", async () => { const viewerDistDir = await createViewerDist() const projectDir = await createTempDir("kanna-project-") const uploadsDir = path.join(projectDir, ".kanna", "uploads") await mkdir(uploadsDir, { recursive: true }) const attachmentPath = path.join(uploadsDir, "mock.png") await writeFile(attachmentPath, "mock", "utf8") const result = await writeStandaloneTranscriptExport({ chatId: "chat-1", title: "Release Review", localPath: projectDir, theme: "light", attachmentMode: "bundle", messages: createMessages(attachmentPath), }, { fetch: async () => new Response(JSON.stringify({ error: "No release viewer assets were found for 0.34.5." }), { status: 400, headers: { "content-type": "application/json", }, }), sharePublicBaseUrl: "https://share.example.com", shareSlugSuffix: "failed123", shareUploadBaseUrl: "https://upload.example.com/api/share", viewerDistDir, now: new Date("2026-04-23T12:34:56.000Z"), }) expect(result.ok).toBe(false) if (result.ok) { throw new Error("Expected export upload to fail") } expect(result.error).toContain("Failed to upload shared transcript file transcript.json") expect(result.error).toContain("No release viewer assets were found") expect(result.shareUrl).toBe("https://share.example.com/release-review-failed123") expect(result.transcriptFileName).toBe("Release-Review-2026-04-23T12-34-56Z-transcript.json") expect(result.transcriptJsonPath).toEndWith("/transcript.json") expect(JSON.parse(result.transcriptJson).title).toBe("Release Review") expect(JSON.stringify(JSON.parse(result.transcriptJson))).not.toContain(projectDir) }) })