@nocobase/app-template-default 1.0.0-beta.87 → 1.0.0-beta.89

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 (107) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/notification-templates.md +5 -0
  3. package/.agents/rules/shell.md +9 -3
  4. package/AGENTS.md +4 -2
  5. package/README.MD +11 -5
  6. package/client/components/back-button.tsx +37 -12
  7. package/client/components/body-portal.tsx +25 -0
  8. package/client/components/brand-loading-mark.tsx +5 -2
  9. package/client/components/brand-mark-geometry.ts +3 -3
  10. package/client/components/breadcrumbs.tsx +60 -3
  11. package/client/components/data-table/data-table.tsx +90 -19
  12. package/client/components/data-table/pagination.tsx +17 -2
  13. package/client/components/flow-diagram.tsx +2 -2
  14. package/client/components/list-feedback.tsx +31 -22
  15. package/client/components/loading.tsx +127 -16
  16. package/client/components/pending-button.tsx +111 -0
  17. package/client/components/role-list.tsx +7 -0
  18. package/client/components/route-child-page.tsx +72 -1
  19. package/client/components/route-overlay.tsx +149 -15
  20. package/client/components/task-progress.tsx +155 -0
  21. package/client/components/use-route-overlay.ts +8 -11
  22. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  23. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  29. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  30. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  31. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  32. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  33. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  34. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  35. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  36. package/client/layouts/app-layout.tsx +8 -8
  37. package/client/layouts/components/sidebar-footer.tsx +18 -25
  38. package/client/lib/navigation-transform.ts +156 -22
  39. package/client/locales/system/en-US.ts +26 -5
  40. package/client/locales/system/settings-en-US.ts +17 -0
  41. package/client/locales/system/settings-zh-CN.ts +15 -0
  42. package/client/locales/system/zh-CN.ts +26 -5
  43. package/client/pages/auth/register.tsx +39 -22
  44. package/client/pages/auth/shared.tsx +1 -3
  45. package/client/pages/settings/default-access.tsx +1 -1
  46. package/client/pages/settings/hooks.tsx +3 -68
  47. package/client/pages/settings/notification-routes.ts +109 -0
  48. package/client/pages/settings/notification-templates.tsx +26 -0
  49. package/client/pages/settings/permission-management-page.tsx +15 -90
  50. package/client/pages/settings/roles.tsx +27 -27
  51. package/client/pages/settings/routes.ts +107 -101
  52. package/client/pages/settings/settings-home.tsx +109 -0
  53. package/client/pages/settings/settings-module-page.tsx +158 -0
  54. package/client/pages/settings/shell-routes.ts +53 -0
  55. package/client/pages/settings/user-detail.tsx +3 -17
  56. package/client/pages/settings/user-form.tsx +9 -15
  57. package/client/pages/settings/users.tsx +11 -27
  58. package/client/routes.ts +17 -1
  59. package/client/routing/app-router.tsx +1 -14
  60. package/client/routing/client-route.tsx +199 -30
  61. package/client/routing/navigation-destination.ts +42 -0
  62. package/client/routing/route-context.tsx +30 -3
  63. package/client/routing/route-leave-boundary.tsx +261 -0
  64. package/client/routing/route-navigation.ts +72 -8
  65. package/client/routing/route-tree.tsx +7 -12
  66. package/client/runtime.ts +2 -2
  67. package/client/styles.css +18 -1
  68. package/config.example.yml +1 -1
  69. package/package.json +13 -13
  70. package/server/config/auth.ts +1 -1
  71. package/server/providers/index.ts +2 -0
  72. package/server/providers/notification-management.ts +27 -0
  73. package/tests/client/account-permissions.test.tsx +1 -1
  74. package/tests/client/auth-i18n.test.tsx +1 -1
  75. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  76. package/tests/client/back-button.test.tsx +34 -0
  77. package/tests/client/client-auth.test.tsx +10 -1
  78. package/tests/client/client-route-recovery.test.tsx +93 -1
  79. package/tests/client/client-routes.test.ts +137 -4
  80. package/tests/client/client-shell.test.tsx +23 -8
  81. package/tests/client/foundation-integration.test.ts +38 -17
  82. package/tests/client/layout-i18n.test.tsx +9 -4
  83. package/tests/client/list-feedback.test.tsx +5 -5
  84. package/tests/client/list-loading.test.tsx +397 -0
  85. package/tests/client/loading.test.tsx +20 -11
  86. package/tests/client/manual-list-retry.test.tsx +2 -2
  87. package/tests/client/navigation-parent.test.tsx +172 -0
  88. package/tests/client/pending-button.test.tsx +106 -0
  89. package/tests/client/permission-management-tabs.test.tsx +458 -17
  90. package/tests/client/primitive-labels.test.tsx +2 -2
  91. package/tests/client/registration-feedback.test.tsx +143 -0
  92. package/tests/client/role-return.test.tsx +21 -3
  93. package/tests/client/roles.test.tsx +84 -3
  94. package/tests/client/route-leave-recheck.test.tsx +74 -0
  95. package/tests/client/route-presentation.test.tsx +557 -0
  96. package/tests/client/session-preparation.test.tsx +20 -17
  97. package/tests/client/settings-continuity.test.tsx +35 -11
  98. package/tests/client/settings-home.test.tsx +112 -0
  99. package/tests/client/settings-idle-list.test.tsx +124 -0
  100. package/tests/client/settings-navigation.test.ts +230 -0
  101. package/tests/client/user-drawer.test.tsx +3 -4
  102. package/tests/fixtures/dialog-footer.html +12 -0
  103. package/tests/fixtures/dialog-footer.tsx +215 -0
  104. package/tests/helpers/authenticated-client.tsx +70 -22
  105. package/tests/playwright/dialog-footer.test.ts +227 -0
  106. package/tests/server/notification-templates.test.ts +144 -0
  107. package/tests/client/permission-management-routes.test.ts +0 -64
