@stigmer/react 3.8.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/attachment/AttachmentChipList.d.ts +16 -4
  2. package/attachment/AttachmentChipList.d.ts.map +1 -1
  3. package/attachment/AttachmentChipList.js +106 -11
  4. package/attachment/AttachmentChipList.js.map +1 -1
  5. package/attachment/AttachmentImageLightbox.d.ts +39 -0
  6. package/attachment/AttachmentImageLightbox.d.ts.map +1 -0
  7. package/attachment/AttachmentImageLightbox.js +57 -0
  8. package/attachment/AttachmentImageLightbox.js.map +1 -0
  9. package/attachment/attachment-utils.d.ts +11 -0
  10. package/attachment/attachment-utils.d.ts.map +1 -1
  11. package/attachment/attachment-utils.js +23 -0
  12. package/attachment/attachment-utils.js.map +1 -1
  13. package/attachment/clipboard.d.ts +51 -0
  14. package/attachment/clipboard.d.ts.map +1 -0
  15. package/attachment/clipboard.js +89 -0
  16. package/attachment/clipboard.js.map +1 -0
  17. package/attachment/index.d.ts +6 -1
  18. package/attachment/index.d.ts.map +1 -1
  19. package/attachment/index.js +4 -1
  20. package/attachment/index.js.map +1 -1
  21. package/attachment/prepare-image.d.ts +43 -0
  22. package/attachment/prepare-image.d.ts.map +1 -0
  23. package/attachment/prepare-image.js +162 -0
  24. package/attachment/prepare-image.js.map +1 -0
  25. package/attachment/useAttachments.d.ts.map +1 -1
  26. package/attachment/useAttachments.js +23 -3
  27. package/attachment/useAttachments.js.map +1 -1
  28. package/attachment/useObjectUrl.d.ts +15 -0
  29. package/attachment/useObjectUrl.d.ts.map +1 -0
  30. package/attachment/useObjectUrl.js +25 -0
  31. package/attachment/useObjectUrl.js.map +1 -0
  32. package/attachment/vision-fit.d.ts +50 -0
  33. package/attachment/vision-fit.d.ts.map +1 -0
  34. package/attachment/vision-fit.js +76 -0
  35. package/attachment/vision-fit.js.map +1 -0
  36. package/composer/SessionComposer.d.ts +3 -1
  37. package/composer/SessionComposer.d.ts.map +1 -1
  38. package/composer/SessionComposer.js +50 -4
  39. package/composer/SessionComposer.js.map +1 -1
  40. package/conversation/ConversationMediaAttachment.d.ts +45 -0
  41. package/conversation/ConversationMediaAttachment.d.ts.map +1 -0
  42. package/conversation/ConversationMediaAttachment.js +153 -0
  43. package/conversation/ConversationMediaAttachment.js.map +1 -0
  44. package/conversation/ConversationTimelineView.d.ts +18 -1
  45. package/conversation/ConversationTimelineView.d.ts.map +1 -1
  46. package/conversation/ConversationTimelineView.js +14 -5
  47. package/conversation/ConversationTimelineView.js.map +1 -1
  48. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  49. package/conversation/ConversationsWorkbench.js +1 -1
  50. package/conversation/ConversationsWorkbench.js.map +1 -1
  51. package/conversation/conversationPresentation.d.ts +10 -3
  52. package/conversation/conversationPresentation.d.ts.map +1 -1
  53. package/conversation/conversationPresentation.js +10 -3
  54. package/conversation/conversationPresentation.js.map +1 -1
  55. package/conversation/index.d.ts +2 -0
  56. package/conversation/index.d.ts.map +1 -1
  57. package/conversation/index.js +1 -0
  58. package/conversation/index.js.map +1 -1
  59. package/conversation/useConversationMediaUrl.d.ts +65 -0
  60. package/conversation/useConversationMediaUrl.d.ts.map +1 -0
  61. package/conversation/useConversationMediaUrl.js +61 -0
  62. package/conversation/useConversationMediaUrl.js.map +1 -0
  63. package/execution/MessageAttachments.d.ts +52 -0
  64. package/execution/MessageAttachments.d.ts.map +1 -0
  65. package/execution/MessageAttachments.js +142 -0
  66. package/execution/MessageAttachments.js.map +1 -0
  67. package/execution/MessageEntry.d.ts +18 -0
  68. package/execution/MessageEntry.d.ts.map +1 -1
  69. package/execution/MessageEntry.js +5 -4
  70. package/execution/MessageEntry.js.map +1 -1
  71. package/execution/MessageThread.d.ts +15 -2
  72. package/execution/MessageThread.d.ts.map +1 -1
  73. package/execution/MessageThread.js +24 -7
  74. package/execution/MessageThread.js.map +1 -1
  75. package/execution/index.d.ts +2 -0
  76. package/execution/index.d.ts.map +1 -1
  77. package/execution/index.js +1 -0
  78. package/execution/index.js.map +1 -1
  79. package/github/decodeGitHubContent.d.ts +10 -5
  80. package/github/decodeGitHubContent.d.ts.map +1 -1
  81. package/github/decodeGitHubContent.js +21 -7
  82. package/github/decodeGitHubContent.js.map +1 -1
  83. package/github/useGitHubFileReader.d.ts.map +1 -1
  84. package/github/useGitHubFileReader.js +5 -5
  85. package/github/useGitHubFileReader.js.map +1 -1
  86. package/index.d.ts +5 -5
  87. package/index.d.ts.map +1 -1
  88. package/index.js +5 -4
  89. package/index.js.map +1 -1
  90. package/internal/form-primitives.d.ts +13 -0
  91. package/internal/form-primitives.d.ts.map +1 -1
  92. package/internal/form-primitives.js +13 -0
  93. package/internal/form-primitives.js.map +1 -1
  94. package/package.json +4 -4
  95. package/session/SessionViewer.js +1 -1
  96. package/session/SessionViewer.js.map +1 -1
  97. package/session/useSessionConversation.d.ts +8 -0
  98. package/session/useSessionConversation.d.ts.map +1 -1
  99. package/session/useSessionConversation.js +12 -2
  100. package/session/useSessionConversation.js.map +1 -1
  101. package/src/attachment/AttachmentChipList.tsx +330 -40
  102. package/src/attachment/AttachmentImageLightbox.tsx +156 -0
  103. package/src/attachment/__tests__/AttachmentChipList.test.tsx +326 -0
  104. package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
  105. package/src/attachment/__tests__/clipboard.test.ts +110 -0
  106. package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
  107. package/src/attachment/__tests__/prepare-image.test.ts +32 -0
  108. package/src/attachment/__tests__/vision-fit.test.ts +93 -0
  109. package/src/attachment/attachment-utils.ts +27 -0
  110. package/src/attachment/clipboard.ts +102 -0
  111. package/src/attachment/index.ts +13 -0
  112. package/src/attachment/prepare-image.ts +179 -0
  113. package/src/attachment/useAttachments.ts +26 -2
  114. package/src/attachment/useObjectUrl.ts +28 -0
  115. package/src/attachment/vision-fit.ts +90 -0
  116. package/src/composer/SessionComposer.tsx +73 -4
  117. package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
  118. package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
  119. package/src/conversation/ConversationMediaAttachment.tsx +308 -0
  120. package/src/conversation/ConversationTimelineView.tsx +60 -10
  121. package/src/conversation/ConversationsWorkbench.tsx +2 -0
  122. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +233 -6
  123. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +67 -1
  124. package/src/conversation/__tests__/useConversationMediaUrl.test.tsx +66 -0
  125. package/src/conversation/conversationPresentation.ts +10 -3
  126. package/src/conversation/index.ts +6 -0
  127. package/src/conversation/useConversationMediaUrl.ts +121 -0
  128. package/src/execution/MessageAttachments.tsx +364 -0
  129. package/src/execution/MessageEntry.tsx +38 -0
  130. package/src/execution/MessageThread.tsx +44 -4
  131. package/src/execution/__tests__/MessageEntry-attachments.test.tsx +228 -0
  132. package/src/execution/__tests__/buildThreadItems-attachments.test.ts +174 -0
  133. package/src/execution/index.ts +6 -0
  134. package/src/github/__tests__/decodeGitHubContent.test.ts +42 -1
  135. package/src/github/decodeGitHubContent.ts +22 -5
  136. package/src/github/useGitHubFileReader.ts +13 -3
  137. package/src/index.ts +16 -1
  138. package/src/internal/form-primitives.tsx +14 -0
  139. package/src/session/SessionViewer.tsx +1 -0
  140. package/src/session/__tests__/useSessionConversation.test.tsx +62 -0
  141. package/src/session/useSessionConversation.ts +21 -2
  142. package/src/workspace/FileViewer.tsx +109 -0
  143. package/src/workspace/WorkspaceFileReader.ts +55 -1
  144. package/src/workspace/__tests__/FileViewer.test.tsx +98 -0
  145. package/src/workspace/index.ts +2 -0
  146. package/styles.css +1 -1
  147. package/workspace/FileViewer.d.ts.map +1 -1
  148. package/workspace/FileViewer.js +40 -2
  149. package/workspace/FileViewer.js.map +1 -1
  150. package/workspace/WorkspaceFileReader.d.ts +29 -1
  151. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  152. package/workspace/WorkspaceFileReader.js +42 -0
  153. package/workspace/WorkspaceFileReader.js.map +1 -1
  154. package/workspace/index.d.ts +1 -1
  155. package/workspace/index.d.ts.map +1 -1
  156. package/workspace/index.js +1 -1
  157. package/workspace/index.js.map +1 -1
