@shopify/shop-minis-react 0.0.34 → 0.0.36

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.
Files changed (63) hide show
  1. package/dist/_virtual/index2.js +4 -4
  2. package/dist/_virtual/index3.js +4 -4
  3. package/dist/components/atoms/alert-dialog.js.map +1 -1
  4. package/dist/components/atoms/icon-button.js +12 -12
  5. package/dist/components/atoms/icon-button.js.map +1 -1
  6. package/dist/components/atoms/text-input.js +22 -0
  7. package/dist/components/atoms/text-input.js.map +1 -0
  8. package/dist/components/commerce/merchant-card.js +1 -0
  9. package/dist/components/commerce/merchant-card.js.map +1 -1
  10. package/dist/components/ui/input.js +15 -9
  11. package/dist/components/ui/input.js.map +1 -1
  12. package/dist/hooks/util/useKeyboardAvoidingView.js +23 -0
  13. package/dist/hooks/util/useKeyboardAvoidingView.js.map +1 -0
  14. package/dist/hooks/util/useShare.js +7 -6
  15. package/dist/hooks/util/useShare.js.map +1 -1
  16. package/dist/index.js +228 -222
  17. package/dist/index.js.map +1 -1
  18. package/dist/mocks.js +17 -10
  19. package/dist/mocks.js.map +1 -1
  20. package/dist/shop-minis-platform/src/types/share.js +5 -0
  21. package/dist/shop-minis-platform/src/types/share.js.map +1 -0
  22. package/dist/shop-minis-react/node_modules/.pnpm/video.js@8.23.3/node_modules/video.js/dist/video.es.js +1 -1
  23. package/dist/utils/colors.js +1 -1
  24. package/dist/utils/image.js +5 -4
  25. package/dist/utils/image.js.map +1 -1
  26. package/package.json +21 -4
  27. package/src/components/atoms/alert-dialog.test.tsx +67 -0
  28. package/src/components/atoms/alert-dialog.tsx +13 -11
  29. package/src/components/atoms/favorite-button.test.tsx +56 -0
  30. package/src/components/atoms/icon-button.tsx +1 -1
  31. package/src/components/atoms/image.test.tsx +108 -0
  32. package/src/components/atoms/product-variant-price.test.tsx +128 -0
  33. package/src/components/atoms/text-input.test.tsx +104 -0
  34. package/src/components/atoms/text-input.tsx +31 -0
  35. package/src/components/commerce/merchant-card.test.tsx +261 -0
  36. package/src/components/commerce/merchant-card.tsx +2 -0
  37. package/src/components/commerce/product-card.test.tsx +364 -0
  38. package/src/components/commerce/product-link.test.tsx +483 -0
  39. package/src/components/commerce/quantity-selector.test.tsx +382 -0
  40. package/src/components/commerce/search.test.tsx +487 -0
  41. package/src/components/content/image-content-wrapper.test.tsx +92 -0
  42. package/src/components/index.ts +1 -0
  43. package/src/components/navigation/transition-link.test.tsx +155 -0
  44. package/src/components/ui/input.test.tsx +21 -0
  45. package/src/components/ui/input.tsx +10 -1
  46. package/src/hooks/content/useCreateImageContent.test.ts +352 -0
  47. package/src/hooks/index.ts +1 -0
  48. package/src/hooks/navigation/useNavigateWithTransition.test.ts +371 -0
  49. package/src/hooks/navigation/useViewTransitions.test.ts +469 -0
  50. package/src/hooks/product/useProductSearch.test.ts +470 -0
  51. package/src/hooks/storage/useAsyncStorage.test.ts +225 -0
  52. package/src/hooks/storage/useImageUpload.test.ts +322 -0
  53. package/src/hooks/util/useKeyboardAvoidingView.ts +37 -0
  54. package/src/hooks/util/useShare.ts +13 -3
  55. package/src/internal/useHandleAction.test.ts +265 -0
  56. package/src/internal/useShopActionsDataFetching.test.ts +465 -0
  57. package/src/mocks.ts +7 -1
  58. package/src/providers/ImagePickerProvider.test.tsx +467 -0
  59. package/src/stories/ProductCard.stories.tsx +2 -2
  60. package/src/stories/TextInput.stories.tsx +26 -0
  61. package/src/test-setup.ts +34 -0
  62. package/src/test-utils.tsx +167 -0
  63. package/src/utils/image.ts +1 -0
