chrome-e2e-sdk 0.0.0-stage → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +739 -2
- package/dist/assertion/Assertion.d.ts +87 -0
- package/dist/assertion/Assertion.d.ts.map +1 -0
- package/dist/assertion/Assertion.js +459 -0
- package/dist/assertion/Assertion.js.map +1 -0
- package/dist/assertion/recorder.d.ts +41 -0
- package/dist/assertion/recorder.d.ts.map +1 -0
- package/dist/assertion/recorder.js +37 -0
- package/dist/assertion/recorder.js.map +1 -0
- package/dist/browser/Browser.d.ts +44 -0
- package/dist/browser/Browser.d.ts.map +1 -0
- package/dist/browser/Browser.js +167 -0
- package/dist/browser/Browser.js.map +1 -0
- package/dist/browser/Locator.d.ts +94 -0
- package/dist/browser/Locator.d.ts.map +1 -0
- package/dist/browser/Locator.js +533 -0
- package/dist/browser/Locator.js.map +1 -0
- package/dist/browser/Page.d.ts +272 -0
- package/dist/browser/Page.d.ts.map +1 -0
- package/dist/browser/Page.js +979 -0
- package/dist/browser/Page.js.map +1 -0
- package/dist/client/MCPClient.d.ts +72 -0
- package/dist/client/MCPClient.d.ts.map +1 -0
- package/dist/client/MCPClient.js +302 -0
- package/dist/client/MCPClient.js.map +1 -0
- package/dist/client/MCPCommand.d.ts +51 -0
- package/dist/client/MCPCommand.d.ts.map +1 -0
- package/dist/client/MCPCommand.js +74 -0
- package/dist/client/MCPCommand.js.map +1 -0
- package/dist/env/loadEnv.d.ts +8 -0
- package/dist/env/loadEnv.d.ts.map +1 -0
- package/dist/env/loadEnv.js +19 -0
- package/dist/env/loadEnv.js.map +1 -0
- package/dist/errors/E2EError.d.ts +50 -0
- package/dist/errors/E2EError.d.ts.map +1 -0
- package/dist/errors/E2EError.js +90 -0
- package/dist/errors/E2EError.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +57 -0
- package/dist/logger.js.map +1 -0
- package/dist/network/Network.d.ts +72 -0
- package/dist/network/Network.d.ts.map +1 -0
- package/dist/network/Network.js +236 -0
- package/dist/network/Network.js.map +1 -0
- package/dist/test/HtmlReport.d.ts +26 -0
- package/dist/test/HtmlReport.d.ts.map +1 -0
- package/dist/test/HtmlReport.js +653 -0
- package/dist/test/HtmlReport.js.map +1 -0
- package/dist/test/Runner.d.ts +146 -0
- package/dist/test/Runner.d.ts.map +1 -0
- package/dist/test/Runner.js +390 -0
- package/dist/test/Runner.js.map +1 -0
- package/dist/test/index.d.ts +4 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +3 -0
- package/dist/test/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/package.json +63 -4
package/README.md
CHANGED
|
@@ -1,3 +1,740 @@
|
|
|
1
|
-
#
|
|
1
|
+
# chrome-e2e-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
驱动**你已经登录的那个 Chrome**。
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Browser, expect } from 'chrome-e2e-sdk'
|
|
7
|
+
|
|
8
|
+
const browser = await Browser.connect()
|
|
9
|
+
const page = await browser.currentPage()
|
|
10
|
+
|
|
11
|
+
await page.getByRole('button', { name: '登录' }).click()
|
|
12
|
+
await page.getByLabel('密码').fill(process.env.PASSWORD!)
|
|
13
|
+
await expect(page.getByText('欢迎回来')).toBeVisible()
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
不用全新浏览器 profile,不用重新登录、不用注入 Cookie、不用重写一遍你系统的
|
|
17
|
+
SSO 握手。SDK 连到你屏幕上那个浏览器,然后驱动它。
|
|
18
|
+
|
|
19
|
+
English version is at the bottom of this file.
|
|
20
|
+
|
|
21
|
+
## 为什么需要它
|
|
22
|
+
|
|
23
|
+
Playwright 和 Puppeteer 做浏览器自动化非常优秀。但它们在一个地方按设计就是
|
|
24
|
+
做不好的:**那些你没法用干净 profile 登录的系统。**
|
|
25
|
+
|
|
26
|
+
如果你的应用在 SSO、内网、VPN、硬件密钥后面,或者只是被公司策略禁止了自动化
|
|
27
|
+
登录,那么一个全新的浏览器 profile 只会给你一个登录页。常见的绕法——storage
|
|
28
|
+
state 文件、Cookie 重放、`baseURL` 凭据头——每种都会在某一类系统上失效,而且
|
|
29
|
+
全都是长期维护负担。
|
|
30
|
+
|
|
31
|
+
`chrome-e2e-sdk` 换一个立场:**你自己在真实浏览器里手动登录一次,测试去驱动那个
|
|
32
|
+
浏览器。** 会话就是会话。
|
|
33
|
+
|
|
34
|
+
这也是它包装 [`chrome-devtools-mcp`][mcp] 而不是直接用 Puppeteer 的原因。上游
|
|
35
|
+
已经把"通过 DevTools 协议驱动一个已存在的 Chrome"这件事做干净了,而且有明确的
|
|
36
|
+
版本化工具接口。这个 SDK 在上面加了一层 Playwright 形状的 API、一套断言和一个
|
|
37
|
+
测试 runner,然后不挡路。
|
|
38
|
+
|
|
39
|
+
**它并不声称浏览器自动化比 Playwright 强。** 它声称的是:有一类系统 Playwright
|
|
40
|
+
够不到,而你不应该为了测它们去自己造一套 Cookie 重放的轮子。
|
|
41
|
+
|
|
42
|
+
## 安装
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm add chrome-e2e-sdk
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
需要 **Node 23+**。`chrome-devtools-mcp` 会作为依赖装上,它的 bin 从本地
|
|
49
|
+
`node_modules` 里解析,不需要全局安装任何东西。
|
|
50
|
+
|
|
51
|
+
## 前置条件:开启远程调试
|
|
52
|
+
|
|
53
|
+
`attach` 模式需要 Chrome 允许自动化连接。有两种方式,**推荐先用第一种**。
|
|
54
|
+
|
|
55
|
+
### 方式一:在日常使用的 Chrome 里开启(Chrome 144+,推荐)
|
|
56
|
+
|
|
57
|
+
1. 打开 Chrome,在地址栏输入 `chrome://inspect/#remote-debugging`
|
|
58
|
+
2. 打开 **Enable remote debugging for this browser instance**(为当前浏览器实例开启远程调试)
|
|
59
|
+
3. 保持这个 Chrome 正常运行
|
|
60
|
+
|
|
61
|
+
这样不用重启 Chrome、不用指定 user data directory,你日常的登录态、扩展、
|
|
62
|
+
Cookie 全部原样保留。SDK 默认的 `attach` 模式会自动找到它。
|
|
63
|
+
|
|
64
|
+
### 方式二:用非默认 profile 手动启动
|
|
65
|
+
|
|
66
|
+
Chrome 只有在用**非默认** `--user-data-dir` 启动时才会暴露调试端口。
|
|
67
|
+
|
|
68
|
+
**macOS**
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
|
72
|
+
--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-e2e-profile
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Linux**
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-e2e-profile
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Windows**
|
|
82
|
+
|
|
83
|
+
```powershell
|
|
84
|
+
& "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" `
|
|
85
|
+
--remote-debugging-port=9222 --user-data-dir="$env:TEMP\chrome-e2e-profile"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
然后在那个窗口里登录你想测的系统。
|
|
89
|
+
|
|
90
|
+
> 这种方式用的是**另一个 profile**,不是你日常的 Chrome,所以里面没有你的登录态,
|
|
91
|
+
> 需要手动登录一次。方式一没有这个问题。
|
|
92
|
+
|
|
93
|
+
先关掉其他 Chrome 窗口——同一个 user data directory 同一时间只能被一个浏览器
|
|
94
|
+
持有。任何能访问到这个调试端口的进程都能驱动那个浏览器,请把它当密码看待。
|
|
95
|
+
|
|
96
|
+
## 两种模式
|
|
97
|
+
|
|
98
|
+
| | `mode: 'attach'`(默认) | `mode: 'launch'` |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| 谁启动 Chrome | 你 | MCP server |
|
|
101
|
+
| 用你的登录态 | **是** | 仅当提供持久 `userDataDir` |
|
|
102
|
+
| 需要有人在场 | 否 | 否 |
|
|
103
|
+
| 适合 | 内网系统、SSO、任何有鉴权的 | CI、公开站点、隔离运行 |
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// 连你已经开着的浏览器
|
|
107
|
+
const browser = await Browser.connect()
|
|
108
|
+
|
|
109
|
+
// 让 server 自己起 Chrome。给一个 profile 目录让登录态跨次保留——
|
|
110
|
+
// 在 CI 上你会登录一次、seed 一次 localStorage,然后把 profile 提交进仓库。
|
|
111
|
+
const ciBrowser = await Browser.connect({
|
|
112
|
+
mode: 'launch',
|
|
113
|
+
userDataDir: '.chrome-e2e-profile',
|
|
114
|
+
headless: true
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
不给 `userDataDir` 时,`launch` 用一次性 profile:完全隔离,但也意味着任何需要
|
|
119
|
+
登录的系统都测不了。
|
|
120
|
+
|
|
121
|
+
> `headless` 只对 `launch` 生效。上游在连接已有浏览器时会**静默忽略**
|
|
122
|
+
> `--headless`,所以 `attach` 模式下传它会直接报错,而不是假装生效。
|
|
123
|
+
|
|
124
|
+
## API
|
|
125
|
+
|
|
126
|
+
### 定位
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
await page.getByRole('button', { name: '保存' }).click()
|
|
130
|
+
await page.getByText('订单详情').click()
|
|
131
|
+
await page.getByLabel('手机号').fill('138xxxx0000')
|
|
132
|
+
await page.locator('.table-row').first().click()
|
|
133
|
+
await page.locator('form').getByLabel('邮箱').fill('a@example.com')
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`getByLabel` 覆盖真实页面里的各种写法:`aria-label`、`<label for>`、包裹式
|
|
137
|
+
label,以及把 label 元素和输入框并排渲染的组件(`.el-form-item` 这类)。
|
|
138
|
+
|
|
139
|
+
### 断言
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { expect } from 'chrome-e2e-sdk'
|
|
143
|
+
|
|
144
|
+
// 元素
|
|
145
|
+
await expect(page.getByRole('button', { name: '保存' })).toBeVisible()
|
|
146
|
+
await expect(page.getByRole('button', { name: '保存' })).toBeEnabled()
|
|
147
|
+
await expect(page.getByText('已保存')).toBeVisible()
|
|
148
|
+
await expect(page.locator('.row')).toHaveCount(10)
|
|
149
|
+
await expect(page.getByLabel('邮箱')).toHaveValue('a@example.com')
|
|
150
|
+
|
|
151
|
+
// 页面
|
|
152
|
+
await expect(page).toHaveTitle('订单')
|
|
153
|
+
await expect(page).toHaveURLContaining('/orders')
|
|
154
|
+
await expect(page).toHaveNoConsoleErrors()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
每个断言都会轮询到超时,然后抛出 `AssertionError`,错误信息里带上选择器、期望值
|
|
158
|
+
和最后一次实际观测值。致命错误——MCP 调用失败、浏览器已关闭——会立刻原样抛出,
|
|
159
|
+
不会被重试成一个误导性的超时。
|
|
160
|
+
|
|
161
|
+
`expect(x).not` 可用,而且否定状态在链式调用中不会丢。
|
|
162
|
+
|
|
163
|
+
### 网络
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const response = await page.network.waitForResponse({
|
|
167
|
+
url: '/api/order/query',
|
|
168
|
+
resourceTypes: ['Fetch', 'XHR']
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
expect(response.status).toBe(200)
|
|
172
|
+
expect(JSON.parse(response.body ?? '{}').data).toBeDefined()
|
|
173
|
+
|
|
174
|
+
const request = await page.network.get(response.requestId)
|
|
175
|
+
expect(request?.requestBody).toContain('page=1')
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 控制台
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const messages = await page.consoleMessages()
|
|
182
|
+
const first = messages.find((message) => message.type === 'error')
|
|
183
|
+
if (first?.id !== undefined) {
|
|
184
|
+
const detail = await page.getConsoleMessage(first.id)
|
|
185
|
+
console.log(detail.args, detail.stackTrace)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await expect(page).toHaveConsoleError(/TypeError/)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### 模拟环境
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
await page.emulate({ networkConditions: 'Slow3G', cpuThrottlingRate: 4 })
|
|
195
|
+
await page.emulate({ userAgent: 'iPhone Safari', viewport: '390x844x3,mobile,touch' })
|
|
196
|
+
await page.clearEmulation()
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### 其他
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
await page.screenshot({ fullPage: true })
|
|
203
|
+
await page.locator('.profile-card').screenshot()
|
|
204
|
+
await page.locator('input[type="file"]').setInputFiles('fixtures/avatar.png')
|
|
205
|
+
await page.locator('.source').dragTo(page.locator('.target'))
|
|
206
|
+
await page.waitForText(['下单成功', 'Order placed'])
|
|
207
|
+
await page.fillForm([
|
|
208
|
+
{ label: '邮箱', value: 'a@example.com' },
|
|
209
|
+
{ label: '密码', value: process.env.PASSWORD! }
|
|
210
|
+
])
|
|
211
|
+
await page.localStorage.setItem('token', 'x')
|
|
212
|
+
await page.localStorage.clear()
|
|
213
|
+
await page.evaluate('() => document.title')
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## 测试 runner
|
|
217
|
+
|
|
218
|
+
SDK 自带一个精简 runner,依赖树里没有 Vitest 或 Jest 运行时。
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { describe, it, run, summary, expect } from 'chrome-e2e-sdk/test'
|
|
222
|
+
|
|
223
|
+
describe('订单', () => {
|
|
224
|
+
it('订单列表', { retries: 1 }, async ({ page }) => {
|
|
225
|
+
await page.goto('https://internal.example.com/orders')
|
|
226
|
+
|
|
227
|
+
await expect(page).toHaveTitle('订单')
|
|
228
|
+
await expect(page.locator('.row')).toHaveCount(10)
|
|
229
|
+
await expect(page).toHaveNoConsoleErrors()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it.skip('对账', async () => {})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const result = await run()
|
|
236
|
+
await summary(result)
|
|
237
|
+
|
|
238
|
+
if (result.failed > 0) {
|
|
239
|
+
process.exitCode = 1
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
每个用例拿到一个全新的标签页。失败时会记录标题、时间、错误、页面 URL、页面标题、
|
|
244
|
+
console 消息和网络请求,并保存截图:
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
test-results/
|
|
248
|
+
screenshots/2026-09-30T07-40-58-618Z-订单-订单列表.png
|
|
249
|
+
summary.json
|
|
250
|
+
summary.html
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`summary()` 同时产出两份:
|
|
254
|
+
|
|
255
|
+
- `summary.json` —— 给机器用,CI 判断、脚本统计
|
|
256
|
+
- `summary.html` —— 给人看的报告,`summary()` 返回的就是它的路径
|
|
257
|
+
|
|
258
|
+
### HTML 报告
|
|
259
|
+
|
|
260
|
+
单文件、零依赖、离线可打开(CSS 和筛选脚本全部内联),因此可以直接当 CI
|
|
261
|
+
artifact 上传或下载后在浏览器里看。
|
|
262
|
+
|
|
263
|
+
- 四张统计卡片 + 状态筛选(全部 / 失败 / 通过 / 跳过)
|
|
264
|
+
- 点开失败行可看:断言错误、页面标题与 URL、失败截图、console 消息、网络请求
|
|
265
|
+
- 自动跟随系统的深色 / 浅色
|
|
266
|
+
- 深链接:`summary.html#failed` 只看失败,`#expand-all` 展开全部详情
|
|
267
|
+
- 截图小于 1 MB 时内嵌为 data URI,更大的按相对路径引用,避免报告膨胀到几十 MB
|
|
268
|
+
- console / network 只展示前 30 / 20 条,完整数据仍在 `summary.json` 里
|
|
269
|
+
- 报告自身的文案默认中文,用例标题和浏览器输出保持原文
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
const result = await run()
|
|
273
|
+
await summary(result)
|
|
274
|
+
|
|
275
|
+
// 换输出目录
|
|
276
|
+
await summary(result, { outputDir: 'artifacts/e2e' })
|
|
277
|
+
|
|
278
|
+
// 报告文案改成英文
|
|
279
|
+
await summary(result, { locale: 'en' })
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
也可以在构造 runner 时设成默认:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
const runner = new TestRunner({ locale: 'en' })
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
> JUnit XML 尚未产出。如果你的 CI(GitLab、Jenkins、Azure DevOps)只认 JUnit
|
|
289
|
+
> 报告面板,HTML 对它是不可见的。
|
|
290
|
+
|
|
291
|
+
## 错误
|
|
292
|
+
|
|
293
|
+
所有失败都是带错误码的 `E2EError`:`CONNECTION_ERROR`、`PAGE_NOT_FOUND`、
|
|
294
|
+
`ELEMENT_NOT_FOUND`、`ELEMENT_NOT_INTERACTABLE`、`TIMEOUT`、
|
|
295
|
+
`NETWORK_TIMEOUT`、`MCP_ERROR`、`ASSERTION_ERROR`、`INVALID_ARGUMENT`、
|
|
296
|
+
`BROWSER_CLOSED`、`NAVIGATION_ERROR`、`SCRIPT_ERROR`、`TOOL_DISABLED`。
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
import { E2EError, E2EErrorCode } from 'chrome-e2e-sdk'
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
await page.getByText('保存').click({ timeout: 2000 })
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof E2EError && error.code === E2EErrorCode.ELEMENT_NOT_FOUND) {
|
|
305
|
+
await page.screenshot({ fullPage: true })
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## 调试
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
DEBUG=chrome-e2e npx tsx your-test.ts
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## 超时
|
|
317
|
+
|
|
318
|
+
默认超时是 **30 秒**,覆盖导航、元素操作、断言和网络等待。内网或 VPN 后面一个真实
|
|
319
|
+
页面走到 `load` 很容易超过 5 秒,所以取值偏大。
|
|
320
|
+
|
|
321
|
+
单次调用可以覆盖:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
await page.goto(url, { timeout: 60000 })
|
|
325
|
+
await page.getByRole('button', { name: '保存' }).click({ timeout: 5000 })
|
|
326
|
+
await expect(page.getByText('已保存')).toBeVisible({ timeout: 10000 })
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
全局改:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
const browser = await Browser.connect({ defaultTimeout: 60000 })
|
|
333
|
+
browser.setDefaultTimeout(60000)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## 刻意不封装的
|
|
337
|
+
|
|
338
|
+
写在这里是为了让你知道边界,而不是等你用的时候才发现:
|
|
339
|
+
|
|
340
|
+
- **堆快照**(12 个 `*_heapsnapshot_*` 工具)—— 内存泄漏排查
|
|
341
|
+
- **Lighthouse 与性能 trace** —— 性能审计,不属于 E2E 主链路
|
|
342
|
+
- **Screencast** —— 录屏
|
|
343
|
+
- **扩展、WebMCP、PWA 安装** —— 上游接口面,E2E 场景用不到
|
|
344
|
+
|
|
345
|
+
## License
|
|
346
|
+
|
|
347
|
+
MIT
|
|
348
|
+
|
|
349
|
+
[mcp]: https://github.com/ChromeDevTools/chrome-devtools-mcp
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
# English
|
|
354
|
+
|
|
355
|
+
Drive **the Chrome you are already logged into**.
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
import { Browser, expect } from 'chrome-e2e-sdk'
|
|
359
|
+
|
|
360
|
+
const browser = await Browser.connect()
|
|
361
|
+
const page = await browser.currentPage()
|
|
362
|
+
|
|
363
|
+
await page.getByRole('button', { name: 'Login' }).click()
|
|
364
|
+
await page.getByLabel('Password').fill(process.env.PASSWORD!)
|
|
365
|
+
await expect(page.getByText('Welcome back')).toBeVisible()
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
No fresh browser profile. No re-login, no cookie injection, no re-implementing
|
|
369
|
+
your app's SSO handshake. The SDK connects to the browser on your screen and
|
|
370
|
+
drives it.
|
|
371
|
+
|
|
372
|
+
## Why
|
|
373
|
+
|
|
374
|
+
Playwright and Puppeteer are excellent at browser automation. They are also,
|
|
375
|
+
by design, bad at one thing: **systems you cannot log into from a clean
|
|
376
|
+
profile.**
|
|
377
|
+
|
|
378
|
+
If your app sits behind SSO, a VPN, an internal network, a hardware key, or
|
|
379
|
+
just a corporate policy that blocks automation logins, a fresh browser profile
|
|
380
|
+
gets you a login page and nothing else. The usual workarounds - storage state
|
|
381
|
+
files, cookie replay, `baseURL` credential headers - each break on a different
|
|
382
|
+
system, and all of them are a maintenance burden.
|
|
383
|
+
|
|
384
|
+
`chrome-e2e-sdk` takes a different position: **you log in by hand, once, in
|
|
385
|
+
your real browser. The test drives that same browser.** The session is the
|
|
386
|
+
session.
|
|
387
|
+
|
|
388
|
+
This is also why it wraps [`chrome-devtools-mcp`][mcp] rather than Puppeteer
|
|
389
|
+
directly. That project already solves driving an existing Chrome over the
|
|
390
|
+
DevTools Protocol with a clean, versioned tool surface. This SDK puts a
|
|
391
|
+
Playwright-shaped API on top of it, adds an assertion library and a test runner,
|
|
392
|
+
and gets out of the way.
|
|
393
|
+
|
|
394
|
+
**It does not claim to automate a browser better than Playwright.** It claims
|
|
395
|
+
that there is a class of system Playwright cannot reach, and that you should
|
|
396
|
+
not have to build a cookie-replay harness to test them.
|
|
397
|
+
|
|
398
|
+
## Install
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
pnpm add chrome-e2e-sdk
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Requires **Node 23+**. `chrome-devtools-mcp` is installed as a dependency and
|
|
405
|
+
its bin is resolved from the local `node_modules`, so there is nothing to
|
|
406
|
+
install globally.
|
|
407
|
+
|
|
408
|
+
## Prerequisite: remote debugging
|
|
409
|
+
|
|
410
|
+
`attach` mode talks to a browser that you start yourself, with the DevTools
|
|
411
|
+
protocol enabled. Chrome only exposes that port when it is launched with a
|
|
412
|
+
**non-default** `--user-data-dir`.
|
|
413
|
+
|
|
414
|
+
**macOS**
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
|
418
|
+
--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-e2e-profile
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
**Linux**
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-e2e-profile
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Windows**
|
|
428
|
+
|
|
429
|
+
```powershell
|
|
430
|
+
& "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" `
|
|
431
|
+
--remote-debugging-port=9222 --user-data-dir="$env:TEMP\chrome-e2e-profile"
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Option 1: enable it in your everyday Chrome (Chrome 144+, recommended)
|
|
435
|
+
|
|
436
|
+
1. Open Chrome and go to `chrome://inspect/#remote-debugging`
|
|
437
|
+
2. Turn on **Enable remote debugging for this browser instance**
|
|
438
|
+
3. Leave that Chrome running
|
|
439
|
+
|
|
440
|
+
No restart, no separate user data directory. Your logins, extensions and cookies
|
|
441
|
+
are all still there, and the default `attach` mode finds it on its own.
|
|
442
|
+
|
|
443
|
+
### Option 2: launch with a non-default profile
|
|
444
|
+
|
|
445
|
+
Chrome only exposes the debugging port when it starts with a **non-default**
|
|
446
|
+
`--user-data-dir`.
|
|
447
|
+
|
|
448
|
+
**macOS**
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
|
452
|
+
--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-e2e-profile
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
**Linux**
|
|
456
|
+
|
|
457
|
+
```bash
|
|
458
|
+
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-e2e-profile
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**Windows**
|
|
462
|
+
|
|
463
|
+
```powershell
|
|
464
|
+
& "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" `
|
|
465
|
+
--remote-debugging-port=9222 --user-data-dir="$env:TEMP\chrome-e2e-profile"
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Then log in to whatever you want to test, in that window.
|
|
469
|
+
|
|
470
|
+
> This is a **different profile** from your everyday Chrome, so it starts with no
|
|
471
|
+
> session and you have to log in once. Option 1 does not have that problem.
|
|
472
|
+
|
|
473
|
+
Close other Chrome windows first - a user data directory can only be held by one
|
|
474
|
+
browser at a time. Anything that can reach that debugging port can drive that
|
|
475
|
+
browser, so treat it like a password.
|
|
476
|
+
|
|
477
|
+
## Two modes
|
|
478
|
+
|
|
479
|
+
| | `mode: 'attach'` (default) | `mode: 'launch'` |
|
|
480
|
+
| --- | --- | --- |
|
|
481
|
+
| Who starts Chrome | you | the MCP server |
|
|
482
|
+
| Uses your logged-in session | **yes** | only with a persistent `userDataDir` |
|
|
483
|
+
| Needs a human present | no | no |
|
|
484
|
+
| Good for | internal apps, SSO, anything behind auth | CI, public sites, hermetic runs |
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
// Attach to the browser you already have open.
|
|
488
|
+
const browser = await Browser.connect()
|
|
489
|
+
|
|
490
|
+
// Let the server start its own Chrome. Give it a profile directory to keep
|
|
491
|
+
// logins between runs - on CI you would log in once, seed localStorage, and
|
|
492
|
+
// commit the profile.
|
|
493
|
+
const ciBrowser = await Browser.connect({
|
|
494
|
+
mode: 'launch',
|
|
495
|
+
userDataDir: '.chrome-e2e-profile',
|
|
496
|
+
headless: true
|
|
497
|
+
})
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Without `userDataDir`, `launch` uses a throwaway profile: fully isolated, and
|
|
501
|
+
nothing behind a login can be tested.
|
|
502
|
+
|
|
503
|
+
> `headless` only applies to `launch`. `chrome-devtools-mcp` ignores
|
|
504
|
+
> `--headless` when it connects to an existing browser, so passing it with
|
|
505
|
+
> `attach` is rejected rather than silently ignored.
|
|
506
|
+
|
|
507
|
+
## API
|
|
508
|
+
|
|
509
|
+
### Locators
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
await page.getByRole('button', { name: 'Save' }).click()
|
|
513
|
+
await page.getByText('Order details').click()
|
|
514
|
+
await page.getByLabel('Phone number').fill('138xxxx0000')
|
|
515
|
+
await page.locator('.table-row').first().click()
|
|
516
|
+
await page.locator('form').getByLabel('Email').fill('a@example.com')
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
`getByLabel` handles the shapes real pages use: `aria-label`, `<label for>`,
|
|
520
|
+
wrapping labels, and components that render a label element next to the input.
|
|
521
|
+
|
|
522
|
+
### Assertions
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
import { expect } from 'chrome-e2e-sdk'
|
|
526
|
+
|
|
527
|
+
// Locator
|
|
528
|
+
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
|
529
|
+
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
|
530
|
+
await expect(page.getByText('Saved')).toBeVisible()
|
|
531
|
+
await expect(page.locator('.row')).toHaveCount(10)
|
|
532
|
+
await expect(page.getByLabel('Email')).toHaveValue('a@example.com')
|
|
533
|
+
|
|
534
|
+
// Page
|
|
535
|
+
await expect(page).toHaveTitle('Orders')
|
|
536
|
+
await expect(page).toHaveURLContaining('/orders')
|
|
537
|
+
await expect(page).toHaveNoConsoleErrors()
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Every assertion polls until the timeout and then throws an `AssertionError`
|
|
541
|
+
carrying the selector, the expected value, and the last observed value. Fatal
|
|
542
|
+
errors - a broken MCP call, a closed browser - are rethrown immediately rather
|
|
543
|
+
than being retried into a misleading timeout.
|
|
544
|
+
|
|
545
|
+
`expect(x).not` works, and the negation survives chaining.
|
|
546
|
+
|
|
547
|
+
### Network
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
const response = await page.network.waitForResponse({
|
|
551
|
+
url: '/api/order/query',
|
|
552
|
+
resourceTypes: ['Fetch', 'XHR']
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
expect(response.status).toBe(200)
|
|
556
|
+
expect(JSON.parse(response.body ?? '{}').data).toBeDefined()
|
|
557
|
+
|
|
558
|
+
const request = await page.network.get(response.requestId)
|
|
559
|
+
expect(request?.requestBody).toContain('page=1')
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
### Console
|
|
563
|
+
|
|
564
|
+
```ts
|
|
565
|
+
const messages = await page.consoleMessages()
|
|
566
|
+
const first = messages.find((message) => message.type === 'error')
|
|
567
|
+
if (first?.id !== undefined) {
|
|
568
|
+
const detail = await page.getConsoleMessage(first.id)
|
|
569
|
+
console.log(detail.args, detail.stackTrace)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
await expect(page).toHaveConsoleError(/TypeError/)
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### Emulation
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
await page.emulate({ networkConditions: 'Slow3G', cpuThrottlingRate: 4 })
|
|
579
|
+
await page.emulate({ userAgent: 'iPhone Safari', viewport: '390x844x3,mobile,touch' })
|
|
580
|
+
await page.clearEmulation()
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Other
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
await page.screenshot({ fullPage: true })
|
|
587
|
+
await page.locator('.profile-card').screenshot()
|
|
588
|
+
await page.locator('input[type="file"]').setInputFiles('fixtures/avatar.png')
|
|
589
|
+
await page.locator('.source').dragTo(page.locator('.target'))
|
|
590
|
+
await page.waitForText(['Order placed', '下单成功'])
|
|
591
|
+
await page.fillForm([
|
|
592
|
+
{ label: 'Email', value: 'a@example.com' },
|
|
593
|
+
{ label: 'Password', value: process.env.PASSWORD! }
|
|
594
|
+
])
|
|
595
|
+
await page.localStorage.setItem('token', 'x')
|
|
596
|
+
await page.localStorage.clear()
|
|
597
|
+
await page.evaluate('() => document.title')
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
## Test runner
|
|
601
|
+
|
|
602
|
+
A small runner ships with the SDK, so there is no Vitest or Jest runtime in the
|
|
603
|
+
dependency tree.
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
import { describe, it, run, summary, expect } from 'chrome-e2e-sdk/test'
|
|
607
|
+
|
|
608
|
+
describe('Orders', () => {
|
|
609
|
+
it('lists orders', { retries: 1 }, async ({ page }) => {
|
|
610
|
+
await page.goto('https://internal.example.com/orders')
|
|
611
|
+
|
|
612
|
+
await expect(page).toHaveTitle('Orders')
|
|
613
|
+
await expect(page.locator('.row')).toHaveCount(10)
|
|
614
|
+
await expect(page).toHaveNoConsoleErrors()
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it.skip('reconciliation', async () => {})
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
const result = await run()
|
|
621
|
+
await summary(result)
|
|
622
|
+
|
|
623
|
+
if (result.failed > 0) {
|
|
624
|
+
process.exitCode = 1
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
Each test gets a fresh tab. A failure records the title, time, error, page URL,
|
|
629
|
+
page title, console messages and network requests, and saves a screenshot:
|
|
630
|
+
|
|
631
|
+
```
|
|
632
|
+
test-results/
|
|
633
|
+
screenshots/2026-09-30T07-40-58-618Z-Orders-lists-orders.png
|
|
634
|
+
summary.json
|
|
635
|
+
summary.html
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
`summary()` produces both:
|
|
639
|
+
|
|
640
|
+
- `summary.json` for machines: CI gating, scripted stats
|
|
641
|
+
- `summary.html` for people, and this is the path it returns
|
|
642
|
+
|
|
643
|
+
### HTML report
|
|
644
|
+
|
|
645
|
+
One self-contained file with no dependencies that opens offline (styles and the
|
|
646
|
+
filter script are inlined), so it can be uploaded as a CI artifact and opened
|
|
647
|
+
straight from the download.
|
|
648
|
+
|
|
649
|
+
- Four stat cards plus a status filter (all / failed / passed / skipped)
|
|
650
|
+
- Expanding a failed row shows the assertion error, page title and URL, the
|
|
651
|
+
failure screenshot, console messages and network requests
|
|
652
|
+
- Follows the system light or dark preference
|
|
653
|
+
- Deep links: `summary.html#failed` filters to failures, `#expand-all` opens
|
|
654
|
+
every detail
|
|
655
|
+
- Screenshots under 1 MB are inlined as data URIs; larger ones are referenced by
|
|
656
|
+
relative path so the report cannot balloon to tens of megabytes
|
|
657
|
+
- Console and network show the first 30 and 20 entries; the full payloads stay
|
|
658
|
+
in `summary.json`
|
|
659
|
+
- The report's own labels are Chinese by default. Test titles and browser output
|
|
660
|
+
are data, not chrome, so they are always shown verbatim
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
const result = await run()
|
|
664
|
+
await summary(result)
|
|
665
|
+
|
|
666
|
+
// different output directory
|
|
667
|
+
await summary(result, { outputDir: 'artifacts/e2e' })
|
|
668
|
+
|
|
669
|
+
// English report chrome
|
|
670
|
+
await summary(result, { locale: 'en' })
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
Or set it as the default for a runner:
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
const runner = new TestRunner({ locale: 'en' })
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
> JUnit XML is not produced yet. If your CI (GitLab, Jenkins, Azure DevOps) only
|
|
680
|
+
> reads JUnit report panels, it will not see this HTML.
|
|
681
|
+
|
|
682
|
+
## Errors
|
|
683
|
+
|
|
684
|
+
Every failure is an `E2EError` with a code: `CONNECTION_ERROR`,
|
|
685
|
+
`PAGE_NOT_FOUND`, `ELEMENT_NOT_FOUND`, `ELEMENT_NOT_INTERACTABLE`, `TIMEOUT`,
|
|
686
|
+
`NETWORK_TIMEOUT`, `MCP_ERROR`, `ASSERTION_ERROR`, `INVALID_ARGUMENT`,
|
|
687
|
+
`BROWSER_CLOSED`, `NAVIGATION_ERROR`, `SCRIPT_ERROR`, `TOOL_DISABLED`.
|
|
688
|
+
|
|
689
|
+
```ts
|
|
690
|
+
import { E2EError, E2EErrorCode } from 'chrome-e2e-sdk'
|
|
691
|
+
|
|
692
|
+
try {
|
|
693
|
+
await page.getByText('Save').click({ timeout: 2000 })
|
|
694
|
+
} catch (error) {
|
|
695
|
+
if (error instanceof E2EError && error.code === E2EErrorCode.ELEMENT_NOT_FOUND) {
|
|
696
|
+
await page.screenshot({ fullPage: true })
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
## Debugging
|
|
702
|
+
|
|
703
|
+
```bash
|
|
704
|
+
DEBUG=chrome-e2e npx tsx your-test.ts
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
## Timeouts
|
|
708
|
+
|
|
709
|
+
The default is **30 seconds**, covering navigation, element actions, assertions
|
|
710
|
+
and network waits. A page behind a VPN or an internal network routinely takes
|
|
711
|
+
longer than five seconds to reach `load`, so the default is generous on purpose.
|
|
712
|
+
|
|
713
|
+
Override per call:
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
await page.goto(url, { timeout: 60000 })
|
|
717
|
+
await page.getByRole('button', { name: 'Save' }).click({ timeout: 5000 })
|
|
718
|
+
await expect(page.getByText('Saved')).toBeVisible({ timeout: 10000 })
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
Or globally:
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
const browser = await Browser.connect({ defaultTimeout: 60000 })
|
|
725
|
+
browser.setDefaultTimeout(60000)
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
## Not wrapped
|
|
729
|
+
|
|
730
|
+
Deliberately, and listed so you know the boundary rather than discovering it:
|
|
731
|
+
|
|
732
|
+
- **Heap snapshots** (12 `*_heapsnapshot_*` tools) - memory leak debugging
|
|
733
|
+
- **Lighthouse and performance traces** - performance auditing, not E2E
|
|
734
|
+
- **Screencast** - video recording
|
|
735
|
+
- **Extensions, WebMCP, PWA install** - upstream surface with no E2E use case
|
|
736
|
+
here
|
|
737
|
+
|
|
738
|
+
## License
|
|
739
|
+
|
|
740
|
+
MIT
|