@@ -0,0 +1,326 @@
1
+ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
2
+ import {
3
+ render,
4
+ cleanup,
5
+ waitFor,
6
+ fireEvent,
7
+ screen,
8
+ } from "@testing-library/react";
9
+ import { AttachmentChipList } from "../AttachmentChipList.js";
10
+ import type { AttachmentChipListProps } from "../AttachmentChipList.js";
11
+ import type { AttachmentEntry } from "../useAttachments.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function entry(overrides: Partial<AttachmentEntry> = {}): AttachmentEntry {
18
+ return {
19
+ id: overrides.id ?? "entry-1",
20
+ file:
21
+ overrides.file ??
22
+ new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", {
23
+ type: "image/png",
24
+ }),
25
+ phase: overrides.phase ?? "ready",
26
+ contentType: overrides.contentType ?? "image/png",
27
+ storageKey: overrides.storageKey ?? "attachments/test/shot.png",
28
+ error: overrides.error ?? null,
29
+ };
30
+ }
31
+
32
+ function renderChips(
33
+ entries: AttachmentEntry[],
34
+ props: Partial<AttachmentChipListProps> = {},
35
+ ) {
36
+ return render(
37
+ <AttachmentChipList
38
+ entries={entries}
39
+ onRemove={vi.fn()}
40
+ onRetry={vi.fn()}
41
+ {...props}
42
+ />,
43
+ );
44
+ }
45
+
46
+ /** The chip's miniature `<img>` (decorative, `aria-hidden`) — never the lightbox image. */
47
+ function chipImage(container: HTMLElement): HTMLImageElement | null {
48
+ return container.querySelector('img[aria-hidden="true"]');
49
+ }
50
+
51
+ // happy-dom does not implement the native dialog show/close methods.
52
+ beforeAll(() => {
53
+ HTMLDialogElement.prototype.showModal = function showModal() {
54
+ this.open = true;
55
+ };
56
+ HTMLDialogElement.prototype.close = function close() {
57
+ this.open = false;
58
+ };
59
+ });
60
+
61
+ afterEach(() => {
62
+ cleanup();
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Image miniature — visible in every phase (#371 gap 2)
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe("AttachmentChipList — image miniature", () => {
71
+ it("renders an object-URL miniature for a ready image entry", async () => {
72
+ const { container } = renderChips([entry()]);
73
+
74
+ await waitFor(() => {
75
+ const img = chipImage(container);
76
+ expect(img).toBeTruthy();
77
+ expect(img!.getAttribute("src")).toContain("blob:");
78
+ expect(img!.getAttribute("alt")).toBe("");
79
+ });
80
+ });
81
+
82
+ it("keeps the generic file icon for non-image entries", () => {
83
+ const md = entry({
84
+ file: new File(["# notes"], "notes.md", { type: "text/markdown" }),
85
+ contentType: "text/markdown",
86
+ });
87
+
88
+ const { container } = renderChips([md]);
89
+
90
+ expect(container.querySelector("img")).toBeNull();
91
+ expect(container.querySelector("svg")).toBeTruthy();
92
+ });
93
+
94
+ it("keeps the miniature visible while uploading — the spinner overlays it, never replaces it", async () => {
95
+ const uploading = entry({ id: "up", phase: "uploading", storageKey: null });
96
+
97
+ const { container } = renderChips([uploading]);
98
+
99
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
100
+ // Spinner present alongside the image, not instead of it.
101
+ expect(container.querySelector("svg.animate-spin")).toBeTruthy();
102
+ });
103
+
104
+ it("renders image chips preview-only: no visible filename or size text", async () => {
105
+ const { container } = renderChips([entry()]);
106
+
107
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
108
+
109
+ // The tile is the chip — the name lives in the tooltip and accessible
110
+ // label, never as a text node (file chips, by contrast, keep theirs).
111
+ expect(screen.queryByText("shot.png")).toBeNull();
112
+ const tile = screen.getByRole("listitem", { name: /^shot\.png/ });
113
+ expect(tile.getAttribute("title")).toBe("shot.png");
114
+ // Remove is an always-visible corner badge, not hover-revealed.
115
+ expect(screen.getByRole("button", { name: "Remove shot.png" })).toBeTruthy();
116
+ });
117
+
118
+ it("keeps visible filename and size text on non-image chips", () => {
119
+ const md = entry({
120
+ file: new File(["# notes"], "notes.md", { type: "text/markdown" }),
121
+ contentType: "text/markdown",
122
+ });
123
+
124
+ renderChips([md]);
125
+
126
+ expect(screen.getByText("notes.md")).toBeTruthy();
127
+ });
128
+
129
+ it("keeps the miniature visible on upload error, alongside the retry action", async () => {
130
+ const errored = entry({
131
+ id: "err",
132
+ phase: "error",
133
+ error: "boom",
134
+ storageKey: null,
135
+ });
136
+
137
+ const { container } = renderChips([errored]);
138
+
139
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
140
+ expect(
141
+ screen.getByRole("button", { name: "Retry uploading shot.png" }),
142
+ ).toBeTruthy();
143
+ });
144
+
145
+ it("revokes the object URL on unmount", async () => {
146
+ const revokeSpy = vi.spyOn(URL, "revokeObjectURL");
147
+ const { container, unmount } = renderChips([entry()]);
148
+
149
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
150
+ const src = chipImage(container)!.getAttribute("src")!;
151
+
152
+ unmount();
153
+
154
+ expect(revokeSpy).toHaveBeenCalledWith(src);
155
+ });
156
+
157
+ it("revokes the object URL when the entry is removed from the list", async () => {
158
+ const revokeSpy = vi.spyOn(URL, "revokeObjectURL");
159
+ const image = entry();
160
+ const { container, rerender } = render(
161
+ <AttachmentChipList entries={[image]} onRemove={vi.fn()} onRetry={vi.fn()} />,
162
+ );
163
+
164
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
165
+ const src = chipImage(container)!.getAttribute("src")!;
166
+
167
+ rerender(
168
+ <AttachmentChipList entries={[]} onRemove={vi.fn()} onRetry={vi.fn()} />,
169
+ );
170
+
171
+ expect(container.querySelector("img")).toBeNull();
172
+ expect(revokeSpy).toHaveBeenCalledWith(src);
173
+ });
174
+
175
+ it("degrades to the compact file chip when the image fails to decode", async () => {
176
+ const { container } = renderChips([entry()]);
177
+
178
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
179
+ fireEvent.error(chipImage(container)!);
180
+
181
+ // No broken-image glyph, no preview affordance — just the file treatment.
182
+ expect(container.querySelector("img")).toBeNull();
183
+ expect(screen.queryByRole("button", { name: "Preview shot.png" })).toBeNull();
184
+ expect(container.querySelector("svg")).toBeTruthy();
185
+ expect(
186
+ screen.getByRole("button", { name: "Remove shot.png" }),
187
+ ).toBeTruthy();
188
+ });
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Click-to-open — built-in lightbox and the onPreview seam (#371 gap 3)
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe("AttachmentChipList — preview", () => {
196
+ it("opens the built-in lightbox with the full image when the chip body is clicked", async () => {
197
+ renderChips([entry()]);
198
+
199
+ expect(document.querySelector("dialog")).toBeNull();
200
+
201
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
202
+
203
+ const dialog = document.querySelector("dialog");
204
+ expect(dialog).toBeTruthy();
205
+ expect(dialog!.getAttribute("aria-label")).toBe("Preview shot.png");
206
+ // The lightbox derives its own object URL; the full image carries the
207
+ // filename as meaningful alt text (unlike the decorative miniature).
208
+ await waitFor(() => {
209
+ const full = dialog!.querySelector("img");
210
+ expect(full).toBeTruthy();
211
+ expect(full!.getAttribute("src")).toContain("blob:");
212
+ expect(full!.getAttribute("alt")).toBe("shot.png");
213
+ });
214
+ });
215
+
216
+ it("closes the lightbox on Escape (the dialog's cancel event)", () => {
217
+ renderChips([entry()]);
218
+
219
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
220
+ const dialog = document.querySelector("dialog")!;
221
+
222
+ fireEvent(dialog, new Event("cancel"));
223
+
224
+ expect(document.querySelector("dialog")).toBeNull();
225
+ });
226
+
227
+ it("closes the lightbox on backdrop click, but not on content clicks", async () => {
228
+ renderChips([entry()]);
229
+
230
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
231
+ const dialog = document.querySelector("dialog")!;
232
+ await waitFor(() => expect(dialog.querySelector("img")).toBeTruthy());
233
+
234
+ // A click landing on content (the image) must not close.
235
+ fireEvent.click(dialog.querySelector("img")!);
236
+ expect(document.querySelector("dialog")).toBeTruthy();
237
+
238
+ // A click landing on the dialog element itself is the backdrop.
239
+ fireEvent.click(dialog);
240
+ expect(document.querySelector("dialog")).toBeNull();
241
+ });
242
+
243
+ it("closes the lightbox via its close button", () => {
244
+ renderChips([entry()]);
245
+
246
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
247
+ fireEvent.click(
248
+ screen.getByRole("button", { name: "Close image preview" }),
249
+ );
250
+
251
+ expect(document.querySelector("dialog")).toBeNull();
252
+ });
253
+
254
+ it("closes the lightbox when the previewed entry is removed from the list", () => {
255
+ const image = entry();
256
+ const { rerender } = renderChips([image]);
257
+
258
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
259
+ expect(document.querySelector("dialog")).toBeTruthy();
260
+
261
+ rerender(
262
+ <AttachmentChipList entries={[]} onRemove={vi.fn()} onRetry={vi.fn()} />,
263
+ );
264
+
265
+ expect(document.querySelector("dialog")).toBeNull();
266
+ });
267
+
268
+ it("routes the click to onPreview instead of opening the built-in lightbox", () => {
269
+ const onPreview = vi.fn();
270
+ const image = entry();
271
+ renderChips([image], { onPreview });
272
+
273
+ fireEvent.click(screen.getByRole("button", { name: "Preview shot.png" }));
274
+
275
+ expect(onPreview).toHaveBeenCalledTimes(1);
276
+ expect(onPreview).toHaveBeenCalledWith(image);
277
+ expect(document.querySelector("dialog")).toBeNull();
278
+ });
279
+
280
+ it("offers no preview affordance on non-image entries", () => {
281
+ const md = entry({
282
+ file: new File(["# notes"], "notes.md", { type: "text/markdown" }),
283
+ contentType: "text/markdown",
284
+ });
285
+
286
+ renderChips([md]);
287
+
288
+ expect(screen.queryByRole("button", { name: /^Preview/ })).toBeNull();
289
+ });
290
+
291
+ it("keeps preview clickable while remove/retry are disabled (preview is read-only)", async () => {
292
+ const { container } = renderChips([entry()], { disabled: true });
293
+
294
+ await waitFor(() => expect(chipImage(container)).toBeTruthy());
295
+
296
+ const preview = screen.getByRole("button", {
297
+ name: "Preview shot.png",
298
+ }) as HTMLButtonElement;
299
+ const remove = screen.getByRole("button", {
300
+ name: "Remove shot.png",
301
+ }) as HTMLButtonElement;
302
+
303
+ expect(preview.disabled).toBe(false);
304
+ expect(remove.disabled).toBe(true);
305
+
306
+ fireEvent.click(preview);
307
+ expect(document.querySelector("dialog")).toBeTruthy();
308
+ });
309
+
310
+ it("handles unicode filenames across the chip, preview target, and lightbox", () => {
311
+ const name = "スクリーンショット 2026-08-10.png";
312
+ const unicode = entry({
313
+ file: new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
314
+ type: "image/png",
315
+ }),
316
+ });
317
+
318
+ renderChips([unicode]);
319
+
320
+ fireEvent.click(screen.getByRole("button", { name: `Preview ${name}` }));
321
+
322
+ const dialog = document.querySelector("dialog");
323
+ expect(dialog).toBeTruthy();
324
+ expect(dialog!.getAttribute("aria-label")).toBe(`Preview ${name}`);
325
+ });
326
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { uniquifyFilename } from "../attachment-utils.js";
3
+
4
+ describe("uniquifyFilename", () => {
5
+ const cases: Array<{
6
+ scenario: string;
7
+ name: string;
8
+ taken: string[];
9
+ expected: string;
10
+ }> = [
11
+ {
12
+ scenario: "returns the name unchanged when nothing collides",
13
+ name: "report.pdf",
14
+ taken: [],
15
+ expected: "report.pdf",
16
+ },
17
+ {
18
+ scenario: "suffixes -2 before the extension on first collision",
19
+ name: "report.pdf",
20
+ taken: ["report.pdf"],
21
+ expected: "report-2.pdf",
22
+ },
23
+ {
24
+ scenario: "walks past already-taken suffixes",
25
+ name: "report.pdf",
26
+ taken: ["report.pdf", "report-2.pdf", "report-3.pdf"],
27
+ expected: "report-4.pdf",
28
+ },
29
+ {
30
+ scenario: "handles names without an extension",
31
+ name: "Makefile",
32
+ taken: ["Makefile"],
33
+ expected: "Makefile-2",
34
+ },
35
+ {
36
+ scenario: "treats a leading dot as a hidden-file prefix, not an extension",
37
+ name: ".env",
38
+ taken: [".env"],
39
+ expected: ".env-2",
40
+ },
41
+ {
42
+ scenario: "only splits on the last dot of a multi-dot name",
43
+ name: "backup.tar.gz",
44
+ taken: ["backup.tar.gz"],
45
+ expected: "backup.tar-2.gz",
46
+ },
47
+ ];
48
+
49
+ for (const { scenario, name, taken, expected } of cases) {
50
+ it(scenario, () => {
51
+ expect(uniquifyFilename(name, new Set(taken))).toBe(expected);
52
+ });
53
+ }
54
+ });
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { extractClipboardFiles } from "../clipboard.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * The function only reads `clipboardData.files` as an array-like, so a
10
+ * plain array cast to FileList is a faithful stand-in (same pattern as the
11
+ * composer fileref tests).
12
+ */
13
+ function clipboardEventWith(files: File[]): { clipboardData: { files: FileList } } {
14
+ return { clipboardData: { files: files as unknown as FileList } };
15
+ }
16
+
17
+ function imageFile(name: string, type = "image/png"): File {
18
+ return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, { type });
19
+ }
20
+
21
+ const SYNTHESIZED_NAME = /^pasted-image-\d{6}-\d+\.[a-z]+$/;
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Tests
25
+ // ---------------------------------------------------------------------------
26
+
27
+ describe("extractClipboardFiles", () => {
28
+ it("returns an empty array for a text-only paste (null clipboardData)", () => {
29
+ expect(extractClipboardFiles({ clipboardData: null })).toEqual([]);
30
+ });
31
+
32
+ it("returns an empty array when the clipboard carries no files", () => {
33
+ expect(extractClipboardFiles(clipboardEventWith([]))).toEqual([]);
34
+ });
35
+
36
+ it("renames the browser's generic image.png to a synthesized name", () => {
37
+ const [file] = extractClipboardFiles(clipboardEventWith([imageFile("image.png")]));
38
+
39
+ expect(file.name).toMatch(SYNTHESIZED_NAME);
40
+ expect(file.name.endsWith(".png")).toBe(true);
41
+ expect(file.type).toBe("image/png");
42
+ });
43
+
44
+ it("renames generic names for every image flavor browsers produce", () => {
45
+ const cases: Array<[string, string, string]> = [
46
+ ["image.jpg", "image/jpeg", ".jpg"],
47
+ ["image.jpeg", "image/jpeg", ".jpg"],
48
+ ["image.gif", "image/gif", ".gif"],
49
+ ["image.webp", "image/webp", ".webp"],
50
+ ["image.tiff", "image/tiff", ".tiff"],
51
+ ["IMAGE.PNG", "image/png", ".png"],
52
+ // Empty name with an image MIME (some WebKit paste payloads).
53
+ ["", "image/png", ".png"],
54
+ ];
55
+
56
+ for (const [name, type, expectedExt] of cases) {
57
+ const [file] = extractClipboardFiles(clipboardEventWith([imageFile(name, type)]));
58
+ expect(file.name, `input name '${name}'`).toMatch(SYNTHESIZED_NAME);
59
+ expect(file.name.endsWith(expectedExt), `'${file.name}' should end with ${expectedExt}`).toBe(true);
60
+ }
61
+ });
62
+
63
+ it("gives two images pasted together distinct names", () => {
64
+ const files = extractClipboardFiles(
65
+ clipboardEventWith([imageFile("image.png"), imageFile("image.png")]),
66
+ );
67
+
68
+ expect(files).toHaveLength(2);
69
+ expect(files[0].name).not.toBe(files[1].name);
70
+ });
71
+
72
+ it("gives images from separate pastes distinct names (session inputs/ dir accumulates)", () => {
73
+ const [first] = extractClipboardFiles(clipboardEventWith([imageFile("image.png")]));
74
+ const [second] = extractClipboardFiles(clipboardEventWith([imageFile("image.png")]));
75
+
76
+ expect(first.name).not.toBe(second.name);
77
+ });
78
+
79
+ it("keeps the real name of a file pasted from a file manager", () => {
80
+ const [file] = extractClipboardFiles(
81
+ clipboardEventWith([imageFile("architecture-sketch.png")]),
82
+ );
83
+
84
+ expect(file.name).toBe("architecture-sketch.png");
85
+ });
86
+
87
+ it("does not rename a non-image file even when named image.png", () => {
88
+ const pdf = new File([new Uint8Array([0x25, 0x50])], "image.png", {
89
+ type: "application/pdf",
90
+ });
91
+
92
+ const [file] = extractClipboardFiles(clipboardEventWith([pdf]));
93
+ expect(file.name).toBe("image.png");
94
+ });
95
+
96
+ it("preserves bytes and metadata across a rename", async () => {
97
+ const original = new File([new Uint8Array([1, 2, 3, 4])], "image.png", {
98
+ type: "image/png",
99
+ lastModified: 1723200000000,
100
+ });
101
+
102
+ const [renamed] = extractClipboardFiles(clipboardEventWith([original]));
103
+
104
+ expect(renamed.type).toBe("image/png");
105
+ expect(renamed.lastModified).toBe(1723200000000);
106
+ expect(new Uint8Array(await renamed.arrayBuffer())).toEqual(
107
+ new Uint8Array([1, 2, 3, 4]),
108
+ );
109
+ });
110
+ });
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { prepareImageForVision } from "../prepare-image.js";
3
+ import { fitToVisionResolution } from "../vision-fit.js";
4
+
5
+ /**
6
+ * Real-Chromium half of the prepare-image coverage: decode, resize, and
7
+ * encode against an actual rendering engine (happy-dom has no canvas 2D
8
+ * or createImageBitmap — see prepare-image.test.ts for the guard rails).
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function makeCanvas(width: number, height: number): [HTMLCanvasElement, CanvasRenderingContext2D] {
16
+ const canvas = document.createElement("canvas");
17
+ canvas.width = width;
18
+ canvas.height = height;
19
+ const ctx = canvas.getContext("2d");
20
+ if (!ctx) throw new Error("test environment must provide a 2D context");
21
+ return [canvas, ctx];
22
+ }
23
+
24
+ /** Flat fills and text — the compressible content of a UI screenshot. */
25
+ function drawUiLike(ctx: CanvasRenderingContext2D, width: number, height: number) {
26
+ ctx.fillStyle = "#f5f5f5";
27
+ ctx.fillRect(0, 0, width, height);
28
+ ctx.fillStyle = "#3355aa";
29
+ ctx.fillRect(40, 40, width / 2, 120);
30
+ ctx.fillStyle = "#111111";
31
+ ctx.font = "24px sans-serif";
32
+ for (let y = 220; y < height - 40; y += 48) {
33
+ ctx.fillText("Error: connection refused at line 42 — retry failed", 48, y);
34
+ }
35
+ }
36
+
37
+ /** Per-pixel noise — the least-compressible content a photo can approach. */
38
+ function drawNoise(ctx: CanvasRenderingContext2D, width: number, height: number) {
39
+ const imageData = ctx.createImageData(width, height);
40
+ const data = imageData.data;
41
+ for (let i = 0; i < data.length; i += 4) {
42
+ data[i] = Math.floor(Math.random() * 256);
43
+ data[i + 1] = Math.floor(Math.random() * 256);
44
+ data[i + 2] = Math.floor(Math.random() * 256);
45
+ data[i + 3] = 255;
46
+ }
47
+ ctx.putImageData(imageData, 0, 0);
48
+ }
49
+
50
+ async function fileFromCanvas(
51
+ canvas: HTMLCanvasElement,
52
+ name: string,
53
+ type: string,
54
+ ): Promise<File> {
55
+ const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, type));
56
+ if (!blob) throw new Error(`canvas could not encode ${type}`);
57
+ return new File([blob], name, { type });
58
+ }
59
+
60
+ async function imageFile(
61
+ width: number,
62
+ height: number,
63
+ name: string,
64
+ type: string,
65
+ draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void = drawUiLike,
66
+ ): Promise<File> {
67
+ const [canvas, ctx] = makeCanvas(width, height);
68
+ draw(ctx, width, height);
69
+ return fileFromCanvas(canvas, name, type);
70
+ }
71
+
72
+ async function decodedSize(file: File): Promise<{ width: number; height: number }> {
73
+ const bitmap = await createImageBitmap(file);
74
+ const size = { width: bitmap.width, height: bitmap.height };
75
+ bitmap.close();
76
+ return size;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Tests
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("prepareImageForVision — real pixel pipeline", () => {
84
+ it("resizes an oversized PNG screenshot to the fitted resolution, staying PNG", async () => {
85
+ const input = await imageFile(3000, 2000, "screenshot.png", "image/png");
86
+ const expected = fitToVisionResolution(3000, 2000);
87
+
88
+ const prepared = await prepareImageForVision(input);
89
+
90
+ expect(prepared).not.toBe(input);
91
+ expect(prepared.type).toBe("image/png");
92
+ expect(prepared.name).toBe("screenshot.png");
93
+ expect(prepared.size).toBeLessThan(input.size);
94
+ expect(prepared.lastModified).toBe(input.lastModified);
95
+ await expect(decodedSize(prepared)).resolves.toEqual(expected);
96
+ });
97
+
98
+ it("passes a within-limits PNG through byte-identical (no generation loss on re-edit)", async () => {
99
+ const input = await imageFile(400, 300, "small.png", "image/png");
100
+
101
+ const prepared = await prepareImageForVision(input);
102
+
103
+ expect(prepared).toBe(input);
104
+ });
105
+
106
+ it("passes a within-limits JPEG through byte-identical", async () => {
107
+ const input = await imageFile(640, 480, "photo.jpg", "image/jpeg");
108
+
109
+ const prepared = await prepareImageForVision(input);
110
+
111
+ expect(prepared).toBe(input);
112
+ });
113
+
114
+ it("keeps JPEG format when resizing a JPEG source", async () => {
115
+ const input = await imageFile(4000, 3000, "camera.jpg", "image/jpeg");
116
+ const expected = fitToVisionResolution(4000, 3000);
117
+
118
+ const prepared = await prepareImageForVision(input);
119
+
120
+ expect(prepared.type).toBe("image/jpeg");
121
+ expect(prepared.name).toBe("camera.jpg");
122
+ await expect(decodedSize(prepared)).resolves.toEqual(expected);
123
+ });
124
+
125
+ it("re-encodes a within-limits WebP to PNG so the Cursor harness can display it", async () => {
126
+ const input = await imageFile(800, 600, "clip.webp", "image/webp");
127
+
128
+ const prepared = await prepareImageForVision(input);
129
+
130
+ expect(prepared).not.toBe(input);
131
+ expect(prepared.type).toBe("image/png");
132
+ expect(prepared.name).toBe("clip.png");
133
+ // Format normalization only — the dimensions stay untouched.
134
+ await expect(decodedSize(prepared)).resolves.toEqual({ width: 800, height: 600 });
135
+ });
136
+
137
+ it("keeps worst-case photographic content under the runner's inline byte cap", async () => {
138
+ // Evidence gate from the T05 plan. Pure noise is the least-compressible
139
+ // content an image can carry; at the fitted resolution a noise PNG
140
+ // encodes to ~3.4 MB — over the runner's per-image inline cap — which
141
+ // is exactly what the PNG-density JPEG fallback exists to catch.
142
+ // The cap constant mirrors MAX_VISION_IMAGE_BYTES in the runner's
143
+ // shared/attachment-vision.ts (a measurement bound in a test, never a
144
+ // production dependency).
145
+ const RUNNER_INLINE_IMAGE_CAP = 3 * 1024 * 1024;
146
+
147
+ const input = await imageFile(3000, 2000, "photo.png", "image/png", drawNoise);
148
+
149
+ const prepared = await prepareImageForVision(input);
150
+
151
+ expect(prepared.size).toBeLessThan(RUNNER_INLINE_IMAGE_CAP);
152
+ // Noise defeats PNG, so the ratio rule must have picked JPEG.
153
+ expect(prepared.type).toBe("image/jpeg");
154
+ expect(prepared.name).toBe("photo.jpg");
155
+ });
156
+
157
+ it("prefers PNG for UI-like content even though JPEG is available", async () => {
158
+ const input = await imageFile(3000, 2000, "dialog.png", "image/png", drawUiLike);
159
+
160
+ const prepared = await prepareImageForVision(input);
161
+
162
+ // Flat fills and text compress better in PNG than JPEG-at-half-size,
163
+ // and PNG keeps UI text crisp — the ratio rule must keep PNG here.
164
+ expect(prepared.type).toBe("image/png");
165
+ });
166
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { prepareImageForVision } from "../prepare-image.js";
3
+
4
+ /**
5
+ * happy-dom half of the prepare-image coverage: the guard rails that make
6
+ * the function safe to call from any environment. The actual pixel work
7
+ * (decode, resize, encode) runs in real Chromium — see
8
+ * prepare-image.browser.test.ts.
9
+ */
10
+ describe("prepareImageForVision — environment guard rails", () => {
11
+ it("returns a non-image file untouched (same instance)", async () => {
12
+ const pdf = new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "brief.pdf", {
13
+ type: "application/pdf",
14
+ });
15
+
16
+ await expect(prepareImageForVision(pdf)).resolves.toBe(pdf);
17
+ });
18
+
19
+ it("returns an image untouched when the canvas pipeline is unavailable", async () => {
20
+ // happy-dom has no createImageBitmap — the exact situation of an old
21
+ // browser or a privacy extension that blocks canvas. The paste must
22
+ // still work end to end with the original bytes; it merely degrades
23
+ // at the runner with the standard disclosure.
24
+ expect(typeof createImageBitmap).toBe("undefined");
25
+
26
+ const png = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", {
27
+ type: "image/png",
28
+ });
29
+
30
+ await expect(prepareImageForVision(png)).resolves.toBe(png);
31
+ });
32
+ });