@@ -0,0 +1,215 @@
1
+ import { useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { createMemoryRouter, Link, Outlet, RouterProvider } from 'react-router';
4
+ import {
5
+ createTestI18nRuntime,
6
+ TestI18nProvider,
7
+ } from '@nocobase/i18n/testing';
8
+ import { Button } from '../../client/components/ui/button';
9
+ import { Input } from '../../client/components/ui/input';
10
+ import { Spinner } from '../../client/components/ui/spinner';
11
+ import {
12
+ Dialog,
13
+ DialogClose,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from '../../client/components/ui/dialog';
20
+ import {
21
+ AlertDialog,
22
+ AlertDialogAction,
23
+ AlertDialogCancel,
24
+ AlertDialogContent,
25
+ AlertDialogDescription,
26
+ AlertDialogFooter,
27
+ AlertDialogHeader,
28
+ AlertDialogTitle,
29
+ } from '../../client/components/ui/alert-dialog';
30
+ import { RouteDialog } from '../../client/components/route-dialog';
31
+ import { RouteDrawer } from '../../client/components/route-drawer';
32
+ import enUS from '../../client/locales/en-US';
33
+ import '../../client/styles.css';
34
+
35
+ // Exercise installed primitives, portals, links and route footers with the real
36
+ // application stylesheet. No backend or authentication is needed for this fixture.
37
+ const params = new URLSearchParams(location.search);
38
+ const kind = params.get('kind') ?? 'dialog';
39
+ const chinese = params.has('chinese');
40
+ const long = params.has('long');
41
+ const pending = params.has('pending');
42
+ const horizontal = params.has('horizontal');
43
+ document.documentElement.dataset.theme = params.get('preset') ?? 'default';
44
+ document.documentElement.classList.toggle('dark', params.has('dark'));
45
+ const runtime = await createTestI18nRuntime({
46
+ application: { namespace: '@nocobase/app-template-default', resources: enUS },
47
+ });
48
+ const cancel = chinese ? '取消并返回实例列表' : 'Cancel';
49
+ const submit = chinese
50
+ ? '确认创建这个具有较长中文名称的应用实例并继续配置'
51
+ : long
52
+ ? 'Create this application instance and continue configuring its workspace'
53
+ : 'Create instance';
54
+ const title = chinese ? '创建应用实例' : 'Create application instance';
55
+ const description = chinese
56
+ ? '输入实例名称,然后创建实例。'
57
+ : 'Enter a name to create an application instance.';
58
+
59
+ function Editor() {
60
+ return (
61
+ <form
62
+ id='instance-form'
63
+ onSubmit={(event) => {
64
+ event.preventDefault();
65
+ void router.navigate('/instances?created=1');
66
+ }}
67
+ >
68
+ <label htmlFor='instance-name'>
69
+ {chinese ? '实例名称' : 'Instance name'}
70
+ </label>
71
+ <Input id='instance-name' required defaultValue='Customer portal' />
72
+ {long && !kind.startsWith('alert') && (
73
+ <div className='h-[900px]' aria-hidden='true' />
74
+ )}
75
+ </form>
76
+ );
77
+ }
78
+
79
+ function Actions() {
80
+ return (
81
+ <>
82
+ {kind === 'dialog-close' ? (
83
+ <DialogClose
84
+ disabled={pending}
85
+ data-testid='cancel'
86
+ render={<Button variant='outline' />}
87
+ >
88
+ {cancel}
89
+ </DialogClose>
90
+ ) : (
91
+ <Button
92
+ variant='outline'
93
+ nativeButton={false}
94
+ disabled={pending}
95
+ data-testid='cancel'
96
+ render={<Link to='/instances?filter=recent' />}
97
+ >
98
+ {cancel}
99
+ </Button>
100
+ )}
101
+ <Button
102
+ type='submit'
103
+ form='instance-form'
104
+ disabled={pending}
105
+ aria-busy={pending}
106
+ data-testid='submit'
107
+ >
108
+ {pending && <Spinner data-icon='inline-start' />}
109
+ {pending ? 'Creating instance…' : submit}
110
+ </Button>
111
+ </>
112
+ );
113
+ }
114
+
115
+ export default function Overlay() {
116
+ const [open, setOpen] = useState(true);
117
+ if (kind === 'route' || kind === 'drawer') {
118
+ const Component = kind === 'drawer' ? RouteDrawer : RouteDialog;
119
+ return (
120
+ <Component
121
+ title={title}
122
+ description={description}
123
+ footer={<Actions />}
124
+ beforeClose={() => !pending}
125
+ >
126
+ <Editor />
127
+ </Component>
128
+ );
129
+ }
130
+ if (kind === 'alert' || kind === 'alert-small') {
131
+ return (
132
+ <AlertDialog
133
+ open={open}
134
+ onOpenChange={(value) => {
135
+ if (!pending) setOpen(value);
136
+ }}
137
+ >
138
+ <AlertDialogContent size={kind === 'alert-small' ? 'sm' : 'default'}>
139
+ <AlertDialogHeader>
140
+ <AlertDialogTitle>{title}</AlertDialogTitle>
141
+ <AlertDialogDescription>{description}</AlertDialogDescription>
142
+ </AlertDialogHeader>
143
+ <Editor />
144
+ <AlertDialogFooter
145
+ className={horizontal ? 'flex-row items-center' : undefined}
146
+ >
147
+ <AlertDialogCancel disabled={pending} data-testid='cancel'>
148
+ {cancel}
149
+ </AlertDialogCancel>
150
+ <AlertDialogAction
151
+ type='submit'
152
+ form='instance-form'
153
+ disabled={pending}
154
+ aria-busy={pending}
155
+ data-testid='submit'
156
+ >
157
+ {pending && <Spinner data-icon='inline-start' />}
158
+ {pending ? 'Creating instance…' : submit}
159
+ </AlertDialogAction>
160
+ </AlertDialogFooter>
161
+ </AlertDialogContent>
162
+ </AlertDialog>
163
+ );
164
+ }
165
+ return (
166
+ <Dialog
167
+ open={open}
168
+ onOpenChange={(value) => {
169
+ if (!pending) setOpen(value);
170
+ }}
171
+ >
172
+ <DialogContent className='flex flex-col overflow-hidden'>
173
+ <DialogHeader>
174
+ <DialogTitle>{title}</DialogTitle>
175
+ <DialogDescription>{description}</DialogDescription>
176
+ </DialogHeader>
177
+ <div className='min-h-0 overflow-y-auto'>
178
+ <Editor />
179
+ </div>
180
+ <DialogFooter
181
+ className={horizontal ? 'flex-row items-center' : undefined}
182
+ >
183
+ <Actions />
184
+ </DialogFooter>
185
+ </DialogContent>
186
+ </Dialog>
187
+ );
188
+ }
189
+
190
+ const router = createMemoryRouter(
191
+ [
192
+ {
193
+ path: '/instances',
194
+ element: (
195
+ <main className='p-6'>
196
+ <h1>Application instances</h1>
197
+ <p data-testid='location'>Instance list</p>
198
+ <Outlet />
199
+ </main>
200
+ ),
201
+ children: [{ path: 'new', element: <Overlay /> }],
202
+ },
203
+ ],
204
+ { initialEntries: ['/instances/new?filter=recent'] },
205
+ );
206
+ router.subscribe((state) => {
207
+ const output = document.querySelector('[data-testid=location]');
208
+ if (output)
209
+ output.textContent = state.location.pathname + state.location.search;
210
+ });
211
+ createRoot(document.getElementById('root')!).render(
212
+ <TestI18nProvider runtime={runtime}>
213
+ <RouterProvider router={router} />
214
+ </TestI18nProvider>,
215
+ );
@@ -1,6 +1,7 @@
1
1
  import type { ReactElement } from 'react';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { CookieJar } from 'jsdom';
3
- import { onTestFinished, vi } from 'vitest';
4
+ import { afterAll, beforeAll, onTestFinished, vi } from 'vitest';
4
5
  import { act, waitFor } from '@testing-library/react';
5
6
  import { createTestApp, type TestApp } from '@nocobase/app-testing/server';
6
7
  import { renderWithApp } from '@nocobase/app-testing/client';
@@ -36,27 +37,18 @@ export async function renderAuthenticatedClient(
36
37
  response: Response,
37
38
  ) => void | Promise<void>;
38
39
  } = {},
40
+ sharedServer?: TestApp,
39
41
  ) {
40
- const server = await createTestApp({
41
- createServer: createStandaloneServer,
42
- connections: ['main'],
43
- config: {
44
- logging: { enabled: false },
45
- app: { publicOrigin: 'http://localhost' },
46
- auth: {
47
- secret: 'test-auth-secret-at-least-32-characters',
48
- emailAndPassword: { disableSignUp: false },
49
- advanced: { disableOriginCheck: false, disableCSRFCheck: false },
50
- },
51
- },
52
- });
53
- onTestFinished(() => server.close());
42
+ const server = sharedServer ?? (await createClientTestServer());
43
+ if (!sharedServer) onTestFinished(() => server.close());
44
+ const identity = sharedServer ? randomUUID() : '';
45
+ const permissionSetKey = identity ? `test-access-${identity}` : 'test-access';
54
46
  const member = {
55
47
  name: 'Test member',
56
- email: 'member@example.test',
48
+ email: identity ? `member-${identity}@example.test` : 'member@example.test',
57
49
  password: 'test-member-password',
58
50
  };
59
- if (options.member) {
51
+ if (options.member || sharedServer) {
60
52
  const response = await server.request('/auth/sign-up/email', {
61
53
  method: 'POST',
62
54
  headers: {
@@ -68,22 +60,77 @@ export async function renderAuthenticatedClient(
68
60
  if (!response.ok)
69
61
  throw new Error(`Member registration failed: ${await response.text()}`);
70
62
  }
63
+ const authz = server.application.container.resolve(authorizationToken);
71
64
  const session = await signIn(
72
65
  server,
73
- options.member ? member : DEFAULT_ADMIN_CREDENTIALS,
66
+ options.member || sharedServer ? member : DEFAULT_ADMIN_CREDENTIALS,
74
67
  );
68
+ if (sharedServer && !options.member) {
69
+ await authz.permissionSets.assign({
70
+ id: randomUUID(),
71
+ permissionSet: 'root',
72
+ subject: { type: 'user', id: session.user.id },
73
+ });
74
+ }
75
75
  if (options.grants) {
76
- const authz = server.application.container.resolve(authorizationToken);
77
76
  await authz.permissionSets.create({
78
- key: 'test-access',
77
+ key: permissionSetKey,
79
78
  grants: options.grants,
80
79
  });
81
80
  await authz.permissionSets.assign({
82
- id: 'test-assignment',
83
- permissionSet: 'test-access',
81
+ id: identity ? `test-assignment-${identity}` : 'test-assignment',
82
+ permissionSet: permissionSetKey,
84
83
  subject: { type: 'user', id: session.user.id },
85
84
  });
86
85
  }
86
+ return renderClient(server, session, permissionSetKey, ui, options);
87
+ }
88
+
89
+ function createClientTestServer() {
90
+ return createTestApp({
91
+ createServer: createStandaloneServer,
92
+ connections: ['main'],
93
+ config: {
94
+ logging: { enabled: false },
95
+ app: { publicOrigin: 'http://localhost' },
96
+ auth: {
97
+ secret: 'test-auth-secret-at-least-32-characters',
98
+ emailAndPassword: { disableSignUp: false },
99
+ advanced: { disableOriginCheck: false, disableCSRFCheck: false },
100
+ },
101
+ },
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Reuse installation within a file whose tests only change their own identity's state.
107
+ * Each render gets a new user, session, grants, cookies and client. Tests that mutate
108
+ * shared application configuration or business data use renderAuthenticatedClient instead.
109
+ */
110
+ export function createAuthenticatedClientRenderer() {
111
+ let server: TestApp | undefined;
112
+ beforeAll(async () => {
113
+ server = await createClientTestServer();
114
+ });
115
+ afterAll(async () => {
116
+ await server?.close();
117
+ });
118
+ return (
119
+ ui: ReactElement,
120
+ options: Parameters<typeof renderAuthenticatedClient>[1] = {},
121
+ ) => {
122
+ if (!server) throw new Error('The test application has not started.');
123
+ return renderAuthenticatedClient(ui, options, server);
124
+ };
125
+ }
126
+
127
+ async function renderClient(
128
+ server: TestApp,
129
+ session: TestSession,
130
+ permissionSetKey: string,
131
+ ui: ReactElement,
132
+ options: NonNullable<Parameters<typeof renderAuthenticatedClient>[1]>,
133
+ ) {
87
134
  await options.prepare?.(server, session);
88
135
  const jar = new CookieJar();
89
136
  const root = `http://localhost${server.publicBasePath.replace(/\/$/u, '')}`;
@@ -135,6 +182,7 @@ export async function renderAuthenticatedClient(
135
182
  ...view,
136
183
  server,
137
184
  session,
185
+ permissionSetKey,
138
186
  identity: () => identity!,
139
187
  async signInAs(credentials: SignInCredentials) {
140
188
  const next = await signIn(server, credentials);
@@ -0,0 +1,227 @@
1
+ /// <reference lib="dom" />
2
+ import { expect, test, type Locator, type Page } from '@playwright/test';
3
+ import path from 'node:path';
4
+
5
+ const applicationUrl = process.env.APP_URL;
6
+ test.skip(!applicationUrl, 'Set APP_URL to the running application entry.');
7
+ const fixture = `${applicationUrl?.replace(/\/$/, '')}/tests/fixtures/dialog-footer.html`;
8
+
9
+ async function geometry(page: Page) {
10
+ return page.getByTestId('cancel').evaluate((cancel) => {
11
+ const submit = document.querySelector('[data-testid=submit]')!;
12
+ const footer = cancel.parentElement!;
13
+ const panel = cancel.closest('[role=dialog], [role=alertdialog]')!;
14
+ const measure = (element: Element) => {
15
+ const rect = element.getBoundingClientRect();
16
+ const css = getComputedStyle(element);
17
+ return {
18
+ x: rect.x,
19
+ y: rect.y,
20
+ right: rect.right,
21
+ bottom: rect.bottom,
22
+ height: rect.height,
23
+ width: rect.width,
24
+ paddingLeft: css.paddingLeft,
25
+ paddingRight: css.paddingRight,
26
+ paddingTop: css.paddingTop,
27
+ paddingBottom: css.paddingBottom,
28
+ minHeight: parseFloat(css.minHeight),
29
+ overflow: element.scrollWidth > element.clientWidth + 1,
30
+ };
31
+ };
32
+ return {
33
+ cancel: measure(cancel),
34
+ submit: measure(submit),
35
+ footer: measure(footer),
36
+ panel: measure(panel),
37
+ horizontal:
38
+ getComputedStyle(footer).flexDirection === 'row' ||
39
+ getComputedStyle(footer).display === 'grid',
40
+ };
41
+ });
42
+ }
43
+
44
+ async function checkGeometry(page: Page) {
45
+ await expect(page.getByTestId('cancel')).toBeVisible();
46
+ let previous = '';
47
+ await expect
48
+ .poll(async () => {
49
+ const current = JSON.stringify(await geometry(page));
50
+ const stable = current === previous;
51
+ previous = current;
52
+ return stable;
53
+ })
54
+ .toBe(true);
55
+ const result = await geometry(page);
56
+ const { cancel, submit, footer, panel, horizontal } = result;
57
+ expect(cancel.height).toBeGreaterThanOrEqual(cancel.minHeight - 1);
58
+ expect(submit.height).toBeGreaterThanOrEqual(submit.minHeight - 1);
59
+ expect(cancel.paddingLeft).toBe(submit.paddingLeft);
60
+ expect(cancel.paddingRight).toBe(submit.paddingRight);
61
+ expect(cancel.paddingTop).toBe(submit.paddingTop);
62
+ expect(cancel.paddingBottom).toBe(submit.paddingBottom);
63
+ if (horizontal) {
64
+ expect(Math.abs(cancel.y - submit.y)).toBeLessThan(1);
65
+ expect(Math.abs(cancel.bottom - submit.bottom)).toBeLessThan(1);
66
+ expect(cancel.right).toBeLessThanOrEqual(submit.x + 1);
67
+ } else {
68
+ expect(submit.bottom).toBeLessThanOrEqual(cancel.y + 1);
69
+ }
70
+ for (const button of [cancel, submit]) {
71
+ expect(button.overflow).toBe(false);
72
+ expect(button.x).toBeGreaterThanOrEqual(panel.x);
73
+ expect(button.right).toBeLessThanOrEqual(panel.right + 1);
74
+ expect(button.y).toBeGreaterThanOrEqual(footer.y);
75
+ expect(button.bottom).toBeLessThanOrEqual(panel.bottom + 1);
76
+ expect(button.bottom).toBeLessThanOrEqual(page.viewportSize()!.height);
77
+ }
78
+ expect(panel.x).toBeGreaterThanOrEqual(0);
79
+ expect(panel.right).toBeLessThanOrEqual(page.viewportSize()!.width);
80
+ expect(panel.overflow).toBe(false);
81
+ return result;
82
+ }
83
+
84
+ async function screenshot(page: Page, name: string) {
85
+ if (process.env.DIALOG_EVIDENCE_DIR) {
86
+ await page.screenshot({
87
+ path: path.join(process.env.DIALOG_EVIDENCE_DIR, `${name}.png`),
88
+ });
89
+ }
90
+ }
91
+
92
+ for (const kind of [
93
+ 'dialog',
94
+ 'dialog-close',
95
+ 'alert',
96
+ 'alert-small',
97
+ 'route',
98
+ 'drawer',
99
+ ]) {
100
+ for (const preset of ['compact', 'default']) {
101
+ for (const width of [1440, 375]) {
102
+ test(`${kind}, ${preset}, ${width}: semantic actions align and keep their padding`, async ({
103
+ page,
104
+ }) => {
105
+ await page.setViewportSize({ width, height: 900 });
106
+ await page.goto(`${fixture}?kind=${kind}&preset=${preset}`);
107
+ const result = await checkGeometry(page);
108
+ expect(result.cancel.height).toBeCloseTo(result.submit.height, 0);
109
+ expect(result.cancel.minHeight).toBe(preset === 'compact' ? 36 : 40);
110
+ expect(result.cancel.paddingLeft).toBe(
111
+ preset === 'compact' ? '14.4px' : '16px',
112
+ );
113
+ if (width > 640 && kind !== 'alert-small')
114
+ expect(result.cancel.width).toBeLessThan(result.submit.width);
115
+ if (preset === 'default') await screenshot(page, `${kind}-${width}-en`);
116
+ });
117
+ }
118
+ }
119
+
120
+ for (const language of ['english', 'chinese']) {
121
+ test(`${kind}: long ${language} labels remain visible on a short phone viewport`, async ({
122
+ page,
123
+ }) => {
124
+ await page.setViewportSize({ width: 375, height: 667 });
125
+ await page.goto(
126
+ `${fixture}?kind=${kind}&long&${language === 'chinese' ? 'chinese&' : ''}dark`,
127
+ );
128
+ await checkGeometry(page);
129
+ await screenshot(page, `${kind}-375-long-${language}`);
130
+ });
131
+ }
132
+
133
+ test(`${kind}: disabled and submitting actions retain geometry`, async ({
134
+ page,
135
+ }) => {
136
+ await page.setViewportSize({ width: 375, height: 900 });
137
+ await page.goto(`${fixture}?kind=${kind}&pending`);
138
+ await checkGeometry(page);
139
+ await expect(page.getByTestId('cancel')).toBeDisabled();
140
+ await expect(page.getByTestId('submit')).toBeDisabled();
141
+ await expect(page.getByTestId('submit')).toHaveAttribute(
142
+ 'aria-busy',
143
+ 'true',
144
+ );
145
+ await page.keyboard.press('Escape');
146
+ await expect(page.getByTestId('submit')).toBeVisible();
147
+ });
148
+ }
149
+
150
+ for (const kind of ['dialog', 'alert']) {
151
+ test(`${kind}: a phone row wraps long labels without overflowing`, async ({
152
+ page,
153
+ }) => {
154
+ await page.setViewportSize({ width: 375, height: 667 });
155
+ await page.goto(`${fixture}?kind=${kind}&horizontal&chinese`);
156
+ await checkGeometry(page);
157
+ });
158
+ }
159
+
160
+ async function expectKeyboardFocus(action: Locator) {
161
+ await action.focus();
162
+ await expect(action).toBeFocused();
163
+ expect(
164
+ await action.evaluate(
165
+ (element) =>
166
+ getComputedStyle(element).outlineStyle !== 'none' ||
167
+ getComputedStyle(element).boxShadow !== 'none',
168
+ ),
169
+ ).toBe(true);
170
+ }
171
+
172
+ for (const kind of ['dialog', 'route', 'drawer']) {
173
+ test(`${kind}: link cancel and native form submit remain independent`, async ({
174
+ page,
175
+ }) => {
176
+ await page.goto(`${fixture}?kind=${kind}`);
177
+ await checkGeometry(page);
178
+ const cancel = page.getByTestId('cancel');
179
+ await expect(cancel).toHaveAttribute('href', '/instances?filter=recent');
180
+ expect(await cancel.evaluate((element) => element.tagName)).toBe('A');
181
+ await expectKeyboardFocus(cancel);
182
+ await page.keyboard.press('Enter');
183
+ await expect(page.getByTestId('location')).toHaveText(
184
+ '/instances?filter=recent',
185
+ );
186
+ await expect(page.getByTestId('submit')).toHaveCount(0);
187
+ await page.goto(`${fixture}?kind=${kind}`);
188
+ await checkGeometry(page);
189
+ await page.getByLabel('Instance name').fill('New customer portal');
190
+ await expectKeyboardFocus(page.getByTestId('submit'));
191
+ await page.keyboard.press('Enter');
192
+ await expect(page.getByTestId('location')).toHaveText(
193
+ '/instances?created=1',
194
+ );
195
+ });
196
+ }
197
+
198
+ test('route footer stays visible when the body scrolls', async ({ page }) => {
199
+ await page.setViewportSize({ width: 375, height: 667 });
200
+ await page.goto(`${fixture}?kind=route&long`);
201
+ const before = await checkGeometry(page);
202
+ await page.locator('[data-slot=route-overlay-body]').evaluate((element) => {
203
+ element.scrollTop = element.scrollHeight;
204
+ });
205
+ await expect
206
+ .poll(() =>
207
+ page
208
+ .locator('[data-slot=route-overlay-body]')
209
+ .evaluate((element) => element.scrollTop),
210
+ )
211
+ .toBeGreaterThan(0);
212
+ const after = await checkGeometry(page);
213
+ expect(after.footer.y).toBe(before.footer.y);
214
+ await page.getByTestId('cancel').click();
215
+ await expect(page.getByTestId('location')).toHaveText(
216
+ '/instances?filter=recent',
217
+ );
218
+ });
219
+
220
+ // DialogClose replaces Button's semantic slot just as AlertDialog's actions do.
221
+ test('DialogClose cancels without submitting the form', async ({ page }) => {
222
+ await page.goto(`${fixture}?kind=dialog-close`);
223
+ await checkGeometry(page);
224
+ await page.getByTestId('cancel').click();
225
+ await expect(page.getByTestId('submit')).toHaveCount(0);
226
+ await expect(page.getByTestId('location')).toHaveText('Instance list');
227
+ });