@nocobase/app-template-default 1.0.0-beta.82 → 1.0.0-beta.84

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 (77) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +9 -1
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/default-access-editor.tsx +13 -2
  7. package/client/components/feedback-form.tsx +474 -0
  8. package/client/components/lifecycle-diagram.tsx +10 -10
  9. package/client/components/permission-editor.tsx +396 -210
  10. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  11. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  12. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  13. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  14. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  15. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  16. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  17. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  18. package/client/hooks/use-list-data.ts +77 -0
  19. package/client/hooks/use-list-location.ts +23 -0
  20. package/client/hooks/use-list-tabs.ts +42 -0
  21. package/client/hooks/use-table-url-state.ts +143 -30
  22. package/client/locales/system/en-US.ts +10 -0
  23. package/client/locales/system/settings-en-US.ts +8 -0
  24. package/client/locales/system/settings-zh-CN.ts +8 -0
  25. package/client/locales/system/zh-CN.ts +8 -0
  26. package/client/pages/settings/default-access-form.tsx +44 -26
  27. package/client/pages/settings/default-access.tsx +56 -27
  28. package/client/pages/settings/hooks.tsx +4 -59
  29. package/client/pages/settings/page-permissions.ts +17 -0
  30. package/client/pages/settings/permissions-model.ts +54 -58
  31. package/client/pages/settings/role-form.tsx +98 -56
  32. package/client/pages/settings/roles.tsx +11 -3
  33. package/client/pages/settings/shared.tsx +43 -33
  34. package/client/pages/settings/user-form.tsx +51 -30
  35. package/client/pages/settings/users.tsx +39 -27
  36. package/client/routing/session-preparation.tsx +10 -3
  37. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  38. package/package.json +13 -12
  39. package/server/providers/index.ts +4 -1
  40. package/server/providers/submission.ts +61 -0
  41. package/tests/client/auth-i18n.test.tsx +9 -0
  42. package/tests/client/back-button.test.tsx +3 -3
  43. package/tests/client/default-access.test.tsx +60 -4
  44. package/tests/client/form-feedback.test.tsx +589 -0
  45. package/tests/client/list-recovery.test.tsx +373 -0
  46. package/tests/client/list-tabs.test.tsx +121 -0
  47. package/tests/client/page-permission-navigation.test.ts +8 -0
  48. package/tests/client/permission-editor.test.tsx +191 -21
  49. package/tests/client/permission-filter-binding.test.tsx +1 -1
  50. package/tests/client/role-return.test.tsx +2 -1
  51. package/tests/client/session-preparation.test.tsx +24 -1
  52. package/tests/client/settings-continuity.test.tsx +5 -4
  53. package/tests/client/settings-feedback.test.tsx +25 -0
  54. package/tests/client/settings-permissions.test.ts +35 -0
  55. package/tests/client/settings.test.tsx +2 -1
  56. package/tests/client/table-url-state.test.tsx +117 -3
  57. package/tests/client/user-drawer.test.tsx +2 -1
  58. package/tests/client/user-permissions.test.tsx +30 -5
  59. package/tests/database/mutation-receipts.test.ts +45 -0
  60. package/tests/fixtures/form-feedback/README.md +33 -0
  61. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  62. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  63. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  64. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  65. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  66. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  67. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  68. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  69. package/tests/fixtures/submission/README.md +35 -0
  70. package/tests/fixtures/submission/browser.test.ts +478 -0
  71. package/tests/fixtures/submission/migration.ts +61 -0
  72. package/tests/fixtures/submission/page.tsx +138 -0
  73. package/tests/fixtures/submission/playwright.config.ts +11 -0
  74. package/tests/fixtures/submission/routes.ts +208 -0
  75. package/tests/helpers/settings-client.tsx +31 -0
  76. package/tests/server/member-role.test.ts +7 -1
  77. package/tests/server/submission-settings.test.ts +241 -0
