1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { ComposerContextId, EnvironmentId } from "@t3tools/contracts";
3import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
4
5const mocks = vi.hoisted(() => ({
6 execute: vi.fn(),
7 download: vi.fn(),
8 persist: vi.fn(),
9 remove: vi.fn(),
10 local: vi.fn(),
11 preview: vi.fn(),
12 dispose: vi.fn(),
13 sequence: 0,
14}));
15vi.mock("expo", () => ({ requireNativeModule: vi.fn() }));
16vi.mock("expo-file-system", () => ({
17 File: class {
18 size = 42;
19 },
20}));
21vi.mock("../state/atom-registry", () => ({
22 appAtomRegistry: {
23 get: () => ({ _tag: "Some", value: { httpBaseUrl: "https://source.example" } }),
24 },
25}));
26vi.mock("../state/session", () => ({
27 environmentSession: { preparedConnectionValueAtom: vi.fn() },
28}));
29vi.mock("../state/assets", () => ({ assetEnvironment: { createUrl: (value: unknown) => value } }));
30vi.mock("../state/use-composer-drafts", () => ({
31 waitForComposerDraftsLoaded: async () => {},
32 findLocalComposerClipboardAttachment: mocks.local,
33}));
34vi.mock("@t3tools/client-runtime/state/runtime", () => ({
35 executeAtomQuery: mocks.execute,
36 squashAtomCommandFailure: () => new Error("offline"),
37}));
38vi.mock("./attachmentDownload", () => ({ downloadAttachmentForPreview: mocks.download }));
39vi.mock("./localAttachmentPreview", () => ({ loadLocalAttachmentPreview: mocks.preview }));
40vi.mock("./composerImages", () => ({
41 persistComposerAttachmentFile: mocks.persist,
42 removePersistedComposerAttachmentFile: mocks.remove,
43}));
44vi.mock("./uuid", () => ({ uuidv4: () => `import-${++mocks.sequence}` }));
45
46import { importComposerContextClipboard } from "./composerContextClipboard";
47
48const image = {
49 version: 1 as const,
50 kind: "image" as const,
51 contextId: ComposerContextId.make("image-source"),
52 label: "Checkout",
53 name: "checkout.png",
54 mimeType: "image/png",
55 sizeBytes: 7,
56 attachmentId: "source-file",
57};
58const terminal = {
59 version: 1 as const,
60 kind: "terminal" as const,
61 contextId: ComposerContextId.make("terminal-source"),
62 label: "Build",
63 terminalId: "main",
64 terminalLabel: "Terminal",
65 lineStart: 1,
66 lineEnd: 1,
67 text: "Build failed",
68};
69const clipboard = {
70 text: "![Checkout](t3-context://v1/image/image-source) [Build](t3-context://v1/terminal/terminal-source)",
71 fragment: encodeComposerContextFragment({
72 version: 1,
73 source: { environmentId: EnvironmentId.make("source") },
74 records: [image, terminal],
75 })!,
76 html: "",
77};
78
79beforeEach(() => {
80 vi.clearAllMocks();
81 mocks.sequence = 0;
82 mocks.local.mockReturnValue(undefined);
83 mocks.execute.mockResolvedValue({ _tag: "Success", value: { relativeUrl: "/assets/source" } });
84 mocks.download.mockResolvedValue({ uri: "file:///download.png", dispose: mocks.dispose });
85 mocks.persist.mockResolvedValue("file:///owned.png");
86 mocks.remove.mockResolvedValue(undefined);
87});
88
89describe("mobile context clipboard imports", () => {
90 it("copies original bytes and rewrites file bindings using a signal without throwIfAborted", async () => {
91 const signal = { aborted: false } as AbortSignal;
92 const result = await importComposerContextClipboard(clipboard, 0, signal);
93 expect(result?.failures).toEqual([]);
94 expect(result?.attachments[0]).toMatchObject({ fileUri: "file:///owned.png", sizeBytes: 42 });
95 expect(result?.context.records[0]).toMatchObject({
96 contextId: "import-1",
97 attachmentId: result?.attachments[0]?.id,
98 });
99 expect(result?.context.records[1]).toMatchObject({ text: "Build failed" });
100 expect(result?.text).toContain("/image/import-1)");
101 expect(mocks.dispose).toHaveBeenCalledOnce();
102 });
103
104 it("keeps failed attachment references visibly unavailable without dropping text context", async () => {
105 mocks.download.mockRejectedValue(new Error("source disconnected"));
106 const result = await importComposerContextClipboard(clipboard, 0, new AbortController().signal);
107 expect(result?.attachments).toEqual([]);
108 expect(result?.failures).toEqual(["checkout.png"]);
109 expect(result?.text).toContain("/image/import-1)");
110 expect(result?.context.records).toEqual([{ ...terminal, contextId: "import-2" }]);
111 });
112
113 it("releases a newly owned file if the destination closes during its copy", async () => {
114 const controller = new AbortController();
115 mocks.persist.mockImplementationOnce(async () => {
116 controller.abort();
117 return "file:///owned.png";
118 });
119 await expect(importComposerContextClipboard(clipboard, 0, controller.signal)).rejects.toThrow(
120 "cancelled",
121 );
122 expect(mocks.remove).toHaveBeenCalledWith("file:///owned.png");
123 expect(mocks.dispose).toHaveBeenCalledOnce();
124 });
125
126 it("can copy an image before its source upload completes", async () => {
127 mocks.local.mockReturnValue({
128 id: "source-file",
129 type: "image",
130 dataUrl: "data:image/png;base64,YWJj",
131 previewUri: "data:image/png;base64,YWJj",
132 name: "checkout.png",
133 mimeType: "image/png",
134 sizeBytes: 3,
135 });
136 const result = await importComposerContextClipboard(clipboard, 0, new AbortController().signal);
137 expect(result?.attachments[0]).toMatchObject({
138 dataUrl: "data:image/png;base64,YWJj",
139 uploadedAttachmentId: undefined,
140 });
141 expect(mocks.execute).not.toHaveBeenCalled();
142 expect(mocks.download).not.toHaveBeenCalled();
143 });
144
145 it("refuses an overflowing context paste before copying files", async () => {
146 await expect(
147 importComposerContextClipboard(clipboard, 0, new AbortController().signal, 200),
148 ).rejects.toThrow("Remove some context");
149 expect(mocks.download).not.toHaveBeenCalled();
150 });
151});