@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/AppShell.js +9 -1
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +3 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +3 -1
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +332 -0
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +7 -0
- package/dist/backend/dev/DevRuntimeReporter.js +36 -0
- package/dist/backend/dev/DevRuntimeReporter.js.map +7 -0
- package/dist/backend/forms/FormHeader.js +3 -1
- package/dist/backend/forms/FormHeader.js.map +2 -2
- package/dist/backend/messages/MessageComposer.js +1 -0
- package/dist/backend/messages/MessageComposer.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -1
- package/src/backend/CrudForm.tsx +5 -0
- package/src/backend/DataTable.tsx +10 -1
- package/src/backend/__tests__/AppShell.test.tsx +14 -0
- package/src/backend/__tests__/CrudForm.defaults.test.tsx +26 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +30 -0
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +495 -0
- package/src/backend/__tests__/DevRuntimeReporter.test.tsx +145 -0
- package/src/backend/__tests__/FormHeader.test.tsx +9 -2
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +412 -0
- package/src/backend/dev/DevRuntimeReporter.tsx +46 -0
- package/src/backend/forms/FormHeader.tsx +5 -1
- package/src/backend/messages/MessageComposer.tsx +1 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +10 -0
package/src/backend/AppShell.tsx
CHANGED
|
@@ -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 />
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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'
|
|
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
|
+
})
|