openxiangda-cli 2.0.0-alpha.16 → 2.0.0-alpha.162

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