openxiangda-cli 2.0.0-alpha.17 → 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 (278) hide show
  1. package/README.md +51 -15
  2. package/bin/run.js +79 -1
  3. package/dist/base.d.ts +59 -5
  4. package/dist/base.d.ts.map +1 -1
  5. package/dist/base.js +213 -10
  6. package/dist/base.js.map +1 -1
  7. package/dist/commands/accept.d.ts +28 -0
  8. package/dist/commands/accept.d.ts.map +1 -0
  9. package/dist/commands/accept.js +21 -0
  10. package/dist/commands/accept.js.map +1 -0
  11. package/dist/commands/cancel.d.ts +19 -1
  12. package/dist/commands/cancel.d.ts.map +1 -1
  13. package/dist/commands/cancel.js +1 -1
  14. package/dist/commands/cancel.js.map +1 -1
  15. package/dist/commands/check.d.ts +22 -1
  16. package/dist/commands/check.d.ts.map +1 -1
  17. package/dist/commands/check.js +17 -4
  18. package/dist/commands/check.js.map +1 -1
  19. package/dist/commands/create.d.ts +39 -0
  20. package/dist/commands/create.d.ts.map +1 -0
  21. package/dist/commands/create.js +457 -0
  22. package/dist/commands/create.js.map +1 -0
  23. package/dist/commands/deploy.d.ts +26 -8
  24. package/dist/commands/deploy.d.ts.map +1 -1
  25. package/dist/commands/deploy.js +31 -17
  26. package/dist/commands/deploy.js.map +1 -1
  27. package/dist/commands/dev.d.ts +23 -1
  28. package/dist/commands/dev.d.ts.map +1 -1
  29. package/dist/commands/dev.js +28 -4
  30. package/dist/commands/dev.js.map +1 -1
  31. package/dist/commands/login.d.ts +29 -0
  32. package/dist/commands/login.d.ts.map +1 -0
  33. package/dist/commands/{auth/login.js → login.js} +27 -9
  34. package/dist/commands/login.js.map +1 -0
  35. package/dist/commands/logs.d.ts +22 -2
  36. package/dist/commands/logs.d.ts.map +1 -1
  37. package/dist/commands/logs.js +9 -5
  38. package/dist/commands/logs.js.map +1 -1
  39. package/dist/commands/retry.d.ts +19 -1
  40. package/dist/commands/retry.d.ts.map +1 -1
  41. package/dist/commands/retry.js +5 -2
  42. package/dist/commands/retry.js.map +1 -1
  43. package/dist/commands/rollback.d.ts +23 -4
  44. package/dist/commands/rollback.d.ts.map +1 -1
  45. package/dist/commands/rollback.js +18 -5
  46. package/dist/commands/rollback.js.map +1 -1
  47. package/dist/commands/skill.d.ts +31 -0
  48. package/dist/commands/skill.d.ts.map +1 -0
  49. package/dist/commands/skill.js +43 -0
  50. package/dist/commands/skill.js.map +1 -0
  51. package/dist/commands/spec.d.ts +39 -0
  52. package/dist/commands/spec.d.ts.map +1 -0
  53. package/dist/commands/spec.js +117 -0
  54. package/dist/commands/spec.js.map +1 -0
  55. package/dist/commands/start.d.ts +28 -0
  56. package/dist/commands/start.d.ts.map +1 -0
  57. package/dist/commands/start.js +18 -0
  58. package/dist/commands/start.js.map +1 -0
  59. package/dist/commands/status.d.ts +19 -1
  60. package/dist/commands/status.d.ts.map +1 -1
  61. package/dist/commands/status.js +1 -1
  62. package/dist/commands/status.js.map +1 -1
  63. package/dist/commands/stop.d.ts +28 -0
  64. package/dist/commands/stop.d.ts.map +1 -0
  65. package/dist/commands/stop.js +18 -0
  66. package/dist/commands/stop.js.map +1 -0
  67. package/dist/create-workspace.d.ts +67 -0
  68. package/dist/create-workspace.d.ts.map +1 -0
  69. package/dist/create-workspace.js +631 -0
  70. package/dist/create-workspace.js.map +1 -0
  71. package/dist/studio-events.d.ts +9 -0
  72. package/dist/studio-events.d.ts.map +1 -0
  73. package/dist/studio-events.js +30 -0
  74. package/dist/studio-events.js.map +1 -0
  75. package/dist/toolchain-capsule.d.ts +4 -0
  76. package/dist/toolchain-capsule.d.ts.map +1 -0
  77. package/dist/toolchain-capsule.js +116 -0
  78. package/dist/toolchain-capsule.js.map +1 -0
  79. package/package.json +16 -9
  80. package/template/.dockerignore +23 -0
  81. package/template/.env.example +3 -0
  82. package/template/AGENTS.md +62 -0
  83. package/template/README.md +70 -0
  84. package/template/_gitignore +14 -0
  85. package/template/apps/web/data-api-live.e2e.html +15 -0
  86. package/template/apps/web/e2e/client-fixture.ts +5 -0
  87. package/template/apps/web/e2e/component-defaults-fixture.css +15 -0
  88. package/template/apps/web/e2e/data-api-live-fixture.tsx +162 -0
  89. package/template/apps/web/e2e/data-api-live.spec.ts +144 -0
  90. package/template/apps/web/e2e/field-protocol-fixture.tsx +387 -0
  91. package/template/apps/web/e2e/field-protocol.spec.ts +285 -0
  92. package/template/apps/web/e2e/mobile-files.spec.ts +93 -0
  93. package/template/apps/web/e2e/mobile-reference-fixture.tsx +314 -0
  94. package/template/apps/web/e2e/mobile-reference.spec.ts +360 -0
  95. package/template/apps/web/e2e/mobile-selection.spec.ts +199 -0
  96. package/template/apps/web/e2e/resource-experience-fixture.tsx +491 -0
  97. package/template/apps/web/e2e/resource-platform-mock.ts +202 -0
  98. package/template/apps/web/e2e/resources.spec.ts +477 -0
  99. package/template/apps/web/e2e/standard-admin.spec.ts +441 -0
  100. package/template/apps/web/e2e/workflow-experience-fixture.tsx +338 -0
  101. package/template/apps/web/e2e/workflow.spec.ts +1173 -0
  102. package/template/apps/web/field-protocol.e2e.html +22 -0
  103. package/template/apps/web/index.html +12 -0
  104. package/template/apps/web/mobile-reference.e2e.html +27 -0
  105. package/template/apps/web/package.json +34 -0
  106. package/template/apps/web/playwright.config.ts +47 -0
  107. package/template/apps/web/resource-experience.e2e.html +16 -0
  108. package/template/apps/web/scripts/check.mjs +43 -0
  109. package/template/apps/web/scripts/verify-build.mjs +6 -0
  110. package/template/apps/web/src/document.css +5 -0
  111. package/template/apps/web/src/main.tsx +66 -0
  112. package/template/apps/web/src/user-end/tailwind.css +5 -0
  113. package/template/apps/web/test/contracts.test.ts +162 -0
  114. package/template/apps/web/test/loopback.test.ts +68 -0
  115. package/template/apps/web/test/standard-user-surfaces.typecheck.tsx +27 -0
  116. package/template/apps/web/tsconfig.json +12 -0
  117. package/template/apps/web/vite.config.ts +42 -0
  118. package/template/apps/web/workflow-experience.e2e.html +15 -0
  119. package/template/appspec/app.md +62 -0
  120. package/template/openxiangda.config.ts +55 -0
  121. package/template/package.json +28 -0
  122. package/template/packages/contracts/package.json +23 -0
  123. package/template/packages/contracts/src/generated.ts +265 -0
  124. package/template/packages/contracts/src/index.ts +1 -0
  125. package/template/packages/contracts/tsconfig.json +9 -0
  126. package/template/pnpm-workspace.yaml +7 -0
  127. package/template/tsconfig.base.json +13 -0
  128. package/dist/commands/app/create.d.ts +0 -15
  129. package/dist/commands/app/create.d.ts.map +0 -1
  130. package/dist/commands/app/create.js +0 -42
  131. package/dist/commands/app/create.js.map +0 -1
  132. package/dist/commands/app/info.d.ts +0 -9
  133. package/dist/commands/app/info.d.ts.map +0 -1
  134. package/dist/commands/app/info.js +0 -10
  135. package/dist/commands/app/info.js.map +0 -1
  136. package/dist/commands/app/link.d.ts +0 -10
  137. package/dist/commands/app/link.d.ts.map +0 -1
  138. package/dist/commands/app/link.js +0 -16
  139. package/dist/commands/app/link.js.map +0 -1
  140. package/dist/commands/app/provision.d.ts +0 -9
  141. package/dist/commands/app/provision.d.ts.map +0 -1
  142. package/dist/commands/app/provision.js +0 -10
  143. package/dist/commands/app/provision.js.map +0 -1
  144. package/dist/commands/auth/login.d.ts +0 -11
  145. package/dist/commands/auth/login.d.ts.map +0 -1
  146. package/dist/commands/auth/login.js.map +0 -1
  147. package/dist/commands/auth/logout.d.ts +0 -6
  148. package/dist/commands/auth/logout.d.ts.map +0 -1
  149. package/dist/commands/auth/logout.js +0 -30
  150. package/dist/commands/auth/logout.js.map +0 -1
  151. package/dist/commands/auth/status.d.ts +0 -6
  152. package/dist/commands/auth/status.d.ts.map +0 -1
  153. package/dist/commands/auth/status.js +0 -60
  154. package/dist/commands/auth/status.js.map +0 -1
  155. package/dist/commands/build.d.ts +0 -11
  156. package/dist/commands/build.d.ts.map +0 -1
  157. package/dist/commands/build.js +0 -23
  158. package/dist/commands/build.js.map +0 -1
  159. package/dist/commands/doctor.d.ts +0 -9
  160. package/dist/commands/doctor.d.ts.map +0 -1
  161. package/dist/commands/doctor.js +0 -7
  162. package/dist/commands/doctor.js.map +0 -1
  163. package/dist/commands/event/delivery/list.d.ts +0 -10
  164. package/dist/commands/event/delivery/list.d.ts.map +0 -1
  165. package/dist/commands/event/delivery/list.js +0 -14
  166. package/dist/commands/event/delivery/list.js.map +0 -1
  167. package/dist/commands/event/delivery/replay.d.ts +0 -13
  168. package/dist/commands/event/delivery/replay.d.ts.map +0 -1
  169. package/dist/commands/event/delivery/replay.js +0 -16
  170. package/dist/commands/event/delivery/replay.js.map +0 -1
  171. package/dist/commands/event/subscription/list.d.ts +0 -9
  172. package/dist/commands/event/subscription/list.d.ts.map +0 -1
  173. package/dist/commands/event/subscription/list.js +0 -10
  174. package/dist/commands/event/subscription/list.js.map +0 -1
  175. package/dist/commands/event/subscription/rotate-secret.d.ts +0 -15
  176. package/dist/commands/event/subscription/rotate-secret.d.ts.map +0 -1
  177. package/dist/commands/event/subscription/rotate-secret.js +0 -33
  178. package/dist/commands/event/subscription/rotate-secret.js.map +0 -1
  179. package/dist/commands/event/subscription/status.d.ts +0 -14
  180. package/dist/commands/event/subscription/status.d.ts.map +0 -1
  181. package/dist/commands/event/subscription/status.js +0 -19
  182. package/dist/commands/event/subscription/status.js.map +0 -1
  183. package/dist/commands/event/timer/list.d.ts +0 -9
  184. package/dist/commands/event/timer/list.d.ts.map +0 -1
  185. package/dist/commands/event/timer/list.js +0 -10
  186. package/dist/commands/event/timer/list.js.map +0 -1
  187. package/dist/commands/event/timer/status.d.ts +0 -14
  188. package/dist/commands/event/timer/status.d.ts.map +0 -1
  189. package/dist/commands/event/timer/status.js +0 -19
  190. package/dist/commands/event/timer/status.js.map +0 -1
  191. package/dist/commands/generate.d.ts +0 -10
  192. package/dist/commands/generate.d.ts.map +0 -1
  193. package/dist/commands/generate.js +0 -8
  194. package/dist/commands/generate.js.map +0 -1
  195. package/dist/commands/oauth/audit/list.d.ts +0 -12
  196. package/dist/commands/oauth/audit/list.d.ts.map +0 -1
  197. package/dist/commands/oauth/audit/list.js +0 -20
  198. package/dist/commands/oauth/audit/list.js.map +0 -1
  199. package/dist/commands/oauth/client/create.d.ts +0 -13
  200. package/dist/commands/oauth/client/create.d.ts.map +0 -1
  201. package/dist/commands/oauth/client/create.js +0 -34
  202. package/dist/commands/oauth/client/create.js.map +0 -1
  203. package/dist/commands/oauth/client/list.d.ts +0 -9
  204. package/dist/commands/oauth/client/list.d.ts.map +0 -1
  205. package/dist/commands/oauth/client/list.js +0 -10
  206. package/dist/commands/oauth/client/list.js.map +0 -1
  207. package/dist/commands/oauth/client/revoke.d.ts +0 -12
  208. package/dist/commands/oauth/client/revoke.d.ts.map +0 -1
  209. package/dist/commands/oauth/client/revoke.js +0 -12
  210. package/dist/commands/oauth/client/revoke.js.map +0 -1
  211. package/dist/commands/oauth/client/rotate.d.ts +0 -13
  212. package/dist/commands/oauth/client/rotate.d.ts.map +0 -1
  213. package/dist/commands/oauth/client/rotate.js +0 -20
  214. package/dist/commands/oauth/client/rotate.js.map +0 -1
  215. package/dist/commands/oauth/client/update.d.ts +0 -15
  216. package/dist/commands/oauth/client/update.d.ts.map +0 -1
  217. package/dist/commands/oauth/client/update.js +0 -23
  218. package/dist/commands/oauth/client/update.js.map +0 -1
  219. package/dist/commands/oauth/runtime/rotate.d.ts +0 -12
  220. package/dist/commands/oauth/runtime/rotate.d.ts.map +0 -1
  221. package/dist/commands/oauth/runtime/rotate.js +0 -31
  222. package/dist/commands/oauth/runtime/rotate.js.map +0 -1
  223. package/dist/commands/oauth/runtime/status.d.ts +0 -10
  224. package/dist/commands/oauth/runtime/status.d.ts.map +0 -1
  225. package/dist/commands/oauth/runtime/status.js +0 -17
  226. package/dist/commands/oauth/runtime/status.js.map +0 -1
  227. package/dist/commands/promote.d.ts +0 -13
  228. package/dist/commands/promote.d.ts.map +0 -1
  229. package/dist/commands/promote.js +0 -9
  230. package/dist/commands/promote.js.map +0 -1
  231. package/dist/commands/secret/audit/list.d.ts +0 -14
  232. package/dist/commands/secret/audit/list.d.ts.map +0 -1
  233. package/dist/commands/secret/audit/list.js +0 -16
  234. package/dist/commands/secret/audit/list.js.map +0 -1
  235. package/dist/commands/secret/create.d.ts +0 -18
  236. package/dist/commands/secret/create.d.ts.map +0 -1
  237. package/dist/commands/secret/create.js +0 -33
  238. package/dist/commands/secret/create.js.map +0 -1
  239. package/dist/commands/secret/delete.d.ts +0 -15
  240. package/dist/commands/secret/delete.d.ts.map +0 -1
  241. package/dist/commands/secret/delete.js +0 -20
  242. package/dist/commands/secret/delete.js.map +0 -1
  243. package/dist/commands/secret/list.d.ts +0 -10
  244. package/dist/commands/secret/list.d.ts.map +0 -1
  245. package/dist/commands/secret/list.js +0 -14
  246. package/dist/commands/secret/list.js.map +0 -1
  247. package/dist/commands/secret/rotate.d.ts +0 -18
  248. package/dist/commands/secret/rotate.d.ts.map +0 -1
  249. package/dist/commands/secret/rotate.js +0 -30
  250. package/dist/commands/secret/rotate.js.map +0 -1
  251. package/dist/commands/secret/update.d.ts +0 -17
  252. package/dist/commands/secret/update.d.ts.map +0 -1
  253. package/dist/commands/secret/update.js +0 -37
  254. package/dist/commands/secret/update.js.map +0 -1
  255. package/dist/commands/skill/install.d.ts +0 -11
  256. package/dist/commands/skill/install.d.ts.map +0 -1
  257. package/dist/commands/skill/install.js +0 -52
  258. package/dist/commands/skill/install.js.map +0 -1
  259. package/dist/commands/skill/validate.d.ts +0 -9
  260. package/dist/commands/skill/validate.d.ts.map +0 -1
  261. package/dist/commands/skill/validate.js +0 -32
  262. package/dist/commands/skill/validate.js.map +0 -1
  263. package/dist/commands/test.d.ts +0 -9
  264. package/dist/commands/test.d.ts.map +0 -1
  265. package/dist/commands/test.js +0 -7
  266. package/dist/commands/test.js.map +0 -1
  267. package/dist/commands/workflow/provider/list.d.ts +0 -10
  268. package/dist/commands/workflow/provider/list.d.ts.map +0 -1
  269. package/dist/commands/workflow/provider/list.js +0 -14
  270. package/dist/commands/workflow/provider/list.js.map +0 -1
  271. package/dist/commands/workflow/provider/rotate.d.ts +0 -15
  272. package/dist/commands/workflow/provider/rotate.d.ts.map +0 -1
  273. package/dist/commands/workflow/provider/rotate.js +0 -24
  274. package/dist/commands/workflow/provider/rotate.js.map +0 -1
  275. package/dist/secret-value.d.ts +0 -5
  276. package/dist/secret-value.d.ts.map +0 -1
  277. package/dist/secret-value.js +0 -15
  278. package/dist/secret-value.js.map +0 -1
