@wdio/devtools-script 1.4.0 → 1.5.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@wdio/devtools-script",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Script to be injected into a page to trace the page",
5
5
  "author": "Christian Bromann <mail@bromann.dev>",
6
6
  "repository": {
@@ -14,12 +14,12 @@
14
14
  "typeScriptVersion": "^5.0.0",
15
15
  "dependencies": {
16
16
  "htm": "^3.1.1",
17
- "parse5": "^8.0.0",
18
- "preact": "^10.27.1",
19
- "vite-plugin-singlefile": "^2.3.2"
17
+ "parse5": "^8.0.1",
18
+ "preact": "^10.29.2",
19
+ "vite-plugin-singlefile": "^2.3.3"
20
20
  },
21
21
  "devDependencies": {
22
- "vite": "8.0.7"
22
+ "vite": "^8.0.16"
23
23
  },
24
24
  "license": "MIT",
25
25
  "scripts": {
@@ -3,7 +3,7 @@ import type { Collector } from './collector.js'
3
3
  const consoleMethods = ['log', 'info', 'warn', 'error'] as const
4
4
  export interface ConsoleLogs {
5
5
  type: 'log' | 'info' | 'warn' | 'error'
6
- args: any[]
6
+ args: unknown[]
7
7
  timestamp: number
8
8
  source?: 'browser' | 'test' | 'terminal'
9
9
  }
@@ -68,94 +68,94 @@ export class NetworkRequestCollector implements Collector<NetworkRequest> {
68
68
  return false
69
69
  }
70
70
 
71
+ #extractFetchUrl(input: RequestInfo | URL): string {
72
+ if (typeof input === 'string') {
73
+ return input
74
+ }
75
+ return input instanceof URL ? input.href : input.url
76
+ }
77
+
78
+ async #readResponseBody(
79
+ response: Response,
80
+ contentType: string
81
+ ): Promise<string | undefined> {
82
+ try {
83
+ if (
84
+ contentType.includes('application/json') ||
85
+ contentType.includes('text/')
86
+ ) {
87
+ return await response.clone().text()
88
+ }
89
+ } catch {
90
+ /* ignore body read errors */
91
+ }
92
+ return undefined
93
+ }
94
+
95
+ async #recordFetchResponse(
96
+ id: string,
97
+ request: Partial<NetworkRequest>,
98
+ response: Response,
99
+ startTime: number
100
+ ): Promise<void> {
101
+ const endTime = performance.now()
102
+ const responseHeaders = this.#extractHeaders(response.headers)
103
+ const contentType = responseHeaders['content-type']?.trim()
104
+ if (!contentType || contentType === '-') {
105
+ this.#pendingRequests.delete(id)
106
+ return
107
+ }
108
+ const responseBody = await this.#readResponseBody(response, contentType)
109
+ this.#requests.push({
110
+ id,
111
+ url: request.url!,
112
+ method: request.method!,
113
+ status: response.status,
114
+ statusText: response.statusText,
115
+ type: 'fetch',
116
+ timestamp: request.timestamp!,
117
+ startTime,
118
+ endTime,
119
+ time: endTime - startTime,
120
+ requestHeaders: request.requestHeaders,
121
+ responseHeaders,
122
+ requestBody: request.requestBody,
123
+ responseBody,
124
+ size: this.#estimateSize(responseBody)
125
+ })
126
+ this.#pendingRequests.delete(id)
127
+ }
128
+
71
129
  #patchFetch() {
72
130
  if (typeof window.fetch !== 'function') {
73
131
  return
74
132
  }
75
-
76
133
  this.#originalFetch = window.fetch
77
134
  const self = this
