@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/dist/script.js +852 -803
- package/package.json +5 -5
- package/src/collectors/consoleLogs.ts +1 -1
- package/src/collectors/networkRequests.ts +127 -148
- package/src/index.ts +42 -53
- package/src/logger.ts +1 -1
- package/src/utils.ts +13 -9
- package/tests/logger.test.ts +36 -0
- package/tests/networkRequests.test.ts +244 -25
|
@@ -1,40 +1,259 @@
|
|
|
1
1
|
/** @vitest-environment happy-dom */
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
3
3
|
import { NetworkRequestCollector } from '../src/collectors/networkRequests.js'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// happy-dom doesn't ship a Blob with reliable .size in some versions —
|
|
6
|
+
// fall back to a stub that returns the byte length so #estimateSize works
|
|
7
|
+
// without depending on the polyfill flavor we land on.
|
|
8
|
+
if (typeof globalThis.Blob === 'undefined') {
|
|
9
|
+
;(globalThis as unknown as { Blob: unknown }).Blob = class {
|
|
10
|
+
size: number
|
|
11
|
+
constructor(parts: BlobPart[]) {
|
|
12
|
+
this.size = parts.reduce(
|
|
13
|
+
(n, p) => n + (typeof p === 'string' ? p.length : 0),
|
|
14
|
+
0
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
7
19
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
20
|
+
let collector: NetworkRequestCollector
|
|
21
|
+
const realFetch = window.fetch
|
|
22
|
+
let fetchMock: ReturnType<typeof vi.fn>
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
// Patch fetch BEFORE constructing the collector so the collector wraps
|
|
26
|
+
// OUR mock and we can drive captures deterministically without real I/O.
|
|
27
|
+
fetchMock = vi.fn()
|
|
28
|
+
window.fetch = fetchMock as unknown as typeof fetch
|
|
29
|
+
collector = new NetworkRequestCollector()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
collector.clear()
|
|
34
|
+
window.fetch = realFetch
|
|
35
|
+
})
|
|
11
36
|
|
|
12
|
-
|
|
37
|
+
describe('NetworkRequestCollector — lifecycle', () => {
|
|
38
|
+
it('starts empty and clear() resets the buffer', () => {
|
|
39
|
+
expect(collector.getArtifacts()).toEqual([])
|
|
13
40
|
collector.clear()
|
|
41
|
+
expect(collector.getArtifacts()).toEqual([])
|
|
42
|
+
// Reference is stable across reads (callers can hold the array ref)
|
|
43
|
+
expect(collector.getArtifacts()).toBe(collector.getArtifacts())
|
|
14
44
|
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('NetworkRequestCollector — fetch capture', () => {
|
|
48
|
+
it('captures a successful JSON response with headers, body, timing, size', async () => {
|
|
49
|
+
fetchMock.mockResolvedValueOnce(
|
|
50
|
+
new Response('{"hello":"world"}', {
|
|
51
|
+
status: 200,
|
|
52
|
+
statusText: 'OK',
|
|
53
|
+
headers: { 'content-type': 'application/json' }
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
await window.fetch('https://api.example.com/data', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'x-trace': 'abc' },
|
|
60
|
+
body: JSON.stringify({ q: 1 })
|
|
61
|
+
})
|
|
15
62
|
|
|
16
|
-
it('should initialize, clear, and return artifacts correctly', () => {
|
|
17
|
-
// Test initialization
|
|
18
63
|
const artifacts = collector.getArtifacts()
|
|
19
|
-
expect(artifacts).
|
|
20
|
-
|
|
21
|
-
expect(
|
|
64
|
+
expect(artifacts).toHaveLength(1)
|
|
65
|
+
const req = artifacts[0]
|
|
66
|
+
expect(req).toMatchObject({
|
|
67
|
+
url: 'https://api.example.com/data',
|
|
68
|
+
method: 'POST',
|
|
69
|
+
type: 'fetch',
|
|
70
|
+
status: 200,
|
|
71
|
+
statusText: 'OK',
|
|
72
|
+
requestBody: JSON.stringify({ q: 1 }),
|
|
73
|
+
responseBody: '{"hello":"world"}'
|
|
74
|
+
})
|
|
75
|
+
expect(req.requestHeaders).toMatchObject({ 'x-trace': 'abc' })
|
|
76
|
+
expect(req.responseHeaders).toMatchObject({
|
|
77
|
+
'content-type': 'application/json'
|
|
78
|
+
})
|
|
79
|
+
expect(req.startTime).toBeTypeOf('number')
|
|
80
|
+
expect(req.endTime).toBeTypeOf('number')
|
|
81
|
+
expect(req.time).toBeGreaterThanOrEqual(0)
|
|
82
|
+
})
|
|
22
83
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
84
|
+
it('skips internal protocols (data:, blob:, chrome:, about:, ws:)', async () => {
|
|
85
|
+
for (const url of [
|
|
86
|
+
'data:text/plain,hello',
|
|
87
|
+
'blob:https://example.com/abc',
|
|
88
|
+
'chrome://settings',
|
|
89
|
+
'chrome-extension://abc/page',
|
|
90
|
+
'about:blank',
|
|
91
|
+
'ws://example.com',
|
|
92
|
+
'wss://example.com'
|
|
93
|
+
]) {
|
|
94
|
+
fetchMock.mockResolvedValueOnce(new Response(''))
|
|
95
|
+
await window.fetch(url)
|
|
96
|
+
}
|
|
97
|
+
// Every call passed through to the original mock but NONE got captured
|
|
98
|
+
expect(collector.getArtifacts()).toEqual([])
|
|
99
|
+
expect(fetchMock).toHaveBeenCalledTimes(7)
|
|
100
|
+
})
|
|
28
101
|
|
|
29
|
-
|
|
30
|
-
|
|
102
|
+
it('skips noise URLs (/favicon.ico, /.well-known/...)', async () => {
|
|
103
|
+
fetchMock.mockResolvedValue(new Response(''))
|
|
104
|
+
await window.fetch('https://example.com/favicon.ico')
|
|
105
|
+
await window.fetch('https://example.com/.well-known/something')
|
|
31
106
|
expect(collector.getArtifacts()).toEqual([])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('extracts the URL from URL objects (URL.href, not toString)', async () => {
|
|
110
|
+
fetchMock.mockResolvedValueOnce(
|
|
111
|
+
new Response('{"ok":1}', {
|
|
112
|
+
status: 200,
|
|
113
|
+
headers: { 'content-type': 'application/json' }
|
|
114
|
+
})
|
|
115
|
+
)
|
|
116
|
+
await window.fetch(new URL('https://example.com/x'))
|
|
117
|
+
expect(collector.getArtifacts()[0].url).toBe('https://example.com/x')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Known limitation: the collector reads `init?.method` only — when a
|
|
121
|
+
// Request object is passed as the first arg with its own method, the
|
|
122
|
+
// collector reports 'GET' (the default) because it doesn't inspect
|
|
123
|
+
// Request.method. Pin the behavior here so a future change is intentional.
|
|
124
|
+
it('reports GET for Request-object inputs with a non-GET Request.method (known limitation)', async () => {
|
|
125
|
+
fetchMock.mockResolvedValueOnce(
|
|
126
|
+
new Response('{"ok":2}', {
|
|
127
|
+
status: 200,
|
|
128
|
+
headers: { 'content-type': 'application/json' }
|
|
129
|
+
})
|
|
130
|
+
)
|
|
131
|
+
await window.fetch(new Request('https://example.com/y', { method: 'PUT' }))
|
|
132
|
+
const captured = collector.getArtifacts()[0]
|
|
133
|
+
expect(captured.url).toBe('https://example.com/y')
|
|
134
|
+
expect(captured.method).toBe('GET') // not 'PUT' — see comment above
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('does not capture an entry when the underlying fetch rejects, and re-throws', async () => {
|
|
138
|
+
fetchMock.mockRejectedValueOnce(new Error('network down'))
|
|
139
|
+
await expect(window.fetch('https://example.com/will-fail')).rejects.toThrow(
|
|
140
|
+
'network down'
|
|
141
|
+
)
|
|
142
|
+
expect(collector.getArtifacts()).toEqual([])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('extracts headers from all three accepted shapes (Headers, Array, plain object) and lowercases keys', async () => {
|
|
146
|
+
// Headers instance
|
|
147
|
+
fetchMock.mockResolvedValueOnce(
|
|
148
|
+
new Response('a', {
|
|
149
|
+
status: 200,
|
|
150
|
+
headers: { 'content-type': 'text/plain' }
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
await window.fetch('https://example.com/h1', {
|
|
154
|
+
headers: new Headers({ 'X-One': '1' })
|
|
155
|
+
})
|
|
156
|
+
// [[k,v]] tuple array
|
|
157
|
+
fetchMock.mockResolvedValueOnce(
|
|
158
|
+
new Response('b', {
|
|
159
|
+
status: 200,
|
|
160
|
+
headers: { 'content-type': 'text/plain' }
|
|
161
|
+
})
|
|
162
|
+
)
|
|
163
|
+
await window.fetch('https://example.com/h2', {
|
|
164
|
+
headers: [['X-Two', '2']]
|
|
165
|
+
})
|
|
166
|
+
// Plain object
|
|
167
|
+
fetchMock.mockResolvedValueOnce(
|
|
168
|
+
new Response('c', {
|
|
169
|
+
status: 200,
|
|
170
|
+
headers: { 'content-type': 'text/plain' }
|
|
171
|
+
})
|
|
172
|
+
)
|
|
173
|
+
await window.fetch('https://example.com/h3', {
|
|
174
|
+
headers: { 'X-Three': '3' }
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const reqs = collector.getArtifacts()
|
|
178
|
+
expect(reqs[0].requestHeaders).toMatchObject({ 'x-one': '1' })
|
|
179
|
+
expect(reqs[1].requestHeaders).toMatchObject({ 'x-two': '2' })
|
|
180
|
+
expect(reqs[2].requestHeaders).toMatchObject({ 'x-three': '3' })
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('NetworkRequestCollector — XHR capture', () => {
|
|
185
|
+
it('captures a successful JSON XHR (status + body + content-type filter passes)', async () => {
|
|
186
|
+
const xhr = new XMLHttpRequest()
|
|
187
|
+
xhr.open('GET', 'https://api.example.com/xhr')
|
|
188
|
+
|
|
189
|
+
// Patch the just-opened xhr to fake a successful JSON response without
|
|
190
|
+
// hitting the network. happy-dom doesn't deliver `load` for a never-sent
|
|
191
|
+
// request, so we wire up the response shape manually + fire the event.
|
|
192
|
+
Object.defineProperty(xhr, 'status', { value: 200, configurable: true })
|
|
193
|
+
Object.defineProperty(xhr, 'statusText', {
|
|
194
|
+
value: 'OK',
|
|
195
|
+
configurable: true
|
|
196
|
+
})
|
|
197
|
+
Object.defineProperty(xhr, 'responseText', {
|
|
198
|
+
value: '{"id":42}',
|
|
199
|
+
configurable: true
|
|
200
|
+
})
|
|
201
|
+
Object.defineProperty(xhr, 'getAllResponseHeaders', {
|
|
202
|
+
value: () => 'content-type: application/json\r\nx-rate-limit: 99\r\n',
|
|
203
|
+
configurable: true
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
xhr.send()
|
|
207
|
+
xhr.dispatchEvent(new Event('load'))
|
|
208
|
+
|
|
209
|
+
const reqs = collector.getArtifacts()
|
|
210
|
+
expect(reqs).toHaveLength(1)
|
|
211
|
+
expect(reqs[0]).toMatchObject({
|
|
212
|
+
url: 'https://api.example.com/xhr',
|
|
213
|
+
method: 'GET',
|
|
214
|
+
type: 'xhr',
|
|
215
|
+
status: 200,
|
|
216
|
+
statusText: 'OK',
|
|
217
|
+
responseBody: '{"id":42}'
|
|
218
|
+
})
|
|
219
|
+
expect(reqs[0].responseHeaders).toMatchObject({
|
|
220
|
+
'content-type': 'application/json',
|
|
221
|
+
'x-rate-limit': '99'
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('skips ignored URLs in XHR open (does not record)', () => {
|
|
226
|
+
const xhr = new XMLHttpRequest()
|
|
227
|
+
xhr.open('GET', 'data:text/plain,hello')
|
|
228
|
+
// The send + load aren't even needed — `open` was filtered, nothing in
|
|
229
|
+
// the pending map, nothing to record on load.
|
|
230
|
+
expect(collector.getArtifacts()).toEqual([])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('captures request body for XHR POST + lowercases response headers', async () => {
|
|
234
|
+
const xhr = new XMLHttpRequest()
|
|
235
|
+
xhr.open('POST', 'https://api.example.com/submit')
|
|
236
|
+
Object.defineProperty(xhr, 'status', { value: 201, configurable: true })
|
|
237
|
+
Object.defineProperty(xhr, 'statusText', {
|
|
238
|
+
value: 'Created',
|
|
239
|
+
configurable: true
|
|
240
|
+
})
|
|
241
|
+
Object.defineProperty(xhr, 'responseText', {
|
|
242
|
+
value: 'OK',
|
|
243
|
+
configurable: true
|
|
244
|
+
})
|
|
245
|
+
Object.defineProperty(xhr, 'getAllResponseHeaders', {
|
|
246
|
+
value: () => 'Content-Type: text/plain\r\n',
|
|
247
|
+
configurable: true
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
xhr.send('payload-data')
|
|
251
|
+
xhr.dispatchEvent(new Event('load'))
|
|
32
252
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
expect(
|
|
38
|
-
expect(artifacts1).not.toBeNull()
|
|
253
|
+
const req = collector.getArtifacts()[0]
|
|
254
|
+
expect(req.requestBody).toBe('payload-data')
|
|
255
|
+
// Header key was uppercase "Content-Type" on the wire; the parser
|
|
256
|
+
// lowercases for consistency with the fetch path.
|
|
257
|
+
expect(req.responseHeaders).toHaveProperty('content-type', 'text/plain')
|
|
39
258
|
})
|
|
40
259
|
})
|