@wordbricks/playwright-mcp 0.1.25 → 0.1.27

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.
Files changed (82) hide show
  1. package/lib/browserContextFactory.js +616 -0
  2. package/lib/browserServerBackend.js +86 -0
  3. package/lib/config.js +302 -0
  4. package/lib/context.js +320 -0
  5. package/lib/extension/cdpRelay.js +352 -0
  6. package/lib/extension/extensionContextFactory.js +56 -0
  7. package/lib/frameworkPatterns.js +35 -0
  8. package/lib/hooks/antiBotDetectionHook.js +178 -0
  9. package/lib/hooks/core.js +145 -0
  10. package/lib/hooks/eventConsumer.js +52 -0
  11. package/lib/hooks/events.js +42 -0
  12. package/lib/hooks/formatToolCallEvent.js +12 -0
  13. package/lib/hooks/frameworkStateHook.js +182 -0
  14. package/lib/hooks/grouping.js +72 -0
  15. package/lib/hooks/jsonLdDetectionHook.js +182 -0
  16. package/lib/hooks/networkFilters.js +82 -0
  17. package/lib/hooks/networkSetup.js +61 -0
  18. package/lib/hooks/networkTrackingHook.js +67 -0
  19. package/lib/hooks/pageHeightHook.js +75 -0
  20. package/lib/hooks/registry.js +41 -0
  21. package/lib/hooks/requireTabHook.js +26 -0
  22. package/lib/hooks/schema.js +89 -0
  23. package/lib/hooks/waitHook.js +33 -0
  24. package/lib/index.js +41 -0
  25. package/lib/mcp/inProcessTransport.js +71 -0
  26. package/lib/mcp/proxyBackend.js +130 -0
  27. package/lib/mcp/server.js +91 -0
  28. package/lib/mcp/tool.js +44 -0
  29. package/lib/mcp/transport.js +188 -0
  30. package/lib/playwrightTransformer.js +520 -0
  31. package/lib/program.js +112 -0
  32. package/lib/response.js +192 -0
  33. package/lib/sessionLog.js +123 -0
  34. package/lib/tab.js +251 -0
  35. package/lib/tools/common.js +55 -0
  36. package/lib/tools/console.js +33 -0
  37. package/lib/tools/dialogs.js +50 -0
  38. package/lib/tools/evaluate.js +62 -0
  39. package/lib/tools/extractFrameworkState.js +225 -0
  40. package/lib/tools/files.js +48 -0
  41. package/lib/tools/form.js +66 -0
  42. package/lib/tools/getSnapshot.js +36 -0
  43. package/lib/tools/getVisibleHtml.js +68 -0
  44. package/lib/tools/install.js +51 -0
  45. package/lib/tools/keyboard.js +83 -0
  46. package/lib/tools/mouse.js +97 -0
  47. package/lib/tools/navigate.js +66 -0
  48. package/lib/tools/network.js +121 -0
  49. package/lib/tools/networkDetail.js +238 -0
  50. package/lib/tools/networkSearch/bodySearch.js +161 -0
  51. package/lib/tools/networkSearch/grouping.js +37 -0
  52. package/lib/tools/networkSearch/helpers.js +32 -0
  53. package/lib/tools/networkSearch/searchHtml.js +76 -0
  54. package/lib/tools/networkSearch/types.js +1 -0
  55. package/lib/tools/networkSearch/urlSearch.js +124 -0
  56. package/lib/tools/networkSearch.js +278 -0
  57. package/lib/tools/pdf.js +41 -0
  58. package/lib/tools/repl.js +414 -0
  59. package/lib/tools/screenshot.js +103 -0
  60. package/lib/tools/scroll.js +131 -0
  61. package/lib/tools/snapshot.js +161 -0
  62. package/lib/tools/tabs.js +62 -0
  63. package/lib/tools/tool.js +35 -0
  64. package/lib/tools/utils.js +78 -0
  65. package/lib/tools/wait.js +60 -0
  66. package/lib/tools.js +68 -0
  67. package/lib/utils/adBlockFilter.js +90 -0
  68. package/lib/utils/codegen.js +55 -0
  69. package/lib/utils/extensionPath.js +10 -0
  70. package/lib/utils/fileUtils.js +40 -0
  71. package/lib/utils/graphql.js +269 -0
  72. package/lib/utils/guid.js +22 -0
  73. package/lib/utils/httpServer.js +39 -0
  74. package/lib/utils/log.js +21 -0
  75. package/lib/utils/manualPromise.js +111 -0
  76. package/lib/utils/networkFormat.js +14 -0
  77. package/lib/utils/package.js +20 -0
  78. package/lib/utils/result.js +2 -0
  79. package/lib/utils/sanitizeHtml.js +130 -0
  80. package/lib/utils/truncate.js +103 -0
  81. package/lib/utils/withTimeout.js +7 -0
  82. package/package.json +11 -1
