@open-mercato/ui 0.7.1-develop.7151.1.00d0391847 → 0.7.1-develop.7153.1.7145d295e6

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 (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/AppShell.js +9 -1
  3. package/dist/backend/AppShell.js.map +2 -2
  4. package/dist/backend/CrudForm.js +3 -0
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/DataTable.js +3 -1
  7. package/dist/backend/DataTable.js.map +2 -2
  8. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +332 -0
  9. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +7 -0
  10. package/dist/backend/dev/DevRuntimeReporter.js +36 -0
  11. package/dist/backend/dev/DevRuntimeReporter.js.map +7 -0
  12. package/dist/backend/forms/FormHeader.js +3 -1
  13. package/dist/backend/forms/FormHeader.js.map +2 -2
  14. package/dist/backend/messages/MessageComposer.js +1 -0
  15. package/dist/backend/messages/MessageComposer.js.map +2 -2
  16. package/package.json +3 -3
  17. package/src/backend/AppShell.tsx +7 -1
  18. package/src/backend/CrudForm.tsx +5 -0
  19. package/src/backend/DataTable.tsx +10 -1
  20. package/src/backend/__tests__/AppShell.test.tsx +14 -0
  21. package/src/backend/__tests__/CrudForm.defaults.test.tsx +26 -0
  22. package/src/backend/__tests__/DataTable.render.test.tsx +30 -0
  23. package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +495 -0
  24. package/src/backend/__tests__/DevRuntimeReporter.test.tsx +145 -0
  25. package/src/backend/__tests__/FormHeader.test.tsx +9 -2
  26. package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +412 -0
  27. package/src/backend/dev/DevRuntimeReporter.tsx +46 -0
  28. package/src/backend/forms/FormHeader.tsx +5 -1
  29. package/src/backend/messages/MessageComposer.tsx +1 -0
  30. package/src/backend/messages/__tests__/MessageComposer.test.tsx +10 -0
@@ -1324,6 +1324,12 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1324
1324
  className={`relative min-h-svh lg:grid transition-[grid-template-columns] duration-200 ease-out ${gridColsClass}`}
1325
1325
  style={{ '--topbar-height': '61px' } as React.CSSProperties}
1326
1326
  >
1327
+ <a
1328
+ href="#main-content"
1329
+ className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-tooltip focus:rounded-md focus:border focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-focus"
1330
+ >
1331
+ {t('appShell.skipToMainContent', 'Skip to main content')}
1332
+ </a>
1327
1333
  {/* Desktop sidebar collapse/expand toggle — sits on the divider line between
1328
1334
  sidebar and content, like Notion/Vercel. Hidden on mobile (hamburger in
1329
1335
  topbar handles the drawer). */}
@@ -1499,7 +1505,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1499
1505
  </div>
1500
1506
  </header>
1501
1507
  <ProgressTopBar t={t} className="sticky top-0 z-sticky" completedAutoHideMs={progressCompletedAutoHideMs} />
1502
- <main className="flex-1 p-4 lg:p-6 mx-auto w-full max-w-screen-2xl">
1508
+ <main id="main-content" tabIndex={-1} className="flex-1 p-4 lg:p-6 mx-auto w-full max-w-screen-2xl">
1503
1509
  <InjectionSpot spotId={BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID} context={injectionContext} />
1504
1510
  <FlashMessages />
1505
1511
  <PartialIndexBanner />
@@ -349,6 +349,8 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
349
349
  // Legacy field-only grid toggle. Use `groups` for advanced layout.
350
350
  twoColumn?: boolean
351
351
  title?: string
352
+ /** Semantic level for the form title. Defaults to 2 for section-level compatibility. */
353
+ titleHeadingLevel?: 1 | 2
352
354
  backHref?: string
353
355
  // Optional extra action buttons rendered next to Delete/Cancel/Save
354
356
  // Useful for custom links like "Show Records" etc.
@@ -726,6 +728,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
726
728
  disableOptimisticLock = false,
727
729
  twoColumn = false,
728
730
  title,
731
+ titleHeadingLevel = 2,
729
732
  backHref,
730
733
  entityId,
731
734
  entityIds,
@@ -3677,6 +3680,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3677
3680
  backHref={backHref}
3678
3681
  backLabel={backLabel}
3679
3682
  title={title}
3683
+ titleHeadingLevel={titleHeadingLevel}
3680
3684
  actions={{
3681
3685
  extraActions: headerExtraActions,
3682
3686
  showDelete: !formReadOnly && showDelete,
@@ -3759,6 +3763,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3759
3763
  backHref={backHref}
3760
3764
  backLabel={backLabel}
3761
3765
  title={title}
3766
+ titleHeadingLevel={titleHeadingLevel}
3762
3767
  actions={{
3763
3768
  extraActions: headerExtraActions,
3764
3769
  showDelete: !formReadOnly && showDelete,
@@ -296,6 +296,11 @@ export type DataTableProps<T extends RowData> = {
296
296
  data: T[]
297
297
  toolbar?: React.ReactNode
298
298
  title?: React.ReactNode
299
+ /**
300
+ * Semantic level for the title. String titles default to 2 for section-level
301
+ * compatibility; ReactNode titles remain caller-owned unless this is set.
302
+ */
303
+ titleHeadingLevel?: 1 | 2
299
304
  actions?: React.ReactNode
300
305
  refreshButton?: DataTableRefreshButton
301
306
  sortable?: boolean
@@ -1209,6 +1214,7 @@ export function DataTable<T extends RowData>({
1209
1214
  data,
1210
1215
  toolbar,
1211
1216
  title,
1217
+ titleHeadingLevel,
1212
1218
  actions,
1213
1219
  refreshButton,
1214
1220
  sortable,
@@ -3270,9 +3276,12 @@ export function DataTable<T extends RowData>({
3270
3276
  }
3271
3277
  : undefined
3272
3278
 
3279
+ const TitleHeading = titleHeadingLevel === 1 ? 'h1' : 'h2'
3273
3280
  const titleContent = hasTitle ? (
3274
3281
  <div className="text-base font-semibold leading-tight min-h-[2.25rem] flex items-center">
3275
- {typeof title === 'string' ? <h2 className="text-base font-semibold">{title}</h2> : title}
3282
+ {typeof title === 'string' || titleHeadingLevel
3283
+ ? <TitleHeading className="text-base font-semibold">{title}</TitleHeading>
3284
+ : title}
3276
3285
  </div>
3277
3286
  ) : <div className="min-h-[2.25rem]" />
3278
3287
 
@@ -92,6 +92,7 @@ jest.mock('../devtools', () => ({
92
92
 
93
93
  const dict = {
94
94
  'appShell.productName': 'Mercato',
95
+ 'appShell.skipToMainContent': 'Skip to main content',
95
96
  'appShell.menu': 'Menu',
96
97
  'appShell.toggleSidebar': 'Toggle sidebar',
97
98
  'appShell.collapseSidebar': 'Collapse',
@@ -212,6 +213,19 @@ describe('AppShell', () => {
212
213
  )
213
214
  })
214
215
 
216
+ it('provides a skip link targeting the focusable main content landmark', () => {
217
+ renderWithProviders(
218
+ <AppShell email="demo@example.com" groups={groups}>
219
+ <div>Child content</div>
220
+ </AppShell>,
221
+ { dict },
222
+ )
223
+
224
+ expect(screen.getByRole('link', { name: 'Skip to main content' })).toHaveAttribute('href', '#main-content')
225
+ expect(screen.getByRole('main')).toHaveAttribute('id', 'main-content')
226
+ expect(screen.getByRole('main')).toHaveAttribute('tabindex', '-1')
227
+ })
228
+
215
229
  it('keeps the incoming page breadcrumb when the pathname change and ApplyBreadcrumb land in the same commit', () => {
216
230
  const { rerender } = renderWithProviders(
217
231
  <AppShell email="demo@example.com" groups={groups}>
@@ -64,6 +64,32 @@ describe('CrudForm custom field default application', () => {
64
64
  })
65
65
  })
66
66
 
67
+ it('forwards explicit title heading semantics while preserving the section-level default', () => {
68
+ const { rerender } = renderWithProviders(
69
+ <CrudForm
70
+ title="Edit"
71
+ fields={fields}
72
+ initialValues={{ name: 'Test' }}
73
+ onSubmit={() => {}}
74
+ />,
75
+ { dict },
76
+ )
77
+
78
+ expect(screen.getByRole('heading', { level: 2, name: 'Edit' })).toBeInTheDocument()
79
+
80
+ rerender(
81
+ <CrudForm
82
+ title="Edit"
83
+ titleHeadingLevel={1}
84
+ fields={fields}
85
+ initialValues={{ name: 'Test' }}
86
+ onSubmit={() => {}}
87
+ />,
88
+ )
89
+
90
+ expect(screen.getByRole('heading', { level: 1, name: 'Edit' })).toBeInTheDocument()
91
+ })
92
+
67
93
  it('applies defaultValue to text inputs on create form', async () => {
68
94
  setupMockDefinitions([
69
95
  { key: 'status', kind: 'text', label: 'Status', defaultValue: 'active', formEditable: true },
@@ -19,6 +19,36 @@ jest.mock('../injection/useInjectionDataWidgets', () => ({
19
19
  type Row = { id: string; name: string }
20
20
 
21
21
  describe('DataTable SSR render', () => {
22
+ it('keeps section-level title semantics by default and supports an explicit page heading', () => {
23
+ const columns: ColumnDef<Row>[] = [
24
+ { accessorKey: 'name', header: 'Name' },
25
+ ]
26
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
27
+ try {
28
+ const { rerender } = render(
29
+ <QueryClientProvider client={queryClient}>
30
+ <I18nProvider locale="en" dict={{}}>
31
+ <DataTable columns={columns} data={[]} title="People" />
32
+ </I18nProvider>
33
+ </QueryClientProvider>,
34
+ )
35
+
36
+ expect(screen.getByRole('heading', { level: 2, name: 'People' })).toBeInTheDocument()
37
+
38
+ rerender(
39
+ <QueryClientProvider client={queryClient}>
40
+ <I18nProvider locale="en" dict={{}}>
41
+ <DataTable columns={columns} data={[]} title="People" titleHeadingLevel={1} embedded />
42
+ </I18nProvider>
43
+ </QueryClientProvider>,
44
+ )
45
+
46
+ expect(screen.getByRole('heading', { level: 1, name: 'People' })).toBeInTheDocument()
47
+ } finally {
48
+ queryClient.clear()
49
+ }
50
+ })
51
+
22
52
  it('renders built-in FilterBar when search/filters provided', () => {
23
53
  const columns: ColumnDef<Row>[] = [
24
54
  { accessorKey: 'name', header: 'Name' },
@@ -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
+ })