├── .gitignore ├── LICENSE ├── README.md ├── data-factory └── project.ts ├── package-lock.json ├── package.json ├── page-object-model ├── DashboardPage.ts ├── Locators.ts ├── LoginPage.ts ├── MyProject.ts ├── PageComponent.ts ├── PopupDetailPage.ts └── Table.ts ├── playwright.config.ts ├── tests ├── BaseTest.ts ├── PagesInstance.ts └── example.spec.ts └── tools ├── ajaxHooker.js ├── apiObserver.js └── domObserver.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test-results/ 3 | /playwright-report/ 4 | /playwright/.cache/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 wityue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 项目名称 2 | 3 | 基于playwright-test实现的Page Object Demo 4 | 5 | ## 说明 6 | 7 | 主要实现了: 8 | 1.多用户交互,使用用例级别的context fixture,确保每个用户执行用例时都有独立的context环境。 9 | 2.监听HTTP请求,捕获接口异常报错,确保接口的稳定性和测试的可靠性。 10 | 3.封装表单和表格填写方法,通过{key1: value1, key2: value2}的方式快速操作。 11 | 4.集成dataclass,进行测试数据管理,与表单和表格的填写方法结合,使测试更轻松. 12 | 5.注入javascript脚本,监听xhr以及fetch类型API请求,仅当无API正在请求时,放开web界面操作,过滤规则在tools/apiObserver.js中进行配置. 13 | 6.注入javascript脚本,对新出现的button,input等按钮disble,当无API正在请求时,恢复disable元素状态. 14 | 7.注入javascript脚本,全局监听指定元素出现,如果出现,则将其style.display属性设置为none 15 | 16 | ## 安装 && 测试 17 | 18 | npm install 19 | npx playwright test 20 | 21 | 具体操作可至playwright官网学习: 22 | 23 | ## 测试报告 24 | 25 | ### 如使用playwright html-reporter,以下两处源码可根据自身情况修改 26 | 27 | 1.html-reporter video附件重命名后,无法在video页签下展示,如不重命名,则只能通过观看video内容确定是哪个用户在操作,可修改以下源码解决: 28 | 路径:node_modules/playwright-core/lib/vite/htmlReport 29 | 查找内容: 30 | ==="video" 31 | 将第一个匹配项修改为 32 | contentType.startsWith("video/") 33 | 34 | 2.如需html测试报告中隐藏某些步骤,可在test.step描述步骤名称时,加特定前缀,如"DeleteFromTheHtmlreport",然后修改以下源码解决: 35 | 路径:```node_modules/@playwright/test/lib/reporters/html.js``` 36 | 方法名:_createTestResult 37 | ```steps: result.steps.map(s => this._createTestStep(s)),``` 38 | 修改为: 39 | ```steps: result.steps.filter(s => !s.title.startsWith('DeleteFromTheHtmlreport')).map(s => this._createTestStep(s)),``` 40 | 如无此文件,则修改路径```node_modules/playwright/lib/reporters/html.js``` 41 | 方法名:_createTestResult 42 | ```steps: dedupeSteps(result.steps).map(s => this._createTestStep(s)),``` 43 | 修改为: 44 | ```steps: dedupeSteps(result.steps).filter(s => !s.step.title.startsWith('DeleteFromTheHtmlreport')).map(s => this._createTestStep(s)),``` 45 | ### 也可参照playwright官方文档,使用第三方报告 46 | 47 | 48 | 49 | ## 项目结构说明 50 | 51 | ### page-object-model 52 | 53 | #### PageCompoment.ts 包含:页面基础类PageCompoment和通用定位器类Locators 54 | 55 | Locators内通用的定位器可根据项目自行适配。 56 | PageCompoment内的fillForm和fillTable方法也需要自行实现,目前方法相当于伪代码,提供了实现的思路。 57 | fillTable可通过Table类获取cellLocator后直接调用fillForm进行填写操作。 58 | 59 | #### table.ts 获取表格cellLocator 60 | 61 | table在Locators内定义,需要根据项目情况自行适配。 62 | 获取到cellLocator后,可使用playwright Locator的所有方法进行操作。 63 | 64 | #### 其他Page对象,继承PageCompoment,实现Page内方法 65 | 66 | ### data-factory 67 | 68 | dataclass 官方文档: 69 | 继承dataclass编写数据类,每个表单可作为一个单独的数据类进行定义。 70 | 71 | ### tests 72 | 73 | #### PagesInstance.ts 74 | 75 | 在此页进行page对象的实例化,然后BaseTest.ts用户fixture内对此类进行实例,以达到每个用户都可操作所有Page的目的。 76 | 77 | #### BaseTest.ts 78 | 79 | 包含Context的创建和关闭方法,以及继承playwright test编写的各个用户fixture信息,其他用例从此文件import test即可进行用例编写,编写方式可至playwright官方文档学习。 80 | 81 | ### tools 82 | 83 | #### ajaxHooker.js 84 | 85 | 引用cxxjackie脚本,增加API请求计数,感谢原作者,原文链接: 86 | 87 | #### apiObserver.js 88 | 89 | 调用ajaxHooker方法,实现当有API请求(XHR和Fetch类型)时,增加蒙层,禁止操作,当前无API正在请求且上一API请求结束80ms以上,删除蒙层,恢复操作. 90 | 接口异常规则也在此文件中进行维护. 91 | 92 | ##### 某些情况下,添加蒙层会与hover操作冲突,如hover有发起网络请求的动作,会导致hover效果丢失 93 | 94 | 此时可执行以下命令在hover前停用蒙层 95 | 96 | ```await page.page.evaluate("window.maskTag=0")``` 97 | 98 | 同样的,hover完成之后通过以下命令启用蒙层 99 | ```await page.page.evaluate("window.maskTag=1")``` 100 | 也可通过调用PagesInstance内temporarilyDisableMask方法禁用再启用,调用方式如下: 101 | 102 | ``` 103 | await temporarilyDisableMask(async () => { 104 | //your code here 105 | }); 106 | ``` 107 | 108 | ##### 异常测试时,可能触发接口监听误告警 109 | 110 | 需执行以下命令临时禁止接口监听 111 | ```await page.page.evaluate("window.maskTag=0")``` 112 | 同样的,异常测试步骤完成后立即启用 113 | ```await page.page.evaluate("window.maskTag=1")``` 114 | 也可通过调用PagesInstance内temporarilyDisableDialog方法禁用再启用,调用方式如下: 115 | 116 | ``` 117 | await temporarilyDisableDialog(async () => { 118 | //your code here 119 | }); 120 | ``` 121 | 122 | #### domObserver.js 123 | 124 | 监听dom变化,对新出现的button及input等元素进行disable及enable操作. 125 | 全局监听指定元素出现并将其隐藏的列表为此文件中```listenElementsClassName```变量,可在其中增加自己想隐藏的元素,若无此需求,可将此列表清空. 126 | -------------------------------------------------------------------------------- /data-factory/project.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "dataclass"; 2 | 3 | /** 4 | * 项目信息类 5 | * @property {string} 项目名称 - 项目名称,默认值为 "ProjectA" 6 | * @property {string} 项目编码 - 项目编码 7 | * @property {string} [项目类型] - 项目类型 8 | */ 9 | export class Project extends Data { 10 | 项目名称 = "ProjectA"; 11 | 项目编码: string; 12 | 项目类型?: string; 13 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playwright-e2e", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "playwright-e2e", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "jsencrypt": "^3.3.1" 13 | }, 14 | "devDependencies": { 15 | "@playwright/test": "^1.30.0" 16 | } 17 | }, 18 | "node_modules/@playwright/test": { 19 | "version": "1.30.0", 20 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.30.0.tgz", 21 | "integrity": "sha512-SVxkQw1xvn/Wk/EvBnqWIq6NLo1AppwbYOjNLmyU0R1RoQ3rLEBtmjTnElcnz8VEtn11fptj1ECxK0tgURhajw==", 22 | "dev": true, 23 | "dependencies": { 24 | "@types/node": "*", 25 | "playwright-core": "1.30.0" 26 | }, 27 | "bin": { 28 | "playwright": "cli.js" 29 | }, 30 | "engines": { 31 | "node": ">=14" 32 | } 33 | }, 34 | "node_modules/@types/node": { 35 | "version": "18.14.0", 36 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.0.tgz", 37 | "integrity": "sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A==", 38 | "dev": true 39 | }, 40 | "node_modules/jsencrypt": { 41 | "version": "3.3.1", 42 | "resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.3.1.tgz", 43 | "integrity": "sha512-dVvV54GdFuJgmEKn+oBiaifDMen4p6o6j/lJh0OVMcouME8sST0bJ7bldIgKBQk4za0zyGn0/pm4vOznR25mLw==" 44 | }, 45 | "node_modules/playwright-core": { 46 | "version": "1.30.0", 47 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.30.0.tgz", 48 | "integrity": "sha512-7AnRmTCf+GVYhHbLJsGUtskWTE33SwMZkybJ0v6rqR1boxq2x36U7p1vDRV7HO2IwTZgmycracLxPEJI49wu4g==", 49 | "dev": true, 50 | "bin": { 51 | "playwright": "cli.js" 52 | }, 53 | "engines": { 54 | "node": ">=14" 55 | } 56 | } 57 | }, 58 | "dependencies": { 59 | "@playwright/test": { 60 | "version": "1.30.0", 61 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.30.0.tgz", 62 | "integrity": "sha512-SVxkQw1xvn/Wk/EvBnqWIq6NLo1AppwbYOjNLmyU0R1RoQ3rLEBtmjTnElcnz8VEtn11fptj1ECxK0tgURhajw==", 63 | "dev": true, 64 | "requires": { 65 | "@types/node": "*", 66 | "playwright-core": "1.30.0" 67 | } 68 | }, 69 | "@types/node": { 70 | "version": "18.14.0", 71 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.0.tgz", 72 | "integrity": "sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A==", 73 | "dev": true 74 | }, 75 | "jsencrypt": { 76 | "version": "3.3.1", 77 | "resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.3.1.tgz", 78 | "integrity": "sha512-dVvV54GdFuJgmEKn+oBiaifDMen4p6o6j/lJh0OVMcouME8sST0bJ7bldIgKBQk4za0zyGn0/pm4vOznR25mLw==" 79 | }, 80 | "playwright-core": { 81 | "version": "1.30.0", 82 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.30.0.tgz", 83 | "integrity": "sha512-7AnRmTCf+GVYhHbLJsGUtskWTE33SwMZkybJ0v6rqR1boxq2x36U7p1vDRV7HO2IwTZgmycracLxPEJI49wu4g==", 84 | "dev": true 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playwright-e2e", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "keywords": [], 8 | "author": "zhiyong", 9 | "license": "ISC", 10 | "devDependencies": { 11 | "@playwright/test": "^1.34.3", 12 | "@types/node": "^20.9.0", 13 | "@typescript-eslint/eslint-plugin": "^5.59.2", 14 | "@typescript-eslint/parser": "^5.59.2", 15 | "eslint": "^8.40.0", 16 | "eslint-plugin-react": "^7.32.2" 17 | }, 18 | "dependencies": { 19 | "dataclass": "^2.1.1", 20 | "jsencrypt": "^3.3.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /page-object-model/DashboardPage.ts: -------------------------------------------------------------------------------- 1 | import PageComponent from "./PageComponent"; 2 | 3 | export default class DashboardPage extends PageComponent { 4 | async waitForMe() { 5 | await this.page 6 | .getByText("我的项目", { exact: true }) 7 | .waitFor({ state: "visible" }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /page-object-model/Locators.ts: -------------------------------------------------------------------------------- 1 | import type { Page, Locator } from "playwright-core"; 2 | 3 | export class Locators { 4 | page: Page; 5 | 6 | constructor(page: Page) { 7 | this.page = page; 8 | } 9 | 10 | get table(): Locator { 11 | return this.onlyVisible( 12 | this.page.locator('//div[contains(@class,"singleTable")]'), 13 | true 14 | ); 15 | } 16 | 17 | button(name: string, onlyVisible = true): Locator { 18 | let buttonLocator = this.page.locator(`button`); 19 | // name中间不包含空格且为中文时,字符间加入空格,以使用正则匹配 20 | if (!name.includes(" ") && /^[\u4e00-\u9fa5]+$/.test(name)) { 21 | const regex = new RegExp(name.split("").join(".*"), "i"); 22 | buttonLocator = buttonLocator.filter({ 23 | has: this.page.getByText(regex, { exact: true }), 24 | }); 25 | } else { 26 | buttonLocator = buttonLocator.filter({ 27 | has: this.page.getByText(name, { exact: true }), 28 | }); 29 | } 30 | return this.onlyVisible(buttonLocator, onlyVisible); 31 | } 32 | 33 | /** 34 | * lable后的第一个元素,用于定位字段后的输入框或只读文本 35 | * @param name label标签的文本 36 | * @param nth 第几个label标签 37 | * @returns label后的第一个元素 38 | */ 39 | locatorFollowingLabel(name: string, nth = -1, onlyVisible = true): Locator { 40 | const regex = new RegExp(`^\\s*${name}\\s*$`, "i"); 41 | const locator = this.page 42 | .locator("label") 43 | .filter({ has: this.page.getByText(regex, { exact: true }) }) 44 | .nth(nth) 45 | .locator("xpath=/following::*[position()=1]"); 46 | return this.onlyVisible(locator, onlyVisible); 47 | } 48 | 49 | /** 50 | * 通过label标签获取input或textarea元素 51 | * @param name label标签的文本 52 | * @param nth 第几个label标签 53 | * @returns input或textarea元素的定位器 54 | */ 55 | input(name: string, nth = -1, onlyVisible = true): Locator { 56 | return this.locatorFollowingLabel(name, nth, onlyVisible).locator( 57 | "input,textarea" 58 | ); 59 | } 60 | 61 | /** 62 | * 获取包含'select'的元素的定位器 63 | * 项目可根据实际情况修改此定位器 64 | * @returns 元素的定位器 65 | */ 66 | get hasSelect(): Locator { 67 | return this.page.locator("//*[contains(class, 'select')]"); 68 | } 69 | 70 | /** 71 | * 获取包含'cascader'的元素的定位器 72 | * 项目可根据实际情况修改此定位器 73 | * @returns 元素的定位器 74 | */ 75 | get hasCascader(): Locator { 76 | return this.page.locator("//*[contains(class, 'cascader')]"); 77 | } 78 | 79 | get selectOptions(): Locator { 80 | return this.page.locator( 81 | `//div[contains(@class,'select-dropdown') and not (contains(@class,'dropdown-hidden'))]` 82 | ); 83 | } 84 | 85 | get modal(): Locator { 86 | return this.page.locator(`//div[contains(@class,"modal-content")]`); 87 | } 88 | 89 | /** 90 | * 根据visible参数过滤定位器 91 | * @param locator 元素的定位器 92 | * @param visible 是否只返回可见元素 93 | * @returns 过滤后的定位器 94 | */ 95 | onlyVisible(locator: Locator, visible?: boolean): Locator { 96 | if (visible) { 97 | return locator.filter({ has: this.page.locator("visible=true") }); 98 | } 99 | return locator; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /page-object-model/LoginPage.ts: -------------------------------------------------------------------------------- 1 | import PageComponent from "./PageComponent"; 2 | 3 | export default class LoginPage extends PageComponent { 4 | async goto() { 5 | await this.page.goto("/login"); 6 | } 7 | 8 | async 登录(账号: string, 密码: string) { 9 | await this.page.getByPlaceholder("手机号或工作邮箱").fill(账号); 10 | await this.page.getByPlaceholder("密码").fill(密码); 11 | this.page.keyboard.press("Enter"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /page-object-model/MyProject.ts: -------------------------------------------------------------------------------- 1 | import PageComponent from "./PageComponent"; 2 | 3 | export default class LoginPage extends PageComponent { 4 | async goto() { 5 | await super.goto("/projects/list/table"); 6 | } 7 | 8 | get 项目主表() { 9 | return this.table("项目编号"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /page-object-model/PageComponent.ts: -------------------------------------------------------------------------------- 1 | import type { Page, Locator } from "playwright-core"; 2 | import { test } from "@playwright/test"; 3 | import { Locators } from "./Locators"; 4 | import { Table } from "./Table"; 5 | 6 | export default class PageComponent { 7 | public page: Page; 8 | public readonly componentType: string; 9 | private _locators: Locators; 10 | constructor(page: Page) { 11 | this.page = page; 12 | this.componentType = "OCANT"; 13 | } 14 | 15 | get locators(){ 16 | if (this._locators?.page !== this.page){ 17 | this._locators = new Locators(this.page) 18 | } 19 | return new Locators(this.page) 20 | } 21 | 22 | async goto(path: string) { 23 | await this.page.goto(`main#${path}`); 24 | } 25 | 26 | public async click提交() { 27 | await this.locators.button("提交").click(); 28 | } 29 | 30 | public async click确定() { 31 | await this.locators.button("确定").click(); 32 | } 33 | 34 | public async click新建() { 35 | await this.locators.button("新建").click(); 36 | } 37 | 38 | /** 39 | * 生成表格 40 | * @param tableUniqueText 表格的唯一文本标识符 41 | * @returns 表格对象 42 | */ 43 | public table(tableUniqueText: string) { 44 | return new Table(this.page, tableUniqueText); 45 | } 46 | 47 | /** 48 | * 等待动画结束 49 | * @param locator 元素的定位器 50 | */ 51 | public async waitForAnimationEnd(locator: Locator) { 52 | await locator.evaluate((element) => 53 | Promise.all( 54 | element.getAnimations().map((animation) => animation.finished) 55 | ) 56 | ); 57 | } 58 | 59 | /** 60 | * 填写表单 61 | * @param fields 字段名和值的映射 62 | */ 63 | public async fillForm( 64 | fields: Map> 65 | ) { 66 | for (const [field, value] of Object.entries(fields)) { 67 | await test.step(`fill ${value} to ${field}`, async () => { 68 | let inputAncestors: Locator; 69 | if (typeof field === "string") { 70 | inputAncestors = this.locators.locatorFollowingLabel(field); 71 | } else { 72 | inputAncestors = field; 73 | } 74 | if (!inputAncestors) { 75 | throw new Error(`Input field "${field}" not found`); 76 | } 77 | await this.waitForAnimationEnd(inputAncestors); 78 | switch (this.componentType) { 79 | case "OCANT": 80 | await this.fillOcAntForm(inputAncestors, value); 81 | break; 82 | case "C7N": 83 | await this.fillC7nForm(inputAncestors, value); 84 | break; 85 | case "ANT": 86 | // Implement ANT specific logic here 87 | break; 88 | default: 89 | throw new Error( 90 | `Unsupported component type "${this.componentType}"` 91 | ); 92 | } 93 | }); 94 | continue; 95 | } 96 | } 97 | 98 | public async fillTable() { 99 | // Implement fillTable() method here 100 | } 101 | 102 | private async fillOcAntForm( 103 | inputAncestors: Locator, 104 | value: string | null | Array 105 | ) { 106 | const input = inputAncestors.locator("input,textarea"); 107 | const isSelect = await inputAncestors 108 | .locator(this.locators.hasSelect) 109 | .count(); 110 | if (isSelect) { 111 | // Implement select method here 112 | return; 113 | } 114 | const isCascader = await inputAncestors 115 | .locator(this.locators.hasCascader) 116 | .count(); 117 | if (isCascader) { 118 | // Implement cascader method here 119 | return; 120 | } 121 | await input.fill(value as string); 122 | } 123 | 124 | private async fillC7nForm(input: Locator, value: string | null) { 125 | const inputType = await input.getAttribute("type"); 126 | const modalCount = await this.locators.modal.count(); 127 | input.click(); 128 | switch (inputType) { 129 | case "search": 130 | await this.locators.selectOptions 131 | .or(this.locators.modal.nth(modalCount + 1)) 132 | .waitFor({ state: "visible" }); 133 | if (await this.locators.selectOptions.count()) { 134 | if (value === null) { 135 | await input.selectOption({ index: 0 }); 136 | } else { 137 | await input.fill(value); 138 | await input.selectOption({ label: value }); 139 | } 140 | } else { 141 | break; 142 | } 143 | break; 144 | case "text": 145 | await input.fill(value ?? ""); 146 | break; 147 | default: 148 | throw new Error(`Unsupported input type "${inputType}"`); 149 | } 150 | } 151 | 152 | public async waitForNetworkIdle(options?: { timeout: number }) { 153 | // 存初次lastResponseEndTime,当循环3次lastResponseEndTime无变化时,认定页面稳定,退出循环 154 | const tempTime = await this.page.evaluate(() => window.lastResponseEndTime); 155 | let count = 3 156 | const startTime = Date.now(); 157 | const { timeout = 30000 } = options || {}; 158 | await test.step("waitForNetworkIdle", async () => { 159 | while (Date.now() - startTime < timeout && count) { 160 | const apiCounter = await this.page.evaluate(() => window.apiCounter); 161 | const lastResponseEndTime = await this.page.evaluate( 162 | () => window.lastResponseEndTime 163 | ); 164 | if (!apiCounter && Date.now() - lastResponseEndTime >= 500) { 165 | if (lastResponseEndTime !== tempTime) { 166 | return; 167 | } else { 168 | count--; 169 | } 170 | } 171 | await new Promise((resolve) => setTimeout(resolve, 500)); 172 | } 173 | }); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /page-object-model/PopupDetailPage.ts: -------------------------------------------------------------------------------- 1 | import PageComponent from "./PageComponent"; 2 | 3 | /** 4 | * 一般为点击其他页面连接跳转至此详情页面,且此详情页面无法跳转至其他页面 5 | */ 6 | 7 | export default class PopupDetailPage extends PageComponent { 8 | async fillFormInThePage() { 9 | // await this.page.waitForSelector('"工作台"') 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /page-object-model/Table.ts: -------------------------------------------------------------------------------- 1 | import type { Page, Locator } from "playwright-core"; 2 | import { test } from "@playwright/test"; 3 | import { Locators } from "./Locators"; 4 | 5 | export class Table { 6 | private locators: Locators; 7 | private readonly tableLocator: Locator; 8 | 9 | /** 10 | * 构造函数 11 | * @param page 页面对象 12 | * @param tableUniqueText 表格唯一文本,可以接收一组文本。 13 | */ 14 | constructor(private page: Page, private tableUniqueText: string | string[]) { 15 | this.locators = new Locators(page); 16 | if (typeof tableUniqueText === "string") { 17 | this.tableLocator = this.locators.table.filter({ 18 | hasText: `${this.tableUniqueText}`, 19 | }); 20 | } else { 21 | this.tableLocator = tableUniqueText.reduce( 22 | (acc, text) => acc.filter({ hasText: text }), 23 | this.locators.table 24 | ); 25 | } 26 | } 27 | 28 | private async getTableHeaders(): Promise { 29 | return await test.step( 30 | "Query table headers and return a list", 31 | async () => { 32 | await this.tableLocator.waitFor({ state: "visible" }); 33 | const headers = await this.tableLocator.locator("thead tr th").all(); 34 | return Promise.all(headers.map((header) => header.innerText())); 35 | } 36 | ); 37 | } 38 | 39 | private get getVisibleRow(){ 40 | return this.tableLocator 41 | .locator("tbody tr") 42 | .locator("visible=true") 43 | } 44 | 45 | private getRowLocator(row: string | number): Locator { 46 | if (typeof row === "string") { 47 | return this.tableLocator 48 | .locator(`tbody tr`) 49 | .filter({ has: this.page.getByText(`${row}`, { exact: true }) }); 50 | } else { 51 | return this.getVisibleRow.nth(row - 1); 52 | } 53 | } 54 | 55 | private async getColumnIndex(col: string | number): Promise { 56 | if (typeof col === "string") { 57 | const headerTexts = await this.getTableHeaders(); 58 | return headerTexts.indexOf(col); 59 | } else { 60 | return col; 61 | } 62 | } 63 | 64 | /** 65 | * 根据行和列的索引或文本,返回单元格的元素定位器 66 | * @param row 行索引或文本 67 | * @param col 列索引或文本 68 | * @returns 返回单元格的元素定位器 69 | */ 70 | public async getCellLocator( 71 | row: string | number, 72 | col: string | number 73 | ): Promise { 74 | const rowLocator = this.getRowLocator(row); 75 | const colIndex = await this.getColumnIndex(col); 76 | return rowLocator.locator(`td:nth-child(${colIndex + 1})`); 77 | } 78 | 79 | /** 80 | * 获取表格一列元素的text并返回list 81 | * @param col 列索引或文本 82 | * @returns 返回单元格的text列表 83 | */ 84 | public async getColumnTexts(col: string | number): Promise { 85 | const colIndex = await this.getColumnIndex(col); 86 | const column = await this.getVisibleRow.locator(`td:nth-child(${colIndex + 1})`).all(); 87 | return Promise.all(column.map((cell) => cell.innerText())); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests', 14 | /* Maximum time one test can run for. */ 15 | timeout: 180 * 1000, 16 | 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 1, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: 'html', 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 30 * 1000, 38 | /* Timeout for each navigation action in milliseconds.*/ 39 | navigationTimeout: 60 * 1000, 40 | 41 | headless: false, 42 | /* Base URL to use in actions like `await page.goto('/')`. */ 43 | baseURL: 'https://demo.cloudlong.cn/', 44 | 45 | /* custome test id. */ 46 | testIdAttribute: "data-tester-id", 47 | 48 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 49 | trace: 'on-first-retry', 50 | video: 'on-first-retry' 51 | }, 52 | 53 | /* Configure projects for major browsers */ 54 | projects: [ 55 | { 56 | name: 'chromium', 57 | use: { ...devices['Desktop Chrome'] }, 58 | }, 59 | 60 | // { 61 | // name: 'firefox', 62 | // use: { ...devices['Desktop Firefox'] }, 63 | // }, 64 | 65 | // { 66 | // name: 'webkit', 67 | // use: { ...devices['Desktop Safari'] }, 68 | // }, 69 | 70 | /* Test against mobile viewports. */ 71 | // { 72 | // name: 'Mobile Chrome', 73 | // use: { ...devices['Pixel 5'] }, 74 | // }, 75 | // { 76 | // name: 'Mobile Safari', 77 | // use: { ...devices['iPhone 12'] }, 78 | // }, 79 | 80 | /* Test against branded browsers. */ 81 | // { 82 | // name: 'Microsoft Edge', 83 | // use: { channel: 'msedge' }, 84 | // }, 85 | // { 86 | // name: 'Google Chrome', 87 | // use: { channel: 'chrome' }, 88 | // }, 89 | ], 90 | 91 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 92 | // outputDir: 'test-results/', 93 | 94 | /* Run your local dev server before starting the tests */ 95 | // webServer: { 96 | // command: 'npm run start', 97 | // port: 3000, 98 | // }, 99 | }); 100 | -------------------------------------------------------------------------------- /tests/BaseTest.ts: -------------------------------------------------------------------------------- 1 | import { test as base, expect, TestInfo } from "@playwright/test"; 2 | import type { 3 | Browser, 4 | BrowserContext, 5 | Page, 6 | } from "playwright-core"; 7 | import PagesInstance from "./PagesInstance"; 8 | 9 | declare global { 10 | interface Window { 11 | apiCounter: number; 12 | lastResponseEndTime: number; 13 | } 14 | } 15 | 16 | async function newContext( 17 | browser: Browser, 18 | testInfo: TestInfo, 19 | 账户名 = "未指定用户" 20 | ): Promise<{ 21 | context: BrowserContext; 22 | close: (testInfo: TestInfo) => Promise; 23 | }> { 24 | let pages: Page[] = []; 25 | const video = testInfo.project.use.video; 26 | const videoMode = normalizeVideoMode(video); 27 | const captureVideo = shouldCaptureVideo(videoMode, testInfo); 28 | const videoOptions = captureVideo 29 | ? { 30 | recordVideo: { 31 | dir: testInfo.outputDir, 32 | size: typeof video === "string" ? undefined : video?.size, 33 | }, 34 | } 35 | : {}; 36 | const context = await browser.newContext(videoOptions); 37 | context.on("dialog", async (dialog) => { 38 | const message = dialog.message(); 39 | await dialog.accept(); 40 | expect.soft(message, { message: "API报错:" + message }).not.toContain("failInfo") 41 | }) 42 | context.on("page", (page) => pages.push(page)); 43 | 44 | // 使用MutationObserver监听DOM变化,结合PerformanceObserver获取最后一次响应的返回时间,以达到loading时禁止点击输入等操作. 45 | await context.addInitScript({ 46 | path: __dirname + "/../tools/ajaxHooker.js", 47 | }); 48 | await context.addInitScript({ 49 | path: __dirname + "/../tools/apiObserver.js", 50 | }); 51 | await context.addInitScript({ 52 | path: __dirname + "/../tools/domObserver.js", 53 | }); 54 | 55 | async function close(testInfo: TestInfo): Promise { 56 | await context.close(); 57 | const testFailed = testInfo.status !== testInfo.expectedStatus; 58 | const preserveVideo = 59 | captureVideo && 60 | (videoMode === "on" || 61 | (testFailed && videoMode === "retain-on-failure") || 62 | (videoMode === "on-first-retry" && testInfo.retry === 1)); 63 | let counter = 0; 64 | if (preserveVideo) { 65 | await Promise.all( 66 | pages.map(async (page: Page) => { 67 | try { 68 | const savedPath = testInfo.outputPath( 69 | `${账户名}${counter ? "-" + counter : ""}.webm` 70 | ); 71 | ++counter; 72 | await page.video()?.saveAs(savedPath); 73 | await page.video()?.delete(); 74 | testInfo.attachments.push({ 75 | name: 账户名, 76 | path: savedPath, 77 | contentType: "video/webm", 78 | }); 79 | } catch (e) { 80 | // Silent catch empty videos. 81 | } 82 | }) 83 | ); 84 | } 85 | } 86 | return { context, close }; 87 | } 88 | 89 | async function newPage( 90 | context: BrowserContext, 91 | 账户名 = "未指定用户" 92 | ): Promise { 93 | const page = await test.step(`${账户名}-启动Page`, async () => { 94 | return await context.newPage(); 95 | }); 96 | return page; 97 | } 98 | 99 | function normalizeVideoMode(video: any): string { 100 | if (!video) return "off"; 101 | let videoMode = typeof video === "string" ? video : video.mode; 102 | if (videoMode === "retry-with-video") videoMode = "on-first-retry"; 103 | return videoMode; 104 | } 105 | 106 | function shouldCaptureVideo(videoMode: string, testInfo: TestInfo): boolean { 107 | return ( 108 | videoMode === "on" || 109 | videoMode === "retain-on-failure" || 110 | (videoMode === "on-first-retry" && testInfo.retry === 1) 111 | ); 112 | } 113 | 114 | type Accounts = { 115 | user_1: PagesInstance; 116 | user_2: PagesInstance; 117 | manager: PagesInstance; 118 | empty: PagesInstance; 119 | }; 120 | 121 | base.beforeAll(async ({}) => { 122 | // 登录有所账号 123 | }); 124 | 125 | // Extend base test by providing "Accounts" 126 | // This new "test" can be used in multiple test files, and each of them will get the fixtures. 127 | export const test = base.extend({ 128 | user_1: async ({ browser }, use, testInfo): Promise => { 129 | const { context, close } = await newContext(browser, testInfo, "user_1"); 130 | await use(new PagesInstance(await newPage(context, "user_1"))); 131 | await close(testInfo); 132 | }, 133 | 134 | user_2: async ({ browser }, use, testInfo): Promise => { 135 | const { context, close } = await newContext(browser, testInfo, "user_2"); 136 | await use(new PagesInstance(await newPage(context, "user_2"))); 137 | await close(testInfo); 138 | }, 139 | 140 | manager: async ({ browser }, use, testInfo): Promise => { 141 | const { context, close } = await newContext(browser, testInfo, "manager"); 142 | await use(new PagesInstance(await newPage(context, "manager"))); 143 | await close(testInfo); 144 | }, 145 | 146 | // 未登录账号Page. 147 | empty: async ({ browser }, use, testInfo): Promise => { 148 | const { context, close } = await newContext(browser, testInfo); 149 | await use(new PagesInstance(await newPage(context))); 150 | await close(testInfo); 151 | }, 152 | }); 153 | 154 | export { expect } from "@playwright/test"; 155 | export { PagesInstance, newContext, newPage }; 156 | -------------------------------------------------------------------------------- /tests/PagesInstance.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from "playwright-core"; 2 | import PageComponent from "../page-object-model/PageComponent"; 3 | import LoginPage from "../page-object-model/LoginPage"; 4 | import DashboardPage from "../page-object-model/DashboardPage"; 5 | import PopupDetailPage from "../page-object-model/PopUpDetailPage"; 6 | import MyProject from "../page-object-model/MyProject"; 7 | 8 | // 所有页面实例,当第一次使用实进行实例化,当Page修改后会重新进行实例化,确定页面实例为最新Page对象 9 | export default class PagesInstance { 10 | page: Page; 11 | private defalutPage: Page; 12 | private previousPage: Page; 13 | LoginPage: LoginPage; 14 | DashboardPage: DashboardPage; 15 | PopupDetailPage: PopupDetailPage; 16 | MyProject: MyProject; 17 | protected pagesClasses: { [key: string]: any } = { 18 | LoginPage, 19 | DashboardPage, 20 | PopupDetailPage, 21 | MyProject, 22 | }; 23 | constructor(page: Page) { 24 | this.page = page; 25 | this.defalutPage = page; 26 | return new Proxy(this, { 27 | get(target, prop) { 28 | if ( 29 | typeof target[prop] === "undefined" || 30 | (target[prop] instanceof PageComponent && 31 | target[prop].page !== target.page) 32 | ) { 33 | delete target[prop]; 34 | target[prop] = new target.pagesClasses[prop as string](target.page); 35 | } 36 | return target[prop]; 37 | }, 38 | }); 39 | } 40 | 41 | // 切换页面,当不传参时,则切换至实例化类时的默认Page 42 | // 43 | // 参数: 44 | // - page: 要切换到的页面 45 | // - previousPage: 是否切换到上一个页面 46 | 47 | switchToPage(page?: Page, previousPage?: false) { 48 | if (page) { 49 | this.previousPage = page; 50 | this.page = page; 51 | } else if (previousPage) { 52 | const current_page = this.page; 53 | this.page = this.previousPage; 54 | this.previousPage = current_page; 55 | } else { 56 | this.page = this.defalutPage; 57 | } 58 | this.page.bringToFront() 59 | } 60 | 61 | // 临时停用mask,避免添加mask对playwright操作造成意外影响 62 | // 63 | // 参数: 64 | // - callback: 要执行的回调函数 65 | // 66 | // 返回值: 67 | // - Promise: 异步回调函数的返回值 68 | async temporarilyDisableMask(callback: () => Promise): Promise { 69 | await this.page.evaluate("window.maskTag=0"); 70 | try { 71 | return await callback(); 72 | } finally { 73 | await this.page.evaluate("window.maskTag=1"); 74 | } 75 | } 76 | 77 | // 临时停用dialog,避免添加异常测试时,误处发接口告警 78 | // 79 | // 参数: 80 | // - callback: 要执行的回调函数 81 | // 82 | // 返回值: 83 | // - Promise: 异步回调函数的返回值 84 | async temporarilyDisableDialog(callback: () => Promise): Promise { 85 | await this.page.evaluate("window.diaglogTag=0"); 86 | try { 87 | return await callback(); 88 | } finally { 89 | await this.page.evaluate("window.dialogTag=1"); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "./BaseTest"; 2 | import { Project } from "../data-factory/project"; 3 | 4 | test.describe("示例", () => { 5 | test.describe.configure({ retries: 2 }); 6 | test("多用户异步用例-1", async ({ manager, user_1 }) => { 7 | await test.step("登录用户", async () => { 8 | const users = [ 9 | { 10 | userPageInstance: manager, 11 | username: "manager", 12 | password: "cloud2018", 13 | }, 14 | { userPageInstance: user_1, username: "user1", password: "cloud2018" }, 15 | ]; 16 | await Promise.all( 17 | users.map(async (user) => { 18 | const { userPageInstance, username, password } = user; 19 | await userPageInstance.LoginPage.goto(); 20 | await userPageInstance.LoginPage.登录(username, password); 21 | await userPageInstance.DashboardPage.waitForMe(); 22 | }) 23 | ); 24 | }); 25 | }); 26 | 27 | test("单用户登录用例-2-此用例将失败", async ({ user_2 }) => { 28 | await test.step("登录用户", async () => { 29 | await user_2.LoginPage.goto(); 30 | await user_2.LoginPage.登录("user2", "cloud2018"); 31 | await user_2.DashboardPage.waitForMe(); 32 | }); 33 | await test.step("断言我关注的元素数量", async () => { 34 | expect(await user_2.page.getByText(`我的关注`).count()).toBe(1); 35 | }); 36 | }); 37 | 38 | test("manager查看我的项目-3", async ({ manager }) => { 39 | await test.step("登录用户", async () => { 40 | await manager.LoginPage.goto(); 41 | await manager.LoginPage.登录("manager", "cloud2018"); 42 | await manager.DashboardPage.waitForMe(); 43 | }); 44 | await test.step("进入我的项目,查看第3行项目编码", async () => { 45 | await manager.MyProject.goto(); 46 | const cell = await manager.MyProject.项目主表.getCellLocator( 47 | 3, 48 | "项目编号" 49 | ); 50 | console.log(await cell.innerText()); 51 | const project = Project.create({}); 52 | console.log(project); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /tools/ajaxHooker.js: -------------------------------------------------------------------------------- 1 | var ajaxHooker = (function () { 2 | "use strict"; 3 | const win = window.unsafeWindow || document.defaultView || window; 4 | win.apiCounter = 0; 5 | win.lastResponseEndTime = 0; 6 | const toString = Object.prototype.toString; 7 | const getDescriptor = Object.getOwnPropertyDescriptor; 8 | const hookFns = []; 9 | const realXhr = win.XMLHttpRequest; 10 | const realFetch = win.fetch; 11 | const resProto = win.Response.prototype; 12 | const xhrResponses = ["response", "responseText", "responseXML"]; 13 | const fetchResponses = ["arrayBuffer", "blob", "formData", "json", "text"]; 14 | const fetchInitProps = [ 15 | "method", 16 | "headers", 17 | "body", 18 | "mode", 19 | "credentials", 20 | "cache", 21 | "redirect", 22 | "referrer", 23 | "referrerPolicy", 24 | "integrity", 25 | "keepalive", 26 | "signal", 27 | "priority", 28 | ]; 29 | const xhrAsyncEvents = ["readystatechange", "load", "loadend"]; 30 | let filter; 31 | 32 | function emptyFn() {} 33 | 34 | function errorFn(err) { 35 | console.error(err); 36 | } 37 | 38 | function defineProp(obj, prop, getter, setter) { 39 | Object.defineProperty(obj, prop, { 40 | configurable: true, 41 | enumerable: true, 42 | get: getter, 43 | set: setter, 44 | }); 45 | } 46 | 47 | function readonly(obj, prop, value = obj[prop]) { 48 | defineProp(obj, prop, () => value, emptyFn); 49 | } 50 | 51 | function writable(obj, prop, value = obj[prop]) { 52 | Object.defineProperty(obj, prop, { 53 | configurable: true, 54 | enumerable: true, 55 | writable: true, 56 | value: value, 57 | }); 58 | } 59 | 60 | function shouldFilter(type, url, method, async) { 61 | return ( 62 | filter && 63 | !filter.find((obj) => { 64 | switch (true) { 65 | case obj.type && obj.type !== type: 66 | case toString.call(obj.url) === "[object String]" && 67 | !url.includes(obj.url): 68 | case toString.call(obj.url) === "[object RegExp]" && 69 | !obj.url.test(url): 70 | case obj.method && obj.method.toUpperCase() !== method.toUpperCase(): 71 | case "async" in obj && obj.async !== async : 72 | return false; 73 | } 74 | return true; 75 | }) 76 | ); 77 | } 78 | 79 | function parseHeaders(obj) { 80 | const headers = {}; 81 | switch (toString.call(obj)) { 82 | case "[object String]": 83 | for (const line of obj.trim().split(/[\r\n]+/)) { 84 | const parts = line.split(/\s*:\s*/); 85 | if (parts.length !== 2) continue; 86 | const lheader = parts[0].toLowerCase(); 87 | if (lheader in headers) { 88 | headers[lheader] += ", " + parts[1]; 89 | } else { 90 | headers[lheader] = parts[1]; 91 | } 92 | } 93 | return headers; 94 | case "[object Headers]": 95 | for (const [key, val] of obj) { 96 | headers[key] = val; 97 | } 98 | return headers; 99 | case "[object Object]": 100 | return { 101 | ...obj, 102 | }; 103 | default: 104 | return headers; 105 | } 106 | } 107 | class AHRequest { 108 | constructor(request) { 109 | this.request = request; 110 | this.requestClone = { 111 | ...this.request, 112 | }; 113 | this.response = {}; 114 | } 115 | waitForHookFns() { 116 | return Promise.all( 117 | hookFns.map((fn) => { 118 | try { 119 | return Promise.resolve(fn(this.request)).then(emptyFn, errorFn); 120 | } catch (err) { 121 | console.error(err); 122 | } 123 | }) 124 | ); 125 | } 126 | waitForResponseFn() { 127 | try { 128 | return Promise.resolve(this.request.response(this.response)).then( 129 | emptyFn, 130 | errorFn 131 | ); 132 | } catch (err) { 133 | console.error(err); 134 | return Promise.resolve(); 135 | } 136 | } 137 | waitForRequestKeys() { 138 | if (this.reqPromise) return this.reqPromise; 139 | const requestKeys = ["url", "method", "abort", "headers", "data"]; 140 | return (this.reqPromise = this.waitForHookFns().then(() => 141 | Promise.all( 142 | requestKeys.map((key) => 143 | Promise.resolve(this.request[key]).then( 144 | (val) => (this.request[key] = val), 145 | (e) => (this.request[key] = this.requestClone[key]) 146 | ) 147 | ) 148 | ) 149 | )); 150 | } 151 | waitForResponseKeys() { 152 | if (this.resPromise) return this.resPromise; 153 | const responseKeys = 154 | this.request.type === "xhr" ? xhrResponses : fetchResponses; 155 | return (this.resPromise = this.waitForResponseFn().then(() => 156 | Promise.all( 157 | responseKeys.map((key) => { 158 | const descriptor = getDescriptor(this.response, key); 159 | if (descriptor && "value" in descriptor) { 160 | return Promise.resolve(descriptor.value).then( 161 | (val) => (this.response[key] = val), 162 | (e) => delete this.response[key] 163 | ); 164 | } else { 165 | delete this.response[key]; 166 | } 167 | }) 168 | ) 169 | )); 170 | } 171 | } 172 | class XhrEvents { 173 | constructor() { 174 | this.events = {}; 175 | } 176 | add(type, event) { 177 | if (type.startsWith("on")) { 178 | this.events[type] = typeof event === "function" ? event : null; 179 | } else { 180 | this.events[type] = this.events[type] || new Set(); 181 | this.events[type].add(event); 182 | } 183 | } 184 | remove(type, event) { 185 | if (type.startsWith("on")) { 186 | this.events[type] = null; 187 | } else { 188 | this.events[type] && this.events[type].delete(event); 189 | } 190 | } 191 | _sIP() { 192 | this.ajaxHooker_isStopped = true; 193 | } 194 | trigger(e) { 195 | if (e.ajaxHooker_isTriggered || e.ajaxHooker_isStopped) return; 196 | e.stopImmediatePropagation = this._sIP; 197 | this.events[e.type] && 198 | this.events[e.type].forEach((fn) => { 199 | !e.ajaxHooker_isStopped && fn.call(e.target, e); 200 | }); 201 | this.events["on" + e.type] && 202 | this.events["on" + e.type].call(e.target, e); 203 | e.ajaxHooker_isTriggered = true; 204 | } 205 | clone() { 206 | const eventsClone = new XhrEvents(); 207 | for (const type in this.events) { 208 | if (type.startsWith("on")) { 209 | eventsClone.events[type] = this.events[type]; 210 | } else { 211 | eventsClone.events[type] = new Set([...this.events[type]]); 212 | } 213 | } 214 | return eventsClone; 215 | } 216 | } 217 | const xhrMethods = { 218 | readyStateChange(e) { 219 | if (e.target.readyState === 4) { 220 | e.target.dispatchEvent( 221 | new CustomEvent("ajaxHooker_responseReady", { 222 | detail: e, 223 | }) 224 | ); 225 | } else { 226 | e.target.__ajaxHooker.eventTrigger(e); 227 | } 228 | }, 229 | asyncListener(e) { 230 | e.target.__ajaxHooker.eventTrigger(e); 231 | }, 232 | setRequestHeader(header, value) { 233 | const ah = this.__ajaxHooker; 234 | ah.originalXhr.setRequestHeader(header, value); 235 | if (this.readyState !== 1) return; 236 | if (header in ah.headers) { 237 | ah.headers[header] += ", " + value; 238 | } else { 239 | ah.headers[header] = value; 240 | } 241 | }, 242 | addEventListener(...args) { 243 | const ah = this.__ajaxHooker; 244 | if (xhrAsyncEvents.includes(args[0])) { 245 | ah.proxyEvents.add(args[0], args[1]); 246 | } else { 247 | ah.originalXhr.addEventListener(...args); 248 | } 249 | }, 250 | removeEventListener(...args) { 251 | const ah = this.__ajaxHooker; 252 | if (xhrAsyncEvents.includes(args[0])) { 253 | ah.proxyEvents.remove(args[0], args[1]); 254 | } else { 255 | ah.originalXhr.removeEventListener(...args); 256 | } 257 | }, 258 | open(method, url, async = true, ...args) { 259 | const ah = this.__ajaxHooker; 260 | ah.url = url.toString(); 261 | ah.method = method.toUpperCase(); 262 | ah.async = !!async; 263 | ah.openArgs = args; 264 | ah.headers = {}; 265 | for (const key of xhrResponses) { 266 | ah.proxyProps[key] = { 267 | get: () => { 268 | const val = ah.originalXhr[key]; 269 | ah.originalXhr.dispatchEvent( 270 | new CustomEvent("ajaxHooker_readResponse", { 271 | detail: { 272 | key, 273 | val, 274 | }, 275 | }) 276 | ); 277 | return val; 278 | }, 279 | }; 280 | } 281 | return ah.originalXhr.open(method, url, ...args); 282 | }, 283 | sendFactory(realSend) { 284 | return function (data) { 285 | const ah = this.__ajaxHooker; 286 | const xhr = ah.originalXhr; 287 | if (xhr.readyState !== 1) return realSend.call(xhr, data); 288 | ah.eventTrigger = (e) => ah.proxyEvents.trigger(e); 289 | if (shouldFilter("xhr", ah.url, ah.method, ah.async)) { 290 | xhr.addEventListener( 291 | "ajaxHooker_responseReady", 292 | (e) => { 293 | ah.eventTrigger(e.detail); 294 | }, { 295 | once: true, 296 | } 297 | ); 298 | return realSend.call(xhr, data); 299 | } 300 | const request = { 301 | type: "xhr", 302 | url: ah.url, 303 | method: ah.method, 304 | abort: false, 305 | headers: ah.headers, 306 | data: data, 307 | response: null, 308 | async: ah.async, 309 | }; 310 | if (!ah.async) { 311 | const requestClone = { 312 | ...request, 313 | }; 314 | hookFns.forEach((fn) => { 315 | try { 316 | toString.call(fn) === "[object Function]" && fn(request); 317 | } catch (err) { 318 | console.error(err); 319 | } 320 | }); 321 | for (const key in request) { 322 | if (toString.call(request[key]) === "[object Promise]") { 323 | request[key] = requestClone[key]; 324 | } 325 | } 326 | xhr.open(request.method, request.url, ah.async, ...ah.openArgs); 327 | for (const header in request.headers) { 328 | xhr.setRequestHeader(header, request.headers[header]); 329 | } 330 | data = request.data; 331 | xhr.addEventListener( 332 | "ajaxHooker_responseReady", 333 | (e) => { 334 | ah.eventTrigger(e.detail); 335 | }, { 336 | once: true, 337 | } 338 | ); 339 | realSend.call(xhr, data); 340 | win.apiCounter--; 341 | win.lastResponseEndTime = Date.now(); 342 | if (toString.call(request.response) === "[object Function]") { 343 | const response = { 344 | finalUrl: xhr.responseURL, 345 | status: xhr.status, 346 | responseHeaders: parseHeaders(xhr.getAllResponseHeaders()), 347 | }; 348 | for (const key of xhrResponses) { 349 | defineProp( 350 | response, 351 | key, 352 | () => { 353 | return (response[key] = ah.originalXhr[key]); 354 | }, 355 | (val) => { 356 | if (toString.call(val) !== "[object Promise]") { 357 | delete response[key]; 358 | response[key] = val; 359 | } 360 | } 361 | ); 362 | } 363 | try { 364 | request.response(response); 365 | } catch (err) { 366 | console.error(err); 367 | } 368 | for (const key of xhrResponses) { 369 | ah.proxyProps[key] = { 370 | get: () => response[key], 371 | }; 372 | } 373 | } 374 | return; 375 | } 376 | const req = new AHRequest(request); 377 | req.waitForRequestKeys().then(() => { 378 | if (request.abort) return; 379 | xhr.open(request.method, request.url, ...ah.openArgs); 380 | for (const header in request.headers) { 381 | xhr.setRequestHeader(header, request.headers[header]); 382 | } 383 | data = request.data; 384 | xhr.addEventListener( 385 | "ajaxHooker_responseReady", 386 | (e) => { 387 | if (typeof request.response !== "function") 388 | return ah.eventTrigger(e.detail); 389 | req.response = { 390 | finalUrl: xhr.responseURL, 391 | status: xhr.status, 392 | responseHeaders: parseHeaders(xhr.getAllResponseHeaders()), 393 | }; 394 | for (const key of xhrResponses) { 395 | defineProp( 396 | req.response, 397 | key, 398 | () => { 399 | return (req.response[key] = ah.originalXhr[key]); 400 | }, 401 | (val) => { 402 | delete req.response[key]; 403 | req.response[key] = val; 404 | } 405 | ); 406 | } 407 | const resPromise = req.waitForResponseKeys().then(() => { 408 | for (const key of xhrResponses) { 409 | if (!(key in req.response)) continue; 410 | ah.proxyProps[key] = { 411 | get: () => { 412 | const val = req.response[key]; 413 | xhr.dispatchEvent( 414 | new CustomEvent( 415 | "ajaxHooker_readResponse", { 416 | detail: { 417 | key, 418 | val, 419 | }, 420 | }) 421 | ); 422 | return val; 423 | }, 424 | }; 425 | } 426 | }); 427 | xhr.addEventListener("ajaxHooker_readResponse", (e) => { 428 | const descriptor = getDescriptor(req.response, e.detail 429 | .key); 430 | if (!descriptor || "get" in descriptor) { 431 | req.response[e.detail.key] = e.detail.val; 432 | } 433 | }); 434 | const eventsClone = ah.proxyEvents.clone(); 435 | ah.eventTrigger = (event) => 436 | resPromise.then(() => eventsClone.trigger(event)); 437 | ah.eventTrigger(e.detail); 438 | }, { 439 | once: true, 440 | } 441 | ); 442 | realSend.call(xhr, data); 443 | win.apiCounter--; 444 | win.lastResponseEndTime = Date.now(); 445 | }); 446 | }; 447 | }, 448 | }; 449 | 450 | function fakeXhr() { 451 | const xhr = new realXhr(); 452 | let ah = xhr.__ajaxHooker; 453 | let xhrProxy = xhr; 454 | if (!ah) { 455 | const proxyEvents = new XhrEvents(); 456 | ah = xhr.__ajaxHooker = { 457 | headers: {}, 458 | originalXhr: xhr, 459 | proxyProps: {}, 460 | proxyEvents: proxyEvents, 461 | eventTrigger: (e) => proxyEvents.trigger(e), 462 | toJSON: emptyFn, // Converting circular structure to JSON 463 | }; 464 | xhrProxy = new Proxy(xhr, { 465 | get(target, prop) { 466 | try { 467 | if (target === xhr) { 468 | if (prop in ah.proxyProps) { 469 | const descriptor = ah.proxyProps[prop]; 470 | return descriptor.get ? descriptor.get() : descriptor.value; 471 | } 472 | if (typeof xhr[prop] === "function") return xhr[prop].bind(xhr); 473 | } 474 | } catch (err) { 475 | console.error(err); 476 | } 477 | return target[prop]; 478 | }, 479 | set(target, prop, value) { 480 | try { 481 | if (target === xhr && prop in ah.proxyProps) { 482 | const descriptor = ah.proxyProps[prop]; 483 | descriptor.set ? 484 | descriptor.set(value) : 485 | (descriptor.value = value); 486 | } else { 487 | target[prop] = value; 488 | } 489 | } catch (err) { 490 | console.error(err); 491 | } 492 | return true; 493 | }, 494 | }); 495 | xhr.addEventListener("readystatechange", xhrMethods.readyStateChange); 496 | xhr.addEventListener("load", xhrMethods.asyncListener); 497 | xhr.addEventListener("loadend", xhrMethods.asyncListener); 498 | for (const evt of xhrAsyncEvents) { 499 | const onEvt = "on" + evt; 500 | ah.proxyProps[onEvt] = { 501 | get: () => proxyEvents.events[onEvt] || null, 502 | set: (val) => proxyEvents.add(onEvt, val), 503 | }; 504 | } 505 | for (const method of [ 506 | "setRequestHeader", 507 | "addEventListener", 508 | "removeEventListener", 509 | "open", 510 | ]) { 511 | ah.proxyProps[method] = { 512 | value: xhrMethods[method], 513 | }; 514 | } 515 | } 516 | ah.proxyProps.send = { 517 | value: xhrMethods.sendFactory(xhr.send), 518 | }; 519 | return xhrProxy; 520 | } 521 | 522 | function hookFetchResponse(response, req) { 523 | if (response.status === 204) { 524 | req.waitForResponseFn() 525 | 526 | } else { 527 | for (const key of fetchResponses) { 528 | response[key] = () => 529 | new Promise((resolve, reject) => { 530 | if (key in req.response) return resolve(req.response[key]); 531 | resProto[key].call(response).then((res) => { 532 | req.response[key] = res; 533 | req.waitForResponseKeys().then(() => { 534 | resolve(key in req.response ? req.response[key] : res); 535 | }); 536 | }, reject); 537 | }); 538 | } 539 | } 540 | 541 | } 542 | 543 | function fakeFetch(url, options = {}) { 544 | if (!url) return realFetch.call(win, url, options); 545 | let init = { 546 | ...options, 547 | }; 548 | if (toString.call(url) === "[object Request]") { 549 | init = {}; 550 | for (const prop of fetchInitProps) init[prop] = url[prop]; 551 | Object.assign(init, options); 552 | url = url.url; 553 | } 554 | url = url.toString(); 555 | init.method = init.method || "GET"; 556 | init.headers = init.headers || {}; 557 | if (shouldFilter("fetch", url, init.method, true)) 558 | return realFetch.call(win, url, init); 559 | const request = { 560 | type: "fetch", 561 | url: url, 562 | method: init.method.toUpperCase(), 563 | abort: false, 564 | headers: parseHeaders(init.headers), 565 | data: init.body, 566 | response: null, 567 | async: true, 568 | }; 569 | const req = new AHRequest(request); 570 | return new Promise((resolve, reject) => { 571 | req 572 | .waitForRequestKeys() 573 | .then(() => { 574 | if (request.abort) 575 | return reject(new DOMException("aborted", "AbortError")); 576 | init.method = request.method; 577 | init.headers = request.headers; 578 | init.body = request.data; 579 | realFetch.call(win, request.url, init).then((response) => { 580 | win.apiCounter--; 581 | win.lastResponseEndTime = Date.now(); 582 | if (typeof request.response === "function") { 583 | req.response = { 584 | finalUrl: response.url, 585 | status: response.status, 586 | responseHeaders: parseHeaders(response.headers), 587 | }; 588 | hookFetchResponse(response, req); 589 | response.clone = () => { 590 | const resClone = resProto.clone.call(response); 591 | hookFetchResponse(resClone, req); 592 | return resClone; 593 | }; 594 | } 595 | resolve(response); 596 | }, reject); 597 | }) 598 | .catch((err) => { 599 | console.error(err); 600 | resolve(realFetch.call(win, url, init)); 601 | win.apiCounter--; 602 | win.lastResponseEndTime = Date.now(); 603 | }); 604 | }); 605 | } 606 | win.XMLHttpRequest = fakeXhr; 607 | Object.keys(realXhr).forEach((key) => (fakeXhr[key] = realXhr[key])); 608 | fakeXhr.prototype = realXhr.prototype; 609 | win.fetch = fakeFetch; 610 | return { 611 | hook: (fn) => hookFns.push(fn), 612 | filter: (arr) => { 613 | filter = Array.isArray(arr) && arr; 614 | }, 615 | protect: () => { 616 | readonly(win, "XMLHttpRequest", fakeXhr); 617 | readonly(win, "fetch", fakeFetch); 618 | }, 619 | unhook: () => { 620 | writable(win, "XMLHttpRequest", realXhr); 621 | writable(win, "fetch", realFetch); 622 | }, 623 | }; 624 | })(); -------------------------------------------------------------------------------- /tools/apiObserver.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | const win = window.unsafeWindow || document.defaultView || window; 4 | // maskTag标记 5 | win.maskTag = 1; 6 | win.dialogTag = 1; 7 | // 记录dom情况,由domObserver.js修改 8 | win.domStatus = 0; 9 | win.lastDomEndTime = 0; 10 | let mask = document.createElement("div"); 11 | mask.style = 12 | "position: fixed;top: 0;right: 0;bottom: 0;left: 0;z-index: 1000;height: 100%;background-color: rgba(0,0,0,.0)"; 13 | mask.id = "networkIdleMask"; 14 | let apiCounterElement = document.createElement("span"); 15 | apiCounterElement.style = 16 | "font-size: 20px;color: red;position: absolute;top: 3%;left: 53%;transform: translate(-50%, -50%);"; 17 | apiCounterElement.id = "apiCounter"; 18 | // 利用PerformanceObserver最后一次网络请求结束时间,会包含ajaxHooker未包含的部门,如script 19 | const apiObserver = new PerformanceObserver((list) => { 20 | const entries = list.getEntries(); 21 | const lastEntry = entries[entries.length - 1]; 22 | // const lastEntryHost = lastEntry.name.match(/^https?:\/\/([^/?#]+)/i)?.[1]; 23 | // if ( 24 | // lastEntryHost === location.host || 25 | // lastEntry.name.includes("https://cdn") 26 | // ) { 27 | if (lastEntry.name.includes("https://cdn")) { 28 | win.lastResponseEndTime = Date.now(); 29 | } 30 | }); 31 | apiObserver.observe({ 32 | entryTypes: ["resource"] 33 | }); 34 | ajaxHooker.filter([ 35 | { 36 | url: /^https:\/\/(oc-test|demo|oc-rel)\.onecontract-cloud.com/, 37 | async: true, 38 | }, 39 | ]); 40 | ajaxHooker.hook((request) => { 41 | win.apiCounter++; 42 | try { 43 | if (win.maskTag) { 44 | apiCounterElement.textContent = "apiCount:" + win.apiCounter; 45 | if (!document.getElementById("networkIdleMask")) { 46 | mask.appendChild(apiCounterElement); 47 | document.body.appendChild(mask); 48 | } 49 | request.response = (res) => { 50 | if (win.dialogTag && res.status === 403){ 51 | win.alert("API:" + res.finalUrl + "-----failInfo:Response status is 403"); 52 | }else if(win.dialogTag && res.json && res.json.hasOwnProperty("failed")) 53 | { 54 | win.alert("API:" + res.finalUrl + "-----failInfo:" + res.json.message); 55 | } 56 | if ( 57 | win.apiCounter === 0 && 58 | document.getElementById("networkIdleMask") 59 | ) { 60 | const timeOut = 10000; 61 | const startTime = Date.now(); 62 | const intervalId = setInterval(() => { 63 | if ( 64 | !win.apiCounter && 65 | Date.now() - win.lastResponseEndTime > 80 66 | ) { 67 | if (document.getElementById("networkIdleMask")) { 68 | if ( !win.domStatus && Date.now() - win.lastDomEndTime > 100 ){ 69 | document.getElementById("networkIdleMask").remove(); 70 | clearInterval(intervalId); 71 | } 72 | } else { 73 | clearInterval(intervalId); 74 | } 75 | } 76 | if (Date.now() - startTime > timeOut) { 77 | clearInterval(intervalId); 78 | } 79 | }, 80); 80 | } 81 | }; 82 | } 83 | } catch (error) { 84 | console.log(error); 85 | } 86 | }); 87 | })(); -------------------------------------------------------------------------------- /tools/domObserver.js: -------------------------------------------------------------------------------- 1 | // 观察DOM变化,对新增的按钮等进行disable或hidden,同时获取loading元素数量 2 | const domObserver = new MutationObserver((mutationsList) => { 3 | // 供apiObserver.js判断当前dom正在变动 4 | window.domStatus = 1; 5 | // listenElementsClassName中元素出现style.display属性将被设置为none,且不会被恢复,防止此类元素出现影响UI自动化操作其他元素 6 | // 如需判断页面中存在这些这些元素,只需playwrigh locator.waitFor("attached")即可. 7 | const listenElementsClassName = ["c7n-notification-notice request"]; 8 | // 临时被禁用,网络结束后启用的元素 9 | const findAllElementsNeedToDisable = (element) => [ 10 | ...(element.tagName === "BUTTON" && !element.disabled 11 | ? [(element.disabled = true && element)] 12 | : []), 13 | ...(element.tagName === "INPUT" && !element.disabled 14 | ? [(element.disabled = true && element)] 15 | : []), 16 | ...(element.tagName === "svg" && 17 | !element.ariaHidden && 18 | !element.closest("button") 19 | ? [(element.closest("div").hidden = true && element.closest("div"))] 20 | : []), 21 | ...Array.from(element.children || []).flatMap(findAllElementsNeedToDisable), 22 | ]; 23 | let elementsToRestore = []; 24 | for (const mutation of mutationsList) { 25 | if (mutation.type === "childList") { 26 | const { target, addedNodes } = mutation; 27 | if (target instanceof HTMLElement && target.classList && addedNodes.length > 0) { 28 | const disabledElements = Array.from(addedNodes || []).flatMap(findAllElementsNeedToDisable); 29 | elementsToRestore = elementsToRestore.concat(disabledElements); 30 | addedNodes.forEach((addedNode) => { 31 | if (typeof addedNode.className === 'string') { 32 | for (const className of listenElementsClassName) { 33 | if (addedNode.className.includes(className)) { 34 | addedNode.style.display = "none"; 35 | break; 36 | } 37 | } 38 | } 39 | }); 40 | } 41 | } 42 | } 43 | // 当目前无loading,且最后一次网络请求结束timeOut时间以上,恢复元素状态. 44 | if (elementsToRestore.length > 0) { 45 | const timeOut = 50; 46 | const intervalId = setInterval(() => { 47 | const now = Date.now(); 48 | if (!window.apiCounter && 49 | now - window.lastResponseEndTime > timeOut && 50 | now - window.lastDomEndTime > timeOut + 20, 51 | !window.domStatus) { 52 | for (const element of elementsToRestore) { 53 | if ( 54 | element instanceof HTMLButtonElement || 55 | element instanceof HTMLInputElement 56 | ) { 57 | element.disabled = false; 58 | } else if (element.tagName === "DIV") { 59 | element.hidden = false; 60 | } 61 | } 62 | clearInterval(intervalId); 63 | } 64 | }, timeOut); 65 | } 66 | window.lastDomEndTime = Date.now(); 67 | window.domStatus = 0; 68 | }); 69 | if (document.body) { 70 | domObserver.observe(document.body, { childList: true, subtree: true }); 71 | } else { 72 | window.addEventListener("DOMContentLoaded", () => 73 | domObserver.observe(document.body, { childList: true, subtree: true }) 74 | ); 75 | } --------------------------------------------------------------------------------