@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,104 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import type { BrowserContext, Locator, Page } from '@playwright/test';
4
+ import { StagePath, stageLocator, syncAutoFill } from '@epoint-testtech/stage-core';
5
+ import { getRequiredEnvVar, log } from '@epoint-testtech/stage-core/utils';
6
+
7
+ type AuthStorageState = {
8
+ cookies?: Parameters<BrowserContext['addCookies']>[0];
9
+ origins?: Array<{
10
+ origin: string;
11
+ localStorage?: Array<{
12
+ name: string;
13
+ value: string;
14
+ }>;
15
+ }>;
16
+ };
17
+
18
+ export class LoginPage {
19
+ private readonly repoInput账号: Locator;
20
+ private readonly repoInput密码: Locator;
21
+ private readonly repoBtn登录: Locator;
22
+ private readonly repoBtn暂不修改: Locator;
23
+ private readonly systemUrl = getRequiredEnvVar('LOGIN_SYSTEM_URL');
24
+
25
+ /**
26
+ * 初始化登录页面对象。
27
+ * @param page Playwright 页面对象
28
+ */
29
+ constructor(public readonly page: Page) {
30
+ this.repoInput账号 = page
31
+ .locator("//input[contains(@type, 'text') or contains(@class,'user') or contains(@id, 'username') or contains(@placeholder, '用户名') or contains(@placeholder, '账号')]")
32
+ .filter({ visible: true })
33
+ .first();
34
+ this.repoInput密码 = page.locator("//input[@type='password' or contains(@placeholder, '密码')]").filter({ visible: true }).first();
35
+ this.repoBtn登录 = page.locator(
36
+ "//*[contains(@class, 'login-btn') or" +
37
+ " contains(@class,'submit') or " +
38
+ "contains(@class,'login-form-btn') or " +
39
+ "contains(@class, 'btn-login')] | //div[@class='second-content']//div[contains(text(), '登')]"
40
+ ).filter({ visible: true }).first();
41
+ this.repoBtn暂不修改 = page.getByRole('button', { name: '暂不修改' }).filter({ visible: true });
42
+ }
43
+
44
+ /**
45
+ * 执行认证复用或账密登录,并在登录后同步 auto-fill。
46
+ */
47
+ async login(): Promise<void> {
48
+ const username = getRequiredEnvVar('LOGIN_USERNAME');
49
+ const password = getRequiredEnvVar('LOGIN_PASSWORD');
50
+
51
+ await this.restoreAuthStateIfPresent();
52
+ await this.page.goto(this.systemUrl);
53
+ if (await stageLocator(this.repoBtn登录, '登录按钮').waitForExist({ timeout: 2_000 })) {
54
+ await this.repoInput账号.fill(username);
55
+ await this.repoInput密码.pressSequentially(password);
56
+ await this.repoBtn登录.click();
57
+ await stageLocator(this.repoBtn暂不修改, '暂不修改').clickIfExist({ timeout: 3_000 });
58
+ await this.saveAuthState();
59
+ log.info(`账密登录-->账号: ${username},认证信息已保存:${StagePath.authFilePath}`);
60
+ } else {
61
+ log.info(`已复用认证信息,不再账密登录:${StagePath.authFilePath}`);
62
+ }
63
+
64
+ await syncAutoFill(this.page);
65
+ }
66
+
67
+ /**
68
+ * 从认证文件中恢复 cookies 和 localStorage。
69
+ * @returns 存在可读取认证文件并完成恢复时返回 true,否则返回 false。
70
+ */
71
+ private async restoreAuthStateIfPresent(): Promise<boolean> {
72
+ if (!fs.existsSync(StagePath.authFilePath)) {
73
+ return false;
74
+ }
75
+
76
+ try {
77
+ const authState = JSON.parse(fs.readFileSync(StagePath.authFilePath, 'utf-8')) as AuthStorageState;
78
+ if (authState.cookies?.length) {
79
+ await this.page.context().addCookies(authState.cookies);
80
+ }
81
+ if (authState.origins?.length) {
82
+ await this.page.context().addInitScript((origins: AuthStorageState['origins']) => {
83
+ const matchedOrigin = origins?.find((item) => item.origin === window.location.origin);
84
+ for (const item of matchedOrigin?.localStorage ?? []) {
85
+ window.localStorage.setItem(item.name, item.value);
86
+ }
87
+ }, authState.origins);
88
+ }
89
+ log.info(`已加载认证信息:${StagePath.authFilePath}`);
90
+ return true;
91
+ } catch (error) {
92
+ log.warn(`认证信息加载失败,将回退账密登录:${StagePath.authFilePath},原因:${String(error)}`);
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 保存当前浏览器上下文认证信息。
99
+ */
100
+ private async saveAuthState(): Promise<void> {
101
+ fs.mkdirSync(path.dirname(StagePath.authFilePath), { recursive: true });
102
+ await this.page.context().storageState({ path: StagePath.authFilePath });
103
+ }
104
+ }
@@ -0,0 +1,37 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { log } from '@epoint-testtech/stage-core/utils';
3
+
4
+ export class MenuPage {
5
+ /**
6
+ * 初始化菜单页面对象。
7
+ * @param page Playwright 页面对象
8
+ */
9
+ constructor(public readonly page: Page) {
10
+ }
11
+
12
+ /**
13
+ * 导航到指定的菜单路径。
14
+ * @param menuNameTree 菜单路径,格式为 "流程服务>动作流>动作流管理" 或模块名称
15
+ */
16
+ async navigateToMenu(menuNameTree: string): Promise<void> {
17
+ let actualSystemMenuRoute = menuNameTree;
18
+
19
+ log.info(`菜单导航-->${actualSystemMenuRoute}`);
20
+ const menuNames = actualSystemMenuRoute.split('>');
21
+ for (const menuName of menuNames) {
22
+ const repoBtn菜单 = this.page.locator(`span.menu-name[title="${menuName}"]`);
23
+ const repoBtn菜单状态 = await repoBtn菜单.locator('..').getAttribute('class');
24
+ if (repoBtn菜单状态?.includes('hasSub')) {
25
+ if (repoBtn菜单状态.includes('expanded')) {
26
+ log.info(` 菜单:${menuName},状态:已展开,无需点击`);
27
+ } else {
28
+ log.info(` 菜单:${menuName},状态:未展开,点击展开`);
29
+ await repoBtn菜单.click();
30
+ }
31
+ } else {
32
+ log.info(` 菜单:${menuName},状态:无子菜单,直接点击`);
33
+ await repoBtn菜单.click();
34
+ }
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 真实 CRUD 骨架示例。
3
+ *
4
+ * 运行前请先准备 `.env`:
5
+ * - LOGIN_SYSTEM_URL: 登录页地址
6
+ * - LOGIN_USERNAME: 登录用户名
7
+ * - LOGIN_PASSWORD: 登录密码
8
+ *
9
+ * 该示例预埋了 zwplace 模块的真实 CRUD 槽位,生成项目后通常只需要
10
+ * 按目标模块替换菜单路径、iframe 关键字和 locator。
11
+ */
12
+ import { test, type Page } from '@playwright/test';
13
+ import { CrudPage, LoginPage, MenuPage, type CrudWorkflowData } from '../skeletons';
14
+
15
+ test.describe('示例 CRUD 测试', () => {
16
+ const menuRoute = '场所窗口信息管理>场所窗口信息列表';
17
+ const crudSlots = {
18
+ moduleId: 'zwplace',
19
+ searchConditions: [
20
+ {
21
+ component: 'input' as const,
22
+ field: 'placename',
23
+ label: '场所名称',
24
+ seedValue: '自动化测试名称',
25
+ strategy: 'timestamp_prefix' as const,
26
+ updatePrefix: '修改',
27
+ addLocator: "xpath=//div[@label='场所名称']//input[@class='mini-textbox-input']",
28
+ editLocator: "xpath=//div[@label='场所名称']//input[@class='mini-textbox-input']",
29
+ listLocator: "xpath=//div[@label='场所名称']//input[@class='mini-textbox-input']",
30
+ },
31
+ ],
32
+ frames: {
33
+ listIframeSrcKeyword: 'gxhzwplacelist.html',
34
+ addIframeSrcKeyword: 'gxhzwplaceadd',
35
+ editIframeSrcKeyword: 'gxhzwplaceedit',
36
+ },
37
+ locators: {
38
+ createButton: "xpath=//a[contains(@class,'mini-button')][normalize-space()='新增场所信息管理']",
39
+ addAutofillTrigger: 'xpath=//*[@title=\'默认填充\']',
40
+ addSaveButton: "xpath=//a[contains(@class,'mini-button')][normalize-space()='保存并关闭']",
41
+ listSearchSubmit: "xpath=//span[text()='搜索']",
42
+ listResultValueLocatorTemplate: "xpath=//*[@id='datagrid']//div[text()='{{value}}']",
43
+ listEditActionLocatorTemplate: "xpath=//*[@id='datagrid']//tr[.//div[text()='{{value}}']]//i[@data-tooltip='修改']",
44
+ editSaveButton: "xpath=//a[contains(@class,'mini-button')][normalize-space()='保存并关闭']",
45
+ listRowSelectLocatorTemplate: "xpath=//*[@id='datagrid']//div[text()='{{value}}']",
46
+ listDeleteButton: "xpath=//a[contains(@class,'mini-button')][normalize-space()='删除选定']",
47
+ deleteConfirmButton: 'text=确定',
48
+ },
49
+ assertions: {
50
+ deletePolicy: 'blocked_by_business_rule' as const,
51
+ blockedDeleteMessage: '在用数据不可删除!',
52
+ },
53
+ };
54
+
55
+ test.describe.configure({ mode: 'serial' });
56
+
57
+ let workflowData: CrudWorkflowData | undefined;
58
+
59
+ async function openModule(page: Page): Promise<void> {
60
+ const loginPage = new LoginPage(page);
61
+ const menuPage = new MenuPage(page);
62
+ await loginPage.login();
63
+ await menuPage.navigateToMenu(menuRoute);
64
+ }
65
+
66
+ test('Create', async ({ page }) => {
67
+ await openModule(page);
68
+ const crudPage = new CrudPage(page, crudSlots);
69
+ workflowData = crudPage.prepareWorkflowData();
70
+ await crudPage.runCreateWorkflow(workflowData);
71
+ });
72
+
73
+ test('Read', async ({ page }) => {
74
+ await openModule(page);
75
+ if (!workflowData) {
76
+ throw new Error('Read 示例依赖 Create 先生成 workflowData');
77
+ }
78
+ const crudPage = new CrudPage(page, crudSlots);
79
+ await crudPage.runReadWorkflow(workflowData);
80
+ });
81
+
82
+ test('Update', async ({ page }) => {
83
+ await openModule(page);
84
+ if (!workflowData) {
85
+ throw new Error('Update 示例依赖 Create 先生成 workflowData');
86
+ }
87
+ const crudPage = new CrudPage(page, crudSlots);
88
+ await crudPage.runUpdateWorkflow(workflowData);
89
+ });
90
+
91
+ test('Delete', async ({ page }) => {
92
+ await openModule(page);
93
+ if (!workflowData) {
94
+ throw new Error('Delete 示例依赖 Create 先生成 workflowData');
95
+ }
96
+ const crudPage = new CrudPage(page, crudSlots);
97
+ await crudPage.runDeleteWorkflow(workflowData);
98
+ });
99
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @Description:
3
+ * @Author: taurus
4
+ * @Date: 2026/06/05 10:02
5
+ */
6
+
7
+ import type { Page } from '@playwright/test';
8
+
9
+ export type LocatorContext = Pick<Page, 'locator'>;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @Description: 数据导出组件
3
+ * @Author: taurus
4
+ * @Date: 2026/06/08 15:10
5
+ */
6
+
7
+
8
+ import type { Locator } from '@playwright/test';
9
+ import { log } from '@epoint-testtech/stage-core/utils';
10
+ import { LocatorContext } from './anchor-types';
11
+
12
+ /**
13
+ * @Description: MiniUI 风格的通用数据导出组件
14
+ * 适用于包含“导出全部”、“导出当前页”、“从第 X 页导出到第 Y 页”的通用导出弹窗
15
+ * * ====================================================================================
16
+ * 📌 【组件库元素变量命名规范 (统一语法糖)】
17
+ * 格式模板:repo + [元素类型缩写] + [中文业务性质名称]
18
+ * * 1. 前缀固定为 "repo":代表 Repository(对象库/定位器仓库)的缩写。
19
+ * 2. 元素类型缩写(大驼峰):根据 UI 元素的实际物理类型或组件类型进行精简:
20
+ * - 输入框 -> Input
21
+ * - 按钮 -> Btn (Button 缩写)
22
+ * - 单选框 -> RadioBtn (RadioButton 缩写)
23
+ * - 容器/锚点 -> Anchor
24
+ * 3. 中文业务性质名称:直接提取页面元素的 Label 文本,或总结成具体的业务描述中文,极具辨识度。
25
+ * * 示例:
26
+ * - repoInput导出开始页码 -> repo(仓库) + Input(输入框) + 导出开始页码(业务名称)
27
+ * - repoRadioBtn导出全部 -> repo(仓库) + RadioBtn(单选框) + 导出全部(业务名称)
28
+ * - repoBtn导出 -> repo(仓库) + Btn(按钮) + 导出(业务名称)
29
+ * ====================================================================================
30
+ */
31
+ export class DataExportComponent {
32
+
33
+ // -- 层1:静态定位器字段(严格遵循统一语法糖命名规范)
34
+ private readonly repoAnchor: Locator;
35
+
36
+ // 单选框定位器
37
+ private readonly repoRadioBtn导出全部: Locator;
38
+ private readonly repoRadioBtn导出当前页: Locator;
39
+ private readonly repoRadioBtn按页码导出: Locator;
40
+
41
+ // 输入框定位器
42
+ private readonly repoInput导出开始页码: Locator;
43
+ private readonly repoInput导出结束页码: Locator;
44
+
45
+ // 动作按钮定位器
46
+ private readonly repoBtn导出: Locator;
47
+
48
+ constructor(
49
+ private readonly context: LocatorContext,
50
+ private readonly label: string
51
+ ) {
52
+ // 1. 组件大容器锚点
53
+ this.repoAnchor = context.locator('div.mini-dataexport-popup, div.mini-popup').filter({ visible: true });
54
+
55
+ // 2. 单选框选项行定位
56
+ this.repoRadioBtn导出全部 = this.repoAnchor
57
+ .locator('div.mini-radiobuttonlist-item')
58
+ .filter({ hasText: /^导出全部$/ });
59
+
60
+ this.repoRadioBtn导出当前页 = this.repoAnchor
61
+ .locator('div.mini-radiobuttonlist-item')
62
+ .filter({ hasText: /^导出当前页$/ });
63
+
64
+ // 核心:定位到包含页码输入框的这一行单选容器(利用文本锁定行,彻底干掉动态ID)
65
+ const scopePageRow = this.repoAnchor
66
+ .locator('div.mini-radiobuttonlist-item')
67
+ .filter({ hasText: '从第' });
68
+
69
+ this.repoRadioBtn按页码导出 = scopePageRow;
70
+
71
+ // 3. 输入框定位(在锁定的 scopePageRow 范围内,按顺序抓取输入框)
72
+ // .first() 匹配第一个 input.mini-export-num (开始页)
73
+ this.repoInput导出开始页码 = scopePageRow.locator('input.mini-export-num').first();
74
+ // .last() 匹配第二个 input.mini-export-num (结束页)
75
+ this.repoInput导出结束页码 = scopePageRow.locator('input.mini-export-num').last();
76
+
77
+ /*
78
+ * 4. 动作按钮定位器多类名设计说明
79
+ * * 这里利用了 Playwright 的多类名 CSS 定位技巧。在编写组件库时,为了应对不同系统的微小差异,有以下三种定位器的类名处理方式:
80
+ * * ① “或” 关系(OR)—— 匹配任意一个类
81
+ * - 写法:用逗号 `,` 分隔。
82
+ * - 示例:context.locator('.mini-button, .mini-export-btn')
83
+ * - 原理:它会同时匹配到 <a class="mini-button"> 或者 <button class="mini-export-btn">。
84
+ * - 场景:组件库中做多系统兼容。比如系统 A 的导出按钮叫 mini-button,系统 B 叫 mini-export-btn,用逗号就能一体化兼容。
85
+ * * ② “且” 关系(AND)—— 必须同时具备这些类
86
+ * - 写法:多个类名紧挨着写在一起,中间不要有空格和逗号。
87
+ * - 示例:context.locator('.mini-button.mini-export-btn')
88
+ * - 原理:只会匹配同时包含这两个类的元素,如:<a class="mini-button mini-export-btn">...</a>。若仅带有一个类则无法匹配。
89
+ * - 场景:精准过滤。当页面上有普通按钮 and 导出按钮时,定位出那个“既是普通按钮又是专门负责导出的”目标。
90
+ * * ③ “包含” 关系(模糊匹配)—— 匹配类名的一部分
91
+ * - 写法:使用 CSS 的属性选择器 [class*="xxx"]。
92
+ * - 示例:context.locator('[class*="mini-button"]')
93
+ * - 原理:只要 class 属性里包含了 mini-button 字符串(如 mini-button-textOnly、mini-button-state-primary)就能被匹配。
94
+ * - 场景:对付类名非常长、且带有动态后缀的前端框架(如 MiniUI、ElementUI)。
95
+ * * 💡 结合当前按钮的实际定位应用:
96
+ * 我们采用“或(OR)”的最高兼容性写法抓取所有具备按钮特征的外壳,再配合 .filter({ hasText: /^导出$/ })
97
+ * 精准过滤出内部包裹着“导出”文本的那一个外壳,从而完美适配各种系统的按钮节点。
98
+ */
99
+ this.repoBtn导出 = this.repoAnchor
100
+ .locator('.mini-button, .mini-export-btn')
101
+ .filter({ hasText: /^导出$/ })
102
+ .first();
103
+ }
104
+
105
+ /**
106
+ * 选择“导出全部”并点击导出
107
+ */
108
+ async exportAll() {
109
+ log.info(`[DataExportComponent] [${this.label}] 准备导出全部数据...`);
110
+ await this.repoRadioBtn导出全部.click();
111
+ await this.repoBtn导出.click();
112
+ }
113
+
114
+ /**
115
+ * 选择“导出当前页”并点击导出
116
+ */
117
+ async exportCurrentPage() {
118
+ log.info(`[DataExportComponent] [${this.label}] 准备导出当前页数据...`);
119
+ await this.repoRadioBtn导出当前页.click();
120
+ await this.repoBtn导出.click();
121
+ }
122
+
123
+ /**
124
+ * 选择“按自定义页码范围”并点击导出
125
+ * @param startPage 开始页码
126
+ * @param endPage 结束页码
127
+ */
128
+ async exportByPageRange(startPage: number, endPage: number) {
129
+ log.info(`[DataExportComponent] [${this.label}] 准备按页码范围导出: 第 ${startPage} 页 到 第 ${endPage} 页`);
130
+
131
+ // 1. 先点击该行,确保激活“按页码”的单选状态
132
+ await this.repoRadioBtn按页码导出.click();
133
+
134
+ // 2. 填充开始页码与结束页码
135
+ await this.repoInput导出开始页码.fill(startPage.toString());
136
+ await this.repoInput导出结束页码.fill(endPage.toString());
137
+
138
+ // 3. 点击最终的导出按钮
139
+ await this.repoBtn导出.click();
140
+
141
+ log.info(`[DataExportComponent] [${this.label}] 导出指令已触发`);
142
+ }
143
+ }
@@ -0,0 +1,2 @@
1
+ export { ListBoxComponent } from './listbox-component';
2
+ export { DataExportComponent } from './dataexport-component';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @Description:
3
+ * @Author: taurus
4
+ * @Date: 2026/06/04 17:06
5
+ */
6
+
7
+ import type { Locator, Page } from '@playwright/test';
8
+ import { log } from '@epoint-testtech/stage-core/utils';
9
+ import { LocatorContext } from './anchor-types';
10
+
11
+ /***
12
+ * @Description: 下拉列表组件
13
+ */
14
+ export class ListBoxComponent {
15
+
16
+ // -- 层1:静态定位器字段(repo* 前缀)
17
+ private readonly repo打开按钮: Locator;
18
+ private readonly repo下拉列表容器: Locator;
19
+
20
+ constructor(
21
+ private readonly context: LocatorContext,
22
+ private readonly label: string
23
+ ) {
24
+ this.repo打开按钮 = context.locator(`//div[@label="${label}"]//span[@name="trigger"]`).filter({ visible: true });
25
+ this.repo下拉列表容器 = context.locator('//table[contains(@class, "mini-listbox-items")]').filter({ visible: true });
26
+ }
27
+
28
+ // --层2:动态定位器工厂(locate* 前缀)
29
+ private locateOption(itemName: string): Locator {
30
+ return this.repo下拉列表容器.locator(`xpath=.//td[text()="${itemName}"]`);
31
+ }
32
+
33
+ // -- 层3:公开动作方法(selectItem 等)
34
+ async selectItem(itemName: string) {
35
+ await this.repo打开按钮.click();
36
+ await this.locateOption(itemName).first().waitFor({ state: 'visible' });
37
+ await this.locateOption(itemName).first().click();
38
+
39
+ log.info(`[ListBox] 标签为 "${this.label}" 的组件成功选择项: ${itemName}`);
40
+ }
41
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "types": ["node"]
10
+ },
11
+ "include": ["src/**/*.ts"]
12
+ }