Skip to content
Source
Sign inSign up

composerContextClipboard.test.ts

372 lines13.5 KB

1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import {
3 COMPOSER_CONTEXT_MAX_RECORDS,
4 ComposerContextId,
5 EnvironmentId,
6 ThreadId,
7} from "@t3tools/contracts";
8import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
9import {
10 collectComposerContextReferences,
11 formatComposerContextReference,
12} from "@t3tools/shared/composerContextReferences";
13​
14const mocks = vi.hoisted(() => ({
15 execute: vi.fn(),
16 download: vi.fn(),
17 persist: vi.fn(),
18 remove: vi.fn(),
19 local: vi.fn(),
20 preview: vi.fn(),
21 dispose: vi.fn(),
22 sequence: 0,
23}));
24vi.mock("expo", () => ({ requireNativeModule: vi.fn() }));
25vi.mock("expo-file-system", () => ({
26 File: class {
27 size = 42;
28 },
29}));
30vi.mock("../state/atom-registry", () => ({
31 appAtomRegistry: {
32 get: (atom: unknown) =>
33 atom === "environments"
34 ? []
35 : { _tag: "Some", value: { httpBaseUrl: "https://source.example" } },
36 },
37}));
38vi.mock("../state/workspace", () => ({
39 workspaceConnections: { environmentsAtom: "environments" },
40}));
41vi.mock("../state/session", () => ({
42 environmentSession: { preparedConnectionValueAtom: vi.fn() },
43}));
44vi.mock("../state/assets", () => ({ assetEnvironment: { createUrl: (value: unknown) => value } }));
45vi.mock("../state/threads", () => ({
46 environmentThreadShells: { threadShellAtom: (value: unknown) => value },
47}));
48vi.mock("../state/use-composer-drafts", () => ({
49 waitForComposerDraftsLoaded: async () => {},
50 findLocalComposerClipboardAttachment: mocks.local,
51}));
52vi.mock("@t3tools/client-runtime/state/runtime", () => ({
53 executeAtomQuery: mocks.execute,
54 squashAtomCommandFailure: () => new Error("offline"),
55}));
56vi.mock("./attachmentDownload", () => ({ downloadAttachmentForPreview: mocks.download }));
57vi.mock("./localAttachmentPreview", () => ({ loadLocalAttachmentPreview: mocks.preview }));
58vi.mock("./composerImages", () => ({
59 persistComposerAttachmentFile: mocks.persist,
60 removePersistedComposerAttachmentFile: mocks.remove,
61}));
62vi.mock("./uuid", () => ({ uuidv4: () => `import-${++mocks.sequence}` }));
63​
64import { importComposerContextClipboard } from "./composerContextClipboard";
65import { createComposerContextHistory } from "./composerContext";
66​
67const image = {
68 version: 1 as const,
69 kind: "image" as const,
70 contextId: ComposerContextId.make("image-source"),
71 label: "Checkout",
72 name: "checkout.png",
73 mimeType: "image/png",
74 sizeBytes: 7,
75 attachmentId: "source-file",
76};
77const terminal = {
78 version: 1 as const,
79 kind: "terminal" as const,
80 contextId: ComposerContextId.make("terminal-source"),
81 label: "Build",
82 terminalId: "main",
83 terminalLabel: "Terminal",
84 lineStart: 1,
85 lineEnd: 1,
86 text: "Build failed",
87};
88const clipboard = {
89 text: "![Checkout](t3-context://v1/image/image-source) [Build](t3-context://v1/terminal/terminal-source)",
90 fragment: encodeComposerContextFragment({
91 version: 1,
92 source: { environmentId: EnvironmentId.make("source") },
93 records: [image, terminal],
94 })!,
95 html: "",
96};
97​
98const localThread = {
99 version: 1 as const,
100 kind: "thread" as const,
101 contextId: ComposerContextId.make("thread_local-thread"),
102 label: "Local discussion",
103 environmentId: EnvironmentId.make("destination"),
104 threadId: ThreadId.make("local-thread"),
105 title: "Local discussion",
106};
107const foreignThread = {
108 ...localThread,
109 contextId: ComposerContextId.make("thread_foreign-thread"),
110 label: "Remote discussion",
111 environmentId: EnvironmentId.make("source"),
112 threadId: ThreadId.make("foreign-thread"),
113 title: "Remote discussion",
114};
115const mixedRecords = [foreignThread, localThread, terminal, image];
116const mixedClipboard = {
117 text: `Compare these findings: ${mixedRecords.map(formatComposerContextReference).join(" ")} before changing anything.`,
118 fragment: encodeComposerContextFragment({
119 version: 1,
120 source: { environmentId: EnvironmentId.make("source") },
121 records: [
122 ...mixedRecords,
123 {
124 ...foreignThread,
125 contextId: ComposerContextId.make("thread_uncopied-thread"),
126 threadId: ThreadId.make("uncopied-thread"),
127 title: "Not in the copied selection",
128 },
129 ],
130 })!,
131 html: "",
132};
133​
134beforeEach(() => {
135 vi.clearAllMocks();
136 mocks.sequence = 0;
137 mocks.local.mockReturnValue(undefined);
138 mocks.execute.mockResolvedValue({ _tag: "Success", value: { relativeUrl: "/assets/source" } });
139 mocks.download.mockResolvedValue({ uri: "file:///download.png", dispose: mocks.dispose });
140 mocks.persist.mockResolvedValue("file:///owned.png");
141 mocks.remove.mockResolvedValue(undefined);
142});
143​
144describe("mobile context clipboard imports", () => {
145 it("copies original bytes and rewrites file bindings using a signal without throwIfAborted", async () => {
146 const signal = { aborted: false } as AbortSignal;
147 const result = await importComposerContextClipboard(clipboard, 0, signal);
148 expect(result?.failures).toEqual([]);
149 expect(result?.attachments[0]).toMatchObject({ fileUri: "file:///owned.png", sizeBytes: 42 });
150 expect(result?.context.records[0]).toMatchObject({
151 contextId: "import-1",
152 attachmentId: result?.attachments[0]?.id,
153 });
154 expect(result?.context.records[1]).toMatchObject({ text: "Build failed" });
155 expect(result?.text).toContain("/image/import-1)");
156 expect(mocks.dispose).toHaveBeenCalledOnce();
157 });
158​
159 it("keeps failed attachment references visibly unavailable without dropping text context", async () => {
160 mocks.download.mockRejectedValue(new Error("source disconnected"));
161 const result = await importComposerContextClipboard(clipboard, 0, new AbortController().signal);
162 expect(result?.attachments).toEqual([]);
163 expect(result?.failures).toEqual(["checkout.png"]);
164 expect(result?.text).toContain("/image/import-1)");
165 expect(result?.context.records).toEqual([{ ...terminal, contextId: "import-2" }]);
166 });
167​
168 it("releases a newly owned file if the destination closes during its copy", async () => {
169 const controller = new AbortController();
170 mocks.persist.mockImplementationOnce(async () => {
171 controller.abort();
172 return "file:///owned.png";
173 });
174 await expect(importComposerContextClipboard(clipboard, 0, controller.signal)).rejects.toThrow(
175 "cancelled",
176 );
177 expect(mocks.remove).toHaveBeenCalledWith("file:///owned.png");
178 expect(mocks.dispose).toHaveBeenCalledOnce();
179 });
180​
181 it("releases its files if cancelled after the last copy finishes", async () => {
182 const controller = new AbortController();
183 mocks.dispose.mockImplementationOnce(() => controller.abort());
184 const imageOnly = {
185 text: "![Checkout](t3-context://v1/image/image-source)",
186 fragment: encodeComposerContextFragment({
187 version: 1,
188 source: { environmentId: EnvironmentId.make("source") },
189 records: [image],
190 })!,
191 html: "",
192 };
193 await expect(importComposerContextClipboard(imageOnly, 0, controller.signal)).rejects.toThrow(
194 "cancelled",
195 );
196 expect(mocks.remove).toHaveBeenCalledWith("file:///owned.png");
197 });
198​
199 it("can copy an image before its source upload completes", async () => {
200 mocks.local.mockReturnValue({
201 id: "source-file",
202 type: "image",
203 dataUrl: "data:image/png;base64,YWJj",
204 previewUri: "data:image/png;base64,YWJj",
205 name: "checkout.png",
206 mimeType: "image/png",
207 sizeBytes: 3,
208 });
209 const result = await importComposerContextClipboard(clipboard, 0, new AbortController().signal);
210 expect(result?.attachments[0]).toMatchObject({
211 dataUrl: "data:image/png;base64,YWJj",
212 uploadedAttachmentId: undefined,
213 });
214 expect(mocks.execute).not.toHaveBeenCalled();
215 expect(mocks.download).not.toHaveBeenCalled();
216 });
217​
218 it("pastes local threads, text context and files at the cap after excluding foreign threads", async () => {
219 const result = await importComposerContextClipboard(
220 mixedClipboard,
221 0,
222 new AbortController().signal,
223 COMPOSER_CONTEXT_MAX_RECORDS - 3,
224 EnvironmentId.make("destination"),
225 );
226 expect(result).not.toBeNull();
227 expect(result!.context.records).toEqual([
228 { ...localThread, contextId: expect.any(String) },
229 { ...terminal, contextId: expect.any(String) },
230 { ...image, contextId: expect.any(String), attachmentId: result!.attachments[0]!.id },
231 ]);
232 expect(result!.attachments).toEqual([
233 expect.objectContaining({ name: image.name, fileUri: "file:///owned.png" }),
234 ]);
235 expect(result!.failures).toEqual([]);
236 expect(result!.skippedThreads).toEqual([foreignThread.title]);
237 const references = collectComposerContextReferences(result!.text);
238 expect(references.map((reference) => reference.label)).toEqual(
239 mixedRecords.map((record) => record.label),
240 );
241 expect(result!.context.records.map((record) => record.contextId)).toEqual(
242 references.slice(1).map((reference) => reference.contextId),
243 );
244 expect(
245 references.every(
246 (reference) => !mixedRecords.some((record) => record.contextId === reference.contextId),
247 ),
248 ).toBe(true);
249 expect(
250 result!.context.records.some((record) => record.contextId === references[0]!.contextId),
251 ).toBe(false);
252 expect(result!.text).toMatch(/^Compare these findings: .* before changing anything\.$/);
253​
254 const restoreHistory = createComposerContextHistory();
255 restoreHistory(formatComposerContextReference(foreignThread), {
256 version: 1,
257 records: [foreignThread],
258 });
259 expect(restoreHistory(`${result!.text} Next step`, result!.context)).toEqual(result!.context);
260​
261 mocks.download.mockClear();
262 await expect(
263 importComposerContextClipboard(
264 mixedClipboard,
265 0,
266 new AbortController().signal,
267 COMPOSER_CONTEXT_MAX_RECORDS - 2,
268 EnvironmentId.make("destination"),
269 ),
270 ).rejects.toThrow("Remove some context");
271 expect(mocks.download).not.toHaveBeenCalled();
272 });
273​
274 // regression: #18322 don't send or paste attached threads a machine can't read
275 it("reports foreign threads and failed files together while keeping the rest of a paste", async () => {
276 mocks.download.mockRejectedValueOnce(new Error("source disconnected"));
277 const result = await importComposerContextClipboard(
278 mixedClipboard,
279 0,
280 new AbortController().signal,
281 0,
282 EnvironmentId.make("destination"),
283 );
284 expect(result).not.toBeNull();
285 expect(result!.context.records).toEqual([
286 { ...localThread, contextId: expect.any(String) },
287 { ...terminal, contextId: expect.any(String) },
288 ]);
289 expect(result!.attachments).toEqual([]);
290 expect(result!.failures).toEqual([image.name]);
291 expect(result!.skippedThreads).toEqual([foreignThread.title]);
292 const references = collectComposerContextReferences(result!.text);
293 expect(references.map((reference) => reference.label)).toEqual(
294 mixedRecords.map((record) => record.label),
295 );
296 expect(
297 references
298 .filter(
299 (reference) =>
300 !result!.context.records.some((record) => record.contextId === reference.contextId),
301 )
302 .map((reference) => reference.label),
303 ).toEqual([foreignThread.label, image.label]);
304 expect(result!.text).toMatch(/^Compare these findings: .* before changing anything\.$/);
305 });
306​
307 it("keeps a thread from a machine the destination links to", async () => {
308 const result = await importComposerContextClipboard(
309 mixedClipboard,
310 0,
311 new AbortController().signal,
312 0,
313 EnvironmentId.make("destination"),
314 new Map([[foreignThread.environmentId, "Source"]]),
315 );
316 expect(result!.skippedThreads).toEqual([]);
317 expect(result!.context.records.filter((record) => record.kind === "thread")).toEqual([
318 { ...foreignThread, contextId: expect.any(String) },
319 { ...localThread, contextId: expect.any(String) },
320 ]);
321 });
322​
323 it("preserves both machines' thread references when pasting without a destination", async () => {
324 const result = await importComposerContextClipboard(
325 mixedClipboard,
326 0,
327 new AbortController().signal,
328 );
329 expect(result).not.toBeNull();
330 expect(result!.context.records).toEqual([
331 { ...foreignThread, contextId: expect.any(String) },
332 { ...localThread, contextId: expect.any(String) },
333 { ...terminal, contextId: expect.any(String) },
334 { ...image, contextId: expect.any(String), attachmentId: result!.attachments[0]!.id },
335 ]);
336 expect(result!.attachments).toHaveLength(1);
337 expect(result!.failures).toEqual([]);
338 expect(
339 collectComposerContextReferences(result!.text).map((reference) => reference.contextId),
340 ).toEqual(result!.context.records.map((record) => record.contextId));
341 });
342​
343 it("pastes a thread link as a chip only where the destination's agent reads its machine", async () => {
344 const text = [
345 "https://app.t3.codes/threads/destination/local-thread",
346 "<t3-thread://v2/source/foreign-thread>",
347 ].join(" ");
348 const result = await importComposerContextClipboard(
349 { text, fragment: "", html: "" },
350 0,
351 new AbortController().signal,
352 0,
353 EnvironmentId.make("destination"),
354 );
355 const [record] = result!.context.records;
356 expect(record).toMatchObject({
357 kind: "thread",
358 environmentId: "destination",
359 threadId: "local-thread",
360 });
361 expect(result!.text).toBe(
362 `[Thread](t3-context://v1/thread/${record!.contextId}) <t3-thread://v2/source/foreign-thread>`,
363 );
364 expect(
365 await importComposerContextClipboard(
366 { text: "plain text", fragment: "", html: "" },
367 0,
368 new AbortController().signal,
369 ),
370 ).toBeNull();
371 });
372});