| ... | ... | @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ |
| 11 | 11 | open: vi.fn(), |
| 12 | 12 | size: vi.fn(), |
| 13 | 13 | readBase64: vi.fn(), |
| 14 | manipulate: vi.fn(), |
| 15 | release: vi.fn(), |
| 14 | 16 | })); |
| 15 | 17 | |
| 16 | 18 | vi.mock("expo-file-system", () => { |
| ... | ... | @@ -80,6 +82,10 @@ vi.mock("expo-file-system", () => { |
| 80 | 82 | |
| 81 | 83 | vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); |
| 82 | 84 | vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile })); |
| 85 | vi.mock("expo-image-manipulator", () => ({ |
| 86 | SaveFormat: { JPEG: "jpeg", PNG: "png", WEBP: "webp" }, |
| 87 | ImageManipulator: { manipulate: mocks.manipulate }, |
| 88 | })); |
| 83 | 89 | vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); |
| 84 | 90 | |
| 85 | 91 | import { |
| ... | ... | @@ -106,20 +112,50 @@ describe("composer file attachments", () => { |
| 106 | 112 | }); |
| 107 | 113 | |
| 108 | 114 | describe("photo library image conversion", () => { |
| 109 | | const jpeg = "/9j/2Q=="; |
| 115 | const rendered = { uri: "file:///cache/ImageManipulator/photo.jpg", base64: "/9j/2Q==" }; |
| 110 | 116 | const photo: ImagePickerAsset = { |
| 111 | 117 | uri: "file:///picker/photo.heic", |
| 112 | 118 | type: "image", |
| 113 | 119 | fileName: "photo.HEIC", |
| 114 | 120 | mimeType: "image/heic", |
| 115 | | fileSize: 20 * 1024 * 1024, |
| 116 | | base64: jpeg, |
| 117 | | width: 1, |
| 118 | | height: 1, |
| 121 | fileSize: 4 * 1024 * 1024, |
| 122 | width: 4032, |
| 123 | height: 3024, |
| 124 | }; |
| 125 | /** |
| 126 | * Stands in for the native manipulator: `size` is what decoding the source yields, |
| 127 | * `resizes` records every requested resize, and `saved` is what saving returns. |
| 128 | */ |
| 129 | const native = { |
| 130 | size: { width: 1, height: 1 }, |
| 131 | resizes: [] as Array<{ width?: number | null; height?: number | null }>, |
| 132 | saved: rendered as { uri: string; base64?: string }, |
| 119 | 133 | }; |
| 120 | 134 | |
| 135 | beforeEach(() => { |
| 136 | native.size = { width: 1, height: 1 }; |
| 137 | native.resizes = []; |
| 138 | native.saved = rendered; |
| 139 | mocks.manipulate.mockReset(); |
| 140 | mocks.release.mockReset(); |
| 141 | mocks.manipulate.mockImplementation(() => { |
| 142 | const context = { |
| 143 | resize(size: { width?: number | null; height?: number | null }) { |
| 144 | native.resizes.push(size); |
| 145 | return context; |
| 146 | }, |
| 147 | renderAsync: async () => ({ |
| 148 | ...native.size, |
| 149 | release: mocks.release, |
| 150 | saveAsync: async () => native.saved, |
| 151 | }), |
| 152 | }; |
| 153 | return context; |
| 154 | }); |
| 155 | }); |
| 156 | |
| 121 | 157 | it.each(["image/heic", "image/heif", undefined])( |
| 122 | | "attaches the native JPEG conversion with matching metadata when the source MIME is %s", |
| 158 | "renders a %s photo to JPEG natively and previews the rendered file", |
| 123 | 159 | async (mimeType) => { |
| 124 | 160 | mocks.pickMedia.mockResolvedValue({ |
| 125 | 161 | canceled: false, |
| ... | ... | @@ -128,6 +164,9 @@ describe("composer file attachments", () => { |
| 128 | 164 | |
| 129 | 165 | const result = await pickComposerImages({ existingCount: 0 }); |
| 130 | 166 | |
| 167 | expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ base64: false })); |
| 168 | expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); |
| 169 | expect(mocks.readBase64).not.toHaveBeenCalled(); |
| 131 | 170 | expect(result).toEqual({ |
| 132 | 171 | images: [ |
| 133 | 172 | { |
| ... | ... | @@ -136,8 +175,8 @@ describe("composer file attachments", () => { |
| 136 | 175 | name: "photo.jpg", |
| 137 | 176 | mimeType: "image/jpeg", |
| 138 | 177 | sizeBytes: 4, |
| 139 | | dataUrl: `data:image/jpeg;base64,${jpeg}`, |
| 140 | | previewUri: `data:image/jpeg;base64,${jpeg}`, |
| 178 | dataUrl: `data:image/jpeg;base64,${rendered.base64}`, |
| 179 | previewUri: rendered.uri, |
| 141 | 180 | }, |
| 142 | 181 | ], |
| 143 | 182 | error: null, |
| ... | ... | @@ -145,11 +184,26 @@ describe("composer file attachments", () => { |
| 145 | 184 | }, |
| 146 | 185 | ); |
| 147 | 186 | |
| 187 | it.each([ |
| 188 | { size: { width: 4032, height: 3024 }, resizes: [{ width: 2048 }] }, |
| 189 | { size: { width: 3024, height: 4032 }, resizes: [{ height: 2048 }] }, |
| 190 | { size: { width: 2048, height: 1536 }, resizes: [] }, |
| 191 | ])("bounds a $size.width x $size.height photo to a 2048 px longest edge", async (input) => { |
| 192 | native.size = input.size; |
| 193 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); |
| 194 | |
| 195 | await pickComposerImages({ existingCount: 0 }); |
| 196 | |
| 197 | expect(native.resizes).toEqual(input.resizes); |
| 198 | // Every decoded bitmap is released, including the full-size one a resize replaces. |
| 199 | expect(mocks.release).toHaveBeenCalledTimes(input.resizes.length + 1); |
| 200 | }); |
| 201 | |
| 148 | 202 | it.each([ |
| 149 | 203 | { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, |
| 150 | 204 | { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, |
| 151 | 205 | { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" }, |
| 152 | | ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => { |
| 206 | ])("keeps original $extension bytes from the picker file", async (original) => { |
| 153 | 207 | const name = `photo.${original.extension}`; |
| 154 | 208 | mocks.pickMedia.mockResolvedValue({ |
| 155 | 209 | canceled: false, |
| ... | ... | @@ -159,6 +213,8 @@ describe("composer file attachments", () => { |
| 159 | 213 | |
| 160 | 214 | const result = await pickComposerImages({ existingCount: 0 }); |
| 161 | 215 | |
| 216 | expect(mocks.manipulate).not.toHaveBeenCalled(); |
| 217 | expect(mocks.readBase64).toHaveBeenCalledWith(photo.uri); |
| 162 | 218 | expect(result.error).toBeNull(); |
| 163 | 219 | expect(result.images).toEqual([ |
| 164 | 220 | expect.objectContaining({ |
| ... | ... | @@ -166,37 +222,74 @@ describe("composer file attachments", () => { |
| 166 | 222 | mimeType: original.mimeType, |
| 167 | 223 | dataUrl: `data:${original.mimeType};base64,${original.base64}`, |
| 168 | 224 | sizeBytes: Buffer.from(original.base64, "base64").byteLength, |
| 225 | previewUri: photo.uri, |
| 169 | 226 | }), |
| 170 | 227 | ]); |
| 171 | 228 | }); |
| 172 | 229 | |
| 173 | | it("checks the converted JPEG size even when the HEIC source was smaller", async () => { |
| 174 | | const oversized = |
| 175 | | jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4); |
| 230 | it("renders a supported original that exceeds the image limit instead of rejecting it", async () => { |
| 176 | 231 | mocks.pickMedia.mockResolvedValue({ |
| 177 | 232 | canceled: false, |
| 178 | | assets: [{ ...photo, fileSize: 42, base64: oversized }], |
| 233 | assets: [{ ...photo, fileName: "photo.jpg", mimeType: "image/jpeg" }], |
| 179 | 234 | }); |
| 235 | mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); |
| 180 | 236 | |
| 181 | | await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ |
| 182 | | images: [], |
| 183 | | error: "'photo.HEIC' exceeds the 10 MB attachment limit.", |
| 237 | const result = await pickComposerImages({ existingCount: 0 }); |
| 238 | |
| 239 | expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); |
| 240 | expect(result.error).toBeNull(); |
| 241 | expect(result.images).toEqual([ |
| 242 | expect.objectContaining({ name: "photo.jpg", mimeType: "image/jpeg", sizeBytes: 4 }), |
| 243 | ]); |
| 244 | }); |
| 245 | |
| 246 | it("measures the picker file instead of trusting the reported size", async () => { |
| 247 | // A content stream can deliver more bytes than the picker advertises; a supported |
| 248 | // original only skips rendering when the file itself measures within the limit. |
| 249 | mocks.pickMedia.mockResolvedValue({ |
| 250 | canceled: false, |
| 251 | assets: [{ ...photo, fileName: "photo.png", mimeType: "image/png", fileSize: 42 }], |
| 184 | 252 | }); |
| 253 | mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); |
| 254 | |
| 255 | const result = await pickComposerImages({ existingCount: 0 }); |
| 256 | |
| 257 | expect(mocks.readBase64).not.toHaveBeenCalled(); |
| 258 | expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); |
| 259 | expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]); |
| 185 | 260 | }); |
| 186 | 261 | |
| 187 | | it("does not relabel unconverted HEIC bytes as JPEG", async () => { |
| 262 | it("renders a supported original whose size cannot be measured", async () => { |
| 188 | 263 | mocks.pickMedia.mockResolvedValue({ |
| 189 | 264 | canceled: false, |
| 190 | | assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }], |
| 265 | assets: [ |
| 266 | { ...photo, uri: "content://media/1", fileName: "photo.png", mimeType: "image/png" }, |
| 267 | ], |
| 191 | 268 | }); |
| 192 | 269 | |
| 193 | 270 | const result = await pickComposerImages({ existingCount: 0 }); |
| 194 | 271 | |
| 195 | | expect(result.images).toEqual([]); |
| 196 | | expect(result.error).toContain("not a supported image type"); |
| 272 | expect(mocks.readBase64).not.toHaveBeenCalled(); |
| 273 | expect(mocks.manipulate).toHaveBeenCalledWith("content://media/1"); |
| 274 | expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]); |
| 197 | 275 | }); |
| 198 | 276 | |
| 199 | | it("retains a converted photo when another original cannot be read", async () => { |
| 277 | it("checks the rendered JPEG against the image limit", async () => { |
| 278 | native.saved = { |
| 279 | uri: rendered.uri, |
| 280 | base64: |
| 281 | rendered.base64.slice(0, 4) + |
| 282 | "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4), |
| 283 | }; |
| 284 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); |
| 285 | |
| 286 | await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ |
| 287 | images: [], |
| 288 | error: "'photo.HEIC' exceeds the 10 MB attachment limit.", |
| 289 | }); |
| 290 | }); |
| 291 | |
| 292 | it("retains a rendered photo when another original cannot be read", async () => { |
| 200 | 293 | mocks.pickMedia.mockResolvedValue({ |
| 201 | 294 | canceled: false, |
| 202 | 295 | assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo], |
| ... | ... | @@ -208,6 +301,21 @@ describe("composer file attachments", () => { |
| 208 | 301 | expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]); |
| 209 | 302 | expect(result.error).toBe("Failed to read 'missing.gif'."); |
| 210 | 303 | }); |
| 304 | |
| 305 | it("reports a photo the native renderer cannot decode", async () => { |
| 306 | mocks.manipulate.mockImplementation(() => ({ |
| 307 | resize: () => { |
| 308 | throw new Error("unreachable"); |
| 309 | }, |
| 310 | renderAsync: () => Promise.reject(new Error("corrupt")), |
| 311 | })); |
| 312 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); |
| 313 | |
| 314 | await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ |
| 315 | images: [], |
| 316 | error: "Failed to read 'photo.HEIC'.", |
| 317 | }); |
| 318 | }); |
| 211 | 319 | }); |
| 212 | 320 | |
| 213 | 321 | describe("photo library videos", () => { |
| ... | ... | @@ -217,10 +325,13 @@ describe("composer file attachments", () => { |
| 217 | 325 | fileName: "photo.png", |
| 218 | 326 | mimeType: "image/png", |
| 219 | 327 | fileSize: 3, |
| 220 | | base64: "YWJj", |
| 221 | 328 | width: 1, |
| 222 | 329 | height: 1, |
| 223 | 330 | }; |
| 331 | |
| 332 | beforeEach(() => { |
| 333 | mocks.readBase64.mockResolvedValue("YWJj"); |
| 334 | }); |
| 224 | 335 | const video: ImagePickerAsset = { |
| 225 | 336 | uri: "file:///picker/clip.mov", |
| 226 | 337 | type: "video", |
| ... | ... | @@ -234,7 +345,9 @@ describe("composer file attachments", () => { |
| 234 | 345 | |
| 235 | 346 | it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => { |
| 236 | 347 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); |
| 237 | | mocks.size.mockReturnValue(video.fileSize); |
| 348 | mocks.size.mockImplementation((uri: string) => |
| 349 | uri.endsWith("clip.mov") ? video.fileSize : 3, |
| 350 | ); |
| 238 | 351 | |
| 239 | 352 | const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 }); |
| 240 | 353 | |
| ... | ... | @@ -345,7 +458,7 @@ describe("composer file attachments", () => { |
| 345 | 458 | canceled: false, |
| 346 | 459 | assets: [{ ...video, fileSize: reported }, image], |
| 347 | 460 | }); |
| 348 | | mocks.size.mockReturnValue(stored); |
| 461 | mocks.size.mockImplementation((uri: string) => (uri.endsWith("clip.mov") ? stored : 3)); |
| 349 | 462 | |
| 350 | 463 | const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit }); |
| 351 | 464 | |