@dfosco/storyboard-react 4.0.0-beta.35 → 4.0.0-beta.37

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.
@@ -1,235 +0,0 @@
1
- .container {
2
- min-height: 100vh;
3
- background-color: var(--bgColor-default, #0d1117);
4
- color: var(--fgColor-default, #e6edf3);
5
- padding: 80px 32px 48px;
6
- }
7
-
8
- .header {
9
- max-width: 720px;
10
- margin: 0 auto 64px;
11
- }
12
-
13
- .title {
14
- font-size: 72px;
15
- font-weight: 400;
16
- margin: 0 0 12px;
17
- color: var(--fgColor-default, #e6edf3);
18
- letter-spacing: -0.03em;
19
- line-height: 1;
20
- }
21
-
22
- .subtitle {
23
- font-size: 15px;
24
- color: var(--fgColor-muted, #848d97);
25
- margin: 4px 0 0;
26
- letter-spacing: 0.01em;
27
- }
28
-
29
- .sceneCount {
30
- font-size: 13px;
31
- color: var(--fgColor-muted, #848d97);
32
- margin: 16px 0 0;
33
- letter-spacing: 0.01em;
34
- }
35
-
36
- .grid {
37
- display: grid;
38
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
39
- gap: 16px;
40
- max-width: 720px;
41
- margin: 0 auto;
42
- }
43
-
44
- .list {
45
- display: flex;
46
- flex-direction: column;
47
- max-width: 720px;
48
- margin: 0 auto;
49
- }
50
-
51
- .listItem {
52
- display: block;
53
- padding: 8px 0;
54
- text-decoration: none;
55
- color: inherit;
56
- /* border-bottom: 1px solid var(--borderColor-muted, #21262d); */
57
- }
58
-
59
- .listItem:first-child {
60
- /* border-top: 1px solid var(--borderColor-muted, #21262d); */
61
- }
62
-
63
- .listItem:hover {
64
- text-decoration: none !important;
65
- }
66
-
67
- .listItem .author {
68
- margin-top: 8px;
69
- }
70
-
71
- .card {
72
- display: block;
73
- border: 1px solid var(--borderColor-default, #30363d);
74
- border-radius: 8px;
75
- overflow: hidden;
76
- background: var(--bgColor-muted, #161b22);
77
- text-decoration: none;
78
- color: inherit;
79
- transition: border-color 0.15s ease, box-shadow 0.15s ease;
80
- }
81
-
82
- .card:hover {
83
- border-color: var(--borderColor-accent-emphasis, #1f6feb);
84
- box-shadow: 0 0 0 1px var(--borderColor-accent-emphasis, #1f6feb);
85
- text-decoration: none !important;
86
- }
87
-
88
- .thumbnail {
89
- aspect-ratio: 16 / 10;
90
- display: flex;
91
- align-items: center;
92
- justify-content: center;
93
- overflow: hidden;
94
- background: var(--bgColor-inset, #010409);
95
-
96
- --placeholder-bg: var(--bgColor-inset, #010409);
97
- --placeholder-grid: var(--borderColor-default, #30363d);
98
- --placeholder-accent: var(--fgColor-accent, #58a6ff);
99
- --placeholder-fg: var(--fgColor-default, #c9d1d9);
100
- --placeholder-muted: var(--fgColor-muted, #484f58);
101
- }
102
-
103
- .thumbnail svg {
104
- width: 100%;
105
- height: 100%;
106
- }
107
-
108
- .cardBody {
109
- padding: 12px 16px;
110
-
111
- &:hover {
112
- background-color: var(--bgColor-muted);
113
- }
114
- }
115
-
116
- .sceneName {
117
- font-size: 28px;
118
- font-weight: 400;
119
- color: var(--fgColor-default, #e6edf3);
120
- margin: 0;
121
- letter-spacing: -0.02em;
122
- line-height: 1.2;
123
- transition: font-style 0.15s ease;
124
- }
125
-
126
- .empty {
127
- text-align: center;
128
- padding: 80px 24px;
129
- color: var(--fgColor-muted, #848d97);
130
- font-size: 15px;
131
- max-width: 720px;
132
- margin: 0 auto;
133
- }
134
-
135
- .sectionTitle {
136
- font-size: 11px;
137
- font-weight: 700;
138
- text-transform: uppercase;
139
- letter-spacing: 0.12em;
140
- color: var(--fgColor-muted, #848d97);
141
- margin: 0 auto 20px;
142
- max-width: 720px;
143
- }
144
-
145
- .headerTop {
146
- display: flex;
147
- align-items: baseline;
148
- justify-content: space-between;
149
- gap: 16px;
150
- }
151
-
152
- .branchDropdown {
153
- display: flex;
154
- align-items: center;
155
- gap: 0;
156
- flex-shrink: 0;
157
- position: relative;
158
- }
159
-
160
- .branchIcon {
161
- position: absolute;
162
- left: 10px;
163
- color: var(--fgColor-muted, #848d97);
164
- pointer-events: none;
165
- z-index: 1;
166
- }
167
-
168
- .branchSelect {
169
- appearance: none;
170
- background-color: transparent;
171
- color: var(--fgColor-default, #e6edf3);
172
- border: 1px solid var(--borderColor-default, #30363d);
173
- border-radius: 20px;
174
- padding: 6px 32px 6px 32px;
175
- font-size: 13px;
176
- font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
177
- cursor: pointer;
178
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23848d97'%3E%3Cpath d='M6 8.5L1.5 4h9L6 8.5z'/%3E%3C/svg%3E");
179
- background-repeat: no-repeat;
180
- background-position: right 12px center;
181
- min-width: 140px;
182
- max-width: 220px;
183
- text-overflow: ellipsis;
184
- overflow: hidden;
185
- transition: border-color 0.15s ease;
186
- }
187
-
188
- .branchSelect:hover {
189
- border-color: var(--fgColor-muted, #848d97);
190
- }
191
-
192
- .branchSelect:focus-visible {
193
- outline: 2px solid var(--borderColor-accent-emphasis, #1f6feb);
194
- outline-offset: -1px;
195
- }
196
-
197
- .author {
198
- display: flex;
199
- align-items: center;
200
- gap: 8px;
201
- margin-top: 6px;
202
- }
203
-
204
- .authorAvatars {
205
- display: flex;
206
- flex-direction: row;
207
- }
208
-
209
- .authorAvatars:hover .authorAvatar {
210
- margin-left: 2px;
211
- }
212
-
213
- .authorAvatars:hover .authorAvatar:first-child {
214
- margin-left: 0;
215
- }
216
-
217
- .authorAvatar {
218
- width: 18px;
219
- height: 18px;
220
- border-radius: 50%;
221
- margin-left: -6px;
222
- transition: margin-left 0.15s ease;
223
- outline: 2px solid var(--bgColor-default, #0d1117);
224
- position: relative;
225
- }
226
-
227
- .authorAvatar:first-child {
228
- margin-left: 0;
229
- }
230
-
231
- .authorName {
232
- font-size: 13px;
233
- color: var(--fgColor-muted, #848d97);
234
- letter-spacing: 0.01em;
235
- }
@@ -1,111 +0,0 @@
1
- /**
2
- * Concurrent refresh queue for bulk snapshot recapture (e.g. on theme change).
3
- *
4
- * Captures run in parallel (up to MAX_CONCURRENT) for speed, but REVEALS are
5
- * staggered on a fixed timeline — widget 0 reveals at 0ms, widget 1 at
6
- * REVEAL_INTERVAL ms, widget 2 at 2×REVEAL_INTERVAL ms, etc., all relative to
7
- * batch start. This creates a clean, predictable wave sweep regardless of how
8
- * fast each capture completes.
9
- *
10
- * After a batch completes, any widgets that failed are re-enqueued for a
11
- * single retry pass.
12
- *
13
- * Sorted spatially (top-to-bottom, left-to-right) before assigning reveal slots.
14
- * Supports cancellation by widget ID.
15
- */
16
- const queue = []
17
- let running = 0
18
- let drainScheduled = false
19
- let batchTotal = 0
20
- let batchDone = 0
21
- const batchFailed = []
22
-
23
- const MAX_CONCURRENT = 4
24
- export const REVEAL_INTERVAL = 200
25
-
26
- /**
27
- * Enqueue a snapshot refresh task for a widget.
28
- * @param {string} widgetId — unique widget identifier (for cancellation)
29
- * @param {(meta: { revealOrder: number, batchStart: number }) => Promise<boolean>} fn
30
- * Must resolve to `true` on success, `false` on failure.
31
- * @param {{ x: number, y: number }} [pos] — spatial position for wave ordering
32
- */
33
- export function enqueueRefresh(widgetId, fn, pos) {
34
- console.log(`[refreshQueue] enqueue: ${widgetId}, queueLen=${queue.length}`)
35
- const existing = queue.findIndex(item => item.widgetId === widgetId)
36
- if (existing !== -1) queue.splice(existing, 1)
37
-
38
- queue.push({ widgetId, fn, x: pos?.x ?? 0, y: pos?.y ?? 0 })
39
- scheduleDrain()
40
- }
41
-
42
- /**
43
- * Cancel a pending refresh for a widget (e.g. user activated it manually).
44
- */
45
- export function cancelRefresh(widgetId) {
46
- const idx = queue.findIndex(item => item.widgetId === widgetId)
47
- if (idx !== -1) queue.splice(idx, 1)
48
- }
49
-
50
- function scheduleDrain() {
51
- if (drainScheduled) return
52
- drainScheduled = true
53
- // Batch all enqueueRefresh calls from the same React commit, then sort
54
- // spatially and assign reveal slots before starting captures.
55
- setTimeout(() => {
56
- drainScheduled = false
57
- queue.sort((a, b) => a.y - b.y || a.x - b.x)
58
- const batchStart = Date.now()
59
- batchTotal = queue.length
60
- batchDone = 0
61
- batchFailed.length = 0
62
- queue.forEach((item, i) => {
63
- item.revealOrder = i
64
- item.batchStart = batchStart
65
- item.isRetry = item.isRetry || false
66
- })
67
- drain()
68
- }, 0)
69
- }
70
-
71
- function onTaskDone(success, item) {
72
- batchDone++
73
- console.log(`[refreshQueue] taskDone: ${item.widgetId}, success=${success}, done=${batchDone}/${batchTotal}, retry=${item.isRetry}`)
74
- if (!success && !item.isRetry) {
75
- batchFailed.push(item)
76
- }
77
- // When batch is complete, re-enqueue failures for one retry
78
- if (batchDone >= batchTotal && batchFailed.length > 0) {
79
- console.log(`[refreshQueue] batch complete, retrying ${batchFailed.length} failed`)
80
- const retries = batchFailed.splice(0)
81
- for (const failed of retries) {
82
- failed.isRetry = true
83
- queue.push(failed)
84
- }
85
- batchTotal = queue.length
86
- batchDone = 0
87
- const batchStart = Date.now()
88
- queue.forEach((item, i) => {
89
- item.revealOrder = i
90
- item.batchStart = batchStart
91
- })
92
- }
93
- drain()
94
- }
95
-
96
- function drain() {
97
- if (running >= MAX_CONCURRENT || queue.length === 0) return
98
-
99
- running++
100
- const item = queue.shift()
101
- const { fn, revealOrder, batchStart } = item
102
- Promise.resolve()
103
- .then(() => fn({ revealOrder, batchStart }))
104
- .then((success) => { running--; onTaskDone(success !== false, item) })
105
- .catch(() => { running--; onTaskDone(false, item) })
106
-
107
- // Start next capture immediately (no stagger on capture start — only reveals are staggered)
108
- if (queue.length > 0 && running < MAX_CONCURRENT) {
109
- drain()
110
- }
111
- }
@@ -1,161 +0,0 @@
1
- /**
2
- * useSnapshotCapture — parent-side capture orchestration hook.
3
- *
4
- * Listens for snapshot-ready signals from an embedded iframe and
5
- * provides a requestCapture() function that triggers a single capture
6
- * of whatever the iframe is currently showing.
7
- *
8
- * Saves a single `snapshot` prop — overwritten every time.
9
- * Only active in dev mode (when onUpdate is provided).
10
- */
11
- import { useState, useEffect, useCallback, useRef } from 'react'
12
- import { uploadImage } from '../canvasApi.js'
13
-
14
- const CAPTURE_TIMEOUT = 3000
15
-
16
- /**
17
- * Run a single capture request against the iframe.
18
- * Returns the dataUrl or null on failure.
19
- */
20
- function captureOnce(iframeContentWindow, requestId, listeners) {
21
- return new Promise((resolve) => {
22
- const timer = setTimeout(() => {
23
- cleanup()
24
- resolve(null)
25
- }, CAPTURE_TIMEOUT)
26
-
27
- function cleanup() {
28
- clearTimeout(timer)
29
- const idx = listeners.indexOf(handler)
30
- if (idx !== -1) listeners.splice(idx, 1)
31
- }
32
-
33
- function handler(data) {
34
- if (data.requestId !== requestId) return
35
- cleanup()
36
- if (data.error || !data.dataUrl) {
37
- if (data.error) console.warn('[snapshot] Capture failed:', data.error)
38
- resolve(null)
39
- } else {
40
- resolve(data.dataUrl)
41
- }
42
- }
43
-
44
- listeners.push(handler)
45
- iframeContentWindow.postMessage({
46
- type: 'storyboard:embed:capture',
47
- requestId,
48
- }, '*')
49
- })
50
- }
51
-
52
- export function useSnapshotCapture({
53
- iframeRef,
54
- widgetId,
55
- onUpdate,
56
- showIframe,
57
- }) {
58
- const [iframeReady, setIframeReady] = useState(false)
59
- const iframeReadyRef = useRef(false)
60
- const capturingRef = useRef(false)
61
- const requestIdCounter = useRef(0)
62
- const captureGeneration = useRef(0)
63
- const responseHandlers = useRef([])
64
- // Track the iframe contentWindow to reset readiness on remount
65
- const lastContentWindowRef = useRef(null)
66
-
67
- // Reset ready state when iframe is unmounted/remounted
68
- useEffect(() => {
69
- setIframeReady(false)
70
- iframeReadyRef.current = false
71
- }, [widgetId])
72
-
73
- // Reset readiness when iframe is torn down so remount waits for new snapshot-ready
74
- useEffect(() => {
75
- if (!showIframe) {
76
- setIframeReady(false)
77
- iframeReadyRef.current = false
78
- lastContentWindowRef.current = null
79
- }
80
- }, [showIframe])
81
-
82
- // Listen for postMessage events from the embedded iframe
83
- useEffect(() => {
84
- if (!onUpdate) return
85
-
86
- function handler(e) {
87
- if (!iframeRef.current) return
88
- if (e.source !== iframeRef.current.contentWindow) return
89
-
90
- // Detect new iframe instance → reset readiness
91
- if (e.source !== lastContentWindowRef.current) {
92
- lastContentWindowRef.current = e.source
93
- setIframeReady(false)
94
- iframeReadyRef.current = false
95
- }
96
-
97
- if (e.data?.type === 'storyboard:embed:snapshot-ready') {
98
- console.log(`[snapshot:${widgetId}] iframe ready`)
99
- setIframeReady(true)
100
- iframeReadyRef.current = true
101
- }
102
-
103
- if (e.data?.type === 'storyboard:embed:snapshot') {
104
- for (const fn of responseHandlers.current) {
105
- fn(e.data)
106
- }
107
- }
108
- }
109
-
110
- window.addEventListener('message', handler)
111
- return () => window.removeEventListener('message', handler)
112
- }, [iframeRef, onUpdate])
113
-
114
- /**
115
- * Capture a single snapshot of the current iframe state.
116
- * Uploads and saves as `snapshot` prop, overwriting any previous value.
117
- */
118
- const requestCapture = useCallback(async ({ force = false } = {}) => {
119
- console.log(`[snapshot:${widgetId}] requestCapture: force=${force}, hasContentWindow=${!!iframeRef.current?.contentWindow}, capturing=${capturingRef.current}, ready=${iframeReadyRef.current}`)
120
- if (!onUpdate) return {}
121
- if (!iframeRef.current?.contentWindow) { console.log(`[snapshot:${widgetId}] requestCapture: no contentWindow`); return {} }
122
- if (capturingRef.current) { console.log(`[snapshot:${widgetId}] requestCapture: already capturing`); return {} }
123
- if (!force && !iframeReadyRef.current) { console.log(`[snapshot:${widgetId}] requestCapture: not ready`); return {} }
124
-
125
- capturingRef.current = true
126
- const gen = ++captureGeneration.current
127
- const cw = iframeRef.current.contentWindow
128
- const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
129
-
130
- try {
131
- const reqId = ++requestIdCounter.current
132
- const dataUrl = await captureOnce(cw, reqId, responseHandlers.current)
133
-
134
- if (gen !== captureGeneration.current) { console.log(`[snapshot:${widgetId}] stale gen after capture`); return {} }
135
- if (!dataUrl) { console.log(`[snapshot:${widgetId}] captureOnce returned null`); return {} }
136
-
137
- const filename = `snapshot-${widgetId}.webp`
138
- console.log(`[snapshot:${widgetId}] uploading ${filename}`)
139
- const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
140
-
141
- if (gen !== captureGeneration.current) { console.log(`[snapshot:${widgetId}] stale gen after upload`); return {} }
142
-
143
- if (result?.filename) {
144
- const cacheBust = `?v=${Date.now()}`
145
- const url = `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
146
- const updates = { snapshot: url }
147
- console.log(`[snapshot:${widgetId}] saved: ${url.slice(0, 60)}`)
148
- onUpdate?.(updates)
149
- return updates
150
- }
151
- return {}
152
- } catch (err) {
153
- console.warn('[snapshot] Capture failed:', err)
154
- return {}
155
- } finally {
156
- capturingRef.current = false
157
- }
158
- }, [onUpdate, iframeRef, widgetId])
159
-
160
- return { iframeReady, requestCapture }
161
- }
@@ -1,164 +0,0 @@
1
- /**
2
- * Tests for useSnapshotCapture hook — single-capture orchestration.
3
- */
4
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
- import { renderHook, act } from '@testing-library/react'
6
- import { useSnapshotCapture } from './useSnapshotCapture.js'
7
-
8
- vi.mock('../canvasApi.js', () => ({
9
- uploadImage: vi.fn().mockResolvedValue({ filename: 'snapshot-test-widget.webp' }),
10
- }))
11
-
12
- import { uploadImage } from '../canvasApi.js'
13
-
14
- function createMockIframeRef(contentWindow = null) {
15
- return { current: contentWindow ? { contentWindow } : null }
16
- }
17
-
18
- function createMockContentWindow() {
19
- return { postMessage: vi.fn() }
20
- }
21
-
22
- describe('useSnapshotCapture', () => {
23
- let listeners = []
24
-
25
- beforeEach(() => {
26
- vi.clearAllMocks()
27
- listeners = []
28
- const origAdd = window.addEventListener
29
- const origRemove = window.removeEventListener
30
- vi.spyOn(window, 'addEventListener').mockImplementation((type, fn, opts) => {
31
- if (type === 'message') listeners.push(fn)
32
- origAdd.call(window, type, fn, opts)
33
- })
34
- vi.spyOn(window, 'removeEventListener').mockImplementation((type, fn, opts) => {
35
- if (type === 'message') listeners = listeners.filter(l => l !== fn)
36
- origRemove.call(window, type, fn, opts)
37
- })
38
- })
39
-
40
- afterEach(() => { vi.restoreAllMocks() })
41
-
42
- function dispatchMessage(source, data) {
43
- const event = new MessageEvent('message', { source, data })
44
- listeners.forEach(fn => fn(event))
45
- }
46
-
47
- it('returns iframeReady=false initially', () => {
48
- const iframeRef = createMockIframeRef()
49
- const { result } = renderHook(() =>
50
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
51
- )
52
- expect(result.current.iframeReady).toBe(false)
53
- })
54
-
55
- it('sets iframeReady=true on snapshot-ready message', () => {
56
- const cw = createMockContentWindow()
57
- const iframeRef = createMockIframeRef(cw)
58
- const { result } = renderHook(() =>
59
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
60
- )
61
- act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
62
- expect(result.current.iframeReady).toBe(true)
63
- })
64
-
65
- it('no-ops when onUpdate is null', async () => {
66
- const cw = createMockContentWindow()
67
- const iframeRef = createMockIframeRef(cw)
68
- const { result } = renderHook(() =>
69
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: null })
70
- )
71
- await act(async () => { await result.current.requestCapture() })
72
- expect(cw.postMessage).not.toHaveBeenCalled()
73
- })
74
-
75
- it('no-ops when iframe not ready', async () => {
76
- const cw = createMockContentWindow()
77
- const iframeRef = createMockIframeRef(cw)
78
- const { result } = renderHook(() =>
79
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
80
- )
81
- await act(async () => { await result.current.requestCapture() })
82
- expect(cw.postMessage).not.toHaveBeenCalled()
83
- })
84
-
85
- it('sends single capture and calls onUpdate with snapshot', async () => {
86
- const cw = createMockContentWindow()
87
- const iframeRef = createMockIframeRef(cw)
88
- const onUpdate = vi.fn()
89
- const { result } = renderHook(() =>
90
- useSnapshotCapture({ iframeRef, widgetId: 'test-widget', onUpdate })
91
- )
92
-
93
- act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
94
-
95
- uploadImage.mockResolvedValueOnce({ filename: 'snapshot-test-widget.webp' })
96
-
97
- await act(async () => {
98
- const promise = result.current.requestCapture()
99
-
100
- await new Promise(r => setTimeout(r, 10))
101
- dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,IMG' })
102
-
103
- await promise
104
- })
105
-
106
- // Single capture, single postMessage
107
- expect(cw.postMessage).toHaveBeenCalledTimes(1)
108
- expect(uploadImage).toHaveBeenCalledTimes(1)
109
- expect(onUpdate).toHaveBeenCalledWith(
110
- expect.objectContaining({
111
- snapshot: expect.stringContaining('snapshot-test-widget.webp'),
112
- })
113
- )
114
- })
115
-
116
- it('guards against concurrent captures', async () => {
117
- const cw = createMockContentWindow()
118
- const iframeRef = createMockIframeRef(cw)
119
- const onUpdate = vi.fn()
120
- const { result } = renderHook(() =>
121
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate })
122
- )
123
-
124
- act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
125
- uploadImage.mockResolvedValue({ filename: 'snapshot-w1.webp' })
126
-
127
- await act(async () => {
128
- const p1 = result.current.requestCapture()
129
- // Second call while first is in-flight should no-op
130
- const p2 = result.current.requestCapture()
131
-
132
- await new Promise(r => setTimeout(r, 10))
133
- dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,IMG' })
134
-
135
- await p1
136
- await p2
137
- })
138
-
139
- expect(cw.postMessage).toHaveBeenCalledTimes(1)
140
- })
141
-
142
- it('handles capture failure gracefully', async () => {
143
- const cw = createMockContentWindow()
144
- const iframeRef = createMockIframeRef(cw)
145
- const onUpdate = vi.fn()
146
- const { result } = renderHook(() =>
147
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate })
148
- )
149
-
150
- act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
151
- uploadImage.mockRejectedValueOnce(new Error('upload failed'))
152
-
153
- await act(async () => {
154
- const promise = result.current.requestCapture()
155
-
156
- await new Promise(r => setTimeout(r, 10))
157
- dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FAIL' })
158
-
159
- await promise
160
- })
161
-
162
- expect(onUpdate).not.toHaveBeenCalled()
163
- })
164
- })