@skyramp/mcp 0.1.7 → 0.2.0-rc.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/build/playwright/registerPlaywrightTools.js +12 -0
- package/build/playwright/traceRecordingPrompt.js +15 -0
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -1
- package/build/prompts/test-recommendation/diffExecutionPlan.js +31 -0
- package/build/prompts/test-recommendation/recommendationSections.js +1 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +94 -0
- package/build/prompts/testbot/testbot-prompts.js +115 -11
- package/build/prompts/testbot/testbot-prompts.test.js +79 -0
- package/build/resources/testbotResource.js +1 -1
- package/build/services/ScenarioGenerationService.integration.test.js +158 -0
- package/build/services/ScenarioGenerationService.js +36 -3
- package/build/services/ScenarioGenerationService.test.js +158 -22
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
- package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
- package/build/tools/generate-tests/generateUIRestTool.js +2 -0
- package/build/tools/test-management/analyzeChangesTool.js +7 -1
- package/build/utils/routeParsers.js +12 -0
- package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
- package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1161 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +250 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +298 -0
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
- package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
- package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
- package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
- package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
- package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
- package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +129 -0
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +137 -0
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
- package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
- package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- package/package.json +2 -2
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var domAnalyzer_exports = {};
|
|
30
|
+
__export(domAnalyzer_exports, {
|
|
31
|
+
default: () => domAnalyzer_default
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(domAnalyzer_exports);
|
|
34
|
+
var crypto = __toESM(require("crypto"));
|
|
35
|
+
var fs = __toESM(require("fs"));
|
|
36
|
+
var import_mcpBundle = require("playwright-core/lib/mcpBundle");
|
|
37
|
+
var import_tool = require("./tool");
|
|
38
|
+
var import_crawler = require("../../../dom-analyzer/crawler");
|
|
39
|
+
var import_blueprint = require("../../../dom-analyzer/blueprint");
|
|
40
|
+
var import_serialization = require("../../../dom-analyzer/serialization");
|
|
41
|
+
function hashStorageState(path) {
|
|
42
|
+
if (!path)
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
const contents = fs.readFileSync(path, "utf-8");
|
|
46
|
+
return crypto.createHash("sha256").update(contents).digest("hex").slice(0, 16);
|
|
47
|
+
} catch {
|
|
48
|
+
const pathHash = crypto.createHash("sha256").update(path).digest("hex").slice(0, 16);
|
|
49
|
+
return `unreadable:${pathHash}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function cacheKey(entryUrl, storageStateHash, probeButtons) {
|
|
53
|
+
return `${entryUrl}::${storageStateHash ?? "no-auth"}::${probeButtons}`;
|
|
54
|
+
}
|
|
55
|
+
function humanDuration(ms) {
|
|
56
|
+
if (ms < 6e4)
|
|
57
|
+
return `${Math.round(ms / 1e3)}s`;
|
|
58
|
+
return `${Math.round(ms / 6e4)}m`;
|
|
59
|
+
}
|
|
60
|
+
const sitemapBuild = (0, import_tool.defineTool)({
|
|
61
|
+
capability: "core",
|
|
62
|
+
schema: {
|
|
63
|
+
name: "browser_sitemap_build",
|
|
64
|
+
title: "Build Sitemap (graph of PageBlueprints)",
|
|
65
|
+
description: [
|
|
66
|
+
"Crawl an application starting from a URL and build a Sitemap \u2014 a graph of PageBlueprints (one per URL)",
|
|
67
|
+
"connected by navigation edges. The Sitemap is cached in the session.",
|
|
68
|
+
"",
|
|
69
|
+
"Call once at the start of a session. Subsequent calls within the TTL (~30 minutes) reuse the cached",
|
|
70
|
+
"Sitemap unless `refresh: true` is passed. Use `browser_sitemap_query` to read already-crawled pages",
|
|
71
|
+
"rather than re-calling this tool.",
|
|
72
|
+
"",
|
|
73
|
+
"Depth defaults to 5, maxPages to 50 (the real bound in practice).",
|
|
74
|
+
"",
|
|
75
|
+
'\u26A0\uFE0F SAFETY: By default, `probeButtons: "immutable-only"` skips destructive-looking buttons',
|
|
76
|
+
"(Delete / Submit / Place Order / etc.) and buttons inside forms to prevent side effects during",
|
|
77
|
+
'crawling. Use `probeButtons: "all"` only against dev / staging environments \u2014 it will click every',
|
|
78
|
+
"unique button, which can submit forms, create records, send notifications, or mutate server state."
|
|
79
|
+
].join("\n"),
|
|
80
|
+
inputSchema: import_mcpBundle.z.object({
|
|
81
|
+
url: import_mcpBundle.z.string().describe("Entry URL to start crawling from"),
|
|
82
|
+
depth: import_mcpBundle.z.number().optional().default(5).describe("Max crawl depth (default: 5)"),
|
|
83
|
+
maxPages: import_mcpBundle.z.number().optional().default(50).describe("Max pages to visit (default: 50)"),
|
|
84
|
+
sameOriginOnly: import_mcpBundle.z.boolean().optional().default(true).describe("Only follow same-origin links (default: true)"),
|
|
85
|
+
probeButtons: import_mcpBundle.z.enum(["immutable-only", "all", "none"]).optional().default("immutable-only").describe('Button-probing safety mode. Default: immutable-only (safe; skips destructive-verb buttons and buttons inside forms). Use "all" only against dev/staging \u2014 probes every button. "none" disables button probing entirely.'),
|
|
86
|
+
playwrightStoragePath: import_mcpBundle.z.string().optional().describe("Path to a Playwright storageState.json file \u2014 cookies, localStorage, sessionStorage per origin. Use this to crawl apps behind a login."),
|
|
87
|
+
refresh: import_mcpBundle.z.boolean().optional().default(false).describe("Force a full re-crawl, bypassing the cache")
|
|
88
|
+
}),
|
|
89
|
+
type: "readOnly"
|
|
90
|
+
},
|
|
91
|
+
handle: async (context, params, response) => {
|
|
92
|
+
const { url, depth, maxPages, sameOriginOnly, probeButtons, playwrightStoragePath, refresh } = params;
|
|
93
|
+
const normalizedUrl = (0, import_serialization.normalizeUrl)(url);
|
|
94
|
+
const storageStateHash = hashStorageState(playwrightStoragePath);
|
|
95
|
+
const probeMode = probeButtons;
|
|
96
|
+
const key = cacheKey(normalizedUrl, storageStateHash, probeMode);
|
|
97
|
+
const cached = context.sitemapCache.get(key);
|
|
98
|
+
if (cached && !refresh) {
|
|
99
|
+
const age = Date.now() - new Date(cached.sitemap.cachedAt).getTime();
|
|
100
|
+
if (age < import_serialization.CRAWL_TTL_MS) {
|
|
101
|
+
const pageCount2 = Object.keys(cached.sitemap.pages).length;
|
|
102
|
+
response.addTextResult(
|
|
103
|
+
`Reusing cached Sitemap from ${humanDuration(age)} ago.
|
|
104
|
+
Entry: ${cached.sitemap.entryUrl}
|
|
105
|
+
Pages: ${pageCount2}
|
|
106
|
+
Edges: ${cached.sitemap.edges.length}
|
|
107
|
+
Storage state: ${storageStateHash ? `auth hash ${storageStateHash}` : "none"}
|
|
108
|
+
Probe mode: ${probeMode}
|
|
109
|
+
|
|
110
|
+
Use browser_sitemap_query to read any page. Pass refresh=true to recrawl.`
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let sitemap;
|
|
116
|
+
if (depth > 0) {
|
|
117
|
+
sitemap = await (0, import_crawler.crawl)(url, {
|
|
118
|
+
depth,
|
|
119
|
+
maxPages,
|
|
120
|
+
sameOriginOnly,
|
|
121
|
+
probeButtons: probeMode,
|
|
122
|
+
playwrightStoragePath
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
sitemap = await (0, import_crawler.crawlSinglePage)(url, { playwrightStoragePath });
|
|
126
|
+
}
|
|
127
|
+
context.sitemapCache.set(key, { sitemap, storageStateHash, probeButtonsMode: probeMode });
|
|
128
|
+
const pageCount = Object.keys(sitemap.pages).length;
|
|
129
|
+
const pageList = Object.keys(sitemap.pages).map((u) => {
|
|
130
|
+
const bp = sitemap.pages[u];
|
|
131
|
+
const elCount = bp.sections.reduce((sum, s) => sum + s.elements.length + s.repeatingElements.length, 0);
|
|
132
|
+
return ` - ${u} (${elCount} elements across ${bp.sections.length} sections)`;
|
|
133
|
+
}).join("\n");
|
|
134
|
+
response.addTextResult(
|
|
135
|
+
`Sitemap built for ${sitemap.entryUrl}
|
|
136
|
+
Pages: ${pageCount}
|
|
137
|
+
Edges: ${sitemap.edges.length}
|
|
138
|
+
Storage state: ${storageStateHash ? `auth hash ${storageStateHash}` : "none"}
|
|
139
|
+
Probe mode: ${probeMode}
|
|
140
|
+
|
|
141
|
+
Pages:
|
|
142
|
+
${pageList}
|
|
143
|
+
|
|
144
|
+
Use browser_sitemap_query to read page blueprints, edges, or derived views (mapJson, outline).`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
const sitemapQuery = (0, import_tool.defineTool)({
|
|
149
|
+
capability: "core",
|
|
150
|
+
schema: {
|
|
151
|
+
name: "browser_sitemap_query",
|
|
152
|
+
title: "Query the cached Sitemap",
|
|
153
|
+
description: [
|
|
154
|
+
"Query the Sitemap cached by browser_sitemap_build. Use this instead of re-calling sitemap_build.",
|
|
155
|
+
"",
|
|
156
|
+
"Modes:",
|
|
157
|
+
" page \u2014 returns the full canonical PageBlueprint for the given URL",
|
|
158
|
+
" (sections with enrichment, logical names, XPaths)",
|
|
159
|
+
" edges \u2014 returns navigation edges originating from the given URL",
|
|
160
|
+
" mapJson \u2014 returns the derived flat logicalName \u2192 xpath map for the URL",
|
|
161
|
+
" outline \u2014 returns the derived textual section-to-element hierarchy for the URL",
|
|
162
|
+
" overview \u2014 (when url is omitted) returns page list + edge list summary"
|
|
163
|
+
].join("\n"),
|
|
164
|
+
inputSchema: import_mcpBundle.z.object({
|
|
165
|
+
type: import_mcpBundle.z.enum(["page", "edges", "mapJson", "outline"]).optional().describe("Query mode. Omit with url to get overview."),
|
|
166
|
+
url: import_mcpBundle.z.string().optional().describe("Page URL to query. Omit to get overview across all pages.")
|
|
167
|
+
}),
|
|
168
|
+
type: "readOnly"
|
|
169
|
+
},
|
|
170
|
+
handle: async (context, params, response) => {
|
|
171
|
+
if (context.sitemapCache.size === 0) {
|
|
172
|
+
response.addError("No Sitemap available. Run browser_sitemap_build first.");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const latest = [...context.sitemapCache.values()].sort(
|
|
176
|
+
(a, b) => new Date(b.sitemap.cachedAt).getTime() - new Date(a.sitemap.cachedAt).getTime()
|
|
177
|
+
)[0];
|
|
178
|
+
const sitemap = latest.sitemap;
|
|
179
|
+
const { type, url } = params;
|
|
180
|
+
if (!url || !type) {
|
|
181
|
+
const overview = {
|
|
182
|
+
schemaVersion: sitemap.schemaVersion,
|
|
183
|
+
entryUrl: sitemap.entryUrl,
|
|
184
|
+
cachedAt: sitemap.cachedAt,
|
|
185
|
+
pages: Object.keys(sitemap.pages).map((u) => ({
|
|
186
|
+
url: u,
|
|
187
|
+
sections: sitemap.pages[u].sections.length,
|
|
188
|
+
elements: sitemap.pages[u].sections.reduce(
|
|
189
|
+
(s, sec) => s + sec.elements.length + sec.repeatingElements.length,
|
|
190
|
+
0
|
|
191
|
+
),
|
|
192
|
+
pageHash: sitemap.pages[u].pageHash
|
|
193
|
+
})),
|
|
194
|
+
edges: sitemap.edges
|
|
195
|
+
};
|
|
196
|
+
response.addTextResult(JSON.stringify(overview, null, 2));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const normalizedQueryUrl = (0, import_serialization.normalizeUrl)(url);
|
|
200
|
+
const blueprint2 = sitemap.pages[normalizedQueryUrl];
|
|
201
|
+
if (!blueprint2) {
|
|
202
|
+
response.addError(
|
|
203
|
+
`Page not found in Sitemap: ${normalizedQueryUrl}. Available pages: ${Object.keys(sitemap.pages).join(", ")}`
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (type === "page") {
|
|
208
|
+
response.addTextResult(JSON.stringify(blueprint2, null, 2));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (type === "edges") {
|
|
212
|
+
const edges = sitemap.edges.filter((e) => e.from === normalizedQueryUrl);
|
|
213
|
+
response.addTextResult(JSON.stringify({ url: normalizedQueryUrl, edges }, null, 2));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (type === "mapJson") {
|
|
217
|
+
response.addTextResult(JSON.stringify((0, import_blueprint.buildMap)(blueprint2), null, 2));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (type === "outline") {
|
|
221
|
+
response.addTextResult((0, import_blueprint.buildOutline)(blueprint2));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
const blueprint = (0, import_tool.defineTabTool)({
|
|
227
|
+
capability: "core",
|
|
228
|
+
schema: {
|
|
229
|
+
name: "browser_blueprint",
|
|
230
|
+
title: "Build PageBlueprint for the current page",
|
|
231
|
+
description: [
|
|
232
|
+
"Build a PageBlueprint for the currently loaded page. Returns the canonical `sections` tree",
|
|
233
|
+
"containing singular elements and repeating-element shapes, each carrying enrichment fields",
|
|
234
|
+
"(mutability, widgetType, framePath, shadowRoot, stableId, testId).",
|
|
235
|
+
"",
|
|
236
|
+
"Call this only when the DOM has changed since the last known-good blueprint \u2014 e.g. after a",
|
|
237
|
+
"modal opens, a form submits, a filter changes a list, or any mutable action. For pure navigation",
|
|
238
|
+
"to an already-crawled URL, reuse the Sitemap cache via browser_sitemap_query.",
|
|
239
|
+
"",
|
|
240
|
+
"Logical names are stable \u2014 use them in generated test code. Refs (from browser_snapshot) are",
|
|
241
|
+
"ephemeral \u2014 use them only for dispatching the next interaction, never in generated code."
|
|
242
|
+
].join("\n"),
|
|
243
|
+
inputSchema: import_mcpBundle.z.object({}),
|
|
244
|
+
type: "readOnly"
|
|
245
|
+
},
|
|
246
|
+
handle: async (tab, _params, response) => {
|
|
247
|
+
try {
|
|
248
|
+
const bp = await (0, import_blueprint.buildPageBlueprint)(tab.page);
|
|
249
|
+
response.addTextResult(JSON.stringify(bp, null, 2));
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (err instanceof import_blueprint.BlueprintInvariantError) {
|
|
252
|
+
response.addError(
|
|
253
|
+
`Blueprint invariant violated: ${err.message}. This is a builder bug; please file an issue with the URL of the page that triggered it.`
|
|
254
|
+
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
var domAnalyzer_default = [sitemapBuild, sitemapQuery, blueprint];
|
|
@@ -29,9 +29,9 @@ const pressKey = (0, import_tool.defineTabTool)({
|
|
|
29
29
|
schema: {
|
|
30
30
|
name: "browser_press_key",
|
|
31
31
|
title: "Press a key",
|
|
32
|
-
description: "Press a key on the keyboard",
|
|
32
|
+
description: "Press a single key on the globally-focused element. This is a FALLBACK tool \u2014 prefer higher-level tools whenever possible: browser_click for buttons and links, browser_type for text entry, browser_type with submit: true for form submission (bundles the fill and the Enter into one recorded action). Only use browser_press_key when no semantic alternative exists: arrow-key navigation in an already-open listbox or combobox, Escape to close a modal that has no visible close button, Tab to shift focus between fields, or an app-specific keyboard shortcut the user actually relies on (e.g. Ctrl+K to open a command palette).",
|
|
33
33
|
inputSchema: import_mcpBundle.z.object({
|
|
34
|
-
key: import_mcpBundle.z.string().describe(
|
|
34
|
+
key: import_mcpBundle.z.string().describe('Key name or character, e.g. "ArrowDown", "Enter", "Escape", "a". Supports modifier combinations like "Control+a" or "Meta+v".')
|
|
35
35
|
}),
|
|
36
36
|
type: "input"
|
|
37
37
|
},
|
|
@@ -77,7 +77,7 @@ const type = (0, import_tool.defineTabTool)({
|
|
|
77
77
|
schema: {
|
|
78
78
|
name: "browser_type",
|
|
79
79
|
title: "Type text",
|
|
80
|
-
description: "Type text into an editable element. Auto-focuses and replaces existing content. NEVER call browser_click on the field first \u2014 clicking before typing injects extra network requests that corrupt the trace.",
|
|
80
|
+
description: "Type text into an editable element. Auto-focuses and replaces existing content. NEVER call browser_click on the field first \u2014 clicking before typing injects extra network requests that corrupt the trace. When recording a UI trace for capture-act-capture, capture browser_blueprint before and after to derive form-state assertions from the textChanges delta.",
|
|
81
81
|
inputSchema: typeSchema,
|
|
82
82
|
type: "input"
|
|
83
83
|
},
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var pageBlueprint_exports = {};
|
|
20
|
+
__export(pageBlueprint_exports, {
|
|
21
|
+
default: () => pageBlueprint_default
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(pageBlueprint_exports);
|
|
24
|
+
var import_mcpBundle = require("playwright-core/lib/mcpBundle");
|
|
25
|
+
var import_tool = require("./tool");
|
|
26
|
+
var import_blueprint = require("../../../dom-analyzer/blueprint");
|
|
27
|
+
var import_blueprintDiff = require("../../../dom-analyzer/blueprintDiff");
|
|
28
|
+
const blueprint = (0, import_tool.defineTabTool)({
|
|
29
|
+
capability: "core",
|
|
30
|
+
schema: {
|
|
31
|
+
name: "browser_blueprint",
|
|
32
|
+
title: "Build PageBlueprint for the current page",
|
|
33
|
+
description: [
|
|
34
|
+
"Build a PageBlueprint for the currently loaded page.",
|
|
35
|
+
"",
|
|
36
|
+
"RETURN SHAPE \u2014 two cases:",
|
|
37
|
+
"",
|
|
38
|
+
"1. First call at a URL (or after the cache has evicted the entry):",
|
|
39
|
+
' {"isFullCapture": true, "pageHash": "...", "blueprint": {...full PageBlueprint...}}',
|
|
40
|
+
"",
|
|
41
|
+
"2. Subsequent call at the same URL:",
|
|
42
|
+
' {"isFullCapture": false, "pageHash": "...", "previousPageHash": "...", "delta": {...BlueprintDelta...}}',
|
|
43
|
+
" The delta is computed automatically against your most recent blueprint at this URL.",
|
|
44
|
+
" Empty arrays in delta (elementsAdded/Removed/textChanges) mean the action did not change observable DOM.",
|
|
45
|
+
"",
|
|
46
|
+
"CAPTURE-ACT-CAPTURE: capture once before your action, perform the action, capture again \u2014 the second",
|
|
47
|
+
"response IS the diff. You only need browser_blueprint_diff when comparing across different URLs.",
|
|
48
|
+
"",
|
|
49
|
+
"Logical names are stable \u2014 use them in generated test code. Refs (from browser_snapshot) are",
|
|
50
|
+
"ephemeral \u2014 never use them in generated code."
|
|
51
|
+
].join("\n"),
|
|
52
|
+
inputSchema: import_mcpBundle.z.object({}),
|
|
53
|
+
type: "readOnly"
|
|
54
|
+
},
|
|
55
|
+
handle: async (tab, _params, response) => {
|
|
56
|
+
try {
|
|
57
|
+
const current = await (0, import_blueprint.buildPageBlueprint)(tab.page);
|
|
58
|
+
const url = current.url;
|
|
59
|
+
const previous = tab.blueprintCache.get(url);
|
|
60
|
+
if (!previous) {
|
|
61
|
+
tab.blueprintCache.put(url, current);
|
|
62
|
+
response.addTextResult(JSON.stringify({
|
|
63
|
+
isFullCapture: true,
|
|
64
|
+
pageHash: current.pageHash,
|
|
65
|
+
blueprint: current
|
|
66
|
+
}, null, 2));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const delta = (0, import_blueprintDiff.diffBlueprints)(previous, current);
|
|
70
|
+
tab.blueprintCache.put(url, current);
|
|
71
|
+
response.addTextResult(JSON.stringify({
|
|
72
|
+
isFullCapture: false,
|
|
73
|
+
pageHash: current.pageHash,
|
|
74
|
+
previousPageHash: previous.pageHash,
|
|
75
|
+
delta
|
|
76
|
+
}, null, 2));
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err instanceof import_blueprint.BlueprintInvariantError) {
|
|
79
|
+
response.addError(
|
|
80
|
+
`Blueprint invariant violated: ${err.message}. This is a builder bug; please file an issue with the URL of the page that triggered it.`
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const PageBlueprintLooseSchema = import_mcpBundle.z.object({
|
|
89
|
+
schemaVersion: import_mcpBundle.z.literal(1).describe("Always 1 for the current PageBlueprint format. Copy from the captured blueprint."),
|
|
90
|
+
url: import_mcpBundle.z.string().describe("Page URL the blueprint was captured from. Copy from the captured blueprint."),
|
|
91
|
+
capturedAt: import_mcpBundle.z.string().describe("ISO-8601 UTC timestamp of capture. Copy from the captured blueprint."),
|
|
92
|
+
pageHash: import_mcpBundle.z.string().describe("Cheap hash of the page structure for staleness detection. Copy from the captured blueprint."),
|
|
93
|
+
sections: import_mcpBundle.z.array(import_mcpBundle.z.any()).describe("The blueprint's sections array \u2014 full structural data. Copy from the captured blueprint without modification.")
|
|
94
|
+
});
|
|
95
|
+
const blueprintDiff = (0, import_tool.defineTool)({
|
|
96
|
+
capability: "core",
|
|
97
|
+
schema: {
|
|
98
|
+
name: "browser_blueprint_diff",
|
|
99
|
+
title: "Diff two PageBlueprints",
|
|
100
|
+
description: [
|
|
101
|
+
"Compute a structured delta between two PageBlueprints.",
|
|
102
|
+
"",
|
|
103
|
+
"In most cases you do NOT need this tool \u2014 browser_blueprint already returns",
|
|
104
|
+
"the delta against the previous capture at the same URL automatically.",
|
|
105
|
+
"",
|
|
106
|
+
"Use this tool only when:",
|
|
107
|
+
" \u2022 Comparing blueprints across DIFFERENT URLs (e.g. before/after a navigation)",
|
|
108
|
+
" \u2014 diffing across URLs is supported and produces a delta with urlChange populated.",
|
|
109
|
+
" \u2022 Reconstructing a delta from blueprints captured outside the current cache window.",
|
|
110
|
+
"",
|
|
111
|
+
"Both blueprints must be from the same MCP session \u2014 the tool cannot verify this.",
|
|
112
|
+
"Judgment about which facts become assertions lives in the NL \u2192 Trace prompt,",
|
|
113
|
+
"not in this tool."
|
|
114
|
+
].join("\n"),
|
|
115
|
+
inputSchema: import_mcpBundle.z.object({
|
|
116
|
+
before: PageBlueprintLooseSchema.describe("PageBlueprint captured BEFORE the action (e.g. before a click that opens a dialog). Pass the full blueprint object returned by an earlier browser_blueprint call."),
|
|
117
|
+
after: PageBlueprintLooseSchema.describe("PageBlueprint captured AFTER the action. Same shape as `before`. The tool computes the structured delta \u2014 what was added, removed, or changed.")
|
|
118
|
+
}),
|
|
119
|
+
type: "readOnly"
|
|
120
|
+
},
|
|
121
|
+
handle: async (_context, params, response) => {
|
|
122
|
+
const delta = (0, import_blueprintDiff.diffBlueprints)(
|
|
123
|
+
params.before,
|
|
124
|
+
params.after
|
|
125
|
+
);
|
|
126
|
+
response.addTextResult(JSON.stringify(delta, null, 2));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
var pageBlueprint_default = [blueprint, blueprintDiff];
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
var import_vitest = require("vitest");
|
|
25
|
+
var import_blueprint = require("../../../dom-analyzer/blueprint");
|
|
26
|
+
var import_blueprintCache = require("../../../dom-analyzer/blueprintCache");
|
|
27
|
+
var import_pageBlueprint = __toESM(require("./pageBlueprint"));
|
|
28
|
+
import_vitest.vi.mock("../../../dom-analyzer/blueprint", async () => {
|
|
29
|
+
const actual = await import_vitest.vi.importActual("../../../dom-analyzer/blueprint");
|
|
30
|
+
return {
|
|
31
|
+
...actual,
|
|
32
|
+
buildPageBlueprint: import_vitest.vi.fn()
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
const blueprintTool = import_pageBlueprint.default.find((t) => t.schema.name === "browser_blueprint");
|
|
36
|
+
function fakeBp(url, pageHash, sections = []) {
|
|
37
|
+
return {
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
url,
|
|
40
|
+
capturedAt: "2026-05-16T00:00:00.000Z",
|
|
41
|
+
pageHash,
|
|
42
|
+
sections
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function mkTab(url) {
|
|
46
|
+
return {
|
|
47
|
+
page: { url: () => url },
|
|
48
|
+
blueprintCache: new import_blueprintCache.BlueprintCache(5),
|
|
49
|
+
modalStates: () => []
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function mkContext(tab) {
|
|
53
|
+
return {
|
|
54
|
+
ensureTab: async () => tab
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function mkResponse() {
|
|
58
|
+
const calls = [];
|
|
59
|
+
return {
|
|
60
|
+
addTextResult: (s) => calls.push(s),
|
|
61
|
+
addError: (s) => calls.push("ERR:" + s),
|
|
62
|
+
setIncludeSnapshot: () => {
|
|
63
|
+
},
|
|
64
|
+
addCode: (_s) => {
|
|
65
|
+
},
|
|
66
|
+
_calls: calls
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
(0, import_vitest.beforeEach)(() => {
|
|
70
|
+
import_vitest.vi.clearAllMocks();
|
|
71
|
+
});
|
|
72
|
+
(0, import_vitest.describe)("browser_blueprint cache", () => {
|
|
73
|
+
(0, import_vitest.it)("first call at a URL returns isFullCapture: true with the blueprint", async () => {
|
|
74
|
+
const tab = mkTab("http://a/orders/1");
|
|
75
|
+
const ctx = mkContext(tab);
|
|
76
|
+
const bp = fakeBp("http://a/orders/1", "h1");
|
|
77
|
+
import_blueprint.buildPageBlueprint.mockResolvedValueOnce(bp);
|
|
78
|
+
const resp = mkResponse();
|
|
79
|
+
await blueprintTool.handle(ctx, {}, resp);
|
|
80
|
+
(0, import_vitest.expect)(resp._calls).toHaveLength(1);
|
|
81
|
+
const out = JSON.parse(resp._calls[0]);
|
|
82
|
+
(0, import_vitest.expect)(out.isFullCapture).toBe(true);
|
|
83
|
+
(0, import_vitest.expect)(out.pageHash).toBe("h1");
|
|
84
|
+
(0, import_vitest.expect)(out.blueprint).toEqual(bp);
|
|
85
|
+
});
|
|
86
|
+
(0, import_vitest.it)("second call at same URL returns isFullCapture: false with a delta", async () => {
|
|
87
|
+
const tab = mkTab("http://a/orders/1");
|
|
88
|
+
const ctx = mkContext(tab);
|
|
89
|
+
const bp1 = fakeBp("http://a/orders/1", "h1");
|
|
90
|
+
const bp2 = fakeBp("http://a/orders/1", "h2");
|
|
91
|
+
import_blueprint.buildPageBlueprint.mockResolvedValueOnce(bp1).mockResolvedValueOnce(bp2);
|
|
92
|
+
await blueprintTool.handle(ctx, {}, mkResponse());
|
|
93
|
+
const r2 = mkResponse();
|
|
94
|
+
await blueprintTool.handle(ctx, {}, r2);
|
|
95
|
+
const out2 = JSON.parse(r2._calls[0]);
|
|
96
|
+
(0, import_vitest.expect)(out2.isFullCapture).toBe(false);
|
|
97
|
+
(0, import_vitest.expect)(out2.pageHash).toBe("h2");
|
|
98
|
+
(0, import_vitest.expect)(out2.previousPageHash).toBe("h1");
|
|
99
|
+
(0, import_vitest.expect)(out2.delta).toMatchObject({
|
|
100
|
+
pageHashChange: { before: "h1", after: "h2" },
|
|
101
|
+
hasStructuralChange: import_vitest.expect.any(Boolean),
|
|
102
|
+
elementsAdded: import_vitest.expect.any(Array),
|
|
103
|
+
elementsRemoved: import_vitest.expect.any(Array),
|
|
104
|
+
textChanges: import_vitest.expect.any(Array)
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
(0, import_vitest.it)("updates stored entry so third call diffs against second", async () => {
|
|
108
|
+
const tab = mkTab("http://a/orders/1");
|
|
109
|
+
const ctx = mkContext(tab);
|
|
110
|
+
const bp1 = fakeBp("http://a/orders/1", "h1");
|
|
111
|
+
const bp2 = fakeBp("http://a/orders/1", "h2");
|
|
112
|
+
const bp3 = fakeBp("http://a/orders/1", "h3");
|
|
113
|
+
import_blueprint.buildPageBlueprint.mockResolvedValueOnce(bp1).mockResolvedValueOnce(bp2).mockResolvedValueOnce(bp3);
|
|
114
|
+
await blueprintTool.handle(ctx, {}, mkResponse());
|
|
115
|
+
await blueprintTool.handle(ctx, {}, mkResponse());
|
|
116
|
+
const r3 = mkResponse();
|
|
117
|
+
await blueprintTool.handle(ctx, {}, r3);
|
|
118
|
+
const out3 = JSON.parse(r3._calls[0]);
|
|
119
|
+
(0, import_vitest.expect)(out3.isFullCapture).toBe(false);
|
|
120
|
+
(0, import_vitest.expect)(out3.previousPageHash).toBe("h2");
|
|
121
|
+
(0, import_vitest.expect)(out3.pageHash).toBe("h3");
|
|
122
|
+
});
|
|
123
|
+
(0, import_vitest.it)("different URL gets its own full capture", async () => {
|
|
124
|
+
const tab = mkTab("http://a/orders/1");
|
|
125
|
+
const ctx = mkContext(tab);
|
|
126
|
+
const bp1 = fakeBp("http://a/orders/1", "h1");
|
|
127
|
+
const bp2 = fakeBp("http://a/orders/2", "g1");
|
|
128
|
+
import_blueprint.buildPageBlueprint.mockResolvedValueOnce(bp1).mockResolvedValueOnce(bp2);
|
|
129
|
+
await blueprintTool.handle(ctx, {}, mkResponse());
|
|
130
|
+
tab.page.url = () => "http://a/orders/2";
|
|
131
|
+
const r2 = mkResponse();
|
|
132
|
+
await blueprintTool.handle(ctx, {}, r2);
|
|
133
|
+
const out2 = JSON.parse(r2._calls[0]);
|
|
134
|
+
(0, import_vitest.expect)(out2.isFullCapture).toBe(true);
|
|
135
|
+
(0, import_vitest.expect)(out2.blueprint).toEqual(bp2);
|
|
136
|
+
});
|
|
137
|
+
});
|