openxiangda-cli 2.0.0-alpha.14 → 2.0.0-alpha.141

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/login.js +73 -0
  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 +28 -0
  83. package/template/README.md +58 -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 +450 -0
  98. package/template/apps/web/e2e/resources.spec.ts +612 -0
  99. package/template/apps/web/e2e/theme-contract-fixture.css +15 -0
  100. package/template/apps/web/e2e/workflow-experience-fixture.tsx +300 -0
  101. package/template/apps/web/e2e/workflow.spec.ts +809 -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 +32 -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 +62 -0
  110. package/template/apps/web/test/contracts.test.ts +155 -0
  111. package/template/apps/web/test/loopback.test.ts +62 -0
  112. package/template/apps/web/tsconfig.json +12 -0
  113. package/template/apps/web/vite.config.ts +40 -0
  114. package/template/apps/web/workflow-experience.e2e.html +15 -0
  115. package/template/appspec/app.md +36 -0
  116. package/template/openxiangda.config.ts +77 -0
  117. package/template/package.json +29 -0
  118. package/template/packages/contracts/package.json +23 -0
  119. package/template/packages/contracts/src/generated.ts +263 -0
  120. package/template/packages/contracts/src/index.ts +1 -0
  121. package/template/packages/contracts/tsconfig.json +9 -0
  122. package/template/pnpm-workspace.yaml +7 -0
  123. package/template/scripts/verify-template-budget.mjs +49 -0
  124. package/template/tsconfig.base.json +13 -0
  125. package/dist/commands/app/create.d.ts +0 -15
  126. package/dist/commands/app/create.d.ts.map +0 -1
  127. package/dist/commands/app/create.js +0 -42
  128. package/dist/commands/app/create.js.map +0 -1
  129. package/dist/commands/app/info.d.ts +0 -9
  130. package/dist/commands/app/info.d.ts.map +0 -1
  131. package/dist/commands/app/info.js +0 -10
  132. package/dist/commands/app/info.js.map +0 -1
  133. package/dist/commands/app/link.d.ts +0 -10
  134. package/dist/commands/app/link.d.ts.map +0 -1
  135. package/dist/commands/app/link.js +0 -16
  136. package/dist/commands/app/link.js.map +0 -1
  137. package/dist/commands/app/provision.d.ts +0 -9
  138. package/dist/commands/app/provision.d.ts.map +0 -1
  139. package/dist/commands/app/provision.js +0 -10
  140. package/dist/commands/app/provision.js.map +0 -1
  141. package/dist/commands/auth/login.d.ts +0 -10
  142. package/dist/commands/auth/login.d.ts.map +0 -1
  143. package/dist/commands/auth/login.js +0 -34
  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 -10
  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 -19
  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,809 @@
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: `/admin/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 ? 'revision-2' : 'revision-1',
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: {
115
+ 申请金额: '¥28,600',
116
+ 采购类型: '办公设备',
117
+ 期望到货: '2026-09-05',
118
+ 申请人: '张三',
119
+ },
120
+ projectionDigest: 'c'.repeat(64),
121
+ },
122
+ },
123
+ fieldPolicy: { default: 'readonly', fields: {} },
124
+ operations: completed
125
+ ? []
126
+ : [
127
+ {
128
+ key: 'approve',
129
+ kind: 'workflow_command',
130
+ label: '同意',
131
+ placement: 'primary',
132
+ tone: 'primary',
133
+ visible: true,
134
+ enabled: true,
135
+ inputSchema: {
136
+ type: 'object',
137
+ properties: {
138
+ comment: {
139
+ type: 'string',
140
+ title: '审批意见',
141
+ format: 'textarea',
142
+ maxLength: 500,
143
+ },
144
+ },
145
+ },
146
+ uiSchema: {
147
+ confirmText: '确认同意',
148
+ confirmation: { description: '提交后流程继续流转。' },
149
+ },
150
+ execute: {
151
+ method: 'POST',
152
+ href: '/workflow/approve',
153
+ idempotencyRequired: true,
154
+ },
155
+ refresh: ['surface', 'timeline', 'work_center'],
156
+ },
157
+ ...['reject', 'return', 'transfer', 'add_assignee'].map(key => ({
158
+ key,
159
+ kind: 'workflow_command',
160
+ label: {
161
+ reject: '拒绝',
162
+ return: '退回',
163
+ transfer: '转交',
164
+ add_assignee: '加签',
165
+ }[key],
166
+ placement: 'secondary',
167
+ tone: key === 'reject' ? 'danger' : 'neutral',
168
+ visible: true,
169
+ enabled: true,
170
+ inputSchema: { type: 'object', properties: {} },
171
+ uiSchema: { confirmText: `确认${key}` },
172
+ execute: {
173
+ method: 'POST',
174
+ href: `/workflow/${key}`,
175
+ idempotencyRequired: true,
176
+ },
177
+ refresh: ['surface', 'timeline', 'work_center'],
178
+ })),
179
+ ],
180
+ extensions: {},
181
+ };
182
+ }
183
+
184
+ function timeline(completed = false) {
185
+ return {
186
+ engineVersion: '2.0',
187
+ instanceId,
188
+ flow: [
189
+ {
190
+ key: 'start',
191
+ nodeId: null,
192
+ kind: 'start',
193
+ title: '张三',
194
+ status: 'completed',
195
+ startedAt: '2026-08-25T01:00:00.000Z',
196
+ completedAt: '2026-08-25T01:00:00.000Z',
197
+ assignees: [],
198
+ },
199
+ {
200
+ key: 'manager',
201
+ nodeId: 'manager-review',
202
+ kind: 'approval',
203
+ title: '部门负责人',
204
+ status: completed ? 'completed' : 'active',
205
+ startedAt: '2026-08-25T01:01:00.000Z',
206
+ completedAt: completed ? '2026-08-25T01:12:00.000Z' : null,
207
+ assignees: [{ userId: 'acceptance-user', displayName: '李明' }],
208
+ },
209
+ {
210
+ key: 'finance',
211
+ nodeId: 'finance-review',
212
+ kind: 'approval',
213
+ title: '财务负责人',
214
+ status: completed ? 'completed' : 'waiting',
215
+ startedAt: null,
216
+ completedAt: null,
217
+ assignees: [{ userId: 'finance-user', displayName: '财务负责人' }],
218
+ },
219
+ ],
220
+ items: [],
221
+ };
222
+ }
223
+
224
+ function launchSurface() {
225
+ return {
226
+ schemaVersion: 'openxiangda.workflow-launch-surface/v2',
227
+ protocolVersion: 'workflow_launch_surface_v2',
228
+ surfaceRevision: 'launch-revision-1',
229
+ engineVersion: '2.0',
230
+ appCode,
231
+ environmentKey: 'preproduction',
232
+ environmentId: 'preproduction-id',
233
+ workflowCode: 'purchase-approval',
234
+ title: '采购审批',
235
+ launchMode: 'standalone',
236
+ processOperationCode: 'openxiangda.workflow.purchase-approval.submit',
237
+ subject: {
238
+ resourceCode: 'purchase-orders',
239
+ factProjection: { amount: 'amount' },
240
+ summaryFields: ['amount'],
241
+ },
242
+ inputSchema: { type: 'object', properties: {} },
243
+ head: {
244
+ workflowRevision: 4,
245
+ definitionVersion: 1,
246
+ bindingVersion: 1,
247
+ nativeRevision: 3,
248
+ contractRevisionId: 'contract-revision-1',
249
+ },
250
+ paths: {
251
+ desktop: '/admin/workflows/purchase-approval/new',
252
+ mobile: '/m/workflows/purchase-approval/start',
253
+ },
254
+ commit: {
255
+ method: 'POST',
256
+ href: `/openxiangda-api/v2/applications/${appCode}/business-process/standard-commands`,
257
+ idempotencyRequired: true,
258
+ },
259
+ };
260
+ }
261
+
262
+ function processCommand(status: 'accepted' | 'started' = 'started') {
263
+ return {
264
+ schemaVersion: 'openxiangda.business-process.command/v2',
265
+ id: commandId,
266
+ appCode,
267
+ environmentKey: 'preproduction',
268
+ operationCode: 'openxiangda.workflow.purchase-approval.submit',
269
+ idempotencyKey: 'process:purchase-approval:test',
270
+ workflowCode: 'purchase-approval',
271
+ status,
272
+ revision: status === 'started' ? 4 : 1,
273
+ subject: {
274
+ resourceCode: 'purchase-orders',
275
+ id: '66666666-6666-4666-8666-666666666666',
276
+ dataRevision: 1,
277
+ factDigest: 'a'.repeat(64),
278
+ },
279
+ definitionVersion: 1,
280
+ bindingVersion: 1,
281
+ requirements: [],
282
+ answers: {},
283
+ preview: {},
284
+ workflowInstanceId: status === 'started' ? instanceId : null,
285
+ attemptCount: 1,
286
+ lastError: null,
287
+ replayed: false,
288
+ createdAt: '2026-08-29T01:00:00.000Z',
289
+ updatedAt: '2026-08-29T01:00:01.000Z',
290
+ };
291
+ }
292
+
293
+ function processCommandSurface() {
294
+ const command = processCommand('started');
295
+ const base = `/openxiangda-api/v2/applications/${appCode}/business-process/commands/${commandId}`;
296
+ return {
297
+ schemaVersion: 'openxiangda.process-command-surface/v2',
298
+ surfaceRevision: 'b'.repeat(64),
299
+ command,
300
+ subject: {
301
+ resourceCode: 'purchase-orders',
302
+ recordId: command.subject.id,
303
+ dataRevision: 1,
304
+ form: {
305
+ kind: 'native-resource-form',
306
+ resourceCode: 'purchase-orders',
307
+ recordId: command.subject.id,
308
+ },
309
+ },
310
+ requirements: [],
311
+ resume: {
312
+ status: { method: 'GET', href: base },
313
+ surface: { method: 'GET', href: `${base}/surface` },
314
+ answer: null,
315
+ retry: null,
316
+ navigationTarget: {
317
+ kind: 'PLATFORM_ROUTE',
318
+ routeCode: 'resource.record',
319
+ appCode,
320
+ pathParams: {
321
+ resourceCode: 'purchase-orders',
322
+ recordId: command.subject.id,
323
+ },
324
+ query: { processCommandId: commandId },
325
+ access: 'AUTHENTICATED',
326
+ },
327
+ desktop: `/purchase-orders/${command.subject.id}?processCommandId=${commandId}`,
328
+ mobile: `/m/purchase-orders/${command.subject.id}?processCommandId=${commandId}`,
329
+ },
330
+ };
331
+ }
332
+
333
+ function todoPage() {
334
+ return {
335
+ schemaVersion: 'openxiangda.application-todo-center/v2',
336
+ appCode,
337
+ environmentKey: 'preproduction',
338
+ view: 'pending',
339
+ counts: { pending: 1, informational: 2, completed: 3, unread: 1 },
340
+ items: [
341
+ {
342
+ messageId: '33333333-3333-4333-8333-333333333333',
343
+ recipientId: '44444444-4444-4444-8444-444444444444',
344
+ state: 'action_required',
345
+ interactionState: 'unread',
346
+ title: '设备采购申请待审批',
347
+ summary: '张三提交了一笔办公设备采购申请',
348
+ fields: [
349
+ { code: 'amount', label: '申请金额', value: '¥28,600' },
350
+ { code: 'department', label: '申请部门', value: '理学院' },
351
+ ],
352
+ actions: [{ code: 'open', label: '去处理' }],
353
+ sourceKind: 'PLATFORM_EVENT',
354
+ sourceLabel: '流程中心',
355
+ occurredAt: '2026-08-28T01:00:00.000Z',
356
+ updatedAt: '2026-08-28T01:01:00.000Z',
357
+ navigation: {
358
+ desktopPath: `/admin/operations/purchases/${instanceId}?taskId=${taskId}`,
359
+ mobilePath: `/m/purchases/${instanceId}?taskId=${taskId}`,
360
+ external: false,
361
+ navigationUnavailable: false,
362
+ },
363
+ },
364
+ ],
365
+ total: 1,
366
+ limit: 12,
367
+ offset: 0,
368
+ nextOffset: null,
369
+ };
370
+ }
371
+
372
+ async function mockWorkflow(
373
+ page: Page,
374
+ options: { forbidden?: boolean; customDetail?: boolean } = {}
375
+ ) {
376
+ let completed = false;
377
+ await page.route('**/service/**', async route => {
378
+ const url = new URL(route.request().url());
379
+ const path = url.pathname;
380
+ if (path.endsWith('/auth/surface')) {
381
+ return route.fulfill({
382
+ contentType: 'application/json',
383
+ body: JSON.stringify(envelope({ csrfToken: 'workflow-csrf-1' })),
384
+ });
385
+ }
386
+ if (path.endsWith('/native/authz/current')) {
387
+ return route.fulfill({
388
+ contentType: 'application/json',
389
+ body: JSON.stringify(
390
+ envelope({
391
+ schemaVersion: 'openxiangda.runtime-authorization/v2',
392
+ state: 'active',
393
+ environment: {
394
+ id: 'preproduction-id',
395
+ key: 'preproduction',
396
+ activeAppVersionId: 'version-1',
397
+ headRevision: 1,
398
+ authzRevisionId: 'authz-1',
399
+ authzVersion: 1,
400
+ scopeDataVersion: 'scope-1',
401
+ },
402
+ subjectProfile: {
403
+ schemaVersion: 'openxiangda.subject-profile/v2',
404
+ userId: 'acceptance-user',
405
+ displayName: '王老师',
406
+ avatarUrl: null,
407
+ jobNumber: 'T001',
408
+ affiliatedDepartment: { id: 'department-1', name: '理学院' },
409
+ },
410
+ roles: [
411
+ {
412
+ code: 'department_manager',
413
+ name: '部门管理员',
414
+ source: 'package',
415
+ },
416
+ ],
417
+ principal: {
418
+ type: 'user_union',
419
+ userId: 'acceptance-user',
420
+ roleCodes: ['department_manager'],
421
+ capabilityCodes: [],
422
+ isAppSuperAdmin: true,
423
+ identityScope:
424
+ 'user-union:preproduction-id:acceptance-user:authz-1',
425
+ },
426
+ })
427
+ ),
428
+ });
429
+ }
430
+ if (path.includes('/directory/')) {
431
+ return route.fulfill({
432
+ contentType: 'application/json',
433
+ body: JSON.stringify(envelope({ items: [], nextCursor: null })),
434
+ });
435
+ }
436
+ if (options.forbidden && path.includes('/workflow/')) {
437
+ return route.fulfill({
438
+ status: 403,
439
+ contentType: 'application/json',
440
+ body: JSON.stringify(envelope(null, 403)),
441
+ });
442
+ }
443
+ if (path.endsWith('/todos') && route.request().method() === 'GET') {
444
+ return route.fulfill({
445
+ contentType: 'application/json',
446
+ body: JSON.stringify(envelope(todoPage())),
447
+ });
448
+ }
449
+ if (path.includes('/todos/') && path.endsWith('/interactions')) {
450
+ return route.fulfill({
451
+ contentType: 'application/json',
452
+ body: JSON.stringify(
453
+ envelope({
454
+ accepted: true,
455
+ duplicate: false,
456
+ interactionState: 'clicked',
457
+ })
458
+ ),
459
+ });
460
+ }
461
+ if (
462
+ path.endsWith(
463
+ '/workflow/definitions/purchase-approval/launch-surface'
464
+ )
465
+ ) {
466
+ return route.fulfill({
467
+ contentType: 'application/json',
468
+ body: JSON.stringify(envelope(launchSurface())),
469
+ });
470
+ }
471
+ if (
472
+ path.endsWith('/business-process/standard-commands') &&
473
+ route.request().method() === 'POST'
474
+ ) {
475
+ const body = route.request().postDataJSON();
476
+ expect(body).toMatchObject({
477
+ schemaVersion: 'openxiangda.standard-process.commit/v2',
478
+ environmentKey: 'preproduction',
479
+ workflowCode: 'purchase-approval',
480
+ mutation: { kind: 'create', data: { amount: '28600' } },
481
+ });
482
+ for (const forbidden of [
483
+ 'facts',
484
+ 'dataRef',
485
+ 'preparationToken',
486
+ 'processOperationCode',
487
+ ]) {
488
+ expect(body).not.toHaveProperty(forbidden);
489
+ }
490
+ return route.fulfill({
491
+ contentType: 'application/json',
492
+ body: JSON.stringify(envelope(processCommand('accepted'))),
493
+ });
494
+ }
495
+ if (
496
+ path.endsWith(`/business-process/commands/${commandId}/surface`) &&
497
+ route.request().method() === 'GET'
498
+ ) {
499
+ return route.fulfill({
500
+ contentType: 'application/json',
501
+ body: JSON.stringify(envelope(processCommandSurface())),
502
+ });
503
+ }
504
+ if (path.endsWith('/workflow/work-center/items')) {
505
+ return route.fulfill({
506
+ contentType: 'application/json',
507
+ body: JSON.stringify(
508
+ envelope({
509
+ engineVersion: '2.0',
510
+ authorizationMode: 'current_user_role_union',
511
+ authorizationDigest: 'digest-1',
512
+ status: 'pending',
513
+ total: 1,
514
+ limit: 20,
515
+ offset: 0,
516
+ items: [task(completed, options.customDetail)],
517
+ })
518
+ ),
519
+ });
520
+ }
521
+ if (path.endsWith(`/workflow/tasks/${taskId}/surface`)) {
522
+ return route.fulfill({
523
+ contentType: 'application/json',
524
+ body: JSON.stringify(
525
+ envelope(surface(completed, options.customDetail))
526
+ ),
527
+ });
528
+ }
529
+ if (path.endsWith(`/workflow/instances/${instanceId}/surface`)) {
530
+ return route.fulfill({
531
+ contentType: 'application/json',
532
+ body: JSON.stringify(
533
+ envelope(surface(completed, options.customDetail))
534
+ ),
535
+ });
536
+ }
537
+ if (path.endsWith(`/workflow/instances/${instanceId}/timeline`)) {
538
+ return route.fulfill({
539
+ contentType: 'application/json',
540
+ body: JSON.stringify(envelope(timeline(completed))),
541
+ });
542
+ }
543
+ if (path.endsWith(`/workflow/tasks/${taskId}/commands/approve`)) {
544
+ expect(route.request().headers()['x-openxiangda-csrf-token']).toBe(
545
+ 'workflow-csrf-1'
546
+ );
547
+ expect(route.request().postDataJSON()).toMatchObject({
548
+ commandToken: 'A'.repeat(43),
549
+ input: { comment: '同意采购' },
550
+ });
551
+ expect(route.request().postDataJSON()).not.toHaveProperty(
552
+ 'expectedTaskVersion'
553
+ );
554
+ expect(route.request().postDataJSON()).not.toHaveProperty(
555
+ 'expectedInstanceVersion'
556
+ );
557
+ completed = true;
558
+ return route.fulfill({
559
+ contentType: 'application/json',
560
+ body: JSON.stringify(envelope({ status: 'completed' })),
561
+ });
562
+ }
563
+ return route.fulfill({
564
+ status: 404,
565
+ contentType: 'application/json',
566
+ body: JSON.stringify(envelope(null, 404)),
567
+ });
568
+ });
569
+ }
570
+
571
+ test('renders a paged desktop work center from the current-user role union', async ({
572
+ page,
573
+ }) => {
574
+ await mockWorkflow(page);
575
+ await page.goto('/workflow-experience.e2e.html?initial=/admin/work-center');
576
+ await expect(page.getByRole('heading', { name: '审批工作台' })).toBeVisible();
577
+ await expect(page.getByRole('link', { name: '采购申请审批' })).toBeVisible();
578
+ await expect(page.getByText('PO-2026-0825')).toBeVisible();
579
+ });
580
+
581
+ test('renders the generic desktop and mobile application todo center without a second router', async ({
582
+ page,
583
+ }) => {
584
+ await mockWorkflow(page);
585
+ await page.goto('/workflow-experience.e2e.html?initial=/admin/todos');
586
+ await expect(page.getByRole('heading', { name: '待办中心' })).toBeVisible();
587
+ await expect(page.locator('.oxa-todo-table')).toBeVisible();
588
+ await expect(page.locator('.oxa-todo-preview-card')).toHaveCount(0);
589
+ await expect(page.getByText('设备采购申请待审批').first()).toBeVisible();
590
+ await expect(page.getByText('¥28,600').first()).toBeVisible();
591
+ await expect(page.locator('.ant-spin-spinning')).toHaveCount(0);
592
+ const unreadRequest = page.waitForRequest(request => {
593
+ const url = new URL(request.url());
594
+ return url.pathname.endsWith('/todos') && url.searchParams.get('unread') === 'true';
595
+ });
596
+ await page.getByRole('switch', { name: '仅看未读' }).click();
597
+ await unreadRequest;
598
+ await page.getByRole('button', { name: /查看详情/ }).first().click();
599
+ await expect(page).toHaveURL(
600
+ new RegExp(`/admin/operations/purchases/${instanceId}\\?taskId=${taskId}`)
601
+ );
602
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
603
+ `${instanceId} / ${taskId}`
604
+ );
605
+
606
+ await page.setViewportSize({ width: 390, height: 844 });
607
+ await page.goto('/workflow-experience.e2e.html?initial=/m/todos');
608
+ await expect(page.locator('.oxa-todo-center-mobile')).toBeVisible();
609
+ await expect(page.locator('.oxa-todo-preview-card')).toHaveCount(0);
610
+ await page.getByRole('button', { name: /查看详情/ }).first().click();
611
+ await expect(page).toHaveURL(
612
+ new RegExp(`/m/purchases/${instanceId}\\?taskId=${taskId}`)
613
+ );
614
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
615
+ `${instanceId} / ${taskId}`
616
+ );
617
+ });
618
+
619
+ test('launches the same workflow from the canonical mobile Surface and follows custom detail routing', async ({
620
+ page,
621
+ }) => {
622
+ await mockWorkflow(page, { customDetail: true });
623
+ await page.setViewportSize({ width: 390, height: 844 });
624
+ await page.goto(
625
+ '/workflow-experience.e2e.html?initial=/m/workflows/purchase-approval/start'
626
+ );
627
+ await expect(page.locator('.oxa-workflow-submission-mobile')).toBeVisible();
628
+ await expect(page.getByRole('heading', { name: '采购审批' })).toBeVisible();
629
+ await page.getByRole('spinbutton', { name: '请输入申请金额' }).fill('28600');
630
+ await page.getByRole('button', { name: '提交审批' }).click();
631
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
632
+ `${instanceId} / ${taskId}`
633
+ );
634
+ await expect(page).toHaveURL(
635
+ new RegExp(`/m/purchases/${instanceId}\\?taskId=${taskId}$`)
636
+ );
637
+ });
638
+
639
+ test('routes desktop and mobile work-center entries to the declared custom detail pages', async ({
640
+ page,
641
+ }) => {
642
+ await mockWorkflow(page, { customDetail: true });
643
+ await page.goto('/workflow-experience.e2e.html?initial=/admin/work-center');
644
+ await page.getByRole('link', { name: '采购申请审批' }).click();
645
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
646
+ `${instanceId} / ${taskId}`
647
+ );
648
+ await expect(page).toHaveURL(
649
+ new RegExp(`/admin/operations/purchases/${instanceId}\\?taskId=${taskId}$`)
650
+ );
651
+
652
+ await page.setViewportSize({ width: 390, height: 844 });
653
+ await page.goto('/workflow-experience.e2e.html?initial=/m/work-center');
654
+ await page.getByText('采购申请审批').click();
655
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
656
+ `${instanceId} / ${taskId}`
657
+ );
658
+ await expect(page).toHaveURL(
659
+ new RegExp(`/m/purchases/${instanceId}\\?taskId=${taskId}$`)
660
+ );
661
+ });
662
+
663
+ test('redirects a direct standard task URL to the declared custom detail page', async ({
664
+ page,
665
+ }) => {
666
+ await mockWorkflow(page, { customDetail: true });
667
+ await page.goto(
668
+ `/workflow-experience.e2e.html?initial=/admin/tasks/${taskId}`
669
+ );
670
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
671
+ `${instanceId} / ${taskId}`
672
+ );
673
+ });
674
+
675
+ test('renders the independent mobile task and executes only a Surface operation', async ({
676
+ page,
677
+ }) => {
678
+ await mockWorkflow(page);
679
+ await page.setViewportSize({ width: 390, height: 844 });
680
+ await page.goto(`/workflow-experience.e2e.html?initial=/m/tasks/${taskId}`);
681
+ await expect(page.locator('.oxa-workflow-mobile-page')).toBeVisible();
682
+ await expect(page.getByText('¥28,600')).toBeVisible();
683
+ await expect(page.getByText('财务负责人').first()).toBeVisible();
684
+ await expect(page.getByRole('button', { name: /同\s*意/ })).toBeVisible();
685
+ await page.getByRole('button', { name: /同\s*意/ }).click();
686
+ await page.getByLabel('审批意见').fill('同意采购');
687
+ await page.getByRole('button', { name: '确认同意' }).click();
688
+ await expect(page.getByText('同意已提交')).toBeVisible();
689
+ await expect(page.getByRole('button', { name: /同\s*意/ })).toHaveCount(0);
690
+ });
691
+
692
+ test('shows an explicit authorization error instead of an empty work center', async ({
693
+ page,
694
+ }) => {
695
+ test.setTimeout(45_000);
696
+ await mockWorkflow(page, { forbidden: true });
697
+ await page.goto('/workflow-experience.e2e.html?initial=/admin/work-center', {
698
+ waitUntil: 'domcontentloaded',
699
+ });
700
+ await expect(page.getByRole('alert')).toContainText('403: forbidden', {
701
+ timeout: 30_000,
702
+ });
703
+ await expect(page.getByRole('link', { name: '采购申请审批' })).toHaveCount(0);
704
+ });
705
+
706
+ test('negotiates direct standard routes after BrowserRouter listener setup', async ({
707
+ page,
708
+ }) => {
709
+ await mockWorkflow(page);
710
+ await page.setViewportSize({ width: 360, height: 844 });
711
+ await page.goto(
712
+ workflowFixtureUrl('/admin/todos?view=pending#unread', {
713
+ state: 'deep-link',
714
+ }),
715
+ );
716
+
717
+ // The initial desktop URL is replaced by the mobile pair and the rendered
718
+ // surface follows the Router update. The fixture is StrictMode-mounted.
719
+ await expect(page.locator('.oxa-todo-center-mobile')).toBeVisible();
720
+ await expect(page.locator('.oxa-todo-center-desktop')).toHaveCount(0);
721
+ await expect(page).toHaveURL(/\/m\/todos\?view=pending#unread$/);
722
+ const historyLength = await page.evaluate(() => window.history.length);
723
+ const negotiatedUrl = page.url();
724
+ await expect
725
+ .poll(() => page.url(), { timeout: 1_000 })
726
+ .toBe(negotiatedUrl);
727
+ expect(await page.evaluate(() => window.history.state?.usr)).toEqual({
728
+ routeNegotiationState: 'deep-link',
729
+ });
730
+
731
+ // 900px remains mobile; crossing to 901px switches the same manifest pair
732
+ // back to desktop without adding a browser-history entry.
733
+ await page.setViewportSize({ width: 900, height: 844 });
734
+ await expect(page.locator('.oxa-todo-center-mobile')).toBeVisible();
735
+ await page.setViewportSize({ width: 901, height: 844 });
736
+ await expect(page.locator('.oxa-todo-center-desktop')).toBeVisible();
737
+ await expect(page).toHaveURL(/\/admin\/todos\?view=pending#unread$/);
738
+ expect(await page.evaluate(() => window.history.length)).toBe(historyLength);
739
+ expect(await page.evaluate(() => window.history.state?.usr)).toEqual({
740
+ routeNegotiationState: 'deep-link',
741
+ });
742
+ await page.goBack();
743
+ await expect(page).not.toHaveURL(/\/admin\/todos\?view=pending#unread$/);
744
+
745
+ // Direct task and instance URLs carry their dynamic parameter and deep-link
746
+ // context to the independent mobile renderer at the narrow width.
747
+ await page.setViewportSize({ width: 360, height: 844 });
748
+ await page.goto(
749
+ workflowFixtureUrl(`/admin/tasks/${taskId}?from=todo#approval`),
750
+ );
751
+ await expect(page.locator('.oxa-workflow-detail-mobile')).toBeVisible();
752
+ await expect(page).toHaveURL(
753
+ new RegExp(`/m/tasks/${taskId}\\?from=todo#approval$`),
754
+ );
755
+
756
+ await page.goto(
757
+ workflowFixtureUrl(`/admin/workflows/${instanceId}?from=todo#timeline`),
758
+ );
759
+ await expect(page.locator('.oxa-workflow-detail-mobile')).toBeVisible();
760
+ await expect(page).toHaveURL(
761
+ new RegExp(`/m/workflows/${instanceId}\\?from=todo#timeline$`),
762
+ );
763
+ });
764
+
765
+ test('negotiates launch pairs in both directions and preserves the mount basename', async ({
766
+ page,
767
+ }) => {
768
+ await mockWorkflow(page);
769
+ const base = '/runtime/route-negotiation';
770
+ await page.setViewportSize({ width: 360, height: 844 });
771
+ await page.goto(
772
+ workflowFixtureUrl(
773
+ `${base}/admin/workflows/purchase-approval/new?source=todo#launch`,
774
+ { base },
775
+ ),
776
+ );
777
+ await expect(page.locator('.oxa-workflow-submission-mobile')).toBeVisible();
778
+ await expect(page).toHaveURL(
779
+ new RegExp(
780
+ `${base}/m/workflows/purchase-approval/start\\?source=todo#launch$`,
781
+ ),
782
+ );
783
+
784
+ await page.setViewportSize({ width: 1_440, height: 900 });
785
+ await expect(page.locator('.oxa-workflow-submission-desktop')).toBeVisible();
786
+ await expect(page).toHaveURL(
787
+ new RegExp(
788
+ `${base}/admin/workflows/purchase-approval/new\\?source=todo#launch$`,
789
+ ),
790
+ );
791
+ });
792
+
793
+ test('does not negotiate non-standard application contribution routes', async ({
794
+ page,
795
+ }) => {
796
+ await mockWorkflow(page);
797
+ const base = '/runtime/route-negotiation';
798
+ await page.setViewportSize({ width: 360, height: 844 });
799
+ const customPath = `${base}/admin/operations/purchases/${instanceId}?taskId=${taskId}`;
800
+ await page.goto(workflowFixtureUrl(customPath, { base }));
801
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
802
+ `${instanceId} / ${taskId}`,
803
+ );
804
+ const customUrl = page.url();
805
+ await expect(page).toHaveURL(new RegExp(`${base}/admin/operations/`));
806
+ await expect
807
+ .poll(() => page.url(), { timeout: 1_000 })
808
+ .toBe(customUrl);
809
+ });