@epoint-testtech/stage-create 0.0.3-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +129 -0
  3. package/dist/render-template.d.ts +10 -0
  4. package/dist/render-template.js +34 -0
  5. package/dist/stage-context.d.ts +27 -0
  6. package/dist/stage-context.js +175 -0
  7. package/package.json +33 -0
  8. package/templates/default/.env.example +18 -0
  9. package/templates/default/package.json.tpl +20 -0
  10. package/templates/default/playwright.config.ts +44 -0
  11. package/templates/default/setup-mac.sh +29 -0
  12. package/templates/default/setup-windows.cmd +13 -0
  13. package/templates/default/setup-windows.ps1 +26 -0
  14. package/templates/default/src/global.setup.ts +5 -0
  15. package/templates/default/tsconfig.json +12 -0
  16. package/templates/glue/.env.example +18 -0
  17. package/templates/glue/package.json.tpl +21 -0
  18. package/templates/glue/playwright.config.ts +56 -0
  19. package/templates/glue/src/data/stage-config.yaml.tpl +13 -0
  20. package/templates/glue/src/gap-executor/gap-executor.ts +162 -0
  21. package/templates/glue/src/gap-executor/gap-healer.ts +50 -0
  22. package/templates/glue/src/gap-executor/page-structure-observer.ts +102 -0
  23. package/templates/glue/src/gap-executor/runtime-runner.ts +817 -0
  24. package/templates/glue/src/report/glue-report.ts +855 -0
  25. package/templates/glue/src/report/run-info.ts +85 -0
  26. package/templates/glue/src/skeletons/crud.skeleton.ts +450 -0
  27. package/templates/glue/src/skeletons/export.skeleton.ts +114 -0
  28. package/templates/glue/src/skeletons/index.ts +18 -0
  29. package/templates/glue/src/skeletons/login.skeleton.ts +104 -0
  30. package/templates/glue/src/skeletons/menu.skeleton.ts +37 -0
  31. package/templates/glue/src/tests/example.spec.ts +99 -0
  32. package/templates/glue/src/web/component/anchor-types.ts +9 -0
  33. package/templates/glue/src/web/component/dataexport-component.ts +143 -0
  34. package/templates/glue/src/web/component/index.ts +2 -0
  35. package/templates/glue/src/web/component/listbox-component.ts +41 -0
  36. package/templates/glue/tsconfig.json +12 -0
