openxiangda-cli 2.0.0-alpha.169 → 2.0.0-alpha.170

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 (36) hide show
  1. package/package.json +5 -5
  2. package/template/AGENTS.md +38 -24
  3. package/template/README.md +14 -17
  4. package/template/apps/web/e2e/component-defaults-fixture.css +15 -0
  5. package/template/apps/web/e2e/data-api-live-fixture.tsx +3 -3
  6. package/template/apps/web/e2e/field-protocol-fixture.tsx +48 -11
  7. package/template/apps/web/e2e/field-protocol.spec.ts +90 -0
  8. package/template/apps/web/e2e/mobile-files.spec.ts +93 -0
  9. package/template/apps/web/e2e/mobile-reference-fixture.tsx +314 -0
  10. package/template/apps/web/e2e/mobile-reference.spec.ts +360 -0
  11. package/template/apps/web/e2e/mobile-selection.spec.ts +199 -0
  12. package/template/apps/web/e2e/resource-experience-fixture.tsx +54 -9
  13. package/template/apps/web/e2e/resource-platform-mock.ts +202 -0
  14. package/template/apps/web/e2e/resources.spec.ts +77 -240
  15. package/template/apps/web/e2e/standard-admin.spec.ts +441 -0
  16. package/template/apps/web/e2e/workflow.spec.ts +2 -2
  17. package/template/apps/web/field-protocol.e2e.html +7 -0
  18. package/template/apps/web/mobile-reference.e2e.html +27 -0
  19. package/template/apps/web/package.json +1 -1
  20. package/template/apps/web/resource-experience.e2e.html +1 -0
  21. package/template/apps/web/src/document.css +5 -0
  22. package/template/apps/web/src/main.tsx +1 -0
  23. package/template/apps/web/src/user-end/tailwind.css +1 -13
  24. package/template/apps/web/test/contracts.test.ts +7 -6
  25. package/template/apps/web/vite.config.ts +1 -0
  26. package/template/appspec/app.md +27 -1
  27. package/template/openxiangda.config.ts +2 -24
  28. package/template/package.json +3 -4
  29. package/template/apps/server/Dockerfile +0 -20
  30. package/template/apps/server/package.json +0 -27
  31. package/template/apps/server/src/app.module.ts +0 -23
  32. package/template/apps/server/src/main.ts +0 -5
  33. package/template/apps/server/test/smoke.test.ts +0 -17
  34. package/template/apps/server/tsconfig.json +0 -9
  35. package/template/apps/web/e2e/theme-contract-fixture.css +0 -15
  36. package/template/scripts/verify-template-budget.mjs +0 -49