78
-
79
135
  window.fetch = async function (
80
136
  input: RequestInfo | URL,
81
137
  init?: RequestInit
82
138
  ): Promise<Response> {
83
- const id = self.#generateId()
84
- const url =
85
- typeof input === 'string'
86
- ? input
87
- : input instanceof URL
88
- ? input.href
89
- : input.url
90
- const method = init?.method?.toUpperCase() || 'GET'
91
-
92
- // Skip internal/non-HTTP requests
139
+ const url = self.#extractFetchUrl(input)
93
140
  if (self.#shouldIgnoreRequest(url)) {
94
141
  return self.#originalFetch!.apply(this, [input, init])
95
142
  }
96
-
143
+ const id = self.#generateId()
97
144
  const startTime = performance.now()
98
- const timestamp = Date.now()
99
-
100
145
  const request: Partial<NetworkRequest> = {
101
146
  id,
102
147
  url,
103
- method,
148
+ method: init?.method?.toUpperCase() || 'GET',
104
149
  type: 'fetch',
105
- timestamp,
150
+ timestamp: Date.now(),
106
151
  startTime,
107
152
  requestHeaders: init?.headers ? self.#extractHeaders(init.headers) : {},
108
153
  requestBody: init?.body ? String(init.body) : undefined
109
154
  }
110
-
111
155
  self.#pendingRequests.set(id, request)
112
-
113
156
  try {
114
157
  const response = await self.#originalFetch!.apply(this, [input, init])
115
- const endTime = performance.now()
116
- const time = endTime - startTime
117
-
118
- const responseHeaders = self.#extractHeaders(response.headers)
119
- const contentType = responseHeaders['content-type']?.trim()
120
-
121
- if (!contentType || contentType === '-') {
122
- self.#pendingRequests.delete(id)
123
- return response
124
- }
125
-
126
- let responseBody: string | undefined
127
- try {
128
- if (
129
- contentType.includes('application/json') ||
130
- contentType.includes('text/')
131
- ) {
132
- responseBody = await response.clone().text()
133
- }
134
- } catch {
135
- // Ignore body read errors
136
- }
137
-
138
- const networkRequest: NetworkRequest = {
139
- id,
140
- url,
141
- method,
142
- status: response.status,
143
- statusText: response.statusText,
144
- type: 'fetch',
145
- timestamp,
146
- startTime,
147
- endTime,
148
- time,
149
- requestHeaders: request.requestHeaders,
150
- responseHeaders,
151
- requestBody: request.requestBody,
152
- responseBody,
153
- size: self.#estimateSize(responseBody)
154
- }
155
-
156
- self.#requests.push(networkRequest)
157
- self.#pendingRequests.delete(id)
158
-
158
+ await self.#recordFetchResponse(id, request, response, startTime)
159
159
  return response
160
160
  } catch (error) {
161
161
  self.#pendingRequests.delete(id)
@@ -164,15 +164,48 @@ export class NetworkRequestCollector implements Collector<NetworkRequest> {
164
164
  }
165
165
  }
166
166
 
167
- #patchXHR() {
168
- if (typeof XMLHttpRequest === 'undefined') {
167
+ #recordXHRResponse(
168
+ xhr: XMLHttpRequest,
169
+ requestData: Partial<NetworkRequest>,
170
+ startTime: number
171
+ ): void {
172
+ const endTime = performance.now()
173
+ const responseHeaders = this.#extractXHRHeaders(xhr)
174
+ const contentType = responseHeaders['content-type']?.trim()
175
+ if (!contentType || contentType === '-') {
169
176
  return
170
177
  }
178
+ let responseBody: string | undefined
179
+ try {
180
+ if (
181
+ contentType.includes('application/json') ||
182
+ contentType.includes('text/')
183
+ ) {
184
+ responseBody = xhr.responseText
185
+ }
186
+ } catch {
187
+ /* ignore body read errors */
188
+ }
189
+ this.#requests.push({
190
+ id: requestData.id!,
191
+ url: requestData.url!,
192
+ method: requestData.method!,
193
+ status: xhr.status,
194
+ statusText: xhr.statusText,
195
+ type: 'xhr',
196
+ timestamp: requestData.timestamp!,
197
+ startTime,
198
+ endTime,
199
+ time: endTime - startTime,
200
+ requestHeaders: requestData.requestHeaders,
201
+ responseHeaders,
202
+ requestBody: requestData.requestBody,
203
+ responseBody,
204
+ size: this.#estimateSize(responseBody)
205
+ })
206
+ }
171
207
 
