@testrelic/playwright-analytics 1.1.0 → 1.2.1
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/fixture.cjs +180 -15
- package/dist/fixture.cjs.map +1 -1
- package/dist/fixture.js +180 -15
- package/dist/fixture.js.map +1 -1
- package/dist/index.cjs +786 -217
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +786 -217
- package/dist/index.js.map +1 -1
- package/package.json +13 -17
package/dist/fixture.cjs
CHANGED
|
@@ -27,12 +27,39 @@ module.exports = __toCommonJS(fixture_exports);
|
|
|
27
27
|
var import_test = require("@playwright/test");
|
|
28
28
|
|
|
29
29
|
// src/navigation-tracker.ts
|
|
30
|
+
var MAX_BODY_SIZE = 10240;
|
|
31
|
+
var MAX_REQUESTS_PER_TEST = 500;
|
|
32
|
+
var TEXT_CONTENT_TYPES = [
|
|
33
|
+
"text/",
|
|
34
|
+
"application/json",
|
|
35
|
+
"application/xml",
|
|
36
|
+
"application/javascript",
|
|
37
|
+
"application/x-www-form-urlencoded",
|
|
38
|
+
"application/graphql"
|
|
39
|
+
];
|
|
40
|
+
function isTextContentType(contentType) {
|
|
41
|
+
const lower = contentType.toLowerCase();
|
|
42
|
+
return TEXT_CONTENT_TYPES.some((prefix) => lower.includes(prefix));
|
|
43
|
+
}
|
|
44
|
+
function truncateBody(body, maxSize) {
|
|
45
|
+
const bytes = new TextEncoder().encode(body);
|
|
46
|
+
if (bytes.length <= maxSize) {
|
|
47
|
+
return { text: body, truncated: false };
|
|
48
|
+
}
|
|
49
|
+
const truncated = new TextDecoder().decode(bytes.slice(0, maxSize));
|
|
50
|
+
return { text: truncated, truncated: true };
|
|
51
|
+
}
|
|
30
52
|
var NavigationTracker = class {
|
|
31
53
|
constructor(page, options) {
|
|
32
54
|
this.page = page;
|
|
33
55
|
this.records = [];
|
|
34
56
|
this.listeners = [];
|
|
35
57
|
this.currentNetworkCounter = null;
|
|
58
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
59
|
+
this.capturedRequests = [];
|
|
60
|
+
this.pendingBodyReads = [];
|
|
61
|
+
this.requestIdCounter = 0;
|
|
62
|
+
this.requestCaptureCount = 0;
|
|
36
63
|
this.includeNetworkStats = options?.includeNetworkStats ?? true;
|
|
37
64
|
this.origGoto = page.goto.bind(page);
|
|
38
65
|
this.origGoBack = page.goBack.bind(page);
|
|
@@ -44,7 +71,33 @@ var NavigationTracker = class {
|
|
|
44
71
|
async init() {
|
|
45
72
|
await this.injectSPADetection();
|
|
46
73
|
}
|
|
47
|
-
|
|
74
|
+
async getCapturedRequests() {
|
|
75
|
+
await Promise.allSettled(this.pendingBodyReads);
|
|
76
|
+
this.pendingBodyReads = [];
|
|
77
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
78
|
+
this.capturedRequests.push({
|
|
79
|
+
url: pending.url,
|
|
80
|
+
method: pending.method,
|
|
81
|
+
resourceType: pending.resourceType,
|
|
82
|
+
statusCode: 0,
|
|
83
|
+
responseTimeMs: Date.now() - pending.startTimeMs,
|
|
84
|
+
startedAt: pending.startedAt,
|
|
85
|
+
requestHeaders: pending.headers,
|
|
86
|
+
requestBody: pending.postData,
|
|
87
|
+
responseBody: null,
|
|
88
|
+
responseHeaders: null,
|
|
89
|
+
contentType: null,
|
|
90
|
+
responseSize: 0,
|
|
91
|
+
requestBodyTruncated: pending.postDataTruncated,
|
|
92
|
+
responseBodyTruncated: false,
|
|
93
|
+
isBinary: false,
|
|
94
|
+
error: "incomplete"
|
|
95
|
+
});
|
|
96
|
+
this.pendingRequests.delete(id);
|
|
97
|
+
}
|
|
98
|
+
return [...this.capturedRequests].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
99
|
+
}
|
|
100
|
+
async flush(testInfo) {
|
|
48
101
|
if (this.includeNetworkStats && this.currentNetworkCounter && this.records.length > 0) {
|
|
49
102
|
this.records[this.records.length - 1].networkStats = {
|
|
50
103
|
totalRequests: this.currentNetworkCounter.totalRequests,
|
|
@@ -68,6 +121,15 @@ var NavigationTracker = class {
|
|
|
68
121
|
description: JSON.stringify(annotation)
|
|
69
122
|
});
|
|
70
123
|
}
|
|
124
|
+
if (this.includeNetworkStats) {
|
|
125
|
+
const requests = await this.getCapturedRequests();
|
|
126
|
+
if (requests.length > 0) {
|
|
127
|
+
testInfo.annotations.push({
|
|
128
|
+
type: "__testrelic_network_requests",
|
|
129
|
+
description: JSON.stringify(requests)
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
71
133
|
}
|
|
72
134
|
dispose() {
|
|
73
135
|
this.page.goto = this.origGoto;
|
|
@@ -79,6 +141,10 @@ var NavigationTracker = class {
|
|
|
79
141
|
}
|
|
80
142
|
this.listeners = [];
|
|
81
143
|
this.records = [];
|
|
144
|
+
this.pendingRequests.clear();
|
|
145
|
+
this.capturedRequests = [];
|
|
146
|
+
this.pendingBodyReads = [];
|
|
147
|
+
this.requestCaptureCount = 0;
|
|
82
148
|
}
|
|
83
149
|
getRecords() {
|
|
84
150
|
return this.records;
|
|
@@ -150,29 +216,100 @@ var NavigationTracker = class {
|
|
|
150
216
|
this.page.on("console", onConsoleMessage);
|
|
151
217
|
this.listeners.push({ event: "console", handler: onConsoleMessage });
|
|
152
218
|
if (this.includeNetworkStats) {
|
|
153
|
-
const onRequest = () => {
|
|
219
|
+
const onRequest = (request) => {
|
|
154
220
|
if (this.currentNetworkCounter) {
|
|
155
221
|
this.currentNetworkCounter.totalRequests++;
|
|
156
222
|
}
|
|
223
|
+
if (this.requestCaptureCount >= MAX_REQUESTS_PER_TEST) return;
|
|
224
|
+
this.requestCaptureCount++;
|
|
225
|
+
try {
|
|
226
|
+
const req = request;
|
|
227
|
+
const reqId = String(this.requestIdCounter++);
|
|
228
|
+
let postData = req.postData() ?? null;
|
|
229
|
+
let postDataTruncated = false;
|
|
230
|
+
if (postData !== null) {
|
|
231
|
+
const result = truncateBody(postData, MAX_BODY_SIZE);
|
|
232
|
+
postData = result.text;
|
|
233
|
+
postDataTruncated = result.truncated;
|
|
234
|
+
}
|
|
235
|
+
const pending = {
|
|
236
|
+
url: req.url(),
|
|
237
|
+
method: req.method(),
|
|
238
|
+
resourceType: this.mapResourceType(req.resourceType()),
|
|
239
|
+
headers: req.headers(),
|
|
240
|
+
postData,
|
|
241
|
+
postDataTruncated,
|
|
242
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
243
|
+
startTimeMs: Date.now()
|
|
244
|
+
};
|
|
245
|
+
this.pendingRequests.set(reqId, pending);
|
|
246
|
+
request.__testrelic_id = reqId;
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
157
249
|
};
|
|
158
250
|
this.page.on("request", onRequest);
|
|
159
251
|
this.listeners.push({ event: "request", handler: onRequest });
|
|
160
252
|
const onResponse = (response) => {
|
|
161
|
-
if (!this.currentNetworkCounter) return;
|
|
162
253
|
try {
|
|
163
254
|
const resp = response;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
255
|
+
if (this.currentNetworkCounter) {
|
|
256
|
+
const status = resp.status();
|
|
257
|
+
if (status >= 400) {
|
|
258
|
+
this.currentNetworkCounter.failedRequests++;
|
|
259
|
+
this.currentNetworkCounter.failedRequestUrls.push(status + " " + resp.url());
|
|
260
|
+
}
|
|
261
|
+
const contentLength = resp.headers()["content-length"];
|
|
262
|
+
if (contentLength) {
|
|
263
|
+
this.currentNetworkCounter.totalBytes += parseInt(contentLength, 10) || 0;
|
|
264
|
+
}
|
|
265
|
+
const resourceType = resp.request().resourceType();
|
|
266
|
+
const typeKey = this.mapResourceType(resourceType);
|
|
267
|
+
this.currentNetworkCounter.byType[typeKey]++;
|
|
168
268
|
}
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
269
|
+
const reqId = resp.request().__testrelic_id;
|
|
270
|
+
if (!reqId) return;
|
|
271
|
+
const pending = this.pendingRequests.get(reqId);
|
|
272
|
+
if (!pending) return;
|
|
273
|
+
this.pendingRequests.delete(reqId);
|
|
274
|
+
const responseTimeMs = Date.now() - pending.startTimeMs;
|
|
275
|
+
const respHeaders = resp.headers();
|
|
276
|
+
const contentType = respHeaders["content-type"] ?? null;
|
|
277
|
+
const responseSize = parseInt(respHeaders["content-length"] ?? "0", 10) || 0;
|
|
278
|
+
const binary = contentType ? !isTextContentType(contentType) : false;
|
|
279
|
+
const bodyPromise = (async () => {
|
|
280
|
+
let responseBody = null;
|
|
281
|
+
let responseBodyTruncated = false;
|
|
282
|
+
if (!binary) {
|
|
283
|
+
try {
|
|
284
|
+
const buf = await resp.body();
|
|
285
|
+
const text = buf.toString("utf-8");
|
|
286
|
+
const result = truncateBody(text, MAX_BODY_SIZE);
|
|
287
|
+
responseBody = result.text;
|
|
288
|
+
responseBodyTruncated = result.truncated;
|
|
289
|
+
} catch {
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const captured = {
|
|
293
|
+
url: pending.url,
|
|
294
|
+
method: pending.method,
|
|
295
|
+
resourceType: pending.resourceType,
|
|
296
|
+
statusCode: resp.status(),
|
|
297
|
+
responseTimeMs,
|
|
298
|
+
startedAt: pending.startedAt,
|
|
299
|
+
requestHeaders: pending.headers,
|
|
300
|
+
requestBody: pending.postData,
|
|
301
|
+
responseBody,
|
|
302
|
+
responseHeaders: respHeaders,
|
|
303
|
+
contentType,
|
|
304
|
+
responseSize,
|
|
305
|
+
requestBodyTruncated: pending.postDataTruncated,
|
|
306
|
+
responseBodyTruncated,
|
|
307
|
+
isBinary: binary,
|
|
308
|
+
error: null
|
|
309
|
+
};
|
|
310
|
+
this.capturedRequests.push(captured);
|
|
311
|
+
})();
|
|
312
|
+
this.pendingBodyReads.push(bodyPromise);
|
|
176
313
|
} catch {
|
|
177
314
|
}
|
|
178
315
|
};
|
|
@@ -187,6 +324,34 @@ var NavigationTracker = class {
|
|
|
187
324
|
} catch {
|
|
188
325
|
}
|
|
189
326
|
}
|
|
327
|
+
try {
|
|
328
|
+
const req = request;
|
|
329
|
+
const reqId = req.__testrelic_id;
|
|
330
|
+
if (!reqId) return;
|
|
331
|
+
const pending = this.pendingRequests.get(reqId);
|
|
332
|
+
if (!pending) return;
|
|
333
|
+
this.pendingRequests.delete(reqId);
|
|
334
|
+
const captured = {
|
|
335
|
+
url: pending.url,
|
|
336
|
+
method: pending.method,
|
|
337
|
+
resourceType: pending.resourceType,
|
|
338
|
+
statusCode: 0,
|
|
339
|
+
responseTimeMs: Date.now() - pending.startTimeMs,
|
|
340
|
+
startedAt: pending.startedAt,
|
|
341
|
+
requestHeaders: pending.headers,
|
|
342
|
+
requestBody: pending.postData,
|
|
343
|
+
responseBody: null,
|
|
344
|
+
responseHeaders: null,
|
|
345
|
+
contentType: null,
|
|
346
|
+
responseSize: 0,
|
|
347
|
+
requestBodyTruncated: pending.postDataTruncated,
|
|
348
|
+
responseBodyTruncated: false,
|
|
349
|
+
isBinary: false,
|
|
350
|
+
error: req.failure()?.errorText ?? "Unknown error"
|
|
351
|
+
};
|
|
352
|
+
this.capturedRequests.push(captured);
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
190
355
|
};
|
|
191
356
|
this.page.on("requestfailed", onRequestFailed);
|
|
192
357
|
this.listeners.push({ event: "requestfailed", handler: onRequestFailed });
|
|
@@ -286,7 +451,7 @@ var test = import_test.test.extend({
|
|
|
286
451
|
}
|
|
287
452
|
await use(page);
|
|
288
453
|
try {
|
|
289
|
-
tracker.flush(testInfo);
|
|
454
|
+
await tracker.flush(testInfo);
|
|
290
455
|
} catch {
|
|
291
456
|
}
|
|
292
457
|
tracker.dispose();
|
package/dist/fixture.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/fixture.ts","../src/navigation-tracker.ts"],"sourcesContent":["/**\n * @testrelic/playwright-analytics/fixture\n *\n * Extended Playwright test fixture that wraps the default `page` fixture\n * to automatically track navigation events.\n */\n\nimport { test as base, expect } from '@playwright/test';\nimport { NavigationTracker } from './navigation-tracker.js';\n\nexport { expect };\n\nexport const test = base.extend({\n page: async ({ page }, use, testInfo) => {\n const tracker = new NavigationTracker(page as never);\n try {\n await tracker.init();\n } catch {\n // Graceful degradation: continue without SPA detection\n }\n\n await use(page);\n\n try {\n tracker.flush(testInfo);\n } catch {\n // FR-009: fixture without reporter is harmless\n }\n tracker.dispose();\n },\n});\n","/**\n * NavigationTracker — wraps a Playwright Page to track navigation events.\n *\n * Intercepts page methods (goto, goBack, goForward, reload) and listens\n * for DOM events (framenavigated, domcontentloaded, load) plus injected\n * SPA detection scripts.\n */\n\nimport type { NavigationAnnotation, NavigationType, NetworkStats, ResourceBreakdown } from '@testrelic/core';\n\n// Minimal Playwright Page type subset for loose coupling\ninterface PageLike {\n goto(url: string, options?: unknown): Promise<unknown>;\n goBack(options?: unknown): Promise<unknown>;\n goForward(options?: unknown): Promise<unknown>;\n reload(options?: unknown): Promise<unknown>;\n url(): string;\n on(event: string, handler: (...args: unknown[]) => void): void;\n off(event: string, handler: (...args: unknown[]) => void): void;\n addInitScript(script: string | { path?: string } | (() => void)): Promise<void>;\n evaluate(fn: (...args: unknown[]) => unknown, ...args: unknown[]): Promise<unknown>;\n mainFrame(): { url(): string };\n}\n\ninterface TestInfoLike {\n annotations: Array<{ type: string; description?: string }>;\n}\n\ninterface NavigationRecord {\n url: string;\n navigationType: NavigationType;\n timestamp: string;\n domContentLoadedAt?: string;\n networkIdleAt?: string;\n networkStats?: NetworkStats;\n}\n\ninterface NetworkCounter {\n totalRequests: number;\n failedRequests: number;\n failedRequestUrls: string[];\n totalBytes: number;\n byType: Record<keyof ResourceBreakdown, number>;\n}\n\nexport class NavigationTracker {\n private records: NavigationRecord[] = [];\n private listeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];\n private origGoto: PageLike['goto'];\n private origGoBack: PageLike['goBack'];\n private origGoForward: PageLike['goForward'];\n private origReload: PageLike['reload'];\n private lastDomContentLoaded: string | undefined;\n private includeNetworkStats: boolean;\n private currentNetworkCounter: NetworkCounter | null = null;\n\n constructor(private page: PageLike, options?: { includeNetworkStats?: boolean }) {\n this.includeNetworkStats = options?.includeNetworkStats ?? true;\n // Store originals\n this.origGoto = page.goto.bind(page);\n this.origGoBack = page.goBack.bind(page);\n this.origGoForward = page.goForward.bind(page);\n this.origReload = page.reload.bind(page);\n\n this.interceptMethods();\n this.attachListeners();\n }\n\n async init(): Promise<void> {\n await this.injectSPADetection();\n }\n\n flush(testInfo: TestInfoLike): void {\n // Finalize network stats for the last navigation\n if (this.includeNetworkStats && this.currentNetworkCounter && this.records.length > 0) {\n this.records[this.records.length - 1].networkStats = {\n totalRequests: this.currentNetworkCounter.totalRequests,\n failedRequests: this.currentNetworkCounter.failedRequests,\n failedRequestUrls: [...this.currentNetworkCounter.failedRequestUrls],\n totalBytes: this.currentNetworkCounter.totalBytes,\n byType: { ...this.currentNetworkCounter.byType },\n };\n }\n\n for (const record of this.records) {\n const annotation: NavigationAnnotation = {\n url: record.url,\n navigationType: record.navigationType,\n timestamp: record.timestamp,\n domContentLoadedAt: record.domContentLoadedAt,\n networkIdleAt: record.networkIdleAt,\n networkStats: record.networkStats,\n };\n testInfo.annotations.push({\n type: 'lambdatest-navigation',\n description: JSON.stringify(annotation),\n });\n }\n }\n\n dispose(): void {\n // Restore original methods\n (this.page as Record<string, unknown>).goto = this.origGoto;\n (this.page as Record<string, unknown>).goBack = this.origGoBack;\n (this.page as Record<string, unknown>).goForward = this.origGoForward;\n (this.page as Record<string, unknown>).reload = this.origReload;\n\n // Remove event listeners\n for (const { event, handler } of this.listeners) {\n this.page.off(event, handler);\n }\n this.listeners = [];\n this.records = [];\n }\n\n getRecords(): readonly NavigationRecord[] {\n return this.records;\n }\n\n private interceptMethods(): void {\n const self = this;\n const page = this.page;\n\n (page as Record<string, unknown>).goto = async function (url: string, options?: unknown) {\n self.recordNavigation(url, 'goto');\n return self.origGoto(url, options);\n };\n\n (page as Record<string, unknown>).goBack = async function (options?: unknown) {\n const result = await self.origGoBack(options);\n self.recordNavigation(page.url(), 'back');\n return result;\n };\n\n (page as Record<string, unknown>).goForward = async function (options?: unknown) {\n const result = await self.origGoForward(options);\n self.recordNavigation(page.url(), 'forward');\n return result;\n };\n\n (page as Record<string, unknown>).reload = async function (options?: unknown) {\n self.recordNavigation(page.url(), 'refresh');\n return self.origReload(options);\n };\n }\n\n private attachListeners(): void {\n // Track DOMContentLoaded for lifecycle timestamps\n const onDomContentLoaded = () => {\n this.lastDomContentLoaded = new Date().toISOString();\n if (this.records.length > 0) {\n this.records[this.records.length - 1].domContentLoadedAt = this.lastDomContentLoaded;\n }\n };\n this.page.on('domcontentloaded', onDomContentLoaded as (...args: unknown[]) => void);\n this.listeners.push({ event: 'domcontentloaded', handler: onDomContentLoaded as (...args: unknown[]) => void });\n\n // Track main frame navigation for non-intercepted navigations (link clicks, form submits)\n const onFrameNavigated = (frame: unknown) => {\n // Only track main frame\n try {\n const frameObj = frame as { url(): string; parentFrame(): unknown };\n if (typeof frameObj.parentFrame === 'function' && frameObj.parentFrame() !== null) {\n return; // Skip sub-frames\n }\n const url = frameObj.url();\n // Skip if we already recorded this URL in the last 50ms (from method interception)\n const lastRecord = this.records[this.records.length - 1];\n if (lastRecord) {\n const timeDiff = Date.now() - new Date(lastRecord.timestamp).getTime();\n if (timeDiff < 50 && lastRecord.url === url) {\n return; // Duplicate from method interception\n }\n }\n // This is an untracked navigation (link click, form submit, etc.)\n this.recordNavigation(url, 'navigation');\n } catch {\n // Ignore frame navigation errors\n }\n };\n this.page.on('framenavigated', onFrameNavigated);\n this.listeners.push({ event: 'framenavigated', handler: onFrameNavigated });\n\n // Track console messages from injected SPA detection script\n const onConsoleMessage = (msg: unknown) => {\n try {\n const msgObj = msg as { text(): string; type(): string };\n if (msgObj.type() !== 'debug') return;\n const text = msgObj.text();\n if (!text.startsWith('__testrelic_nav:')) return;\n const data = JSON.parse(text.slice('__testrelic_nav:'.length));\n if (data.type && data.url) {\n this.recordNavigation(data.url, data.type as NavigationType);\n }\n } catch {\n // Ignore parse errors\n }\n };\n this.page.on('console', onConsoleMessage);\n this.listeners.push({ event: 'console', handler: onConsoleMessage });\n\n // Network stats tracking\n if (this.includeNetworkStats) {\n const onRequest = () => {\n if (this.currentNetworkCounter) {\n this.currentNetworkCounter.totalRequests++;\n }\n };\n this.page.on('request', onRequest);\n this.listeners.push({ event: 'request', handler: onRequest });\n\n const onResponse = (response: unknown) => {\n if (!this.currentNetworkCounter) return;\n try {\n const resp = response as {\n url(): string;\n status(): number;\n headers(): Record<string, string>;\n request(): { resourceType(): string };\n };\n const status = resp.status();\n if (status >= 400) {\n this.currentNetworkCounter.failedRequests++;\n this.currentNetworkCounter.failedRequestUrls.push(status + ' ' + resp.url());\n }\n const contentLength = resp.headers()['content-length'];\n if (contentLength) {\n this.currentNetworkCounter.totalBytes += parseInt(contentLength, 10) || 0;\n }\n const resourceType = resp.request().resourceType();\n const typeKey = this.mapResourceType(resourceType);\n this.currentNetworkCounter.byType[typeKey]++;\n } catch {\n // Ignore response processing errors\n }\n };\n this.page.on('response', onResponse);\n this.listeners.push({ event: 'response', handler: onResponse });\n\n const onRequestFailed = (request: unknown) => {\n if (this.currentNetworkCounter) {\n this.currentNetworkCounter.failedRequests++;\n try {\n const req = request as { url(): string };\n this.currentNetworkCounter.failedRequestUrls.push('ERR ' + req.url());\n } catch {\n // Ignore\n }\n }\n };\n this.page.on('requestfailed', onRequestFailed);\n this.listeners.push({ event: 'requestfailed', handler: onRequestFailed });\n }\n }\n\n private async injectSPADetection(): Promise<void> {\n try {\n await this.page.addInitScript(() => {\n const origPush = history.pushState.bind(history);\n const origReplace = history.replaceState.bind(history);\n\n history.pushState = function (...args: Parameters<typeof origPush>) {\n origPush(...args);\n // eslint-disable-next-line no-console\n console.debug('__testrelic_nav:' + JSON.stringify({\n type: 'spa_route',\n url: location.href,\n }));\n };\n\n history.replaceState = function (...args: Parameters<typeof origReplace>) {\n origReplace(...args);\n // eslint-disable-next-line no-console\n console.debug('__testrelic_nav:' + JSON.stringify({\n type: 'spa_replace',\n url: location.href,\n }));\n };\n\n window.addEventListener('popstate', () => {\n // eslint-disable-next-line no-console\n console.debug('__testrelic_nav:' + JSON.stringify({\n type: 'popstate',\n url: location.href,\n }));\n });\n\n window.addEventListener('hashchange', () => {\n // eslint-disable-next-line no-console\n console.debug('__testrelic_nav:' + JSON.stringify({\n type: 'hash_change',\n url: location.href,\n }));\n });\n });\n } catch {\n // Ignore injection errors (page may be closed)\n }\n }\n\n private recordNavigation(url: string, type: NavigationType): void {\n // Finalize network stats for the previous navigation\n if (this.includeNetworkStats && this.currentNetworkCounter && this.records.length > 0) {\n this.records[this.records.length - 1].networkStats = {\n totalRequests: this.currentNetworkCounter.totalRequests,\n failedRequests: this.currentNetworkCounter.failedRequests,\n failedRequestUrls: [...this.currentNetworkCounter.failedRequestUrls],\n totalBytes: this.currentNetworkCounter.totalBytes,\n byType: { ...this.currentNetworkCounter.byType },\n };\n }\n\n this.records.push({\n url,\n navigationType: type,\n timestamp: new Date().toISOString(),\n });\n\n // Start fresh counter for this navigation\n if (this.includeNetworkStats) {\n this.currentNetworkCounter = this.createNetworkCounter();\n }\n }\n\n private createNetworkCounter(): NetworkCounter {\n return {\n totalRequests: 0,\n failedRequests: 0,\n failedRequestUrls: [],\n totalBytes: 0,\n byType: { xhr: 0, document: 0, script: 0, stylesheet: 0, image: 0, font: 0, other: 0 },\n };\n }\n\n private mapResourceType(type: string): keyof ResourceBreakdown {\n switch (type) {\n case 'xhr':\n case 'fetch':\n return 'xhr';\n case 'document':\n return 'document';\n case 'script':\n return 'script';\n case 'stylesheet':\n return 'stylesheet';\n case 'image':\n return 'image';\n case 'font':\n return 'font';\n default:\n return 'other';\n }\n }\n}\n"],"mappings":"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","names":["base"]}
|
|
1
|
+
{"version":3,"sources":["../src/fixture.ts","../src/navigation-tracker.ts"],"sourcesContent":["/**\n * @testrelic/playwright-analytics/fixture\n *\n * Extended Playwright test fixture that wraps the default `page` fixture\n * to automatically track navigation events.\n */\n\nimport { test as base, expect } from '@playwright/test';\nimport { NavigationTracker } from './navigation-tracker.js';\n\nexport { expect };\n\nexport const test = base.extend({\n page: async ({ page }, use, testInfo) => {\n const tracker = new NavigationTracker(page as never);\n try {\n await tracker.init();\n } catch {\n // Graceful degradation: continue without SPA detection\n }\n\n await use(page);\n\n try {\n await tracker.flush(testInfo);\n } catch {\n // FR-009: fixture without reporter is harmless\n }\n tracker.dispose();\n },\n});\n","/**\n * NavigationTracker — wraps a Playwright Page to track navigation events.\n *\n * Intercepts page methods (goto, goBack, goForward, reload) and listens\n * for DOM events (framenavigated, domcontentloaded, load) plus injected\n * SPA detection scripts.\n */\n\nimport type { CapturedNetworkRequest, NavigationAnnotation, NavigationType, NetworkStats, ResourceBreakdown, ResourceType } from '@testrelic/core';\n\n// Minimal Playwright Page type subset for loose coupling\ninterface PageLike {\n goto(url: string, options?: unknown): Promise<unknown>;\n goBack(options?: unknown): Promise<unknown>;\n goForward(options?: unknown): Promise<unknown>;\n reload(options?: unknown): Promise<unknown>;\n url(): string;\n on(event: string, handler: (...args: unknown[]) => void): void;\n off(event: string, handler: (...args: unknown[]) => void): void;\n addInitScript(script: string | { path?: string } | (() => void)): Promise<void>;\n evaluate(fn: (...args: unknown[]) => unknown, ...args: unknown[]): Promise<unknown>;\n mainFrame(): { url(): string };\n}\n\ninterface TestInfoLike {\n annotations: Array<{ type: string; description?: string }>;\n}\n\ninterface NavigationRecord {\n url: string;\n navigationType: NavigationType;\n timestamp: string;\n domContentLoadedAt?: string;\n networkIdleAt?: string;\n networkStats?: NetworkStats;\n}\n\ninterface NetworkCounter {\n totalRequests: number;\n failedRequests: number;\n failedRequestUrls: string[];\n totalBytes: number;\n byType: Record<keyof ResourceBreakdown, number>;\n}\n\ninterface PendingRequest {\n url: string;\n method: string;\n resourceType: ResourceType;\n headers: Record<string, string>;\n postData: string | null;\n postDataTruncated: boolean;\n startedAt: string;\n startTimeMs: number;\n}\n\nconst MAX_BODY_SIZE = 10240; // 10 KB\nconst MAX_REQUESTS_PER_TEST = 500;\n\nconst TEXT_CONTENT_TYPES = [\n 'text/',\n 'application/json',\n 'application/xml',\n 'application/javascript',\n 'application/x-www-form-urlencoded',\n 'application/graphql',\n];\n\nfunction isTextContentType(contentType: string): boolean {\n const lower = contentType.toLowerCase();\n return TEXT_CONTENT_TYPES.some(prefix => lower.includes(prefix));\n}\n\nfunction truncateBody(body: string, maxSize: number): { text: string; truncated: boolean } {\n const bytes = new TextEncoder().encode(body);\n if (bytes.length <= maxSize) {\n return { text: body, truncated: false };\n }\n // Truncate at byte boundary, decode back to valid string\n const truncated = new TextDecoder().decode(bytes.slice(0, maxSize));\n return { text: truncated, truncated: true };\n}\n\nexport class NavigationTracker {\n private records: NavigationRecord[] = [];\n private listeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];\n private origGoto: PageLike['goto'];\n private origGoBack: PageLike['goBack'];\n private origGoForward: PageLike['goForward'];\n private origReload: PageLike['reload'];\n private lastDomContentLoaded: string | undefined;\n private includeNetworkStats: boolean;\n private currentNetworkCounter: NetworkCounter | null = null;\n private pendingRequests = new Map<string, PendingRequest>();\n private capturedRequests: CapturedNetworkRequest[] = [];\n private pendingBodyReads: Promise<void>[] = [];\n private requestIdCounter = 0;\n private requestCaptureCount = 0;\n\n constructor(private page: PageLike, options?: { includeNetworkStats?: boolean }) {\n this.includeNetworkStats = options?.includeNetworkStats ?? true;\n // Store originals\n this.origGoto = page.goto.bind(page);\n this.origGoBack = page.goBack.bind(page);\n this.origGoForward = page.goForward.bind(page);\n this.origReload = page.reload.bind(page);\n\n this.interceptMethods();\n this.attachListeners();\n }\n\n async init(): Promise<void> {\n await this.injectSPADetection();\n }\n\n async getCapturedRequests(): Promise<CapturedNetworkRequest[]> {\n // Wait for all pending body reads to complete\n await Promise.allSettled(this.pendingBodyReads);\n this.pendingBodyReads = [];\n // Convert any remaining pending requests (incomplete) to captured entries\n for (const [id, pending] of this.pendingRequests) {\n this.capturedRequests.push({\n url: pending.url,\n method: pending.method,\n resourceType: pending.resourceType,\n statusCode: 0,\n responseTimeMs: Date.now() - pending.startTimeMs,\n startedAt: pending.startedAt,\n requestHeaders: pending.headers,\n requestBody: pending.postData,\n responseBody: null,\n responseHeaders: null,\n contentType: null,\n responseSize: 0,\n requestBodyTruncated: pending.postDataTruncated,\n responseBodyTruncated: false,\n isBinary: false,\n error: 'incomplete',\n });\n this.pendingRequests.delete(id);\n }\n // Return sorted by startedAt\n return [...this.capturedRequests].sort((a, b) => a.startedAt.localeCompare(b.startedAt));\n }\n\n async flush(testInfo: TestInfoLike): Promise<void> {\n // Finalize network stats for the last navigation\n if (this.includeNetworkStats && this.currentNetworkCounter && this.records.length > 0) {\n this.records[this.records.length - 1].networkStats = {\n totalRequests: this.currentNetworkCounter.totalRequests,\n failedRequests: this.currentNetworkCounter.failedRequests,\n failedRequestUrls: [...this.currentNetworkCounter.failedRequestUrls],\n totalBytes: this.currentNetworkCounter.totalBytes,\n byType: { ...this.currentNetworkCounter.byType },\n };\n }\n\n for (const record of this.records) {\n const annotation: NavigationAnnotation = {\n url: record.url,\n navigationType: record.navigationType,\n timestamp: record.timestamp,\n domContentLoadedAt: record.domContentLoadedAt,\n networkIdleAt: record.networkIdleAt,\n networkStats: record.networkStats,\n };\n testInfo.annotations.push({\n type: 'lambdatest-navigation',\n description: JSON.stringify(annotation),\n });\n }\n\n // Capture and serialize individual network requests\n if (this.includeNetworkStats) {\n const requests = await this.getCapturedRequests();\n if (requests.length > 0) {\n testInfo.annotations.push({\n type: '__testrelic_network_requests',\n description: JSON.stringify(requests),\n });\n }\n }\n }\n\n dispose(): void {\n // Restore original methods\n (this.page as Record<string, unknown>).goto = this.origGoto;\n (this.page as Record<string, unknown>).goBack = this.origGoBack;\n (this.page as Record<string, unknown>).goForward = this.origGoForward;\n (this.page as Record<string, unknown>).reload = this.origReload;\n\n // Remove event listeners\n for (const { event, handler } of this.listeners) {\n this.page.off(event, handler);\n }\n this.listeners = [];\n this.records = [];\n this.pendingRequests.clear();\n this.capturedRequests = [];\n this.pendingBodyReads = [];\n this.requestCaptureCount = 0;\n }\n\n getRecords(): readonly NavigationRecord[] {\n return this.records;\n }\n\n private interceptMethods(): void {\n const self = this;\n const page = this.page;\n\n (page as Record<string, unknown>).goto = async function (url: string, options?: unknown) {\n self.recordNavigation(url, 'goto');\n return self.origGoto(url, options);\n };\n\n (page as Record<string, unknown>).goBack = async function (options?: unknown) {\n const result = await self.origGoBack(options);\n self.recordNavigation(page.url(), 'back');\n return result;\n };\n\n (page as Record<string, unknown>).goForward = async function (options?: unknown) {\n const result = await self.origGoForward(options);\n self.recordNavigation(page.url(), 'forward');\n return result;\n };\n\n (page as Record<string, unknown>).reload = async function (options?: unknown) {\n self.recordNavigation(page.url(), 'refresh');\n return self.origReload(options);\n };\n }\n\n private attachListeners(): void {\n // Track DOMContentLoaded for lifecycle timestamps\n const onDomContentLoaded = () => {\n this.lastDomContentLoaded = new Date().toISOString();\n if (this.records.length > 0) {\n this.records[this.records.length - 1].domContentLoadedAt = this.lastDomContentLoaded;\n }\n };\n this.page.on('domcontentloaded', onDomContentLoaded as (...args: unknown[]) => void);\n this.listeners.push({ event: 'domcontentloaded', handler: onDomContentLoaded as (...args: unknown[]) => void });\n\n // Track main frame navigation for non-intercepted navigations (link clicks, form submits)\n const onFrameNavigated = (frame: unknown) => {\n // Only track main frame\n try {\n const frameObj = frame as { url(): string; parentFrame(): unknown };\n if (typeof frameObj.parentFrame === 'function' && frameObj.parentFrame() !== null) {\n return; // Skip sub-frames\n }\n const url = frameObj.url();\n // Skip if we already recorded this URL in the last 50ms (from method interception)\n const lastRecord = this.records[this.records.length - 1];\n if (lastRecord) {\n const timeDiff = Date.now() - new Date(lastRecord.timestamp).getTime();\n if (timeDiff < 50 && lastRecord.url === url) {\n return; // Duplicate from method interception\n }\n }\n // This is an untracked navigation (link click, form submit, etc.)\n this.recordNavigation(url, 'navigation');\n } catch {\n // Ignore frame navigation errors\n }\n };\n this.page.on('framenavigated', onFrameNavigated);\n this.listeners.push({ event: 'framenavigated', handler: onFrameNavigated });\n\n // Track console messages from injected SPA detection script\n const onConsoleMessage = (msg: unknown) => {\n try {\n const msgObj = msg as { text(): string; type(): string };\n if (msgObj.type() !== 'debug') return;\n const text = msgObj.text();\n if (!text.startsWith('__testrelic_nav:')) return;\n const data = JSON.parse(text.slice('__testrelic_nav:'.length));\n if (data.type && data.url) {\n this.recordNavigation(data.url, data.type as NavigationType);\n }\n } catch {\n // Ignore parse errors\n }\n };\n this.page.on('console', onConsoleMessage);\n this.listeners.push({ event: 'console', handler: onConsoleMessage });\n\n // Network stats tracking + individual request capture\n if (this.includeNetworkStats) {\n const onRequest = (request: unknown) => {\n if (this.currentNetworkCounter) {\n this.currentNetworkCounter.totalRequests++;\n }\n // Capture individual request details\n if (this.requestCaptureCount >= MAX_REQUESTS_PER_TEST) return;\n this.requestCaptureCount++;\n try {\n const req = request as {\n url(): string;\n method(): string;\n resourceType(): string;\n headers(): Record<string, string>;\n postData(): string | null;\n };\n const reqId = String(this.requestIdCounter++);\n let postData = req.postData() ?? null;\n let postDataTruncated = false;\n if (postData !== null) {\n const result = truncateBody(postData, MAX_BODY_SIZE);\n postData = result.text;\n postDataTruncated = result.truncated;\n }\n const pending: PendingRequest = {\n url: req.url(),\n method: req.method(),\n resourceType: this.mapResourceType(req.resourceType()),\n headers: req.headers(),\n postData,\n postDataTruncated,\n startedAt: new Date().toISOString(),\n startTimeMs: Date.now(),\n };\n this.pendingRequests.set(reqId, pending);\n // Tag the request object with our ID for lookup in response/failed handlers\n (request as Record<string, unknown>).__testrelic_id = reqId;\n } catch {\n // Ignore request capture errors\n }\n };\n this.page.on('request', onRequest);\n this.listeners.push({ event: 'request', handler: onRequest });\n\n const onResponse = (response: unknown) => {\n try {\n const resp = response as {\n url(): string;\n status(): number;\n headers(): Record<string, string>;\n request(): { resourceType(): string; __testrelic_id?: string };\n body(): Promise<Buffer>;\n };\n // Aggregate stats (existing logic)\n if (this.currentNetworkCounter) {\n const status = resp.status();\n if (status >= 400) {\n this.currentNetworkCounter.failedRequests++;\n this.currentNetworkCounter.failedRequestUrls.push(status + ' ' + resp.url());\n }\n const contentLength = resp.headers()['content-length'];\n if (contentLength) {\n this.currentNetworkCounter.totalBytes += parseInt(contentLength, 10) || 0;\n }\n const resourceType = resp.request().resourceType();\n const typeKey = this.mapResourceType(resourceType);\n this.currentNetworkCounter.byType[typeKey]++;\n }\n // Individual request capture\n const reqId = (resp.request() as Record<string, unknown>).__testrelic_id as string | undefined;\n if (!reqId) return;\n const pending = this.pendingRequests.get(reqId);\n if (!pending) return;\n this.pendingRequests.delete(reqId);\n const responseTimeMs = Date.now() - pending.startTimeMs;\n const respHeaders = resp.headers();\n const contentType = respHeaders['content-type'] ?? null;\n const responseSize = parseInt(respHeaders['content-length'] ?? '0', 10) || 0;\n const binary = contentType ? !isTextContentType(contentType) : false;\n // Read response body asynchronously\n const bodyPromise = (async () => {\n let responseBody: string | null = null;\n let responseBodyTruncated = false;\n if (!binary) {\n try {\n const buf = await resp.body();\n const text = buf.toString('utf-8');\n const result = truncateBody(text, MAX_BODY_SIZE);\n responseBody = result.text;\n responseBodyTruncated = result.truncated;\n } catch {\n // Body unavailable (e.g., redirect)\n }\n }\n const captured: CapturedNetworkRequest = {\n url: pending.url,\n method: pending.method,\n resourceType: pending.resourceType,\n statusCode: resp.status(),\n responseTimeMs,\n startedAt: pending.startedAt,\n requestHeaders: pending.headers,\n requestBody: pending.postData,\n responseBody,\n responseHeaders: respHeaders,\n contentType,\n responseSize,\n requestBodyTruncated: pending.postDataTruncated,\n responseBodyTruncated,\n isBinary: binary,\n error: null,\n };\n this.capturedRequests.push(captured);\n })();\n this.pendingBodyReads.push(bodyPromise);\n } catch {\n // Ignore response processing errors\n }\n };\n this.page.on('response', onResponse);\n this.listeners.push({ event: 'response', handler: onResponse });\n\n const onRequestFailed = (request: unknown) => {\n // Aggregate stats (existing logic)\n if (this.currentNetworkCounter) {\n this.currentNetworkCounter.failedRequests++;\n try {\n const req = request as { url(): string };\n this.currentNetworkCounter.failedRequestUrls.push('ERR ' + req.url());\n } catch {\n // Ignore\n }\n }\n // Individual request capture\n try {\n const req = request as { failure(): { errorText: string } | null; __testrelic_id?: string };\n const reqId = (req as Record<string, unknown>).__testrelic_id as string | undefined;\n if (!reqId) return;\n const pending = this.pendingRequests.get(reqId);\n if (!pending) return;\n this.pendingRequests.delete(reqId);\n const captured: CapturedNetworkRequest = {\n url: pending.url,\n method: pending.method,\n resourceType: pending.resourceType,\n statusCode: 0,\n responseTimeMs: Date.now() - pending.startTimeMs,\n startedAt: pending.startedAt,\n requestHeaders: pending.headers,\n requestBody: pending.postData,\n responseBody: null,\n responseHeaders: null,\n contentType: null,\n responseSize: 0,\n requestBodyTruncated: pending.postDataTruncated,\n responseBodyTruncated: false,\n isBinary: false,\n error: req.failure()?.errorText ?? 'Unknown error',\n };\n this.capturedRequests.push(captured);\n } catch {\n // Ignore\n }\n };\n this.page.on('requestfailed', onRequestFailed);\n this.listeners.push({ event: 'requestfailed', handler: onRequestFailed });\n }\n }\n\n private async injectSPADetection(): Promise<void> {\n try {\n await this.page.addInitScript(() => {\n const origPush = history.pushState.bind(history);\n const origReplace = history.replaceState.bind(history);\n\n history.pushState = function (...args: Parameters<typeof origPush>) {\n origPush(...args);\n // eslint-disable-next-line no-console\n console.debug('__testrelic_nav:' + JSON.stringify({\n type: 'spa_route',\n url: location.href,\n }));\n };\n\n history.replaceState = function (...args: Parameters<typeof origReplace>) {\n origReplace(...args);\n // eslint-disable-next-line no-console\n console.debug('__testrelic_nav:' + JSON.stringify({\n type: 'spa_replace',\n url: location.href,\n }));\n };\n\n window.addEventListener('popstate', () => {\n // eslint-disable-next-line no-console\n console.debug('__testrelic_nav:' + JSON.stringify({\n type: 'popstate',\n url: location.href,\n }));\n });\n\n window.addEventListener('hashchange', () => {\n // eslint-disable-next-line no-console\n console.debug('__testrelic_nav:' + JSON.stringify({\n type: 'hash_change',\n url: location.href,\n }));\n });\n });\n } catch {\n // Ignore injection errors (page may be closed)\n }\n }\n\n private recordNavigation(url: string, type: NavigationType): void {\n // Finalize network stats for the previous navigation\n if (this.includeNetworkStats && this.currentNetworkCounter && this.records.length > 0) {\n this.records[this.records.length - 1].networkStats = {\n totalRequests: this.currentNetworkCounter.totalRequests,\n failedRequests: this.currentNetworkCounter.failedRequests,\n failedRequestUrls: [...this.currentNetworkCounter.failedRequestUrls],\n totalBytes: this.currentNetworkCounter.totalBytes,\n byType: { ...this.currentNetworkCounter.byType },\n };\n }\n\n this.records.push({\n url,\n navigationType: type,\n timestamp: new Date().toISOString(),\n });\n\n // Start fresh counter for this navigation\n if (this.includeNetworkStats) {\n this.currentNetworkCounter = this.createNetworkCounter();\n }\n }\n\n private createNetworkCounter(): NetworkCounter {\n return {\n totalRequests: 0,\n failedRequests: 0,\n failedRequestUrls: [],\n totalBytes: 0,\n byType: { xhr: 0, document: 0, script: 0, stylesheet: 0, image: 0, font: 0, other: 0 },\n };\n }\n\n private mapResourceType(type: string): keyof ResourceBreakdown {\n switch (type) {\n case 'xhr':\n case 'fetch':\n return 'xhr';\n case 'document':\n return 'document';\n case 'script':\n return 'script';\n case 'stylesheet':\n return 'stylesheet';\n case 'image':\n return 'image';\n case 'font':\n return 'font';\n default:\n return 'other';\n }\n }\n}\n"],"mappings":"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","names":["base"]}
|
package/dist/fixture.js
CHANGED
|
@@ -2,12 +2,39 @@
|
|
|
2
2
|
import { test as base, expect } from "@playwright/test";
|
|
3
3
|
|
|
4
4
|
// src/navigation-tracker.ts
|
|
5
|
+
var MAX_BODY_SIZE = 10240;
|
|
6
|
+
var MAX_REQUESTS_PER_TEST = 500;
|
|
7
|
+
var TEXT_CONTENT_TYPES = [
|
|
8
|
+
"text/",
|
|
9
|
+
"application/json",
|
|
10
|
+
"application/xml",
|
|
11
|
+
"application/javascript",
|
|
12
|
+
"application/x-www-form-urlencoded",
|
|
13
|
+
"application/graphql"
|
|
14
|
+
];
|
|
15
|
+
function isTextContentType(contentType) {
|
|
16
|
+
const lower = contentType.toLowerCase();
|
|
17
|
+
return TEXT_CONTENT_TYPES.some((prefix) => lower.includes(prefix));
|
|
18
|
+
}
|
|
19
|
+
function truncateBody(body, maxSize) {
|
|
20
|
+
const bytes = new TextEncoder().encode(body);
|
|
21
|
+
if (bytes.length <= maxSize) {
|
|
22
|
+
return { text: body, truncated: false };
|
|
23
|
+
}
|
|
24
|
+
const truncated = new TextDecoder().decode(bytes.slice(0, maxSize));
|
|
25
|
+
return { text: truncated, truncated: true };
|
|
26
|
+
}
|
|
5
27
|
var NavigationTracker = class {
|
|
6
28
|
constructor(page, options) {
|
|
7
29
|
this.page = page;
|
|
8
30
|
this.records = [];
|
|
9
31
|
this.listeners = [];
|
|
10
32
|
this.currentNetworkCounter = null;
|
|
33
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
34
|
+
this.capturedRequests = [];
|
|
35
|
+
this.pendingBodyReads = [];
|
|
36
|
+
this.requestIdCounter = 0;
|
|
37
|
+
this.requestCaptureCount = 0;
|
|
11
38
|
this.includeNetworkStats = options?.includeNetworkStats ?? true;
|
|
12
39
|
this.origGoto = page.goto.bind(page);
|
|
13
40
|
this.origGoBack = page.goBack.bind(page);
|
|
@@ -19,7 +46,33 @@ var NavigationTracker = class {
|
|
|
19
46
|
async init() {
|
|
20
47
|
await this.injectSPADetection();
|
|
21
48
|
}
|
|
22
|
-
|
|
49
|
+
async getCapturedRequests() {
|
|
50
|
+
await Promise.allSettled(this.pendingBodyReads);
|
|
51
|
+
this.pendingBodyReads = [];
|
|
52
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
53
|
+
this.capturedRequests.push({
|
|
54
|
+
url: pending.url,
|
|
55
|
+
method: pending.method,
|
|
56
|
+
resourceType: pending.resourceType,
|
|
57
|
+
statusCode: 0,
|
|
58
|
+
responseTimeMs: Date.now() - pending.startTimeMs,
|
|
59
|
+
startedAt: pending.startedAt,
|
|
60
|
+
requestHeaders: pending.headers,
|
|
61
|
+
requestBody: pending.postData,
|
|
62
|
+
responseBody: null,
|
|
63
|
+
responseHeaders: null,
|
|
64
|
+
contentType: null,
|
|
65
|
+
responseSize: 0,
|
|
66
|
+
requestBodyTruncated: pending.postDataTruncated,
|
|
67
|
+
responseBodyTruncated: false,
|
|
68
|
+
isBinary: false,
|
|
69
|
+
error: "incomplete"
|
|
70
|
+
});
|
|
71
|
+
this.pendingRequests.delete(id);
|
|
72
|
+
}
|
|
73
|
+
return [...this.capturedRequests].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
74
|
+
}
|
|
75
|
+
async flush(testInfo) {
|
|
23
76
|
if (this.includeNetworkStats && this.currentNetworkCounter && this.records.length > 0) {
|
|
24
77
|
this.records[this.records.length - 1].networkStats = {
|
|
25
78
|
totalRequests: this.currentNetworkCounter.totalRequests,
|
|
@@ -43,6 +96,15 @@ var NavigationTracker = class {
|
|
|
43
96
|
description: JSON.stringify(annotation)
|
|
44
97
|
});
|
|
45
98
|
}
|
|
99
|
+
if (this.includeNetworkStats) {
|
|
100
|
+
const requests = await this.getCapturedRequests();
|
|
101
|
+
if (requests.length > 0) {
|
|
102
|
+
testInfo.annotations.push({
|
|
103
|
+
type: "__testrelic_network_requests",
|
|
104
|
+
description: JSON.stringify(requests)
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
46
108
|
}
|
|
47
109
|
dispose() {
|
|
48
110
|
this.page.goto = this.origGoto;
|
|
@@ -54,6 +116,10 @@ var NavigationTracker = class {
|
|
|
54
116
|
}
|
|
55
117
|
this.listeners = [];
|
|
56
118
|
this.records = [];
|
|
119
|
+
this.pendingRequests.clear();
|
|
120
|
+
this.capturedRequests = [];
|
|
121
|
+
this.pendingBodyReads = [];
|
|
122
|
+
this.requestCaptureCount = 0;
|
|
57
123
|
}
|
|
58
124
|
getRecords() {
|
|
59
125
|
return this.records;
|
|
@@ -125,29 +191,100 @@ var NavigationTracker = class {
|
|
|
125
191
|
this.page.on("console", onConsoleMessage);
|
|
126
192
|
this.listeners.push({ event: "console", handler: onConsoleMessage });
|
|
127
193
|
if (this.includeNetworkStats) {
|
|
128
|
-
const onRequest = () => {
|
|
194
|
+
const onRequest = (request) => {
|
|
129
195
|
if (this.currentNetworkCounter) {
|
|
130
196
|
this.currentNetworkCounter.totalRequests++;
|
|
131
197
|
}
|
|
198
|
+
if (this.requestCaptureCount >= MAX_REQUESTS_PER_TEST) return;
|
|
199
|
+
this.requestCaptureCount++;
|
|
200
|
+
try {
|
|
201
|
+
const req = request;
|
|
202
|
+
const reqId = String(this.requestIdCounter++);
|
|
203
|
+
let postData = req.postData() ?? null;
|
|
204
|
+
let postDataTruncated = false;
|
|
205
|
+
if (postData !== null) {
|
|
206
|
+
const result = truncateBody(postData, MAX_BODY_SIZE);
|
|
207
|
+
postData = result.text;
|
|
208
|
+
postDataTruncated = result.truncated;
|
|
209
|
+
}
|
|
210
|
+
const pending = {
|
|
211
|
+
url: req.url(),
|
|
212
|
+
method: req.method(),
|
|
213
|
+
resourceType: this.mapResourceType(req.resourceType()),
|
|
214
|
+
headers: req.headers(),
|
|
215
|
+
postData,
|
|
216
|
+
postDataTruncated,
|
|
217
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
218
|
+
startTimeMs: Date.now()
|
|
219
|
+
};
|
|
220
|
+
this.pendingRequests.set(reqId, pending);
|
|
221
|
+
request.__testrelic_id = reqId;
|
|
222
|
+
} catch {
|
|
223
|
+
}
|
|
132
224
|
};
|
|
133
225
|
this.page.on("request", onRequest);
|
|
134
226
|
this.listeners.push({ event: "request", handler: onRequest });
|
|
135
227
|
const onResponse = (response) => {
|
|
136
|
-
if (!this.currentNetworkCounter) return;
|
|
137
228
|
try {
|
|
138
229
|
const resp = response;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
230
|
+
if (this.currentNetworkCounter) {
|
|
231
|
+
const status = resp.status();
|
|
232
|
+
if (status >= 400) {
|
|
233
|
+
this.currentNetworkCounter.failedRequests++;
|
|
234
|
+
this.currentNetworkCounter.failedRequestUrls.push(status + " " + resp.url());
|
|
235
|
+
}
|
|
236
|
+
const contentLength = resp.headers()["content-length"];
|
|
237
|
+
if (contentLength) {
|
|
238
|
+
this.currentNetworkCounter.totalBytes += parseInt(contentLength, 10) || 0;
|
|
239
|
+
}
|
|
240
|
+
const resourceType = resp.request().resourceType();
|
|
241
|
+
const typeKey = this.mapResourceType(resourceType);
|
|
242
|
+
this.currentNetworkCounter.byType[typeKey]++;
|
|
143
243
|
}
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
244
|
+
const reqId = resp.request().__testrelic_id;
|
|
245
|
+
if (!reqId) return;
|
|
246
|
+
const pending = this.pendingRequests.get(reqId);
|
|
247
|
+
if (!pending) return;
|
|
248
|
+
this.pendingRequests.delete(reqId);
|
|
249
|
+
const responseTimeMs = Date.now() - pending.startTimeMs;
|
|
250
|
+
const respHeaders = resp.headers();
|
|
251
|
+
const contentType = respHeaders["content-type"] ?? null;
|
|
252
|
+
const responseSize = parseInt(respHeaders["content-length"] ?? "0", 10) || 0;
|
|
253
|
+
const binary = contentType ? !isTextContentType(contentType) : false;
|
|
254
|
+
const bodyPromise = (async () => {
|
|
255
|
+
let responseBody = null;
|
|
256
|
+
let responseBodyTruncated = false;
|
|
257
|
+
if (!binary) {
|
|
258
|
+
try {
|
|
259
|
+
const buf = await resp.body();
|
|
260
|
+
const text = buf.toString("utf-8");
|
|
261
|
+
const result = truncateBody(text, MAX_BODY_SIZE);
|
|
262
|
+
responseBody = result.text;
|
|
263
|
+
responseBodyTruncated = result.truncated;
|
|
264
|
+
} catch {
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const captured = {
|
|
268
|
+
url: pending.url,
|
|
269
|
+
method: pending.method,
|
|
270
|
+
resourceType: pending.resourceType,
|
|
271
|
+
statusCode: resp.status(),
|
|
272
|
+
responseTimeMs,
|
|
273
|
+
startedAt: pending.startedAt,
|
|
274
|
+
requestHeaders: pending.headers,
|
|
275
|
+
requestBody: pending.postData,
|
|
276
|
+
responseBody,
|
|
277
|
+
responseHeaders: respHeaders,
|
|
278
|
+
contentType,
|
|
279
|
+
responseSize,
|
|
280
|
+
requestBodyTruncated: pending.postDataTruncated,
|
|
281
|
+
responseBodyTruncated,
|
|
282
|
+
isBinary: binary,
|
|
283
|
+
error: null
|
|
284
|
+
};
|
|
285
|
+
this.capturedRequests.push(captured);
|
|
286
|
+
})();
|
|
287
|
+
this.pendingBodyReads.push(bodyPromise);
|
|
151
288
|
} catch {
|
|
152
289
|
}
|
|
153
290
|
};
|
|
@@ -162,6 +299,34 @@ var NavigationTracker = class {
|
|
|
162
299
|
} catch {
|
|
163
300
|
}
|
|
164
301
|
}
|
|
302
|
+
try {
|
|
303
|
+
const req = request;
|
|
304
|
+
const reqId = req.__testrelic_id;
|
|
305
|
+
if (!reqId) return;
|
|
306
|
+
const pending = this.pendingRequests.get(reqId);
|
|
307
|
+
if (!pending) return;
|
|
308
|
+
this.pendingRequests.delete(reqId);
|
|
309
|
+
const captured = {
|
|
310
|
+
url: pending.url,
|
|
311
|
+
method: pending.method,
|
|
312
|
+
resourceType: pending.resourceType,
|
|
313
|
+
statusCode: 0,
|
|
314
|
+
responseTimeMs: Date.now() - pending.startTimeMs,
|
|
315
|
+
startedAt: pending.startedAt,
|
|
316
|
+
requestHeaders: pending.headers,
|
|
317
|
+
requestBody: pending.postData,
|
|
318
|
+
responseBody: null,
|
|
319
|
+
responseHeaders: null,
|
|
320
|
+
contentType: null,
|
|
321
|
+
responseSize: 0,
|
|
322
|
+
requestBodyTruncated: pending.postDataTruncated,
|
|
323
|
+
responseBodyTruncated: false,
|
|
324
|
+
isBinary: false,
|
|
325
|
+
error: req.failure()?.errorText ?? "Unknown error"
|
|
326
|
+
};
|
|
327
|
+
this.capturedRequests.push(captured);
|
|
328
|
+
} catch {
|
|
329
|
+
}
|
|
165
330
|
};
|
|
166
331
|
this.page.on("requestfailed", onRequestFailed);
|
|
167
332
|
this.listeners.push({ event: "requestfailed", handler: onRequestFailed });
|
|
@@ -261,7 +426,7 @@ var test = base.extend({
|
|
|
261
426
|
}
|
|
262
427
|
await use(page);
|
|
263
428
|
try {
|
|
264
|
-
tracker.flush(testInfo);
|
|
429
|
+
await tracker.flush(testInfo);
|
|
265
430
|
} catch {
|
|
266
431
|
}
|
|
267
432
|
tracker.dispose();
|