@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/dist/script.js +874 -709
- package/package.json +7 -2
- package/src/collector.ts +4 -0
- package/src/collectors/consoleLogs.ts +1 -0
- package/src/collectors/networkRequests.ts +335 -0
- package/tests/networkRequests.test.ts +40 -0
- package/tests/utils.test.ts +140 -0
- package/types.d.ts +21 -0
- package/tests/preload.test.ts +0 -47
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wdio/devtools-script",
|
|
3
|
-
"version": "1.
|
|
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
|
-
"
|
|
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
|
|
@@ -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
|
package/tests/preload.test.ts
DELETED
|
@@ -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
|
-
// })
|