@wdio/devtools-script 1.0.0 → 1.2.0

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/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@wdio/devtools-script",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Script to be injected into a page to trace the page",
5
5
  "author": "Christian Bromann <mail@bromann.dev>",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/webdriverio/devtools.git",
9
+ "directory": "packages/devtools-script"
10
+ },
6
11
  "type": "module",
7
12
  "types": "./types.d.ts",
8
13
  "exports": "./dist/script.js",
@@ -18,6 +23,6 @@
18
23
  "dev": "vite",
19
24
  "build": "tsc && vite build",
20
25
  "preview": "vite preview",
21
- "test": "eslint ."
26
+ "lint": "eslint ."
22
27
  }
23
28
  }
package/src/collector.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getLogs, clearLogs } from './logger.js'
2
2
  import { ConsoleLogCollector } from './collectors/consoleLogs.js'
3
+ import { NetworkRequestCollector } from './collectors/networkRequests.js'
3
4
 
4
5
  class DataCollector {
5
6
  #metadata = {
@@ -9,6 +10,7 @@ class DataCollector {
9
10
  #errors: string[] = []
10
11
  #mutations: TraceMutation[] = []
11
12
  #consoleLogs = new ConsoleLogCollector()
13
+ #networkRequests = new NetworkRequestCollector()
12
14
 
13
15
  captureError(err: Error) {
14
16
  const error = err.stack || err.message
@@ -23,6 +25,7 @@ class DataCollector {
23
25
  this.#errors = []
24
26
  this.#mutations = []
25
27
  this.#consoleLogs.clear()
28
+ this.#networkRequests.clear()
26
29
  clearLogs()
27
30
  }
28
31
 
@@ -35,6 +38,7 @@ class DataCollector {
35
38
  errors: this.#errors,
36
39
  mutations: this.#mutations,
37
40
  consoleLogs: this.#consoleLogs.getArtifacts(),
41
+ networkRequests: this.#networkRequests.getArtifacts(),
38
42
  traceLogs: getLogs(),
39
43
  metadata: this.getMetadata()
40
44
  } as const
@@ -5,6 +5,7 @@ export interface ConsoleLogs {
5
5
  type: 'log' | 'info' | 'warn' | 'error'
6
6
  args: any[]
7
7
  timestamp: number
8
+ source?: 'browser' | 'test' | 'terminal'
8
9
  }
9
10
 
10
11
  export class ConsoleLogCollector implements Collector<ConsoleLogs> {
@@ -0,0 +1,335 @@
1
+ import type { Collector } from './collector.js'
2
+ import type { NetworkRequest } from '../../types.js'
3
+
4
+ export class NetworkRequestCollector implements Collector<NetworkRequest> {
5
+ #requests: NetworkRequest[] = []
6
+ #pendingRequests = new Map<string, Partial<NetworkRequest>>()
7
+ #originalFetch?: typeof fetch
8
+ #originalXhrOpen?: typeof XMLHttpRequest.prototype.open
9
+ #originalXhrSend?: typeof XMLHttpRequest.prototype.send
10
+
11
+ constructor() {
12
+ this.#patchFetch()
13
+ this.#patchXHR()
14
+ }
15
+
16
+ getArtifacts(): NetworkRequest[] {
17
+ return this.#requests
18
+ }
19
+
20
+ clear(): void {
21
+ this.#requests = []
22
+ this.#pendingRequests.clear()
23
+ }
24
+
25
+ #generateId(): string {
26
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
27
+ }
28
+
29
+ #shouldIgnoreRequest(url: string): boolean {
30
+ // Filter out internal URLs, data URLs, blob URLs, chrome extensions, etc.
31
+ if (!url) {
32
+ return true
33
+ }
34
+
35
+ const urlLower = url.toLowerCase()
36
+
37
+ // Ignore non-HTTP protocols
38
+ if (urlLower.startsWith('data:')) {
39
+ return true
40
+ }
41
+ if (urlLower.startsWith('blob:')) {
42
+ return true
43
+ }
44
+ if (urlLower.startsWith('chrome:')) {
45
+ return true
46
+ }
47
+ if (urlLower.startsWith('chrome-extension:')) {
48
+ return true
49
+ }
50
+ if (urlLower.startsWith('about:')) {
51
+ return true
52
+ }
53
+
54
+ // Ignore WebSocket connections
55
+ if (urlLower.startsWith('ws:') || urlLower.startsWith('wss:')) {
56
+ return true
57
+ }
58
+
59
+ // Ignore browser internal requests
60
+ if (urlLower.includes('/.well-known/')) {
61
+ return true
62
+ }
63
+ if (urlLower.includes('/favicon.ico')) {
64
+ return true
65
+ }
66
+
67
+ return false
68
+ }
69
+
70
+ #patchFetch() {
71
+ if (typeof window.fetch !== 'function') {
72
+ return
73
+ }
74
+
75
+ this.#originalFetch = window.fetch
76
+ const self = this
77
+
78
+ window.fetch = async function (
79
+ input: RequestInfo | URL,
80
+ init?: RequestInit
81
+ ): Promise<Response> {
82
+ const id = self.#generateId()
83
+ const url =
84
+ typeof input === 'string'
85
+ ? input
86
+ : input instanceof URL
87
+ ? input.href
88
+ : input.url
89
+ const method = init?.method?.toUpperCase() || 'GET'
90
+
91
+ // Skip internal/non-HTTP requests
92
+ if (self.#shouldIgnoreRequest(url)) {
93
+ return self.#originalFetch!.apply(this, [input, init])
94
+ }
95
+
96
+ const startTime = performance.now()
97
+ const timestamp = Date.now()
98
+
99
+ const request: Partial<NetworkRequest> = {
100
+ id,
101
+ url,
102
+ method,
103
+ type: 'fetch',
104
+ timestamp,
105
+ startTime,
106
+ requestHeaders: init?.headers ? self.#extractHeaders(init.headers) : {},
107
+ requestBody: init?.body ? String(init.body) : undefined
108
+ }
109
+
110
+ self.#pendingRequests.set(id, request)
111
+
112
+ try {
113
+ const response = await self.#originalFetch!.apply(this, [input, init])
114
+ const endTime = performance.now()
115
+ const time = endTime - startTime
116
+
117
+ const responseHeaders = self.#extractHeaders(response.headers)
118
+ const contentType = responseHeaders['content-type']?.trim()
119
+
120
+ if (!contentType || contentType === '-') {
121
+ self.#pendingRequests.delete(id)
122
+ return response
123
+ }
124
+
125
+ let responseBody: string | undefined
126
+ try {
127
+ if (
128
+ contentType.includes('application/json') ||
129
+ contentType.includes('text/')
130
+ ) {
131
+ responseBody = await response.clone().text()
132
+ }
133
+ } catch {
134
+ // Ignore body read errors
135
+ }
136
+
137
+ const networkRequest: NetworkRequest = {
138
+ id,
139
+ url,
140
+ method,
141
+ status: response.status,
142
+ statusText: response.statusText,
143
+ type: 'fetch',
144
+ timestamp,
145
+ startTime,
146
+ endTime,
147
+ time,
148
+ requestHeaders: request.requestHeaders,
149
+ responseHeaders,
150
+ requestBody: request.requestBody,
151
+ responseBody,
152
+ size: self.#estimateSize(responseBody)
153
+ }
154
+
155
+ self.#requests.push(networkRequest)
156
+ self.#pendingRequests.delete(id)
157
+
158
+ return response
159
+ } catch (error) {
160
+ self.#pendingRequests.delete(id)
161
+ throw error
162
+ }
163
+ }
164
+ }
165
+
166
+ #patchXHR() {
167
+ if (typeof XMLHttpRequest === 'undefined') {
168
+ return
169
+ }
170
+
171
+ const self = this
172
+ this.#originalXhrOpen = XMLHttpRequest.prototype.open
173
+ this.#originalXhrSend = XMLHttpRequest.prototype.send
174
+
175
+ XMLHttpRequest.prototype.open = function (
176
+ method: string,
177
+ url: string | URL,
178
+ async?: boolean,
179
+ username?: string | null,
180
+ password?: string | null
181
+ ) {
182
+ const id = self.#generateId()
183
+ const urlString = typeof url === 'string' ? url : url.href
184
+
185
+ // Skip internal/non-HTTP requests
186
+ if (self.#shouldIgnoreRequest(urlString)) {
187
+ return self.#originalXhrOpen!.call(
188
+ this,
189
+ method,
190
+ url as string,
191
+ async ?? true,
192
+ username,
193
+ password
194
+ )
195
+ }
196
+
197
+ ;(this as any)._networkRequestId = id
198
+ ;(this as any)._networkRequestData = {
199
+ id,
200
+ url: urlString,
201
+ method: method.toUpperCase(),
202
+ type: 'xhr',
203
+ timestamp: Date.now(),
204
+ startTime: performance.now(),
205
+ requestHeaders: {}
206
+ }
207
+
208
+ return self.#originalXhrOpen!.call(
209
+ this,
210
+ method,
211
+ url as string,
212
+ async ?? true,
213
+ username,
214
+ password
215
+ )
216
+ }
217
+
218
+ XMLHttpRequest.prototype.send = function (
219
+ body?: Document | XMLHttpRequestBodyInit | null
220
+ ) {
221
+ const requestData = (this as any)
222
+ ._networkRequestData as Partial<NetworkRequest>
223
+
224
+ // If no request data, this request was filtered out - just send it
225
+ if (!requestData) {
226
+ return self.#originalXhrSend!.call(this, body)
227
+ }
228
+
229
+ if (body) {
230
+ requestData.requestBody = String(body)
231
+ }
232
+
233
+ const startTime = requestData.startTime || performance.now()
234
+
235
+ const loadHandler = function (this: XMLHttpRequest) {
236
+ const endTime = performance.now()
237
+ const time = endTime - startTime
238
+
239
+ const responseHeaders = self.#extractXHRHeaders(this)
240
+ const contentType = responseHeaders['content-type']?.trim()
241
+
242
+ if (!contentType || contentType === '-') {
243
+ return
244
+ }
245
+
246
+ let responseBody: string | undefined
247
+ try {
248
+ if (
249
+ contentType.includes('application/json') ||
250
+ contentType.includes('text/')
251
+ ) {
252
+ responseBody = this.responseText
253
+ }
254
+ } catch {
255
+ // Ignore
256
+ }
257
+
258
+ const networkRequest: NetworkRequest = {
259
+ id: requestData.id!,
260
+ url: requestData.url!,
261
+ method: requestData.method!,
262
+ status: this.status,
263
+ statusText: this.statusText,
264
+ type: 'xhr',
265
+ timestamp: requestData.timestamp!,
266
+ startTime,
267
+ endTime,
268
+ time,
269
+ requestHeaders: requestData.requestHeaders,
270
+ responseHeaders,
271
+ requestBody: requestData.requestBody,
272
+ responseBody,
273
+ size: self.#estimateSize(responseBody)
274
+ }
275
+
276
+ self.#requests.push(networkRequest)
277
+ }
278
+
279
+ const errorHandler = function (this: XMLHttpRequest) {
280
+ // Skip errors
281
+ }
282
+
283
+ this.addEventListener('load', loadHandler)
284
+ this.addEventListener('error', errorHandler)
285
+
286
+ return self.#originalXhrSend!.call(this, body)
287
+ }
288
+ }
289
+
290
+ #extractHeaders(headers: HeadersInit | Headers): Record<string, string> {
291
+ const result: Record<string, string> = {}
292
+
293
+ if (headers instanceof Headers) {
294
+ headers.forEach((value, key) => {
295
+ result[key.toLowerCase()] = value
296
+ })
297
+ } else if (Array.isArray(headers)) {
298
+ headers.forEach(([key, value]) => {
299
+ result[key.toLowerCase()] = value
300
+ })
301
+ } else if (headers) {
302
+ Object.entries(headers).forEach(([key, value]) => {
303
+ result[key.toLowerCase()] = value
304
+ })
305
+ }
306
+
307
+ return result
308
+ }
309
+
310
+ #extractXHRHeaders(xhr: XMLHttpRequest): Record<string, string> {
311
+ const result: Record<string, string> = {}
312
+ const headersString = xhr.getAllResponseHeaders()
313
+
314
+ if (headersString) {
315
+ const headers = headersString.trim().split(/[\r\n]+/)
316
+ headers.forEach((line) => {
317
+ const parts = line.split(': ')
318
+ const key = parts.shift()
319
+ const value = parts.join(': ')
320
+ if (key) {
321
+ result[key.toLowerCase()] = value
322
+ }
323
+ })
324
+ }
325
+
326
+ return result
327
+ }
328
+
329
+ #estimateSize(body?: string): number {
330
+ if (!body) {
331
+ return 0
332
+ }
333
+ return new Blob([body]).size
334
+ }
335
+ }
@@ -0,0 +1,40 @@
1
+ /** @vitest-environment happy-dom */
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
3
+ import { NetworkRequestCollector } from '../src/collectors/networkRequests.js'
4
+
5
+ describe('NetworkRequestCollector', () => {
6
+ let collector: NetworkRequestCollector
7
+
8
+ beforeEach(() => {
9
+ collector = new NetworkRequestCollector()
10
+ })
11
+
12
+ afterEach(() => {
13
+ collector.clear()
14
+ })
15
+
16
+ it('should initialize, clear, and return artifacts correctly', () => {
17
+ // Test initialization
18
+ const artifacts = collector.getArtifacts()
19
+ expect(artifacts).toEqual([])
20
+ expect(Array.isArray(artifacts)).toBe(true)
21
+ expect(artifacts).toHaveLength(0)
22
+
23
+ // Test clear functionality
24
+ collector.clear()
25
+ const clearedArtifacts = collector.getArtifacts()
26
+ expect(clearedArtifacts).toEqual([])
27
+ expect(clearedArtifacts).toHaveLength(0)
28
+
29
+ // Test multiple clears are safe
30
+ collector.clear()
31
+ expect(collector.getArtifacts()).toEqual([])
32
+
33
+ // Test reference consistency
34
+ const artifacts1 = collector.getArtifacts()
35
+ const artifacts2 = collector.getArtifacts()
36
+ expect(artifacts1).toBe(artifacts2)
37
+ expect(artifacts1).toBeDefined()
38
+ expect(artifacts1).not.toBeNull()
39
+ })
40
+ })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach } from 'vitest'
5
+ import {
6
+ waitForBody,
7
+ assignRef,
8
+ getRef,
9
+ parseFragment,
10
+ parseDocument
11
+ } from '../src/utils.js'
12
+
13
+ describe('DOM mutation capture utilities', () => {
14
+ beforeEach(() => {
15
+ if (!document.body) {
16
+ const body = document.createElement('body')
17
+ document.documentElement.appendChild(body)
18
+ }
19
+ document.body.innerHTML = ''
20
+ })
21
+
22
+ it('should wait for body to exist before capturing mutations', async () => {
23
+ await expect(waitForBody()).resolves.toBeUndefined()
24
+ })
25
+
26
+ it('should assign trackable refs to DOM elements for mutation identification', () => {
27
+ const parent = document.createElement('div')
28
+ const child1 = document.createElement('span')
29
+ const child2 = document.createElement('p')
30
+ parent.appendChild(child1)
31
+ parent.appendChild(child2)
32
+
33
+ assignRef(parent)
34
+
35
+ const parentRef = getRef(parent)
36
+ const child1Ref = getRef(child1)
37
+ const child2Ref = getRef(child2)
38
+
39
+ // Each element should get unique ref
40
+ expect(parentRef).toBeTruthy()
41
+ expect(child1Ref).toBeTruthy()
42
+ expect(child2Ref).toBeTruthy()
43
+ expect(parentRef).not.toBe(child1Ref)
44
+ expect(child1Ref).not.toBe(child2Ref)
45
+ })
46
+
47
+ it('should maintain stable refs across multiple assignments', () => {
48
+ const div = document.createElement('div')
49
+ assignRef(div)
50
+ const firstRef = getRef(div)
51
+
52
+ assignRef(div)
53
+ const secondRef = getRef(div)
54
+
55
+ expect(firstRef).toBe(secondRef)
56
+ })
57
+
58
+ it('should serialize DOM elements to transmittable VNode structure', () => {
59
+ const button = document.createElement('button')
60
+ button.id = 'submit'
61
+ button.className = 'btn-primary'
62
+ button.textContent = 'Submit'
63
+
64
+ const vnode = parseFragment(button)
65
+
66
+ // VNode should be serializable (has type and props)
67
+ expect(vnode).toHaveProperty('type')
68
+ expect(vnode).toHaveProperty('props')
69
+ expect(JSON.stringify(vnode)).toBeTruthy()
70
+ })
71
+
72
+ it('should serialize complete document hierarchy for initial capture', () => {
73
+ const div = document.createElement('div')
74
+ div.innerHTML = '<header><h1>App</h1></header><main><p>Content</p></main>'
75
+
76
+ const vnode = parseDocument(div)
77
+
78
+ // Document parsing wraps in html element
79
+ expect(vnode).toHaveProperty('type')
80
+ expect(vnode).toHaveProperty('props')
81
+ expect(JSON.stringify(vnode)).toBeTruthy()
82
+ })
83
+
84
+ it('should handle parsing errors without breaking mutation capture', () => {
85
+ const fragmentResult = parseFragment(null as any)
86
+ const documentResult = parseDocument(null as any)
87
+
88
+ // Should return error containers instead of throwing
89
+ expect(typeof fragmentResult).toBe('object')
90
+ expect(typeof documentResult).toBe('object')
91
+
92
+ if (typeof fragmentResult === 'object') {
93
+ expect(fragmentResult.type).toBe('div')
94
+ expect(fragmentResult.props.class).toBe('parseFragmentWrapper')
95
+ }
96
+
97
+ if (typeof documentResult === 'object') {
98
+ expect(documentResult.type).toBe('div')
99
+ expect(documentResult.props.class).toBe('parseDocument')
100
+ }
101
+ })
102
+
103
+ it('should support complete mutation tracking workflow: assign ref → serialize → transmit', () => {
104
+ // Simulate what happens in index.ts when MutationObserver detects changes
105
+ const addedNode = document.createElement('article')
106
+ addedNode.innerHTML = '<h2>New Section</h2><p>New content</p>'
107
+
108
+ // Step 1: Assign ref so we can track this node
109
+ assignRef(addedNode)
110
+ const nodeRef = getRef(addedNode)
111
+
112
+ // Step 2: Serialize for transmission to backend
113
+ const serialized = parseFragment(addedNode)
114
+
115
+ // Step 3: Verify we can identify and serialize the mutation
116
+ expect(nodeRef).toBeTruthy()
117
+ expect(serialized).toHaveProperty('type')
118
+ expect(serialized).toHaveProperty('props')
119
+
120
+ // The serialized VNode should be transmittable as JSON
121
+ const json = JSON.stringify(serialized)
122
+ expect(json).toBeTruthy()
123
+ expect(() => JSON.parse(json)).not.toThrow()
124
+ })
125
+
126
+ it('should support mutation removal tracking via refs', () => {
127
+ const target = document.createElement('div')
128
+ const child = document.createElement('span')
129
+ target.appendChild(child)
130
+
131
+ assignRef(target)
132
+
133
+ // When mutation observer detects removals, we can get refs
134
+ const targetRef = getRef(target)
135
+ const childRef = getRef(child)
136
+
137
+ expect(targetRef).not.toBeNull()
138
+ expect(childRef).not.toBeNull()
139
+ })
140
+ })
package/types.d.ts CHANGED
@@ -1,6 +1,26 @@
1
1
  import type { DataCollectorType } from './src/collector.ts'