172
- const self = this
173
- this.#originalXhrOpen = XMLHttpRequest.prototype.open
174
- this.#originalXhrSend = XMLHttpRequest.prototype.send
175
-
208
+ #patchXHROpen(self: this): void {
176
209
  XMLHttpRequest.prototype.open = function (
177
210
  method: string,
178
211
  url: string | URL,
@@ -180,31 +213,18 @@ export class NetworkRequestCollector implements Collector<NetworkRequest> {
180
213
  username?: string | null,
181
214
  password?: string | null
182
215
  ) {
183
- const id = self.#generateId()
184
216
  const urlString = typeof url === 'string' ? url : url.href
185
-
186
- // Skip internal/non-HTTP requests
187
- if (self.#shouldIgnoreRequest(urlString)) {
188
- return self.#originalXhrOpen!.call(
189
- this,
190
- method,
191
- url as string,
192
- async ?? true,
193
- username,
194
- password
195
- )
217
+ if (!self.#shouldIgnoreRequest(urlString)) {
218
+ self.#pendingXHRRequests.set(this, {
219
+ id: self.#generateId(),
220
+ url: urlString,
221
+ method: method.toUpperCase(),
222
+ type: 'xhr',
223
+ timestamp: Date.now(),
224
+ startTime: performance.now(),
225
+ requestHeaders: {}
226
+ })
196
227
  }
197
-
198
- self.#pendingXHRRequests.set(this, {
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
228
  return self.#originalXhrOpen!.call(
209
229
  this,
210
230
  method,
@@ -214,78 +234,37 @@ export class NetworkRequestCollector implements Collector<NetworkRequest> {
214
234
  password
215
235
  )
216
236
  }
237
+ }
217
238
 
239
+ #patchXHRSend(self: this): void {
218
240
  XMLHttpRequest.prototype.send = function (
219
241
  body?: Document | XMLHttpRequestBodyInit | null
220
242
  ) {
221
243
  const requestData = self.#pendingXHRRequests.get(this)
222
-
223
- // If no request data, this request was filtered out - just send it
224
244
  if (!requestData) {
225
245
  return self.#originalXhrSend!.call(this, body)
226
246
  }
227
-
228
247
  if (body) {
229
248
  requestData.requestBody = String(body)
230
249
  }
231
-
232
250
  const startTime = requestData.startTime || performance.now()
233
-
234
- const loadHandler = function (this: XMLHttpRequest) {
235
- const endTime = performance.now()
236
- const time = endTime - startTime
237
-
238
- const responseHeaders = self.#extractXHRHeaders(this)
239
- const contentType = responseHeaders['content-type']?.trim()
240
-
241
- if (!contentType || contentType === '-') {
242
- return
243
- }
244
-
245
- let responseBody: string | undefined
246
- try {
247
- if (
248
- contentType.includes('application/json') ||
249
- contentType.includes('text/')
250
- ) {
251
- responseBody = this.responseText
252
- }
253
- } catch {
254
- // Ignore
255
- }
256
-
257
- const networkRequest: NetworkRequest = {
258
- id: requestData.id!,
259
- url: requestData.url!,
260
- method: requestData.method!,
261
- status: this.status,
262
- statusText: this.statusText,
263
- type: 'xhr',
264
- timestamp: requestData.timestamp!,
265
- startTime,
266
- endTime,
267
- time,
268
- requestHeaders: requestData.requestHeaders,
269
- responseHeaders,
270
- requestBody: requestData.requestBody,
271
- responseBody,
272
- size: self.#estimateSize(responseBody)
273
- }
274
-
275
- self.#requests.push(networkRequest)
276
- }
277
-
278
- const errorHandler = function (this: XMLHttpRequest) {
279
- // Skip errors
280
- }
281
-
282
- this.addEventListener('load', loadHandler)
283
- this.addEventListener('error', errorHandler)
284
-
251
+ this.addEventListener('load', function (this: XMLHttpRequest) {
252
+ self.#recordXHRResponse(this, requestData, startTime)
253
+ })
285
254
  return self.#originalXhrSend!.call(this, body)
286
255
  }
287
256
  }
288
257
 
258
+ #patchXHR() {
259
+ if (typeof XMLHttpRequest === 'undefined') {
260
+ return
261
+ }
262
+ this.#originalXhrOpen = XMLHttpRequest.prototype.open
263
+ this.#originalXhrSend = XMLHttpRequest.prototype.send
264
+ this.#patchXHROpen(this)
265
+ this.#patchXHRSend(this)
266
+ }
267
+
289
268
  #extractHeaders(headers: HeadersInit | Headers): Record<string, string> {