@@ -0,0 +1,85 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import { StagePath } from '@epoint-testtech/stage-core';
5
+ import { addTimeStampPrefix, generateTimeStamp } from '@epoint-testtech/stage-core/utils';
6
+
7
+ export type GlueRunInfo = {
8
+ runTimeStamp: string;
9
+ authFilePath: string;
10
+ batchReportDirPath: string;
11
+ playwrightReportDirPath: string;
12
+ stageGlueReportHtmlPath: string;
13
+ stageGlueReportJsonPath: string;
14
+ };
15
+
16
+ export type GlueRunInfoContext = GlueRunInfo & {
17
+ glueReportRoot: string;
18
+ reportDir: string;
19
+ runInfoPath: string;
20
+ };
21
+
22
+ /**
23
+ * 读取当前执行批次信息。
24
+ * @param configDir Playwright 配置文件所在目录。
25
+ * @returns 已写入的执行批次信息,不存在时返回 undefined。
26
+ */
27
+ export function readGlueRunInfo(projectRootDir: string): GlueRunInfo | undefined {
28
+ const runInfoPath = path.join(projectRootDir, 'glue-report', 'run-info.json');
29
+ if (!fs.existsSync(runInfoPath)) {
30
+ return undefined;
31
+ }
32
+ return JSON.parse(fs.readFileSync(runInfoPath, 'utf-8')) as GlueRunInfo;
33
+ }
34
+
35
+ /**
36
+ * 解析当前执行批次下的下载产物目录。
37
+ * @param testTitle Playwright 用例标题。
38
+ * @param configDir Playwright 配置文件所在目录,默认使用当前工作目录。
39
+ * @returns 位于 `glue-report/<runTimeStamp>/downloads/` 下的用例级下载目录。
40
+ */
41
+ export function resolveGlueDownloadDir(testTitle: string): string {
42
+ return path.join(StagePath.downloadDirPath, addTimeStampPrefix(testTitle));
43
+ }
44
+
45
+ /**
46
+ * 初始化本轮胶水编码运行信息,并确保多次加载配置时复用同一个执行时间戳。
47
+ * @param configDir Playwright 配置文件所在目录。
48
+ * @returns 本轮运行信息及常用路径。
49
+ */
50
+ export function initializeGlueRunInfo(configDir: string): GlueRunInfoContext {
51
+ const glueReportRoot = path.join(configDir, 'glue-report');
52
+ const runInfoPath = path.join(glueReportRoot, 'run-info.json');
53
+ const authFilePath = path.join(glueReportRoot, 'stage-auth.json');
54
+ const timestamp = process.env.STAGE_REPORT_TIMESTAMP || generateTimeStamp();
55
+ const reportDir = path.join(glueReportRoot, timestamp);
56
+ const playwrightReportDirPath = path.join(reportDir, 'playwright-report');
57
+ const downloadDirPath = path.join(reportDir, 'downloads');
58
+
59
+ process.env.STAGE_PROJECT_ROOT_DIR_PATH = configDir;
60
+ process.env.STAGE_REPORT_ROOT_DIR_PATH = glueReportRoot;
61
+ process.env.STAGE_REPORT_DIR_PATH = reportDir;
62
+ process.env.STAGE_REPORT_TIMESTAMP = timestamp;
63
+ process.env.STAGE_AUTH_FILE_PATH = authFilePath;
64
+ process.env.STAGE_GLUE_PLAYWRIGHT_REPORT_DIR = reportDir;
65
+ process.env.STAGE_DOWNLOAD_DIR_PATH = downloadDirPath;
66
+
67
+ const runInfo: GlueRunInfo = {
68
+ runTimeStamp: timestamp,
69
+ authFilePath,
70
+ batchReportDirPath: reportDir,
71
+ playwrightReportDirPath,
72
+ stageGlueReportHtmlPath: path.join(reportDir, `stage-glue-${timestamp}.html`),
73
+ stageGlueReportJsonPath: path.join(reportDir, `stage-glue-${timestamp}.json`),
74
+ };
75
+
76
+ fs.mkdirSync(glueReportRoot, { recursive: true });
77
+ fs.writeFileSync(runInfoPath, JSON.stringify(runInfo, null, 2), 'utf-8');
78
+
79
+ return {
80
+ ...runInfo,
81
+ glueReportRoot,
82
+ reportDir,
83
+ runInfoPath,
84
+ };
85
+ }
@@ -0,0 +1,450 @@
1
+ import { expect, type FrameLocator, type Page } from '@playwright/test';
2
+ import { stageLocator } from '@epoint-testtech/stage-core';
3
+ import { addTimeStampPrefix, log } from '@epoint-testtech/stage-core/utils';
4
+ import { ListBoxComponent } from '../web/component';
5
+
6
+ export type CrudDeletePolicy = 'success_delete' | 'blocked_by_business_rule';
7
+ export type CrudDataValueStrategy = 'timestamp_prefix' | 'fixed_value';
8
+
9
+ export type CrudDataKeySlot = {
10
+ field: string;
11
+ seedValue: string;
12
+ strategy?: CrudDataValueStrategy;
13
+ fixedValue?: string;
14
+ updatePrefix?: string;
15
+ };
16
+
17
+ export type CrudListboxSelectionSlot = {
18
+ field: string;
19
+ label: string;
20
+ value: string;
21
+ };
22
+
23
+ export type CrudInputSearchConditionSlot = CrudDataKeySlot & {
24
+ component: 'input';
25
+ label: string;
26
+ addLocator: string;
27
+ editLocator: string;
28
+ listLocator: string;
29
+ searchSeedValue?: string;
30
+ };
31
+
32
+ export type CrudListboxSearchConditionSlot = {
33
+ component: 'listbox';
34
+ field: string;
35
+ label: string;
36
+ value: string;
37
+ createSelections?: CrudListboxSelectionSlot[];
38
+ };
39
+
40
+ export type CrudSearchConditionSlot = CrudInputSearchConditionSlot | CrudListboxSearchConditionSlot;
41
+
42
+ /**
43
+ * CRUD 骨架输入槽位。
44
+ *
45
+ * 这是“外部传给骨架的参数定义”,不是运行过程中自动算出来的数据。
46
+ * 可以理解为:黄金脚本里所有会随业务模块变化的动态信息,都收敛到这个对象里。
47
+ *
48
+ * 结构分工:
49
+ * - moduleId: 当前业务模块标识,只用于日志和问题定位
50
+ * - searchConditions: 列表页搜索条件清单,Read 工作流会逐项校验,Create 工作流补选其中的 listbox 条目
51
+ * - frames: 各业务页面 iframe 的进入关键字
52
+ * - locators: CRUD 流程需要用到的通用页面控件定位表达式
53
+ * - assertions: 删除阶段的断言策略
54
+ */
55
+ export type CrudFlowSlots = {
56
+ moduleId: string;
57
+ searchConditions: CrudSearchConditionSlot[];
58
+ frames: {
59
+ listIframeSrcKeyword: string;
60
+ addIframeSrcKeyword: string;
61
+ editIframeSrcKeyword: string;
62
+ };
63
+ locators: {
64
+ createButton: string;
65
+ addAutofillTrigger?: string;
66
+ addSaveButton: string;
67
+ listSearchSubmit: string;
68
+ listResultValueLocatorTemplate: string;
69
+ listEditActionLocatorTemplate: string;
70
+ editSaveButton: string;
71
+ listRowSelectLocatorTemplate: string;
72
+ listDeleteButton: string;
73
+ deleteConfirmButton?: string;
74
+ };
75
+ assertions: {
76
+ deletePolicy: CrudDeletePolicy;
77
+ blockedDeleteMessage?: string;
78
+ };
79
+ };
80
+
81
+ /**
82
+ * 单个搜索条件的 CRUD 流程运行时数据。
83
+ */
84
+ export type CrudFlowRuntimeData = {
85
+ createValue: string;
86
+ updateValue: string;
87
+ searchValue: string;
88
+ };
89
+
90
+ export type CrudSearchConditionRuntimeData = CrudFlowRuntimeData & {
91
+ field: string;
92
+ label: string;
93
+ component: CrudSearchConditionSlot['component'];
94
+ };
95
+
96
+ /**
97
+ * CRUD 工作流运行时数据。
98
+ *
99
+ * 在 CrudFlowRuntimeData 基础上额外携带 dataKey,
100
+ * 作为 Create/Read/Update/Delete 各阶段独立调用时共享的行定位锚点。
101
+ */
102
+ export type CrudWorkflowData = CrudFlowRuntimeData & {
103
+ dataKey: CrudInputSearchConditionSlot;
104
+ };
105
+
106
+ export class CrudPage {
107
+ private readonly repoIFrame列表页: FrameLocator;
108
+ private readonly repoIFrame新增页: FrameLocator;
109
+ private readonly repoIFrame修改页: FrameLocator;
110
+
111
+ /**
112
+ * 初始化 CRUD 页面对象。
113
+ * @param page Playwright 页面对象
114
+ * @param slots CRUD 骨架运行所需的动态槽位
115
+ */
116
+ constructor(
117
+ public readonly page: Page,
118
+ private readonly slots: CrudFlowSlots
119
+ ) {
120
+ this.repoIFrame列表页 = page.locator(`iframe[src*="${slots.frames.listIframeSrcKeyword}"]`).contentFrame();
121
+ this.repoIFrame新增页 = page.locator(`iframe[src*="${slots.frames.addIframeSrcKeyword}"]`).contentFrame();
122
+ this.repoIFrame修改页 = page.locator(`iframe[src*="${slots.frames.editIframeSrcKeyword}"]`).contentFrame();
123
+ }
124
+
125
+ /**
126
+ * 准备 CRUD 工作流运行时数据。
127
+ * @returns 用于 Create/Read/Update/Delete 串联的数据
128
+ * @throws 当没有 input 搜索条件时抛出异常
129
+ */
130
+ prepareWorkflowData(): CrudWorkflowData {
131
+ const dataKey = this.resolveInputRowAnchor();
132
+ return {
133
+ ...this.buildRuntimeData(dataKey),
134
+ dataKey,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * 执行 Create 工作流。
140
+ * @param workflowData 工作流运行时数据
141
+ */
142
+ async runCreateWorkflow(workflowData: CrudWorkflowData): Promise<void> {
143
+ const listboxSelections = this.resolveCreateSelections();
144
+ await this.createRecord(workflowData.createValue, workflowData.dataKey.addLocator, listboxSelections);
145
+ await this.searchByInputAndAssertCount(workflowData.dataKey, workflowData.searchValue, 1);
146
+ }
147
+
148
+ /**
149
+ * 执行 Read/Search 工作流。
150
+ * @param workflowData 工作流运行时数据
151
+ */
152
+ async runReadWorkflow(workflowData: CrudWorkflowData): Promise<void> {
153
+ for (const condition of this.slots.searchConditions) {
154
+ if (condition.component === 'input') {
155
+ await this.searchByInputAndAssertCount(condition, workflowData.searchValue, 1);
156
+ } else {
157
+ await this.searchByListboxAndAssertRecord(condition, workflowData.dataKey, workflowData.createValue, 1);
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 执行 Update 工作流。
164
+ * @param workflowData 工作流运行时数据
165
+ */
166
+ async runUpdateWorkflow(workflowData: CrudWorkflowData): Promise<void> {
167
+ await this.updateRecord(workflowData.createValue, workflowData.updateValue, workflowData.dataKey.editLocator);
168
+ await this.searchByInputAndAssertCount(workflowData.dataKey, workflowData.updateValue, 1);
169
+ }
170
+
171
+ /**
172
+ * 执行 Delete 工作流。
173
+ * @param workflowData 工作流运行时数据
174
+ */
175
+ async runDeleteWorkflow(workflowData: CrudWorkflowData): Promise<void> {
176
+ await this.deleteRecord(workflowData.updateValue);
177
+
178
+ if (this.slots.assertions.deletePolicy === 'success_delete') {
179
+ await this.searchByInputAndAssertCount(workflowData.dataKey, workflowData.updateValue, 0);
180
+ } else {
181
+ await this.assertDeleteBlocked();
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 为 listbox 流程解析唯一行锚点。
187
+ * @returns 用于新增、修改、删除定位的 input 搜索条件
188
+ * @throws 当 searchConditions 中不存在 input 条件时抛出异常
189
+ */
190
+ private resolveInputRowAnchor(): CrudInputSearchConditionSlot {
191
+ const inputCondition = this.slots.searchConditions.find(
192
+ (condition): condition is CrudInputSearchConditionSlot => condition.component === 'input'
193
+ );
194
+ if (!inputCondition) {
195
+ throw new Error('listbox CRUD 流程需要至少一个 input 搜索条件作为唯一行锚点');
196
+ }
197
+ return inputCondition;
198
+ }
199
+
200
+ /**
201
+ * 解析新增工作流需要补选的 listbox 条目。
202
+ * @returns 去重后的 listbox 选择项
203
+ */
204
+ private resolveCreateSelections(): CrudListboxSelectionSlot[] {
205
+ const selections = this.slots.searchConditions
206
+ .filter((condition): condition is CrudListboxSearchConditionSlot => condition.component === 'listbox')
207
+ .flatMap((condition) => condition.createSelections ?? [
208
+ { field: condition.field, label: condition.label, value: condition.value },
209
+ ]);
210
+
211
+ const seen = new Set<string>();
212
+ return selections.filter((selection) => {
213
+ const key = `${selection.field}::${selection.label}::${selection.value}`;
214
+ if (seen.has(key)) return false;
215
+ seen.add(key);
216
+ return true;
217
+ });
218
+ }
219
+
220
+ /**
221
+ * 构建当前流程运行时使用的关键数据。
222
+ * @param dataKey 当前流程的数据主锚点槽位
223
+ * @returns 包含新增值、修改值和搜索值的运行时数据
224
+ */
225
+ private buildRuntimeData(dataKey: CrudDataKeySlot & { searchSeedValue?: string }): CrudFlowRuntimeData {
226
+ const createValue = this.buildCreateValue(dataKey);
227
+ const updatePrefix = dataKey.updatePrefix ?? '修改';
228
+ const updateValue = `${updatePrefix}${createValue}`;
229
+
230
+ return {
231
+ createValue,
232
+ updateValue,
233
+ searchValue: dataKey.searchSeedValue ?? createValue,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * 按策略生成新增阶段的数据主键值。
239
+ * @param dataKey 当前流程的数据主锚点槽位
240
+ * @returns 本次新增要写入页面的数据值
241
+ */
242
+ private buildCreateValue(dataKey: CrudDataKeySlot): string {
243
+ if (dataKey.strategy === 'fixed_value') {
244
+ return dataKey.fixedValue ?? dataKey.seedValue;
245
+ }
246
+ return addTimeStampPrefix(dataKey.seedValue);
247
+ }
248
+
249
+ /**
250
+ * 将动态值渲染到 locator 模板中。
251
+ * @param template 含有 `{{value}}` 占位符的模板
252
+ * @param value 需要替换进去的运行时值
253
+ * @returns 渲染后的定位表达式
254
+ */
255
+ private 渲染动态定位模板(template: string, value: string): string {
256
+ return template.replaceAll('{{value}}', value);
257
+ }
258
+
259
+ /**
260
+ * 执行新增记录流程。
261
+ * @param createValue 本次新增写入的数据主键值
262
+ * @param inputLocator 新增表单中需要补填的 input 定位表达式
263
+ * @param listboxSelections 新增表单中需要补选的 listbox 条目
264
+ */
265
+ private async createRecord(
266
+ createValue: string,
267
+ inputLocator: string,
268
+ listboxSelections: CrudListboxSelectionSlot[] = []
269
+ ): Promise<void> {
270
+ log.info(`新增数据-->点击新增按钮, 模块: ${this.slots.moduleId}`);
271
+ await this.repoIFrame列表页.locator(this.slots.locators.createButton).click();
272
+
273
+ await this.repoIFrame新增页.locator(inputLocator).waitFor();
274
+
275
+ if (this.slots.locators.addAutofillTrigger) {
276
+ await stageLocator(this.repoIFrame新增页.locator(this.slots.locators.addAutofillTrigger), '新增页默认填充').clickIfExist({ timeout: 2_000 });
277
+ }
278
+
279
+ log.info(`新增数据-->补填input锚点: ${createValue}`);
280
+ await this.repoIFrame新增页.locator(inputLocator).fill(createValue);
281
+
282
+ for (const selection of listboxSelections) {
283
+ log.info(`新增数据-->补选listbox: ${selection.field}, 标签: ${selection.label}, 值: ${selection.value}`);
284
+ await this.selectListbox(this.repoIFrame新增页, selection.label, selection.value);
285
+ }
286
+
287
+ await this.repoIFrame新增页.locator(this.slots.locators.addSaveButton).click();
288
+ }
289
+
290
+ /**
291
+ * 选择指定上下文中的 listbox 项。
292
+ * @param context 包含 locator 方法的页面或 iframe 上下文
293
+ * @param label listbox 的 label
294
+ * @param value 要选择的文本值
295
+ */
296
+ private async selectListbox(context: FrameLocator, label: string, value: string): Promise<void> {
297
+ const listbox = new ListBoxComponent(context, label);
298
+ await listbox.selectItem(value);
299
+ }
300
+
301
+ /**
302
+ * 使用 listbox 搜索条件搜索,并按唯一行锚点校验结果是否出现。
303
+ * @param condition listbox 搜索条件槽位
304
+ * @param rowAnchor 唯一行锚点槽位
305
+ * @param resultValue 需要在列表结果中校验的数据主锚点值
306
+ * @param expectedCount 期望匹配条数
307
+ */
308
+ private async searchByListboxAndAssertRecord(
309
+ condition: CrudListboxSearchConditionSlot,
310
+ rowAnchor: CrudInputSearchConditionSlot,
311
+ resultValue: string,
312
+ expectedCount: number
313
+ ): Promise<void> {
314
+ log.info(`ListBox搜索校验-->字段: ${condition.field}, 标签: ${condition.label}, 值: ${condition.value}, 结果锚点: ${resultValue}, 期望条数: ${expectedCount}`);
315
+ await this.repoIFrame列表页.locator(rowAnchor.listLocator).fill('');
316
+ await this.selectListbox(this.repoIFrame列表页, condition.label, condition.value);
317
+ await this.repoIFrame列表页.locator(this.slots.locators.listSearchSubmit).click();
318
+
319
+ const repo搜索结果值 = this.repoIFrame列表页.locator(
320
+ this.渲染动态定位模板(this.slots.locators.listResultValueLocatorTemplate, resultValue)
321
+ );
322
+ const actualCount = await repo搜索结果值.count();
323
+ expect(actualCount).toBe(expectedCount);
324
+ }
325
+
326
+ /**
327
+ * 执行 input 搜索并校验搜索结果条数。
328
+ * @param condition input 搜索条件槽位
329
+ * @param searchValue 搜索输入值
330
+ * @param expectedCount 期望结果条数
331
+ */
332
+ private async searchByInputAndAssertCount(
333
+ condition: CrudInputSearchConditionSlot,
334
+ searchValue: string,
335
+ expectedCount: number
336
+ ): Promise<void> {
337
+ log.info(`搜索校验-->字段: ${condition.field}, 标签: ${condition.label}, 值: ${searchValue}, 期望条数: ${expectedCount}`);
338
+ await this.repoIFrame列表页.locator(condition.listLocator).fill(searchValue);
339
+ await this.repoIFrame列表页.locator(this.slots.locators.listSearchSubmit).click();
340
+
341
+ const repo搜索结果值 = this.repoIFrame列表页.locator(
342
+ this.渲染动态定位模板(this.slots.locators.listResultValueLocatorTemplate, searchValue)
343
+ );
344
+ const actualCount = await repo搜索结果值.count();
345
+ expect(actualCount).toBe(expectedCount);
346
+ }
347
+
348
+ /**
349
+ * 执行修改记录流程。
350
+ * @param currentValue 当前列表中用于定位待修改记录的旧值
351
+ * @param updateValue 修改后写入的新值
352
+ * @param inputLocator 修改表单中需要补填的 input 定位表达式
353
+ */
354
+ private async updateRecord(currentValue: string, updateValue: string, inputLocator: string): Promise<void> {
355
+ const repoBtn修改行操作 = this.渲染动态定位模板(this.slots.locators.listEditActionLocatorTemplate, currentValue);
356
+ log.info(`修改数据-->旧值: ${currentValue}, 新值: ${updateValue}`);
357
+ await this.repoIFrame列表页.locator(repoBtn修改行操作).click();
358
+
359
+ await this.repoIFrame修改页.locator(inputLocator).waitFor();
360
+ await this.repoIFrame修改页.locator(inputLocator).fill(updateValue);
361
+ await this.repoIFrame修改页.locator(this.slots.locators.editSaveButton).click();
362
+ }
363
+
364
+ /**
365
+ * 执行删除记录流程。
366
+ * @param targetValue 列表中用于选中待删除记录的目标值
367
+ */
368
+ private async deleteRecord(targetValue: string): Promise<void> {
369
+ const repo选中待删除记录 = this.渲染动态定位模板(this.slots.locators.listRowSelectLocatorTemplate, targetValue);
370
+
371
+ log.info(`删除数据-->目标值: ${targetValue}, 预期策略: ${this.slots.assertions.deletePolicy}`);
372
+ await this.repoIFrame列表页.locator(repo选中待删除记录).click();
373
+ await this.repoIFrame列表页.locator(this.slots.locators.listDeleteButton).click();
374
+
375
+ if (this.slots.locators.deleteConfirmButton) {
376
+ await this.clickDeleteConfirmButton();
377
+ }
378
+ }
379
+
380
+ /**
381
+ * 点击删除确认弹框中的确认按钮。
382
+ *
383
+ * 黄金参考脚本中的确认按钮优先出现在列表页 iframe 内,
384
+ * 但不同页面实现也可能挂在顶层 page,因此这里按“列表 iframe -> 顶层 page”顺序回退。
385
+ */
386
+ private async clickDeleteConfirmButton(): Promise<void> {
387
+ const deleteConfirmButton = this.slots.locators.deleteConfirmButton;
388
+ if (!deleteConfirmButton) {
389
+ return;
390
+ }
391
+
392
+ const repoBtn删除确认候选 = [
393
+ {
394
+ locator: this.repoIFrame列表页.locator(deleteConfirmButton),
395
+ name: '删除确认(列表页iframe)',
396
+ },
397
+ {
398
+ locator: this.page.locator(deleteConfirmButton),
399
+ name: '删除确认(顶层页面)',
400
+ },
401
+ ];
402
+
403
+ for (const candidate of repoBtn删除确认候选) {
404
+ const repoStageLocator = stageLocator(candidate.locator, candidate.name);
405
+ const isExist = await repoStageLocator.waitForExist({ timeout: 2_000, state: 'visible' });
406
+ if (isExist) {
407
+ await candidate.locator.click();
408
+ log.info(`元素:${candidate.name}存在,并点击`);
409
+ return;
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * 校验删除被业务规则阻塞。
416
+ * @throws 当未提供阻塞提示消息时抛出异常
417
+ */
418
+ private async assertDeleteBlocked(): Promise<void> {
419
+ if (!this.slots.assertions.blockedDeleteMessage) {
420
+ throw new Error('deletePolicy=blocked_by_business_rule 时必须提供 blockedDeleteMessage');
421
+ }
422
+ log.info(`删除阻塞校验-->消息: ${this.slots.assertions.blockedDeleteMessage}`);
423
+ const blockedDeleteMessage = this.slots.assertions.blockedDeleteMessage;
424
+ const repo删除阻塞消息候选 = [
425
+ this.repoIFrame列表页.locator(`text=${blockedDeleteMessage}`),
426
+ this.page.locator(`text=${blockedDeleteMessage}`),
427
+ this.repoIFrame列表页.locator('body'),
428
+ this.page.locator('body'),
429
+ ];
430
+
431
+ for (const candidate of repo删除阻塞消息候选) {
432
+ try {
433
+ if (await candidate.count() === 0) {
434
+ continue;
435
+ }
436
+
437
+ if (candidate === repo删除阻塞消息候选[0] || candidate === repo删除阻塞消息候选[1]) {
438
+ await expect(candidate).toBeVisible({ timeout: 5_000 });
439
+ } else {
440
+ await expect(candidate).toContainText(blockedDeleteMessage, { timeout: 5_000 });
441
+ }
442
+ return;
443
+ } catch {
444
+ continue;
445
+ }
446
+ }
447
+
448
+ throw new Error(`未找到删除阻塞提示消息: ${blockedDeleteMessage}`);
449
+ }
450
+ }
@@ -0,0 +1,114 @@
1
+ import { expect, type FrameLocator, type Page, type TestInfo } from '@playwright/test';
2
+ import { log } from '@epoint-testtech/stage-core/utils';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { resolveGlueDownloadDir } from '../report/run-info';
6
+ import { DataExportComponent } from '../web/component';
7
+
8
+ export type ExportMode = 'all' | 'current_page' | 'page_range';
9
+
10
+ export type ExportFlowSlots = {
11
+ moduleId: string;
12
+ frames: {
13
+ listIframeSrcKeyword: string;
14
+ };
15
+ locators: {
16
+ exportButton: string;
17
+ };
18
+ export: {
19
+ label: string;
20
+ mode: ExportMode;
21
+ startPage?: number;
22
+ endPage?: number;
23
+ };
24
+ };
25
+
26
+ export type ExportWorkflowResult = {
27
+ filePath: string;
28
+ suggestedFilename: string;
29
+ size: number;
30
+ };
31
+
32
+ export class ExportPage {
33
+ private readonly repoIFrame列表页: FrameLocator;
34
+
35
+ /**
36
+ * 初始化导出工作流页面对象。
37
+ * @param page Playwright 页面对象
38
+ * @param slots 导出工作流动态槽位
39
+ */
40
+ constructor(
41
+ public readonly page: Page,
42
+ private readonly slots: ExportFlowSlots
43
+ ) {
44
+ this.repoIFrame列表页 = page.locator(`iframe[src*="${slots.frames.listIframeSrcKeyword}"]`).contentFrame();
45
+ }
46
+
47
+ /**
48
+ * 执行导出工作流并校验下载文件。
49
+ * @param testInfo Playwright 测试信息,用于生成下载目录
50
+ * @returns 下载文件信息
51
+ */
52
+ async runExportWorkflow(testInfo: TestInfo): Promise<ExportWorkflowResult> {
53
+ const repoBtn触发导出弹窗 = this.repoIFrame列表页.locator(this.slots.locators.exportButton);
54
+ await repoBtn触发导出弹窗.click();
55
+
56
+ const repoDataExportComponent = new DataExportComponent(this.repoIFrame列表页, this.slots.export.label);
57
+ const [download] = await Promise.all([
58
+ this.page.waitForEvent('download', { timeout: 60_000 }),
59
+ this.triggerExport(repoDataExportComponent),
60
+ ]);
61
+
62
+ const downloadDir = resolveGlueDownloadDir(testInfo.title);
63
+ fs.mkdirSync(downloadDir, { recursive: true });
64
+ const filePath = path.join(downloadDir, download.suggestedFilename());
65
+ await download.saveAs(filePath);
66
+
67
+ expect(fs.existsSync(filePath), `文件不存在: ${filePath}`).toBe(true);
68
+ const stat = fs.statSync(filePath);
69
+ expect(stat.size, '文件大小为 0').toBeGreaterThan(0);
70
+ this.assertXlsxFile(filePath);
71
+
72
+ log.info(`[导出工作流] 文件已保存到: ${filePath}`);
73
+ return {
74
+ filePath,
75
+ suggestedFilename: download.suggestedFilename(),
76
+ size: stat.size,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * 按导出策略触发导出组件动作。
82
+ * @param component 数据导出组件
83
+ */
84
+ private async triggerExport(component: DataExportComponent): Promise<void> {
85
+ if (this.slots.export.mode === 'all') {
86
+ await component.exportAll();
87
+ return;
88
+ }
89
+
90
+ if (this.slots.export.mode === 'current_page') {
91
+ await component.exportCurrentPage();
92
+ return;
93
+ }
94
+
95
+ if (this.slots.export.startPage === undefined || this.slots.export.endPage === undefined) {
96
+ throw new Error('page_range 导出模式需要提供 startPage 和 endPage');
97
+ }
98
+
99
+ await component.exportByPageRange(this.slots.export.startPage, this.slots.export.endPage);
100
+ }
101
+
102
+ /**
103
+ * 校验下载文件是 xlsx/OOXML 文件。
104
+ * @param filePath 下载文件路径
105
+ */
106
+ private assertXlsxFile(filePath: string): void {
107
+ const buffer = Buffer.alloc(4);
108
+ const fd = fs.openSync(filePath, 'r');
109
+ fs.readSync(fd, buffer, 0, 4, 0);
110
+ fs.closeSync(fd);
111
+ const isXlsx = buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x03 && buffer[3] === 0x04;
112
+ expect(isXlsx, `文件不是有效 xlsx 格式,实际头字节: ${buffer.toString('hex')}`).toBe(true);
113
+ }
114
+ }
@@ -0,0 +1,18 @@
1
+ export { MenuPage } from './menu.skeleton';
2
+ export { LoginPage } from './login.skeleton';
3
+ export { CrudPage } from './crud.skeleton';
4
+ export { ExportPage } from './export.skeleton';
5
+ export type {
6
+ CrudDataKeySlot,
7
+ CrudDataValueStrategy,
8
+ CrudDeletePolicy,
9
+ CrudFlowRuntimeData,
10
+ CrudFlowSlots,
11
+ CrudInputSearchConditionSlot,
12
+ CrudListboxSearchConditionSlot,
13
+ CrudListboxSelectionSlot,
14
+ CrudSearchConditionRuntimeData,
15
+ CrudSearchConditionSlot,
16
+ CrudWorkflowData,
17
+ } from './crud.skeleton';
18
+ export type { ExportFlowSlots, ExportMode, ExportWorkflowResult } from './export.skeleton';