| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { ComposerContextId, EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; |
| 4 | |
| 5 | const 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 | })); |
| 15 | vi.mock("expo", () => ({ requireNativeModule: vi.fn() })); |
| 16 | vi.mock("expo-file-system", () => ({ |
| 17 | File: class { |
| 18 | size = 42; |
| 19 | }, |
| 20 | })); |
| 21 | vi.mock("../state/atom-registry", () => ({ |
| 22 | appAtomRegistry: { |
| 23 | get: () => ({ _tag: "Some", value: { httpBaseUrl: "https://source.example" } }), |
| 24 | }, |
| 25 | })); |
| 26 | vi.mock("../state/session", () => ({ |
| 27 | environmentSession: { preparedConnectionValueAtom: vi.fn() }, |
| 28 | })); |
| 29 | vi.mock("../state/assets", () => ({ assetEnvironment: { createUrl: (value: unknown) => value } })); |
| 30 | vi.mock("../state/use-composer-drafts", () => ({ |
| 31 | waitForComposerDraftsLoaded: async () => {}, |
| 32 | findLocalComposerClipboardAttachment: mocks.local, |
| 33 | })); |
| 34 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ |
| 35 | executeAtomQuery: mocks.execute, |
| 36 | squashAtomCommandFailure: () => new Error("offline"), |
| 37 | })); |
| 38 | vi.mock("./attachmentDownload", () => ({ downloadAttachmentForPreview: mocks.download })); |
| 39 | vi.mock("./localAttachmentPreview", () => ({ loadLocalAttachmentPreview: mocks.preview })); |
| 40 | vi.mock("./composerImages", () => ({ |
| 41 | persistComposerAttachmentFile: mocks.persist, |
| 42 | removePersistedComposerAttachmentFile: mocks.remove, |
| 43 | })); |
| 44 | vi.mock("./uuid", () => ({ uuidv4: () => `import-${++mocks.sequence}` })); |
| 45 | |
| 46 | import { importComposerContextClipboard } from "./composerContextClipboard"; |
| 47 | |
| 48 | const 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 | }; |
| 58 | const 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 | }; |
| 69 | const clipboard = { |
| 70 | text: " [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 | |
| 79 | beforeEach(() => { |
| 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 | |
| 89 | describe("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 | }); |