Files
PaperClipAI/ui/src/lib/artifact-card-data.test.ts
T
DottaandPaperclip 24c58e479a Improve task artifacts with rich cards and editable stories (#14469)
Render eight artifact card types from real task records and share them with editable Storybook stories. Preserve document review and media/file actions, and load bounded CSV previews on request.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-28 15:38:06 -05:00

128 lines
4.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
artifactNumber,
artifactPreviewUrl,
artifactUrl,
CSV_PREVIEW_MAX_BYTES,
loadArtifactCsv,
parseArtifactCsv,
} from "./artifact-card-data";
afterEach(() => vi.unstubAllGlobals());
describe("artifact metadata", () => {
it("does not turn absent or invalid counts into zero", () => {
for (const value of [undefined, null, "12", -1, Infinity, NaN])
expect(artifactNumber({ additions: value }, "additions")).toBeNull();
expect(artifactNumber({ additions: 0 }, "additions")).toBe(0);
});
it("loads optional thumbnails only through local authenticated attachment routes", () => {
for (const value of [
"https://example.com/tracker.png",
"http://127.0.0.1/private",
"//evil.test/track",
"/api/companies",
"/api/attachments/a/content?redirect=1",
])
expect(artifactPreviewUrl(value)).toBe("");
expect(artifactPreviewUrl("/api/attachments/file-1/content")).toBe(
"/api/attachments/file-1/content",
);
});
it("permits web and root-relative URLs only", () => {
for (const url of [
"javascript:alert(1)",
"//evil.test",
"/\\evil.test",
"data:text/html,hello",
"https:\n//evil.test",
])
expect(artifactUrl(url)).toBe("");
expect(artifactUrl("/api/attachments/a/content")).toBe(
"/api/attachments/a/content",
);
expect(artifactUrl("https://example.com/path?q=1")).toBe(
"https://example.com/path?q=1",
);
});
});
describe("CSV artifact preview", () => {
it("reads quoted commas, escaped quotes, embedded newlines, CRLF, BOM and empty cells", () => {
expect(
parseArtifactCsv(
'\uFEFFName,Notes,Count\r\n"Smith, Jo","a ""quote""\nand line",0\r\nOther,,2\r\n',
),
).toEqual({
columns: ["Name", "Notes", "Count"],
rows: [
["Smith, Jo", 'a "quote"\nand line', "0"],
["Other", "", "2"],
],
truncated: false,
});
});
it("bounds rows, columns and bytes and rejects malformed quoting", () => {
const preview = parseArtifactCsv(
"Name\n" + Array.from({ length: 202 }, (_, i) => `${i}`).join("\n"),
);
expect(preview.rows).toHaveLength(200);
expect(preview.truncated).toBe(true);
expect(() => parseArtifactCsv(Array(51).fill("x").join(","))).toThrow(
"too many columns",
);
expect(() =>
parseArtifactCsv("x".repeat(CSV_PREVIEW_MAX_BYTES + 1)),
).toThrow("too large");
expect(() => parseArtifactCsv('Name\n"unfinished')).toThrow(
"could not be previewed",
);
expect(() => parseArtifactCsv('Name\n"closed"extra')).toThrow(
"could not be previewed",
);
expect(parseArtifactCsv("")).toEqual({
columns: [],
rows: [],
truncated: false,
});
});
it("never fetches remote or arbitrary local URLs", async () => {
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
for (const path of [
"https://example.com/data.csv",
"/api/companies",
"/api/attachments/a/content?redirect=yes",
])
await expect(loadArtifactCsv(path)).rejects.toThrow("unavailable");
expect(fetcher).not.toHaveBeenCalled();
});
it("uses authenticated attachment content with no redirects", async () => {
const fetcher = vi.fn().mockResolvedValue(new Response("A,B\n1,2"));
vi.stubGlobal("fetch", fetcher);
const signal = new AbortController().signal;
expect(
(await loadArtifactCsv("/api/attachments/file-1/content", signal)).rows,
).toEqual([["1", "2"]]);
expect(fetcher).toHaveBeenCalledWith("/api/attachments/file-1/content", {
credentials: "same-origin",
redirect: "error",
signal,
});
});
it("stops oversized streamed files even when the server sends no content length", async () => {
const cancel = vi.fn();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(CSV_PREVIEW_MAX_BYTES + 1));
},
cancel,
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(stream)));
await expect(
loadArtifactCsv("/api/attachments/file-1/content"),
).rejects.toThrow("too large");
expect(cancel).toHaveBeenCalled();
});
});