@shopify/shop-minis-react 0.4.0 → 0.4.1

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.
@@ -0,0 +1,193 @@
1
+ import React from 'react'
2
+
3
+ import {renderHook} from '@testing-library/react'
4
+ import {describe, expect, it, vi, beforeEach} from 'vitest'
5
+
6
+ import {useImagePicker} from './useImagePicker'
7
+
8
+ import type {
9
+ OpenCameraParams,
10
+ OpenGalleryParams,
11
+ } from '../../providers/ImagePickerProvider'
12
+
13
+ // Mock the ImagePickerProvider context
14
+ const mockOpenCamera = vi.fn()
15
+ const mockOpenGallery = vi.fn()
16
+
17
+ vi.mock('../../providers/ImagePickerProvider', () => ({
18
+ useImagePickerContext: () => ({
19
+ openCamera: mockOpenCamera,
20
+ openGallery: mockOpenGallery,
21
+ }),
22
+ ImagePickerProvider: ({children}: {children: React.ReactNode}) => children,
23
+ }))
24
+
25
+ describe('useImagePicker', () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks()
28
+ })
29
+
30
+ describe('openCamera', () => {
31
+ it('calls context openCamera with provided params', async () => {
32
+ const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
33
+ mockOpenCamera.mockResolvedValue(mockFile)
34
+
35
+ const {result} = renderHook(() => useImagePicker())
36
+
37
+ const params: OpenCameraParams = {
38
+ cameraFacing: 'front',
39
+ quality: 'high',
40
+ customQuality: {size: 2000, compression: 0.9},
41
+ }
42
+
43
+ const file = await result.current.openCamera(params)
44
+
45
+ expect(mockOpenCamera).toHaveBeenCalledWith(params)
46
+ expect(file).toBe(mockFile)
47
+ })
48
+
49
+ it('calls context openCamera with default params when none provided', async () => {
50
+ const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
51
+ mockOpenCamera.mockResolvedValue(mockFile)
52
+
53
+ const {result} = renderHook(() => useImagePicker())
54
+
55
+ const file = await result.current.openCamera()
56
+
57
+ expect(mockOpenCamera).toHaveBeenCalledWith({
58
+ cameraFacing: undefined,
59
+ quality: undefined,
60
+ customQuality: undefined,
61
+ })
62
+ expect(file).toBe(mockFile)
63
+ })
64
+
65
+ it('supports partial params', async () => {
66
+ const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
67
+ mockOpenCamera.mockResolvedValue(mockFile)
68
+
69
+ const {result} = renderHook(() => useImagePicker())
70
+
71
+ const file = await result.current.openCamera({quality: 'low'})
72
+
73
+ expect(mockOpenCamera).toHaveBeenCalledWith({
74
+ cameraFacing: undefined,
75
+ quality: 'low',
76
+ customQuality: undefined,
77
+ })
78
+ expect(file).toBe(mockFile)
79
+ })
80
+ })
81
+
82
+ describe('openGallery', () => {
83
+ it('calls context openGallery with provided params', async () => {
84
+ const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
85
+ mockOpenGallery.mockResolvedValue(mockFile)
86
+
87
+ const {result} = renderHook(() => useImagePicker())
88
+
89
+ const params: OpenGalleryParams = {
90
+ quality: 'medium',
91
+ customQuality: {size: 1500, compression: 0.8},
92
+ }
93
+
94
+ const file = await result.current.openGallery(params)
95
+
96
+ expect(mockOpenGallery).toHaveBeenCalledWith(params)
97
+ expect(file).toBe(mockFile)
98
+ })
99
+
100
+ it('calls context openGallery with default params when none provided', async () => {
101
+ const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
102
+ mockOpenGallery.mockResolvedValue(mockFile)
103
+
104
+ const {result} = renderHook(() => useImagePicker())
105
+
106
+ const file = await result.current.openGallery()
107
+
108
+ expect(mockOpenGallery).toHaveBeenCalledWith({
109
+ quality: undefined,
110
+ customQuality: undefined,
111
+ })
112
+ expect(file).toBe(mockFile)
113
+ })
114
+
115
+ it('supports partial params', async () => {
116
+ const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
117
+ mockOpenGallery.mockResolvedValue(mockFile)
118
+
119
+ const {result} = renderHook(() => useImagePicker())
120
+
121
+ const file = await result.current.openGallery({quality: 'original'})
122
+
123
+ expect(mockOpenGallery).toHaveBeenCalledWith({
124
+ quality: 'original',
125
+ customQuality: undefined,
126
+ })
127
+ expect(file).toBe(mockFile)
128
+ })
129
+ })
130
+
131
+ describe('Error handling', () => {
132
+ it('propagates errors from openCamera', async () => {
133
+ const error = new Error('Camera permission denied')
134
+ mockOpenCamera.mockRejectedValue(error)
135
+
136
+ const {result} = renderHook(() => useImagePicker())
137
+
138
+ await expect(result.current.openCamera()).rejects.toThrow(
139
+ 'Camera permission denied'
140
+ )
141
+ })
142
+
143
+ it('propagates errors from openGallery', async () => {
144
+ const error = new Error('Gallery access denied')
145
+ mockOpenGallery.mockRejectedValue(error)
146
+
147
+ const {result} = renderHook(() => useImagePicker())
148
+
149
+ await expect(result.current.openGallery()).rejects.toThrow(
150
+ 'Gallery access denied'
151
+ )
152
+ })
153
+ })
154
+
155
+ describe('Quality settings', () => {
156
+ it('passes through all quality options for camera', async () => {
157
+ const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
158
+ mockOpenCamera.mockResolvedValue(mockFile)
159
+
160
+ const {result} = renderHook(() => useImagePicker())
161
+
162
+ // Test each quality setting
163
+ const qualities = ['low', 'medium', 'high', 'original'] as const
164
+
165
+ for (const quality of qualities) {
166
+ await result.current.openCamera({quality})
167
+ expect(mockOpenCamera).toHaveBeenLastCalledWith({
168
+ cameraFacing: undefined,
169
+ quality,
170
+ customQuality: undefined,
171
+ })
172
+ }
173
+ })
174
+
175
+ it('passes through all quality options for gallery', async () => {
176
+ const mockFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
177
+ mockOpenGallery.mockResolvedValue(mockFile)
178
+
179
+ const {result} = renderHook(() => useImagePicker())
180
+
181
+ // Test each quality setting
182
+ const qualities = ['low', 'medium', 'high', 'original'] as const
183
+
184
+ for (const quality of qualities) {
185
+ await result.current.openGallery({quality})
186
+ expect(mockOpenGallery).toHaveBeenLastCalledWith({
187
+ quality,
188
+ customQuality: undefined,
189
+ })
190
+ }
191
+ })
192
+ })
193
+ })
@@ -1,24 +1,43 @@
1
+ import {useCallback} from 'react'
2
+
1
3
  import {
2
- CameraFacing,
3
4
  useImagePickerContext,
5
+ OpenCameraParams,
6
+ OpenGalleryParams,
4
7
  } from '../../providers/ImagePickerProvider'