@@ -0,0 +1,199 @@
1
+ import { expect, test, type Page } from '@playwright/test';
2
+
3
+ const field = (page: Page, label: string) => page.locator(`[data-field-code="${label}"]`);
4
+ const picker = (page: Page, label: string) => field(page, label).getByRole('dialog', { name: `选择${label}` });
5
+ const open = async (page: Page, label: string) => {
6
+ await field(page, label).getByRole('button', { name: `选择${label}`, exact: true }).click();
7
+ await expect(picker(page, label)).toBeVisible();
8
+ };
9
+ const submit = async (page: Page) => {
10
+ await page.getByRole('button', { name: '验证表单', exact: true }).click();
11
+ await expect(page.locator('[data-saved-values]')).not.toBeEmpty();
12
+ return JSON.parse(await page.locator('[data-saved-values]').innerText());
13
+ };
14
+ const department = (id: string, label: string, hasChildren = false, selectable = true) => ({
15
+ kind: 'department', id, label, hasChildren, selectable,
16
+ snapshot: { value: id, label, fullPath: `学校 / ${label}` },
17
+ });
18
+ const member = (id: string, label: string, selectable = true) => ({
19
+ kind: 'user', id, label, selectable,
20
+ snapshot: { value: id, label, employeeNo: `E-${id}`, departments: [{ value: 'college-a', label: '理学院' }] },
21
+ });
22
+
23
+ test.describe('mobile platform selection fields', () => {
24
+ test.beforeEach(async ({ page }) => {
25
+ await page.setViewportSize({ width: 390, height: 844 });
26
+ await page.route('**/china-divisions/**', route => route.fulfill({ json: [] }));
27
+ await page.route('**/files/*/content?**', route => route.fulfill({ status: 204, body: '' }));
28
+ });
29
+
30
+ test('member browsing, paging and search retain staged snapshots until confirmation', async ({ page }) => {
31
+ const errors: string[] = [];
32
+ page.on('pageerror', error => errors.push(error.message));
33
+ const queries: string[] = [];
34
+ await page.route('**/directory/**', async route => {
35
+ const url = new URL(route.request().url());
36
+ queries.push(url.pathname + url.search);
37
+ const tree = url.pathname.endsWith('/tree');
38
+ const browse = url.pathname.endsWith('/users') && url.pathname.includes('/departments/');
39
+ const items = tree ? [department('college-a', '理学院')]
40
+ : browse ? (url.searchParams.get('page') === '2' ? [member('user-3', '李老师')] : [member('user-2', '王老师'), member('blocked', '不可选成员', false)])
41
+ : [member('user-4', '陈老师')];
42
+ await route.fulfill({ json: { schemaVersion: 'openxiangda.directory-entry-page/v2', kind: tree ? 'department' : 'user', items,
43
+ nextCursor: browse && url.searchParams.get('page') === '1' ? '2' : null } });
44
+ });
45
+ await page.goto('/field-protocol.e2e.html?mode=mobile');
46
+ await open(page, '成员多选');
47
+ const sheet = picker(page, '成员多选');
48
+ await sheet.getByRole('button', { name: '进入理学院' }).click();
49
+ await expect(sheet.getByText('不可选成员', { exact: true })).toBeVisible();
50
+ await expect(sheet.locator('.adm-check-list-item').filter({ hasText: '不可选成员' })).toHaveAttribute('aria-disabled', 'true');
51
+ await sheet.getByText('不可选成员', { exact: true }).click({ force: true });
52
+ await expect(sheet.getByLabel('已选项目')).not.toContainText('不可选成员');
53
+ await sheet.getByText('王老师', { exact: true }).click();
54
+ await sheet.getByRole('button', { name: '更多成员' }).click();
55
+ await sheet.getByText('李老师', { exact: true }).click();
56
+ await sheet.getByPlaceholder('搜索成员多选').fill('陈老师');
57
+ await sheet.getByText('陈老师', { exact: true }).click();
58
+ await expect(sheet.getByLabel('已选项目')).toContainText('王老师');
59
+ await expect(sheet.getByLabel('已选项目')).toContainText('李老师');
60
+ await sheet.getByRole('button', { name: '关闭', exact: true }).click();
61
+ await expect(field(page, '成员多选').getByRole('button', { name: '选择成员多选', exact: true })).toHaveText('张三');
62
+
63
+ await open(page, '成员多选');
64
+ await sheet.getByRole('button', { name: '清空', exact: true }).click();
65
+ await sheet.getByRole('button', { name: '进入理学院' }).click();
66
+ await sheet.getByText('王老师', { exact: true }).click();
67
+ await sheet.getByPlaceholder('搜索成员多选').fill('陈老师');
68
+ await sheet.getByText('陈老师', { exact: true }).click();
69
+ await sheet.getByRole('button', { name: '确定(2)', exact: true }).click();
70
+ const saved = await submit(page);
71
+ expect(saved['成员多选']).toEqual([member('user-2', '王老师').snapshot, member('user-4', '陈老师').snapshot]);
72
+ await page.getByRole('button', { name: '重新载入已保存值' }).click();
73
+ await expect(field(page, '成员多选').getByRole('button', { name: '选择成员多选', exact: true })).toHaveText('王老师、陈老师');
74
+ expect(queries.some(query => query.includes('page=2'))).toBe(true);
75
+ expect(errors).toEqual([]);
76
+ await open(page, '成员多选');
77
+ await sheet.getByRole('button', { name: '进入理学院' }).click();
78
+ await expect(sheet.getByText('王老师', { exact: true }).last()).toBeVisible();
79
+ await page.screenshot({ path: 'test-results/mobile-member-picker.png' });
80
+ });
81
+
82
+ test('explicit mobile department control browses disabled ancestors and retries failed pages', async ({ page }) => {
83
+ await page.setViewportSize({ width: 1200, height: 900 });
84
+ let failures = 1;
85
+ await page.route('**/directory/**', async route => {
86
+ const url = new URL(route.request().url());
87
+ const parent = url.searchParams.get('parentId');
88
+ if (parent === 'root' && failures-- > 0) { await route.fulfill({ status: 503, json: { code: 'OPENXIANGDA_DIRECTORY_REQUEST_FAILED', message: '目录暂时不可用' } }); return; }
89
+ await route.fulfill({ json: { schemaVersion: 'openxiangda.directory-entry-page/v2', kind: 'department',
90
+ items: parent === 'root' ? [department('college-a', '理学院'), department('college-b', '文学院')]
91
+ : url.searchParams.get('offset') ? [department('office', '办公室')]
92
+ : [department('root', '学校', true, false)], nextCursor: !parent && !url.searchParams.get('offset') ? '100' : null } });
93
+ });
94
+ await page.goto('/field-protocol.e2e.html?mode=mobile');
95
+ await open(page, '部门多选');
96
+ const sheet = picker(page, '部门多选');
97
+ await expect(sheet.locator('.ant-tree, .ant-select, .ant-drawer')).toHaveCount(0);
98
+ await sheet.getByRole('button', { name: '清空', exact: true }).click();
99
+ await sheet.getByRole('button', { name: '更多部门' }).click();
100
+ await sheet.getByText('办公室', { exact: true }).click();
101
+ await sheet.getByRole('button', { name: '进入学校' }).click();
102
+ await expect(sheet.getByRole('alert')).toContainText('目录暂时不可用');
103
+ await sheet.getByRole('button', { name: '重试', exact: true }).click();
104
+ await sheet.getByText('文学院', { exact: true }).click();
105
+ await sheet.getByRole('button', { name: '确定(2)', exact: true }).click();
106
+ expect((await submit(page))['部门多选']).toEqual([department('office', '办公室').snapshot, department('college-b', '文学院').snapshot]);
107
+ await open(page, '部门单选');
108
+ await picker(page, '部门单选').getByRole('button', { name: '进入学校' }).click();
109
+ await picker(page, '部门单选').getByText('文学院', { exact: true }).click();
110
+ await picker(page, '部门单选').getByRole('button', { name: '确定(1)', exact: true }).click();
111
+ expect((await submit(page))['部门单选']).toEqual(department('college-b', '文学院').snapshot);
112
+ await page.setViewportSize({ width: 390, height: 844 });
113
+ await open(page, '部门多选');
114
+ await sheet.getByRole('button', { name: '进入学校' }).click();
115
+ await expect(sheet.getByText('文学院', { exact: true }).last()).toBeVisible();
116
+ await page.screenshot({ path: 'test-results/mobile-department-picker.png' });
117
+ });
118
+
119
+ test('reference search ignores stale pages, preserves off-page choices and retries without losing them', async ({ page }) => {
120
+ let releaseOld: (() => void) | undefined;
121
+ let oldFinished = false;
122
+ let failed = false;
123
+ const reference = (value: string, label: string) => ({ value, label, resourceCode: 'field-lookups', snapshot: { code: `CODE-${value}` } });
124
+ await page.route('**/source/query', async route => {
125
+ const request = route.request().postDataJSON();
126
+ if (request.cursor === 'old-next') { await new Promise<void>(resolve => { releaseOld = resolve; }); }
127
+ if (request.keyword === '故障' && !failed) { failed = true; await route.fulfill({ status: 503, json: { code: 'OPENXIANGDA_DATA_SOURCE_FAILED', message: '候选加载失败' } }); return; }
128
+ const items = request.cursor ? [reference('stale', '过期候选')] : request.keyword ? [reference('b', '测量服务')] : [reference('a', '检测设备')];
129
+ await route.fulfill({ json: { schemaVersion: 'openxiangda.data-field-source-page/v2',
130
+ environment: { id: 'env', key: 'production', activeAppVersionId: 'version', headRevision: 1, authzRevisionId: 'authz' },
131
+ resourceCode: 'field-records', fieldCode: request.fieldCode, operation: request.operation, sourceResourceCode: 'field-lookups',
132
+ items, nextCursor: request.keyword || request.cursor ? null : 'old-next' } });
133
+ if (request.cursor) oldFinished = true;
134
+ });
135
+ await page.goto('/field-protocol.e2e.html?mode=mobile');
136
+ await open(page, '动态多选下拉');
137
+ const sheet = picker(page, '动态多选下拉');
138
+ await sheet.getByRole('button', { name: '清空', exact: true }).click();
139
+ await sheet.getByText('检测设备', { exact: true }).click();
140
+ await sheet.getByRole('button', { name: '加载更多', exact: true }).click();
141
+ await expect.poll(() => Boolean(releaseOld)).toBe(true);
142
+ await sheet.getByPlaceholder('搜索动态多选下拉').fill('测量');
143
+ await sheet.getByText('测量服务', { exact: true }).click();
144
+ releaseOld!();
145
+ await expect.poll(() => oldFinished).toBe(true);
146
+ await expect(sheet.getByText('过期候选')).toHaveCount(0);
147
+ await expect(sheet.getByRole('button', { name: '加载更多', exact: true })).toHaveCount(0);
148
+ await expect(sheet.getByLabel('已选项目')).toContainText('检测设备');
149
+ await page.screenshot({ path: 'test-results/mobile-reference-picker.png' });
150
+ await sheet.getByPlaceholder('搜索动态多选下拉').fill('故障');
151
+ await expect(sheet.getByRole('alert')).toContainText('候选加载失败');
152
+ await sheet.getByRole('button', { name: '重试', exact: true }).click();
153
+ await expect(sheet.getByText('测量服务', { exact: true }).last()).toBeVisible();
154
+ await sheet.getByPlaceholder('搜索动态多选下拉').press('Enter');
155
+ await expect(page.locator('[data-saved-values]')).toBeEmpty();
156
+ await sheet.getByRole('button', { name: '确定(2)', exact: true }).click();
157
+ expect((await submit(page))['动态多选下拉']).toEqual([reference('a', '检测设备'), reference('b', '测量服务')]);
158
+ await page.getByRole('button', { name: '重新载入已保存值' }).click();
159
+ await open(page, '动态多选下拉');
160
+ await sheet.getByRole('button', { name: '移除检测设备', exact: true }).click();
161
+ await sheet.getByRole('button', { name: '确定(1)', exact: true }).click();
162
+ expect((await submit(page))['动态多选下拉']).toEqual([reference('b', '测量服务')]);
163
+ });
164
+
165
+ test('cascade drilldown and path search commit complete single/multiple snapshots', async ({ page }) => {
166
+ await page.goto('/field-protocol.e2e.html?mode=mobile');
167
+ await open(page, '级联多选');
168
+ const sheet = picker(page, '级联多选');
169
+ await sheet.getByRole('button', { name: '清空', exact: true }).click();
170
+ await sheet.getByRole('button', { name: '进入设备' }).click();
171
+ await sheet.getByText('光谱仪', { exact: true }).click();
172
+ await sheet.getByRole('button', { name: '首页', exact: true }).click();
173
+ await sheet.getByPlaceholder('搜索选项或路径').fill('服务');
174
+ await sheet.getByText('培训', { exact: true }).click();
175
+ await expect(sheet.getByLabel('已选项目')).toContainText('设备 / 光谱仪');
176
+ await expect(sheet.getByLabel('已选项目')).toContainText('服务 / 培训');
177
+ await page.screenshot({ path: 'test-results/mobile-cascade-picker.png' });
178
+ await sheet.getByRole('button', { name: '关闭', exact: true }).click();
179
+ await expect(field(page, '级联多选').getByRole('button', { name: '选择级联多选', exact: true })).toHaveText('设备 / 显微镜');
180
+ await open(page, '级联多选');
181
+ await sheet.getByRole('button', { name: '进入设备' }).click();
182
+ await sheet.getByText('光谱仪', { exact: true }).click();
183
+ await sheet.getByRole('button', { name: '确定(2)', exact: true }).click();
184
+ const expected = [
185
+ [{ label: '设备', value: 'equipment' }, { label: '显微镜', value: 'microscope' }],
186
+ [{ label: '设备', value: 'equipment' }, { label: '光谱仪', value: 'spectrometer' }],
187
+ ];
188
+ expect((await submit(page))['级联多选']).toEqual(expected);
189
+ await open(page, '级联单选');
190
+ await picker(page, '级联单选').getByRole('button', { name: '首页', exact: true }).click();
191
+ await picker(page, '级联单选').getByRole('button', { name: '进入服务' }).click();
192
+ await picker(page, '级联单选').getByText('培训', { exact: true }).click();
193
+ await picker(page, '级联单选').getByRole('button', { name: '确定', exact: true }).click();
194
+ expect((await submit(page))['级联单选']).toEqual([{ label: '服务', value: 'service' }, { label: '培训', value: 'training' }]);
195
+ await page.getByRole('button', { name: '重新载入已保存值' }).click();
196
+ await expect(field(page, '级联单选').getByRole('button', { name: '选择级联单选', exact: true })).toHaveText('服务 / 培训');
197
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(0);
198
+ });
199
+ });
@@ -1,6 +1,7 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
- import { Button, Typography } from 'antd';
3
+ import { Button, Input, Modal, Select, Typography } from 'antd';
4
+ import { useNavigate } from 'react-router-dom';
4
5
  import {
5
6
  defineApplicationContributions,
6
7
  OpenXiangdaAdminPage,
@@ -8,7 +9,7 @@ import {
8
9
  } from 'openxiangda/react';
9
10
  import type { DataFieldSurface, DataResourceSurface } from 'openxiangda/core';
10
11
  import 'openxiangda/react/styles.css';
11
- import './theme-contract-fixture.css';
12
+ import './component-defaults-fixture.css';
12
13
 
13
14
  const field = (
14
15
  label: string,
@@ -158,9 +159,29 @@ const surface: DataResourceSurface = {
158
159
  };
159
160
 
160
161
  const { enabled, displayOrder, ...remainingFields } = fields;
161
- const acceptanceSurface = new URLSearchParams(location.search).get('primary') === 'falsy'
162
+ const fixtureMode = new URLSearchParams(location.search).get('primary') || (location.pathname.includes('/views/') ? 'named' : location.pathname.endsWith('.html') ? null : 'business');
163
+ const acceptanceSurface: DataResourceSurface = fixtureMode === 'falsy'
162
164
  ? { ...surface, fields: { enabled, displayOrder, ...remainingFields } }
163
- : surface;
165
+ : fixtureMode === 'business' || fixtureMode === 'named'
166
+ ? {
167
+ ...surface,
168
+ fields: { ...fields, name: { ...fields.name, list: true, requiredHint: true, sortable: true }, displayOrder: { ...fields.displayOrder, sortable: true } },
169
+ list: { ...surface.list, fieldOrder: ['name', 'enabled', 'displayOrder'], filterFields: ['name', 'enabled', 'displayOrder', ...surface.list!.filterFields!], defaultPageSize: 10 },
170
+ }
171
+ : surface;
172
+
173
+ if (fixtureMode === 'named') acceptanceSurface.views = [
174
+ { code: 'quick', name: '简要登记', generated: { list: true, detail: true, create: true, update: true, delete: false },
175
+ list: { fieldOrder: ['name', 'code'], defaultPageSize: 10, searchableFields: ['name'] },
176
+ form: { layout: 'sections', fieldOrder: ['name', 'code'] }, detail: { layout: 'sections', fieldOrder: ['name', 'code'] },
177
+ sections: [{ title: '登记信息', fields: ['name', 'code'] }], mobile: { enabled: true } },
178
+ { code: 'complete', name: '完整管理', generated: { list: true, detail: true, create: true, update: true, delete: true },
179
+ list: { fieldOrder: ['name', 'enabled', 'displayOrder'], defaultPageSize: 10, searchableFields: ['name'] },
180
+ form: { layout: 'sections', fieldOrder: ['name', 'code', 'description', 'enabled', 'displayOrder'] },
181
+ detail: { layout: 'sections', fieldOrder: ['name', 'code', 'description', 'enabled', 'displayOrder'] },
182
+ sections: [{ title: '栏目信息', fields: ['name', 'code', 'description'] }, { title: '管理设置', fields: ['enabled', 'displayOrder'] }],
183
+ mobile: { enabled: true } },
184
+ ];
164
185
 
165
186
  const names = [
166
187
  '内容栏目',
@@ -197,21 +218,32 @@ const resourceDefinitions = Object.fromEntries(
197
218
  );
198
219
 
199
220
  function ContentOperationsPage() {
221
+ const navigate = useNavigate();
200
222
  return (
201
223
  <OpenXiangdaAdminPage
202
- className="theme-contract-page"
224
+ className="component-defaults-page"
203
225
  data-testid="custom-admin-page"
204
226
  >
205
227
  <Typography.Title level={2}>内容业务操作</Typography.Title>
206
- <div className="theme-contract-panel">
207
- 自定义后台页语义主题面板
228
+ <div className="component-defaults-panel">
229
+ 自定义后台页默认组件面板
230
+ <Button onClick={() => navigate('/resource-01')}>打开用户端</Button>
208
231
  </div>
209
232
  </OpenXiangdaAdminPage>
210
233
  );
211
234
  }
212
235
 
213
236
  function ResourcePortalPage() {
214
- return <Typography.Title level={2}>用户端内容门户</Typography.Title>;
237
+ const [open, setOpen] = useState(false);
238
+ return <main>
239
+ <Typography.Title level={2}>用户端内容门户</Typography.Title>
240
+ <Input aria-label="用户端输入" />
241
+ <Select options={[{ label: '可用选项', value: 'available' }]} style={{ width: 200 }} />
242
+ <Button onClick={() => setOpen(true)}>打开用户弹窗</Button>
243
+ <Modal open={open} title="用户端确认" onCancel={() => setOpen(false)} onOk={() => setOpen(false)}>
244
+ 默认组件保持可用
245
+ </Modal>
246
+ </main>;
215
247
  }
216
248
 
217
249
  const applicationContributions = defineApplicationContributions(
@@ -244,6 +276,7 @@ const applicationContributions = defineApplicationContributions(
244
276
  toolbar: [
245
277
  {
246
278
  code: 'publish-selected',
279
+ requiresSelection: true,
247
280
  label: '发布选中栏目',
248
281
  capability: 'app:openxiangda-application:operations:read',
249
282
  render: ({ selectedRecords }) => (
@@ -272,6 +305,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
272
305
  icon: 'database',
273
306
  order: 0,
274
307
  items: [
308
+ ...(acceptanceSurface.views || []).map((view, index) => ({
309
+ pageCode: `resource:resource-01:view:${view.code}:list`, label: view.name, order: index - 2,
310
+ })),
275
311
  { pageCode: 'resource:resource-01:list', label: '内容栏目', order: 0 },
276
312
  { pageCode: 'resource:resource-02:list', label: '内容文章', order: 1 },
277
313
  { pageCode: 'resource:resource-03:list', label: '内容专题', order: 2 },
@@ -293,6 +329,15 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
293
329
  },
294
330
  ]}
295
331
  adminPages={[
332
+ ...(acceptanceSurface.views || []).flatMap(view => (['list', 'detail', 'create', 'update'] as const).map(operation => ({
333
+ code: `resource:resource-01:view:${view.code}:${operation}`,
334
+ kind: `resource-${operation}` as const,
335
+ path: `/admin/resources/resource-01/views/${view.code}${({ list: '', detail: '/:id', create: '/new', update: '/:id/edit' })[operation]}`,
336
+ label: `${({ list: '', detail: '', create: '新增', update: '编辑' })[operation]}${view.name}`,
337
+ navigationEligible: operation === 'list',
338
+ capability: resourceDefinitions['resource-01'].capabilities[operation === 'list' || operation === 'detail' ? 'read' : operation],
339
+ resourceCode: 'resource-01', viewCode: view.code,
340
+ }))),
296
341
  {
297
342
  code: 'resource:resource-01:list',
298
343
  kind: 'resource-list',
@@ -0,0 +1,202 @@
1
+ import type { Page, BrowserContext } from '@playwright/test';
2
+ import { appCode, capabilities } from '../../../packages/contracts/src/generated.js';
3
+ const runtimeBase = `/view/${appCode}`;
4
+ const allCapabilities = [...capabilities] as string[];
5
+
6
+ function runtimeAuthorization(authorized: boolean, superAdmin = false) {
7
+ return {
8
+ schemaVersion: 'openxiangda.runtime-authorization/v2',
9
+ state: 'active',
10
+ environment: {
11
+ id: 'preproduction-id',
12
+ key: 'preproduction',
13
+ activeAppVersionId: 'app-version-1',
14
+ headRevision: 1,
15
+ authzRevisionId: 'authz-1',
16
+ authzVersion: 1,
17
+ scopeDataVersion: 'scope-1',
18
+ },
19
+ subjectProfile: {
20
+ schemaVersion: 'openxiangda.subject-profile/v2',
21
+ userId: 'acceptance-user',
22
+ displayName: '王老师',
23
+ avatarUrl: null,
24
+ jobNumber: 'T001',
25
+ affiliatedDepartment: { id: 'department-1', name: '理学院' },
26
+ },
27
+ roles: [
28
+ { code: 'college_admin', name: '学院管理员', source: 'package' },
29
+ { code: 'instrument_admin', name: '仪器管理员', source: 'package' },
30
+ ],
31
+ principal: {
32
+ type: 'user_union',
33
+ userId: 'acceptance-user',
34
+ roleCodes: ['college_admin', 'instrument_admin'],
35
+ capabilityCodes: authorized
36
+ ? [
37
+ ...allCapabilities,
38
+ 'app:openxiangda-application:operations:read',
39
+ ]
40
+ : [],
41
+ isAppSuperAdmin: superAdmin,
42
+ identityScope: 'user-union:preproduction-id:acceptance-user:authz-1',
43
+ },
44
+ };
45
+ }
46
+
47
+ export async function mockPlatform(
48
+ page: Page | BrowserContext,
49
+ authorized = true,
50
+ targetRuntimeBase = runtimeBase,
51
+ superAdmin = false,
52
+ queryItemsByResource: Record<string, Array<Record<string, unknown>>> = {},
53
+ ) {
54
+ let contextReads = 0;
55
+ let avatarCompletes = 0;
56
+ const observed: Array<{ path: string; perspectiveCode: string }> = [];
57
+ await page.route('https://upload.example.test/avatar-upload', route =>
58
+ route.fulfill({ status: 200, body: '' })
59
+ );
60
+ await page.route(`**${targetRuntimeBase}/**`, async route => {
61
+ if (route.request().resourceType() !== 'document') return route.continue();
62
+ const response = await route.fetch();
63
+ const body = (await response.text()).replace(
64
+ '<head>',
65
+ `<head><meta name="openxiangda-runtime-base" content="${targetRuntimeBase}/" />` +
66
+ `<meta name="openxiangda-app-code" content="${appCode}" />` +
67
+ '<meta name="openxiangda-environment" content="preproduction" />'
68
+ );
69
+ await route.fulfill({ response, body });
70
+ });
71
+ await page.route('**/service/**', async route => {
72
+ const url = new URL(route.request().url());
73
+ const request = route.request();
74
+ const perspectiveCode = request.headers()['x-openxiangda-perspective'] || '';
75
+ if (url.pathname.endsWith('/native/authz/current')) {
76
+ contextReads += 1;
77
+ return route.fulfill({
78
+ contentType: 'application/json',
79
+ body: JSON.stringify({
80
+ code: 200,
81
+ data: runtimeAuthorization(authorized, superAdmin),
82
+ }),
83
+ });
84
+ }
85
+ if (url.pathname.endsWith('/native/authz/profile/avatar/initiate')) {
86
+ return route.fulfill({
87
+ contentType: 'application/json',
88
+ body: JSON.stringify({
89
+ code: 200,
90
+ data: {
91
+ uploadUrl: 'https://upload.example.test/avatar-upload',
92
+ uploadMethod: 'put',
93
+ headers: { 'content-type': 'image/png' },
94
+ objectName: 'images/tenant/app/user/avatar.png',
95
+ },
96
+ }),
97
+ });
98
+ }
99
+ if (url.pathname.endsWith('/native/authz/profile/avatar/complete')) {
100
+ avatarCompletes += 1;
101
+ return route.fulfill({
102
+ contentType: 'application/json',
103
+ body: JSON.stringify({
104
+ code: 200,
105
+ data: {
106
+ schemaVersion: 'openxiangda.subject-profile/v2',
107
+ userId: 'acceptance-user',
108
+ displayName: '王老师',
109
+ avatarUrl: 'https://cdn.example.test/avatar/new.png',
110
+ jobNumber: 'T001',
111
+ affiliatedDepartment: { id: 'department-1', name: '理学院' },
112
+ },
113
+ }),
114
+ });
115
+ }
116
+ const sourceQuery = url.pathname.match(
117
+ /\/native\/data-resources\/([^/]+)\/fields\/([^/]+)\/source\/query$/
118
+ );
119
+ if (sourceQuery) {
120
+ observed.push({ path: url.pathname, perspectiveCode });
121
+ return route.fulfill({
122
+ contentType: 'application/json',
123
+ body: JSON.stringify({
124
+ code: 200,
125
+ data: {
126
+ schemaVersion: 'openxiangda.data-field-source-page/v2',
127
+ resourceCode: decodeURIComponent(sourceQuery[1]),
128
+ fieldCode: decodeURIComponent(sourceQuery[2]),
129
+ items: [],
130
+ nextCursor: null,
131
+ },
132
+ }),
133
+ });
134
+ }
135
+ const exportRequest = url.pathname.match(
136
+ /\/native\/data\/([^/]+)\/export$/
137
+ );
138
+ if (exportRequest) {
139
+ observed.push({ path: url.pathname, perspectiveCode });
140
+ return route.fulfill({
141
+ status: 200,
142
+ contentType: 'text/csv; charset=utf-8',
143
+ headers: {
144
+ 'content-disposition': `attachment; filename="${decodeURIComponent(
145
+ exportRequest[1]
146
+ )}.csv"`,
147
+ },
148
+ body: 'id,name\nacceptance-1,OpenXiangda\n',
149
+ });
150
+ }
151
+ const query = url.pathname.match(/\/native\/data\/([^/]+)\/query$/);
152
+ if (query) {
153
+ const resourceCode = decodeURIComponent(query[1]);
154
+ const items = queryItemsByResource[resourceCode] || [];
155
+ observed.push({ path: url.pathname, perspectiveCode });
156
+ return route.fulfill({
157
+ contentType: 'application/json',
158
+ body: JSON.stringify({
159
+ code: 200,
160
+ data: {
161
+ schemaVersion: 'openxiangda.data-page/v2',
162
+ resourceCode,
163
+ appVersionId: 'app-version-1',
164
+ environmentHeadRevision: 1,
165
+ items,
166
+ total: items.length,
167
+ limit: 500,
168
+ offset: 0,
169
+ },
170
+ }),
171
+ });
172
+ }
173
+ if (url.pathname.includes('/directory/')) {
174
+ observed.push({ path: url.pathname, perspectiveCode });
175
+ return route.fulfill({
176
+ contentType: 'application/json',
177
+ body: JSON.stringify({ code: 200, data: { items: [], nextCursor: null } }),
178
+ });
179
+ }
180
+ if (url.pathname.includes('/openxiangda-app-api/v2/')) {
181
+ observed.push({ path: url.pathname, perspectiveCode });
182
+ return route.fulfill({
183
+ contentType: 'application/json',
184
+ body: JSON.stringify({ code: 200, data: { status: 'ok' } }),
185
+ });
186
+ }
187
+ return route.fulfill({
188
+ status: 404,
189
+ contentType: 'application/json',
190
+ body: JSON.stringify({ code: 404, message: 'not mocked' }),
191
+ });
192
+ });
193
+ return {
194
+ observed,
195
+ get contextReads() {
196
+ return contextReads;
197
+ },
198
+ get avatarCompletes() {
199
+ return avatarCompletes;
200
+ },
201
+ };
202
+ }