e2e-pilot 0.0.69

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 (152) hide show
  1. package/bin.js +3 -0
  2. package/dist/aria-snapshot.d.ts +95 -0
  3. package/dist/aria-snapshot.d.ts.map +1 -0
  4. package/dist/aria-snapshot.js +490 -0
  5. package/dist/aria-snapshot.js.map +1 -0
  6. package/dist/bippy.js +971 -0
  7. package/dist/cdp-relay.d.ts +16 -0
  8. package/dist/cdp-relay.d.ts.map +1 -0
  9. package/dist/cdp-relay.js +715 -0
  10. package/dist/cdp-relay.js.map +1 -0
  11. package/dist/cdp-session.d.ts +42 -0
  12. package/dist/cdp-session.d.ts.map +1 -0
  13. package/dist/cdp-session.js +154 -0
  14. package/dist/cdp-session.js.map +1 -0
  15. package/dist/cdp-types.d.ts +63 -0
  16. package/dist/cdp-types.d.ts.map +1 -0
  17. package/dist/cdp-types.js +91 -0
  18. package/dist/cdp-types.js.map +1 -0
  19. package/dist/cli.d.ts +3 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +213 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/create-logger.d.ts +9 -0
  24. package/dist/create-logger.d.ts.map +1 -0
  25. package/dist/create-logger.js +25 -0
  26. package/dist/create-logger.js.map +1 -0
  27. package/dist/debugger-api.md +458 -0
  28. package/dist/debugger-examples-types.d.ts +24 -0
  29. package/dist/debugger-examples-types.d.ts.map +1 -0
  30. package/dist/debugger-examples-types.js +2 -0
  31. package/dist/debugger-examples-types.js.map +1 -0
  32. package/dist/debugger-examples.d.ts +6 -0
  33. package/dist/debugger-examples.d.ts.map +1 -0
  34. package/dist/debugger-examples.js +53 -0
  35. package/dist/debugger-examples.js.map +1 -0
  36. package/dist/debugger.d.ts +381 -0
  37. package/dist/debugger.d.ts.map +1 -0
  38. package/dist/debugger.js +633 -0
  39. package/dist/debugger.js.map +1 -0
  40. package/dist/editor-api.md +364 -0
  41. package/dist/editor-examples.d.ts +11 -0
  42. package/dist/editor-examples.d.ts.map +1 -0
  43. package/dist/editor-examples.js +124 -0
  44. package/dist/editor-examples.js.map +1 -0
  45. package/dist/editor.d.ts +203 -0
  46. package/dist/editor.d.ts.map +1 -0
  47. package/dist/editor.js +336 -0
  48. package/dist/editor.js.map +1 -0
  49. package/dist/execute.d.ts +50 -0
  50. package/dist/execute.d.ts.map +1 -0
  51. package/dist/execute.js +576 -0
  52. package/dist/execute.js.map +1 -0
  53. package/dist/index.d.ts +11 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +7 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/mcp-client.d.ts +20 -0
  58. package/dist/mcp-client.d.ts.map +1 -0
  59. package/dist/mcp-client.js +56 -0
  60. package/dist/mcp-client.js.map +1 -0
  61. package/dist/mcp.d.ts +5 -0
  62. package/dist/mcp.d.ts.map +1 -0
  63. package/dist/mcp.js +720 -0
  64. package/dist/mcp.js.map +1 -0
  65. package/dist/mcp.test.d.ts +10 -0
  66. package/dist/mcp.test.d.ts.map +1 -0
  67. package/dist/mcp.test.js +2999 -0
  68. package/dist/mcp.test.js.map +1 -0
  69. package/dist/network-capture.d.ts +23 -0
  70. package/dist/network-capture.d.ts.map +1 -0
  71. package/dist/network-capture.js +98 -0
  72. package/dist/network-capture.js.map +1 -0
  73. package/dist/protocol.d.ts +54 -0
  74. package/dist/protocol.d.ts.map +1 -0
  75. package/dist/protocol.js +2 -0
  76. package/dist/protocol.js.map +1 -0
  77. package/dist/react-source.d.ts +13 -0
  78. package/dist/react-source.d.ts.map +1 -0
  79. package/dist/react-source.js +68 -0
  80. package/dist/react-source.js.map +1 -0
  81. package/dist/scoped-fs.d.ts +94 -0
  82. package/dist/scoped-fs.d.ts.map +1 -0
  83. package/dist/scoped-fs.js +356 -0
  84. package/dist/scoped-fs.js.map +1 -0
  85. package/dist/selector-generator.js +8126 -0
  86. package/dist/start-relay-server.d.ts +6 -0
  87. package/dist/start-relay-server.d.ts.map +1 -0
  88. package/dist/start-relay-server.js +33 -0
  89. package/dist/start-relay-server.js.map +1 -0
  90. package/dist/styles-api.md +117 -0
  91. package/dist/styles-examples.d.ts +8 -0
  92. package/dist/styles-examples.d.ts.map +1 -0
  93. package/dist/styles-examples.js +64 -0
  94. package/dist/styles-examples.js.map +1 -0
  95. package/dist/styles.d.ts +27 -0
  96. package/dist/styles.d.ts.map +1 -0
  97. package/dist/styles.js +234 -0
  98. package/dist/styles.js.map +1 -0
  99. package/dist/trace-utils.d.ts +14 -0
  100. package/dist/trace-utils.d.ts.map +1 -0
  101. package/dist/trace-utils.js +21 -0
  102. package/dist/trace-utils.js.map +1 -0
  103. package/dist/utils.d.ts +20 -0
  104. package/dist/utils.d.ts.map +1 -0
  105. package/dist/utils.js +75 -0
  106. package/dist/utils.js.map +1 -0
  107. package/dist/wait-for-page-load.d.ts +16 -0
  108. package/dist/wait-for-page-load.d.ts.map +1 -0
  109. package/dist/wait-for-page-load.js +127 -0
  110. package/dist/wait-for-page-load.js.map +1 -0
  111. package/package.json +67 -0
  112. package/src/aria-snapshot.ts +610 -0
  113. package/src/assets/aria-labels-github-snapshot.txt +605 -0
  114. package/src/assets/aria-labels-github.png +0 -0
  115. package/src/assets/aria-labels-google-snapshot.txt +49 -0
  116. package/src/assets/aria-labels-google.png +0 -0
  117. package/src/assets/aria-labels-hacker-news-snapshot.txt +1023 -0
  118. package/src/assets/aria-labels-hacker-news.png +0 -0
  119. package/src/cdp-relay.ts +925 -0
  120. package/src/cdp-session.ts +203 -0
  121. package/src/cdp-timing.md +128 -0
  122. package/src/cdp-types.ts +155 -0
  123. package/src/cli.ts +250 -0
  124. package/src/create-logger.ts +36 -0
  125. package/src/debugger-examples-types.ts +13 -0
  126. package/src/debugger-examples.ts +66 -0
  127. package/src/debugger.md +453 -0
  128. package/src/debugger.ts +713 -0
  129. package/src/editor-examples.ts +148 -0
  130. package/src/editor.ts +390 -0
  131. package/src/execute.ts +763 -0
  132. package/src/index.ts +10 -0
  133. package/src/mcp-client.ts +78 -0
  134. package/src/mcp.test.ts +3596 -0
  135. package/src/mcp.ts +876 -0
  136. package/src/network-capture.ts +140 -0
  137. package/src/prompt.bak.md +323 -0
  138. package/src/prompt.md +7 -0
  139. package/src/protocol.ts +63 -0
  140. package/src/react-source.ts +94 -0
  141. package/src/resource.md +436 -0
  142. package/src/scoped-fs.ts +411 -0
  143. package/src/snapshots/hacker-news-focused-accessibility.md +202 -0
  144. package/src/snapshots/hacker-news-initial-accessibility.md +11 -0
  145. package/src/snapshots/hacker-news-tabbed-accessibility.md +202 -0
  146. package/src/snapshots/shadcn-ui-accessibility.md +11 -0
  147. package/src/start-relay-server.ts +43 -0
  148. package/src/styles-examples.ts +77 -0
  149. package/src/styles.ts +345 -0
  150. package/src/trace-utils.ts +43 -0
  151. package/src/utils.ts +91 -0
  152. package/src/wait-for-page-load.ts +174 -0
