openxiangda-cli 2.0.0-alpha.171 → 2.0.0-alpha.173
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.
- package/package.json +5 -5
- package/template/apps/web/e2e/mobile-files.spec.ts +3 -0
- package/template/apps/web/e2e/resource-platform-mock.ts +1 -1
- package/template/apps/web/e2e/resources.spec.ts +4 -1
- package/template/apps/web/e2e/standard-admin.spec.ts +38 -10
- package/template/apps/web/e2e/workflow-entry-fixture.tsx +107 -0
- package/template/apps/web/e2e/workflow-entry.spec.ts +525 -0
- package/template/apps/web/e2e/workflow.spec.ts +67 -17
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/test/contracts.test.ts +1 -0
- package/template/apps/web/vite.config.ts +1 -0
- package/template/apps/web/workflow-entry.e2e.html +15 -0
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openxiangda-cli",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.173",
|
|
4
4
|
"description": "Thin application-level CLI for OpenXiangda 2.0.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@oclif/core": "4.13.3",
|
|
24
|
-
"openxiangda-contracts": "2.0.0-alpha.
|
|
25
|
-
"openxiangda-devkit-core": "2.0.0-alpha.
|
|
26
|
-
"openxiangda-mcp": "2.0.0-alpha.
|
|
27
|
-
"openxiangda-skill-kit": "2.0.0-alpha.
|
|
24
|
+
"openxiangda-contracts": "2.0.0-alpha.87",
|
|
25
|
+
"openxiangda-devkit-core": "2.0.0-alpha.111",
|
|
26
|
+
"openxiangda-mcp": "2.0.0-alpha.111",
|
|
27
|
+
"openxiangda-skill-kit": "2.0.0-alpha.136"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"tsx": "4.23.12",
|
|
@@ -75,6 +75,9 @@ test.describe('mobile managed files', () => {
|
|
|
75
75
|
await expect(page.locator('.adm-image-viewer-indicator')).toHaveText('2 / 2');
|
|
76
76
|
await expect(page.locator('.adm-image-viewer-image-wrapper img').last()).toHaveJSProperty('naturalWidth', 640);
|
|
77
77
|
await page.screenshot({ path: info.outputPath('mobile-image-preview.png') });
|
|
78
|
+
const imageDownload = page.waitForEvent('download');
|
|
79
|
+
await page.getByRole('button', { name: '下载', exact: true }).click();
|
|
80
|
+
expect((await imageDownload).suggestedFilename()).toBe('第二张.png');
|
|
78
81
|
await page.getByRole('button', { name: '关闭预览', exact: true }).click();
|
|
79
82
|
await expect(page.getByRole('button', { name: '关闭预览', exact: true })).toHaveCount(0);
|
|
80
83
|
let downloadAttempts = 0;
|
|
@@ -3,7 +3,7 @@ import { appCode, capabilities } from '../../../packages/contracts/src/generated
|
|
|
3
3
|
const runtimeBase = `/view/${appCode}`;
|
|
4
4
|
const allCapabilities = [...capabilities] as string[];
|
|
5
5
|
|
|
6
|
-
function runtimeAuthorization(authorized: boolean, superAdmin = false) {
|
|
6
|
+
export function runtimeAuthorization(authorized: boolean, superAdmin = false) {
|
|
7
7
|
return {
|
|
8
8
|
schemaVersion: 'openxiangda.runtime-authorization/v2',
|
|
9
9
|
state: 'active',
|
|
@@ -335,7 +335,10 @@ test('renders canonical semantic labels in the real generated resource list', as
|
|
|
335
335
|
const detail = page.getByRole('dialog');
|
|
336
336
|
await expect(detail).toBeVisible();
|
|
337
337
|
await expect(detail).toContainText('组织信息演示');
|
|
338
|
-
|
|
338
|
+
const frame = detail.locator('[data-detail-frame="standard"]');
|
|
339
|
+
await expect(frame).toBeVisible();
|
|
340
|
+
await expect(frame.locator('.oxa-record-detail-card')).toHaveCount(2);
|
|
341
|
+
await expect(frame.locator('.oxa-record-detail-card').getByRole('heading', { level: 2 })).toHaveText(['栏目设置', '管理信息']);
|
|
339
342
|
});
|
|
340
343
|
|
|
341
344
|
test('renders the canonical first field on the mobile generated list', async ({ page }) => {
|
|
@@ -148,7 +148,7 @@ test('named views share records while keeping columns, drawer routes and drafts
|
|
|
148
148
|
await expect(nextPage.getByLabel('栏目说明', { exact: true })).toHaveCount(0);
|
|
149
149
|
await nextPage.screenshot({ path: testInfo.outputPath('named-quick-new-page.png'), fullPage: true, animations: 'disabled' });
|
|
150
150
|
await nextPage.getByRole('button', { name: '提交', exact: true }).click();
|
|
151
|
-
await expect(nextPage).toHaveURL(`${base}/quick`);
|
|
151
|
+
await expect(nextPage).toHaveURL(`${base}/quick/record-22`);
|
|
152
152
|
expect(state.writes).toHaveLength(1);
|
|
153
153
|
expect(state.writes[0].resourceCode).toBe('resource-01');
|
|
154
154
|
expect(state.records.at(-1)?.name).toBe('简要登记的草稿');
|
|
@@ -174,7 +174,7 @@ test('mobile named forms retain their grouping and return to the same view after
|
|
|
174
174
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
|
175
175
|
await page.screenshot({ path: testInfo.outputPath('named-mobile-complete-form.png'), fullPage: true, animations: 'disabled' });
|
|
176
176
|
await form.getByRole('button', { name: '提交', exact: true }).click();
|
|
177
|
-
await expect(page).toHaveURL('/m/admin/resources/resource-01/views/complete');
|
|
177
|
+
await expect(page).toHaveURL('/m/admin/resources/resource-01/views/complete/record-22');
|
|
178
178
|
expect(state.writes).toHaveLength(1);
|
|
179
179
|
expect(state.drafts).toHaveLength(0);
|
|
180
180
|
});
|
|
@@ -285,7 +285,7 @@ test('drawer full screen preserves input, close discards edits and new page rest
|
|
|
285
285
|
expect(state.drafts).toHaveLength(1);
|
|
286
286
|
await nextPage.screenshot({ path: testInfo.outputPath('standard-new-page-draft.png'), fullPage: true, animations: 'disabled' });
|
|
287
287
|
await nextPage.getByRole('button', { name: '提交', exact: true }).click();
|
|
288
|
-
await expect(nextPage).toHaveURL('/admin/resources/resource-01');
|
|
288
|
+
await expect(nextPage).toHaveURL('/admin/resources/resource-01/record-22');
|
|
289
289
|
expect(state.writes).toHaveLength(1); expect(state.drafts).toHaveLength(0);
|
|
290
290
|
});
|
|
291
291
|
|
|
@@ -307,7 +307,7 @@ test('record reads can retry and reconnect refresh does not overwrite an edit or
|
|
|
307
307
|
await page.context().setOffline(false);
|
|
308
308
|
await expect.poll(() => state.reads).toBeGreaterThan(reads);
|
|
309
309
|
await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('我的修改');
|
|
310
|
-
await drawer.getByRole('button', { name: '
|
|
310
|
+
await drawer.getByRole('button', { name: '保存' }).click();
|
|
311
311
|
await expect(drawer.getByText('保存失败', { exact: true })).toBeVisible();
|
|
312
312
|
expect(state.writes.at(-1).expectedRevision).toBe(1);
|
|
313
313
|
await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('我的修改');
|
|
@@ -333,7 +333,7 @@ test('mobile grouped entry keeps one column and saves through the same Data API'
|
|
|
333
333
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
|
334
334
|
await page.screenshot({ path: testInfo.outputPath('standard-mobile-form.png'), animations: 'disabled' });
|
|
335
335
|
await form.getByRole('button', { name: '提交', exact: true }).click();
|
|
336
|
-
await expect(page).toHaveURL('/m/admin/resources/resource-01');
|
|
336
|
+
await expect(page).toHaveURL('/m/admin/resources/resource-01/record-22');
|
|
337
337
|
expect(state.writes).toHaveLength(1);
|
|
338
338
|
expect(state.records.at(-1)?.name).toBe('移动端新增记录');
|
|
339
339
|
expect(errors).toEqual([]);
|
|
@@ -430,7 +430,7 @@ test('mobile partial drafts restore from a bottom sheet and submit once', async
|
|
|
430
430
|
await page.screenshot({ path: testInfo.outputPath('mobile-draft-restore.png'), animations: 'disabled' });
|
|
431
431
|
await prompt.getByRole('button', { name: '载入草稿' }).click();
|
|
432
432
|
await expect(form.getByRole('textbox', { name: '栏目说明', exact: true })).toHaveValue('手机暂存内容');
|
|
433
|
-
await page.getByRole('button', { name:
|
|
433
|
+
await page.getByRole('button', { name: /^草稿箱/ }).click();
|
|
434
434
|
const draftBox = page.getByRole('dialog', { name: /草稿箱/ });
|
|
435
435
|
await expect(draftBox.locator('.oxa-draft-card')).toBeVisible();
|
|
436
436
|
await expect.poll(async () => { const box = await draftBox.boundingBox(); return box ? Math.abs(box.y + box.height - 844) : Infinity; }).toBeLessThan(2);
|
|
@@ -439,7 +439,7 @@ test('mobile partial drafts restore from a bottom sheet and submit once', async
|
|
|
439
439
|
await page.getByRole('button', { name: '关闭弹层' }).click();
|
|
440
440
|
await form.getByRole('textbox', { name: '栏目名称', exact: true }).fill('手机恢复后提交');
|
|
441
441
|
await form.getByRole('button', { name: '提交', exact: true }).click();
|
|
442
|
-
await expect(page).toHaveURL('/m/admin/resources/resource-01');
|
|
442
|
+
await expect(page).toHaveURL('/m/admin/resources/resource-01/record-22');
|
|
443
443
|
expect(state.writes).toHaveLength(1); expect(state.drafts).toHaveLength(0);
|
|
444
444
|
});
|
|
445
445
|
|
|
@@ -452,6 +452,11 @@ test('direct entry is independent and adapts desktop URLs to touch controls with
|
|
|
452
452
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
453
453
|
await expect(page.locator('.oxa-mobile-form')).toBeVisible();
|
|
454
454
|
await expect(page.locator('.oxa-mobile-entry')).toBeVisible();
|
|
455
|
+
await expect(page.locator('.oxa-mobile-entry')).toHaveCSS('padding-left', '0px');
|
|
456
|
+
const header = await page.locator('.oxa-mobile-header').boundingBox();
|
|
457
|
+
const entry = await page.locator('.oxa-mobile-entry').boundingBox();
|
|
458
|
+
expect(header?.x).toBe(entry?.x); expect(header?.width).toBe(entry?.width);
|
|
459
|
+
await expect(page.getByRole('button', { name: /^草稿箱/ })).toHaveCount(0);
|
|
455
460
|
await expect(page.getByRole('textbox', { name: /栏目名称/ })).toHaveValue('保留未暂存输入');
|
|
456
461
|
await expect(page.locator('.oxa-sider, .oxa-topbar')).toHaveCount(0);
|
|
457
462
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
@@ -459,18 +464,30 @@ test('direct entry is independent and adapts desktop URLs to touch controls with
|
|
|
459
464
|
await expect(page.getByRole('textbox', { name: /栏目名称/ })).toHaveValue('保留未暂存输入');
|
|
460
465
|
});
|
|
461
466
|
|
|
462
|
-
test('details open readonly in a drawer and closing unsaved entry has no confirmation or write', async ({ page }) => {
|
|
467
|
+
test('details open readonly in a drawer and closing unsaved entry has no confirmation or write', async ({ page }, testInfo) => {
|
|
463
468
|
const state = await editablePlatform(page);
|
|
464
469
|
await page.goto('/resource-experience.e2e.html?primary=business');
|
|
465
470
|
const initialUrl = page.url();
|
|
466
471
|
await page.getByRole('button', { name: '查看', exact: true }).first().click();
|
|
467
472
|
const detail = page.getByRole('dialog', { name: '内容栏目详情' });
|
|
468
473
|
await expect(detail).toBeVisible();
|
|
469
|
-
await expect(detail.locator('.oxa-
|
|
470
|
-
await expect(detail.
|
|
474
|
+
await expect(detail.locator('.oxa-record-detail-sections')).toBeVisible();
|
|
475
|
+
await expect(detail.getByRole('heading', { name: '业务记录 1', exact: true })).toBeVisible();
|
|
471
476
|
await expect(detail.getByRole('textbox')).toHaveCount(0);
|
|
472
477
|
await expect(detail.getByRole('button', { name: '提交', exact: true })).toHaveCount(0);
|
|
473
478
|
expect(page.url()).toBe(initialUrl);
|
|
479
|
+
await expect(detail).toHaveCSS('width', '850px');
|
|
480
|
+
await page.screenshot({ path: testInfo.outputPath('ordinary-detail-drawer-850.png'), animations: 'disabled' });
|
|
481
|
+
await detail.getByRole('button', { name: '编辑', exact: true }).first().click();
|
|
482
|
+
const edit = page.getByRole('dialog', { name: '编辑数据' });
|
|
483
|
+
await expect(page.getByRole('dialog')).toHaveCount(1);
|
|
484
|
+
await expect(edit.locator('.oxa-record-detail.is-editing')).toBeVisible();
|
|
485
|
+
await edit.getByLabel('栏目名称', { exact: true }).fill('普通原地编辑');
|
|
486
|
+
await page.screenshot({ path: testInfo.outputPath('ordinary-detail-inline-edit.png'), animations: 'disabled' });
|
|
487
|
+
await edit.getByRole('button', { name: '关闭', exact: true }).click();
|
|
488
|
+
await expect(detail).toBeVisible();
|
|
489
|
+
await expect(detail.getByRole('heading', { name: '业务记录 1', exact: true })).toBeVisible();
|
|
490
|
+
expect(page.url()).toBe(initialUrl);
|
|
474
491
|
await detail.getByRole('button', { name: '关闭', exact: true }).click();
|
|
475
492
|
await page.getByRole('button', { name: '新增', exact: true }).click();
|
|
476
493
|
const entry = page.getByRole('dialog', { name: '新增数据' });
|
|
@@ -481,3 +498,14 @@ test('details open readonly in a drawer and closing unsaved entry has no confirm
|
|
|
481
498
|
expect(state.writes).toHaveLength(0);
|
|
482
499
|
expect(state.draftWrites).toHaveLength(0);
|
|
483
500
|
});
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
test('edit uses current record without prompting for an existing saved draft', async ({ page }) => {
|
|
504
|
+
const state = await editablePlatform(page);
|
|
505
|
+
state.drafts.push({id:'edit-draft', mode:'update', recordId:'record-1', revision:1, recordRevision:1, values:{name:'旧草稿'}, updatedAt:'2026-09-05T07:06:00.000Z'});
|
|
506
|
+
await page.goto('/admin/resources/resource-01/record-1/edit');
|
|
507
|
+
await expect(page.getByRole('textbox', {name:/栏目名称/})).toHaveValue('业务记录 1');
|
|
508
|
+
await expect(page.getByRole('dialog', {name:'载入暂存数据'})).toHaveCount(0);
|
|
509
|
+
await page.getByRole('button', {name:'草稿箱', exact:true}).click();
|
|
510
|
+
await expect(page.getByRole('dialog', {name:/草稿箱/})).toContainText('旧草稿');
|
|
511
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Refine } from '@refinedev/core';
|
|
2
|
+
import { App as AntdApp } from 'antd';
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import ReactDOM from 'react-dom/client';
|
|
5
|
+
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
|
6
|
+
import {
|
|
7
|
+
createApplicationProvider, GeneratedResourcePage, OpenXiangdaResourceDefinitionsProvider,
|
|
8
|
+
OpenXiangdaUiProvider, OpenXiangdaWorkflowDefinitionsProvider, RuntimeBoundary, WorkflowSubmissionPage,
|
|
9
|
+
type GeneratedResourceDefinition,
|
|
10
|
+
} from 'openxiangda/react';
|
|
11
|
+
import 'openxiangda/react/styles.css';
|
|
12
|
+
import 'openxiangda/mobile/styles.css';
|
|
13
|
+
|
|
14
|
+
const code = 'purchase-orders';
|
|
15
|
+
const capabilityBase = `app:openxiangda-application:data:${code}`;
|
|
16
|
+
const capabilities = {
|
|
17
|
+
read: `${capabilityBase}:read`, create: `${capabilityBase}:create`,
|
|
18
|
+
update: `${capabilityBase}:update`, delete: `${capabilityBase}:delete`,
|
|
19
|
+
};
|
|
20
|
+
const fieldCapabilities = { readCapabilities: [capabilities.read], createCapabilities: [], updateCapabilities: [] };
|
|
21
|
+
const definition: GeneratedResourceDefinition = {
|
|
22
|
+
code, name: '采购申请', capabilities,
|
|
23
|
+
surface: {
|
|
24
|
+
mutationOwner: 'workflow',
|
|
25
|
+
generated: { list: true, detail: true, create: false, update: false, delete: false },
|
|
26
|
+
fields: {
|
|
27
|
+
title: { label: '申请名称', type: 'text.short', widget: 'text', requiredHint: true, list: true, ...fieldCapabilities },
|
|
28
|
+
amount: { label: '申请金额', type: 'number.decimal', widget: 'money', requiredHint: true, list: true, ...fieldCapabilities },
|
|
29
|
+
internalNote: { label: '隐藏字段', type: 'text.short', widget: 'text', ...fieldCapabilities },
|
|
30
|
+
systemValue: { label: '系统字段', type: 'text.short', widget: 'text', system: true, ...fieldCapabilities },
|
|
31
|
+
decision: { label: '审批结论', type: 'text.short', widget: 'readonly', ...fieldCapabilities },
|
|
32
|
+
attachments: { label: '审批附件', type: 'file', widget: 'attachment', ...fieldCapabilities },
|
|
33
|
+
},
|
|
34
|
+
list: { defaultPageSize: 20, fieldOrder: ['title', 'amount'] },
|
|
35
|
+
form: { layout: 'flat', fieldOrder: ['title', 'amount', 'systemValue', 'decision'] },
|
|
36
|
+
detail: { layout: 'flat', fieldOrder: ['title', 'amount', 'decision'] },
|
|
37
|
+
mobile: { enabled: true },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const childCode = 'purchase-lines';
|
|
41
|
+
const childCapability = `app:openxiangda-application:data:${childCode}`;
|
|
42
|
+
const childDefinition: GeneratedResourceDefinition = {
|
|
43
|
+
code: childCode, name: '采购明细',
|
|
44
|
+
capabilities: { read: `${childCapability}:read`, create: `${childCapability}:create`, update: `${childCapability}:update`, delete: `${childCapability}:delete` },
|
|
45
|
+
surface: {
|
|
46
|
+
mutationOwner: 'native', generated: { list: false, detail: false, create: false, update: false, delete: false },
|
|
47
|
+
fields: {
|
|
48
|
+
purchaseId: { label: '主记录', type: 'text.short', widget: 'text', ...fieldCapabilities },
|
|
49
|
+
sortOrder: { label: '顺序', type: 'number.integer', widget: 'number', ...fieldCapabilities },
|
|
50
|
+
name: { label: '品名', type: 'text.short', widget: 'text', requiredHint: true, ...fieldCapabilities },
|
|
51
|
+
quantity: { label: '数量', type: 'number.integer', widget: 'number', requiredHint: true, ...fieldCapabilities },
|
|
52
|
+
hiddenNote: { label: '隐藏明细字段', type: 'text.short', widget: 'text', hidden: true, ...fieldCapabilities },
|
|
53
|
+
detailNote: { label: '仅详情说明', type: 'text.short', widget: 'text', ...fieldCapabilities },
|
|
54
|
+
},
|
|
55
|
+
form: { layout: 'flat', fieldOrder: ['name', 'quantity', 'hiddenNote'] },
|
|
56
|
+
detail: { layout: 'flat', fieldOrder: ['name', 'quantity', 'detailNote', 'hiddenNote'] },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const definitions = { [code]: definition, [childCode]: childDefinition };
|
|
60
|
+
const workflowDefinitions = [{
|
|
61
|
+
code: 'purchase-approval', title: '采购审批', launch: { mode: 'standalone' },
|
|
62
|
+
subject: { resourceCode: code, factProjection: { amount: 'amount' }, summaryFields: ['amount'] },
|
|
63
|
+
processOperationCode: 'openxiangda.workflow.purchase-approval.submit',
|
|
64
|
+
}] as const;
|
|
65
|
+
const query = new URLSearchParams(window.location.search);
|
|
66
|
+
if (query.get('fields') === 'complex') {
|
|
67
|
+
definition.surface.fields.items = {
|
|
68
|
+
label: '采购明细', type: 'subtable', widget: 'subtable',
|
|
69
|
+
subtable: { resourceCode: childCode, foreignKey: 'purchaseId', orderField: 'sortOrder', maxRows: 20 },
|
|
70
|
+
...fieldCapabilities,
|
|
71
|
+
};
|
|
72
|
+
definition.surface.detail!.fieldOrder!.push('items');
|
|
73
|
+
}
|
|
74
|
+
const mode = query.get('mode') || 'list';
|
|
75
|
+
const base = mode === 'mobile-detail' ? '/m/admin/purchases' : '/admin/purchases';
|
|
76
|
+
const paths = { fallback: '/admin', list: base, detail: `${base}/:id` };
|
|
77
|
+
|
|
78
|
+
function Entry() {
|
|
79
|
+
const [completions, setCompletions] = useState<string[]>([]);
|
|
80
|
+
const [render, setRender] = useState(0);
|
|
81
|
+
const [dismissed, setDismissed] = useState(false);
|
|
82
|
+
const location = useLocation();
|
|
83
|
+
if (mode === 'list') return <GeneratedResourcePage resourceCode={code} paths={paths} />;
|
|
84
|
+
if (mode === 'mobile-detail' || mode === 'desktop-detail') return <Routes><Route path={paths.detail} element={<GeneratedResourcePage resourceCode={code} mode="detail" variant={mode === 'mobile-detail' ? 'mobile' : 'desktop'} paths={paths} />} /></Routes>;
|
|
85
|
+
return <>
|
|
86
|
+
<button onClick={() => setRender(value => value + 1)}>重新渲染父页面</button>
|
|
87
|
+
<output data-testid="parent-render">{render}</output>
|
|
88
|
+
<output data-testid="completions">{completions.join(',')}</output>
|
|
89
|
+
<output data-testid="route">{location.pathname}{location.search}</output>
|
|
90
|
+
{!dismissed && <WorkflowSubmissionPage workflowCode="purchase-approval" variant={mode === 'mobile' ? 'mobile' : 'desktop'}
|
|
91
|
+
onDismiss={() => setDismissed(true)} onCompleted={id => setCompletions(values => [...values, id])} />}
|
|
92
|
+
</>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
96
|
+
<React.StrictMode>
|
|
97
|
+
<OpenXiangdaUiProvider><AntdApp><MemoryRouter initialEntries={[(mode === 'mobile-detail' || mode === 'desktop-detail') ? `${paths.list}/record-1` : paths.list]}><RuntimeBoundary>
|
|
98
|
+
<OpenXiangdaResourceDefinitionsProvider definitions={definitions}>
|
|
99
|
+
<OpenXiangdaWorkflowDefinitionsProvider definitions={workflowDefinitions}>
|
|
100
|
+
<Refine dataProvider={createApplicationProvider(definitions)} resources={[{ name: code, list: paths.list, show: paths.detail }]}>
|
|
101
|
+
<Entry />
|
|
102
|
+
</Refine>
|
|
103
|
+
</OpenXiangdaWorkflowDefinitionsProvider>
|
|
104
|
+
</OpenXiangdaResourceDefinitionsProvider>
|
|
105
|
+
</RuntimeBoundary></MemoryRouter></AntdApp></OpenXiangdaUiProvider>
|
|
106
|
+
</React.StrictMode>,
|
|
107
|
+
);
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { expect, test, type Page } from '@playwright/test';
|
|
2
|
+
import { mockPlatform, runtimeAuthorization } from './resource-platform-mock';
|
|
3
|
+
|
|
4
|
+
const appCode = 'openxiangda-application';
|
|
5
|
+
const resourceCode = 'purchase-orders';
|
|
6
|
+
const commandId = '55555555-5555-4555-8555-555555555555';
|
|
7
|
+
const instanceId = '22222222-2222-4222-8222-222222222222';
|
|
8
|
+
const workflowBase = `/openxiangda-api/v2/applications/${appCode}/workflow`;
|
|
9
|
+
const processBase = `/openxiangda-api/v2/applications/${appCode}/business-process`;
|
|
10
|
+
const capability = `app:${appCode}:data:${resourceCode}:read`;
|
|
11
|
+
const fieldCapabilities = { readCapabilities: [capability], createCapabilities: [], updateCapabilities: [] };
|
|
12
|
+
const correctionFields = {
|
|
13
|
+
title: { label: '申请名称', type: 'text.short', widget: 'text', requiredHint: true, ...fieldCapabilities },
|
|
14
|
+
amount: { label: '申请金额', type: 'number.decimal', widget: 'money', requiredHint: true, ...fieldCapabilities },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function workflowEntryPlatform(page: Page) {
|
|
18
|
+
await mockPlatform(page, false, undefined, true);
|
|
19
|
+
const state = {
|
|
20
|
+
admin: true,
|
|
21
|
+
record: { id: 'record-1', revision: 8, title: '原业务申请', amount: 28600, decision: '已同意', internalNote: '不得显示', systemValue: '内部标识', attachments: [], richText: '<p>原说明</p>' },
|
|
22
|
+
complexFields: false, uploads: [] as any[], childQueries: [] as any[],
|
|
23
|
+
children: [
|
|
24
|
+
{ id: 'line-1', revision: 3, purchaseId: 'record-1', sortOrder: 0, name: '原明细甲', quantity: 2, hiddenNote: '不得显示的明细值', detailNote: '保留详情说明' },
|
|
25
|
+
{ id: 'line-2', revision: 4, purchaseId: 'record-1', sortOrder: 1, name: '原明细乙', quantity: 3, hiddenNote: '不得显示的明细值', detailNote: '第二项说明' },
|
|
26
|
+
],
|
|
27
|
+
correctionReads: 0, correctionWrites: [] as any[], correctionEffects: 0,
|
|
28
|
+
correctionReadFailure: false, wrongSurfaceScope: false, forbiddenEditable: false,
|
|
29
|
+
loseCorrectionResponse: false, loseLaunchResponse: false,
|
|
30
|
+
launches: [] as any[], launchEffects: 0, statusReads: 0, failStatusOnce: false,
|
|
31
|
+
processStatus: 'started' as 'accepted' | 'started' | 'awaiting_input',
|
|
32
|
+
answers: [] as any[], nativeWrites: [] as string[],
|
|
33
|
+
corrections: new Map<string, unknown>(), commands: new Map<string, unknown>(),
|
|
34
|
+
};
|
|
35
|
+
const file = { schemaVersion: 'openxiangda.data-file-ref/v2', id: 'file-1', name: '申请说明.txt', size: 6, contentType: 'text/plain' };
|
|
36
|
+
await page.route('**/fixture-file-upload', route => route.fulfill({ status: 200, body: '' }));
|
|
37
|
+
function command(status = state.processStatus) {
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 'openxiangda.business-process.command/v2', id: commandId, appCode,
|
|
40
|
+
environmentKey: 'preproduction', operationCode: 'openxiangda.workflow.purchase-approval.submit',
|
|
41
|
+
idempotencyKey: 'fixture-command-key', workflowCode: 'purchase-approval', status,
|
|
42
|
+
revision: status === 'started' ? 4 : 1,
|
|
43
|
+
subject: { resourceCode, id: state.record.id, dataRevision: state.record.revision, factDigest: 'a'.repeat(64) },
|
|
44
|
+
definitionVersion: 1, bindingVersion: 1, requirements: [], answers: {}, preview: {},
|
|
45
|
+
workflowInstanceId: status === 'started' ? instanceId : null, attemptCount: 1, lastError: null,
|
|
46
|
+
replayed: false, createdAt: '2026-09-06T01:00:00.000Z', updatedAt: '2026-09-06T01:00:01.000Z',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
await page.route('**/service/**', async route => {
|
|
50
|
+
const request = route.request();
|
|
51
|
+
const path = new URL(request.url()).pathname;
|
|
52
|
+
const ok = (data: unknown) => route.fulfill({ json: { code: 200, data } });
|
|
53
|
+
const fail = (status: number, message: string) => route.fulfill({ status, json: { code: status, message } });
|
|
54
|
+
if (path.endsWith('/native/authz/current')) {
|
|
55
|
+
const authorization = runtimeAuthorization(false, state.admin);
|
|
56
|
+
authorization.principal.capabilityCodes = [capability];
|
|
57
|
+
return ok(authorization);
|
|
58
|
+
}
|
|
59
|
+
if (path.endsWith('/auth/surface')) return ok({ csrfToken: 'workflow-entry-csrf' });
|
|
60
|
+
if (path.includes('/native/admin-list/preferences/')) return ok(null);
|
|
61
|
+
if (path.endsWith(`/workflow/records/${resourceCode}/${state.record.id}/detail`)) {
|
|
62
|
+
const childFields = { name: { label: '品名', type: 'text.short', widget: 'text', ...fieldCapabilities }, quantity: { label: '数量', type: 'number.integer', widget: 'number', ...fieldCapabilities }, detailNote: { label: '仅详情说明', type: 'text.short', widget: 'text', ...fieldCapabilities } };
|
|
63
|
+
const fields = { ...correctionFields, decision: { label: '审批结论', type: 'text.short', widget: 'readonly', ...fieldCapabilities }, ...(state.complexFields ? { items: { label: '采购明细', type: 'subtable', widget: 'subtable', subtable: { resourceCode: 'purchase-lines', foreignKey: 'purchaseId', orderField: 'sortOrder', maxRows: 20 }, ...fieldCapabilities } } : {}) };
|
|
64
|
+
const instance = { id: instanceId, appCode, environmentKey: 'preproduction', status: 'approved', outcome: 'approved', startedAt: '2026-09-06T01:00:00Z', completedAt: '2026-09-06T02:38:00Z', dataRef: { resourceCode, recordId: state.record.id } };
|
|
65
|
+
const surface = { schemaVersion: 'openxiangda.workflow-surface/v2', protocolVersion: 'workflow_surface_v2', engineVersion: '2.0', surfaceRevision: 'a'.repeat(64), instanceSequence: 4, instance, task: null, operations: [],
|
|
66
|
+
detailNavigation: { desktopPath: `/admin/purchases/${state.record.id}`, mobilePath: `/m/admin/purchases/${state.record.id}`, custom: false },
|
|
67
|
+
presentation: { summary: { title: '采购申请', initiatorDisplayName: '陈晨', departmentDisplayName: '办公室', submittedAt: instance.startedAt }, businessDetail: {
|
|
68
|
+
status: 'ready', resourceCode, resourceName: '采购申请', recordId: state.record.id, requestedRevision: state.record.revision, sourceRevision: state.record.revision,
|
|
69
|
+
surface: { mutationOwner: 'workflow', fields, detail: { layout: 'sections', fieldOrder: Object.keys(fields) } }, record: state.record,
|
|
70
|
+
subtables: state.complexFields ? { items: { resourceCode: 'purchase-lines', surface: { fields: childFields, detail: { fieldOrder: Object.keys(childFields) } }, rows: state.children, total: state.children.length } } : {}, projectionDigest: 'c'.repeat(64),
|
|
71
|
+
} },
|
|
72
|
+
};
|
|
73
|
+
const timeline = { engineVersion: '2.0', instanceId, instanceSequence: 4, timelineRevision: 'b'.repeat(64), flow: { nodes: [], edges: [] }, items: [], display: { entries: [] } };
|
|
74
|
+
return ok({ schemaVersion: 'openxiangda.workflow-detail-surface/v2', protocolVersion: 'workflow_detail_surface_v2', detailRevision: 'd'.repeat(64), instanceSequence: 4, timelineRevision: timeline.timelineRevision, surface, timeline,
|
|
75
|
+
currentStatus: { code: 'approved', label: '已同意', tone: 'success' }, nodes: [], handlingRecords: [], operations: [], navigationContext: { desktopReturnPath: '/admin/purchases', mobileReturnPath: '/m/admin/purchases' } });
|
|
76
|
+
}
|
|
77
|
+
if (path.endsWith(`/workflow/instances/${instanceId}/data-audit`)) return ok({ schemaVersion: 'openxiangda.data-audit-page/v2', items: [], total: 0, offset: 0, limit: 50 });
|
|
78
|
+
if (path.endsWith('/native/data/purchase-lines/query')) {
|
|
79
|
+
state.childQueries.push(request.postDataJSON());
|
|
80
|
+
return ok({ schemaVersion: 'openxiangda.data-page/v2', resourceCode: 'purchase-lines', items: state.children, total: state.children.length, offset: 0, limit: 20 });
|
|
81
|
+
}
|
|
82
|
+
if (path.endsWith(`/native/data/${resourceCode}/query`)) {
|
|
83
|
+
return ok({ schemaVersion: 'openxiangda.data-page/v2', resourceCode, items: [state.record], total: 1, offset: 0, limit: 20 });
|
|
84
|
+
}
|
|
85
|
+
if (path.includes(`/native/data/${resourceCode}/records/`) && request.method() === 'GET') {
|
|
86
|
+
return ok({ schemaVersion: 'openxiangda.data-record/v2', data: state.record });
|
|
87
|
+
}
|
|
88
|
+
if (path.endsWith(`/native/data/${resourceCode}/files/uploads/initiate`)) {
|
|
89
|
+
state.uploads.push(request.postDataJSON());
|
|
90
|
+
return ok({ schemaVersion: 'openxiangda.data-file-upload-plan/v2', resourceCode, fieldCode: 'attachments', file, uploadMethod: 'PUT', uploadUrl: new URL('/fixture-file-upload', request.url()).href, headers: {}, expiresAt: '2099-01-01T00:00:00.000Z' });
|
|
91
|
+
}
|
|
92
|
+
if (path.endsWith(`/native/data/${resourceCode}/files/file-1/complete`)) return ok(file);
|
|
93
|
+
if (path.includes(`/native/data/${resourceCode}/`) && request.method() !== 'GET') {
|
|
94
|
+
state.nativeWrites.push(path);
|
|
95
|
+
return fail(403, '流程记录不允许普通 CRUD 写入');
|
|
96
|
+
}
|
|
97
|
+
if (path.endsWith('/correction-surface')) {
|
|
98
|
+
state.correctionReads += 1;
|
|
99
|
+
if (!state.admin) return fail(403, '当前用户无权编辑数据');
|
|
100
|
+
if (state.correctionReadFailure) return fail(500, '暂时无法读取编辑表单');
|
|
101
|
+
const fields = state.complexFields ? {
|
|
102
|
+
...correctionFields,
|
|
103
|
+
attachments: { label: '申请附件', type: 'file', widget: 'attachment', maxCount: 3, ...fieldCapabilities },
|
|
104
|
+
richText: { label: '申请说明', type: 'text.rich', widget: 'rich-text', ...fieldCapabilities },
|
|
105
|
+
items: { label: '采购明细', type: 'subtable', widget: 'subtable', subtable: { resourceCode: 'purchase-lines', foreignKey: 'purchaseId', orderField: 'sortOrder', maxRows: 20 }, ...fieldCapabilities },
|
|
106
|
+
} : correctionFields;
|
|
107
|
+
return ok({
|
|
108
|
+
schemaVersion: 'openxiangda.workflow-record-correction-surface/v2', appCode, resourceCode,
|
|
109
|
+
recordId: state.record.id, environmentKey: state.wrongSurfaceScope ? 'production' : 'preproduction',
|
|
110
|
+
record: state.record, expectedRevision: state.record.revision, preservesApprovalResult: true,
|
|
111
|
+
editableFields: state.forbiddenEditable ? ['title', 'amount', 'internalNote'] : Object.keys(fields),
|
|
112
|
+
surface: {
|
|
113
|
+
mutationOwner: 'workflow', fields,
|
|
114
|
+
form: { layout: 'flat', fieldOrder: Object.keys(fields) },
|
|
115
|
+
},
|
|
116
|
+
command: { method: 'POST', href: `${workflowBase}/records/${resourceCode}/${state.record.id}/corrections` },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (path.endsWith('/corrections')) {
|
|
120
|
+
const input = request.postDataJSON();
|
|
121
|
+
state.correctionWrites.push(input);
|
|
122
|
+
expect(request.headers()['x-openxiangda-csrf-token']).toBe('workflow-entry-csrf');
|
|
123
|
+
if (!state.admin) return fail(403, '当前用户无权编辑数据');
|
|
124
|
+
const stored = state.corrections.get(input.idempotencyKey);
|
|
125
|
+
if (stored) return ok(stored);
|
|
126
|
+
if (input.operations[0].expectedRevision !== state.record.revision) return fail(409, '数据已发生变化,请关闭后重新核对');
|
|
127
|
+
for (const operation of input.operations.slice(1)) {
|
|
128
|
+
expect(Object.keys(operation.data || {}).every(key => ['name', 'quantity', 'purchaseId', 'sortOrder'].includes(key))).toBe(true);
|
|
129
|
+
const child = state.children.find(row => row.id === operation.id);
|
|
130
|
+
if (operation.operation !== 'create' && (!child || child.revision !== operation.expectedRevision)) return fail(409, '明细已发生变化,请重新核对');
|
|
131
|
+
}
|
|
132
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
133
|
+
Object.assign(state.record, input.operations[0].data, { revision: state.record.revision + 1 });
|
|
134
|
+
for (const operation of input.operations.slice(1)) {
|
|
135
|
+
if (operation.operation === 'create') state.children.push({ id: `line-${state.children.length + 3}`, revision: 1, ...operation.data });
|
|
136
|
+
else if (operation.operation === 'delete') state.children = state.children.filter(row => row.id !== operation.id);
|
|
137
|
+
else {
|
|
138
|
+
const child = state.children.find(row => row.id === operation.id)!;
|
|
139
|
+
Object.assign(child, operation.data, { revision: child.revision + 1 });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
state.correctionEffects += 1;
|
|
143
|
+
const result = {
|
|
144
|
+
schemaVersion: 'openxiangda.workflow-record-correction-result/v2', preservesApprovalResult: true,
|
|
145
|
+
transaction: { replayed: false, items: [{ id: state.record.id, revision: state.record.revision }] },
|
|
146
|
+
};
|
|
147
|
+
state.corrections.set(input.idempotencyKey, result);
|
|
148
|
+
if (state.loseCorrectionResponse) { state.loseCorrectionResponse = false; return fail(500, '响应暂时不可用,请重试'); }
|
|
149
|
+
return ok(result);
|
|
150
|
+
}
|
|
151
|
+
if (path.endsWith('/workflow/definitions/purchase-approval/launch-surface')) {
|
|
152
|
+
const commit = { method: 'POST', href: `${processBase}/standard-commands`, idempotencyRequired: true };
|
|
153
|
+
return ok({
|
|
154
|
+
schemaVersion: 'openxiangda.workflow-launch-surface/v3', protocolVersion: 'workflow_launch_surface_v3',
|
|
155
|
+
surfaceRevision: 'launch-revision', engineVersion: '2.0', appCode, environmentKey: 'preproduction',
|
|
156
|
+
environmentId: 'preproduction-id', workflowCode: 'purchase-approval', title: '采购审批', launchMode: 'standalone',
|
|
157
|
+
subject: { resourceCode, factProjection: { amount: 'amount' }, summaryFields: ['amount'] },
|
|
158
|
+
inputSchema: { type: 'object', properties: {} },
|
|
159
|
+
head: { workflowRevision: 4, definitionVersion: 1, bindingVersion: 1, nativeRevision: 3, contractRevisionId: 'contract-1' },
|
|
160
|
+
paths: { desktop: '/workflows/purchase-approval/start', mobile: '/m/workflows/purchase-approval/start' },
|
|
161
|
+
submission: { kind: 'standard-process', processOperationCode: 'openxiangda.workflow.purchase-approval.submit', commit },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (path.endsWith('/business-process/standard-commands')) {
|
|
165
|
+
const input = request.postDataJSON();
|
|
166
|
+
state.launches.push(input);
|
|
167
|
+
const stored = state.commands.get(input.idempotencyKey);
|
|
168
|
+
if (stored) return ok(stored);
|
|
169
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
170
|
+
Object.assign(state.record, input.mutation.data, { id: 'record-created', revision: 1, decision: '审批中' });
|
|
171
|
+
state.launchEffects += 1;
|
|
172
|
+
const result = command('accepted');
|
|
173
|
+
state.commands.set(input.idempotencyKey, result);
|
|
174
|
+
if (state.loseLaunchResponse) { state.loseLaunchResponse = false; return fail(500, '响应暂时不可用,请重试'); }
|
|
175
|
+
return ok(result);
|
|
176
|
+
}
|
|
177
|
+
if (path.endsWith(`/business-process/commands/${commandId}/surface`)) {
|
|
178
|
+
state.statusReads += 1;
|
|
179
|
+
if (state.failStatusOnce) { state.failStatusOnce = false; return fail(500, '状态暂时不可用'); }
|
|
180
|
+
return ok({
|
|
181
|
+
schemaVersion: 'openxiangda.process-command-surface/v2', surfaceRevision: 'b'.repeat(64), command: command(),
|
|
182
|
+
subject: { resourceCode, recordId: state.record.id, dataRevision: state.record.revision, form: { kind: 'native-resource-form', resourceCode, recordId: state.record.id } },
|
|
183
|
+
requirements: state.processStatus === 'awaiting_input' ? [{ id: 'department', kind: 'choose_department', title: '申请部门', required: true, candidates: [{ value: 'college', label: '理学院' }] }] : [],
|
|
184
|
+
resume: {
|
|
185
|
+
status: { method: 'GET', href: `${processBase}/commands/${commandId}` },
|
|
186
|
+
surface: { method: 'GET', href: `${processBase}/commands/${commandId}/surface` },
|
|
187
|
+
answer: state.processStatus === 'awaiting_input' ? { method: 'POST', href: `${processBase}/commands/${commandId}/answers`, expectedRevision: 1 } : null,
|
|
188
|
+
retry: null,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (path.endsWith(`/business-process/commands/${commandId}/answers`)) {
|
|
193
|
+
state.answers.push(request.postDataJSON());
|
|
194
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
195
|
+
state.processStatus = 'started';
|
|
196
|
+
return ok(command());
|
|
197
|
+
}
|
|
198
|
+
return route.fallback();
|
|
199
|
+
});
|
|
200
|
+
return state;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function openCorrection(page: Page, search = '') {
|
|
204
|
+
await page.goto(`/workflow-entry.e2e.html${search}`);
|
|
205
|
+
await page.getByRole('button', { name: '编辑', exact: true }).first().click();
|
|
206
|
+
const drawer = page.getByRole('dialog', { name: '编辑数据' });
|
|
207
|
+
await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('原业务申请');
|
|
208
|
+
await expect(drawer.getByLabel('申请金额', { exact: true })).toHaveValue('28600');
|
|
209
|
+
return drawer;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
test('ordinary workflow editing uses the shared form and opens readonly detail after one guarded save', async ({ page }, testInfo) => {
|
|
213
|
+
const state = await workflowEntryPlatform(page);
|
|
214
|
+
const drawer = await openCorrection(page);
|
|
215
|
+
await expect(drawer.locator('.ant-alert')).toHaveCount(0);
|
|
216
|
+
await expect(drawer.getByText(/更正|审计|重新审批/)).toHaveCount(0);
|
|
217
|
+
for (const label of ['隐藏字段', '系统字段', '审批结论', '审批附件']) await expect(drawer.getByLabel(label, { exact: true })).toHaveCount(0);
|
|
218
|
+
await drawer.getByLabel('申请名称', { exact: true }).fill('已更正的业务申请');
|
|
219
|
+
await drawer.getByRole('button', { name: '保存' }).dblclick();
|
|
220
|
+
await expect(drawer.getByRole('button', { name: '关闭', exact: true })).toBeDisabled();
|
|
221
|
+
await page.keyboard.press('Escape');
|
|
222
|
+
await expect(drawer).toBeVisible();
|
|
223
|
+
const detail = page.getByRole('dialog', { name: '申请详情' });
|
|
224
|
+
await expect(detail.getByRole('heading', { name: '已更正的业务申请', exact: true })).toBeVisible();
|
|
225
|
+
await expect(detail.locator('input,textarea')).toHaveCount(0);
|
|
226
|
+
await expect(detail.locator('.oxa-record-detail-hero').getByText('已同意', { exact: true })).toBeVisible();
|
|
227
|
+
expect(state.correctionWrites).toHaveLength(1);
|
|
228
|
+
expect(state.correctionWrites[0].operations[0]).toMatchObject({ expectedRevision: 8, data: { title: '已更正的业务申请', amount: 28600 } });
|
|
229
|
+
expect(Object.keys(state.correctionWrites[0].operations[0].data).sort()).toEqual(['amount', 'title']);
|
|
230
|
+
expect(state.correctionEffects).toBe(1);
|
|
231
|
+
expect(state.nativeWrites).toEqual([]);
|
|
232
|
+
expect(state.launches).toEqual([]);
|
|
233
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-correction-readonly-detail.png'), animations: 'disabled' });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('an uncertain workflow edit preserves input and replays the original revision and idempotency key', async ({ page }) => {
|
|
237
|
+
const state = await workflowEntryPlatform(page);
|
|
238
|
+
state.loseCorrectionResponse = true;
|
|
239
|
+
const drawer = await openCorrection(page);
|
|
240
|
+
await drawer.getByLabel('申请名称', { exact: true }).fill('网络重试后保留的申请');
|
|
241
|
+
await drawer.getByRole('button', { name: '保存' }).click();
|
|
242
|
+
await expect(drawer.getByText('响应暂时不可用,请重试')).toBeVisible();
|
|
243
|
+
await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('网络重试后保留的申请');
|
|
244
|
+
await drawer.getByRole('button', { name: '保存' }).click();
|
|
245
|
+
await expect(page.getByRole('dialog', { name: '申请详情' }).getByRole('heading', { name: '网络重试后保留的申请', exact: true })).toBeVisible();
|
|
246
|
+
expect(state.correctionWrites).toHaveLength(2);
|
|
247
|
+
expect(state.correctionWrites[1]).toEqual(state.correctionWrites[0]);
|
|
248
|
+
expect(state.correctionEffects).toBe(1);
|
|
249
|
+
expect(state.launches).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('CAS conflicts retain the original form and revision without a hidden reload or reapproval', async ({ page }) => {
|
|
253
|
+
const state = await workflowEntryPlatform(page);
|
|
254
|
+
const drawer = await openCorrection(page);
|
|
255
|
+
const initialReads = state.correctionReads;
|
|
256
|
+
state.record.revision = 9;
|
|
257
|
+
await drawer.getByLabel('申请名称', { exact: true }).fill('尚未覆盖并发修改');
|
|
258
|
+
await drawer.getByRole('button', { name: '保存' }).click();
|
|
259
|
+
await expect(drawer.getByText('数据已发生变化,请关闭后重新核对')).toBeVisible();
|
|
260
|
+
await drawer.getByRole('button', { name: '保存' }).click();
|
|
261
|
+
await expect.poll(() => state.correctionWrites.length).toBe(2);
|
|
262
|
+
await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('尚未覆盖并发修改');
|
|
263
|
+
expect(state.correctionWrites[1]).toEqual(state.correctionWrites[0]);
|
|
264
|
+
expect(state.correctionWrites[1].operations[0].expectedRevision).toBe(8);
|
|
265
|
+
expect(state.correctionReads).toBe(initialReads);
|
|
266
|
+
expect(state.correctionEffects).toBe(0);
|
|
267
|
+
expect(state.launches).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('revoked admin authority fails saving and hides editing when identity is reloaded', async ({ page }) => {
|
|
271
|
+
const state = await workflowEntryPlatform(page);
|
|
272
|
+
const drawer = await openCorrection(page);
|
|
273
|
+
state.admin = false;
|
|
274
|
+
await drawer.getByLabel('申请名称', { exact: true }).fill('撤权后不能保存');
|
|
275
|
+
await drawer.getByRole('button', { name: '保存' }).click();
|
|
276
|
+
await expect(drawer.getByText('当前用户无权编辑数据')).toBeVisible();
|
|
277
|
+
await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('撤权后不能保存');
|
|
278
|
+
await page.reload();
|
|
279
|
+
await expect(page.getByRole('button', { name: '查看', exact: true })).toBeVisible();
|
|
280
|
+
await expect(page.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0);
|
|
281
|
+
expect(state.correctionEffects).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('record edit load retries and rejects a Surface for another environment or a hidden editable field', async ({ page }) => {
|
|
285
|
+
const state = await workflowEntryPlatform(page);
|
|
286
|
+
state.correctionReadFailure = true;
|
|
287
|
+
await page.goto('/workflow-entry.e2e.html');
|
|
288
|
+
await page.getByRole('button', { name: '编辑', exact: true }).first().click();
|
|
289
|
+
const drawer = page.getByRole('dialog', { name: '编辑数据' });
|
|
290
|
+
await expect(drawer.getByText('暂时无法读取编辑表单')).toBeVisible();
|
|
291
|
+
state.correctionReadFailure = false;
|
|
292
|
+
state.wrongSurfaceScope = true;
|
|
293
|
+
await drawer.getByRole('button', { name: '重试', exact: true }).click();
|
|
294
|
+
await expect(drawer.getByText('OPENXIANGDA_WORKFLOW_CORRECTION_SURFACE_SCOPE_INVALID')).toBeVisible();
|
|
295
|
+
state.wrongSurfaceScope = false;
|
|
296
|
+
state.forbiddenEditable = true;
|
|
297
|
+
await drawer.getByRole('button', { name: '重试', exact: true }).click();
|
|
298
|
+
await expect(drawer.getByText('OPENXIANGDA_WORKFLOW_CORRECTION_FIELDS_INVALID')).toBeVisible();
|
|
299
|
+
await expect(drawer.locator('input,textarea')).toHaveCount(0);
|
|
300
|
+
state.forbiddenEditable = false;
|
|
301
|
+
await drawer.getByRole('button', { name: '重试', exact: true }).click();
|
|
302
|
+
await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('原业务申请');
|
|
303
|
+
expect(state.correctionWrites).toEqual([]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('workflow launch retries one command, blocks close while processing and opens readonly record detail', async ({ page }, testInfo) => {
|
|
307
|
+
const state = await workflowEntryPlatform(page);
|
|
308
|
+
state.admin = false;
|
|
309
|
+
state.loseLaunchResponse = true;
|
|
310
|
+
state.processStatus = 'accepted';
|
|
311
|
+
await page.goto('/workflow-entry.e2e.html');
|
|
312
|
+
await page.getByRole('button', { name: '新增流程申请' }).click();
|
|
313
|
+
const drawer = page.getByRole('dialog', { name: '采购审批' });
|
|
314
|
+
await expect(drawer.getByRole('link', { name: '新开页面' })).toHaveAttribute('href', '/workflows/purchase-approval/start');
|
|
315
|
+
await drawer.getByLabel('申请名称', { exact: true }).fill('抽屉新增的申请');
|
|
316
|
+
await drawer.getByLabel('申请金额', { exact: true }).fill('1200');
|
|
317
|
+
for (const label of ['隐藏字段', '系统字段', '审批结论', '审批附件']) await expect(drawer.getByLabel(label, { exact: true })).toHaveCount(0);
|
|
318
|
+
await drawer.getByRole('button', { name: '提交审批' }).dblclick();
|
|
319
|
+
await expect(drawer.getByRole('button', { name: '关闭', exact: true })).toBeDisabled();
|
|
320
|
+
await expect(page.getByText('响应暂时不可用,请重试')).toBeVisible();
|
|
321
|
+
await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('抽屉新增的申请');
|
|
322
|
+
await drawer.getByRole('button', { name: '提交审批' }).click();
|
|
323
|
+
await expect.poll(() => state.statusReads).toBeGreaterThan(0);
|
|
324
|
+
await expect(drawer.getByRole('link', { name: '新开页面' })).toHaveAttribute('href', `/workflows/purchase-approval/start?processCommandId=${commandId}`);
|
|
325
|
+
await expect(drawer.getByRole('button', { name: '关闭', exact: true })).toBeDisabled();
|
|
326
|
+
await page.keyboard.press('Escape');
|
|
327
|
+
await expect(drawer).toBeVisible();
|
|
328
|
+
state.processStatus = 'started';
|
|
329
|
+
const detail = page.getByRole('dialog', { name: '申请详情' });
|
|
330
|
+
await expect(detail.getByRole('heading', { name: '抽屉新增的申请', exact: true })).toBeVisible();
|
|
331
|
+
await expect(detail.locator('input,textarea')).toHaveCount(0);
|
|
332
|
+
expect(state.launches).toHaveLength(2);
|
|
333
|
+
expect(state.launches[1]).toEqual(state.launches[0]);
|
|
334
|
+
expect(state.launchEffects).toBe(1);
|
|
335
|
+
expect(state.nativeWrites).toEqual([]);
|
|
336
|
+
expect(state.correctionWrites).toEqual([]);
|
|
337
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-launch-readonly-detail.png'), animations: 'disabled' });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('mobile launch completes once across parent renders and does not change the parent route', async ({ page }, testInfo) => {
|
|
341
|
+
const state = await workflowEntryPlatform(page);
|
|
342
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
343
|
+
await page.goto('/workflow-entry.e2e.html?mode=mobile');
|
|
344
|
+
const drawer = page.getByRole('dialog', { name: '采购审批' });
|
|
345
|
+
await drawer.getByRole('textbox', { name: '申请名称', exact: true }).fill('移动流程申请');
|
|
346
|
+
await drawer.getByRole('textbox', { name: '申请金额', exact: true }).fill('320');
|
|
347
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-launch-mobile.png'), animations: 'disabled' });
|
|
348
|
+
await drawer.getByRole('button', { name: '提交审批' }).click();
|
|
349
|
+
await expect(page.getByTestId('completions')).toHaveText('record-created');
|
|
350
|
+
await drawer.getByRole('button', { name: '关闭', exact: true }).click();
|
|
351
|
+
await page.getByRole('button', { name: '重新渲染父页面' }).click();
|
|
352
|
+
await expect(page.getByTestId('parent-render')).toHaveText('1');
|
|
353
|
+
await expect(page.getByTestId('completions')).toHaveText('record-created');
|
|
354
|
+
await expect(page.getByTestId('route')).toHaveText('/admin/purchases');
|
|
355
|
+
expect(state.launchEffects).toBe(1);
|
|
356
|
+
expect(state.nativeWrites).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('a failed command status read refreshes in the same drawer without another submission', async ({ page }) => {
|
|
360
|
+
const state = await workflowEntryPlatform(page);
|
|
361
|
+
state.failStatusOnce = true;
|
|
362
|
+
await page.goto('/workflow-entry.e2e.html?mode=callback');
|
|
363
|
+
const drawer = page.getByRole('dialog', { name: '采购审批' });
|
|
364
|
+
await drawer.getByLabel('申请名称', { exact: true }).fill('状态重试申请');
|
|
365
|
+
await drawer.getByLabel('申请金额', { exact: true }).fill('200');
|
|
366
|
+
await drawer.getByRole('button', { name: '提交审批' }).click();
|
|
367
|
+
await expect(drawer.getByText('暂时无法确认申请状态')).toBeVisible();
|
|
368
|
+
await drawer.getByRole('button', { name: '刷新提交状态' }).click();
|
|
369
|
+
await expect(page.getByTestId('completions')).toHaveText('record-created');
|
|
370
|
+
// A parent state update supplies a new callback while the drawer remains mounted.
|
|
371
|
+
await page.getByRole('button', { name: '重新渲染父页面' }).evaluate(element => element.click());
|
|
372
|
+
await expect(page.getByTestId('parent-render')).toHaveText('1');
|
|
373
|
+
await expect(page.getByTestId('completions')).toHaveText('record-created');
|
|
374
|
+
expect(state.launches).toHaveLength(1);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('supplementary answers are guarded and resume completion without resubmitting approval', async ({ page }) => {
|
|
378
|
+
const state = await workflowEntryPlatform(page);
|
|
379
|
+
state.processStatus = 'awaiting_input';
|
|
380
|
+
await page.goto('/workflow-entry.e2e.html?mode=callback');
|
|
381
|
+
const drawer = page.getByRole('dialog', { name: '采购审批' });
|
|
382
|
+
await drawer.getByLabel('申请名称', { exact: true }).fill('需要补充部门');
|
|
383
|
+
await drawer.getByLabel('申请金额', { exact: true }).fill('800');
|
|
384
|
+
await drawer.getByRole('button', { name: '提交审批' }).click();
|
|
385
|
+
await drawer.getByRole('combobox', { name: '申请部门' }).click();
|
|
386
|
+
await page.locator('.ant-select-dropdown').getByText('理学院', { exact: true }).click();
|
|
387
|
+
await drawer.getByRole('button', { name: '提交补充信息' }).dblclick();
|
|
388
|
+
await expect(page.getByTestId('completions')).toHaveText('record-created');
|
|
389
|
+
expect(state.answers).toHaveLength(1);
|
|
390
|
+
expect(state.answers[0]).toMatchObject({ expectedRevision: 1, answers: { department: 'college' } });
|
|
391
|
+
expect(state.launches).toHaveLength(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('detail switches to the ordinary full-width editor and back with only one dialog', async ({ page }, testInfo) => {
|
|
395
|
+
const state = await workflowEntryPlatform(page);
|
|
396
|
+
await page.goto('/workflow-entry.e2e.html');
|
|
397
|
+
await page.getByRole('button', { name: '查看', exact: true }).click();
|
|
398
|
+
const detail = page.getByRole('dialog', { name: '申请详情' });
|
|
399
|
+
await expect(detail.getByRole('heading', { name: '原业务申请', exact: true })).toBeVisible();
|
|
400
|
+
await expect(detail).toHaveCSS('width', '850px');
|
|
401
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-detail-drawer-850.png'), animations: 'disabled' });
|
|
402
|
+
await detail.getByRole('button', { name: '全屏', exact: true }).click();
|
|
403
|
+
await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(page.viewportSize()!.width);
|
|
404
|
+
await detail.getByRole('button', { name: '编辑', exact: true }).click();
|
|
405
|
+
const editor = page.getByRole('dialog', { name: '编辑数据' });
|
|
406
|
+
await expect(editor.getByLabel('申请名称', { exact: true })).toHaveValue('原业务申请');
|
|
407
|
+
await expect(page.getByRole('dialog')).toHaveCount(1);
|
|
408
|
+
await expect(page.getByRole('dialog', { name: /编辑申请|更正/ })).toHaveCount(0);
|
|
409
|
+
await expect(editor.getByRole('button', { name: '退出全屏' })).toBeVisible();
|
|
410
|
+
await expect.poll(async () => Math.round((await editor.boundingBox())!.width)).toBe(page.viewportSize()!.width);
|
|
411
|
+
await editor.getByLabel('申请名称', { exact: true }).fill('直接编辑的数据');
|
|
412
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-ordinary-editor-fullscreen.png'), animations: 'disabled' });
|
|
413
|
+
await editor.getByRole('button', { name: '保存', exact: true }).click();
|
|
414
|
+
await expect(detail.getByRole('heading', { name: '直接编辑的数据', exact: true })).toBeVisible();
|
|
415
|
+
await expect(page.getByRole('dialog')).toHaveCount(1);
|
|
416
|
+
await expect(detail.getByRole('button', { name: '退出全屏' })).toBeVisible();
|
|
417
|
+
expect(state.correctionWrites[0].operations[0].id).toBe('record-1');
|
|
418
|
+
expect(state.launches).toEqual([]);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('standalone record detail keeps the approved content width and cancels editing in place', async ({ page }, testInfo) => {
|
|
422
|
+
const state = await workflowEntryPlatform(page);
|
|
423
|
+
await page.setViewportSize({ width: 1440, height: 900 });
|
|
424
|
+
await page.goto('/workflow-entry.e2e.html?mode=desktop-detail');
|
|
425
|
+
const heading = page.getByRole('heading', { name: '原业务申请', exact: true });
|
|
426
|
+
await expect(heading).toBeVisible();
|
|
427
|
+
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
428
|
+
await expect(page.locator('.oxa-record-detail-body > .oxa-record-detail-width')).toHaveCSS('width', '1056px');
|
|
429
|
+
await expect(page.locator('.oxa-record-detail-hero h1')).toHaveCSS('font-size', '23px');
|
|
430
|
+
await expect(page.getByRole('tab')).toHaveCount(3);
|
|
431
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-detail-standalone-1056.png'), animations: 'disabled' });
|
|
432
|
+
await page.getByRole('button', { name: '编辑', exact: true }).first().click();
|
|
433
|
+
await page.getByLabel('申请名称', { exact: true }).fill('取消的更改');
|
|
434
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-detail-standalone-inline-edit.png'), animations: 'disabled' });
|
|
435
|
+
await page.getByRole('button', { name: /取\s*消/ }).click();
|
|
436
|
+
await expect(heading).toBeVisible();
|
|
437
|
+
expect(state.correctionWrites).toHaveLength(0);
|
|
438
|
+
expect(state.launches).toHaveLength(0);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('mobile detail edits on the current page and returns to the same readonly record', async ({ page }, testInfo) => {
|
|
442
|
+
const state = await workflowEntryPlatform(page);
|
|
443
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
444
|
+
await page.goto('/workflow-entry.e2e.html?mode=mobile-detail');
|
|
445
|
+
await expect(page.getByRole('heading', { name: '原业务申请', exact: true })).toBeVisible();
|
|
446
|
+
await expect(page.locator('.oxa-record-detail-hero h1')).toHaveCSS('font-size', '20px');
|
|
447
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-detail-mobile-390.png'), animations: 'disabled' });
|
|
448
|
+
await page.getByRole('button', { name: '编辑', exact: true }).first().click();
|
|
449
|
+
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
450
|
+
const form = page.locator('.oxa-mobile-form');
|
|
451
|
+
await expect(form.getByRole('textbox', { name: '申请名称', exact: true })).toHaveValue('原业务申请');
|
|
452
|
+
await form.getByRole('textbox', { name: '申请名称', exact: true }).fill('移动页面直接修改');
|
|
453
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
|
454
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-ordinary-editor-mobile.png'), animations: 'disabled' });
|
|
455
|
+
await form.getByRole('button', { name: '保存', exact: true }).click();
|
|
456
|
+
await expect(page.getByRole('heading', { name: '移动页面直接修改', exact: true })).toBeVisible();
|
|
457
|
+
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
458
|
+
await expect(page.locator('.oxa-readonly-form input')).toHaveCount(0);
|
|
459
|
+
expect(state.record.id).toBe('record-1');
|
|
460
|
+
expect(state.launches).toEqual([]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('ordinary workflow editing reuses Native uploads, rich text and parent-child transaction plans', async ({ page }, testInfo) => {
|
|
464
|
+
const state = await workflowEntryPlatform(page);
|
|
465
|
+
state.complexFields = true;
|
|
466
|
+
const editor = await openCorrection(page, '?fields=complex');
|
|
467
|
+
const subtable = editor.locator('.oxa-subtable-field');
|
|
468
|
+
await expect(subtable.getByLabel('品名', { exact: true }).first()).toHaveValue('原明细甲');
|
|
469
|
+
await expect(subtable.getByText('仅详情说明', { exact: true })).toHaveCount(0);
|
|
470
|
+
await expect(subtable.getByText('隐藏明细字段', { exact: true })).toHaveCount(0);
|
|
471
|
+
await editor.locator('input[type="file"]').first().setInputFiles({ name: '申请说明.txt', mimeType: 'text/plain', buffer: Buffer.from('说明') });
|
|
472
|
+
await expect(editor.getByText('申请说明.txt', { exact: true })).toBeVisible();
|
|
473
|
+
await editor.getByRole('textbox', { name: '富文本内容' }).fill('直接修改富文本说明');
|
|
474
|
+
await subtable.getByLabel('品名', { exact: true }).first().fill('');
|
|
475
|
+
await editor.getByRole('button', { name: '保存', exact: true }).click();
|
|
476
|
+
await expect(editor.getByText('请完善子表单第 1 项')).toBeVisible();
|
|
477
|
+
expect(state.correctionWrites).toHaveLength(0);
|
|
478
|
+
await subtable.getByLabel('品名', { exact: true }).first().fill('修改后的明细甲');
|
|
479
|
+
await subtable.getByRole('row').nth(2).getByRole('button', { name: '删除', exact: true }).click();
|
|
480
|
+
await subtable.getByRole('button', { name: '新增一项' }).click();
|
|
481
|
+
await subtable.getByLabel('品名', { exact: true }).nth(1).fill('新增明细丙');
|
|
482
|
+
await subtable.getByLabel('数量', { exact: true }).nth(1).fill('5');
|
|
483
|
+
await expect(page.getByRole('dialog')).toHaveCount(1);
|
|
484
|
+
await page.screenshot({ path: testInfo.outputPath('workflow-ordinary-editor-attachments-subtable.png'), animations: 'disabled' });
|
|
485
|
+
await editor.getByRole('button', { name: '保存', exact: true }).click();
|
|
486
|
+
await expect(page.getByRole('dialog', { name: '申请详情' })).toBeVisible();
|
|
487
|
+
await expect(page.getByRole('dialog', { name: '申请详情' }).getByText('保留详情说明', { exact: true })).toBeVisible();
|
|
488
|
+
await expect(page.getByRole('dialog', { name: '申请详情' }).getByText('不得显示的明细值', { exact: true })).toHaveCount(0);
|
|
489
|
+
expect(state.uploads).toMatchObject([{ fieldCode: 'attachments', recordId: 'record-1' }]);
|
|
490
|
+
const operations = state.correctionWrites[0].operations;
|
|
491
|
+
expect(operations[0]).toMatchObject({ operation: 'update', id: 'record-1', expectedRevision: 8, data: { attachments: [{ id: 'file-1' }] } });
|
|
492
|
+
expect(operations[0].data.richText).toContain('直接修改富文本说明');
|
|
493
|
+
expect(operations[0].data).not.toHaveProperty('items');
|
|
494
|
+
expect(operations).toEqual(expect.arrayContaining([
|
|
495
|
+
expect.objectContaining({ operation: 'delete', resourceCode: 'purchase-lines', id: 'line-2', expectedRevision: 4 }),
|
|
496
|
+
expect.objectContaining({ operation: 'update', resourceCode: 'purchase-lines', id: 'line-1', expectedRevision: 3, data: { name: '修改后的明细甲' } }),
|
|
497
|
+
expect.objectContaining({ operation: 'create', resourceCode: 'purchase-lines', data: { name: '新增明细丙', quantity: 5, purchaseId: 'record-1', sortOrder: 1 } }),
|
|
498
|
+
]));
|
|
499
|
+
expect(state.correctionEffects).toBe(1);
|
|
500
|
+
expect(state.record.decision).toBe('已同意');
|
|
501
|
+
expect(state.launches).toEqual([]);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('mobile child editing selects form fields while readonly details retain their own selected fields', async ({ page }) => {
|
|
505
|
+
const state = await workflowEntryPlatform(page);
|
|
506
|
+
state.complexFields = true;
|
|
507
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
508
|
+
await page.goto('/workflow-entry.e2e.html?mode=mobile-detail&fields=complex');
|
|
509
|
+
await expect(page.locator('.oxa-workflow-subtable-mobile')).toContainText('保留详情说明');
|
|
510
|
+
await expect(page.getByLabel('隐藏明细字段', { exact: true })).toHaveCount(0);
|
|
511
|
+
await page.getByRole('button', { name: '编辑', exact: true }).first().click();
|
|
512
|
+
const form = page.locator('.oxa-mobile-form');
|
|
513
|
+
const childName = form.getByRole('textbox', { name: '品名', exact: true }).first();
|
|
514
|
+
await expect(childName).toHaveValue('原明细甲');
|
|
515
|
+
await expect(form.getByLabel('仅详情说明', { exact: true })).toHaveCount(0);
|
|
516
|
+
await expect(form.getByLabel('隐藏明细字段', { exact: true })).toHaveCount(0);
|
|
517
|
+
await childName.fill('移动明细修改');
|
|
518
|
+
await form.getByRole('button', { name: '保存', exact: true }).click();
|
|
519
|
+
await expect(page.locator('.oxa-workflow-subtable-mobile')).toContainText('保留详情说明');
|
|
520
|
+
await expect(page.locator('.oxa-workflow-subtable-mobile')).toContainText('移动明细修改');
|
|
521
|
+
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
522
|
+
expect(state.correctionWrites[0].operations.slice(1)).toEqual([
|
|
523
|
+
{ operation: 'update', resourceCode: 'purchase-lines', id: 'line-1', expectedRevision: 3, data: { name: '移动明细修改' } },
|
|
524
|
+
]);
|
|
525
|
+
});
|
|
@@ -526,7 +526,7 @@ function processCommandSurface() {
|
|
|
526
526
|
};
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
function todoPage(offset = 0) {
|
|
529
|
+
function todoPage(offset = 0, view = 'all') {
|
|
530
530
|
const first = {
|
|
531
531
|
messageId: '33333333-3333-4333-8333-333333333333',
|
|
532
532
|
recipientId: '44444444-4444-4444-8444-444444444444',
|
|
@@ -560,7 +560,7 @@ function todoPage(offset = 0) {
|
|
|
560
560
|
schemaVersion: 'openxiangda.application-todo-center/v2',
|
|
561
561
|
appCode,
|
|
562
562
|
environmentKey: 'preproduction',
|
|
563
|
-
view
|
|
563
|
+
view,
|
|
564
564
|
counts: { pending: 1, informational: 2, completed: 3, unread: 1 },
|
|
565
565
|
items: [offset > 0 ? second : first],
|
|
566
566
|
total: 2,
|
|
@@ -649,7 +649,7 @@ async function mockWorkflow(
|
|
|
649
649
|
return route.fulfill({
|
|
650
650
|
contentType: 'application/json',
|
|
651
651
|
body: JSON.stringify(
|
|
652
|
-
envelope(todoPage(Number(url.searchParams.get('offset') || 0))),
|
|
652
|
+
envelope(todoPage(Number(url.searchParams.get('offset') || 0), url.searchParams.get('view') || 'all')),
|
|
653
653
|
),
|
|
654
654
|
});
|
|
655
655
|
}
|
|
@@ -709,6 +709,7 @@ async function mockWorkflow(
|
|
|
709
709
|
});
|
|
710
710
|
}
|
|
711
711
|
if (path.endsWith('/workflow/work-center/items')) {
|
|
712
|
+
const view = url.searchParams.get('view') || 'pending';
|
|
712
713
|
return route.fulfill({
|
|
713
714
|
contentType: 'application/json',
|
|
714
715
|
body: JSON.stringify(
|
|
@@ -716,11 +717,12 @@ async function mockWorkflow(
|
|
|
716
717
|
engineVersion: '2.0',
|
|
717
718
|
authorizationMode: 'current_user_role_union',
|
|
718
719
|
authorizationDigest: 'digest-1',
|
|
719
|
-
|
|
720
|
+
view,
|
|
721
|
+
counts: { created: 1, pending: completed ? 0 : 1, handled: completed ? 1 : 0, cc: 1 },
|
|
720
722
|
total: 1,
|
|
721
723
|
limit: 20,
|
|
722
724
|
offset: 0,
|
|
723
|
-
items: [task(completed, options.customDetail)],
|
|
725
|
+
items: [{ ...task(completed, options.customDetail), id: view === 'pending' ? taskId : instanceId, itemKind: view === 'pending' ? 'task' : 'instance', instanceId, taskId: view === 'pending' ? taskId : null, workflowTitle: '采购审批', taskTitle: '负责人审批', instanceStatus: completed ? 'approved' : 'running', resourceCode: 'purchase-orders', recordId: 'purchase-1', occurredAt: '2026-08-25T01:00:00.000Z' }],
|
|
724
726
|
})
|
|
725
727
|
),
|
|
726
728
|
});
|
|
@@ -803,9 +805,55 @@ test('renders a paged desktop work center from the current-user role union', asy
|
|
|
803
805
|
}) => {
|
|
804
806
|
await mockWorkflow(page);
|
|
805
807
|
await page.goto('/workflow-experience.e2e.html?initial=/work-center');
|
|
806
|
-
await expect(page.getByRole('heading', { name: '
|
|
807
|
-
await expect(page.getByRole('
|
|
808
|
-
await expect(page.getByText('
|
|
808
|
+
await expect(page.getByRole('heading', { name: '待办中心' })).toBeVisible();
|
|
809
|
+
await expect(page.getByRole('button', { name: '采购申请审批' })).toBeVisible();
|
|
810
|
+
await expect(page.getByText('采购审批', { exact: true })).toBeVisible();
|
|
811
|
+
for (const [view, label] of [['created', '我创建的'], ['handled', '我已处理'], ['cc', '抄送我的'], ['pending', '待我审批']]) {
|
|
812
|
+
const request = page.waitForRequest(request => new URL(request.url()).pathname.endsWith('/workflow/work-center/items') && new URL(request.url()).searchParams.get('view') === view);
|
|
813
|
+
await page.getByRole('tab', { name: new RegExp(label) }).click();
|
|
814
|
+
await request;
|
|
815
|
+
await expect(page.getByRole('button', { name: '采购申请审批' })).toBeVisible();
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test('a current task handler can send CC with the Surface instance command and multiple directory users', async ({ page }) => {
|
|
820
|
+
await mockWorkflow(page);
|
|
821
|
+
const current = detail();
|
|
822
|
+
const cc = {
|
|
823
|
+
key: 'cc', kind: 'workflow_command', label: '抄送', group: 'instance_control', audience: 'participant',
|
|
824
|
+
placement: 'overflow', emphasis: 'neutral', tone: 'neutral', visible: true, enabled: true,
|
|
825
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['userIds'], properties: {
|
|
826
|
+
userIds: { type: 'array', title: '抄送给', minItems: 1, maxItems: 20, uniqueItems: true, items: { type: 'string' } },
|
|
827
|
+
comment: { type: 'string', title: '留言', maxLength: 4000 },
|
|
828
|
+
} },
|
|
829
|
+
uiSchema: { presentation: 'dialog', confirmText: '确认抄送', properties: { userIds: { component: 'user_select', mode: 'multiple' } } },
|
|
830
|
+
execute: { method: 'POST', href: `/openxiangda-api/v2/applications/${appCode}/workflow/instances/${instanceId}/commands/cc`, idempotencyRequired: true },
|
|
831
|
+
refresh: ['surface', 'timeline', 'work_center'],
|
|
832
|
+
};
|
|
833
|
+
const augmented = { ...current, operations: [...current.operations, cc], surface: { ...current.surface, operations: [...current.surface.operations, cc] } };
|
|
834
|
+
await page.route('**/workflow/tasks/*/detail', route => route.fulfill({ json: envelope(augmented) }));
|
|
835
|
+
await page.route('**/directory/users?**', route => route.fulfill({ json: envelope({ items: [{ id: 'cc-user-1', label: '陈老师' }, { id: 'cc-user-2', label: '李老师' }], nextCursor: null }) }));
|
|
836
|
+
let writes = 0;
|
|
837
|
+
await page.route('**/workflow/instances/*/commands/cc', async route => {
|
|
838
|
+
writes += 1;
|
|
839
|
+
expect(route.request().postDataJSON()).toMatchObject({ commandToken: current.surface.commandToken, input: { userIds: ['cc-user-1', 'cc-user-2'], comment: '请查阅' } });
|
|
840
|
+
expect(route.request().postDataJSON().idempotencyKey).toBeTruthy();
|
|
841
|
+
expect(route.request().headers()['x-openxiangda-csrf-token']).toBe('workflow-csrf-1');
|
|
842
|
+
await route.fulfill({ json: envelope({ status: 'completed', advanced: false, instanceId }) });
|
|
843
|
+
});
|
|
844
|
+
await page.goto(workflowFixtureUrl(`/tasks/${taskId}`));
|
|
845
|
+
await page.getByRole('button', { name: '更多操作' }).click();
|
|
846
|
+
await page.getByRole('button', { name: '抄送', exact: true }).click();
|
|
847
|
+
await page.getByRole('button', { name: '确认抄送' }).click();
|
|
848
|
+
expect(writes).toBe(0);
|
|
849
|
+
const select = page.getByRole('dialog').getByRole('combobox');
|
|
850
|
+
await select.fill('老师');
|
|
851
|
+
await page.getByTitle('陈老师', { exact: true }).click();
|
|
852
|
+
await page.getByTitle('李老师', { exact: true }).click();
|
|
853
|
+
await page.getByLabel('留言').fill('请查阅');
|
|
854
|
+
await page.getByRole('button', { name: '确认抄送' }).click();
|
|
855
|
+
await expect.poll(() => writes).toBe(1);
|
|
856
|
+
await expect(page.getByRole('button', { name: /同\s*意/ })).toBeVisible();
|
|
809
857
|
});
|
|
810
858
|
|
|
811
859
|
test('renders the generic desktop and mobile application todo center without a second router', async ({
|
|
@@ -813,7 +861,7 @@ test('renders the generic desktop and mobile application todo center without a s
|
|
|
813
861
|
}) => {
|
|
814
862
|
await mockWorkflow(page);
|
|
815
863
|
await page.goto('/workflow-experience.e2e.html?initial=/todos');
|
|
816
|
-
await expect(page.getByRole('heading', { name: '
|
|
864
|
+
await expect(page.getByRole('heading', { name: '消息中心' })).toBeVisible();
|
|
817
865
|
await expect(page.getByTestId('standard-frame-desktop')).toHaveCount(0);
|
|
818
866
|
await expect(page.locator('.oxa-todo-table')).toBeVisible();
|
|
819
867
|
await expect(page.locator('.oxa-todo-preview-card')).toHaveCount(0);
|
|
@@ -881,7 +929,7 @@ test('selects paired application todo renderers while the platform owns data, pa
|
|
|
881
929
|
await page.getByRole('button', { name: '切换未读' }).click();
|
|
882
930
|
await unreadRequest;
|
|
883
931
|
await expect(page.getByTestId('custom-todo-query')).toContainText(
|
|
884
|
-
'
|
|
932
|
+
'all/true/all',
|
|
885
933
|
);
|
|
886
934
|
|
|
887
935
|
await page.getByRole('button', { name: '设备采购申请待审批' }).click();
|
|
@@ -970,7 +1018,7 @@ test('routes desktop and mobile work-center entries to the declared custom detai
|
|
|
970
1018
|
}) => {
|
|
971
1019
|
await mockWorkflow(page, { customDetail: true });
|
|
972
1020
|
await page.goto('/workflow-experience.e2e.html?initial=/work-center');
|
|
973
|
-
await page.getByRole('
|
|
1021
|
+
await page.getByRole('button', { name: '采购申请审批' }).click();
|
|
974
1022
|
await expect(page.getByTestId('custom-workflow-detail')).toContainText(
|
|
975
1023
|
`${instanceId} / ${taskId}`
|
|
976
1024
|
);
|
|
@@ -1008,7 +1056,7 @@ test('renders a standalone desktop detail on the canonical user URL', async ({
|
|
|
1008
1056
|
await page.goto(
|
|
1009
1057
|
workflowFixtureUrl(`/tasks/${taskId}?from=todo#approval`),
|
|
1010
1058
|
);
|
|
1011
|
-
await expect(page.locator('.oxa-
|
|
1059
|
+
await expect(page.locator('.oxa-record-detail-desktop.is-page')).toBeVisible();
|
|
1012
1060
|
await expect(page).toHaveURL(
|
|
1013
1061
|
new RegExp(`/tasks/${taskId}\\?from=todo#approval$`),
|
|
1014
1062
|
);
|
|
@@ -1019,6 +1067,7 @@ test('renders a standalone desktop detail on the canonical user URL', async ({
|
|
|
1019
1067
|
await expect(page.getByText('业务数据已变化')).toHaveCount(0);
|
|
1020
1068
|
await expect(page.getByText('流程信息')).toHaveCount(0);
|
|
1021
1069
|
await expect(page.getByText('操作记录')).toHaveCount(0);
|
|
1070
|
+
await page.getByRole('tab', { name: '审批历史' }).click();
|
|
1022
1071
|
await expect(page.getByText('由值班负责人继续处理')).toHaveCount(1);
|
|
1023
1072
|
await expect(page.getByRole('button', { name: /同\s*意/ })).toBeVisible();
|
|
1024
1073
|
await expect(page.getByRole('button', { name: /拒\s*绝/ })).toBeVisible();
|
|
@@ -1042,8 +1091,9 @@ test('renders the independent mobile task and executes only a Surface operation'
|
|
|
1042
1091
|
await mockWorkflow(page);
|
|
1043
1092
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
1044
1093
|
await page.goto(`/workflow-experience.e2e.html?initial=/m/tasks/${taskId}`);
|
|
1045
|
-
await expect(page.locator('.oxa-
|
|
1094
|
+
await expect(page.locator('.oxa-record-detail-mobile')).toBeVisible();
|
|
1046
1095
|
await expect(page.getByText('¥30,000')).toBeVisible();
|
|
1096
|
+
await page.getByRole('tab', { name: '审批历史' }).click();
|
|
1047
1097
|
await expect(page.getByText('财务负责人').first()).toBeVisible();
|
|
1048
1098
|
await expect(page.getByRole('button', { name: /同\s*意/ })).toBeVisible();
|
|
1049
1099
|
await page.getByRole('button', { name: /同\s*意/ }).click();
|
|
@@ -1064,7 +1114,7 @@ test('shows an explicit authorization error instead of an empty work center', as
|
|
|
1064
1114
|
await expect(page.getByRole('alert')).toContainText('403: forbidden', {
|
|
1065
1115
|
timeout: 30_000,
|
|
1066
1116
|
});
|
|
1067
|
-
await expect(page.getByRole('
|
|
1117
|
+
await expect(page.getByRole('button', { name: '采购申请审批' })).toHaveCount(0);
|
|
1068
1118
|
});
|
|
1069
1119
|
|
|
1070
1120
|
test('negotiates direct standard routes after BrowserRouter listener setup', async ({
|
|
@@ -1112,7 +1162,7 @@ test('negotiates direct standard routes after BrowserRouter listener setup', asy
|
|
|
1112
1162
|
await page.goto(
|
|
1113
1163
|
workflowFixtureUrl(`/tasks/${taskId}?from=todo#approval`),
|
|
1114
1164
|
);
|
|
1115
|
-
await expect(page.locator('.oxa-
|
|
1165
|
+
await expect(page.locator('.oxa-record-detail-mobile')).toBeVisible();
|
|
1116
1166
|
await expect(page).toHaveURL(
|
|
1117
1167
|
new RegExp(`/m/tasks/${taskId}\\?from=todo#approval$`),
|
|
1118
1168
|
);
|
|
@@ -1120,7 +1170,7 @@ test('negotiates direct standard routes after BrowserRouter listener setup', asy
|
|
|
1120
1170
|
await page.goto(
|
|
1121
1171
|
workflowFixtureUrl(`/workflows/${instanceId}?from=todo#timeline`),
|
|
1122
1172
|
);
|
|
1123
|
-
await expect(page.locator('.oxa-
|
|
1173
|
+
await expect(page.locator('.oxa-record-detail-mobile')).toBeVisible();
|
|
1124
1174
|
await expect(page).toHaveURL(
|
|
1125
1175
|
new RegExp(`/m/workflows/${instanceId}\\?from=todo#timeline$`),
|
|
1126
1176
|
);
|
|
@@ -1146,7 +1196,7 @@ test('negotiates launch pairs in both directions and preserves the mount basenam
|
|
|
1146
1196
|
);
|
|
1147
1197
|
|
|
1148
1198
|
await page.setViewportSize({ width: 1_440, height: 900 });
|
|
1149
|
-
await expect(page.locator('.oxa-workflow-
|
|
1199
|
+
await expect(page.locator('.oxa-workflow-entry')).toBeVisible();
|
|
1150
1200
|
await expect(page).toHaveURL(
|
|
1151
1201
|
new RegExp(
|
|
1152
1202
|
`${base}/workflows/purchase-approval/start\\?source=todo#launch$`,
|
|
@@ -144,6 +144,7 @@ test('browser acceptance serializes the prewarmed Vite HTML entrypoints', () =>
|
|
|
144
144
|
'field-protocol.e2e.html',
|
|
145
145
|
'mobile-reference.e2e.html',
|
|
146
146
|
'resource-experience.e2e.html',
|
|
147
|
+
'workflow-entry.e2e.html',
|
|
147
148
|
'workflow-experience.e2e.html',
|
|
148
149
|
]);
|
|
149
150
|
for (const entry of htmlEntries) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="openxiangda-runtime-base" content="/" />
|
|
7
|
+
<meta name="openxiangda-app-code" content="openxiangda-application" />
|
|
8
|
+
<meta name="openxiangda-environment" content="preproduction" />
|
|
9
|
+
<title>Workflow entry acceptance</title>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
<script type="module" src="/e2e/workflow-entry-fixture.tsx"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|