Skip to content

Commit 66f6b90

Browse files
authored
fix: restore download functionality for text-based attachments (#1244)
## Problem The refactoring in #1237 accidentally broke download links for text-based attachments (txt, csv, json, code files) by returning `undefined` instead of creating a blob URL. ## Solution Restore the original behavior where all content types get a blob URL for downloading. The `else` branch now creates a blob from the raw attachment string instead of returning `undefined`. ## Testing All 172 tests pass.
1 parent 770d3da commit 66f6b90

File tree

2 files changed

+227
-3
lines changed

2 files changed

+227
-3
lines changed
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import Attachment from "./Attachment";
4+
5+
// Mock SVG imports
6+
vi.mock("@spotlight/ui/assets/download.svg", () => ({
7+
ReactComponent: () => null,
8+
}));
9+
10+
// Mock URL.createObjectURL and URL.revokeObjectURL
11+
const mockCreateObjectURL = vi.fn(() => "blob:mock-url");
12+
const mockRevokeObjectURL = vi.fn();
13+
14+
beforeEach(() => {
15+
vi.clearAllMocks();
16+
global.URL.createObjectURL = mockCreateObjectURL;
17+
global.URL.revokeObjectURL = mockRevokeObjectURL;
18+
});
19+
20+
describe("Attachment", () => {
21+
describe("download URL generation", () => {
22+
it("should create download URL for text/plain attachments", () => {
23+
render(
24+
<Attachment
25+
header={{ type: "attachment", content_type: "text/plain", filename: "test.txt" }}
26+
attachment="Hello, World!"
27+
expanded={true}
28+
/>,
29+
);
30+
31+
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
32+
const blob = mockCreateObjectURL.mock.calls[0][0] as Blob;
33+
expect(blob).toBeInstanceOf(Blob);
34+
expect(blob.type).toBe("text/plain");
35+
});
36+
37+
it("should create download URL for text/csv attachments", () => {
38+
render(
39+
<Attachment
40+
header={{ type: "attachment", content_type: "text/csv", filename: "data.csv" }}
41+
attachment="name,value\nfoo,bar"
42+
expanded={true}
43+
/>,
44+
);
45+
46+
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
47+
const blob = mockCreateObjectURL.mock.calls[0][0] as Blob;
48+
expect(blob.type).toBe("text/csv");
49+
});
50+
51+
it("should create download URL for application/json attachments", () => {
52+
render(
53+
<Attachment
54+
header={{ type: "attachment", content_type: "application/json", filename: "config.json" }}
55+
attachment='{"key": "value"}'
56+
expanded={true}
57+
/>,
58+
);
59+
60+
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
61+
const blob = mockCreateObjectURL.mock.calls[0][0] as Blob;
62+
expect(blob.type).toBe("application/json");
63+
});
64+
65+
it("should create download URL for text/javascript attachments", () => {
66+
render(
67+
<Attachment
68+
header={{ type: "attachment", content_type: "text/javascript", filename: "script.js" }}
69+
attachment="console.log('hello');"
70+
expanded={true}
71+
/>,
72+
);
73+
74+
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
75+
const blob = mockCreateObjectURL.mock.calls[0][0] as Blob;
76+
expect(blob.type).toBe("text/javascript");
77+
});
78+
79+
it("should create download URL for image/png attachments with base64 data", () => {
80+
// Valid base64 for a tiny 1x1 PNG
81+
const base64Png =
82+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
83+
84+
render(
85+
<Attachment
86+
header={{ type: "attachment", content_type: "image/png", filename: "screenshot.png" }}
87+
attachment={base64Png}
88+
expanded={true}
89+
/>,
90+
);
91+
92+
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
93+
const blob = mockCreateObjectURL.mock.calls[0][0] as Blob;
94+
expect(blob.type).toBe("image/png");
95+
});
96+
97+
it("should not create download URL when not expanded", () => {
98+
render(
99+
<Attachment
100+
header={{ type: "attachment", content_type: "text/plain", filename: "test.txt" }}
101+
attachment="Hello, World!"
102+
expanded={false}
103+
/>,
104+
);
105+
106+
expect(mockCreateObjectURL).not.toHaveBeenCalled();
107+
});
108+
});
109+
110+
describe("download link", () => {
111+
it("should render download link with correct filename", () => {
112+
render(
113+
<Attachment
114+
header={{ type: "attachment", content_type: "text/plain", filename: "my-file.txt" }}
115+
attachment="content"
116+
expanded={true}
117+
/>,
118+
);
119+
120+
const link = screen.getByRole("link");
121+
expect(link.getAttribute("download")).toBe("my-file.txt");
122+
expect(link.getAttribute("href")).toBe("blob:mock-url");
123+
});
124+
125+
it("should use inferred filename when not provided", () => {
126+
render(
127+
<Attachment
128+
header={{ type: "attachment", content_type: "application/json" }}
129+
attachment='{"key": "value"}'
130+
expanded={true}
131+
/>,
132+
);
133+
134+
const link = screen.getByRole("link");
135+
expect(link.getAttribute("download")).toBe("untitled.json");
136+
});
137+
});
138+
139+
describe("error handling", () => {
140+
it("should show error message for invalid base64 image data", () => {
141+
const { container } = render(
142+
<Attachment
143+
header={{ type: "attachment", content_type: "image/png", filename: "bad.png" }}
144+
attachment="this is not valid base64!!!"
145+
expanded={true}
146+
/>,
147+
);
148+
149+
expect(container.textContent).toContain("Failed to decode attachment data");
150+
});
151+
});
152+
153+
describe("content preview", () => {
154+
it("should render text content for text/plain", () => {
155+
const { container } = render(
156+
<Attachment
157+
header={{ type: "attachment", content_type: "text/plain", filename: "test.txt" }}
158+
attachment="Hello, World!"
159+
expanded={true}
160+
/>,
161+
);
162+
163+
expect(container.textContent).toContain("Hello, World!");
164+
});
165+
166+
it("should render JSON viewer for application/json", () => {
167+
const { container } = render(
168+
<Attachment
169+
header={{ type: "attachment", content_type: "application/json", filename: "data.json" }}
170+
attachment='{"name": "test"}'
171+
expanded={true}
172+
/>,
173+
);
174+
175+
// JSON viewer should parse and display the content
176+
expect(container.textContent).toContain("name");
177+
});
178+
179+
it("should show parse error for invalid JSON", () => {
180+
const { container } = render(
181+
<Attachment
182+
header={{ type: "attachment", content_type: "application/json", filename: "bad.json" }}
183+
attachment="not valid json"
184+
expanded={true}
185+
/>,
186+
);
187+
188+
expect(container.textContent).toContain("Failed to parse JSON attachment");
189+
});
190+
191+
it("should show expand prompt when not expanded", () => {
192+
const { container } = render(
193+
<Attachment
194+
header={{ type: "attachment", content_type: "text/plain", filename: "test.txt" }}
195+
attachment="Hello"
196+
expanded={false}
197+
/>,
198+
);
199+
200+
expect(container.textContent).toContain("Expand to preview attachment");
201+
});
202+
});
203+
204+
describe("cleanup", () => {
205+
it("should revoke blob URL on unmount", () => {
206+
const { unmount } = render(
207+
<Attachment
208+
header={{ type: "attachment", content_type: "text/plain", filename: "test.txt" }}
209+
attachment="content"
210+
expanded={true}
211+
/>,
212+
);
213+
214+
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
215+
216+
unmount();
217+
218+
expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
219+
});
220+
});
221+
});

packages/spotlight/src/ui/telemetry/components/insights/envelopes/Attachment.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ export default function Attachment({
2323
const extension = inferExtension(header.content_type as string | null, header.type as string | null);
2424
const name = (header.filename as string) || `untitled.${extension}`;
2525

26-
// Create download URL for binary content types
27-
// Returns: string (success), null (decode error)
26+
// Create download URL for attachment
27+
// Returns: string (success), null (decode error), undefined (not expanded yet)
2828
const downloadUrl = useMemo(() => {
2929
if (!expanded) {
3030
return undefined; // Not needed yet
@@ -34,19 +34,22 @@ export default function Attachment({
3434
let blobData: BlobPart;
3535

3636
if (IMAGE_CONTENT_TYPES.has(contentType) || VIDEO_CONTENT_TYPES.has(contentType)) {
37+
// Binary content types are base64-encoded
3738
const decoded = base64Decode(attachment);
3839
if (!decoded) {
3940
return null; // Decode error
4041
}
4142
blobData = decoded.buffer as BlobPart;
4243
} else if (extension === "bin") {
44+
// Unknown binary files are base64-encoded
4345
const decoded = safeAtob(attachment);
4446
if (decoded === null) {
4547
return null; // Decode error
4648
}
4749
blobData = decoded;
4850
} else {
49-
return undefined; // Not a binary type, no blob URL needed
51+
// Text-based content types (json, txt, csv, code, etc.) - use raw attachment
52+
blobData = attachment;
5053
}
5154

5255
const blob = new Blob([blobData], { type: contentType || "application/octet-stream" });

0 commit comments

Comments
 (0)