package/src/mcp.ts ADDED
@@ -0,0 +1,876 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
+ import { z } from 'zod'
4
+ import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { spawn } from 'node:child_process'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { createRequire } from 'node:module'
10
+ import dedent from 'string-dedent'
11
+ import {
12
+ getCdpUrl,
13
+ LOG_FILE_PATH,
14
+ VERSION,
15
+ sleep,
16
+ RELAY_PORT,
17
+ getServerVersion,
18
+ setDeviceScaleFactorForMacOS,
19
+ preserveSystemColorScheme,
20
+ formatAsCommentLines,
21
+ formatDateTimeForPath,
22
+ } from './utils.js'
23
+ import { killPortProcess } from 'kill-port-process'
24
+ import { CDPSession } from './cdp-session.js'
25
+ import { executeCode, savePageSnapshots } from './execute.js'
26
+ import { startTracing, stopTracing, type ActiveTracing } from './trace-utils.js'
27
+ const __filename = fileURLToPath(import.meta.url)
28
+ const __dirname = path.dirname(__filename)
29
+ const require = createRequire(import.meta.url)
30
+
31
+ interface State {
32
+ isConnected: boolean
33
+ page: Page | null
34
+ browser: Browser | null
35
+ context: BrowserContext | null
36
+ }
37
+
38
+ const state: State = {
39
+ isConnected: false,
40
+ page: null,
41
+ browser: null,
42
+ context: null,
43
+ }
44
+
45
+ const userState: Record<string, any> = {}
46
+
47
+ // Store logs per page targetId
48
+ const browserLogs: Map<string, string[]> = new Map()
49
+ const MAX_LOGS_PER_PAGE = 5000
50
+
51
+ // Store last accessibility snapshot per page for diff feature
52
+ const lastSnapshots: WeakMap<Page, string> = new WeakMap()
53
+
54
+ // Cache CDP sessions per page
55
+ const cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
56
+
57
+ // Track active recording with tracing
58
+ // The recording state is persisted to disk so it survives MCP restarts (e.g., Claude Code auto-compact)
59
+ let activeRecording: { path: string; tracing: ActiveTracing } | null = null
60
+
61
+ // Persisted recording state file path
62
+ const RECORDING_STATE_FILE = '.e2e-pilot/recording-state.json'
63
+
64
+ interface PersistedRecordingState {
65
+ path: string
66
+ tracing: ActiveTracing
67
+ }
68
+
69
+ function getRecordingStateFilePath(): string {
70
+ return path.join(process.cwd(), RECORDING_STATE_FILE)
71
+ }
72
+
73
+ function saveRecordingState(recording: { path: string; tracing: ActiveTracing }): void {
74
+ const stateFilePath = getRecordingStateFilePath()
75
+ const dir = path.dirname(stateFilePath)
76
+ fs.mkdirSync(dir, { recursive: true })
77
+ const state: PersistedRecordingState = {
78
+ path: recording.path,
79
+ tracing: recording.tracing,
80
+ }
81
+ fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2), 'utf-8')
82
+ }
83
+
84
+ function loadRecordingState(): PersistedRecordingState | null {
85
+ const stateFilePath = getRecordingStateFilePath()
86
+ try {
87
+ if (!fs.existsSync(stateFilePath)) {
88
+ return null
89
+ }
90
+ const content = fs.readFileSync(stateFilePath, 'utf-8')
91
+ const state = JSON.parse(content) as PersistedRecordingState
92
+ // Verify the recording file still exists
93
+ if (!fs.existsSync(state.path)) {
94
+ clearRecordingState()
95
+ return null
96
+ }
97
+ return state
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ function clearRecordingState(): void {
104
+ const stateFilePath = getRecordingStateFilePath()
105
+ try {
106
+ if (fs.existsSync(stateFilePath)) {
107
+ fs.unlinkSync(stateFilePath)
108
+ }
109
+ } catch {
110
+ // Ignore errors when clearing state
111
+ }
112
+ }
113
+
114
+ // Restore recording state from disk on MCP startup
115
+ const persistedState = loadRecordingState()
116
+ if (persistedState) {
117
+ activeRecording = {
118
+ path: persistedState.path,
119
+ tracing: persistedState.tracing,
120
+ }
121
+ }
122
+
123
+ const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the E2E Pilot extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe`
124
+
125
+ interface RemoteConfig {
126
+ host: string
127
+ port: number
128
+ token?: string
129
+ }
130
+
131
+ function getRemoteConfig(): RemoteConfig | null {
132
+ const host = process.env.E2E_PILOT_HOST
133
+ if (!host) {
134
+ return null
135
+ }
136
+ return {
137
+ host,
138
+ port: RELAY_PORT,
139
+ token: process.env.E2E_PILOT_TOKEN,
140
+ }
141
+ }
142
+
143
+ function clearUserState() {
144
+ Object.keys(userState).forEach((key) => delete userState[key])
145
+ }
146
+
147
+ function clearConnectionState() {
148
+ state.isConnected = false
149
+ state.browser = null
150
+ state.page = null
151
+ state.context = null
152
+ }
153
+
154
+ function getLogServerUrl(): string {
155
+ const remote = getRemoteConfig()
156
+ if (remote) {
157
+ return `http://${remote.host}:${remote.port}/mcp-log`
158
+ }
159
+ return `http://127.0.0.1:${RELAY_PORT}/mcp-log`
160
+ }
161
+
162
+ async function sendLogToRelayServer(level: string, ...args: any[]) {
163
+ try {
164
+ await fetch(getLogServerUrl(), {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: JSON.stringify({ level, args }),
168
+ signal: AbortSignal.timeout(1000),
169
+ })
170
+ } catch {
171
+ // Silently fail if relay server is not available
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Log to both console.error (for early startup) and relay server log file.
177
+ * Fire-and-forget to avoid blocking.
178
+ */
179
+ function mcpLog(...args: any[]) {
180
+ console.error(...args)
181
+ sendLogToRelayServer('log', ...args)
182
+ }
183
+
184
+ async function killRelayServer(port: number): Promise<void> {
185
+ try {
186
+ await killPortProcess(port)
187
+ await sleep(500)
188
+ } catch {}
189
+ }
190
+
191
+ /**
192
+ * Compare two semver versions. Returns:
193
+ * - negative if v1 < v2
194
+ * - 0 if v1 === v2
195
+ * - positive if v1 > v2
196
+ */
197
+ function compareVersions(v1: string, v2: string): number {
198
+ const parts1 = v1.split('.').map(Number)
199
+ const parts2 = v2.split('.').map(Number)
200
+ const len = Math.max(parts1.length, parts2.length)
201
+
202
+ for (let i = 0; i < len; i++) {
203
+ const p1 = parts1[i] || 0
204
+ const p2 = parts2[i] || 0
205
+ if (p1 !== p2) {
206
+ return p1 - p2
207
+ }
208
+ }
209
+ return 0
210
+ }
211
+
212
+ async function ensureRelayServer(): Promise<void> {
213
+ const serverVersion = await getServerVersion(RELAY_PORT)
214
+
215
+ if (serverVersion === VERSION) {
216
+ return
217
+ }
218
+
219
+ // Don't restart if server version is higher than MCP version.
220
+ // This prevents older MCPs from killing a newer server.
221
+ if (serverVersion !== null && compareVersions(serverVersion, VERSION) > 0) {
222
+ return
223
+ }
224
+
225
+ if (serverVersion !== null) {
226
+ mcpLog(`CDP relay server version mismatch (server: ${serverVersion}, mcp: ${VERSION}), restarting...`)
227
+ await killRelayServer(RELAY_PORT)
228
+ } else {
229
+ mcpLog('CDP relay server not running, starting it...')
230
+ }
231
+
232
+ const dev = process.env.E2E_PILOT_NODE_ENV === 'development'
233
+ const scriptPath = dev
234
+ ? path.resolve(__dirname, '../src/start-relay-server.ts')
235
+ : require.resolve('../dist/start-relay-server.js')
236
+
237
+ const serverProcess = spawn(dev ? 'tsx' : process.execPath, [scriptPath], {
238
+ detached: true,
239
+ stdio: 'ignore',
240
+ env: {
241
+ ...process.env,
242
+ },
243
+ })
244
+
245
+ serverProcess.unref()
246
+
247
+ for (let i = 0; i < 10; i++) {
248
+ await sleep(500)
249
+ const newVersion = await getServerVersion(RELAY_PORT)
250
+ if (newVersion === VERSION) {
251
+ mcpLog('CDP relay server started successfully, waiting for extension to connect...')
252
+ await sleep(1000)
253
+ return
254
+ }
255
+ }
256
+
257
+ throw new Error(`Failed to start CDP relay server after 5 seconds. Check logs at: ${LOG_FILE_PATH}`)
258
+ }
259
+
260
+ async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
261
+ if (state.isConnected && state.browser && state.page) {
262
+ return { browser: state.browser, page: state.page }
263
+ }
264
+
265
+ const remote = getRemoteConfig()
266
+ if (!remote) {
267
+ await ensureRelayServer()
268
+ }
269
+
270
+ const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT })
271
+ console.log(`Connecting to CDP endpoint: ${cdpEndpoint}`)
272
+ const browser = await chromium.connectOverCDP(cdpEndpoint)
273
+ console.log(`Connected to CDP endpoint`)
274
+
275
+ // Clear connection state when browser disconnects (e.g., extension reconnects, relay server restarts)
276
+ browser.on('disconnected', () => {
277
+ mcpLog('Browser disconnected, clearing connection state')
278
+ clearConnectionState()
279
+ })
280
+
281
+ const contexts = browser.contexts()
282
+ const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
283
+ // set default action timeout
284
+ context.setDefaultTimeout(5000)
285
+
286
+ // Set up console listener for all future pages
287
+ context.on('page', (page) => {
288
+ setupPageConsoleListener(page)
289
+ })
290
+
291
+ const pages = context.pages()
292
+ if (pages.length === 0) {
293
+ throw new Error(NO_TABS_ERROR)
294
+ }
295
+ const page = pages[0]
296
+
297
+ // Set up console listener for all existing pages
298
+ context.pages().forEach((p) => setupPageConsoleListener(p))
299
+
300
+ // These functions only set context-level options, they do NOT send CDP commands to pages.
301
+ // Sending CDP commands (like Emulation.setEmulatedMedia or setDeviceMetricsOverride) to pages
302
+ // immediately after connectOverCDP causes pages to render white/blank with about:blank URLs,
303
+ // because pages may not be fully initialized yet. Playwright applies these settings lazily.
304
+ await preserveSystemColorScheme(context)
305
+ await setDeviceScaleFactorForMacOS(context)
306
+
307
+ state.browser = browser
308
+ state.page = page
309
+ state.context = context
310
+ state.isConnected = true
311
+
312
+ return { browser, page }
313
+ }
314
+
315
+ async function getPageTargetId(page: Page): Promise<string> {
316
+ if (!page) {
317
+ throw new Error('Page is null or undefined')
318
+ }
319
+
320
+ const guid = (page as any)._guid
321
+ if (guid) {
322
+ return guid
323
+ }
324
+
325
+ throw new Error('Could not get page identifier: _guid not available')
326
+ }
327
+
328
+ function setupPageConsoleListener(page: Page) {
329
+ // Get targetId synchronously using _guid
330
+ const targetId = (page as any)._guid as string | undefined
331
+
332
+ if (!targetId) {
333
+ // If no _guid, silently fail - this shouldn't happen in normal operation
334
+ return
335
+ }
336
+
337
+ // Initialize logs array for this page
338
+ if (!browserLogs.has(targetId)) {
339
+ browserLogs.set(targetId, [])
340
+ }
341
+
342
+ page.on('framenavigated', (frame) => {
343
+ if (frame === page.mainFrame()) {
344
+ browserLogs.set(targetId, [])
345
+ }
346
+ })
347
+
348
+ page.on('close', () => {
349
+ browserLogs.delete(targetId)
350
+ })
351
+
352
+ page.on('console', (msg) => {
353
+ try {
354
+ let logEntry = `[${msg.type()}] ${msg.text()}`
355
+
356
+ if (!browserLogs.has(targetId)) {
357
+ browserLogs.set(targetId, [])
358
+ }
359
+ const pageLogs = browserLogs.get(targetId)!
360
+
361
+ pageLogs.push(logEntry)
362
+ if (pageLogs.length > MAX_LOGS_PER_PAGE) {
363
+ pageLogs.shift()
364
+ }
365
+ } catch (e) {
366
+ mcpLog('[MCP] Failed to get console message text:', e)
367
+ return
368
+ }
369
+ })
370
+ }
371
+
372
+ async function getCurrentPage(timeout = 5000) {
373
+ if (state.page && !state.page.isClosed()) {
374
+ return state.page
375
+ }
376
+
377
+ // Current page is closed or doesn't exist - find another available page
378
+ if (state.browser) {
379
+ const contexts = state.browser.contexts()
380
+ if (contexts.length > 0) {
381
+ const pages = contexts[0].pages().filter((p) => !p.isClosed())
382
+
383
+ if (pages.length > 0) {
384
+ const page = pages[0]
385
+ await page.waitForLoadState('domcontentloaded', { timeout }).catch(() => {})
386
+ // Update state.page to the new default page
387
+ state.page = page
388
+ return page
389
+ }
390
+ }
391
+ }
392
+
393
+ throw new Error(NO_TABS_ERROR)
394
+ }
395
+
396
+ async function resetConnection(): Promise<{ browser: Browser; page: Page; context: BrowserContext }> {
397
+ if (state.browser) {
398
+ try {
399
+ await state.browser.close()
400
+ } catch (e) {
401
+ mcpLog('Error closing browser:', e)
402
+ }
403
+ }
404
+
405
+ clearConnectionState()
406
+ clearUserState()
407
+
408
+ // DO NOT clear browser logs on reset - logs should persist across reconnections
409
+ // browserLogs.clear()
410
+
411
+ const remote = getRemoteConfig()
412
+ if (!remote) {
413
+ await ensureRelayServer()
414
+ }
415
+
416
+ const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT })
417
+ const browser = await chromium.connectOverCDP(cdpEndpoint)
418
+
419
+ // Clear connection state when browser disconnects (e.g., extension reconnects, relay server restarts)
420
+ browser.on('disconnected', () => {
421
+ mcpLog('Browser disconnected, clearing connection state')
422
+ clearConnectionState()
423
+ })
424
+
425
+ const contexts = browser.contexts()
426
+ const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
427
+ context.setDefaultTimeout(5000)
428
+
429
+ // Set up console listener for all future pages
430
+ context.on('page', (page) => {
431
+ setupPageConsoleListener(page)
432
+ })
433
+
434
+ const pages = context.pages()
435
+ if (pages.length === 0) {
436
+ throw new Error(NO_TABS_ERROR)
437
+ }
438
+ const page = pages[0]
439
+
440
+ // Set up console listener for all existing pages
441
+ context.pages().forEach((p) => setupPageConsoleListener(p))
442
+
443
+ await preserveSystemColorScheme(context)
444
+ await setDeviceScaleFactorForMacOS(context)
445
+
446
+ state.browser = browser
447
+ state.page = page
448
+ state.context = context
449
+ state.isConnected = true
450
+
451
+ return { browser, page, context }
452
+ }
453
+
454
+ const server = new McpServer({
455
+ name: 'e2e-pilot',
456
+ title: 'The better playwright MCP: works as a browser extension. No context bloat. More capable.',
457
+ version: '1.0.0',
458
+ })
459
+
460
+ const promptContent =
461
+ fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'prompt.md'), 'utf-8') +
462
+ `\n\nfor debugging internal errors, check relay server logs at: ${LOG_FILE_PATH}`
463
+
464
+ server.tool(
465
+ 'execute',
466
+ promptContent,
467
+ {
468
+ intend: z.string().describe('The intent of the call, e.g. "Fill form and submit"'),
469
+ code: z
470
+ .string()
471
+ .optional()
472
+ .describe(
473
+ 'js playwright code, has {page, state, context} in scope. Should be one line, using ; to execute multiple statements. you MUST call execute multiple times instead of writing complex scripts in a single tool call.',
474
+ ),
475
+ file: z
476
+ .string()
477
+ .optional()
478
+ .describe('Path to a file containing the code to execute. If provided, code parameter is ignored.'),
479
+ startLine: z
480
+ .number()
481
+ .optional()
482
+ .describe('Start line number (1-indexed) when loading from file. If omitted, starts from beginning.'),
483
+ endLine: z
484
+ .number()
485
+ .optional()
486
+ .describe('End line number (1-indexed, inclusive) when loading from file. If omitted, reads to end of file.'),
487
+ timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'),
488
+ },
489
+ async ({ code, file, startLine, endLine, timeout, intend }) => {
490
+ // Load code from file if file parameter is provided
491
+ let finalCode: string
492
+ if (file) {
493
+ if (!fs.existsSync(file)) {
494
+ return {
495
+ content: [{ type: 'text', text: `File not found: ${file}` }],
496
+ isError: true,
497
+ }
498
+ }
499
+ const fileContent = fs.readFileSync(file, 'utf-8')
500
+ const lines = fileContent.split('\n')
501
+ const start = startLine ? startLine - 1 : 0 // Convert to 0-indexed
502
+ const end = endLine ? endLine : lines.length // endLine is inclusive, so no -1
503
+ finalCode = lines.slice(start, end).join('\n')
504
+ } else if (code) {
505
+ finalCode = code
506
+ } else {
507
+ return {
508
+ content: [{ type: 'text', text: 'Either "code" or "file" parameter must be provided' }],
509
+ isError: true,
510
+ }
511
+ }
512
+
513
+ const remote = getRemoteConfig()
514
+ if (!remote) {
515
+ await ensureRelayServer()
516
+ }
517
+ await ensureConnection()
518
+
519
+ const page = await getCurrentPage(timeout)
520
+ const context = state.context || page.context()
521
+
522
+ return executeCode({
523
+ code: finalCode,
524
+ timeout,
525
+ deps: {
526
+ page,
527
+ context,
528
+ userState,
529
+ browserLogs,
530
+ lastSnapshots,
531
+ cdpSessionCache,
532
+ getPageTargetId,
533
+ getCdpUrl: () => getCdpUrl(remote || { port: RELAY_PORT }),
534
+ resetConnection,
535
+ logger: mcpLog,
536
+ sendLogToRelayServer,
537
+ intend,
538
+ activeRecordingPath: activeRecording?.path,
539
+ },
540
+ })
541
+ },
542
+ )
543
+
544
+ server.tool(
545
+ 'snapshot',
546
+ dedent`
547
+ Take a snapshot of the current page.
548
+ Returns the path to the snapshot file.
549
+ `,
550
+ {},
551
+ async () => {
552
+ const remote = getRemoteConfig()
553
+ if (!remote) {
554
+ await ensureRelayServer()
555
+ }
556
+ await ensureConnection()
557
+ const page = await getCurrentPage()
558
+
559
+ try {
560
+ const snapshotPaths = await savePageSnapshots({ page, lastSnapshots, timeout: 5000 })
561
+
562
+ if (!snapshotPaths) {
563
+ return {
564
+ content: [
565
+ {
566
+ type: 'text',
567
+ text: `Failed to save snapshot: _snapshotForAI returned null. The page may not support accessibility snapshots or the page is not fully loaded. Try using 'execute' tool with 'await page.waitForLoadState("domcontentloaded")' first.`,
568
+ },
569
+ ],
570
+ isError: true,
571
+ }
572
+ }
573
+
574
+ const viewportSize = page.viewportSize()
575
+ let responseText = `Snapshots saved:\n- Full: ${snapshotPaths.fullPath}`
576
+ if (viewportSize) {
577
+ responseText += `\n- Viewport: ${viewportSize.width}x${viewportSize.height}`
578
+ }
579
+
580
+ return {
581
+ content: [
582
+ {
583
+ type: 'text',
584
+ text: responseText,
585
+ },
586
+ ],
587
+ }
588
+ } catch (error: any) {
589
+ return {
590
+ content: [
591
+ {
592
+ type: 'text',
593
+ text: `Failed to save snapshot: ${error.message}`,
594
+ },
595
+ ],
596
+ isError: true,
597
+ }
598
+ }
599
+ },
600
+ )
601
+
602
+ server.tool(
603
+ 'start_recording',
604
+ dedent`
605
+ Start recording browser interactions to a .js file for e2e test code generation.
606
+ Also starts Playwright tracing to capture screenshots, DOM snapshots, and action logs.
607
+ Creates files at:
608
+ - .e2e-pilot/recordings/YYYY-MM-DD_HH-MM-SS.js (code recording)
609
+ - .e2e-pilot/traces/YYYY-MM-DD_HH-MM-SS.zip (trace file)
610
+ All subsequent execute calls will append their code to this file until stop_recording is called.
611
+ Use stop_recording to stop and get the recording and trace file paths.
612
+ `,
613
+ {
614
+ title: z.string().describe('A title describing the recording purpose, e.g. "Login flow" or "Create new loan"'),
615
+ pageDescription: z.string().describe('A multiline description of the page state.'),
616
+ },
617
+ async ({ title, pageDescription }) => {
618
+ const remote = getRemoteConfig()
619
+ if (!remote) {
620
+ await ensureRelayServer()
621
+ }
622
+ await ensureConnection()
623
+
624
+ const context = state.context
625
+ if (!context) {
626
+ return {
627
+ content: [{ type: 'text', text: 'No browser context available. Call execute first.' }],
628
+ isError: true,
629
+ }
630
+ }
631
+
632
+ if (activeRecording) {
633
+ return {
634
+ content: [{ type: 'text', text: `Recording already active at: ${activeRecording.path}` }],
635
+ isError: true,
636
+ }
637
+ }
638
+
639
+ const timeout = 5000
640
+ const page = await getCurrentPage(timeout)
641
+
642
+ const pageUrl = page.url()
643
+ const pageTitle = await page.title()
644
+
645
+ const now = new Date()
646
+ const dateTimeStr = formatDateTimeForPath(now)
647
+
648
+ const recordingDir = path.join(process.cwd(), '.e2e-pilot', 'recordings')
649
+ fs.mkdirSync(recordingDir, { recursive: true })
650
+
651
+ const recordingPath = path.join(recordingDir, `${dateTimeStr}.js`)
652
+ const snapshotPaths = await savePageSnapshots({ page, lastSnapshots, timeout })
653
+
654
+ // Start tracing
655
+ const tracesBaseDir = path.join(process.cwd(), '.e2e-pilot', 'traces')
656
+ const tracing = await startTracing({ context, baseDir: tracesBaseDir })
657
+
658
+ const metaComment = dedent`
659
+ // ${title}
660
+
661
+ // Initial state:
662
+ // Snapshot: ${snapshotPaths?.fullPath}
663
+ // URL: ${pageUrl}
664
+ // Title: ${pageTitle}
665
+ ${formatAsCommentLines(pageDescription)}
666
+
667
+
668
+ `
669
+ fs.writeFileSync(recordingPath, metaComment, 'utf-8')
670
+
671
+ activeRecording = { path: recordingPath, tracing }
672
+ saveRecordingState(activeRecording)
673
+
674
+ let responseText = `Recording started: ${recordingPath}\nTracing started: ${tracing.path}\n\nPage URL: ${pageUrl}\nPage Title: ${pageTitle}`
675
+ if (snapshotPaths) {
676
+ responseText += `\n\nSnapshots saved:\n- Full: ${snapshotPaths.fullPath}`
677
+ }
678
+
679
+ return {
680
+ content: [
681
+ {
682
+ type: 'text',
683
+ text: responseText,
684
+ },
685
+ ],
686
+ }
687
+ },
688
+ )
689
+
690
+ server.tool(
691
+ 'read_recording',
692
+ dedent`
693
+ Read the currently active recording file.
694
+ Returns the content of the recording started by start_recording.
695
+ `,
696
+ {},
697
+ async () => {
698
+ if (!activeRecording) {
699
+ return {
700
+ content: [
701
+ {
702
+ type: 'text',
703
+ text: 'No active recording. Call start_recording first.',
704
+ },
705
+ ],
706
+ isError: true,
707
+ }
708
+ }
709
+
710
+ try {
711
+ const content = fs.readFileSync(activeRecording.path, 'utf-8')
712
+ return {
713
+ content: [
714
+ {
715
+ type: 'text',
716
+ text: `Recording file: ${activeRecording.path}\n\n${content || '(empty)'}`,
717
+ },
718
+ ],
719
+ }
720
+ } catch (error: any) {
721
+ return {
722
+ content: [
723
+ {
724
+ type: 'text',
725
+ text: `Failed to read recording: ${error.message}`,
726
+ },
727
+ ],
728
+ isError: true,
729
+ }
730
+ }
731
+ },
732
+ )
733
+
734
+ server.tool(
735
+ 'stop_recording',
736
+ dedent`
737
+ Stop the active recording and tracing session.
738
+ Returns the paths to the recording file and trace file.
739
+ View traces with: npx playwright show-trace <trace-file>
740
+ `,
741
+ {},
742
+ async () => {
743
+ if (!activeRecording) {
744
+ return {
745
+ content: [{ type: 'text', text: 'No active recording. Call start_recording first.' }],
746
+ isError: true,
747
+ }
748
+ }
749
+
750
+ const context = state.context
751
+ if (!context) {
752
+ const recordingPath = activeRecording.path
753
+ activeRecording = null
754
+ clearRecordingState()
755
+ return {
756
+ content: [
757
+ {
758
+ type: 'text',
759
+ text: `Recording stopped: ${recordingPath}\n\nWarning: Could not stop tracing (no browser context).`,
760
+ },
761
+ ],
762
+ }
763
+ }
764
+
765
+ const recordingPath = activeRecording.path
766
+ let tracePath: string | null = null
767
+ let tracingWarning = ''
768
+
769
+ try {
770
+ tracePath = await stopTracing({ context, activeTracing: activeRecording.tracing })
771
+ } catch (error: any) {
772
+ // Tracing may fail if MCP was restarted (tracing session lost)
773
+ // Still allow stopping the recording gracefully
774
+ tracingWarning = `\n\nWarning: Could not save trace (${error.message}). This can happen if the MCP was restarted during recording.`
775
+ }
776
+
777
+ activeRecording = null
778
+ clearRecordingState()
779
+
780
+ if (tracePath) {
781
+ return {
782
+ content: [
783
+ {
784
+ type: 'text',
785
+ text: `Recording stopped: ${recordingPath}\nTrace saved: ${tracePath}\n\nView trace with: npx playwright show-trace ${tracePath}`,
786
+ },
787
+ ],
788
+ }
789
+ }
790
+
791
+ return {
792
+ content: [
793
+ {
794
+ type: 'text',
795
+ text: `Recording stopped: ${recordingPath}${tracingWarning}`,
796
+ },
797
+ ],
798
+ }
799
+ },
800
+ )
801
+
802
+ server.tool(
803
+ 'reset',
804
+ dedent`
805
+ Recreates the CDP connection and resets the browser/page/context. Use this when the MCP stops responding, you get connection errors, if there are no pages in context, assertion failures, page closed, or other issues.
806
+
807
+ After calling this tool, the page and context variables are automatically updated in the execution environment.
808
+
809
+ This tools also removes any custom properties you may have added to the global scope AND clearing all keys from the \`state\` object. Only \`page\`, \`context\`, \`state\` (empty), \`console\`, and utility functions will remain.
810
+
811
+ if playwright always returns all pages as about:blank urls and evaluate does not work you should ask the user to restart Chrome. This is a known Chrome bug.
812
+ `,
813
+ {},
814
+ async () => {
815
+ try {
816
+ const { page, context } = await resetConnection()
817
+ const pagesCount = context.pages().length
818
+ return {
819
+ content: [
820
+ {
821
+ type: 'text',
822
+ text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}`,
823
+ },
824
+ ],
825
+ }
826
+ } catch (error: any) {
827
+ return {
828
+ content: [
829
+ {
830
+ type: 'text',
831
+ text: `Failed to reset connection: ${error.message}`,
832
+ },
833
+ ],
834
+ isError: true,
835
+ }
836
+ }
837
+ },
838
+ )
839
+
840
+ async function checkRemoteServer({ host, port }: { host: string; port: number }): Promise<void> {
841
+ const versionUrl = `http://${host}:${port}/version`
842
+ try {
843
+ const response = await fetch(versionUrl, { signal: AbortSignal.timeout(3000) })
844
+ if (!response.ok) {
845
+ throw new Error(`Server responded with status ${response.status}`)
846
+ }
847
+ } catch (error: any) {
848
+ const isConnectionError = error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError'
849
+ if (isConnectionError) {
850
+ throw new Error(
851
+ `Cannot connect to remote relay server at ${host}:${port}. ` +
852
+ `Make sure 'npx -y e2e-pilot serve' is running on the host machine.`,
853
+ )
854
+ }
855
+ throw new Error(`Failed to connect to remote relay server: ${error.message}`)
856
+ }
857
+ }
858
+
859
+ export async function startMcp(options: { host?: string; token?: string } = {}) {
860
+ if (options.host) {
861
+ process.env.E2E_PILOT_HOST = options.host
862
+ }
863
+ if (options.token) {
864
+ process.env.E2E_PILOT_TOKEN = options.token
865
+ }
866
+
867
+ const remote = getRemoteConfig()
868
+ if (!remote) {
869
+ await ensureRelayServer()
870
+ } else {
871
+ mcpLog(`Using remote CDP relay server: ${remote.host}:${remote.port}`)
872
+ await checkRemoteServer(remote)
873
+ }
874
+ const transport = new StdioServerTransport()
875
+ await server.connect(transport)
876
+ }