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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +739 -2
  3. package/dist/assertion/Assertion.d.ts +87 -0
  4. package/dist/assertion/Assertion.d.ts.map +1 -0
  5. package/dist/assertion/Assertion.js +459 -0
  6. package/dist/assertion/Assertion.js.map +1 -0
  7. package/dist/assertion/recorder.d.ts +41 -0
  8. package/dist/assertion/recorder.d.ts.map +1 -0
  9. package/dist/assertion/recorder.js +37 -0
  10. package/dist/assertion/recorder.js.map +1 -0
  11. package/dist/browser/Browser.d.ts +44 -0
  12. package/dist/browser/Browser.d.ts.map +1 -0
  13. package/dist/browser/Browser.js +167 -0
  14. package/dist/browser/Browser.js.map +1 -0
  15. package/dist/browser/Locator.d.ts +94 -0
  16. package/dist/browser/Locator.d.ts.map +1 -0
  17. package/dist/browser/Locator.js +533 -0
  18. package/dist/browser/Locator.js.map +1 -0
  19. package/dist/browser/Page.d.ts +272 -0
  20. package/dist/browser/Page.d.ts.map +1 -0
  21. package/dist/browser/Page.js +979 -0
  22. package/dist/browser/Page.js.map +1 -0
  23. package/dist/client/MCPClient.d.ts +72 -0
  24. package/dist/client/MCPClient.d.ts.map +1 -0
  25. package/dist/client/MCPClient.js +302 -0
  26. package/dist/client/MCPClient.js.map +1 -0
  27. package/dist/client/MCPCommand.d.ts +51 -0
  28. package/dist/client/MCPCommand.d.ts.map +1 -0
  29. package/dist/client/MCPCommand.js +74 -0
  30. package/dist/client/MCPCommand.js.map +1 -0
  31. package/dist/env/loadEnv.d.ts +8 -0
  32. package/dist/env/loadEnv.d.ts.map +1 -0
  33. package/dist/env/loadEnv.js +19 -0
  34. package/dist/env/loadEnv.js.map +1 -0
  35. package/dist/errors/E2EError.d.ts +50 -0
  36. package/dist/errors/E2EError.d.ts.map +1 -0
  37. package/dist/errors/E2EError.js +90 -0
  38. package/dist/errors/E2EError.js.map +1 -0
  39. package/dist/index.d.ts +32 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +37 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/logger.d.ts +12 -0
  44. package/dist/logger.d.ts.map +1 -0
  45. package/dist/logger.js +57 -0
  46. package/dist/logger.js.map +1 -0
  47. package/dist/network/Network.d.ts +72 -0
  48. package/dist/network/Network.d.ts.map +1 -0
  49. package/dist/network/Network.js +236 -0
  50. package/dist/network/Network.js.map +1 -0
  51. package/dist/test/HtmlReport.d.ts +26 -0
  52. package/dist/test/HtmlReport.d.ts.map +1 -0
  53. package/dist/test/HtmlReport.js +653 -0
  54. package/dist/test/HtmlReport.js.map +1 -0
  55. package/dist/test/Runner.d.ts +146 -0
  56. package/dist/test/Runner.d.ts.map +1 -0
  57. package/dist/test/Runner.js +390 -0
  58. package/dist/test/Runner.js.map +1 -0
  59. package/dist/test/index.d.ts +4 -0
  60. package/dist/test/index.d.ts.map +1 -0
  61. package/dist/test/index.js +3 -0
  62. package/dist/test/index.js.map +1 -0
  63. package/dist/version.d.ts +2 -0
  64. package/dist/version.d.ts.map +1 -0
  65. package/dist/version.js +4 -0
  66. package/dist/version.js.map +1 -0
  67. package/package.json +63 -4
package/README.md CHANGED
@@ -1,3 +1,740 @@
1
- # Temporary Holding Version
1
+ # chrome-e2e-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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