@@ -0,0 +1,1173 @@
1
+ import { expect, test, type Page } from '@playwright/test';
2
+ import { appCode } from '../../../packages/contracts/src/generated.js';
3
+
4
+ const taskId = '11111111-1111-4111-8111-111111111111';
5
+ const instanceId = '22222222-2222-4222-8222-222222222222';
6
+ const commandId = '55555555-5555-4555-8555-555555555555';
7
+ function envelope(data: unknown, code = 200) {
8
+ return { code, message: code === 200 ? 'success' : 'forbidden', data };
9
+ }
10
+
11
+ function detailNavigation(custom = false) {
12
+ return custom
13
+ ? {
14
+ custom: true,
15
+ desktopPath: `/admin/operations/purchases/${instanceId}?taskId=${taskId}`,
16
+ mobilePath: `/m/purchases/${instanceId}?taskId=${taskId}`,
17
+ }
18
+ : {
19
+ custom: false,
20
+ desktopPath: `/tasks/${taskId}`,
21
+ mobilePath: `/m/tasks/${taskId}`,
22
+ };
23
+ }
24
+
25
+ function workflowFixtureUrl(
26
+ initialPath: string,
27
+ options: { base?: string; state?: string; surfaces?: 'custom' | 'error' } = {},
28
+ ) {
29
+ const params = new URLSearchParams({ initial: initialPath });
30
+ if (options.base) params.set('base', options.base);
31
+ if (options.state) params.set('state', options.state);
32
+ if (options.surfaces) params.set('surfaces', options.surfaces);
33
+ return `/workflow-experience.e2e.html?${params.toString()}`;
34
+ }
35
+
36
+ function task(completed = false, customDetail = false) {
37
+ return {
38
+ schemaVersion: 'openxiangda.workflow-task/v2',
39
+ engineVersion: '2.0',
40
+ id: taskId,
41
+ instanceId,
42
+ workflowCode: 'purchase-approval',
43
+ businessKey: 'PO-2026-0825',
44
+ nodeId: 'manager-review',
45
+ title: '采购申请审批',
46
+ status: completed ? 'completed' : 'assigned',
47
+ approvalMode: 'single',
48
+ taskKind: 'normal',
49
+ assignedRoleSubjectKey: 'membership:manager',
50
+ activeParticipantId: 'participant-1',
51
+ participants: [],
52
+ originTaskId: null,
53
+ returnSessionId: null,
54
+ version: completed ? 2 : 1,
55
+ dataRef: { resourceCode: 'purchase-orders', recordId: 'purchase-1' },
56
+ createdAt: '2026-08-25T01:00:00.000Z',
57
+ updatedAt: '2026-08-25T01:10:00.000Z',
58
+ detailNavigation: detailNavigation(customDetail),
59
+ };
60
+ }
61
+
62
+ function surface(completed = false, customDetail = false) {
63
+ return {
64
+ schemaVersion: 'openxiangda.workflow-surface/v2',
65
+ protocolVersion: 'workflow_surface_v2',
66
+ surfaceRevision: (completed ? 'b' : 'a').repeat(64),
67
+ engineVersion: '2.0',
68
+ commandToken: 'A'.repeat(43),
69
+ commandTokenExpiresAt: '2099-08-29T09:05:00.000Z',
70
+ instanceSequence: completed ? 4 : 3,
71
+ detailNavigation: detailNavigation(customDetail),
72
+ navigationTarget: {
73
+ kind: 'resource_record',
74
+ appCode,
75
+ environmentKey: 'preproduction',
76
+ resourceCode: 'purchase-orders',
77
+ recordId: 'purchase-1',
78
+ desktopPath: '/purchase-orders/purchase-1',
79
+ mobilePath: '/m/purchase-orders/purchase-1',
80
+ },
81
+ instance: {
82
+ schemaVersion: 'openxiangda.workflow-instance/v2',
83
+ engineVersion: '2.0',
84
+ id: instanceId,
85
+ appCode,
86
+ environmentId: 'preproduction-id',
87
+ environmentKey: 'preproduction',
88
+ workflowCode: 'purchase-approval',
89
+ definitionVersion: 1,
90
+ bindingVersion: 1,
91
+ businessKey: 'PO-2026-0825',
92
+ generation: 1,
93
+ status: completed ? 'approved' : 'running',
94
+ initiatorUserId: 'applicant-user',
95
+ initiatorAuthorizationDigest: null,
96
+ dataRef: { resourceCode: 'purchase-orders', recordId: 'purchase-1' },
97
+ dataRevision: '8',
98
+ currentNodeId: completed ? null : 'manager-review',
99
+ outcome: completed ? 'approved' : null,
100
+ version: completed ? 3 : 2,
101
+ eventSequence: completed ? 4 : 3,
102
+ startedAt: '2026-08-25T01:00:00.000Z',
103
+ completedAt: completed ? '2026-08-25T01:12:00.000Z' : null,
104
+ },
105
+ task: completed ? null : task(false, customDetail),
106
+ presentation: {
107
+ status: { label: completed ? '已同意' : '待审批', tone: 'warning' },
108
+ layout: 'approval_detail',
109
+ businessData: {
110
+ status: 'fresh',
111
+ resourceCode: 'purchase-orders',
112
+ recordId: 'purchase-1',
113
+ requestedRevision: 8,
114
+ sourceRevision: 8,
115
+ fields: { amount: 28600 },
116
+ projectionDigest: 'c'.repeat(64),
117
+ },
118
+ businessDetail: {
119
+ status: 'stale',
120
+ resourceCode: 'purchase-orders',
121
+ resourceName: '采购申请',
122
+ recordId: 'purchase-1',
123
+ requestedRevision: 8,
124
+ sourceRevision: 9,
125
+ surface: {
126
+ mutationOwner: 'workflow',
127
+ fields: {
128
+ amount: {
129
+ label: '申请金额',
130
+ type: 'number.decimal',
131
+ widget: 'money',
132
+ section: '申请信息',
133
+ readCapabilities: [],
134
+ createCapabilities: [],
135
+ updateCapabilities: [],
136
+ },
137
+ internalVenue: {
138
+ label: '技术场地引用',
139
+ type: 'reference',
140
+ widget: 'reference',
141
+ section: '系统信息',
142
+ system: true,
143
+ readCapabilities: [],
144
+ createCapabilities: [],
145
+ updateCapabilities: [],
146
+ },
147
+ },
148
+ detail: {
149
+ layout: 'sections',
150
+ fieldOrder: ['amount', 'internalVenue'],
151
+ },
152
+ },
153
+ record: {
154
+ amount: 30000,
155
+ internalVenue: '77777777-7777-4777-8777-777777777777',
156
+ },
157
+ subtables: {},
158
+ projectionDigest: 'd'.repeat(64),
159
+ },
160
+ summary: {
161
+ title: '采购申请审批',
162
+ initiatorDisplayName: '张三',
163
+ departmentDisplayName: '理学院',
164
+ submittedAt: '2026-08-25T01:00:00.000Z',
165
+ businessNumber: 'PO-2026-0825',
166
+ },
167
+ },
168
+ fieldPolicy: { default: 'readonly', fields: {} },
169
+ operations: completed
170
+ ? []
171
+ : [
172
+ {
173
+ key: 'approve',
174
+ kind: 'workflow_command',
175
+ label: '同意',
176
+ group: 'decision',
177
+ audience: 'participant',
178
+ placement: 'primary',
179
+ emphasis: 'primary',
180
+ tone: 'primary',
181
+ visible: true,
182
+ enabled: true,
183
+ inputSchema: {
184
+ type: 'object',
185
+ properties: {
186
+ comment: {
187
+ type: 'string',
188
+ title: '审批意见',
189
+ format: 'textarea',
190
+ maxLength: 500,
191
+ },
192
+ },
193
+ },
194
+ uiSchema: {
195
+ confirmText: '确认同意',
196
+ confirmation: { description: '提交后流程继续流转。' },
197
+ },
198
+ execute: {
199
+ method: 'POST',
200
+ href: `/openxiangda-api/v2/applications/${appCode}/workflow/tasks/${taskId}/commands/approve`,
201
+ idempotencyRequired: true,
202
+ },
203
+ refresh: ['surface', 'timeline', 'work_center'],
204
+ },
205
+ ...['reject', 'return', 'transfer', 'add_assignee'].map(key => ({
206
+ key,
207
+ kind: 'workflow_command',
208
+ label: {
209
+ reject: '拒绝',
210
+ return: '退回',
211
+ transfer: '转交',
212
+ add_assignee: '加签',
213
+ }[key],
214
+ group: key === 'reject' ? 'decision' : 'task_collaboration',
215
+ audience: 'participant',
216
+ placement: key === 'reject' ? 'primary' : 'overflow',
217
+ emphasis:
218
+ key === 'reject'
219
+ ? 'danger'
220
+ : key === 'return'
221
+ ? 'warning'
222
+ : 'neutral',
223
+ tone: key === 'reject' ? 'danger' : 'neutral',
224
+ visible: true,
225
+ enabled: true,
226
+ inputSchema: { type: 'object', properties: {} },
227
+ uiSchema: { confirmText: `确认${key}` },
228
+ execute: {
229
+ method: 'POST',
230
+ href: `/openxiangda-api/v2/applications/${appCode}/workflow/tasks/${taskId}/commands/${key}`,
231
+ idempotencyRequired: true,
232
+ },
233
+ refresh: ['surface', 'timeline', 'work_center'],
234
+ })),
235
+ ],
236
+ extensions: {},
237
+ };
238
+ }
239
+
240
+ function timeline(completed = false) {
241
+ const flow = [
242
+ {
243
+ key: 'start',
244
+ nodeId: null,
245
+ kind: 'start',
246
+ title: '张三',
247
+ status: 'completed',
248
+ startedAt: '2026-08-25T01:00:00.000Z',
249
+ completedAt: '2026-08-25T01:00:00.000Z',
250
+ assignees: [],
251
+ operations: [],
252
+ },
253
+ {
254
+ key: 'manager',
255
+ nodeId: 'manager-review',
256
+ kind: 'approval',
257
+ title: '部门负责人',
258
+ status: completed ? 'completed' : 'active',
259
+ startedAt: '2026-08-25T01:01:00.000Z',
260
+ completedAt: completed ? '2026-08-25T01:12:00.000Z' : null,
261
+ assignees: [{ userId: 'acceptance-user', displayName: '李明' }],
262
+ operations: [{
263
+ id: 'operation-transfer',
264
+ operation: 'transfer',
265
+ operationLabel: '转交',
266
+ actorUserId: 'acceptance-user',
267
+ actorDisplayName: '李明',
268
+ actingForUserId: null,
269
+ reason: '由值班负责人继续处理',
270
+ detail: {},
271
+ severity: 'normal',
272
+ createdAt: '2026-08-25T01:08:00.000Z',
273
+ }],
274
+ },
275
+ {
276
+ key: 'finance',
277
+ nodeId: 'finance-review',
278
+ kind: 'approval',
279
+ title: '财务负责人',
280
+ status: completed ? 'completed' : 'waiting',
281
+ startedAt: null,
282
+ completedAt: null,
283
+ assignees: [{ userId: 'finance-user', displayName: '财务负责人' }],
284
+ operations: [],
285
+ },
286
+ ];
287
+ const items = [{
288
+ id: 'operation-transfer',
289
+ operation: 'transfer',
290
+ operationLabel: '转交',
291
+ actorUserId: 'acceptance-user',
292
+ actorDisplayName: '李明',
293
+ actingForUserId: null,
294
+ reason: '由值班负责人继续处理',
295
+ detail: {},
296
+ severity: 'normal',
297
+ createdAt: '2026-08-25T01:08:00.000Z',
298
+ }];
299
+ const display = {
300
+ entries: [
301
+ {
302
+ key: 'start',
303
+ kind: 'submission',
304
+ nodeId: null,
305
+ nodeKind: 'start',
306
+ title: '提交申请',
307
+ status: 'completed',
308
+ enteredAt: '2026-08-25T01:00:00.000Z',
309
+ leftAt: '2026-08-25T01:00:00.000Z',
310
+ primaryDisplayTime: '2026-08-25T01:00:00.000Z',
311
+ people: [{
312
+ userId: 'applicant-user',
313
+ displayName: '张三',
314
+ avatarUrl: null,
315
+ departmentDisplayName: '理学院',
316
+ }],
317
+ operations: [],
318
+ result: {},
319
+ terminalReason: null,
320
+ },
321
+ {
322
+ key: 'manager',
323
+ kind: 'node',
324
+ nodeId: 'manager-review',
325
+ nodeKind: 'approval',
326
+ title: '部门负责人',
327
+ status: completed ? 'completed' : 'active',
328
+ enteredAt: '2026-08-25T01:01:00.000Z',
329
+ leftAt: completed ? '2026-08-25T01:12:00.000Z' : null,
330
+ primaryDisplayTime: '2026-08-25T01:08:00.000Z',
331
+ people: [],
332
+ operations: [{
333
+ id: 'operation-transfer',
334
+ operation: 'transfer',
335
+ operationLabel: '转交',
336
+ actor: {
337
+ userId: 'acceptance-user',
338
+ displayName: '李明',
339
+ avatarUrl: null,
340
+ departmentDisplayName: null,
341
+ },
342
+ actingForUserId: null,
343
+ reason: '由值班负责人继续处理',
344
+ severity: 'normal',
345
+ occurredAt: '2026-08-25T01:08:00.000Z',
346
+ }],
347
+ result: {},
348
+ terminalReason: null,
349
+ },
350
+ {
351
+ key: 'finance',
352
+ kind: completed ? 'node' : 'planned_node',
353
+ nodeId: 'finance-review',
354
+ nodeKind: 'approval',
355
+ title: '财务负责人',
356
+ status: completed ? 'completed' : 'waiting',
357
+ enteredAt: null,
358
+ leftAt: null,
359
+ primaryDisplayTime: null,
360
+ people: [{
361
+ userId: 'finance-user',
362
+ displayName: '财务负责人',
363
+ avatarUrl: null,
364
+ departmentDisplayName: null,
365
+ }],
366
+ operations: [],
367
+ result: {},
368
+ terminalReason: null,
369
+ },
370
+ ],
371
+ };
372
+ return {
373
+ engineVersion: '2.0',
374
+ instanceId,
375
+ instanceSequence: completed ? 4 : 3,
376
+ timelineRevision: (completed ? 'f' : 'e').repeat(64),
377
+ flow,
378
+ items,
379
+ display,
380
+ };
381
+ }
382
+
383
+ function detail(completed = false, customDetail = false) {
384
+ const currentSurface = surface(completed, customDetail);
385
+ const currentTimeline = timeline(completed);
386
+ return {
387
+ schemaVersion: 'openxiangda.workflow-detail-surface/v2',
388
+ protocolVersion: 'workflow_detail_surface_v2',
389
+ detailRevision: (completed ? '2' : '1').repeat(64),
390
+ instanceSequence: currentSurface.instanceSequence,
391
+ timelineRevision: currentTimeline.timelineRevision,
392
+ surface: currentSurface,
393
+ timeline: currentTimeline,
394
+ currentStatus: {
395
+ code: completed ? 'approved' : 'assigned',
396
+ label: completed ? '已同意' : '待审批',
397
+ tone: completed ? 'success' : 'warning',
398
+ },
399
+ nodes: currentTimeline.display.entries,
400
+ handlingRecords: currentTimeline.display.entries.flatMap(
401
+ entry => entry.operations
402
+ ),
403
+ operations: currentSurface.operations,
404
+ navigationContext: {
405
+ desktopReturnPath: '/admin/work-center',
406
+ mobileReturnPath: '/m/work-center',
407
+ },
408
+ };
409
+ }
410
+
411
+ function launchSurface() {
412
+ return {
413
+ schemaVersion: 'openxiangda.workflow-launch-surface/v3',
414
+ protocolVersion: 'workflow_launch_surface_v3',
415
+ surfaceRevision: 'launch-revision-1',
416
+ engineVersion: '2.0',
417
+ appCode,
418
+ environmentKey: 'preproduction',
419
+ environmentId: 'preproduction-id',
420
+ workflowCode: 'purchase-approval',
421
+ title: '采购审批',
422
+ launchMode: 'standalone',
423
+ processOperationCode: 'openxiangda.workflow.purchase-approval.submit',
424
+ subject: {
425
+ resourceCode: 'purchase-orders',
426
+ factProjection: { amount: 'amount' },
427
+ summaryFields: ['amount'],
428
+ },
429
+ inputSchema: { type: 'object', properties: {} },
430
+ head: {
431
+ workflowRevision: 4,
432
+ definitionVersion: 1,
433
+ bindingVersion: 1,
434
+ nativeRevision: 3,
435
+ contractRevisionId: 'contract-revision-1',
436
+ },
437
+ paths: {
438
+ desktop: '/workflows/purchase-approval/start',
439
+ mobile: '/m/workflows/purchase-approval/start',
440
+ },
441
+ commit: {
442
+ method: 'POST',
443
+ href: `/openxiangda-api/v2/applications/${appCode}/business-process/standard-commands`,
444
+ idempotencyRequired: true,
445
+ },
446
+ submission: {
447
+ kind: 'standard-process',
448
+ processOperationCode: 'openxiangda.workflow.purchase-approval.submit',
449
+ commit: {
450
+ method: 'POST',
451
+ href: `/openxiangda-api/v2/applications/${appCode}/business-process/standard-commands`,
452
+ idempotencyRequired: true,
453
+ },
454
+ },
455
+ };
456
+ }
457
+
458
+ function processCommand(status: 'accepted' | 'started' = 'started') {
459
+ return {
460
+ schemaVersion: 'openxiangda.business-process.command/v2',
461
+ id: commandId,
462
+ appCode,
463
+ environmentKey: 'preproduction',
464
+ operationCode: 'openxiangda.workflow.purchase-approval.submit',
465
+ idempotencyKey: 'process:purchase-approval:test',
466
+ workflowCode: 'purchase-approval',
467
+ status,
468
+ revision: status === 'started' ? 4 : 1,
469
+ subject: {
470
+ resourceCode: 'purchase-orders',
471
+ id: '66666666-6666-4666-8666-666666666666',
472
+ dataRevision: 1,
473
+ factDigest: 'a'.repeat(64),
474
+ },
475
+ definitionVersion: 1,
476
+ bindingVersion: 1,
477
+ requirements: [],
478
+ answers: {},
479
+ preview: {},
480
+ workflowInstanceId: status === 'started' ? instanceId : null,
481
+ attemptCount: 1,
482
+ lastError: null,
483
+ replayed: false,
484
+ createdAt: '2026-08-29T01:00:00.000Z',
485
+ updatedAt: '2026-08-29T01:00:01.000Z',
486
+ };
487
+ }
488
+
489
+ function processCommandSurface() {
490
+ const command = processCommand('started');
491
+ const base = `/openxiangda-api/v2/applications/${appCode}/business-process/commands/${commandId}`;
492
+ return {
493
+ schemaVersion: 'openxiangda.process-command-surface/v2',
494
+ surfaceRevision: 'b'.repeat(64),
495
+ command,
496
+ subject: {
497
+ resourceCode: 'purchase-orders',
498
+ recordId: command.subject.id,
499
+ dataRevision: 1,
500
+ form: {
501
+ kind: 'native-resource-form',
502
+ resourceCode: 'purchase-orders',
503
+ recordId: command.subject.id,
504
+ },
505
+ },
506
+ requirements: [],
507
+ resume: {
508
+ status: { method: 'GET', href: base },
509
+ surface: { method: 'GET', href: `${base}/surface` },
510
+ answer: null,
511
+ retry: null,
512
+ navigationTarget: {
513
+ kind: 'PLATFORM_ROUTE',
514
+ routeCode: 'resource.record',
515
+ appCode,
516
+ pathParams: {
517
+ resourceCode: 'purchase-orders',
518
+ recordId: command.subject.id,
519
+ },
520
+ query: { processCommandId: commandId },
521
+ access: 'AUTHENTICATED',
522
+ },
523
+ desktop: `/purchase-orders/${command.subject.id}?processCommandId=${commandId}`,
524
+ mobile: `/m/purchase-orders/${command.subject.id}?processCommandId=${commandId}`,
525
+ },
526
+ };
527
+ }
528
+
529
+ function todoPage(offset = 0) {
530
+ const first = {
531
+ messageId: '33333333-3333-4333-8333-333333333333',
532
+ recipientId: '44444444-4444-4444-8444-444444444444',
533
+ state: 'action_required',
534
+ interactionState: 'unread',
535
+ title: '设备采购申请待审批',
536
+ summary: '张三提交了一笔办公设备采购申请',
537
+ fields: [
538
+ { code: 'amount', label: '申请金额', value: '¥28,600' },
539
+ { code: 'department', label: '申请部门', value: '理学院' },
540
+ ],
541
+ actions: [{ code: 'open', label: '去处理' }],
542
+ sourceKind: 'PLATFORM_EVENT',
543
+ sourceLabel: '流程中心',
544
+ occurredAt: '2026-08-28T01:00:00.000Z',
545
+ updatedAt: '2026-08-28T01:01:00.000Z',
546
+ navigation: {
547
+ desktopPath: `/admin/operations/purchases/${instanceId}?taskId=${taskId}`,
548
+ mobilePath: `/m/purchases/${instanceId}?taskId=${taskId}`,
549
+ external: false,
550
+ navigationUnavailable: false,
551
+ },
552
+ };
553
+ const second = {
554
+ ...first,
555
+ messageId: '77777777-7777-4777-8777-777777777777',
556
+ title: '采购结果通知',
557
+ interactionState: 'read',
558
+ };
559
+ return {
560
+ schemaVersion: 'openxiangda.application-todo-center/v2',
561
+ appCode,
562
+ environmentKey: 'preproduction',
563
+ view: 'pending',
564
+ counts: { pending: 1, informational: 2, completed: 3, unread: 1 },
565
+ items: [offset > 0 ? second : first],
566
+ total: 2,
567
+ limit: 12,
568
+ offset,
569
+ nextOffset: offset > 0 ? null : 1,
570
+ };
571
+ }
572
+
573
+ async function mockWorkflow(
574
+ page: Page,
575
+ options: {
576
+ commandConflict?: boolean;
577
+ forbidden?: boolean;
578
+ customDetail?: boolean;
579
+ } = {}
580
+ ) {
581
+ let completed = false;
582
+ await page.route('**/service/**', async route => {
583
+ const url = new URL(route.request().url());
584
+ const path = url.pathname;
585
+ if (path.endsWith('/auth/surface')) {
586
+ return route.fulfill({
587
+ contentType: 'application/json',
588
+ body: JSON.stringify(envelope({ csrfToken: 'workflow-csrf-1' })),
589
+ });
590
+ }
591
+ if (path.endsWith('/native/authz/current')) {
592
+ return route.fulfill({
593
+ contentType: 'application/json',
594
+ body: JSON.stringify(
595
+ envelope({
596
+ schemaVersion: 'openxiangda.runtime-authorization/v2',
597
+ state: 'active',
598
+ environment: {
599
+ id: 'preproduction-id',
600
+ key: 'preproduction',
601
+ activeAppVersionId: 'version-1',
602
+ headRevision: 1,
603
+ authzRevisionId: 'authz-1',
604
+ authzVersion: 1,
605
+ scopeDataVersion: 'scope-1',
606
+ },
607
+ subjectProfile: {
608
+ schemaVersion: 'openxiangda.subject-profile/v2',
609
+ userId: 'acceptance-user',
610
+ displayName: '王老师',
611
+ avatarUrl: null,
612
+ jobNumber: 'T001',
613
+ affiliatedDepartment: { id: 'department-1', name: '理学院' },
614
+ },
615
+ roles: [
616
+ {
617
+ code: 'department_manager',
618
+ name: '部门管理员',
619
+ source: 'package',
620
+ },
621
+ ],
622
+ principal: {
623
+ type: 'user_union',
624
+ userId: 'acceptance-user',
625
+ roleCodes: ['department_manager'],
626
+ capabilityCodes: [],
627
+ isAppSuperAdmin: true,
628
+ identityScope:
629
+ 'user-union:preproduction-id:acceptance-user:authz-1',
630
+ },
631
+ })
632
+ ),
633
+ });
634
+ }
635
+ if (path.includes('/directory/')) {
636
+ return route.fulfill({
637
+ contentType: 'application/json',
638
+ body: JSON.stringify(envelope({ items: [], nextCursor: null })),
639
+ });
640
+ }
641
+ if (options.forbidden && path.includes('/workflow/')) {
642
+ return route.fulfill({
643
+ status: 403,
644
+ contentType: 'application/json',
645
+ body: JSON.stringify(envelope(null, 403)),
646
+ });
647
+ }
648
+ if (path.endsWith('/todos') && route.request().method() === 'GET') {
649
+ return route.fulfill({
650
+ contentType: 'application/json',
651
+ body: JSON.stringify(
652
+ envelope(todoPage(Number(url.searchParams.get('offset') || 0))),
653
+ ),
654
+ });
655
+ }
656
+ if (path.includes('/todos/') && path.endsWith('/interactions')) {
657
+ return route.fulfill({
658
+ contentType: 'application/json',
659
+ body: JSON.stringify(
660
+ envelope({
661
+ accepted: true,
662
+ duplicate: false,
663
+ interactionState: 'clicked',
664
+ })
665
+ ),
666
+ });
667
+ }
668
+ if (
669
+ path.endsWith(
670
+ '/workflow/definitions/purchase-approval/launch-surface'
671
+ )
672
+ ) {
673
+ return route.fulfill({
674
+ contentType: 'application/json',
675
+ body: JSON.stringify(envelope(launchSurface())),
676
+ });
677
+ }
678
+ if (
679
+ path.endsWith('/business-process/standard-commands') &&
680
+ route.request().method() === 'POST'
681
+ ) {
682
+ const body = route.request().postDataJSON();
683
+ expect(body).toMatchObject({
684
+ schemaVersion: 'openxiangda.standard-process.commit/v2',
685
+ environmentKey: 'preproduction',
686
+ workflowCode: 'purchase-approval',
687
+ mutation: { kind: 'create', data: { amount: 28600 } },
688
+ });
689
+ for (const forbidden of [
690
+ 'facts',
691
+ 'dataRef',
692
+ 'preparationToken',
693
+ 'processOperationCode',
694
+ ]) {
695
+ expect(body).not.toHaveProperty(forbidden);
696
+ }
697
+ return route.fulfill({
698
+ contentType: 'application/json',
699
+ body: JSON.stringify(envelope(processCommand('accepted'))),
700
+ });
701
+ }
702
+ if (
703
+ path.endsWith(`/business-process/commands/${commandId}/surface`) &&
704
+ route.request().method() === 'GET'
705
+ ) {
706
+ return route.fulfill({
707
+ contentType: 'application/json',
708
+ body: JSON.stringify(envelope(processCommandSurface())),
709
+ });
710
+ }
711
+ if (path.endsWith('/workflow/work-center/items')) {
712
+ return route.fulfill({
713
+ contentType: 'application/json',
714
+ body: JSON.stringify(
715
+ envelope({
716
+ engineVersion: '2.0',
717
+ authorizationMode: 'current_user_role_union',
718
+ authorizationDigest: 'digest-1',
719
+ status: 'pending',
720
+ total: 1,
721
+ limit: 20,
722
+ offset: 0,
723
+ items: [task(completed, options.customDetail)],
724
+ })
725
+ ),
726
+ });
727
+ }
728
+ if (path.endsWith(`/workflow/tasks/${taskId}/surface`)) {
729
+ return route.fulfill({
730
+ contentType: 'application/json',
731
+ body: JSON.stringify(
732
+ envelope(surface(completed, options.customDetail))
733
+ ),
734
+ });
735
+ }
736
+ if (
737
+ path.endsWith(`/workflow/tasks/${taskId}/detail`) ||
738
+ path.endsWith(`/workflow/instances/${instanceId}/detail`)
739
+ ) {
740
+ expect(route.request().headers()['x-openxiangda-csrf-token']).toBe(
741
+ 'workflow-csrf-1'
742
+ );
743
+ return route.fulfill({
744
+ contentType: 'application/json',
745
+ body: JSON.stringify(
746
+ envelope(detail(completed, options.customDetail))
747
+ ),
748
+ });
749
+ }
750
+ if (path.endsWith(`/workflow/instances/${instanceId}/surface`)) {
751
+ return route.fulfill({
752
+ contentType: 'application/json',
753
+ body: JSON.stringify(
754
+ envelope(surface(completed, options.customDetail))
755
+ ),
756
+ });
757
+ }
758
+ if (path.endsWith(`/workflow/instances/${instanceId}/timeline`)) {
759
+ return route.fulfill({
760
+ contentType: 'application/json',
761
+ body: JSON.stringify(envelope(timeline(completed))),
762
+ });
763
+ }
764
+ if (path.endsWith(`/workflow/tasks/${taskId}/commands/approve`)) {
765
+ expect(route.request().headers()['x-openxiangda-csrf-token']).toBe(
766
+ 'workflow-csrf-1'
767
+ );
768
+ expect(route.request().postDataJSON()).toMatchObject({
769
+ commandToken: 'A'.repeat(43),
770
+ input: { comment: '同意采购' },
771
+ });
772
+ expect(route.request().postDataJSON()).not.toHaveProperty(
773
+ 'expectedTaskVersion'
774
+ );
775
+ expect(route.request().postDataJSON()).not.toHaveProperty(
776
+ 'expectedInstanceVersion'
777
+ );
778
+ if (options.commandConflict) {
779
+ return route.fulfill({
780
+ status: 409,
781
+ contentType: 'application/json',
782
+ body: JSON.stringify(
783
+ envelope({ freshSurface: surface(false, options.customDetail) }, 409)
784
+ ),
785
+ });
786
+ }
787
+ completed = true;
788
+ return route.fulfill({
789
+ contentType: 'application/json',
790
+ body: JSON.stringify(envelope({ status: 'completed' })),
791
+ });
792
+ }
793
+ return route.fulfill({
794
+ status: 404,
795
+ contentType: 'application/json',
796
+ body: JSON.stringify(envelope(null, 404)),
797
+ });
798
+ });
799
+ }
800
+
801
+ test('renders a paged desktop work center from the current-user role union', async ({
802
+ page,
803
+ }) => {
804
+ await mockWorkflow(page);
805
+ await page.goto('/workflow-experience.e2e.html?initial=/work-center');
806
+ await expect(page.getByRole('heading', { name: '审批工作台' })).toBeVisible();
807
+ await expect(page.getByRole('link', { name: '采购申请审批' })).toBeVisible();
808
+ await expect(page.getByText('PO-2026-0825')).toBeVisible();
809
+ });
810
+
811
+ test('renders the generic desktop and mobile application todo center without a second router', async ({
812
+ page,
813
+ }) => {
814
+ await mockWorkflow(page);
815
+ await page.goto('/workflow-experience.e2e.html?initial=/todos');
816
+ await expect(page.getByRole('heading', { name: '待办中心' })).toBeVisible();
817
+ await expect(page.getByTestId('standard-frame-desktop')).toHaveCount(0);
818
+ await expect(page.locator('.oxa-todo-table')).toBeVisible();
819
+ await expect(page.locator('.oxa-todo-preview-card')).toHaveCount(0);
820
+ await expect(page.getByText('设备采购申请待审批').first()).toBeVisible();
821
+ await expect(page.getByText('¥28,600').first()).toBeVisible();
822
+ await expect(page.locator('.ant-spin-spinning')).toHaveCount(0);
823
+ const unreadRequest = page.waitForRequest(request => {
824
+ const url = new URL(request.url());
825
+ return url.pathname.endsWith('/todos') && url.searchParams.get('unread') === 'true';
826
+ });
827
+ await page.getByRole('switch', { name: '仅看未读' }).click();
828
+ await unreadRequest;
829
+ await page.getByRole('button', { name: /查看详情/ }).first().click();
830
+ await expect(page).toHaveURL(
831
+ new RegExp(`/admin/operations/purchases/${instanceId}\\?taskId=${taskId}`)
832
+ );
833
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
834
+ `${instanceId} / ${taskId}`
835
+ );
836
+
837
+ await page.setViewportSize({ width: 390, height: 844 });
838
+ await page.goto('/workflow-experience.e2e.html?initial=/m/todos');
839
+ await expect(page.locator('.oxa-todo-center-mobile')).toBeVisible();
840
+ await expect(page.locator('.oxa-todo-preview-card')).toHaveCount(0);
841
+ await page.getByRole('button', { name: /查看详情/ }).first().click();
842
+ await expect(page).toHaveURL(
843
+ new RegExp(`/m/purchases/${instanceId}\\?taskId=${taskId}`)
844
+ );
845
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
846
+ `${instanceId} / ${taskId}`
847
+ );
848
+ });
849
+
850
+ test('selects paired application todo renderers while the platform owns data, paging and navigation', async ({
851
+ page,
852
+ }) => {
853
+ await mockWorkflow(page);
854
+ await page.goto(
855
+ workflowFixtureUrl('/todos', { surfaces: 'custom' }),
856
+ );
857
+ const desktopFrame = page.getByTestId('standard-frame-desktop');
858
+ await expect(desktopFrame).toHaveAttribute(
859
+ 'data-page-kind',
860
+ 'application-todo-center',
861
+ );
862
+ await expect(desktopFrame).toHaveAttribute(
863
+ 'data-route-code',
864
+ 'application.todo-center.desktop',
865
+ );
866
+ await expect(page.getByTestId('custom-todo-desktop')).toBeVisible();
867
+ await expect(page.getByText('设备采购申请待审批')).toBeVisible();
868
+
869
+ const nextPage = page.waitForRequest(request => {
870
+ const url = new URL(request.url());
871
+ return url.pathname.endsWith('/todos') && url.searchParams.get('offset') === '1';
872
+ });
873
+ await page.getByRole('button', { name: '加载更多' }).click();
874
+ await nextPage;
875
+ await expect(page.getByText('采购结果通知')).toBeVisible();
876
+
877
+ const unreadRequest = page.waitForRequest(request => {
878
+ const url = new URL(request.url());
879
+ return url.pathname.endsWith('/todos') && url.searchParams.get('unread') === 'true';
880
+ });
881
+ await page.getByRole('button', { name: '切换未读' }).click();
882
+ await unreadRequest;
883
+ await expect(page.getByTestId('custom-todo-query')).toContainText(
884
+ 'pending/true/all',
885
+ );
886
+
887
+ await page.getByRole('button', { name: '设备采购申请待审批' }).click();
888
+ await expect(page).toHaveURL(
889
+ new RegExp(`/admin/operations/purchases/${instanceId}\\?taskId=${taskId}`),
890
+ );
891
+
892
+ await page.setViewportSize({ width: 390, height: 844 });
893
+ await page.goto(
894
+ workflowFixtureUrl('/m/todos', { surfaces: 'custom' }),
895
+ );
896
+ await expect(page.getByTestId('custom-todo-mobile')).toBeVisible();
897
+ await expect(page.getByTestId('standard-frame-mobile')).toHaveAttribute(
898
+ 'data-route-code',
899
+ 'application.todo-center.mobile',
900
+ );
901
+ });
902
+
903
+ test('frames platform-owned workflow launch, task and instance routes with safe metadata', async ({
904
+ page,
905
+ }) => {
906
+ await mockWorkflow(page);
907
+ for (const [path, kind, routeCode, params] of [
908
+ [
909
+ '/workflows/purchase-approval/start',
910
+ 'workflow-launch',
911
+ 'workflow.purchase-approval.launch.desktop',
912
+ 'purchase-approval',
913
+ ],
914
+ [
915
+ `/tasks/${taskId}`,
916
+ 'workflow-task',
917
+ 'workflow.task.desktop',
918
+ taskId,
919
+ ],
920
+ [
921
+ `/workflows/${instanceId}`,
922
+ 'workflow-instance',
923
+ 'workflow.instance.desktop',
924
+ instanceId,
925
+ ],
926
+ ] as const) {
927
+ await page.goto(workflowFixtureUrl(path, { surfaces: 'custom' }));
928
+ const frame = page.getByTestId('standard-frame-desktop');
929
+ await expect(frame).toHaveAttribute('data-page-kind', kind);
930
+ await expect(frame).toHaveAttribute('data-route-code', routeCode);
931
+ await expect(frame).toHaveAttribute(
932
+ 'data-route-params',
933
+ new RegExp(params),
934
+ );
935
+ }
936
+ });
937
+
938
+ test('contains an application standard renderer failure inside the platform boundary', async ({
939
+ page,
940
+ }) => {
941
+ await mockWorkflow(page);
942
+ await page.goto(workflowFixtureUrl('/todos', { surfaces: 'error' }));
943
+ await expect(page.getByText('页面显示失败')).toBeVisible();
944
+ await expect(page.getByText(/平台登录态和业务数据未受影响/)).toBeVisible();
945
+ await expect(page.getByRole('button', { name: '重新加载页面' })).toBeVisible();
946
+ });
947
+
948
+ test('launches the same workflow from the canonical mobile Surface and follows custom detail routing', async ({
949
+ page,
950
+ }) => {
951
+ await mockWorkflow(page, { customDetail: true });
952
+ await page.setViewportSize({ width: 390, height: 844 });
953
+ await page.goto(
954
+ '/workflow-experience.e2e.html?initial=/m/workflows/purchase-approval/start'
955
+ );
956
+ await expect(page.locator('.oxa-workflow-submission-mobile')).toBeVisible();
957
+ await expect(page.getByRole('heading', { name: '采购审批' })).toBeVisible();
958
+ await page.getByRole('textbox', { name: '申请金额', exact: true }).fill('28600');
959
+ await page.getByRole('button', { name: '提交审批' }).click();
960
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
961
+ `${instanceId} / ${taskId}`
962
+ );
963
+ await expect(page).toHaveURL(
964
+ new RegExp(`/m/purchases/${instanceId}\\?taskId=${taskId}$`)
965
+ );
966
+ });
967
+
968
+ test('routes desktop and mobile work-center entries to the declared custom detail pages', async ({
969
+ page,
970
+ }) => {
971
+ await mockWorkflow(page, { customDetail: true });
972
+ await page.goto('/workflow-experience.e2e.html?initial=/work-center');
973
+ await page.getByRole('link', { name: '采购申请审批' }).click();
974
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
975
+ `${instanceId} / ${taskId}`
976
+ );
977
+ await expect(page).toHaveURL(
978
+ new RegExp(`/admin/operations/purchases/${instanceId}\\?taskId=${taskId}$`)
979
+ );
980
+
981
+ await page.setViewportSize({ width: 390, height: 844 });
982
+ await page.goto('/workflow-experience.e2e.html?initial=/m/work-center');
983
+ await page.getByText('采购申请审批').click();
984
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
985
+ `${instanceId} / ${taskId}`
986
+ );
987
+ await expect(page).toHaveURL(
988
+ new RegExp(`/m/purchases/${instanceId}\\?taskId=${taskId}$`)
989
+ );
990
+ });
991
+
992
+ test('redirects a direct standard task URL to the declared custom detail page', async ({
993
+ page,
994
+ }) => {
995
+ await mockWorkflow(page, { customDetail: true });
996
+ await page.goto(
997
+ `/workflow-experience.e2e.html?initial=/tasks/${taskId}`
998
+ );
999
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
1000
+ `${instanceId} / ${taskId}`
1001
+ );
1002
+ });
1003
+
1004
+ test('renders a standalone desktop detail on the canonical user URL', async ({
1005
+ page,
1006
+ }) => {
1007
+ await mockWorkflow(page);
1008
+ await page.goto(
1009
+ workflowFixtureUrl(`/tasks/${taskId}?from=todo#approval`),
1010
+ );
1011
+ await expect(page.locator('.oxa-workflow-desktop-standalone-page')).toBeVisible();
1012
+ await expect(page).toHaveURL(
1013
+ new RegExp(`/tasks/${taskId}\\?from=todo#approval$`),
1014
+ );
1015
+ await expect(page.locator('.oxa-app-layout')).toHaveCount(0);
1016
+ await expect(page.locator('.oxa-topbar')).toHaveCount(0);
1017
+ await expect(page.getByText('¥30,000')).toBeVisible();
1018
+ await expect(page.getByText('技术场地引用')).toHaveCount(0);
1019
+ await expect(page.getByText('业务数据已变化')).toHaveCount(0);
1020
+ await expect(page.getByText('流程信息')).toHaveCount(0);
1021
+ await expect(page.getByText('操作记录')).toHaveCount(0);
1022
+ await expect(page.getByText('由值班负责人继续处理')).toHaveCount(1);
1023
+ await expect(page.getByRole('button', { name: /同\s*意/ })).toBeVisible();
1024
+ await expect(page.getByRole('button', { name: /拒\s*绝/ })).toBeVisible();
1025
+ });
1026
+
1027
+ test('shows a friendly refresh prompt only after a real command conflict', async ({
1028
+ page,
1029
+ }) => {
1030
+ await mockWorkflow(page, { commandConflict: true });
1031
+ await page.goto(workflowFixtureUrl(`/tasks/${taskId}`));
1032
+ await expect(page.getByText('业务数据已变化')).toHaveCount(0);
1033
+ await page.getByRole('button', { name: /同\s*意/ }).click();
1034
+ await page.getByLabel('审批意见').fill('同意采购');
1035
+ await page.getByRole('button', { name: '确认同意' }).click();
1036
+ await expect(page.getByText('内容已更新,请刷新后重试')).toBeVisible();
1037
+ });
1038
+
1039
+ test('renders the independent mobile task and executes only a Surface operation', async ({
1040
+ page,
1041
+ }) => {
1042
+ await mockWorkflow(page);
1043
+ await page.setViewportSize({ width: 390, height: 844 });
1044
+ await page.goto(`/workflow-experience.e2e.html?initial=/m/tasks/${taskId}`);
1045
+ await expect(page.locator('.oxa-workflow-mobile-page')).toBeVisible();
1046
+ await expect(page.getByText('¥30,000')).toBeVisible();
1047
+ await expect(page.getByText('财务负责人').first()).toBeVisible();
1048
+ await expect(page.getByRole('button', { name: /同\s*意/ })).toBeVisible();
1049
+ await page.getByRole('button', { name: /同\s*意/ }).click();
1050
+ await page.getByLabel('审批意见').fill('同意采购');
1051
+ await page.getByRole('button', { name: '确认同意' }).click();
1052
+ await expect(page.getByText('同意已提交')).toBeVisible();
1053
+ await expect(page.getByRole('button', { name: /同\s*意/ })).toHaveCount(0);
1054
+ });
1055
+
1056
+ test('shows an explicit authorization error instead of an empty work center', async ({
1057
+ page,
1058
+ }) => {
1059
+ test.setTimeout(45_000);
1060
+ await mockWorkflow(page, { forbidden: true });
1061
+ await page.goto('/workflow-experience.e2e.html?initial=/work-center', {
1062
+ waitUntil: 'domcontentloaded',
1063
+ });
1064
+ await expect(page.getByRole('alert')).toContainText('403: forbidden', {
1065
+ timeout: 30_000,
1066
+ });
1067
+ await expect(page.getByRole('link', { name: '采购申请审批' })).toHaveCount(0);
1068
+ });
1069
+
1070
+ test('negotiates direct standard routes after BrowserRouter listener setup', async ({
1071
+ page,
1072
+ }) => {
1073
+ await mockWorkflow(page);
1074
+ await page.setViewportSize({ width: 360, height: 844 });
1075
+ await page.goto(
1076
+ workflowFixtureUrl('/todos?view=pending#unread', {
1077
+ state: 'deep-link',
1078
+ }),
1079
+ );
1080
+
1081
+ // The initial desktop URL is replaced by the mobile pair and the rendered
1082
+ // surface follows the Router update. The fixture is StrictMode-mounted.
1083
+ await expect(page.locator('.oxa-todo-center-mobile')).toBeVisible();
1084
+ await expect(page.locator('.oxa-todo-center-desktop')).toHaveCount(0);
1085
+ await expect(page).toHaveURL(/\/m\/todos\?view=pending#unread$/);
1086
+ const historyLength = await page.evaluate(() => window.history.length);
1087
+ const negotiatedUrl = page.url();
1088
+ await expect
1089
+ .poll(() => page.url(), { timeout: 1_000 })
1090
+ .toBe(negotiatedUrl);
1091
+ expect(await page.evaluate(() => window.history.state?.usr)).toEqual({
1092
+ routeNegotiationState: 'deep-link',
1093
+ });
1094
+
1095
+ // 900px remains mobile; crossing to 901px switches the same manifest pair
1096
+ // back to desktop without adding a browser-history entry.
1097
+ await page.setViewportSize({ width: 900, height: 844 });
1098
+ await expect(page.locator('.oxa-todo-center-mobile')).toBeVisible();
1099
+ await page.setViewportSize({ width: 901, height: 844 });
1100
+ await expect(page.locator('.oxa-todo-center-desktop')).toBeVisible();
1101
+ await expect(page).toHaveURL(/\/todos\?view=pending#unread$/);
1102
+ expect(await page.evaluate(() => window.history.length)).toBe(historyLength);
1103
+ expect(await page.evaluate(() => window.history.state?.usr)).toEqual({
1104
+ routeNegotiationState: 'deep-link',
1105
+ });
1106
+ await page.goBack();
1107
+ await expect(page).not.toHaveURL(/\/todos\?view=pending#unread$/);
1108
+
1109
+ // Direct task and instance URLs carry their dynamic parameter and deep-link
1110
+ // context to the independent mobile renderer at the narrow width.
1111
+ await page.setViewportSize({ width: 360, height: 844 });
1112
+ await page.goto(
1113
+ workflowFixtureUrl(`/tasks/${taskId}?from=todo#approval`),
1114
+ );
1115
+ await expect(page.locator('.oxa-workflow-detail-mobile-native')).toBeVisible();
1116
+ await expect(page).toHaveURL(
1117
+ new RegExp(`/m/tasks/${taskId}\\?from=todo#approval$`),
1118
+ );
1119
+
1120
+ await page.goto(
1121
+ workflowFixtureUrl(`/workflows/${instanceId}?from=todo#timeline`),
1122
+ );
1123
+ await expect(page.locator('.oxa-workflow-detail-mobile-native')).toBeVisible();
1124
+ await expect(page).toHaveURL(
1125
+ new RegExp(`/m/workflows/${instanceId}\\?from=todo#timeline$`),
1126
+ );
1127
+ });
1128
+
1129
+ test('negotiates launch pairs in both directions and preserves the mount basename', async ({
1130
+ page,
1131
+ }) => {
1132
+ await mockWorkflow(page);
1133
+ const base = '/runtime/route-negotiation';
1134
+ await page.setViewportSize({ width: 360, height: 844 });
1135
+ await page.goto(
1136
+ workflowFixtureUrl(
1137
+ `${base}/workflows/purchase-approval/start?source=todo#launch`,
1138
+ { base },
1139
+ ),
1140
+ );
1141
+ await expect(page.locator('.oxa-workflow-submission-mobile')).toBeVisible();
1142
+ await expect(page).toHaveURL(
1143
+ new RegExp(
1144
+ `${base}/m/workflows/purchase-approval/start\\?source=todo#launch$`,
1145
+ ),
1146
+ );
1147
+
1148
+ await page.setViewportSize({ width: 1_440, height: 900 });
1149
+ await expect(page.locator('.oxa-workflow-submission-desktop')).toBeVisible();
1150
+ await expect(page).toHaveURL(
1151
+ new RegExp(
1152
+ `${base}/workflows/purchase-approval/start\\?source=todo#launch$`,
1153
+ ),
1154
+ );
1155
+ });
1156
+
1157
+ test('does not negotiate non-standard application contribution routes', async ({
1158
+ page,
1159
+ }) => {
1160
+ await mockWorkflow(page);
1161
+ const base = '/runtime/route-negotiation';
1162
+ await page.setViewportSize({ width: 360, height: 844 });
1163
+ const customPath = `${base}/admin/operations/purchases/${instanceId}?taskId=${taskId}`;
1164
+ await page.goto(workflowFixtureUrl(customPath, { base }));
1165
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
1166
+ `${instanceId} / ${taskId}`,
1167
+ );
1168
+ const customUrl = page.url();
1169
+ await expect(page).toHaveURL(new RegExp(`${base}/admin/operations/`));
1170
+ await expect
1171
+ .poll(() => page.url(), { timeout: 1_000 })
1172
+ .toBe(customUrl);
1173
+ });