5
8
 
6
9
  interface UseImagePickerReturns {
7
10
  /**
8
11
  * Opens the camera to take a photo.
9
12
  */
10
- openCamera: (cameraFacing?: CameraFacing) => Promise<File>
13
+ openCamera: (params?: OpenCameraParams) => Promise<File>
11
14
  /**
12
15
  * Opens the gallery to select an image.
13
16
  */
14
- openGallery: () => Promise<File>
17
+ openGallery: (params?: OpenGalleryParams) => Promise<File>
15
18
  }
16
19
 
17
20
  export function useImagePicker(): UseImagePickerReturns {
18
21
  const {openCamera, openGallery} = useImagePickerContext()
19
22
 
23
+ const openCameraWithQuality = useCallback(
24
+ async ({cameraFacing, quality, customQuality}: OpenCameraParams = {}) => {
25
+ const file = await openCamera({cameraFacing, quality, customQuality})
26
+ return file
27
+ },
28
+ [openCamera]
29
+ )
30
+
31
+ const openGalleryWithQuality = useCallback(
32
+ async ({quality, customQuality}: OpenGalleryParams = {}) => {
33
+ const file = await openGallery({quality, customQuality})
34
+ return file
35
+ },
36
+ [openGallery]
37
+ )
38
+
20
39
  return {
21
- openCamera,
22
- openGallery,
40
+ openCamera: openCameraWithQuality,
41
+ openGallery: openGalleryWithQuality,
23
42
  }
24
43
  }
