@open-mercato/ui 0.7.1-develop.7150.1.c1941e0c22 → 0.7.1-develop.7152.1.a69e92f9c9

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,495 @@
1
+ import * as React from 'react'
2
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+
4
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
5
+ import { DevRuntimeDiagnosticsBanner } from '../dev/DevRuntimeDiagnosticsBanner'
6
+ import {
7
+ DEV_RUNTIME_BANNER_META_NAME,
8
+ DEV_RUNTIME_LOGS_URL_META_NAME,
9
+ DEV_RUNTIME_TOKEN_HEADER,
10
+ DEV_RUNTIME_TOKEN_META_NAME,
11
+ type RuntimeIssue,
12
+ type RuntimeStatus,
13
+ } from '@open-mercato/shared/lib/dev-runtime/types'
14
+
15
+ const TOKEN = 'banner-token-fixture'
16
+
17
+ function setMeta(name: string, content: string): void {
18
+ const element = document.createElement('meta')
19
+ element.setAttribute('name', name)
20
+ element.setAttribute('content', content)
21
+ document.head.appendChild(element)
22
+ }
23
+
24
+ function enableBanner({ logsUrl }: { logsUrl?: string } = {}): void {
25
+ setMeta(DEV_RUNTIME_TOKEN_META_NAME, TOKEN)
26
+ setMeta(DEV_RUNTIME_BANNER_META_NAME, '1')
27
+ if (logsUrl) setMeta(DEV_RUNTIME_LOGS_URL_META_NAME, logsUrl)
28
+ }
29
+
30
+ function createIssue(overrides: Partial<RuntimeIssue> = {}): RuntimeIssue {
31
+ return {
32
+ id: '1-1',
33
+ fingerprint: 'fingerprint-a',
34
+ code: 'db_relation_missing',
35
+ source: 'log',
36
+ severity: 'error',
37
+ title: 'Database schema mismatch',
38
+ detail: 'Relation `sandboxs` is missing',
39
+ firstSeenAt: '2026-08-18T10:00:00.000Z',
40
+ lastSeenAt: '2026-08-18T10:00:05.000Z',
41
+ occurrences: 3,
42
+ generation: 1,
43
+ recovery: 'migrate',
44
+ ...overrides,
45
+ }
46
+ }
47
+
48
+ function createStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
49
+ const issue = overrides.issueSummary === undefined ? createIssue() : overrides.issueSummary
50
+ return {
51
+ schemaVersion: 1,
52
+ generation: 1,
53
+ health: 'degraded',
54
+ ready: true,
55
+ failed: false,
56
+ updatedAt: '2026-08-18T10:00:05.000Z',
57
+ upstream: { configuredPort: 3000, publicUrl: 'http://localhost:3000' },
58
+ incidents: issue ? [issue] : [],
59
+ legacy: { failureLines: [] },
60
+ ...overrides,
61
+ issueSummary: issue,
62
+ }
63
+ }
64
+
65
+ let fetchMock: jest.Mock
66
+
67
+ let actionResponse: Response | (() => Response) = new Response(JSON.stringify({ accepted: true }), { status: 202 })
68
+ let logsSnapshot: unknown = {
69
+ generation: 1,
70
+ nextCursor: 2,
71
+ lines: [
72
+ { seq: 1, at: '2026-08-18T10:00:01.000Z', generation: 1, source: 'log', text: 'Relation `sandboxs` is missing' },
73
+ { seq: 2, at: '2026-08-18T10:00:02.000Z', generation: 1, source: 'process', text: 'migration check failed' },
74
+ ],
75
+ }
76
+
77
+ function mockStatusResponses(...statuses: Array<RuntimeStatus | null>): void {
78
+ let index = 0
79
+ fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
80
+ if (init?.method === 'POST') {
81
+ return typeof actionResponse === 'function' ? actionResponse() : actionResponse.clone()
82
+ }
83
+ if (String(url).startsWith('/api/dev-runtime/logs')) {
84
+ return new Response(JSON.stringify(logsSnapshot), { status: 200, headers: { 'content-type': 'application/json' } })
85
+ }
86
+ const status = statuses[Math.min(index, statuses.length - 1)]
87
+ index += 1
88
+ if (!status) return new Response(null, { status: 404 })
89
+ return new Response(JSON.stringify(status), { status: 200, headers: { 'content-type': 'application/json' } })
90
+ })
91
+ }
92
+
93
+ // The banner ships inside AppProviders in the real app, so the provider is the
94
+ // realistic default; the provider-less path is covered explicitly below.
95
+ function renderBanner() {
96
+ return render(
97
+ <I18nProvider locale="en" dict={{}}>
98
+ <DevRuntimeDiagnosticsBanner />
99
+ </I18nProvider>,
100
+ )
101
+ }
102
+
103
+ function actionCalls(): Array<[string, RequestInit]> {
104
+ return fetchMock.mock.calls.filter((call) => call[1]?.method === 'POST') as Array<[string, RequestInit]>
105
+ }
106
+
107
+ // jsdom does not implement <dialog>.showModal, which ConfirmDialog relies on.
108
+ function installDialogPolyfill() {
109
+ Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
110
+ configurable: true,
111
+ value(this: HTMLDialogElement) { this.setAttribute('open', '') },
112
+ })
113
+ Object.defineProperty(HTMLDialogElement.prototype, 'close', {
114
+ configurable: true,
115
+ value(this: HTMLDialogElement) { this.removeAttribute('open') },
116
+ })
117
+ }
118
+
119
+ beforeEach(() => {
120
+ installDialogPolyfill()
121
+ document.head.innerHTML = ''
122
+ actionResponse = new Response(JSON.stringify({ accepted: true }), { status: 202 })
123
+ logsSnapshot = {
124
+ generation: 1,
125
+ nextCursor: 2,
126
+ lines: [
127
+ { seq: 1, at: '2026-08-18T10:00:01.000Z', generation: 1, source: 'log', text: 'Relation `sandboxs` is missing' },
128
+ { seq: 2, at: '2026-08-18T10:00:02.000Z', generation: 1, source: 'process', text: 'migration check failed' },
129
+ ],
130
+ }
131
+ fetchMock = jest.fn()
132
+ global.fetch = fetchMock as unknown as typeof fetch
133
+ })
134
+
135
+ afterEach(() => {
136
+ cleanup()
137
+ jest.useRealTimers()
138
+ })
139
+
140
+ describe('DevRuntimeDiagnosticsBanner', () => {
141
+ it('renders nothing when the banner meta is absent', async () => {
142
+ renderBanner()
143
+ await waitFor(() => expect(fetchMock).not.toHaveBeenCalled())
144
+ expect(screen.queryByTestId('dev-runtime-diagnostics-banner')).toBeNull()
145
+ })
146
+
147
+ it('sends the per-run token with the status request', async () => {
148
+ enableBanner()
149
+ mockStatusResponses(createStatus())
150
+ renderBanner()
151
+
152
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
153
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/dev-runtime/status')
154
+ expect(fetchMock.mock.calls[0][1].headers[DEV_RUNTIME_TOKEN_HEADER]).toBe(TOKEN)
155
+ })
156
+
157
+ it('shows the localized headline and the concise incident detail', async () => {
158
+ enableBanner()
159
+ mockStatusResponses(createStatus())
160
+ renderBanner()
161
+
162
+ const banner = await screen.findByTestId('dev-runtime-diagnostics-banner')
163
+ expect(banner).toHaveAttribute('data-health', 'degraded')
164
+ expect(banner.textContent).toContain('Runtime degraded')
165
+ expect(banner.textContent).toContain('Database schema mismatch')
166
+ expect(banner.textContent).toContain('Relation `sandboxs` is missing')
167
+ })
168
+
169
+ it('uses a polite status role while degraded and an alert while unavailable', async () => {
170
+ enableBanner()
171
+ mockStatusResponses(createStatus())
172
+ const { unmount } = renderBanner()
173
+ const degraded = await screen.findByTestId('dev-runtime-diagnostics-banner')
174
+ expect(degraded).toHaveAttribute('role', 'status')
175
+ expect(degraded).toHaveAttribute('aria-live', 'polite')
176
+ unmount()
177
+
178
+ mockStatusResponses(createStatus({ health: 'unavailable', ready: false, failed: true }))
179
+ renderBanner()
180
+ const unavailable = await screen.findByTestId('dev-runtime-diagnostics-banner')
181
+ expect(unavailable).toHaveAttribute('role', 'alert')
182
+ expect(unavailable).toHaveAttribute('aria-live', 'assertive')
183
+ })
184
+
185
+ it('stays hidden while the runtime is ready', async () => {
186
+ enableBanner()
187
+ mockStatusResponses(createStatus({ health: 'ready', issueSummary: undefined, incidents: [] }))
188
+ renderBanner()
189
+
190
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled())
191
+ expect(screen.queryByTestId('dev-runtime-diagnostics-banner')).toBeNull()
192
+ })
193
+
194
+ it('renders during startup and while recovering', async () => {
195
+ enableBanner()
196
+ mockStatusResponses(createStatus({ health: 'starting', ready: false }))
197
+ const { unmount } = renderBanner()
198
+ expect((await screen.findByTestId('dev-runtime-diagnostics-banner')).textContent).toContain('Runtime starting')
199
+ unmount()
200
+
201
+ mockStatusResponses(createStatus({
202
+ health: 'recovering',
203
+ ready: false,
204
+ recovery: { action: 'migrate', startedAt: '2026-08-18T10:00:00.000Z', busy: true },
205
+ }))
206
+ renderBanner()
207
+ expect((await screen.findByTestId('dev-runtime-diagnostics-banner')).textContent).toContain('Runtime recovering')
208
+ })
209
+
210
+ it('hides the retry control while a recovery action is busy', async () => {
211
+ enableBanner()
212
+ mockStatusResponses(createStatus({
213
+ health: 'recovering',
214
+ ready: false,
215
+ recovery: { action: 'migrate', startedAt: '2026-08-18T10:00:00.000Z', busy: true },
216
+ }))
217
+ renderBanner()
218
+
219
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
220
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
221
+ })
222
+
223
+ it('expands technical details on demand and hides them again', async () => {
224
+ enableBanner()
225
+ mockStatusResponses(createStatus({ issueSummary: createIssue({ path: '/backend/example' }) }))
226
+ renderBanner()
227
+
228
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
229
+ expect(screen.queryByText('db_relation_missing')).toBeNull()
230
+
231
+ fireEvent.click(screen.getByRole('button', { name: /Show details/ }))
232
+ expect(screen.getByText('db_relation_missing')).toBeInTheDocument()
233
+ expect(screen.getByText('3')).toBeInTheDocument()
234
+ expect(screen.getByText('/backend/example')).toBeInTheDocument()
235
+
236
+ fireEvent.click(screen.getByRole('button', { name: /Hide details/ }))
237
+ expect(screen.queryByText('db_relation_missing')).toBeNull()
238
+ })
239
+
240
+ it('shows the log tail inline instead of navigating to the splash port', async () => {
241
+ enableBanner()
242
+ mockStatusResponses(createStatus())
243
+ renderBanner()
244
+
245
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
246
+ // No cross-port link as the primary affordance.
247
+ expect(screen.queryByRole('link', { name: /View logs/ })).toBeNull()
248
+
249
+ fireEvent.click(screen.getByRole('button', { name: /View logs/ }))
250
+ expect(await screen.findByText(/migration check failed/)).toBeInTheDocument()
251
+ expect(fetchMock.mock.calls.some(([url]) => String(url).startsWith('/api/dev-runtime/logs'))).toBe(true)
252
+
253
+ fireEvent.click(screen.getByRole('button', { name: /Hide logs/ }))
254
+ expect(screen.queryByText(/migration check failed/)).toBeNull()
255
+ })
256
+
257
+ it('reports an empty log tail rather than rendering a blank panel', async () => {
258
+ enableBanner()
259
+ mockStatusResponses(createStatus())
260
+ logsSnapshot = { generation: 1, nextCursor: 0, lines: [] }
261
+ renderBanner()
262
+
263
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
264
+ fireEvent.click(screen.getByRole('button', { name: /View logs/ }))
265
+ expect(await screen.findByText('No diagnostic lines yet.')).toBeInTheDocument()
266
+ })
267
+
268
+ it('still links the standalone splash as a secondary affordance when available', async () => {
269
+ enableBanner({ logsUrl: 'http://localhost:4000' })
270
+ mockStatusResponses(createStatus())
271
+ renderBanner()
272
+
273
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
274
+ fireEvent.click(screen.getByRole('button', { name: /View logs/ }))
275
+ expect(await screen.findByRole('link', { name: 'http://localhost:4000' })).toBeInTheDocument()
276
+ })
277
+
278
+ it('dismisses the current incident without changing runtime state', async () => {
279
+ enableBanner()
280
+ mockStatusResponses(createStatus())
281
+ renderBanner()
282
+
283
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
284
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
285
+ expect(screen.queryByTestId('dev-runtime-diagnostics-banner')).toBeNull()
286
+
287
+ // Only the browser view is dismissed; the collector keeps reporting it.
288
+ const calls = fetchMock.mock.calls.length
289
+ expect(calls).toBeGreaterThan(0)
290
+ expect(fetchMock.mock.calls.every((call) => call[1]?.method === undefined)).toBe(true)
291
+ })
292
+
293
+ it('reappears for a new fingerprint after a dismissal', async () => {
294
+ jest.useFakeTimers()
295
+ enableBanner()
296
+ mockStatusResponses(
297
+ createStatus(),
298
+ createStatus(),
299
+ createStatus({ issueSummary: createIssue({ fingerprint: 'fingerprint-b', title: 'Bundler crashed' }) }),
300
+ )
301
+ renderBanner()
302
+
303
+ await act(async () => { await Promise.resolve() })
304
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
305
+ expect(screen.queryByTestId('dev-runtime-diagnostics-banner')).toBeNull()
306
+
307
+ await act(async () => {
308
+ jest.advanceTimersByTime(2000)
309
+ await Promise.resolve()
310
+ })
311
+ await act(async () => {
312
+ jest.advanceTimersByTime(2000)
313
+ await Promise.resolve()
314
+ })
315
+
316
+ expect(screen.getByTestId('dev-runtime-diagnostics-banner').textContent).toContain('Bundler crashed')
317
+ })
318
+
319
+ it('keeps the page usable when the status bridge is unavailable', async () => {
320
+ enableBanner()
321
+ fetchMock.mockRejectedValue(new Error('bridge down'))
322
+ renderBanner()
323
+
324
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled())
325
+ expect(screen.queryByTestId('dev-runtime-diagnostics-banner')).toBeNull()
326
+ })
327
+
328
+ it('renders nothing when the bridge returns 404', async () => {
329
+ enableBanner()
330
+ mockStatusResponses(null)
331
+ renderBanner()
332
+
333
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled())
334
+ expect(screen.queryByTestId('dev-runtime-diagnostics-banner')).toBeNull()
335
+ })
336
+
337
+ // Restarting `yarn dev` mints a new supervisor token while an already-open tab
338
+ // keeps the previous one in its <meta>, so every poll answers 403. That must
339
+ // clear the banner, not freeze the dead runtime's incident on screen — and it
340
+ // must not route a routine dev-overlay 403 through the staff-auth pipeline.
341
+ it('clears the banner when a stale token starts answering 403', async () => {
342
+ enableBanner()
343
+ let status: RuntimeStatus | null = createStatus()
344
+ fetchMock.mockImplementation(async () => (
345
+ status
346
+ ? new Response(JSON.stringify(status), { status: 200, headers: { 'content-type': 'application/json' } })
347
+ : new Response(JSON.stringify({ error: { code: 'forbidden', message: 'Invalid dev runtime token.' } }), {
348
+ status: 403,
349
+ headers: { 'content-type': 'application/json' },
350
+ })
351
+ ))
352
+ renderBanner()
353
+
354
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
355
+
356
+ status = null
357
+ await waitFor(
358
+ () => expect(screen.queryByTestId('dev-runtime-diagnostics-banner')).toBeNull(),
359
+ { timeout: 6000 },
360
+ )
361
+ }, 10000)
362
+
363
+ it('lets the action row wrap instead of scrolling on narrow viewports', async () => {
364
+ enableBanner({ logsUrl: 'http://localhost:4000' })
365
+ mockStatusResponses(createStatus())
366
+ renderBanner()
367
+
368
+ const banner = await screen.findByTestId('dev-runtime-diagnostics-banner')
369
+ const actionRow = banner.querySelector('.flex.flex-wrap.items-center')
370
+ // Wraps rather than scrolls, and labels never break mid-word when it does.
371
+ expect(actionRow?.className).toContain('flex-wrap')
372
+ expect(screen.getByRole('button', { name: /Restart runtime/ }).className).toContain('whitespace-nowrap')
373
+ // Dismiss is pinned to the header corner, not part of the wrapping action
374
+ // row — otherwise it orphans onto a line of its own once actions wrap.
375
+ const dismiss = screen.getByRole('button', { name: 'Dismiss' })
376
+ expect(actionRow?.contains(dismiss)).toBe(false)
377
+ })
378
+
379
+ // The app shell's sidebar toggle and the toast stack own fixed slots at the
380
+ // top of the viewport, so a top-anchored banner is overlapped by them.
381
+ it('floats at the bottom above app chrome instead of sitting in page flow', async () => {
382
+ enableBanner()
383
+ mockStatusResponses(createStatus())
384
+ renderBanner()
385
+
386
+ const banner = await screen.findByTestId('dev-runtime-diagnostics-banner')
387
+ expect(banner.className).toContain('fixed')
388
+ expect(banner.className).toContain('z-banner')
389
+ // Bottom-right, but lifted clear of the support-chat launcher that lives in
390
+ // that corner with its own very high third-party z-index.
391
+ expect(banner.className).toContain('sm:right-4')
392
+ expect(banner.className).toContain('bottom-20')
393
+ expect(banner.className).not.toContain('sm:left-4')
394
+ expect(banner.className).not.toContain('bottom-3')
395
+ // Never a full-bleed top bar: that is what collided with the shell chrome.
396
+ expect(banner.className).not.toContain('border-b')
397
+ expect(banner.className).not.toContain('top-0')
398
+ })
399
+
400
+ it('offers restart plus the classifier-justified action only', async () => {
401
+ enableBanner()
402
+ mockStatusResponses(createStatus())
403
+ renderBanner()
404
+
405
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
406
+ expect(screen.getByRole('button', { name: /Run migrations/ })).toBeInTheDocument()
407
+ expect(screen.getByRole('button', { name: /Restart runtime/ })).toBeInTheDocument()
408
+ expect(screen.queryByRole('button', { name: /Run generators/ })).toBeNull()
409
+ })
410
+
411
+ it('offers only restart when the incident has no justified recovery', async () => {
412
+ enableBanner()
413
+ mockStatusResponses(createStatus({ issueSummary: createIssue({ recovery: undefined }) }))
414
+ renderBanner()
415
+
416
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
417
+ expect(screen.getByRole('button', { name: /Restart runtime/ })).toBeInTheDocument()
418
+ expect(screen.queryByRole('button', { name: /Run migrations/ })).toBeNull()
419
+ })
420
+
421
+ it('posts restart to the allowlisted action endpoint with the run token', async () => {
422
+ enableBanner()
423
+ mockStatusResponses(createStatus())
424
+ renderBanner()
425
+
426
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
427
+ fireEvent.click(screen.getByRole('button', { name: /Restart runtime/ }))
428
+
429
+ await waitFor(() => expect(actionCalls()).toHaveLength(1))
430
+ const [url, init] = actionCalls()[0]
431
+ expect(url).toBe('/api/dev-runtime/actions/restart')
432
+ expect(init.headers).toMatchObject({ [DEV_RUNTIME_TOKEN_HEADER]: TOKEN })
433
+ })
434
+
435
+ it('requires confirmation before running migrations and can be cancelled', async () => {
436
+ enableBanner()
437
+ mockStatusResponses(createStatus())
438
+ renderBanner()
439
+
440
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
441
+ fireEvent.click(screen.getByRole('button', { name: /Run migrations/ }))
442
+
443
+ const dialogText = await screen.findByText(/not automatically reversible/i)
444
+ expect(dialogText).toBeInTheDocument()
445
+ expect(actionCalls()).toHaveLength(0)
446
+
447
+ fireEvent.click(screen.getByRole('button', { name: /^Cancel$/ }))
448
+ await waitFor(() => expect(actionCalls()).toHaveLength(0))
449
+ })
450
+
451
+ it('runs migrations once the confirmation is accepted', async () => {
452
+ enableBanner()
453
+ mockStatusResponses(createStatus())
454
+ renderBanner()
455
+
456
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
457
+ fireEvent.click(screen.getByRole('button', { name: /Run migrations/ }))
458
+ await screen.findByText(/not automatically reversible/i)
459
+
460
+ const dialogConfirm = screen.getAllByRole('button', { name: /Run migrations/ }).at(-1)!
461
+ fireEvent.click(dialogConfirm)
462
+
463
+ await waitFor(() => expect(actionCalls()).toHaveLength(1))
464
+ expect(actionCalls()[0][0]).toBe('/api/dev-runtime/actions/migrate')
465
+ })
466
+
467
+ it('surfaces a rejected action instead of failing silently', async () => {
468
+ enableBanner()
469
+ mockStatusResponses(createStatus())
470
+ actionResponse = () => new Response(
471
+ JSON.stringify({ error: { code: 'action_busy', message: 'The "generate" action is still running.' } }),
472
+ { status: 409, headers: { 'content-type': 'application/json' } },
473
+ )
474
+ renderBanner()
475
+
476
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
477
+ fireEvent.click(screen.getByRole('button', { name: /Restart runtime/ }))
478
+
479
+ expect(await screen.findByText('The "generate" action is still running.')).toBeInTheDocument()
480
+ })
481
+
482
+ it('hides recovery controls while an action is already running', async () => {
483
+ enableBanner()
484
+ mockStatusResponses(createStatus({
485
+ health: 'recovering',
486
+ ready: false,
487
+ recovery: { action: 'migrate', startedAt: '2026-08-18T10:00:00.000Z', busy: true },
488
+ }))
489
+ renderBanner()
490
+
491
+ await screen.findByTestId('dev-runtime-diagnostics-banner')
492
+ expect(screen.queryByRole('button', { name: /Restart runtime/ })).toBeNull()
493
+ expect(screen.queryByRole('button', { name: /Run migrations/ })).toBeNull()
494
+ })
495
+ })
@@ -0,0 +1,145 @@
1
+ import * as React from 'react'
2
+ import { cleanup, render, waitFor } from '@testing-library/react'
3
+
4
+ import { DevRuntimeReporter } from '../dev/DevRuntimeReporter'
5
+ import {
6
+ reportDevRuntimeError,
7
+ resetDevRuntimeReporterForTests,
8
+ } from '@open-mercato/shared/lib/dev-runtime/report'
9
+ import { DEV_RUNTIME_TOKEN_HEADER, DEV_RUNTIME_TOKEN_META_NAME } from '@open-mercato/shared/lib/dev-runtime/types'
10
+
11
+ const TOKEN = 'reporter-token-fixture'
12
+
13
+ function enableCollector(): void {
14
+ const element = document.createElement('meta')
15
+ element.setAttribute('name', DEV_RUNTIME_TOKEN_META_NAME)
16
+ element.setAttribute('content', TOKEN)
17
+ document.head.appendChild(element)
18
+ }
19
+
20
+ function sentReports(fetchMock: jest.Mock): Array<Record<string, unknown>> {
21
+ return fetchMock.mock.calls.map((call) => JSON.parse(String(call[1].body)) as Record<string, unknown>)
22
+ }
23
+
24
+ let fetchMock: jest.Mock
25
+
26
+ beforeEach(() => {
27
+ document.head.innerHTML = ''
28
+ resetDevRuntimeReporterForTests()
29
+ fetchMock = jest.fn().mockResolvedValue(new Response(null, { status: 202 }))
30
+ global.fetch = fetchMock as unknown as typeof fetch
31
+ })
32
+
33
+ afterEach(cleanup)
34
+
35
+ describe('reportDevRuntimeError', () => {
36
+ it('stays silent without a collector token', () => {
37
+ reportDevRuntimeError({ kind: 'global-error', error: new Error('boom') })
38
+ expect(fetchMock).not.toHaveBeenCalled()
39
+ })
40
+
41
+ it('posts a bounded report with the per-run token', () => {
42
+ enableCollector()
43
+ reportDevRuntimeError({ kind: 'global-error', error: Object.assign(new TypeError('boom'), { digest: 'abc123' }) })
44
+
45
+ expect(fetchMock).toHaveBeenCalledTimes(1)
46
+ const [url, init] = fetchMock.mock.calls[0]
47
+ expect(url).toBe('/api/dev-runtime/diagnostics')
48
+ expect(init.method).toBe('POST')
49
+ expect(init.headers[DEV_RUNTIME_TOKEN_HEADER]).toBe(TOKEN)
50
+ expect(sentReports(fetchMock)[0]).toMatchObject({
51
+ kind: 'global-error',
52
+ message: 'TypeError: boom',
53
+ digest: 'abc123',
54
+ path: '/',
55
+ })
56
+ })
57
+
58
+ it('bounds an over-long stack', () => {
59
+ enableCollector()
60
+ const error = new Error('boom')
61
+ error.stack = 'y'.repeat(5000)
62
+ reportDevRuntimeError({ kind: 'global-error', error })
63
+
64
+ expect(String(sentReports(fetchMock)[0].stack).length).toBeLessThanOrEqual(2000)
65
+ })
66
+
67
+ it('reports the same failure only once per page', () => {
68
+ enableCollector()
69
+ const error = new Error('boom')
70
+ reportDevRuntimeError({ kind: 'global-error', error })
71
+ reportDevRuntimeError({ kind: 'global-error', error })
72
+ reportDevRuntimeError({ kind: 'global-error', error })
73
+
74
+ expect(fetchMock).toHaveBeenCalledTimes(1)
75
+ })
76
+
77
+ it('caps the number of distinct reports per page', () => {
78
+ enableCollector()
79
+ for (let index = 0; index < 30; index += 1) {
80
+ reportDevRuntimeError({ kind: 'window-error', message: `boom ${index}` })
81
+ }
82
+ expect(fetchMock).toHaveBeenCalledTimes(20)
83
+ })
84
+
85
+ it('never throws when the collector rejects the request', () => {
86
+ enableCollector()
87
+ fetchMock.mockImplementation(() => { throw new Error('network down') })
88
+ expect(() => reportDevRuntimeError({ kind: 'global-error', message: 'boom' })).not.toThrow()
89
+ })
90
+
91
+ it('ignores a report without a usable message', () => {
92
+ enableCollector()
93
+ reportDevRuntimeError({ kind: 'window-error', message: ' ' })
94
+ expect(fetchMock).not.toHaveBeenCalled()
95
+ })
96
+ })
97
+
98
+ describe('DevRuntimeReporter', () => {
99
+ it('forwards an uncaught window error', async () => {
100
+ enableCollector()
101
+ render(<DevRuntimeReporter />)
102
+
103
+ window.dispatchEvent(new ErrorEvent('error', { message: 'TypeError: boom', error: new TypeError('boom') }))
104
+
105
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
106
+ expect(sentReports(fetchMock)[0]).toMatchObject({ kind: 'window-error' })
107
+ })
108
+
109
+ it('classifies a chunk load failure separately', async () => {
110
+ enableCollector()
111
+ render(<DevRuntimeReporter />)
112
+
113
+ window.dispatchEvent(new ErrorEvent('error', { message: 'ChunkLoadError: Loading chunk 42 failed' }))
114
+
115
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
116
+ expect(sentReports(fetchMock)[0]).toMatchObject({ kind: 'chunk-load-error' })
117
+ })
118
+
119
+ it('forwards an unhandled promise rejection', async () => {
120
+ enableCollector()
121
+ render(<DevRuntimeReporter />)
122
+
123
+ const event = new Event('unhandledrejection') as Event & { reason?: unknown }
124
+ event.reason = new Error('rejected')
125
+ window.dispatchEvent(event)
126
+
127
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
128
+ expect(sentReports(fetchMock)[0]).toMatchObject({ kind: 'unhandled-rejection', message: 'Error: rejected' })
129
+ })
130
+
131
+ it('stops listening after unmount', async () => {
132
+ enableCollector()
133
+ const { unmount } = render(<DevRuntimeReporter />)
134
+ unmount()
135
+
136
+ window.dispatchEvent(new ErrorEvent('error', { message: 'TypeError: boom' }))
137
+ await waitFor(() => expect(fetchMock).not.toHaveBeenCalled())
138
+ })
139
+
140
+ it('renders nothing', () => {
141
+ enableCollector()
142
+ const { container } = render(<DevRuntimeReporter />)
143
+ expect(container.innerHTML).toBe('')
144
+ })
145
+ })