@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85

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 (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. package/client/source-extensions.ts +0 -19
@@ -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
+ }
@@ -0,0 +1,373 @@
1
+ import process from 'node:process';
2
+ import console from 'node:console';
3
+ import { Buffer } from 'node:buffer';
4
+ import { URL } from 'node:url';
5
+ /* global document */
6
+ // Run only against an authorized isolated application with the equipment fixture installed.
7
+ import { chromium, expect } from '@playwright/test';
8
+ import { mkdir, writeFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+
11
+ const [base, output, name = 'application', legacy] = process.argv.slice(2);
12
+ if (
13
+ !base ||
14
+ !output ||
15
+ !/^http:\/\/(127\.0\.0\.1|localhost):\d+\/main\/$/.test(base)
16
+ )
17
+ throw new Error(
18
+ 'Supply the isolated server entry URL and an evidence directory.',
19
+ );
20
+ await mkdir(output, { recursive: true });
21
+ const browser = await chromium.launch();
22
+ const page = await browser.newPage({ viewport: { width: 1280, height: 960 } });
23
+ const failures = [];
24
+ page.on('pageerror', (error) => failures.push(error.message));
25
+ await page.goto(`${base}login`, { waitUntil: 'domcontentloaded' });
26
+ if (legacy !== '--legacy') {
27
+ await page.getByRole('button', { name: 'Sign in', exact: true }).click();
28
+ await expect(page.getByLabel('Username or email')).toBeFocused();
29
+ await expect(page.getByLabel('Username or email')).toHaveAttribute(
30
+ 'aria-invalid',
31
+ 'true',
32
+ );
33
+ await page
34
+ .getByLabel('Username or email')
35
+ .fill(process.env.TEST_ADMIN_USERNAME ?? 'nocobase');
36
+ await page
37
+ .getByLabel('Password', { exact: true })
38
+ .fill('incorrect-fixture-password');
39
+ await page.getByRole('button', { name: 'Sign in', exact: true }).click();
40
+ await expect(page.locator('form [role="alert"]').first()).toBeVisible();
41
+ await expect(page.getByLabel('Password', { exact: true })).toHaveValue(
42
+ 'incorrect-fixture-password',
43
+ );
44
+ await page.screenshot({
45
+ path: join(output, `${name}-native-login.png`),
46
+ fullPage: true,
47
+ animations: 'disabled',
48
+ });
49
+ }
50
+ await page
51
+ .getByLabel('Username or email')
52
+ .fill(process.env.TEST_ADMIN_USERNAME ?? 'nocobase');
53
+ await page
54
+ .getByLabel('Password', { exact: true })
55
+ .fill(process.env.TEST_ADMIN_PASSWORD ?? 'admin123');
56
+ await page.getByRole('button', { name: 'Sign in', exact: true }).click();
57
+ await expect(page.getByLabel('Username or email')).not.toBeVisible();
58
+
59
+ async function open(container = 'page') {
60
+ await page.goto(`${base}equipment`, { waitUntil: 'domcontentloaded' });
61
+ await expect(page.getByTestId('retained-record')).toContainText(
62
+ 'Legacy camera',
63
+ );
64
+ if (container !== 'page') {
65
+ await page.getByLabel('Container', { exact: true }).selectOption(container);
66
+ await expect(page).toHaveURL(
67
+ new RegExp(
68
+ container === 'detail-edit'
69
+ ? '/equipment/retained/edit$'
70
+ : `/equipment/${container}$`,
71
+ ),
72
+ );
73
+ await expect(
74
+ page.getByLabel('Equipment name', { exact: true }),
75
+ ).toBeVisible();
76
+ }
77
+ }
78
+ async function save() {
79
+ await page.getByRole('button', { name: 'Save request', exact: true }).click();
80
+ }
81
+ async function inline(locator) {
82
+ await expect(locator).toHaveAttribute('aria-invalid', 'true');
83
+ const ids = (await locator.getAttribute('aria-describedby')).split(/\s+/);
84
+ expect(
85
+ await page.evaluate(
86
+ (ids) => ids.every((id) => document.getElementById(id)),
87
+ ids,
88
+ ),
89
+ ).toBe(true);
90
+ }
91
+ async function delayedRequest(action) {
92
+ let release;
93
+ let reached;
94
+ let complete;
95
+ const completed = new Promise((resolve) => {
96
+ complete = resolve;
97
+ });
98
+ const reachedPromise = new Promise((resolve) => {
99
+ reached = resolve;
100
+ });
101
+ const gate = new Promise((resolve) => {
102
+ release = resolve;
103
+ });
104
+ await page.route('**/api/equipmentFixture', async (route) => {
105
+ if (route.request().method() !== 'POST') return route.continue();
106
+ reached();
107
+ await gate;
108
+ await route.continue();
109
+ complete();
110
+ });
111
+ await save();
112
+ await reachedPromise;
113
+ await action();
114
+ release();
115
+ await completed;
116
+ await page.unroute('**/api/equipmentFixture');
117
+ }
118
+
119
+ await open();
120
+ const before = await page.getByTestId('retained-record').innerText();
121
+ await writeFile(join(output, `${name}-record.json`), before);
122
+ if (legacy === '--legacy') {
123
+ await save();
124
+ await expect(page.getByRole('alert')).toHaveText('Unable to save equipment.');
125
+ await expect(page.locator('#row-camera')).toHaveCount(0);
126
+ await page.screenshot({
127
+ path: join(output, `${name}-before.png`),
128
+ fullPage: true,
129
+ animations: 'disabled',
130
+ });
131
+ await browser.close();
132
+ console.log(`${name}: legacy failure reproduced; record captured`);
133
+ process.exit(0);
134
+ }
135
+
136
+ const checked = [
137
+ 'native template sign-in validation and request failure preserve input',
138
+ ];
139
+ for (const container of ['page', 'dialog', 'drawer', 'detail-edit']) {
140
+ await open(container);
141
+ const routeBeforeFailure = page.url();
142
+ const title = page.getByLabel('Equipment name', { exact: true });
143
+ await title.fill('');
144
+ await save();
145
+ await expect(title).toBeFocused();
146
+ await inline(title);
147
+ expect(await title.getAttribute('aria-describedby')).toContain('name-help');
148
+ await title.fill('Retained equipment draft');
149
+ await page.getByLabel('Attachment', { exact: true }).setInputFiles({
150
+ name: 'equipment.txt',
151
+ mimeType: 'text/plain',
152
+ buffer: Buffer.from('attachment fixture'),
153
+ });
154
+ await save();
155
+ await expect(page.locator('#row-camera')).toBeFocused();
156
+ await inline(page.locator('#row-camera'));
157
+ await expect(
158
+ page.getByRole('tab', { name: 'Items', exact: true }),
159
+ ).toHaveAttribute('aria-selected', 'true');
160
+ await expect(
161
+ page.getByRole('button', { name: 'Borrowed items', exact: true }),
162
+ ).toHaveAttribute('aria-expanded', 'true');
163
+ await expect(title).toHaveValue('Retained equipment draft');
164
+ expect(
165
+ await page
166
+ .getByLabel('Attachment', { exact: true })
167
+ .evaluate((input) => input.files[0]?.name),
168
+ ).toBe('equipment.txt');
169
+ await page.locator('#row-camera').fill('Corrected camera');
170
+ await expect(page.locator('#row-camera')).toBeFocused();
171
+ await expect(page.locator('#row-camera')).not.toHaveAttribute('aria-invalid');
172
+ await page.screenshot({
173
+ path: join(output, `${name}-${container}.png`),
174
+ fullPage: true,
175
+ animations: 'disabled',
176
+ });
177
+ expect(page.url()).toBe(routeBeforeFailure);
178
+ checked.push(
179
+ `${container}: native errors, region reveal, inline association, scroll/focus, input and file retention`,
180
+ );
181
+ }
182
+
183
+ await open();
184
+ await delayedRequest(() =>
185
+ page.getByRole('button', { name: 'Reorder rows', exact: true }).click(),
186
+ );
187
+ await expect(page.locator('#row-tripod')).toBeFocused();
188
+ await inline(page.locator('#row-camera'));
189
+ checked.push('request snapshot follows stable rows after reordering');
190
+ await open();
191
+ await delayedRequest(() =>
192
+ page.getByRole('button', { name: 'Delete camera row', exact: true }).click(),
193
+ );
194
+ await expect(page.locator('#row-tripod')).toBeFocused();
195
+ await expect(page.locator('#row-camera')).toHaveCount(0);
196
+ checked.push('deleted rows do not receive response errors');
197
+ await open();
198
+ await page.getByRole('tab', { name: 'Items', exact: true }).click();
199
+ await page.getByRole('button', { name: 'Borrowed items', exact: true }).click();
200
+ await delayedRequest(() =>
201
+ page.locator('#row-camera').fill('Edited while request was pending'),
202
+ );
203
+ await expect(page.locator('#row-tripod')).toBeFocused();
204
+ await expect(page.locator('#row-camera')).not.toHaveAttribute('aria-invalid');
205
+ checked.push('edits during a request discard stale field errors');
206
+
207
+ await open();
208
+ await page.route('**/api/equipmentFixture', (route) =>
209
+ route.request().method() === 'POST'
210
+ ? route.abort('failed')
211
+ : route.continue(),
212
+ );
213
+ await page.getByLabel('Response', { exact: true }).selectOption('success');
214
+ await save();
215
+ await expect(page.getByRole('alert')).toBeFocused();
216
+ await expect(page.getByRole('alert')).toContainText('Unable to connect');
217
+ await page.unroute('**/api/equipmentFixture');
218
+ checked.push(
219
+ 'aborted HTTP transport preserves input and reports a connection failure',
220
+ );
221
+ for (const mode of ['unknown', 'denied', 'network']) {
222
+ await open();
223
+ await page.getByLabel('Response', { exact: true }).selectOption(mode);
224
+ await save();
225
+ await expect(page.getByRole('alert')).toBeFocused();
226
+ await expect(page.getByLabel('Equipment name', { exact: true })).toHaveValue(
227
+ 'Equipment draft',
228
+ );
229
+ await expect(page.locator('body')).not.toContainText('Private developer');
230
+ await expect(page.locator('body')).not.toContainText('Secret salary');
231
+ checked.push(`${mode}: safe form error and retained input`);
232
+ }
233
+ await open();
234
+ await page
235
+ .getByRole('button', { name: 'Toggle field access', exact: true })
236
+ .click();
237
+ await save();
238
+ await expect(page.getByRole('alert')).toBeFocused();
239
+ await expect(page.locator('#row-camera')).toHaveCount(0);
240
+ await expect(page.getByRole('alert')).not.toContainText('Camera item');
241
+ await expect(
242
+ page.getByRole('tab', { name: 'Details', exact: true }),
243
+ ).toHaveAttribute('aria-selected', 'true');
244
+ checked.push('inaccessible fields do not appear or open their region');
245
+ await open();
246
+ await page
247
+ .getByRole('button', { name: 'Toggle disabled', exact: true })
248
+ .click();
249
+ await save();
250
+ await expect(page.getByRole('alert')).toBeFocused();
251
+ await page.keyboard.press('Tab');
252
+ await expect(page.getByLabel('Equipment name', { exact: true })).toBeFocused();
253
+ checked.push('disabled fields fall back to a keyboard-accessible safe message');
254
+ await open();
255
+ await page.getByLabel('Response', { exact: true }).selectOption('business');
256
+ await save();
257
+ await expect(page.getByLabel('Department', { exact: true })).toBeFocused();
258
+ await inline(page.getByLabel('Department', { exact: true }));
259
+ await page.getByLabel('Department', { exact: true }).fill('Operations');
260
+ await page.getByLabel('Response', { exact: true }).selectOption('success');
261
+ await save();
262
+ await expect(page.getByRole('status')).toHaveText('Saved; workflow submitted.');
263
+ await expect(page.getByRole('alert')).toHaveCount(0);
264
+ await expect(page.getByTestId('workflow-status')).toContainText('submitted');
265
+ const submittedId = (
266
+ await page.getByTestId('workflow-status').innerText()
267
+ ).split(':')[0];
268
+ await page
269
+ .getByRole('button', { name: 'Approve request', exact: true })
270
+ .click();
271
+ await expect(page.getByTestId('workflow-status')).toContainText('approved');
272
+ const persisted = await (
273
+ await page.request.get(`${base}api/equipmentFixture`)
274
+ ).json();
275
+ expect(persisted.requests.find((row) => row.id === submittedId)?.workflow).toBe(
276
+ 'approved',
277
+ );
278
+ expect(
279
+ persisted.history
280
+ .filter((row) => row.recordId === submittedId)
281
+ .map((row) => row.event)
282
+ .sort(),
283
+ ).toEqual(['approved', 'submitted']);
284
+ await page.screenshot({
285
+ path: join(output, `${name}-approved-workflow.png`),
286
+ fullPage: true,
287
+ animations: 'disabled',
288
+ });
289
+ checked.push(
290
+ 'real equipment submission and approval persist with both history events',
291
+ );
292
+ checked.push(
293
+ 'trusted business copy and successful retry clear obsolete errors',
294
+ );
295
+ await open();
296
+ expect(await page.getByTestId('retained-record').innerText()).toBe(before);
297
+ await expect(page.getByTestId('custom-style')).toHaveCSS(
298
+ 'font-family',
299
+ /serif/,
300
+ );
301
+ const restricted = await browser.newContext();
302
+ const member = {
303
+ email: 'restricted@example.test',
304
+ password: 'fixture-member-password',
305
+ };
306
+ const nativeHeaders = { origin: new URL(base).origin };
307
+ let signedIn = await restricted.request.post(`${base}api/auth/sign-in/email`, {
308
+ headers: nativeHeaders,
309
+ data: member,
310
+ });
311
+ if (!signedIn.ok()) {
312
+ const registered = await restricted.request.post(
313
+ `${base}api/auth/sign-up/email`,
314
+ {
315
+ headers: nativeHeaders,
316
+ data: {
317
+ ...member,
318
+ username: 'restricted_fixture',
319
+ name: 'Restricted fixture member',
320
+ },
321
+ },
322
+ );
323
+ expect(registered.ok()).toBe(true);
324
+ signedIn = await restricted.request.post(`${base}api/auth/sign-in/email`, {
325
+ headers: nativeHeaders,
326
+ data: member,
327
+ });
328
+ }
329
+ expect(signedIn.ok()).toBe(true);
330
+ const deniedRead = await restricted.request.get(`${base}api/equipmentFixture`);
331
+ expect(deniedRead.status()).toBe(403);
332
+ expect(await deniedRead.text()).not.toContain('Legacy camera');
333
+ const deniedWrite = await restricted.request.post(
334
+ `${base}api/equipmentFixture`,
335
+ {
336
+ headers: nativeHeaders,
337
+ data: {
338
+ mode: 'success',
339
+ name: 'Denied request',
340
+ department: 'Operations',
341
+ items: [],
342
+ attachment: '',
343
+ },
344
+ },
345
+ );
346
+ expect(deniedWrite.status()).toBe(403);
347
+ const restrictedPage = await restricted.newPage();
348
+ await restrictedPage.goto(`${base}equipment`, {
349
+ waitUntil: 'domcontentloaded',
350
+ });
351
+ await expect(
352
+ restrictedPage.getByRole('heading', { name: 'Access denied', exact: true }),
353
+ ).toBeVisible();
354
+ await expect(
355
+ restrictedPage.getByLabel('Equipment name', { exact: true }),
356
+ ).toHaveCount(0);
357
+ await restricted.close();
358
+ checked.push(
359
+ 'native ordinary identity is denied both business reads and writes without record disclosure',
360
+ );
361
+ expect(failures).toEqual([]);
362
+ await writeFile(
363
+ join(output, `${name}-checks.json`),
364
+ JSON.stringify(
365
+ { base, checked, recordPreserved: true, pageErrors: failures },
366
+ null,
367
+ 2,
368
+ ),
369
+ );
370
+ await browser.close();
371
+ console.log(
372
+ `${name}: ${checked.length} scenario groups passed; business record and custom style preserved`,
373
+ );
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="data:," />
7
+ <title>Permission alignment verification</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script
12
+ type="module"
13
+ src="/tests/fixtures/permission-alignment.tsx"
14
+ ></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,124 @@
1
+ import { useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { PermissionEditor } from '../../client/components/permission-editor';
4
+ import { Button } from '../../client/components/ui/button';
5
+ import { AppThemeProvider } from '../../client/theme';
6
+ import '../../client/styles.css';
7
+
8
+ // A browser fixture for real editor geometry; no server permissions are changed.
9
+ const fields = [
10
+ 'Alerts',
11
+ 'Monitoring rules',
12
+ 'Business processes',
13
+ 'Rule calculations with a deliberately long English label',
14
+ 'Process nodes',
15
+ ];
16
+ const levels = [
17
+ { value: '', label: 'No additional record access' },
18
+ { value: 'all', label: 'All records' },
19
+ { value: 'custom', label: 'Custom conditions' },
20
+ ];
21
+
22
+ export default function Fixture() {
23
+ const params = new URLSearchParams(location.search);
24
+ const readOnly = params.has('readonly');
25
+ const chinese = params.has('chinese');
26
+ const [enabled, setEnabled] = useState<Record<string, boolean>>({});
27
+ const [values, setValues] = useState<Record<string, string>>({});
28
+ const [condition, setCondition] = useState(false);
29
+ return (
30
+ <AppThemeProvider>
31
+ <main className='mx-auto grid max-w-6xl gap-4 p-4'>
32
+ <h1 className='text-xl'>
33
+ Roles and permissions · Alignment verification
34
+ </h1>
35
+ <PermissionEditor
36
+ readOnly={readOnly}
37
+ sections={[
38
+ {
39
+ id: 'business',
40
+ title: 'Business operations',
41
+ groups: [
42
+ ...['Alerts', 'Monitoring rules', 'Responsible units'].map(
43
+ (group) => ({
44
+ id: group,
45
+ title: group,
46
+ rows: (group === 'Monitoring rules'
47
+ ? ['View', 'Configure', 'Run']
48
+ : ['Manage']
49
+ ).map((action) => {
50
+ const id = `${action} ${group.toLowerCase()}`;
51
+ return {
52
+ id,
53
+ label: id,
54
+ levels: [],
55
+ hint: 'Existing permission descriptions remain visible.',
56
+ enabled: enabled[id] ?? true,
57
+ scopes: (group === 'Responsible units'
58
+ ? ['Responsible units']
59
+ : group === 'Alerts'
60
+ ? fields.slice(0, 2)
61
+ : fields
62
+ ).map((label, index) => {
63
+ const scopeId = `${id}:${index}`;
64
+ return {
65
+ id: scopeId,
66
+ label:
67
+ chinese && index === 3
68
+ ? '需要完整显示名称的跨部门监控规则计算数据范围长标签'
69
+ : label,
70
+ levels,
71
+ level:
72
+ values[scopeId] ?? (index === 0 ? 'custom' : ''),
73
+ hint:
74
+ index % 2 === 0
75
+ ? 'Other roles and default rules still apply.'
76
+ : undefined,
77
+ configuration:
78
+ index === 0 ? (
79
+ <Button
80
+ size='sm'
81
+ variant='outline'
82
+ disabled={readOnly}
83
+ onClick={() =>
84
+ setCondition((value) => !value)
85
+ }
86
+ >
87
+ Conditions
88
+ </Button>
89
+ ) : undefined,
90
+ };
91
+ }),
92
+ };
93
+ }),
94
+ }),
95
+ ),
96
+ {
97
+ id: 'without-ranges',
98
+ title: 'Other operations',
99
+ rows: [
100
+ { id: 'create', label: 'Create alert', enabled: true },
101
+ { id: 'delete', label: 'Delete alert', enabled: false },
102
+ ],
103
+ },
104
+ ],
105
+ },
106
+ ]}
107
+ onEnabledChange={(id, value) =>
108
+ setEnabled((current) => ({ ...current, [id]: value }))
109
+ }
110
+ onLevelChange={(id, value) =>
111
+ setValues((current) => ({ ...current, [id]: value }))
112
+ }
113
+ />
114
+ <p role='status'>
115
+ {condition
116
+ ? 'Condition editor requested'
117
+ : 'No condition editor requested'}
118
+ </p>
119
+ </main>
120
+ </AppThemeProvider>
121
+ );
122
+ }
123
+
124
+ createRoot(document.getElementById('root')!).render(<Fixture />);