@asteby/metacore-runtime-react 23.7.1 → 23.9.0

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,210 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // useStageLayout (per-org kanban lane-order persistence) + its non-intrusive
4
+ // gating in DynamicKanban: the reset affordance only shows when a custom order
5
+ // exists, and a missing `/stage-layout` endpoint leaves the board fully intact.
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import {
8
+ cleanup,
9
+ fireEvent,
10
+ render,
11
+ renderHook,
12
+ screen,
13
+ waitFor,
14
+ } from '@testing-library/react'
15
+ import React from 'react'
16
+
17
+ const I18N_T = (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k
18
+ const I18N = { language: 'es' }
19
+ const USE_TRANSLATION = { t: I18N_T, i18n: I18N }
20
+ vi.mock('react-i18next', () => ({
21
+ useTranslation: () => USE_TRANSLATION,
22
+ }))
23
+ vi.mock('@tanstack/react-router', () => ({
24
+ useNavigate: () => () => {},
25
+ }))
26
+
27
+ import { useStageLayout } from '../stage-layout'
28
+ import { DynamicKanban } from '../dynamic-kanban'
29
+ import { ApiProvider, type ApiClient } from '../api-context'
30
+ import { useMetadataCache } from '../metadata-cache'
31
+ import type { TableMetadata } from '../types'
32
+
33
+ const STAGES = [
34
+ { key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
35
+ { key: 'in_progress', label: 'In Progress', color: 'blue', order: 1 },
36
+ { key: 'done', label: 'Done', color: 'green', order: 2 },
37
+ ]
38
+
39
+ function meta(): TableMetadata {
40
+ return {
41
+ title: 'Issues',
42
+ endpoint: '/data/issue',
43
+ view_type: 'kanban',
44
+ group_by: 'stage',
45
+ stages: STAGES,
46
+ columns: [
47
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
48
+ {
49
+ key: 'stage',
50
+ label: 'Stage',
51
+ type: 'status',
52
+ sortable: false,
53
+ filterable: true,
54
+ options: STAGES.map((s) => ({ value: s.key, label: s.label, color: s.color })),
55
+ },
56
+ ],
57
+ actions: [],
58
+ perPageOptions: [50],
59
+ defaultPerPage: 50,
60
+ searchPlaceholder: 'Buscar...',
61
+ enableCRUDActions: true,
62
+ hasActions: false,
63
+ }
64
+ }
65
+
66
+ const CARDS = [{ id: 1, title: 'Fix login bug', stage: 'backlog' }]
67
+
68
+ function fakeApi(over: Partial<ApiClient> = {}, stageLayoutData: any = { model: 'issue', stage_order: null }): ApiClient {
69
+ const ok = (data: unknown) => ({ data: { success: true, data } })
70
+ return {
71
+ get: vi.fn(async (url: string) => {
72
+ if (url.startsWith('/metadata/table/')) return ok(meta())
73
+ if (url.startsWith('/stage-layout')) return ok(stageLayoutData)
74
+ if (url.startsWith('/custom-stages')) return { data: { success: false } }
75
+ return ok(CARDS)
76
+ }),
77
+ post: vi.fn(async () => ok(null)),
78
+ put: vi.fn(async () => ok(null)),
79
+ delete: vi.fn(async () => ok(null)),
80
+ ...over,
81
+ }
82
+ }
83
+
84
+ const wrapper =
85
+ (client: ApiClient) =>
86
+ ({ children }: { children: React.ReactNode }) =>
87
+ <ApiProvider client={client}>{children}</ApiProvider>
88
+
89
+ afterEach(cleanup)
90
+
91
+ describe('useStageLayout', () => {
92
+ it('reports available + hasCustomLayout from the GET', async () => {
93
+ const api = fakeApi({}, { model: 'issue', stage_order: ['done', 'backlog', 'in_progress'] })
94
+ const { result } = renderHook(() => useStageLayout('issue'), {
95
+ wrapper: wrapper(api),
96
+ })
97
+ await waitFor(() => expect(result.current.available).toBe(true))
98
+ expect(result.current.hasCustomLayout).toBe(true)
99
+ })
100
+
101
+ it('is unavailable when the endpoint 404s (drag stays off)', async () => {
102
+ const api = fakeApi({
103
+ get: vi.fn(async (url: string) => {
104
+ if (url.startsWith('/stage-layout')) throw new Error('404')
105
+ return { data: { success: true, data: meta() } }
106
+ }),
107
+ })
108
+ const { result } = renderHook(() => useStageLayout('issue'), {
109
+ wrapper: wrapper(api),
110
+ })
111
+ // give the effect a tick
112
+ await waitFor(() => expect(api.get).toHaveBeenCalled())
113
+ await new Promise((r) => setTimeout(r, 0))
114
+ expect(result.current.available).toBe(false)
115
+ expect(result.current.hasCustomLayout).toBe(false)
116
+ })
117
+
118
+ it('save PUTs the full order and reset DELETEs', async () => {
119
+ const put = vi.fn(async () => ({ data: { success: true, data: null } }))
120
+ const del = vi.fn(async () => ({ data: { success: true, data: null } }))
121
+ const api = fakeApi({ put, delete: del })
122
+ const { result } = renderHook(() => useStageLayout('issue'), {
123
+ wrapper: wrapper(api),
124
+ })
125
+ await waitFor(() => expect(result.current.available).toBe(true))
126
+
127
+ await result.current.save(['done', 'backlog', 'in_progress'])
128
+ expect(put).toHaveBeenCalledWith('/stage-layout', {
129
+ model: 'issue',
130
+ stage_order: ['done', 'backlog', 'in_progress'],
131
+ })
132
+ await waitFor(() => expect(result.current.hasCustomLayout).toBe(true))
133
+
134
+ await result.current.reset()
135
+ expect(del).toHaveBeenCalledWith('/stage-layout?model=issue')
136
+ })
137
+
138
+ it('save re-throws on a {success:false} envelope', async () => {
139
+ const put = vi.fn(async () => ({ data: { success: false, message: 'nope' } }))
140
+ const api = fakeApi({ put })
141
+ const { result } = renderHook(() => useStageLayout('issue'), {
142
+ wrapper: wrapper(api),
143
+ })
144
+ await waitFor(() => expect(result.current.available).toBe(true))
145
+ await expect(result.current.save(['a', 'b'])).rejects.toThrow()
146
+ })
147
+ })
148
+
149
+ describe('DynamicKanban stage-layout gating', () => {
150
+ it('shows the reset-order affordance only when a custom order exists', async () => {
151
+ useMetadataCache.getState().setMetadata('issue', meta())
152
+ const api = fakeApi({}, { model: 'issue', stage_order: ['done', 'backlog', 'in_progress'] })
153
+ render(
154
+ <ApiProvider client={api}>
155
+ <DynamicKanban model="issue" />
156
+ </ApiProvider>,
157
+ )
158
+ await screen.findByText('Backlog')
159
+ expect(
160
+ await screen.findByTestId('kanban-reset-order'),
161
+ ).toBeTruthy()
162
+ })
163
+
164
+ it('resets the order: DELETE + metadata refetch', async () => {
165
+ useMetadataCache.getState().setMetadata('issue', meta())
166
+ const del = vi.fn(async () => ({ data: { success: true, data: null } }))
167
+ const api = fakeApi({ delete: del }, { model: 'issue', stage_order: ['done', 'backlog', 'in_progress'] })
168
+ render(
169
+ <ApiProvider client={api}>
170
+ <DynamicKanban model="issue" />
171
+ </ApiProvider>,
172
+ )
173
+ const btn = await screen.findByTestId('kanban-reset-order')
174
+ const metaCallsBefore = (api.get as any).mock.calls.filter((c: any[]) =>
175
+ String(c[0]).startsWith('/metadata/table/'),
176
+ ).length
177
+ fireEvent.click(btn)
178
+ await waitFor(() => expect(del).toHaveBeenCalledWith('/stage-layout?model=issue'))
179
+ // a fresh metadata GET followed the reset (falls back to declared order)
180
+ await waitFor(() => {
181
+ const after = (api.get as any).mock.calls.filter((c: any[]) =>
182
+ String(c[0]).startsWith('/metadata/table/'),
183
+ ).length
184
+ expect(after).toBeGreaterThan(metaCallsBefore)
185
+ })
186
+ })
187
+
188
+ it('a missing /stage-layout endpoint leaves the board intact (no reset, lanes + cards render)', async () => {
189
+ useMetadataCache.getState().setMetadata('issue', meta())
190
+ const api = fakeApi({
191
+ get: vi.fn(async (url: string) => {
192
+ if (url.startsWith('/metadata/table/')) return { data: { success: true, data: meta() } }
193
+ if (url.startsWith('/stage-layout')) throw new Error('404')
194
+ if (url.startsWith('/custom-stages')) return { data: { success: false } }
195
+ return { data: { success: true, data: CARDS } }
196
+ }),
197
+ })
198
+ render(
199
+ <ApiProvider client={api}>
200
+ <DynamicKanban model="issue" />
201
+ </ApiProvider>,
202
+ )
203
+ // board still paints every lane + a card
204
+ expect(await screen.findByText('Backlog')).toBeTruthy()
205
+ expect(screen.getByText('Done')).toBeTruthy()
206
+ expect(await screen.findByText('Fix login bug')).toBeTruthy()
207
+ // no reset affordance without a wired endpoint
208
+ expect(screen.queryByTestId('kanban-reset-order')).toBeNull()
209
+ })
210
+ })
@@ -0,0 +1,293 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Stage overrides (the per-lane ⚙ gear) coverage:
4
+ // 1. useStageOverrides: available flips on GET success/404; save PUTs the
5
+ // {model, stage_key, ...patch} body; reset DELETEs with model + stage_key.
6
+ // 2. cardMatchesStageFilters: the client-side belt-and-suspenders over the
7
+ // unscoped initial board page (eq/neq/contains/in).
8
+ // 3. StageConfigDialog: a DECLARED lane saves via onSaveOverride + resets when
9
+ // overridden; a CUSTOM lane saves via onUpdateCustom + deletes via onDelete.
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
12
+
13
+ const I18N_T = (k: string, opts?: Record<string, any>) => {
14
+ let s = (opts?.defaultValue as string) ?? k
15
+ if (opts) {
16
+ for (const [ok, ov] of Object.entries(opts)) {
17
+ if (ok === 'defaultValue') continue
18
+ s = s.replace(new RegExp(`{{${ok}}}`, 'g'), String(ov))
19
+ }
20
+ }
21
+ return s
22
+ }
23
+ const USE_TRANSLATION = { t: I18N_T, i18n: { language: 'es' } }
24
+ vi.mock('react-i18next', () => ({ useTranslation: () => USE_TRANSLATION }))
25
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
26
+
27
+ import React from 'react'
28
+ import { cardMatchesStageFilters, StageConfigDialog, type CustomStage, type StageConfigTarget } from '../custom-stages'
29
+ import { useStageOverrides } from '../stage-overrides'
30
+ import { ApiProvider, type ApiClient } from '../api-context'
31
+ import type { ColumnDefinition } from '../types'
32
+
33
+ afterEach(cleanup)
34
+
35
+ const COLUMNS: ColumnDefinition[] = [
36
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false },
37
+ { key: 'priority', label: 'Priority', type: 'text', sortable: false, filterable: false },
38
+ { key: 'id', label: 'ID', type: 'text', sortable: false, filterable: false },
39
+ ]
40
+
41
+ function fakeApi(over: Partial<ApiClient> = {}): ApiClient {
42
+ const ok = (data: unknown) => ({ data: { success: true, data } })
43
+ return {
44
+ get: vi.fn(async () => ok([])),
45
+ post: vi.fn(async () => ok(null)),
46
+ put: vi.fn(async () => ok(null)),
47
+ delete: vi.fn(async () => ok(null)),
48
+ ...over,
49
+ }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // 1. useStageOverrides
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function OverridesProbe({ model }: { model: string }) {
57
+ const o = useStageOverrides(model)
58
+ return (
59
+ <div>
60
+ <span data-testid="available">{String(o.available)}</span>
61
+ <button data-testid="save" onClick={() => void o.save('backlog', { label: 'Nuevo', color: 'blue', filters: [{ field: 'priority', op: 'eq', value: 'high' }] })} />
62
+ <button data-testid="reset" onClick={() => void o.reset('backlog')} />
63
+ </div>
64
+ )
65
+ }
66
+
67
+ describe('useStageOverrides', () => {
68
+ it('reports available after a successful GET and PUTs/DELETEs the right shape', async () => {
69
+ const api = fakeApi()
70
+ render(
71
+ <ApiProvider client={api}>
72
+ <OverridesProbe model="issue" />
73
+ </ApiProvider>,
74
+ )
75
+ await waitFor(() => expect(screen.getByTestId('available').textContent).toBe('true'))
76
+ expect(api.get).toHaveBeenCalledWith('/stage-overrides?model=issue')
77
+
78
+ fireEvent.click(screen.getByTestId('save'))
79
+ await waitFor(() =>
80
+ expect(api.put).toHaveBeenCalledWith('/stage-overrides', {
81
+ model: 'issue',
82
+ stage_key: 'backlog',
83
+ label: 'Nuevo',
84
+ color: 'blue',
85
+ filters: [{ field: 'priority', op: 'eq', value: 'high' }],
86
+ }),
87
+ )
88
+
89
+ fireEvent.click(screen.getByTestId('reset'))
90
+ await waitFor(() =>
91
+ expect(api.delete).toHaveBeenCalledWith(
92
+ '/stage-overrides?model=issue&stage_key=backlog',
93
+ ),
94
+ )
95
+ })
96
+
97
+ it('degrades to available=false when the endpoint 404s', async () => {
98
+ const api = fakeApi({ get: vi.fn(async () => Promise.reject(new Error('404'))) })
99
+ render(
100
+ <ApiProvider client={api}>
101
+ <OverridesProbe model="issue" />
102
+ </ApiProvider>,
103
+ )
104
+ await waitFor(() => expect(screen.getByTestId('available').textContent).toBe('false'))
105
+ })
106
+ })
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 2. cardMatchesStageFilters
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('cardMatchesStageFilters', () => {
113
+ it('applies eq / neq / contains (array + string) / in', () => {
114
+ expect(cardMatchesStageFilters({ priority: 'high' }, [{ field: 'priority', op: 'eq', value: 'high' }])).toBe(true)
115
+ expect(cardMatchesStageFilters({ priority: 'low' }, [{ field: 'priority', op: 'eq', value: 'high' }])).toBe(false)
116
+ expect(cardMatchesStageFilters({ priority: 'low' }, [{ field: 'priority', op: 'neq', value: 'high' }])).toBe(true)
117
+ // contains against a jsonb array membership + a string substring
118
+ expect(cardMatchesStageFilters({ tags: ['a', 'b'] }, [{ field: 'tags', op: 'contains', value: 'b' }])).toBe(true)
119
+ expect(cardMatchesStageFilters({ title: 'hello world' }, [{ field: 'title', op: 'contains', value: 'world' }])).toBe(true)
120
+ // in: comma-separated candidates
121
+ expect(cardMatchesStageFilters({ priority: 'mid' }, [{ field: 'priority', op: 'in', value: 'mid,high' }])).toBe(true)
122
+ expect(cardMatchesStageFilters({ priority: 'low' }, [{ field: 'priority', op: 'in', value: 'mid,high' }])).toBe(false)
123
+ })
124
+
125
+ it('AND-combines multiple conditions and passes empty/blank ones', () => {
126
+ expect(
127
+ cardMatchesStageFilters({ priority: 'high', assignee: 'ana' }, [
128
+ { field: 'priority', op: 'eq', value: 'high' },
129
+ { field: 'assignee', op: 'eq', value: 'ana' },
130
+ ]),
131
+ ).toBe(true)
132
+ expect(cardMatchesStageFilters({ priority: 'high' }, [])).toBe(true)
133
+ expect(cardMatchesStageFilters({ priority: 'high' }, [{ field: 'priority', op: 'eq', value: ' ' }])).toBe(true)
134
+ })
135
+ })
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // 3. StageConfigDialog
139
+ // ---------------------------------------------------------------------------
140
+
141
+ const DECLARED_TARGET: StageConfigTarget = {
142
+ kind: 'declared',
143
+ stageKey: 'backlog',
144
+ label: 'Backlog',
145
+ color: 'slate',
146
+ filters: [],
147
+ overridden: false,
148
+ }
149
+
150
+ const CUSTOM_STAGE: CustomStage = {
151
+ id: 42,
152
+ model: 'issue',
153
+ key: 'urgent',
154
+ label: 'Urgente',
155
+ color: 'red',
156
+ position: 5,
157
+ type: 'stage',
158
+ filters: [],
159
+ enabled: true,
160
+ }
161
+
162
+ function renderConfig(target: StageConfigTarget, handlers: Partial<React.ComponentProps<typeof StageConfigDialog>> = {}) {
163
+ const props = {
164
+ open: true,
165
+ onOpenChange: vi.fn(),
166
+ columns: COLUMNS,
167
+ target,
168
+ onSaveOverride: vi.fn(async () => {}),
169
+ onResetOverride: vi.fn(async () => {}),
170
+ onUpdateCustom: vi.fn(async () => {}),
171
+ onDeleteCustom: vi.fn(),
172
+ ...handlers,
173
+ }
174
+ render(<StageConfigDialog {...props} />)
175
+ return props
176
+ }
177
+
178
+ describe('StageConfigDialog', () => {
179
+ it('a DECLARED lane saves label/color/conditions via onSaveOverride', async () => {
180
+ const props = renderConfig(DECLARED_TARGET)
181
+ fireEvent.change(screen.getByTestId('stage-config-name'), { target: { value: 'Pendientes' } })
182
+ fireEvent.click(screen.getByTestId('stage-config-color-blue'))
183
+ // add a condition
184
+ fireEvent.click(screen.getByTestId('stage-config-add-first-condition'))
185
+ fireEvent.change(screen.getByTestId('custom-stage-condition-value-0'), { target: { value: 'high' } })
186
+ fireEvent.click(screen.getByTestId('stage-config-save'))
187
+ await waitFor(() => expect(props.onSaveOverride).toHaveBeenCalled())
188
+ const [stageKey, patch] = (props.onSaveOverride as any).mock.calls[0]
189
+ expect(stageKey).toBe('backlog')
190
+ expect(patch.label).toBe('Pendientes')
191
+ expect(patch.color).toBe('blue')
192
+ expect(patch.filters).toEqual([{ field: 'title', op: 'eq', value: 'high' }])
193
+ })
194
+
195
+ it('pre-populates the WHOLE form from the lane (label, color, existing conditions)', () => {
196
+ renderConfig({
197
+ ...DECLARED_TARGET,
198
+ label: 'En revisión',
199
+ color: 'amber',
200
+ filters: [{ field: 'priority', op: 'eq', value: 'high' }],
201
+ })
202
+ // Name + color pre-filled from the current lane state.
203
+ expect((screen.getByTestId('stage-config-name') as HTMLInputElement).value).toBe('En revisión')
204
+ expect(screen.getByTestId('stage-config-color-amber').getAttribute('aria-pressed')).toBe('true')
205
+ // The existing condition is loaded into the builder, editable.
206
+ expect((screen.getByTestId('custom-stage-condition-value-0') as HTMLInputElement).value).toBe('high')
207
+ })
208
+
209
+ it('renders the current-conditions chips: locked base chip, final chip, removable filter chips', () => {
210
+ renderConfig({
211
+ ...DECLARED_TARGET,
212
+ label: 'Hecho',
213
+ isFinal: true,
214
+ filters: [
215
+ { field: 'state', op: 'eq', value: 'open' },
216
+ { field: 'priority', op: 'neq', value: 'low' },
217
+ ],
218
+ })
219
+ // Base chip is always present and shows the stage scope.
220
+ const base = screen.getByTestId('stage-config-base-chip')
221
+ expect(base.textContent).toContain('Etapa = Hecho')
222
+ // Final chip renders for a terminal stage.
223
+ expect(screen.getByTestId('stage-config-final-chip')).toBeTruthy()
224
+ // One editable chip per extra condition, with legible operators.
225
+ expect(screen.getByTestId('stage-config-filter-chip-0').textContent).toContain('state')
226
+ expect(screen.getByTestId('stage-config-filter-chip-0').textContent).toContain('=')
227
+ expect(screen.getByTestId('stage-config-filter-chip-1').textContent).toContain('≠')
228
+ })
229
+
230
+ it('removing a condition chip drops it from the builder', () => {
231
+ renderConfig({
232
+ ...DECLARED_TARGET,
233
+ filters: [
234
+ { field: 'state', op: 'eq', value: 'open' },
235
+ { field: 'priority', op: 'neq', value: 'low' },
236
+ ],
237
+ })
238
+ expect(screen.getByTestId('stage-config-filter-chip-1')).toBeTruthy()
239
+ fireEvent.click(screen.getByTestId('stage-config-filter-chip-remove-1'))
240
+ expect(screen.queryByTestId('stage-config-filter-chip-1')).toBeNull()
241
+ // The first condition survives.
242
+ expect(screen.getByTestId('stage-config-filter-chip-0')).toBeTruthy()
243
+ })
244
+
245
+ it('shows the "Personalizada" badge and a reset confirm listing the original values', async () => {
246
+ renderConfig({ ...DECLARED_TARGET, overridden: false })
247
+ expect(screen.queryByTestId('stage-config-reset')).toBeNull()
248
+ cleanup()
249
+ const props = renderConfig({
250
+ ...DECLARED_TARGET,
251
+ overridden: true,
252
+ original: {
253
+ label: 'Backlog',
254
+ color: 'slate',
255
+ filters: [{ field: 'state', op: 'eq', value: 'todo' }],
256
+ },
257
+ })
258
+ expect(screen.getByTestId('stage-config-personalizada')).toBeTruthy()
259
+ // First click reveals the confirm panel spelling out what reverts.
260
+ fireEvent.click(screen.getByTestId('stage-config-reset'))
261
+ const panel = screen.getByTestId('stage-config-reset-confirm-panel')
262
+ expect(panel.textContent).toContain('Backlog')
263
+ expect(panel.textContent).toContain('slate')
264
+ expect(panel.textContent).toContain('state = todo')
265
+ expect(props.onResetOverride).not.toHaveBeenCalled()
266
+ // The confirm button actually drops the override.
267
+ fireEvent.click(screen.getByTestId('stage-config-reset-confirm'))
268
+ await waitFor(() => expect(props.onResetOverride).toHaveBeenCalledWith('backlog'))
269
+ })
270
+
271
+ it('a CUSTOM lane saves via onUpdateCustom and deletes via onDeleteCustom', async () => {
272
+ const target: StageConfigTarget = {
273
+ kind: 'custom',
274
+ stageKey: 'urgent',
275
+ id: 42,
276
+ label: 'Urgente',
277
+ color: 'red',
278
+ filters: [],
279
+ customStage: CUSTOM_STAGE,
280
+ }
281
+ const props = renderConfig(target)
282
+ expect(screen.queryByTestId('stage-config-reset')).toBeNull()
283
+ fireEvent.change(screen.getByTestId('stage-config-name'), { target: { value: 'Muy urgente' } })
284
+ fireEvent.click(screen.getByTestId('stage-config-save'))
285
+ await waitFor(() => expect(props.onUpdateCustom).toHaveBeenCalled())
286
+ const [id, patch] = (props.onUpdateCustom as any).mock.calls[0]
287
+ expect(id).toBe(42)
288
+ expect(patch.label).toBe('Muy urgente')
289
+
290
+ fireEvent.click(screen.getByTestId('stage-config-delete'))
291
+ expect(props.onDeleteCustom).toHaveBeenCalledWith(CUSTOM_STAGE)
292
+ })
293
+ })