@@ -0,0 +1,314 @@
1
+ import {describe, expect, it, vi, beforeEach} from 'vitest'
2
+
3
+ import {resizeImage} from './resizeImage'
4
+
5
+ describe('resizeImage', () => {
6
+ let mockCanvas: any
7
+ let mockContext: any
8
+ let mockImage: any
9
+
10
+ beforeEach(() => {
11
+ // Mock canvas context
12
+ mockContext = {
13
+ drawImage: vi.fn(),
14
+ }
15
+
16
+ // Mock canvas
17
+ mockCanvas = {
18
+ width: 0,
19
+ height: 0,
20
+ getContext: vi.fn(() => mockContext),
21
+ toBlob: vi.fn(),
22
+ }
23
+
24
+ // Mock document.createElement
25
+ const originalCreateElement = document.createElement
26
+ document.createElement = vi.fn((tag: string) => {
27
+ if (tag === 'canvas') {
28
+ return mockCanvas as any
29
+ }
30
+ return originalCreateElement(tag)
31
+ })
32
+
33
+ // Mock URL methods
34
+ global.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
35
+ global.URL.revokeObjectURL = vi.fn()
36
+
37
+ // Mock Image constructor
38
+ mockImage = {
39
+ width: 3000,
40
+ height: 4000,
41
+ onload: null as any,
42
+ onerror: null as any,
43
+ src: '',
44
+ }
45
+
46
+ global.Image = vi.fn(() => mockImage) as any
47
+ })
48
+
49
+ describe('Quality settings', () => {
50
+ it('returns original file for "original" quality', async () => {
51
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
52
+
53
+ const result = await resizeImage({
54
+ file: originalFile,
55
+ quality: 'original',
56
+ })
57
+
58
+ expect(result).toBe(originalFile)
59
+ expect(global.Image).not.toHaveBeenCalled()
60
+ expect(document.createElement).not.toHaveBeenCalled()
61
+ })
62
+
63
+ it('resizes to 1080px for low quality', async () => {
64
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
65
+
66
+ // Set up canvas toBlob to call callback
67
+ mockCanvas.toBlob.mockImplementation((callback: any) => {
68
+ const blob = new Blob(['resized'], {type: 'image/jpeg'})
69
+ callback(blob)
70
+ })
71
+
72
+ // Trigger image load after src is set
73
+ global.Image = vi.fn(() => {
74
+ const img = mockImage
75
+ setTimeout(() => {
76
+ if (img.onload) img.onload()
77
+ }, 0)
78
+ return img
79
+ }) as any
80
+
81
+ const resultPromise = resizeImage({
82
+ file: originalFile,
83
+ quality: 'low',
84
+ })
85
+
86
+ await new Promise(resolve => setTimeout(resolve, 10))
87
+
88
+ const result = await resultPromise
89
+
90
+ expect(result).toBeInstanceOf(File)
91
+ expect(result.type).toBe('image/jpeg')
92
+ expect(result.name).toBe('test.jpg')
93
+
94
+ // Check canvas dimensions were set correctly (maintaining aspect ratio)
95
+ expect(mockCanvas.width).toBe(810) // 1080 * (3000/4000)
96
+ expect(mockCanvas.height).toBe(1080)
97
+ })
98
+
99
+ it('resizes to 1600px for medium quality', async () => {
100
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
101
+
102
+ mockCanvas.toBlob.mockImplementation((callback: any) => {
103
+ const blob = new Blob(['resized'], {type: 'image/jpeg'})
104
+ callback(blob)
105
+ })
106
+
107
+ global.Image = vi.fn(() => {
108
+ const img = mockImage
109
+ setTimeout(() => {
110
+ if (img.onload) img.onload()
111
+ }, 0)
112
+ return img
113
+ }) as any
114
+
115
+ const result = await resizeImage({
116
+ file: originalFile,
117
+ quality: 'medium',
118
+ })
119
+
120
+ expect(result).toBeInstanceOf(File)
121
+ expect(mockCanvas.width).toBe(1200) // 1600 * (3000/4000)
122
+ expect(mockCanvas.height).toBe(1600)
123
+ })
124
+
125
+ it('resizes to 2048px for high quality', async () => {
126
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
127
+
128
+ mockCanvas.toBlob.mockImplementation((callback: any) => {
129
+ const blob = new Blob(['resized'], {type: 'image/jpeg'})
130
+ callback(blob)
131
+ })
132
+
133
+ global.Image = vi.fn(() => {
134
+ const img = mockImage
135
+ setTimeout(() => {
136
+ if (img.onload) img.onload()
137
+ }, 0)
138
+ return img
139
+ }) as any
140
+
141
+ const result = await resizeImage({
142
+ file: originalFile,
143
+ quality: 'high',
144
+ })
145
+
146
+ expect(result).toBeInstanceOf(File)
147
+ expect(mockCanvas.width).toBe(1536) // 2048 * (3000/4000)
148
+ expect(mockCanvas.height).toBe(2048)
149
+ })
150
+ })
151
+
152
+ describe('Custom quality', () => {
153
+ it('uses custom size when provided', async () => {
154
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
155
+
156
+ mockCanvas.toBlob.mockImplementation((callback: any) => {
157
+ const blob = new Blob(['resized'], {type: 'image/jpeg'})
158
+ callback(blob)
159
+ })
160
+
161
+ global.Image = vi.fn(() => {
162
+ const img = mockImage
163
+ setTimeout(() => {
164
+ if (img.onload) img.onload()
165
+ }, 0)
166
+ return img
167
+ }) as any
168
+
169
+ const result = await resizeImage({
170
+ file: originalFile,
171
+ quality: 'low',
172
+ customQuality: {size: 500, compression: 0.6},
173
+ })
174
+
175
+ expect(result).toBeInstanceOf(File)
176
+ expect(mockCanvas.width).toBe(375) // 500 * (3000/4000)
177
+ expect(mockCanvas.height).toBe(500)
178
+ })
179
+ })
180
+
181
+ describe('Aspect ratio handling', () => {
182
+ it('maintains aspect ratio for landscape images', async () => {
183
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
184
+
185
+ // Set landscape dimensions
186
+ mockImage.width = 4000
187
+ mockImage.height = 3000
188
+
189
+ mockCanvas.toBlob.mockImplementation((callback: any) => {
190
+ const blob = new Blob(['resized'], {type: 'image/jpeg'})
191
+ callback(blob)
192
+ })
193
+
194
+ global.Image = vi.fn(() => {
195
+ const img = mockImage
196
+ setTimeout(() => {
197
+ if (img.onload) img.onload()
198
+ }, 0)
199
+ return img
200
+ }) as any
201
+
202
+ await resizeImage({
203
+ file: originalFile,
204
+ quality: 'low',
205
+ })
206
+
207
+ expect(mockCanvas.width).toBe(1080)
208
+ expect(mockCanvas.height).toBe(810) // 1080 * (3000/4000)
209
+ })
210
+
211
+ it('does not resize if image is smaller than target size', async () => {
212
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
213
+
214
+ // Set small dimensions
215
+ mockImage.width = 800
216
+ mockImage.height = 600
217
+
218
+ mockCanvas.toBlob.mockImplementation((callback: any) => {
219
+ const blob = new Blob(['resized'], {type: 'image/jpeg'})
220
+ callback(blob)
221
+ })
222
+
223
+ global.Image = vi.fn(() => {
224
+ const img = mockImage
225
+ setTimeout(() => {
226
+ if (img.onload) img.onload()
227
+ }, 0)
228
+ return img
229
+ }) as any
230
+
231
+ await resizeImage({
232
+ file: originalFile,
233
+ quality: 'high',
234
+ })
235
+
236
+ // Should maintain original dimensions
237
+ expect(mockCanvas.width).toBe(800)
238
+ expect(mockCanvas.height).toBe(600)
239
+ })
240
+ })
241
+
242
+ describe('Error handling', () => {
243
+ it('rejects when image fails to load', async () => {
244
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
245
+
246
+ global.Image = vi.fn(() => {
247
+ const img = mockImage
248
+ setTimeout(() => {
249
+ if (img.onerror) img.onerror()
250
+ }, 0)
251
+ return img
252
+ }) as any
253
+
254
+ await expect(
255
+ resizeImage({
256
+ file: originalFile,
257
+ quality: 'medium',
258
+ })
259
+ ).rejects.toThrow('Failed to load image')
260
+
261
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
262
+ })
263
+ })
264
+
265
+ describe('Resource cleanup', () => {
266
+ it('revokes object URL after successful resize', async () => {
267
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
268
+
269
+ mockCanvas.toBlob.mockImplementation((callback: any) => {
270
+ const blob = new Blob(['resized'], {type: 'image/jpeg'})
271
+ callback(blob)
272
+ })
273
+
274
+ global.Image = vi.fn(() => {
275
+ const img = mockImage
276
+ setTimeout(() => {
277
+ if (img.onload) img.onload()
278
+ }, 0)
279
+ return img
280
+ }) as any
281
+
282
+ await resizeImage({
283
+ file: originalFile,
284
+ quality: 'medium',
285
+ })
286
+
287
+ expect(URL.createObjectURL).toHaveBeenCalledWith(originalFile)
288
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
289
+ })
290
+
291
+ it('revokes object URL even when error occurs', async () => {
292
+ const originalFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
293
+
294
+ global.Image = vi.fn(() => {
295
+ const img = mockImage
296
+ setTimeout(() => {
297
+ if (img.onerror) img.onerror()
298
+ }, 0)
299
+ return img
300
+ }) as any
301
+
302
+ try {
303
+ await resizeImage({
304
+ file: originalFile,
305
+ quality: 'medium',
306
+ })
307
+ } catch {
308
+ // Expected to throw
309
+ }
310
+
311
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
312
+ })
313
+ })
314
+ })
@@ -0,0 +1,108 @@
1
+ import type {
2
+ ImageQuality,
3
+ CustomImageQuality,
4
+ } from '../../providers/ImagePickerProvider'
5
+
6
+ interface ResizeSettings {
7
+ size: number
8
+ compression: number
9
+ }
10
+
11
+ export interface ResizeImageParams {
12
+ file: File
13
+ quality: ImageQuality
14
+ customQuality?: CustomImageQuality
15
+ }
16
+
17
+ const QUALITY_SETTINGS: {
18
+ [key in Exclude<ImageQuality, 'original'>]: ResizeSettings
19
+ } = {
20
+ low: {
21
+ size: 1080,
22
+ compression: 0.7,
23
+ },
24
+ medium: {
25
+ size: 1600,
26
+ compression: 0.85,
27
+ },
28
+ high: {
29
+ size: 2048,
30
+ compression: 0.92,
31
+ },
32
+ } as const
33
+
34
+ export function resizeImage({
35
+ file,
36
+ quality,
37
+ customQuality,
38
+ }: ResizeImageParams): Promise<File> {
39
+ if (quality === 'original') {
40
+ return Promise.resolve(file)
41
+ }
42
+
43
+ const defaultSettings = QUALITY_SETTINGS[quality]
44
+ const settings: ResizeSettings = customQuality
45
+ ? {
46
+ size: customQuality.size ?? defaultSettings.size,
47
+ compression: customQuality.compression ?? defaultSettings.compression,
48
+ }
49
+ : defaultSettings
50
+ const maxSize = settings.size
51
+
52
+ return new Promise((resolve, reject) => {
53
+ const img = new Image()
54
+ const url = URL.createObjectURL(file)
55
+
56
+ img.onerror = () => {
57
+ URL.revokeObjectURL(url)
58
+ reject(new Error('Failed to load image'))
59
+ }
60
+
61
+ img.onload = () => {
62
+ URL.revokeObjectURL(url)
63
+
64
+ const canvas = document.createElement('canvas')
65
+ let width = img.width
66
+ let height = img.height
67
+
68
+ // Resize image dimensions maintaining aspect ratio
69
+ if (width > height) {
70
+ if (width > maxSize) {
71
+ height *= maxSize / width
72
+ width = maxSize
73
+ }
74
+ } else if (height > maxSize) {
75
+ width *= maxSize / height
76
+ height = maxSize
77
+ }
78
+
79
+ canvas.width = width
80
+ canvas.height = height
81
+
82
+ const ctx = canvas.getContext('2d')
83
+ if (!ctx) {
84
+ reject(new Error('Failed to get canvas context'))
85
+ return
86
+ }
87
+ ctx.drawImage(img, 0, 0, width, height)
88
+
89
+ canvas.toBlob(
90
+ blob => {
91
+ if (!blob) {
92
+ reject(new Error('Failed to create blob'))
93
+ return
94
+ }
95
+ const resizedFile = new File([blob], file.name, {
96
+ type: 'image/jpeg',
97
+ lastModified: Date.now(),
98
+ })
99
+ resolve(resizedFile)
100
+ },
101
+ 'image/jpeg',
102
+ settings.compression
103
+ )
104
+ }
105
+
106
+ img.src = url
107
+ })
108
+ }
@@ -34,6 +34,37 @@ describe('ImagePickerProvider', () => {
34
34
  mockRequestPermission.mockResolvedValue({granted: true})
35
35
  // Clear interaction reporting mock
36
36
  mockReportInteraction.mockClear()
37
+
38
+ // Mock URL.createObjectURL and URL.revokeObjectURL for jsdom
39
+ global.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
40
+ global.URL.revokeObjectURL = vi.fn()
41
+
42
+ // Mock Image constructor for resizing
43
+ global.Image = class MockImage {
44
+ width = 1920
45
+ height = 1080
46
+ onload = null as any
47
+ onerror = null as any
48
+ src = ''
49
+
50
+ constructor() {
51
+ setTimeout(() => {
52
+ if (this.onload) {
53
+ this.onload()
54
+ }
55
+ }, 0)
56
+ }
57
+ } as any
58
+
59
+ // Mock canvas methods
60
+ HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
61
+ drawImage: vi.fn(),
62
+ })) as any
63
+
64
+ HTMLCanvasElement.prototype.toBlob = vi.fn(callback => {
65
+ const blob = new Blob(['mock'], {type: 'image/jpeg'})
66
+ callback(blob)
67
+ }) as any
37
68
  })
38
69
 
39
70
  afterEach(() => {
@@ -334,7 +365,7 @@ describe('ImagePickerProvider', () => {
334
365
  <button
335
366
  type="button"
336
367
  onClick={() =>
337
- openCamera('front').catch(() => {
368
+ openCamera({cameraFacing: 'front'}).catch(() => {
338
369
  // Ignore errors from cleanup
339
370
  })
340
371
  }