@@ -0,0 +1,467 @@
1
+ import {
2
+ render,
3
+ screen,
4
+ fireEvent,
5
+ renderHook,
6
+ act,
7
+ } from '@testing-library/react'
8
+ import {describe, expect, it, vi, beforeEach} from 'vitest'
9
+
10
+ import {ImagePickerProvider, useImagePickerContext} from './ImagePickerProvider'
11
+
12
+ describe('ImagePickerProvider', () => {
13
+ beforeEach(() => {
14
+ vi.clearAllMocks()
15
+ })
16
+
17
+ describe('Context', () => {
18
+ it('throws error when used outside of provider', () => {
19
+ // Silence console.error for this test
20
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
21
+
22
+ expect(() => {
23
+ renderHook(() => useImagePickerContext())
24
+ }).toThrow(
25
+ 'useImagePickerContext must be used within an ImagePickerProvider'
26
+ )
27
+
28
+ consoleSpy.mockRestore()
29
+ })
30
+
31
+ it('provides context value when used within provider', () => {
32
+ const {result} = renderHook(() => useImagePickerContext(), {
33
+ wrapper: ({children}) => (
34
+ <ImagePickerProvider>{children}</ImagePickerProvider>
35
+ ),
36
+ })
37
+
38
+ expect(result.current).toHaveProperty('openCamera')
39
+ expect(result.current).toHaveProperty('openGallery')
40
+ expect(typeof result.current.openCamera).toBe('function')
41
+ expect(typeof result.current.openGallery).toBe('function')
42
+ })
43
+ })
44
+
45
+ describe('Hidden Inputs', () => {
46
+ it('renders hidden file inputs', () => {
47
+ const {container} = render(
48
+ <ImagePickerProvider>
49
+ <div>Test Content</div>
50
+ </ImagePickerProvider>
51
+ )
52
+
53
+ const inputs = container.querySelectorAll('input[type="file"]')
54
+ expect(inputs).toHaveLength(3)
55
+
56
+ // Gallery input
57
+ const galleryInput = inputs[0]
58
+ expect(galleryInput).toHaveAttribute('accept', 'image/*')
59
+ expect(galleryInput).not.toHaveAttribute('capture')
60
+ expect(galleryInput).toHaveStyle({display: 'none'})
61
+
62
+ // Front camera input
63
+ const frontCameraInput = inputs[1]
64
+ expect(frontCameraInput).toHaveAttribute('accept', 'image/*')
65
+ expect(frontCameraInput).toHaveAttribute('capture', 'user')
66
+
67
+ // Back camera input
68
+ const backCameraInput = inputs[2]
69
+ expect(backCameraInput).toHaveAttribute('accept', 'image/*')
70
+ expect(backCameraInput).toHaveAttribute('capture', 'environment')
71
+ })
72
+ })
73
+
74
+ describe('openGallery', () => {
75
+ it('triggers gallery input click and resolves with selected file', async () => {
76
+ const TestComponent = () => {
77
+ const {openGallery} = useImagePickerContext()
78
+
79
+ return (
80
+ <button
81
+ type="button"
82
+ onClick={() =>
83
+ openGallery()
84
+ .then(file => {
85
+ const span = document.createElement('span')
86
+ span.textContent = file.name
87
+ span.setAttribute('data-testid', 'selected-file')
88
+ document.body.appendChild(span)
89
+ })
90
+ .catch(() => {
91
+ // Ignore errors from cleanup
92
+ })
93
+ }
94
+ >
95
+ Open Gallery
96
+ </button>
97
+ )
98
+ }
99
+
100
+ const {container} = render(
101
+ <ImagePickerProvider>
102
+ <TestComponent />
103
+ </ImagePickerProvider>
104
+ )
105
+
106
+ const galleryInput = container.querySelector(
107
+ 'input[type="file"]:not([capture])'
108
+ ) as HTMLInputElement
109
+ const clickSpy = vi.spyOn(galleryInput, 'click')
110
+
111
+ // Click the button to open gallery
112
+ const button = screen.getByText('Open Gallery')
113
+ fireEvent.click(button)
114
+
115
+ expect(clickSpy).toHaveBeenCalledTimes(1)
116
+
117
+ // Simulate file selection
118
+ const file = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
119
+ Object.defineProperty(galleryInput, 'files', {
120
+ value: [file],
121
+ configurable: true,
122
+ })
123
+
124
+ await act(async () => {
125
+ fireEvent.change(galleryInput)
126
+ })
127
+
128
+ // Check that the promise resolved with the file
129
+ await vi.waitFor(() => {
130
+ const selectedFile = document.querySelector(
131
+ '[data-testid="selected-file"]'
132
+ )
133
+ expect(selectedFile?.textContent).toBe('test.jpg')
134
+ })
135
+ })
136
+
137
+ it('handles cancel event', async () => {
138
+ const TestComponent = () => {
139
+ const {openGallery} = useImagePickerContext()
140
+
141
+ return (
142
+ <button
143
+ type="button"
144
+ onClick={() =>
145
+ openGallery().catch(error => {
146
+ const span = document.createElement('span')
147
+ span.textContent = error.message
148
+ span.setAttribute('data-testid', 'cancel-message')
149
+ document.body.appendChild(span)
150
+ })
151
+ }
152
+ >
153
+ Open Gallery
154
+ </button>
155
+ )
156
+ }
157
+
158
+ const {container} = render(
159
+ <ImagePickerProvider>
160
+ <TestComponent />
161
+ </ImagePickerProvider>
162
+ )
163
+
164
+ const galleryInput = container.querySelector(
165
+ 'input[type="file"]:not([capture])'
166
+ ) as HTMLInputElement
167
+
168
+ const button = screen.getByText('Open Gallery')
169
+ fireEvent.click(button)
170
+
171
+ // Simulate cancel event
172
+ await act(async () => {
173
+ const cancelEvent = new Event('cancel', {bubbles: true})
174
+ galleryInput.dispatchEvent(cancelEvent)
175
+ })
176
+
177
+ await vi.waitFor(() => {
178
+ const cancelMessage = document.querySelector(
179
+ '[data-testid="cancel-message"]'
180
+ )
181
+ expect(cancelMessage?.textContent).toBe('User cancelled file selection')
182
+ })
183
+ })
184
+ })
185
+
186
+ describe('openCamera', () => {
187
+ it('triggers back camera input click by default', async () => {
188
+ const TestComponent = () => {
189
+ const {openCamera} = useImagePickerContext()
190
+
191
+ return (
192
+ <button
193
+ type="button"
194
+ onClick={() =>
195
+ openCamera().catch(() => {
196
+ // Ignore errors from cleanup
197
+ })
198
+ }
199
+ >
200
+ Open Camera
201
+ </button>
202
+ )
203
+ }
204
+
205
+ const {container} = render(
206
+ <ImagePickerProvider>
207
+ <TestComponent />
208
+ </ImagePickerProvider>
209
+ )
210
+
211
+ const backCameraInput = container.querySelector(
212
+ 'input[capture="environment"]'
213
+ ) as HTMLInputElement
214
+ const clickSpy = vi.spyOn(backCameraInput, 'click')
215
+
216
+ const button = screen.getByText('Open Camera')
217
+ fireEvent.click(button)
218
+
219
+ expect(clickSpy).toHaveBeenCalledTimes(1)
220
+ })
221
+
222
+ it('triggers front camera input click when specified', async () => {
223
+ const TestComponent = () => {
224
+ const {openCamera} = useImagePickerContext()
225
+
226
+ return (
227
+ <button
228
+ type="button"
229
+ onClick={() =>
230
+ openCamera('front').catch(() => {
231
+ // Ignore errors from cleanup
232
+ })
233
+ }
234
+ >
235
+ Open Front Camera
236
+ </button>
237
+ )
238
+ }
239
+
240
+ const {container} = render(
241
+ <ImagePickerProvider>
242
+ <TestComponent />
243
+ </ImagePickerProvider>
244
+ )
245
+
246
+ const frontCameraInput = container.querySelector(
247
+ 'input[capture="user"]'
248
+ ) as HTMLInputElement
249
+ const clickSpy = vi.spyOn(frontCameraInput, 'click')
250
+
251
+ const button = screen.getByText('Open Front Camera')
252
+ fireEvent.click(button)
253
+
254
+ expect(clickSpy).toHaveBeenCalledTimes(1)
255
+ })
256
+
257
+ it('resolves with selected file from camera', async () => {
258
+ const TestComponent = () => {
259
+ const {openCamera} = useImagePickerContext()
260
+
261
+ return (
262
+ <button
263
+ type="button"
264
+ onClick={() =>
265
+ openCamera()
266
+ .then(file => {
267
+ const span = document.createElement('span')
268
+ span.textContent = file.name
269
+ span.setAttribute('data-testid', 'camera-file')
270
+ document.body.appendChild(span)
271
+ })
272
+ .catch(() => {
273
+ // Ignore errors from cleanup
274
+ })
275
+ }
276
+ >
277
+ Open Camera
278
+ </button>
279
+ )
280
+ }
281
+
282
+ const {container} = render(
283
+ <ImagePickerProvider>
284
+ <TestComponent />
285
+ </ImagePickerProvider>
286
+ )
287
+
288
+ const cameraInput = container.querySelector(
289
+ 'input[capture="environment"]'
290
+ ) as HTMLInputElement
291
+
292
+ const button = screen.getByText('Open Camera')
293
+ fireEvent.click(button)
294
+
295
+ // Simulate file capture
296
+ const file = new File(['photo'], 'photo.jpg', {type: 'image/jpeg'})
297
+ Object.defineProperty(cameraInput, 'files', {
298
+ value: [file],
299
+ configurable: true,
300
+ })
301
+
302
+ await act(async () => {
303
+ fireEvent.change(cameraInput)
304
+ })
305
+
306
+ await vi.waitFor(() => {
307
+ const cameraFile = document.querySelector('[data-testid="camera-file"]')
308
+ expect(cameraFile?.textContent).toBe('photo.jpg')
309
+ })
310
+ })
311
+
312
+ it('handles cancel event for camera', async () => {
313
+ const TestComponent = () => {
314
+ const {openCamera} = useImagePickerContext()
315
+
316
+ return (
317
+ <button
318
+ type="button"
319
+ onClick={() =>
320
+ openCamera().catch(error => {
321
+ const span = document.createElement('span')
322
+ span.textContent = error.message
323
+ span.setAttribute('data-testid', 'camera-cancel')
324
+ document.body.appendChild(span)
325
+ })
326
+ }
327
+ >
328
+ Open Camera
329
+ </button>
330
+ )
331
+ }
332
+
333
+ const {container} = render(
334
+ <ImagePickerProvider>
335
+ <TestComponent />
336
+ </ImagePickerProvider>
337
+ )
338
+
339
+ const cameraInput = container.querySelector(
340
+ 'input[capture="environment"]'
341
+ ) as HTMLInputElement
342
+
343
+ const button = screen.getByText('Open Camera')
344
+ fireEvent.click(button)
345
+
346
+ // Simulate cancel event
347
+ await act(async () => {
348
+ const cancelEvent = new Event('cancel', {bubbles: true})
349
+ cameraInput.dispatchEvent(cancelEvent)
350
+ })
351
+
352
+ await vi.waitFor(() => {
353
+ const cancelMessage = document.querySelector(
354
+ '[data-testid="camera-cancel"]'
355
+ )
356
+ expect(cancelMessage?.textContent).toBe('User cancelled camera')
357
+ })
358
+ })
359
+ })
360
+
361
+ describe('Multiple Picker Handling', () => {
362
+ it('rejects previous promise when new picker is opened', async () => {
363
+ const TestComponent = () => {
364
+ const {openCamera, openGallery} = useImagePickerContext()
365
+
366
+ return (
367
+ <>
368
+ <button
369
+ type="button"
370
+ onClick={() =>
371
+ openGallery().catch(error => {
372
+ const span = document.createElement('span')
373
+ span.textContent = error.message
374
+ span.setAttribute('data-testid', 'gallery-error')
375
+ document.body.appendChild(span)
376
+ })
377
+ }
378
+ >
379
+ Open Gallery
380
+ </button>
381
+ <button
382
+ type="button"
383
+ onClick={() =>
384
+ openCamera().catch(() => {
385
+ // Ignore errors from cleanup
386
+ })
387
+ }
388
+ >
389
+ Open Camera
390
+ </button>
391
+ </>
392
+ )
393
+ }
394
+
395
+ render(
396
+ <ImagePickerProvider>
397
+ <TestComponent />
398
+ </ImagePickerProvider>
399
+ )
400
+
401
+ // Open gallery first
402
+ const galleryButton = screen.getByText('Open Gallery')
403
+ fireEvent.click(galleryButton)
404
+
405
+ // Then immediately open camera
406
+ const cameraButton = screen.getByText('Open Camera')
407
+ fireEvent.click(cameraButton)
408
+
409
+ await vi.waitFor(() => {
410
+ const errorMessage = document.querySelector(
411
+ '[data-testid="gallery-error"]'
412
+ )
413
+ expect(errorMessage?.textContent).toBe(
414
+ 'New file picker opened before previous completed'
415
+ )
416
+ })
417
+ })
418
+ })
419
+
420
+ describe('Cleanup', () => {
421
+ it('clears input value after file selection', async () => {
422
+ const TestComponent = () => {
423
+ const {openGallery} = useImagePickerContext()
424
+
425
+ return (
426
+ <button
427
+ type="button"
428
+ onClick={() =>
429
+ openGallery().catch(() => {
430
+ // Ignore errors from cleanup
431
+ })
432
+ }
433
+ >
434
+ Open Gallery
435
+ </button>
436
+ )
437
+ }
438
+
439
+ const {container} = render(
440
+ <ImagePickerProvider>
441
+ <TestComponent />
442
+ </ImagePickerProvider>
443
+ )
444
+
445
+ const galleryInput = container.querySelector(
446
+ 'input[type="file"]:not([capture])'
447
+ ) as HTMLInputElement
448
+
449
+ const button = screen.getByText('Open Gallery')
450
+ fireEvent.click(button)
451
+
452
+ // Set file and trigger change
453
+ const file = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
454
+ Object.defineProperty(galleryInput, 'files', {
455
+ value: [file],
456
+ configurable: true,
457
+ })
458
+
459
+ await act(async () => {
460
+ fireEvent.change(galleryInput)
461
+ })
462
+
463
+ // Check that input value was cleared
464
+ expect(galleryInput.value).toBe('')
465
+ })
466
+ })
467
+ })
@@ -36,7 +36,7 @@ injectMocks()
36
36
  export const Single: Story = {
37
37
  decorators: [
38
38
  Story => (
39
- <div style={{maxWidth: 200}}>
39
+ <div style={{maxWidth: 150}}>
40
40
  <Story />
41
41
  </div>
42
42
  ),
@@ -69,7 +69,7 @@ export const Single: Story = {
69
69
 
70
70
  featuredImage: {
71
71
  url: 'https://cdn.shopify.com/static/sample-images/teapot.jpg',
72
- altText: 'Product 1',
72
+ altText: 'Teapot',
73
73
  },
74
74
  },
75
75
  },
@@ -0,0 +1,26 @@
1
+ import {fn} from 'storybook/test'
2
+
3
+ import {TextInput} from '../components'
4
+
5
+ import type {Meta, StoryObj} from '@storybook/react-vite'
6
+
7
+ const meta = {
8
+ title: 'Atoms/TextInput',
9
+ component: TextInput,
10
+ parameters: {
11
+ layout: 'padded',
12
+ },
13
+ tags: ['autodocs'],
14
+ } satisfies Meta<typeof TextInput>
15
+
16
+ export default meta
17
+ type Story = StoryObj<typeof meta>
18
+
19
+ export const Default: Story = {
20
+ args: {
21
+ placeholder: 'Search...',
22
+ onFocus: fn(),
23
+ onBlur: fn(),
24
+ onChange: fn(),
25
+ },
26
+ }
@@ -0,0 +1,34 @@
1
+ import '@testing-library/jest-dom'
2
+ import {cleanup} from '@testing-library/react'
3
+ import {afterEach, vi} from 'vitest'
4
+
5
+ // Clean up after each test to prevent test pollution
6
+ afterEach(() => {
7
+ cleanup()
8
+ })
9
+
10
+ // Mock window.matchMedia if not available (for components using media queries)
11
+ Object.defineProperty(window, 'matchMedia', {
12
+ writable: true,
13
+ value: vi.fn().mockImplementation((query: string) => ({
14
+ matches: false,
15
+ media: query,
16
+ onchange: null,
17
+ addListener: vi.fn(), // deprecated
18
+ removeListener: vi.fn(), // deprecated
19
+ addEventListener: vi.fn(),
20
+ removeEventListener: vi.fn(),
21
+ dispatchEvent: vi.fn(),
22
+ })),
23
+ })
24
+
25
+ // Mock IntersectionObserver (used by List component)
26
+ global.IntersectionObserver = vi.fn().mockImplementation(() => ({
27
+ observe: vi.fn(),
28
+ unobserve: vi.fn(),
29
+ disconnect: vi.fn(),
30
+ root: null,
31
+ rootMargin: '',
32
+ thresholds: [],
33
+ takeRecords: vi.fn(),
34
+ }))
@@ -0,0 +1,167 @@
1
+ import React from 'react'
2
+
3
+ import {
4
+ render,
5
+ type RenderOptions,
6
+ type RenderResult,
7
+ } from '@testing-library/react'
8
+ import {vi} from 'vitest'
9
+
10
+ import {createProduct, createShop} from './mocks'
11
+
12
+ import type {
13
+ Product,
14
+ ProductVariant,
15
+ Shop,
16
+ ProductImage,
17
+ Money,
18
+ } from '@shopify/shop-minis-platform'
19
+
20
+ // Create spyable mock SDK functions
21
+ const createSpyableMockSDK = () => {
22
+ return {
23
+ navigateToProduct: vi.fn(),
24
+ navigateToShop: vi.fn(),
25
+ saveProduct: vi.fn().mockResolvedValue({ok: true, data: undefined}),
26
+ unsaveProduct: vi.fn().mockResolvedValue({ok: true, data: undefined}),
27
+ favorite: vi.fn().mockResolvedValue({ok: true, data: undefined}),
28
+ unfavorite: vi.fn().mockResolvedValue({ok: true, data: undefined}),
29
+ followShop: vi.fn().mockResolvedValue({ok: true, data: true}),
30
+ unfollowShop: vi.fn().mockResolvedValue({ok: true, data: false}),
31
+ buyProduct: vi.fn(),
32
+ buyProducts: vi.fn(),
33
+ addToCart: vi.fn(),
34
+ getProduct: vi.fn(),
35
+ getProducts: vi.fn(),
36
+ getProductSearch: vi.fn(),
37
+ getRecommendedProducts: vi.fn(),
38
+ getPopularProducts: vi.fn(),
39
+ getSavedProducts: vi.fn(),
40
+ getRecentProducts: vi.fn(),
41
+ getCuratedProducts: vi.fn(),
42
+ share: vi.fn(),
43
+ closeMini: vi.fn(),
44
+ showErrorScreen: vi.fn(),
45
+ showErrorToast: vi.fn(),
46
+ getAccountInformation: vi.fn(),
47
+ getPersistedItem: vi.fn(),
48
+ setPersistedItem: vi.fn(),
49
+ removePersistedItem: vi.fn(),
50
+ clearPersistedItems: vi.fn(),
51
+ } as any
52
+ }
53
+
54
+ // Mock window.minisSDK for tests
55
+ export const mockMinisSDK = createSpyableMockSDK()
56
+
57
+ // Setup minisSDK mock globally
58
+ if (typeof window !== 'undefined') {
59
+ ;(window as any).minisSDK = mockMinisSDK
60
+ }
61
+
62
+ // Custom render with providers if needed
63
+ export function renderWithProviders(
64
+ ui: React.ReactElement,
65
+ options?: Omit<RenderOptions, 'wrapper'>
66
+ ): RenderResult {
67
+ return render(ui, {
68
+ ...options,
69
+ })
70
+ }
71
+
72
+ // Re-export mock factories from mocks.ts with backwards compatibility
73
+ export const mockProduct = (overrides: Partial<Product> = {}): Product => {
74
+ const baseProduct = createProduct(
75
+ overrides.id || 'product-1',
76
+ overrides.title || 'Test Product',
77
+ overrides.price?.amount || '99.99',
78
+ overrides.compareAtPrice?.amount
79
+ )
80
+ return {
81
+ ...baseProduct,
82
+ ...overrides,
83
+ }
84
+ }
85
+
86
+ export const mockShop = (overrides: Partial<Shop> = {}): Shop => {
87
+ const baseShop = createShop(
88
+ overrides.id || 'shop-1',
89
+ overrides.name || 'Test Shop'
90
+ )
91
+ return {
92
+ ...baseShop,
93
+ ...overrides,
94
+ }
95
+ }
96
+
97
+ // Helper to create multiple products
98
+ export const mockProducts = (count = 5): Product[] => {
99
+ return Array.from({length: count}, (_, i) =>
100
+ createProduct(
101
+ `product-${i + 1}`,
102
+ `Test Product ${i + 1}`,
103
+ `${(i + 1) * 50}.00`
104
+ )
105
+ )
106
+ }
107
+
108
+ // Export commonly used mock data helpers for backwards compatibility
109
+ export const mockMoney = (amount = '29.99', currencyCode = 'USD'): Money => ({
110
+ amount,
111
+ currencyCode: currencyCode as any,
112
+ })
113
+
114
+ export const mockProductImage = (
115
+ overrides: Partial<ProductImage> = {}
116
+ ): ProductImage => ({
117
+ url: 'https://example.com/product-image.jpg',
118
+ altText: 'Product image',
119
+ width: 1000,
120
+ height: 1000,
121
+ sensitive: false,
122
+ thumbhash: 'someThumbhash',
123
+ ...overrides,
124
+ })
125
+
126
+ export const mockProductVariant = (
127
+ overrides: Partial<ProductVariant> = {}
128
+ ): ProductVariant => ({
129
+ id: 'variant-1',
130
+ isFavorited: false,
131
+ price: mockMoney('29.99', 'USD'),
132
+ compareAtPrice: mockMoney('39.99', 'USD'),
133
+ image: mockProductImage(),
134
+ ...overrides,
135
+ })
136
+
137
+ // Helper to wait for async updates
138
+ export const waitForAsync = () => new Promise(resolve => setTimeout(resolve, 0))
139
+
140
+ // Common test assertions for accessibility
141
+ export const expectToBeAccessible = (element: HTMLElement) => {
142
+ // Check for basic accessibility attributes
143
+ const role = element.getAttribute('role')
144
+ const ariaLabel = element.getAttribute('aria-label')
145
+ const ariaLabelledBy = element.getAttribute('aria-labelledby')
146
+
147
+ // At least one of these should be present for interactive elements
148
+ if (element.tagName === 'BUTTON' || element.onclick) {
149
+ const hasAccessibility = Boolean(
150
+ role || ariaLabel || ariaLabelledBy || element.textContent?.trim()
151
+ )
152
+ expect(hasAccessibility).toBe(true)
153
+ }
154
+ }
155
+
156
+ // Reset all mocks
157
+ export const resetAllMocks = () => {
158
+ Object.values(mockMinisSDK).forEach(mock => {
159
+ if (typeof mock === 'function' && 'mockReset' in mock) {
160
+ ;(mock as any).mockReset()
161
+ }
162
+ })
163
+ }
164
+
165
+ // Export everything from testing library for convenience
166
+ export * from '@testing-library/react'
167
+ export {default as userEvent} from '@testing-library/user-event'