290
269
  const result: Record<string, string> = {}
291
270
 
package/src/index.ts CHANGED
@@ -8,6 +8,43 @@ import {
8
8
  import { log } from './logger.js'
9
9
  import { collector } from './collector.js'
10
10
 
11
+ function serializeMutation(
12
+ m: MutationRecord,
13
+ timestamp: number
14
+ ): TraceMutation {
15
+ const addedNodes = Array.from(m.addedNodes).map((node) => {
16
+ assignRef(node as Element)
17
+ return parseFragment(node as Element)
18
+ })
19
+ const removedNodes = Array.from(m.removedNodes).map((node) => getRef(node))
20
+ const target = getRef(m.target)
21
+ const previousSibling = m.previousSibling ? getRef(m.previousSibling) : null
22
+ const nextSibling = m.nextSibling ? getRef(m.nextSibling) : null
23
+ let attributeValue: string | undefined
24
+ if (m.type === 'attributes') {
25
+ attributeValue = (m.target as Element).getAttribute(m.attributeName!) || ''
26
+ }
27
+ let newTextContent: string | undefined
28
+ if (m.type === 'characterData') {
29
+ newTextContent = (m.target as Element).textContent || ''
30
+ }
31
+ log(`added mutation: ${m.type}`)
32
+ return {
33
+ type: m.type,
34
+ attributeName: m.attributeName,
35
+ attributeNamespace: m.attributeNamespace,
36
+ oldValue: m.oldValue,
37
+ addedNodes,
38
+ target,
39
+ removedNodes,
40
+ previousSibling,
41
+ nextSibling,
42
+ timestamp,
43
+ attributeValue,
44
+ newTextContent
45
+ } as TraceMutation
46
+ }
47
+
11
48
  try {
12
49
  log('waiting for body to render')
13
50
  await waitForBody()
@@ -32,66 +69,18 @@ try {
32
69
  const observer = new MutationObserver((ml) => {
33
70
  const timestamp = Date.now()
34
71
  const mutationList = ml.filter((m) => m.attributeName !== 'data-wdio-ref')
35
-
36
72
  log(`observed ${mutationList.length} mutations`)
37
73
  try {
38
74
  collector.captureMutation(
39
- mutationList.map(
40
- ({
41
- target: t,
42
- addedNodes: an,
43
- removedNodes: rn,
44
- type,
45
- attributeName,
46
- attributeNamespace,
47
- previousSibling: ps,
48
- nextSibling: ns,
49
- oldValue
50
- }) => {
51
- const addedNodes = Array.from(an).map((node) => {
52
- assignRef(node as Element)
53
- return parseFragment(node as Element)
54
- })
55
-
56
- const removedNodes = Array.from(rn).map((node) => getRef(node))
57
- const target = getRef(t)
58
- const previousSibling = ps ? getRef(ps) : null
59
- const nextSibling = ns ? getRef(ns) : null
60
-
61
- let attributeValue: string | undefined
62
- if (type === 'attributes') {
63
- attributeValue = (t as Element).getAttribute(attributeName!) || ''
64
- }
65
- let newTextContent: string | undefined
66
- if (type === 'characterData') {
67
- newTextContent = (t as Element).textContent || ''
68
- }
69
-
70
- log(`added mutation: ${type}`)
71
- return {
72
- type,
73
- attributeName,
74
- attributeNamespace,
75
- oldValue,
76
- addedNodes,
77
- target,
78
- removedNodes,
79
- previousSibling,
80
- nextSibling,
81
- timestamp,
82
- attributeValue,
83
- newTextContent
84
- } as TraceMutation
85
- }
86
- )
75
+ mutationList.map((m) => serializeMutation(m, timestamp))
87
76
  )
88
- } catch (err: any) {
89
- collector.captureError(err)
77
+ } catch (err) {
78
+ collector.captureError(err as Error)
90
79
  }
91
80
  })
92
81
  observer.observe(document.body, config)
93
- } catch (err: any) {
94
- collector.captureError(err)
82
+ } catch (err) {
83
+ collector.captureError(err as Error)
95
84
  }
96
85
 
97
86
  log('Finished program')
package/src/logger.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  let logs: string[] = []
2
2
 
3
- export function log(...args: any[]) {
3
+ export function log(...args: unknown[]) {
4
4
  logs.push(args.map((a) => JSON.stringify(a)).join(' '))
5
5
  }
6
6
 
package/src/utils.ts CHANGED
@@ -14,7 +14,7 @@ export type vElement = DefaultTreeAdapterMap['element']
14
14
  export type vText = DefaultTreeAdapterMap['textNode']
15
15
  export type vChildNode = DefaultTreeAdapterMap['childNode']
16
16
 
17
- function createVNode(elem: any) {
17
+ function createVNode(elem: { type: unknown; props: unknown }) {
18
18
  const { type, props } = elem
19
19
  return { type, props } as SimplifiedVNode
20
20
  }
@@ -22,7 +22,7 @@ function createVNode(elem: any) {
22
22
  export function parseNode(
23
23
  fragment: vFragment | vComment | vText | vChildNode
24
24
  ): SimplifiedVNode | string {
25
- const props: Record<string, any> = {}
25
+ const props: Record<string, unknown> = {}
26
26
 
27
27
  if (fragment.nodeName === '#comment') {
28
28
  return (fragment as vComment).data
@@ -38,10 +38,10 @@ export function parseNode(
38
38
 
39
39
  try {
40
40
  return createVNode(
41
- h(tagName, props, ...(childNodes || []).map((cn) => parseNode(cn))) as any
41
+ h(tagName, props, ...(childNodes || []).map((cn) => parseNode(cn)))
42
42
  )
43
- } catch (err: any) {
44
- return createVNode(h('div', { class: 'parseNode' }, err.stack))
43
+ } catch (err) {
44
+ return createVNode(h('div', { class: 'parseNode' }, (err as Error).stack))
45
45
  }
46
46
  }
47
47
 
@@ -49,8 +49,10 @@ export function parseDocument(node: HTMLElement) {
49
49
  try {
50
50
  const fragment = parse(node.outerHTML)
51
51
  return parseNode(fragment.childNodes[0])
52
- } catch (err: any) {
53
- return createVNode(h('div', { class: 'parseDocument' }, err.stack))
52
+ } catch (err) {
53
+ return createVNode(
54
+ h('div', { class: 'parseDocument' }, (err as Error).stack)
55
+ )
54
56
  }
55
57
  }
56
58
 
@@ -58,8 +60,10 @@ export function parseFragment(node: Element) {
58
60
  try {
59
61
  const fragment = parseFragmentImport(node.outerHTML)
60
62
  return parseNode(fragment)
61
- } catch (err: any) {
62
- return createVNode(h('div', { class: 'parseFragmentWrapper' }, err.stack))
63
+ } catch (err) {
64
+ return createVNode(
65
+ h('div', { class: 'parseFragmentWrapper' }, (err as Error).stack)
66
+ )
63
67
  }
64
68
  }
65
69
 
@@ -0,0 +1,36 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { clearLogs, getLogs, log } from '../src/logger.js'
3
+
4
+ describe('script/logger', () => {
5
+ beforeEach(() => clearLogs())
6
+
7
+ it('appends a JSON-serialized line per call', () => {
8
+ log('hello', 42)
9
+ log({ a: 1 })
10
+ expect(getLogs()).toEqual(['"hello" 42', '{"a":1}'])
11
+ })
12
+
13
+ it('joins multiple args with a single space', () => {
14
+ log('a', 'b', 'c')
15
+ expect(getLogs()).toEqual(['"a" "b" "c"'])
16
+ })
17
+
18
+ it('clearLogs wipes the buffer', () => {
19
+ log('x')
20
+ clearLogs()
21
+ expect(getLogs()).toEqual([])
22
+ })
23
+
24
+ it('getLogs returns the live buffer (callers must not mutate)', () => {
25
+ log('one')
26
+ const snap = getLogs()
27
+ log('two')
28
+ expect(snap).toEqual(['"one"', '"two"'])
29
+ })
30
+
31
+ it('renders undefined args as an empty slot (JSON.stringify(undefined) → undefined)', () => {
32
+ log('a', undefined, 'b')
33
+ // JSON.stringify(undefined) returns undefined, and Array#join coerces it to ''
34
+ expect(getLogs()).toEqual(['"a" "b"'])
35
+ })
36
+ })