@@ -0,0 +1,369 @@
1
+ import { RouteChildPage } from '#components/route-child-page';
2
+ // Test-only business customization. Install in an isolated generated application's client/pages/.
3
+ import { FormFeedback, useApiClient } from '@nocobase/app-client';
4
+ import { useTranslation } from '@nocobase/i18n/client';
5
+ import {
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ type ReactElement,
11
+ } from 'react';
12
+ import { FeedbackForm } from '#components/feedback-form';
13
+ import { Button } from '#components/ui/button';
14
+ import { Input } from '#components/ui/input';
15
+ import { RouteDialog } from '#components/route-dialog';
16
+ import { RouteDrawer } from '#components/route-drawer';
17
+ import { useLocation, useNavigate } from 'react-router';
18
+ import { PageContainer } from '#components/page-container';
19
+
20
+ interface Row {
21
+ key: string;
22
+ name: string;
23
+ }
24
+
25
+ export default function EquipmentFixture(): ReactElement {
26
+ const api = useApiClient();
27
+ const { t } = useTranslation();
28
+ const [name, setName] = useState('Equipment draft');
29
+ const [department, setDepartment] = useState('Operations');
30
+ const [rows, setRows] = useState<Row[]>([
31
+ { key: 'camera', name: 'Camera' },
32
+ { key: 'tripod', name: 'Tripod' },
33
+ ]);
34
+ const [tab, setTab] = useState('details');
35
+ const [expanded, setExpanded] = useState(false);
36
+ const [hidden, setHidden] = useState(false);
37
+ const [disabled, setDisabled] = useState(false);
38
+ const [mode, setMode] = useState('structured');
39
+ const location = useLocation();
40
+ const navigate = useNavigate();
41
+ const container = location.pathname.endsWith('/drawer')
42
+ ? 'drawer'
43
+ : location.pathname.endsWith('/dialog')
44
+ ? 'dialog'
45
+ : location.pathname.endsWith('/edit')
46
+ ? 'detail-edit'
47
+ : 'page';
48
+ const [request, setRequest] = useState<
49
+ { id: string; workflow: string } | undefined
50
+ >();
51
+ const [saved, setSaved] = useState(false);
52
+ const [record, setRecord] = useState<unknown>();
53
+ const [feedback] = useState(
54
+ () =>
55
+ new FormFeedback({
56
+ messages: {
57
+ invalid: 'Check the fields.',
58
+ failed: 'Unable to save.',
59
+ denied: 'Access denied.',
60
+ network: 'Check your connection.',
61
+ },
62
+ }),
63
+ );
64
+ const liveRef = useRef({ rows, hidden, disabled });
65
+ const revealWaitersRef = useRef<(() => void)[]>([]);
66
+ useLayoutEffect(() => {
67
+ liveRef.current = { rows, hidden, disabled };
68
+ if (tab === 'items' && expanded) {
69
+ for (const resolve of revealWaitersRef.current.splice(0)) resolve();
70
+ }
71
+ }, [rows, hidden, disabled, tab, expanded]);
72
+ const anchorsRef = useRef(new Map<string, HTMLDivElement>());
73
+ const inputsRef = useRef(new Map<string, HTMLInputElement>());
74
+ useEffect(() => {
75
+ const remove = ['camera', 'tripod'].map((key) =>
76
+ feedback.register({
77
+ id: key,
78
+ path: () =>
79
+ `items.${liveRef.current.rows.findIndex((row) => row.key === key)}.name`,
80
+ value: () => liveRef.current.rows.find((row) => row.key === key)?.name,
81
+ label: () => (key === 'camera' ? 'Camera item' : 'Tripod item'),
82
+ anchor: () => anchorsRef.current.get(key) ?? null,
83
+ control: () => inputsRef.current.get(key) ?? null,
84
+ accessible: () =>
85
+ !liveRef.current.hidden &&
86
+ liveRef.current.rows.some((row) => row.key === key),
87
+ editable: () => !liveRef.current.disabled,
88
+ reveal: () => {
89
+ if (inputsRef.current.has(key)) return Promise.resolve();
90
+ return new Promise<void>((resolve) => {
91
+ revealWaitersRef.current.push(resolve);
92
+ setTab('items');
93
+ setExpanded(true);
94
+ });
95
+ },
96
+ }),
97
+ );
98
+ return () => remove.forEach((dispose) => dispose());
99
+ }, [feedback]);
100
+ useEffect(() => {
101
+ void api
102
+ .request<{ data: unknown }>({ path: 'equipmentFixture' })
103
+ .then((result) => setRecord(result.data));
104
+ }, [api]);
105
+ const content = (
106
+ <FeedbackForm
107
+ feedback={feedback}
108
+ fieldMessage={({ reason }) =>
109
+ reason === 'DEPARTMENT'
110
+ ? t('equipment.departmentError')
111
+ : t('equipment.itemError')
112
+ }
113
+ className='flex flex-col gap-4'
114
+ onSubmit={async (event) => {
115
+ event.preventDefault();
116
+ setSaved(false);
117
+ if (mode === 'network')
118
+ throw new TypeError('Test connection interruption');
119
+ const result = await api.request<{
120
+ data: { id: string; workflow: string };
121
+ }>({
122
+ path: 'equipmentFixture',
123
+ method: 'POST',
124
+ json: {
125
+ mode,
126
+ name,
127
+ department,
128
+ items: rows,
129
+ attachment: 'safety-check.pdf',
130
+ },
131
+ });
132
+ setRequest(result.data);
133
+ setSaved(true);
134
+ }}
135
+ >
136
+ <div>
137
+ <label htmlFor='equipment-name'>{t('equipment.name')}</label>
138
+ <Input
139
+ id='equipment-name'
140
+ name='name'
141
+ required
142
+ value={name}
143
+ aria-describedby='name-help'
144
+ onChange={(event) => setName(event.target.value)}
145
+ />
146
+ <p id='name-help'>{t('equipment.help')}</p>
147
+ </div>
148
+ <div>
149
+ <label htmlFor='department'>{t('equipment.department')}</label>
150
+ <Input
151
+ id='department'
152
+ name='department'
153
+ required
154
+ value={department}
155
+ onChange={(event) => setDepartment(event.target.value)}
156
+ />
157
+ </div>
158
+ <div>
159
+ <label htmlFor='attachment'>{t('equipment.attachment')}</label>
160
+ <Input id='attachment' name='attachment' type='file' />
161
+ <p>{t('equipment.retainedAttachment')}: safety-check.pdf</p>
162
+ </div>
163
+ <div
164
+ className='flex gap-2'
165
+ role='tablist'
166
+ aria-label='Equipment sections'
167
+ >
168
+ <Button
169
+ type='button'
170
+ role='tab'
171
+ aria-selected={tab === 'details'}
172
+ onClick={() => setTab('details')}
173
+ >
174
+ Details
175
+ </Button>
176
+ <Button
177
+ type='button'
178
+ role='tab'
179
+ aria-selected={tab === 'items'}
180
+ onClick={() => setTab('items')}
181
+ >
182
+ Items
183
+ </Button>
184
+ </div>
185
+ <div
186
+ className='h-48 overflow-y-auto rounded-lg border p-4'
187
+ aria-label='Scrollable equipment fields'
188
+ >
189
+ <div className='min-h-96'>
190
+ <p>{t('equipment.longForm')}</p>
191
+ </div>
192
+ <Button
193
+ type='button'
194
+ aria-expanded={expanded}
195
+ onClick={() => setExpanded(!expanded)}
196
+ >
197
+ Borrowed items
198
+ </Button>
199
+ {rows.map((row, index) => (
200
+ <div
201
+ key={row.key}
202
+ ref={(node) => {
203
+ if (node) anchorsRef.current.set(row.key, node);
204
+ else anchorsRef.current.delete(row.key);
205
+ }}
206
+ >
207
+ {tab === 'items' && expanded && !hidden && (
208
+ <div>
209
+ <label htmlFor={`row-${row.key}`}>
210
+ {row.key === 'camera' ? 'Camera item' : 'Tripod item'}
211
+ </label>
212
+ <Input
213
+ id={`row-${row.key}`}
214
+ name={`items.${index}.name`}
215
+ value={row.name}
216
+ disabled={disabled}
217
+ ref={(node) => {
218
+ if (node) inputsRef.current.set(row.key, node);
219
+ else inputsRef.current.delete(row.key);
220
+ }}
221
+ onChange={(event) => {
222
+ feedback.clear(row.key);
223
+ setRows(
224
+ rows.map((entry) =>
225
+ entry.key === row.key
226
+ ? { ...entry, name: event.target.value }
227
+ : entry,
228
+ ),
229
+ );
230
+ }}
231
+ />
232
+ </div>
233
+ )}
234
+ </div>
235
+ ))}
236
+ </div>
237
+ <div className='flex flex-wrap gap-2'>
238
+ <Button
239
+ type='button'
240
+ variant='outline'
241
+ onClick={() => setRows([...rows].reverse())}
242
+ >
243
+ Reorder rows
244
+ </Button>
245
+ <Button
246
+ type='button'
247
+ variant='outline'
248
+ onClick={() => setRows(rows.filter((row) => row.key !== 'camera'))}
249
+ >
250
+ Delete camera row
251
+ </Button>
252
+ <Button
253
+ type='button'
254
+ variant='outline'
255
+ onClick={() => setHidden(!hidden)}
256
+ >
257
+ Toggle field access
258
+ </Button>
259
+ <Button
260
+ type='button'
261
+ variant='outline'
262
+ onClick={() => setDisabled(!disabled)}
263
+ >
264
+ Toggle disabled
265
+ </Button>
266
+ </div>
267
+ <label>
268
+ {t('equipment.response')}
269
+ <select
270
+ aria-label='Response'
271
+ value={mode}
272
+ onChange={(event) => setMode(event.target.value)}
273
+ >
274
+ {[
275
+ 'structured',
276
+ 'business',
277
+ 'unknown',
278
+ 'denied',
279
+ 'network',
280
+ 'success',
281
+ ].map((value) => (
282
+ <option key={value}>{value}</option>
283
+ ))}
284
+ </select>
285
+ </label>
286
+ <Button type='submit'>{t('equipment.save')}</Button>
287
+ {saved && <p role='status'>{t('equipment.saved')}</p>}
288
+ {request && (
289
+ <div>
290
+ <p data-testid='workflow-status'>
291
+ {request.id}: {request.workflow}
292
+ </p>
293
+ {request.workflow === 'submitted' && (
294
+ <Button
295
+ type='button'
296
+ onClick={async () => {
297
+ const result = await api.request<{
298
+ data: { id: string; workflow: string };
299
+ }>({
300
+ path: 'equipmentFixture',
301
+ method: 'POST',
302
+ json: {
303
+ mode: 'approve',
304
+ recordId: request.id,
305
+ name,
306
+ department,
307
+ items: rows,
308
+ attachment: 'safety-check.pdf',
309
+ },
310
+ });
311
+ setRequest(result.data);
312
+ }}
313
+ >
314
+ Approve request
315
+ </Button>
316
+ )}
317
+ </div>
318
+ )}
319
+ </FeedbackForm>
320
+ );
321
+ return (
322
+ <PageContainer>
323
+ <h1 className='text-xl font-semibold'>{t('equipment.title')}</h1>
324
+ <p data-testid='custom-style' className='font-serif'>
325
+ {t('equipment.customCopy')}
326
+ </p>
327
+ <pre data-testid='retained-record'>{JSON.stringify(record)}</pre>
328
+ <label>
329
+ Container
330
+ <select
331
+ aria-label='Container'
332
+ value={container}
333
+ onChange={(event) =>
334
+ void navigate(
335
+ event.target.value === 'page'
336
+ ? '/equipment'
337
+ : `/equipment/${event.target.value === 'detail-edit' ? 'retained/edit' : event.target.value}`,
338
+ )
339
+ }
340
+ >
341
+ {['page', 'dialog', 'drawer', 'detail-edit'].map((value) => (
342
+ <option key={value}>{value}</option>
343
+ ))}
344
+ </select>
345
+ </label>
346
+ {container === 'dialog' ? (
347
+ <RouteDialog
348
+ title='Equipment borrowing'
349
+ description='Customized equipment request'
350
+ closeTo='/equipment'
351
+ >
352
+ {content}
353
+ </RouteDialog>
354
+ ) : container === 'drawer' ? (
355
+ <RouteDrawer
356
+ title='Equipment borrowing'
357
+ description='Customized equipment request'
358
+ closeTo='/equipment'
359
+ >
360
+ {content}
361
+ </RouteDrawer>
362
+ ) : container === 'detail-edit' ? (
363
+ <RouteChildPage>{content}</RouteChildPage>
364
+ ) : (
365
+ <section aria-label='Equipment form'>{content}</section>
366
+ )}
367
+ </PageContainer>
368
+ );
369
+ }
@@ -0,0 +1,174 @@
1
+ import { createRequire } from 'node:module';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { Buffer } from 'node:buffer';
6
+ import { log } from 'node:console';
7
+ const require = createRequire(path.join(process.cwd(), 'package.json'));
8
+ const { chromium, expect } = require('@playwright/test');
9
+ const [base, out, name] = process.argv.slice(2);
10
+ if (!/^http:\/\/127\.0\.0\.1:\d+\/main\/$/.test(base ?? ''))
11
+ throw new Error('Supply the isolated application entry.');
12
+ fs.mkdirSync(out, { recursive: true });
13
+ const browser = await chromium.launch();
14
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
15
+ const pageErrors = [];
16
+ page.on('pageerror', (error) => pageErrors.push(error.message));
17
+ const shot = (point) =>
18
+ page.screenshot({
19
+ path: path.join(out, `${name}-${point}.png`),
20
+ fullPage: true,
21
+ });
22
+ try {
23
+ await page.goto(base + 'login');
24
+ await page
25
+ .getByLabel('Username or email')
26
+ .fill(process.env.TEST_ADMIN_USERNAME ?? 'nocobase');
27
+ await page
28
+ .getByLabel('Password', { exact: true })
29
+ .fill(process.env.TEST_ADMIN_PASSWORD ?? 'admin123');
30
+ await page.getByRole('button', { name: 'Sign in', exact: true }).click();
31
+ await expect(page.getByLabel('Username or email')).not.toBeVisible();
32
+ await page.goto(base + 'equipment-validation');
33
+ const group = page.getByRole('form', {
34
+ name: 'Equipment group',
35
+ exact: true,
36
+ });
37
+ const save = group.getByRole('button', {
38
+ name: 'Save equipment group',
39
+ exact: true,
40
+ });
41
+ const entry = group.getByRole('button', {
42
+ name: 'Equipment group',
43
+ exact: true,
44
+ });
45
+ await save.click();
46
+ await expect(entry).toBeFocused();
47
+ await expect(group.getByRole('alert')).toHaveCount(0);
48
+ await expect(group.getByRole('list')).toHaveCount(0);
49
+ const errorId = await entry.getAttribute('aria-describedby');
50
+ await expect(page.locator(`[id="${errorId}"]`)).toHaveText(
51
+ 'Add at least two equipment items.',
52
+ );
53
+ await save.click();
54
+ await expect(
55
+ group.getByText('Add at least two equipment items.', { exact: true }),
56
+ ).toHaveCount(1);
57
+ await expect(group.getByLabel('Equipment item 1')).toHaveValue('Camera');
58
+ await shot('group-inline');
59
+ await entry.click();
60
+ await save.click();
61
+ await expect(group.getByRole('status')).toHaveText('Equipment group saved.');
62
+ await expect(group.locator('[data-form-feedback-error]')).toHaveCount(0);
63
+ const unique = page.getByLabel('Unique name', { exact: true });
64
+ await page
65
+ .getByRole('button', { name: 'Save unique name', exact: true })
66
+ .click();
67
+ const controlled = page.getByRole('form', {
68
+ name: 'Unique equipment',
69
+ exact: true,
70
+ });
71
+ await expect(unique).toBeFocused();
72
+ await expect(controlled.getByRole('alert')).toHaveCount(0);
73
+ await expect(
74
+ controlled.getByText('Choose a unique equipment name.', { exact: true }),
75
+ ).toHaveCount(1);
76
+ await unique.fill('Retained correction');
77
+ await expect(
78
+ controlled.getByText('Choose a unique equipment name.', { exact: true }),
79
+ ).toHaveCount(0);
80
+ await expect(unique).toHaveAttribute('aria-invalid', 'false');
81
+ await expect(unique).toHaveAttribute('aria-describedby', 'updated-help');
82
+ await expect(unique).toHaveValue('Retained correction');
83
+ await shot('corrected-inline');
84
+ await page.goto(base + 'equipment/drawer');
85
+ const draft = page.getByLabel('Equipment name', { exact: true });
86
+ await draft.fill('Retained equipment draft');
87
+ const file = page.getByLabel('Attachment', { exact: true });
88
+ await file.setInputFiles({
89
+ name: 'equipment.txt',
90
+ mimeType: 'text/plain',
91
+ buffer: Buffer.from('retained equipment'),
92
+ });
93
+ const submit = page.getByRole('button', {
94
+ name: 'Save request',
95
+ exact: true,
96
+ });
97
+ await submit.click();
98
+ await expect(page.locator('#row-camera')).toBeFocused();
99
+ await expect(
100
+ page.getByRole('tab', { name: 'Items', exact: true }),
101
+ ).toHaveAttribute('aria-selected', 'true');
102
+ await expect(
103
+ page.getByRole('button', { name: 'Borrowed items', exact: true }),
104
+ ).toHaveAttribute('aria-expanded', 'true');
105
+ await expect(page.getByRole('alert')).toHaveCount(0);
106
+ await expect(page.locator('[data-form-feedback-error]')).toHaveCount(2);
107
+ expect(page.url()).toBe(base + 'equipment/drawer');
108
+ await page.locator('#row-camera').fill('Corrected camera');
109
+ await expect(page.locator('[data-form-feedback-error]')).toHaveCount(1);
110
+ await expect(page.locator('#row-tripod')).toHaveAttribute(
111
+ 'aria-invalid',
112
+ 'true',
113
+ );
114
+ await expect(draft).toHaveValue('Retained equipment draft');
115
+ expect(await file.evaluate((input) => input.files[0].name)).toBe(
116
+ 'equipment.txt',
117
+ );
118
+ await shot('drawer-inline');
119
+ await page.locator('#row-tripod').fill('Corrected tripod');
120
+ await expect(page.locator('[data-form-feedback-error]')).toHaveCount(0);
121
+ await page.getByLabel('Response', { exact: true }).selectOption('success');
122
+ await submit.click();
123
+ await expect(page.getByRole('status')).toHaveText(
124
+ 'Saved; workflow submitted.',
125
+ );
126
+ await expect(page.getByRole('alert')).toHaveCount(0);
127
+ await page.getByLabel('Response', { exact: true }).selectOption('unknown');
128
+ await submit.click();
129
+ await expect(page.getByRole('alert')).toBeFocused();
130
+ await expect(page.getByRole('alert')).toContainText('Unable to save.');
131
+ await expect(page.getByRole('alert').getByRole('button')).toHaveCount(0);
132
+ await expect(page.getByRole('alert')).not.toContainText('Private');
133
+ await shot('form-error');
134
+ await page.getByLabel('Response', { exact: true }).selectOption('network');
135
+ await submit.click();
136
+ await expect(page.getByRole('alert')).toContainText('Unable to connect.');
137
+ await expect(draft).toHaveValue('Retained equipment draft');
138
+ await page.getByLabel('Response', { exact: true }).selectOption('denied');
139
+ await submit.click();
140
+ await expect(page.getByRole('alert')).toContainText('current access');
141
+ await page.getByLabel('Response', { exact: true }).selectOption('structured');
142
+ await page
143
+ .getByRole('button', { name: 'Toggle disabled', exact: true })
144
+ .click();
145
+ await submit.click();
146
+ await expect(page.getByRole('alert')).toBeFocused();
147
+ await expect(page.getByRole('alert')).toContainText('Unable to save.');
148
+ await expect(page.getByRole('alert').getByRole('button')).toHaveCount(0);
149
+ expect(pageErrors).toEqual([]);
150
+ fs.writeFileSync(
151
+ path.join(out, `${name}-inline-checks.json`),
152
+ JSON.stringify(
153
+ {
154
+ base,
155
+ checked: [
156
+ 'array-level error is inline only and first editable entry focused',
157
+ 'repeated submission and successful group retry clean errors',
158
+ 'controlled field reason, correction and help association',
159
+ 'real drawer remains open and regions reveal before row focus',
160
+ 'row reasons are inline only and clear independently',
161
+ 'draft, file and successful request retry retained',
162
+ 'unknown, network and permission failures keep safe form copy',
163
+ 'unavailable field focus uses a safe accessible fallback',
164
+ ],
165
+ pageErrors,
166
+ },
167
+ null,
168
+ 2,
169
+ ),
170
+ );
171
+ log(`${name}: inline feedback browser checks passed`);
172
+ } finally {
173
+ await browser.close();
174
+ }