@@ -0,0 +1,182 @@
1
+ import { FRAMEWORK_STATE_PATTERNS, MAX_DISPLAY_ITEMS, } from "../frameworkPatterns.js";
2
+ import { Ok } from "../utils/result.js";
3
+ import { trackEvent } from "./events.js";
4
+ import { hookNameSchema } from "./schema.js";
5
+ const pageFrameworkStates = new WeakMap();
6
+ const seenFrameworkKeysByContext = new WeakMap();
7
+ const getSeenFrameworkKeys = (context) => {
8
+ let set = seenFrameworkKeysByContext.get(context);
9
+ if (!set) {
10
+ set = new Set();
11
+ seenFrameworkKeysByContext.set(context, set);
12
+ }
13
+ return set;
14
+ };
15
+ export const frameworkStatePreHook = {
16
+ name: hookNameSchema.enum["framework-state-pre"],
17
+ handler: async (context) => {
18
+ const frameworkState = await detectFrameworkState(context);
19
+ if (frameworkState) {
20
+ // Store the initial state
21
+ if (context.tab?.page)
22
+ pageFrameworkStates.set(context.tab.page, frameworkState);
23
+ // Track event for newly detected framework state
24
+ const newKeys = Object.keys(frameworkState).filter((key) => !getSeenFrameworkKeys(context.context).has(key));
25
+ if (newKeys.length > 0) {
26
+ trackEvent(context.context, {
27
+ type: "framework-state",
28
+ data: {
29
+ state: frameworkState,
30
+ action: "detected",
31
+ },
32
+ });
33
+ // Mark keys as seen
34
+ newKeys.forEach((key) => getSeenFrameworkKeys(context.context).add(key));
35
+ }
36
+ }
37
+ return Ok(undefined);
38
+ },
39
+ };
40
+ export const frameworkStatePostHook = {
41
+ name: hookNameSchema.enum["framework-state-post"],
42
+ handler: async (context) => {
43
+ const newFrameworkState = await detectFrameworkState(context);
44
+ const initialState = context.tab?.page
45
+ ? pageFrameworkStates.get(context.tab.page)
46
+ : undefined;
47
+ if (newFrameworkState) {
48
+ const changes = [];
49
+ if (initialState) {
50
+ // Compare states
51
+ const allKeys = new Set([
52
+ ...Object.keys(initialState),
53
+ ...Object.keys(newFrameworkState),
54
+ ]);
55
+ for (const key of allKeys) {
56
+ const initVal = initialState[key];
57
+ const currVal = newFrameworkState[key];
58
+ if (!initVal && currVal)
59
+ changes.push(`+ ${key}: ${formatValue(currVal)}`);
60
+ else if (initVal && !currVal)
61
+ changes.push(`- ${key}`);
62
+ else if (initVal &&
63
+ currVal &&
64
+ JSON.stringify(initVal) !== JSON.stringify(currVal))
65
+ changes.push(`~ ${key}: changed`);
66
+ }
67
+ if (changes.length > 0) {
68
+ trackEvent(context.context, {
69
+ type: "framework-state",
70
+ data: {
71
+ state: newFrameworkState,
72
+ changes,
73
+ action: "changed",
74
+ },
75
+ });
76
+ }
77
+ }
78
+ else {
79
+ // No initial state, but we have state now
80
+ const newKeys = Object.keys(newFrameworkState).filter((key) => !getSeenFrameworkKeys(context.context).has(key));
81
+ if (newKeys.length > 0) {
82
+ trackEvent(context.context, {
83
+ type: "framework-state",
84
+ data: {
85
+ state: newFrameworkState,
86
+ action: "detected",
87
+ },
88
+ });
89
+ newKeys.forEach((key) => getSeenFrameworkKeys(context.context).add(key));
90
+ }
91
+ }
92
+ // Update stored state
93
+ if (context.tab?.page)
94
+ pageFrameworkStates.set(context.tab.page, newFrameworkState);
95
+ }
96
+ return Ok(undefined);
97
+ },
98
+ };
99
+ async function detectFrameworkState(context) {
100
+ if (!context.tab?.page)
101
+ return null;
102
+ const result = await context.tab.page.evaluate((patterns) => {
103
+ const state = {};
104
+ const MAX_ITEMS = 5;
105
+ // Scan window object for these patterns
106
+ for (const pattern of patterns) {
107
+ if (pattern in window) {
108
+ try {
109
+ const value = window[pattern];
110
+ // Only capture if it's a non-empty object or has meaningful content
111
+ if (value &&
112
+ (typeof value === "object" || typeof value === "string")) {
113
+ state[pattern] =
114
+ typeof value === "object"
115
+ ? {
116
+ type: "object",
117
+ keys: Object.keys(value).slice(0, MAX_ITEMS * 2),
118
+ }
119
+ : {
120
+ type: typeof value,
121
+ preview: String(value).slice(0, 200),
122
+ };
123
+ }
124
+ }
125
+ catch (e) {
126
+ // Skip inaccessible properties
127
+ }
128
+ }
129
+ }
130
+ // Also check for React Fiber internals
131
+ const reactRootSelectors = [
132
+ "#__next",
133
+ "#root",
134
+ "#app",
135
+ "[data-reactroot]",
136
+ ];
137
+ for (const selector of reactRootSelectors) {
138
+ const element = document.querySelector(selector);
139
+ if (element) {
140
+ const fiberKey = Object.keys(element).find((key) => key.startsWith("__reactInternalInstance") ||
141
+ key.startsWith("__reactFiber") ||
142
+ key.startsWith("_reactRootContainer"));
143
+ if (fiberKey) {
144
+ state["React Fiber Root"] = { selector, fiberKey };
145
+ break;
146
+ }
147
+ }
148
+ }
149
+ return Object.keys(state).length > 0 ? state : null;
150
+ }, FRAMEWORK_STATE_PATTERNS);
151
+ return result;
152
+ }
153
+ function formatValue(value) {
154
+ if (typeof value === "object" && value !== null && "type" in value) {
155
+ if (value.type === "object" && "keys" in value && Array.isArray(value.keys))
156
+ return `{${value.keys.join(", ")}${value.keys.length >= MAX_DISPLAY_ITEMS * 2 ? ", ..." : ""}}`;
157
+ else if ("preview" in value && typeof value.preview === "string")
158
+ return `"${value.preview}${value.preview.length >= 200 ? "..." : ""}"`;
159
+ }
160
+ return JSON.stringify(value);
161
+ }
162
+ export const formatFrameworkStateEvent = (event) => {
163
+ const { state, changes, action } = event.data;
164
+ const messages = [];
165
+ if (action === "detected") {
166
+ messages.push("Framework state detected:");
167
+ const keys = Object.keys(state);
168
+ for (const key of keys) {
169
+ const value = state[key];
170
+ messages.push(` ${key}: ${formatValue(value)}`);
171
+ }
172
+ }
173
+ else if (action === "changed" && changes) {
174
+ messages.push("Framework state changed:");
175
+ messages.push(...changes.map((change) => ` ${change}`));
176
+ }
177
+ return messages.join("\n");
178
+ };
179
+ export const frameworkStateHooks = {
180
+ pre: frameworkStatePreHook,
181
+ post: frameworkStatePostHook,
182
+ };
@@ -0,0 +1,72 @@
1
+ const rules = new Map();
2
+ // Helper to define a rule with typed callbacks without using type assertions in callers
3
+ export const defineGroupingRule = (spec) => {
4
+ const { match } = spec;
5
+ return {
6
+ match,
7
+ keyOf: (e) => {
8
+ // Planner guarantees keyOf is only called when match(e) is true
9
+ if (!match(e))
10
+ return "";
11
+ return spec.keyOf(e);
12
+ },
13
+ summaryOf: (first, run) => {
14
+ // Build a typed run defensively using the provided type guard
15
+ const typedRun = [];
16
+ for (const ev of run) {
17
+ if (match(ev))
18
+ typedRun.push(ev);
19
+ }
20
+ const typedFirst = match(first) ? first : typedRun[0];
21
+ if (!typedFirst)
22
+ return "";
23
+ return spec.summaryOf(typedFirst, typedRun);
24
+ },
25
+ };
26
+ };
27
+ export const registerGroupingRule = (type, rule) => {
28
+ rules.set(type, rule);
29
+ };
30
+ const getGroupingRule = (type) => {
31
+ return rules.get(type);
32
+ };
33
+ export const planGroupedMessages = (events) => {
34
+ const replacementById = new Map();
35
+ const skipIds = new Set();
36
+ let currentType;
37
+ let currentKey;
38
+ let run = [];
39
+ const flush = () => {
40
+ if (currentType && run.length > 1) {
41
+ const rule = getGroupingRule(currentType);
42
+ if (rule) {
43
+ const first = run[0];
44
+ const summary = rule.summaryOf(first, run);
45
+ replacementById.set(first.id, summary);
46
+ for (let i = 1; i < run.length; i++)
47
+ skipIds.add(run[i].id);
48
+ }
49
+ }
50
+ currentType = undefined;
51
+ currentKey = undefined;
52
+ run = [];
53
+ };
54
+ for (const ev of events) {
55
+ const rule = getGroupingRule(ev.type);
56
+ if (!rule || !rule.match(ev)) {
57
+ flush();
58
+ continue;
59
+ }
60
+ const key = rule.keyOf(ev);
61
+ if (currentType === ev.type && currentKey === key) {
62
+ run.push(ev);
63
+ continue;
64
+ }
65
+ flush();
66
+ currentType = ev.type;
67
+ currentKey = key;
68
+ run = [ev];
69
+ }
70
+ flush();
71
+ return { replacementById, skipIds };
72
+ };
@@ -0,0 +1,182 @@
1
+ import { Ok } from "../utils/result.js";
2
+ import { trackEvent } from "./events.js";
3
+ import { hookNameSchema } from "./schema.js";
4
+ const MAX_DISPLAY_ITEMS = 5;
5
+ const pageJsonLdStates = new WeakMap();
6
+ const seenJsonLdTypesByContext = new WeakMap();
7
+ const getSeenJsonLdTypes = (context) => {
8
+ let set = seenJsonLdTypesByContext.get(context);
9
+ if (!set) {
10
+ set = new Set();
11
+ seenJsonLdTypesByContext.set(context, set);
12
+ }
13
+ return set;
14
+ };
15
+ export const jsonLdDetectionPreHook = {
16
+ name: hookNameSchema.enum["json-ld-detection-pre"],
17
+ handler: async (context) => {
18
+ const jsonLdState = await detectJsonLdState(context);
19
+ if (jsonLdState) {
20
+ // Store the initial state
21
+ if (context.tab?.page)
22
+ pageJsonLdStates.set(context.tab.page, jsonLdState);
23
+ // Track event for newly detected JSON-LD types
24
+ const newTypes = Object.keys(jsonLdState).filter((type) => !getSeenJsonLdTypes(context.context).has(type));
25
+ if (newTypes.length > 0) {
26
+ trackEvent(context.context, {
27
+ type: "json-ld",
28
+ data: {
29
+ state: jsonLdState,
30
+ action: "detected",
31
+ },
32
+ });
33
+ // Mark types as seen
34
+ newTypes.forEach((type) => getSeenJsonLdTypes(context.context).add(type));
35
+ }
36
+ }
37
+ return Ok(undefined);
38
+ },
39
+ };
40
+ export const jsonLdDetectionPostHook = {
41
+ name: hookNameSchema.enum["json-ld-detection-post"],
42
+ handler: async (context) => {
43
+ const newJsonLdState = await detectJsonLdState(context);
44
+ const initialState = context.tab?.page
45
+ ? pageJsonLdStates.get(context.tab.page)
46
+ : undefined;
47
+ if (newJsonLdState || initialState) {
48
+ const changes = [];
49
+ if (initialState && newJsonLdState) {
50
+ // Compare states
51
+ const allTypes = new Set([
52
+ ...Object.keys(initialState),
53
+ ...Object.keys(newJsonLdState),
54
+ ]);
55
+ for (const type of allTypes) {
56
+ const initInfo = initialState[type];
57
+ const currInfo = newJsonLdState[type];
58
+ if (!initInfo && currInfo)
59
+ changes.push(`+ ${type}${currInfo.count > 1 ? ` (${currInfo.count} instances)` : ""}`);
60
+ else if (initInfo && !currInfo)
61
+ changes.push(`- ${type}${initInfo.count > 1 ? ` (${initInfo.count} instances)` : ""}`);
62
+ else if (initInfo && currInfo && initInfo.count !== currInfo.count)
63
+ changes.push(`~ ${type}: ${initInfo.count} → ${currInfo.count} instances`);
64
+ }
65
+ if (changes.length > 0) {
66
+ trackEvent(context.context, {
67
+ type: "json-ld",
68
+ data: {
69
+ state: newJsonLdState,
70
+ changes,
71
+ action: "changed",
72
+ },
73
+ });
74
+ }
75
+ }
76
+ else if (newJsonLdState && !initialState) {
77
+ // No initial state, but we have state now
78
+ const newTypes = Object.keys(newJsonLdState).filter((type) => !getSeenJsonLdTypes(context.context).has(type));
79
+ if (newTypes.length > 0) {
80
+ trackEvent(context.context, {
81
+ type: "json-ld",
82
+ data: {
83
+ state: newJsonLdState,
84
+ action: "detected",
85
+ },
86
+ });
87
+ newTypes.forEach((type) => getSeenJsonLdTypes(context.context).add(type));
88
+ }
89
+ }
90
+ // Update stored state
91
+ if (context.tab?.page && newJsonLdState)
92
+ pageJsonLdStates.set(context.tab.page, newJsonLdState);
93
+ }
94
+ return Ok(undefined);
95
+ },
96
+ };
97
+ async function detectJsonLdState(context) {
98
+ if (!context.tab?.page)
99
+ return null;
100
+ const result = await context.tab.page.evaluate(() => {
101
+ const state = {};
102
+ // Find all JSON-LD scripts
103
+ const scripts = document.querySelectorAll('script[type="application/ld+json"]');
104
+ scripts.forEach((script, index) => {
105
+ try {
106
+ // Parse JSON
107
+ const data = JSON.parse(script.textContent || "{}");
108
+ // Extract @type - handle both single and array types
109
+ let types = [];
110
+ if (data["@type"]) {
111
+ types = Array.isArray(data["@type"])
112
+ ? data["@type"]
113
+ : [data["@type"]];
114
+ }
115
+ else if (data["@graph"] && Array.isArray(data["@graph"])) {
116
+ // Handle @graph structures
117
+ data["@graph"].forEach((item) => {
118
+ if (item["@type"]) {
119
+ const itemTypes = Array.isArray(item["@type"])
120
+ ? item["@type"]
121
+ : [item["@type"]];
122
+ types.push(...itemTypes);
123
+ }
124
+ });
125
+ }
126
+ // Count occurrences of each type
127
+ types.forEach((type) => {
128
+ if (!state[type])
129
+ state[type] = { count: 0, indices: [] };
130
+ state[type].count++;
131
+ state[type].indices.push(index);
132
+ });
133
+ }
134
+ catch (e) {
135
+ state["InvalidJSON-LD"] = state["InvalidJSON-LD"] || {
136
+ count: 0,
137
+ indices: [],
138
+ };
139
+ state["InvalidJSON-LD"].count++;
140
+ state["InvalidJSON-LD"].indices.push(index);
141
+ }
142
+ });
143
+ return Object.keys(state).length > 0 ? state : null;
144
+ });
145
+ return result;
146
+ }
147
+ function buildStateMessages(state, types) {
148
+ const msgs = [];
149
+ const targetTypes = types ?? Object.keys(state);
150
+ // Sort by count (descending) and take top MAX_DISPLAY_ITEMS
151
+ const sortedTypes = targetTypes
152
+ .sort((a, b) => (state[b].count || 0) - (state[a].count || 0))
153
+ .slice(0, MAX_DISPLAY_ITEMS);
154
+ for (const type of sortedTypes) {
155
+ const info = state[type];
156
+ if (info.count === 1)
157
+ msgs.push(` ${type}`);
158
+ else
159
+ msgs.push(` ${type} (${info.count} instances)`);
160
+ }
161
+ // Add indicator if there are more types
162
+ if (targetTypes.length > MAX_DISPLAY_ITEMS)
163
+ msgs.push(` ... and ${targetTypes.length - MAX_DISPLAY_ITEMS} more type(s)`);
164
+ return msgs;
165
+ }
166
+ export const formatJsonLdEvent = (event) => {
167
+ const { state, changes, action } = event.data;
168
+ const messages = [];
169
+ if (action === "detected") {
170
+ messages.push("New JSON-LD types detected:");
171
+ messages.push(...buildStateMessages(state));
172
+ }
173
+ else if (action === "changed" && changes) {
174
+ messages.push("JSON-LD changes after action:");
175
+ messages.push(...changes.map((change) => ` ${change}`));
176
+ }
177
+ return messages.join("\n");
178
+ };
179
+ export const jsonLdDetectionHooks = {
180
+ pre: jsonLdDetectionPreHook,
181
+ post: jsonLdDetectionPostHook,
182
+ };
@@ -0,0 +1,82 @@
1
+ const MEANINGFUL_RESOURCE_TYPES = ["document", "xhr", "fetch"];
2
+ const ALLOWED_METHODS = ["GET", "POST"];
3
+ const EXCLUDED_EXTENSIONS = [
4
+ ".svg",
5
+ ".css",
6
+ ".map", // JS files and source maps
7
+ ];
8
+ const hasExcludedExtension = (url) => {
9
+ return EXCLUDED_EXTENSIONS.some((ext) => {
10
+ const extRegex = new RegExp(`${ext.replace(".", "\\.")}(\\?|#|$)`, "i");
11
+ return extRegex.test(url);
12
+ });
13
+ };
14
+ export const isAntiBotUrl = (url) => {
15
+ if (url.includes("challenges.cloudflare.com"))
16
+ return true;
17
+ if (url.includes(".awswaf.com"))
18
+ return true;
19
+ return false;
20
+ };
21
+ const isSuccessfulStatus = (status) => {
22
+ // Status 0 is for failed requests, which we want to capture
23
+ // 2xx status codes are successful
24
+ // Exclude 204 No Content
25
+ return status === 0 || (status >= 200 && status < 300 && status !== 204);
26
+ };
27
+ export const shouldCaptureRequest = (method, url, status, resourceType) => {
28
+ if (isAntiBotUrl(url))
29
+ return true;
30
+ return (!hasExcludedExtension(url) &&
31
+ MEANINGFUL_RESOURCE_TYPES.includes(resourceType) &&
32
+ ALLOWED_METHODS.includes(method) &&
33
+ isSuccessfulStatus(status));
34
+ };
35
+ /**
36
+ * Format URL with trimmed parameters
37
+ */
38
+ export const formatUrlWithTrimmedParams = (url) => {
39
+ try {
40
+ const urlObj = new URL(url);
41
+ const params = urlObj.searchParams;
42
+ if (params.toString()) {
43
+ const trimmedParams = new URLSearchParams();
44
+ params.forEach((value, key) => {
45
+ if (value.length > 5)
46
+ trimmedParams.set(key, value.substring(0, 5) + "...");
47
+ else
48
+ trimmedParams.set(key, value);
49
+ });
50
+ return `${urlObj.origin}${urlObj.pathname}?${trimmedParams.toString()}`;
51
+ }
52
+ return url;
53
+ }
54
+ catch {
55
+ // If URL parsing fails, return as is
56
+ return url;
57
+ }
58
+ };
59
+ /**
60
+ * Normalize a pathname by removing trailing slashes (except root)
61
+ */
62
+ export const normalizePathname = (pathname) => {
63
+ if (!pathname)
64
+ return "/";
65
+ if (pathname === "/")
66
+ return "/";
67
+ return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
68
+ };
69
+ /**
70
+ * Normalize URL for grouping by ignoring query/hash and trailing slash
71
+ */
72
+ export const normalizeUrlForGrouping = (url) => {
73
+ try {
74
+ const u = new URL(url);
75
+ return `${u.origin}${normalizePathname(u.pathname)}`;
76
+ }
77
+ catch {
78
+ // Fallback for non-standard/relative URLs: strip query/hash and trailing slash
79
+ const base = url.split(/[?#]/)[0] || "/";
80
+ return normalizePathname(base);
81
+ }
82
+ };
@@ -0,0 +1,61 @@
1
+ import { trackEvent } from "./events.js";
2
+ import { shouldCaptureRequest } from "./networkFilters.js";
3
+ const eventIdToEntryMap = new WeakMap();
4
+ const getEventIdMap = (context) => {
5
+ let map = eventIdToEntryMap.get(context);
6
+ if (!map) {
7
+ map = new Map();
8
+ eventIdToEntryMap.set(context, map);
9
+ }
10
+ return map;
11
+ };
12
+ export const getNetworkEventEntry = (context, id) => getEventIdMap(context).get(id);
13
+ export const setupNetworkTracking = (context, page) => {
14
+ page.on("response", async (response) => {
15
+ const request = response.request();
16
+ const method = request.method();
17
+ const url = request.url();
18
+ const status = response.status();
19
+ const resourceType = request.resourceType();
20
+ // Apply filters before saving the event
21
+ if (shouldCaptureRequest(method, url, status, resourceType)) {
22
+ const setCookies = await response
23
+ .headerValues("set-cookie")
24
+ .catch(() => []);
25
+ const cookieValues = setCookies.length ? setCookies : undefined;
26
+ const networkData = {
27
+ method,
28
+ url,
29
+ status,
30
+ resourceType,
31
+ postData: request.postData() || undefined,
32
+ setCookies: cookieValues,
33
+ };
34
+ const id = trackEvent(context, {
35
+ type: "network-request",
36
+ data: networkData,
37
+ });
38
+ getEventIdMap(context).set(id, { request, response });
39
+ }
40
+ });
41
+ page.on("requestfailed", (request) => {
42
+ const method = request.method();
43
+ const url = request.url();
44
+ const status = 0; // Failed requests have status 0
45
+ const resourceType = request.resourceType();
46
+ if (shouldCaptureRequest(method, url, status, resourceType)) {
47
+ const networkData = {
48
+ method,
49
+ url,
50
+ status,
51
+ resourceType,
52
+ postData: request.postData() || undefined,
53
+ };
54
+ const id = trackEvent(context, {
55
+ type: "network-request",
56
+ data: networkData,
57
+ });
58
+ getEventIdMap(context).set(id, { request });
59
+ }
60
+ });
61
+ };
@@ -0,0 +1,67 @@
1
+ import { filter, pipe, toArray } from "@fxts/core";
2
+ import { parseGraphQLRequestFromHttp } from "../utils/graphql.js";
3
+ import { formatNetworkSummaryLine } from "../utils/networkFormat.js";
4
+ import { Ok } from "../utils/result.js";
5
+ import { getEventStore, isEventType } from "./events.js";
6
+ import { defineGroupingRule } from "./grouping.js";
7
+ import { normalizeUrlForGrouping } from "./networkFilters.js";
8
+ import { hookNameSchema } from "./schema.js";
9
+ const networkTrackingPreHook = {
10
+ name: hookNameSchema.enum["network-tracking-pre"],
11
+ handler: async (_ctx) => {
12
+ // Pre-hook now just acts as a marker, event consumption happens elsewhere
13
+ return Ok(undefined);
14
+ },
15
+ };
16
+ const networkTrackingPostHook = {
17
+ name: hookNameSchema.enum["network-tracking-post"],
18
+ handler: async (_ctx) => {
19
+ // Post-hook now just acts as a marker, network event collection happens automatically
20
+ return Ok(undefined);
21
+ },
22
+ };
23
+ export const networkTrackingHooks = {
24
+ pre: networkTrackingPreHook,
25
+ post: networkTrackingPostHook,
26
+ };
27
+ export const formatNetworkEvent = (event) => {
28
+ const { method, url, status, postData, setCookies } = event.data;
29
+ const summary = formatNetworkSummaryLine({ method, url, status, postData });
30
+ if (!setCookies || setCookies.length === 0)
31
+ return summary;
32
+ const names = setCookies
33
+ .map((cookie) => {
34
+ const firstPart = cookie.split(";", 1)[0];
35
+ const [name] = firstPart.split("=", 1);
36
+ return name?.trim();
37
+ })
38
+ .filter((name) => !!name);
39
+ if (!names.length)
40
+ return summary;
41
+ return `${summary} | Set-Cookie keys: ${names.join(", ")}`;
42
+ };
43
+ const computeNetworkGroupKey = (event) => {
44
+ const method = (event.data.method || "").toUpperCase();
45
+ const baseUrl = normalizeUrlForGrouping(event.data.url);
46
+ const gql = parseGraphQLRequestFromHttp(event.data.method, event.data.url, {}, event.data.postData);
47
+ if (gql) {
48
+ const type = gql.operationType === "unknown" ? "operation" : gql.operationType;
49
+ const op = gql.operationName ? `${type} ${gql.operationName}` : type;
50
+ return `${method} ${baseUrl} [GraphQL: ${op}]`;
51
+ }
52
+ return `${method} ${baseUrl}`;
53
+ };
54
+ export const networkGroupingRule = defineGroupingRule({
55
+ match: (e) => e.type === "network-request",
56
+ keyOf: (e) => computeNetworkGroupKey(e),
57
+ summaryOf: (first, run) => {
58
+ const key = computeNetworkGroupKey(first);
59
+ const count = run.length;
60
+ const firstStatus = first.data.status;
61
+ const allSameStatus = run.every((e) => e.data.status === firstStatus);
62
+ return allSameStatus
63
+ ? `${key} → ${firstStatus} (x${count})`
64
+ : `${key} (x${count})`;
65
+ },
66
+ });
67
+ export const listNetworkEvents = (context) => pipe(context, getEventStore, (store) => store.events.values(), filter(isEventType("network-request")), toArray);