2
2
  import type { ConsoleLog as ConsoleLogImport } from './src/collectors/consoleLogs.ts'
3
3
 
4
+ export interface NetworkRequest {
5
+ id: string
6
+ url: string
7
+ method: string
8
+ status?: number
9
+ statusText?: string
10
+ type: string
11
+ initiator?: string
12
+ size?: number
13
+ time?: number
14
+ requestHeaders?: Record<string, string>
15
+ responseHeaders?: Record<string, string>
16
+ requestBody?: string
17
+ responseBody?: string
18
+ timestamp: number
19
+ startTime: number
20
+ endTime?: number
21
+ error?: string
22
+ }
23
+
4
24
  export interface TraceMetadata {
5
25
  url: string
6
26
  viewport: VisualViewport
@@ -15,6 +35,7 @@ export interface SimplifiedVNode {
15
35
 
16
36
  declare global {
17
37
  type ConsoleLogs = ConsoleLogImport
38
+ type NetworkRequest = NetworkRequest
18
39
 
19
40
  interface Element {
20
41
  'wdio-ref': string
@@ -1,47 +0,0 @@
1
- // import { test, expect } from 'vitest'
2
- // import { h, render } from 'preact'
3
- // import type { VNode as PreactVNode } from 'preact'
4
-
5
- // function transform (node: SimplifiedVNode | string): PreactVNode<{}> | string {
6
- // if (typeof node !== 'object') {
7
- // return node
8
- // }
9
-
10
- // const { children, ...props } = node.props
11
- // const childrenRequired = children || []
12
- // const c = Array.isArray(childrenRequired) ? childrenRequired : [childrenRequired]
13
- // return h(node.type as string, props, ...c.map(transform)) as PreactVNode<{}>
14
- // }
15
-
16
- // test('should be able serialize DOM', async () => {
17
- // await import('../src/index.ts')
18
- // expect(window.wdioCaptureErrors).toEqual([])
19
- // expect(window.wdioDOMChanges.length).toBe(1)
20
- // expect(window.wdioDOMChanges).toMatchSnapshot()
21
- // })
22
-
23
- // test('should be able to parse serialized DOM and render it', () => {
24
- // const stage = document.createDocumentFragment()
25
- // const [initial] = window.wdioDOMChanges
26
- // render(transform(initial.addedNodes[0]), stage)
27
- // expect(document.documentElement.outerHTML)
28
- // .toBe((stage.childNodes[0] as HTMLElement).outerHTML)
29
- // })
30
-
31
- // test('should be able to properly serialize changes', async () => {
32
- // const change = document.createElement('div')
33
- // change.setAttribute('id', 'change')
34
- // change.appendChild(document.createTextNode('some '))
35
- // const bold = document.createElement('i')
36
- // bold.appendChild(document.createTextNode('real'))
37
- // change.appendChild(bold)
38
- // change.appendChild(document.createTextNode(' change'))
39
- // document.body.appendChild(change)
40
-
41
- // await new Promise((resolve) => setTimeout(resolve, 10))
42
- // expect(window.wdioDOMChanges.length).toBe(2)
43
- // const [, vChange] = window.wdioDOMChanges
44
- // const stage = document.createDocumentFragment()
45
- // render(transform((vChange.addedNodes[0] as SimplifiedVNode).props.children as SimplifiedVNode), stage)
46
- // expect((stage.childNodes[0] as HTMLElement).outerHTML).toMatchSnapshot()
47
- // })