@skyramp/mcp 0.2.1-rc.1 → 0.2.2
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 +10 -0
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +98 -87
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +92 -60
- package/build/prompts/test-maintenance/driftAnalysisSections.js +139 -197
- package/build/prompts/test-recommendation/scopeAssessment.js +106 -5
- package/build/prompts/test-recommendation/scopeAssessment.test.js +128 -1
- package/build/prompts/testbot/testbot-prompts.js +6 -9
- package/build/prompts/testbot/testbot-prompts.test.js +38 -22
- package/build/services/TestDiscoveryService.js +39 -9
- package/build/tools/test-management/actionsTool.js +166 -148
- package/build/tools/test-management/analyzeChangesTool.js +10 -12
- package/build/tools/test-management/analyzeTestHealthTool.js +10 -22
- package/build/tools/test-management/uiAnalyzeChangesTool.js +8 -2
- package/build/tools/test-management/uiAnalyzeChangesTool.test.js +47 -0
- package/build/utils/dartRouteExtractor.js +319 -0
- package/build/utils/dartRouteExtractor.test.js +307 -0
- package/build/utils/docker.test.js +1 -1
- package/build/utils/uiPageEnumerator.js +67 -0
- package/build/utils/uiPageEnumerator.test.js +222 -0
- package/build/utils/versions.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/assertApiRequestTool.js +46 -0
- package/node_modules/playwright/lib/mcp/skyramp/index.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +313 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +146 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +519 -52
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +32 -14
- package/package.json +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +0 -261
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
|
@@ -43,6 +43,10 @@ var import_log = require("../log");
|
|
|
43
43
|
var import_skyRampExport = require("../test/skyRampExport");
|
|
44
44
|
var import_exportTool = require("./exportTool");
|
|
45
45
|
var import_assertTool = require("./assertTool");
|
|
46
|
+
var import_assertApiRequestTool = require("./assertApiRequestTool");
|
|
47
|
+
var import_loadTraceTool = require("./loadTraceTool");
|
|
48
|
+
var import_skyRampImport = require("./skyRampImport");
|
|
49
|
+
var import_utils = require("playwright-core/lib/utils");
|
|
46
50
|
var import_types = require("./types");
|
|
47
51
|
const traceDebug = (0, import_utilsBundle.debug)("pw:mcp:trace");
|
|
48
52
|
class TraceRecordingBackend {
|
|
@@ -115,7 +119,7 @@ class TraceRecordingBackend {
|
|
|
115
119
|
}
|
|
116
120
|
async listTools() {
|
|
117
121
|
const browserTools = await this._browserBackend.listTools();
|
|
118
|
-
return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)()];
|
|
122
|
+
return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)(), (0, import_loadTraceTool.loadTraceMcpTool)()];
|
|
119
123
|
}
|
|
120
124
|
async callTool(name, args, progress) {
|
|
121
125
|
if (!this._initialized)
|
|
@@ -138,10 +142,27 @@ class TraceRecordingBackend {
|
|
|
138
142
|
}
|
|
139
143
|
return exportResult;
|
|
140
144
|
}
|
|
145
|
+
if (name === import_loadTraceTool.loadTraceSchema.name) {
|
|
146
|
+
const parsed = import_loadTraceTool.loadTraceSchema.inputSchema.parse(args || {});
|
|
147
|
+
return this._handleLoadTrace(parsed);
|
|
148
|
+
}
|
|
141
149
|
if (name === import_assertTool.assertToolSchema.name) {
|
|
142
150
|
const parsed = import_assertTool.assertToolSchema.inputSchema.parse(args || {});
|
|
143
151
|
return this._handleAssert(parsed);
|
|
144
152
|
}
|
|
153
|
+
if (name === import_assertApiRequestTool.assertApiRequestSchema.name) {
|
|
154
|
+
this._trackedActions.push({
|
|
155
|
+
toolName: "browser_assert_api_request",
|
|
156
|
+
args: {},
|
|
157
|
+
code: "",
|
|
158
|
+
timestamp: Date.now(),
|
|
159
|
+
pageAlias: this._currentPageAlias
|
|
160
|
+
});
|
|
161
|
+
traceDebug("Assert API request marker recorded");
|
|
162
|
+
return {
|
|
163
|
+
content: [{ type: "text", text: "### Result\nAPI request assertion marker recorded. The next action that triggers a network request will have its payload captured for validation in the generated test." }]
|
|
164
|
+
};
|
|
165
|
+
}
|
|
145
166
|
if (name === "browser_select_option") {
|
|
146
167
|
const result2 = await this._handleSelectOption(args || {});
|
|
147
168
|
return result2;
|
|
@@ -178,6 +199,7 @@ class TraceRecordingBackend {
|
|
|
178
199
|
timestamp: ts,
|
|
179
200
|
pageAlias: this._currentPageAlias
|
|
180
201
|
});
|
|
202
|
+
await this._enableFlutterAccessibility();
|
|
181
203
|
const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
|
|
182
204
|
return {
|
|
183
205
|
content: [
|
|
@@ -191,17 +213,88 @@ Reloaded current page: ${currentUrl}
|
|
|
191
213
|
}
|
|
192
214
|
const timestampBeforeAction = Date.now();
|
|
193
215
|
const pageAliasBeforeAction = this._currentPageAlias;
|
|
216
|
+
const stripRestore = await this._maybeStripWrapperRoles(name, args);
|
|
194
217
|
let preClickHoverCode = null;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
|
|
218
|
+
let preClickSnapshot = null;
|
|
219
|
+
let preClickLocatorCount = 0;
|
|
220
|
+
let preClickParentChain = null;
|
|
221
|
+
let result;
|
|
222
|
+
try {
|
|
223
|
+
if (name === "browser_click" && args?.ref) {
|
|
224
|
+
const hoverResult = await this._browserBackend.callTool("browser_hover", {
|
|
225
|
+
element: args.element || "element",
|
|
226
|
+
ref: args.ref
|
|
227
|
+
});
|
|
228
|
+
if (!hoverResult.isError) {
|
|
229
|
+
const parsed = (0, import_response.parseResponse)(hoverResult);
|
|
230
|
+
preClickHoverCode = parsed?.code ?? null;
|
|
231
|
+
preClickSnapshot = parsed?.snapshot ?? null;
|
|
232
|
+
if (preClickHoverCode) {
|
|
233
|
+
const roleCountMatch = preClickHoverCode.match(/getByRole\(\s*['"](\w+)['"]\s*(?:,\s*\{[^}]*name:\s*(?:'([^']*)'|"([^"]*)")\s*[^}]*\})?\s*\)/);
|
|
234
|
+
if (roleCountMatch) {
|
|
235
|
+
try {
|
|
236
|
+
const page = this._browserBackend.context?.currentTab()?.page;
|
|
237
|
+
if (page) {
|
|
238
|
+
const r = roleCountMatch[1];
|
|
239
|
+
const n = roleCountMatch[2] ?? roleCountMatch[3];
|
|
240
|
+
preClickLocatorCount = n ? await page.getByRole(r, { name: n }).count() : await page.getByRole(r).count();
|
|
241
|
+
traceDebug(`[pre-click count] getByRole("${r}"${n ? `, { name: "${n}" }` : ""}) = ${preClickLocatorCount}`);
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const page = this._browserBackend.context?.currentTab()?.page;
|
|
249
|
+
if (page) {
|
|
250
|
+
preClickParentChain = await page.locator(`aria-ref=${args.ref}`).evaluate((el) => {
|
|
251
|
+
const ARIA_LANDMARKS = /* @__PURE__ */ new Set(["navigation", "main", "banner", "contentinfo", "complementary", "search", "form"]);
|
|
252
|
+
const SEMANTIC_TAGS_UNIQUE_CANDIDATES = ["nav", "main", "header", "footer", "aside"];
|
|
253
|
+
const SKIP_TESTID = /^(container|wrapper|content|main|root|app)$/i;
|
|
254
|
+
const SKIP_ID = /^(root|app|main|container|wrapper|content)$/i;
|
|
255
|
+
const isDynamic = (s) => /[-_]\d+$/.test(s) || /^[0-9a-f]{8,}/.test(s);
|
|
256
|
+
let cur = el.parentElement;
|
|
257
|
+
while (cur && cur !== el.ownerDocument.body) {
|
|
258
|
+
const tid = cur.getAttribute("data-testid") || cur.getAttribute("data-test-id");
|
|
259
|
+
if (tid && !isDynamic(tid) && !SKIP_TESTID.test(tid))
|
|
260
|
+
return `getByTestId(${JSON.stringify(tid)})`;
|
|
261
|
+
const id = cur.id;
|
|
262
|
+
if (id && !isDynamic(id) && !SKIP_ID.test(id)) {
|
|
263
|
+
const escapedId = id.replace(/(["\\])/g, "\\$1");
|
|
264
|
+
return `locator(${JSON.stringify("#" + escapedId)})`;
|
|
265
|
+
}
|
|
266
|
+
const role = cur.getAttribute("role");
|
|
267
|
+
if (role && ARIA_LANDMARKS.has(role)) {
|
|
268
|
+
const ariaLabel = cur.getAttribute("aria-label");
|
|
269
|
+
if (ariaLabel)
|
|
270
|
+
return `getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(ariaLabel)} })`;
|
|
271
|
+
if (el.ownerDocument.querySelectorAll(`[role="${role}"]`).length === 1)
|
|
272
|
+
return `getByRole(${JSON.stringify(role)})`;
|
|
273
|
+
}
|
|
274
|
+
const tag = cur.tagName.toLowerCase();
|
|
275
|
+
if (SEMANTIC_TAGS_UNIQUE_CANDIDATES.includes(tag)) {
|
|
276
|
+
if (el.ownerDocument.querySelectorAll(tag).length === 1)
|
|
277
|
+
return `locator(${JSON.stringify(tag)})`;
|
|
278
|
+
}
|
|
279
|
+
cur = cur.parentElement;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}).catch(() => null);
|
|
283
|
+
traceDebug(`[pre-click parent chain] ${preClickParentChain}`);
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
result = await this._browserBackend.callTool(name, args);
|
|
290
|
+
} finally {
|
|
291
|
+
if (stripRestore)
|
|
292
|
+
await stripRestore();
|
|
202
293
|
}
|
|
203
|
-
|
|
204
|
-
|
|
294
|
+
if (name === "browser_navigate" && !result.isError) {
|
|
295
|
+
await this._enableFlutterAccessibility();
|
|
296
|
+
}
|
|
297
|
+
result = await this._improveActionSelector(name, args || {}, result, preClickHoverCode, preClickSnapshot, preClickLocatorCount, preClickParentChain);
|
|
205
298
|
if (name === "browser_fill_form" && result.isError) {
|
|
206
299
|
const resultText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
|
207
300
|
if (resultText.includes("not a <select> element") || resultText.includes("selectOption") && resultText.includes("Timeout")) {
|
|
@@ -227,6 +320,216 @@ Reloaded current page: ${currentUrl}
|
|
|
227
320
|
serverClosed() {
|
|
228
321
|
void this._autoExportAndClose().catch(import_log.logUnhandledError);
|
|
229
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Load a prior Skyramp trace and replay it against the live browser, honoring
|
|
325
|
+
* an optional stop point, then seed _trackedActions with the replayed actions
|
|
326
|
+
* so the model can continue recording and export a combined trace.
|
|
327
|
+
*/
|
|
328
|
+
async _handleLoadTrace(params) {
|
|
329
|
+
const stopParams = ["stopAtStep", "stopAtUrl", "stopBefore"].filter((k) => {
|
|
330
|
+
const v = params[k];
|
|
331
|
+
return v !== void 0 && v !== "";
|
|
332
|
+
});
|
|
333
|
+
if (stopParams.length > 1)
|
|
334
|
+
return { content: [{ type: "text", text: `### Error
|
|
335
|
+
Pass at most ONE of stopAtStep / stopAtUrl / stopBefore (got ${stopParams.join(", ")}). Omit all three to replay the whole trace.` }], isError: true };
|
|
336
|
+
let loadedActions;
|
|
337
|
+
try {
|
|
338
|
+
loadedActions = await (0, import_skyRampImport.actionsFromPath)(params.path);
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return { content: [{ type: "text", text: `### Error
|
|
341
|
+
Failed to load trace from ${params.path}: ${e.message}` }], isError: true };
|
|
342
|
+
}
|
|
343
|
+
if (!loadedActions.length)
|
|
344
|
+
return { content: [{ type: "text", text: `### Error
|
|
345
|
+
No actions found in ${params.path}.` }], isError: true };
|
|
346
|
+
if (params.dryRun) {
|
|
347
|
+
const stepList = loadedActions.map((a, i) => (0, import_loadTraceTool.describeStep)(a, i)).join("\n");
|
|
348
|
+
return {
|
|
349
|
+
content: [{
|
|
350
|
+
type: "text",
|
|
351
|
+
text: `### Trace loaded (dry run)
|
|
352
|
+
${loadedActions.length} steps in ${params.path}. Nothing was replayed.
|
|
353
|
+
|
|
354
|
+
Steps:
|
|
355
|
+
${stepList}
|
|
356
|
+
|
|
357
|
+
Call skyramp_load_trace again with stopAtStep / stopAtUrl / stopBefore (or none of them to replay all) to replay.`
|
|
358
|
+
}]
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
await this._browserBackend.context.ensureTab();
|
|
363
|
+
} catch {
|
|
364
|
+
}
|
|
365
|
+
let result;
|
|
366
|
+
try {
|
|
367
|
+
result = await (0, import_loadTraceTool.replayActions)(loadedActions, { pageForAlias: (alias) => this._pageForAlias(alias) }, params);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
return { content: [{ type: "text", text: `### Error
|
|
370
|
+
Replay failed: ${e.message}` }], isError: true };
|
|
371
|
+
}
|
|
372
|
+
for (let i = 0; i < result.stopIndex; i++)
|
|
373
|
+
this._seedTrackedAction(loadedActions[i]);
|
|
374
|
+
if (result.stopIndex > 0)
|
|
375
|
+
this._currentPageAlias = loadedActions[result.stopIndex - 1].frame.pageAlias || "page";
|
|
376
|
+
const remaining = (0, import_loadTraceTool.listStepsFrom)(loadedActions, result.stopIndex);
|
|
377
|
+
const stoppedNote = (0, import_loadTraceTool.describeStopReason)(result);
|
|
378
|
+
const errorNote = result.error ? `
|
|
379
|
+
|
|
380
|
+
\u26A0\uFE0F Replay stopped at step ${result.error.stepIndex} (${result.error.actionName}): ${result.error.message}
|
|
381
|
+
You can continue recording from this point or fix the issue.` : "";
|
|
382
|
+
return {
|
|
383
|
+
content: [{
|
|
384
|
+
type: "text",
|
|
385
|
+
text: `### Trace replayed
|
|
386
|
+
${result.completedCount} of ${loadedActions.length} steps replayed from ${params.path}. ${stoppedNote}${errorNote}
|
|
387
|
+
|
|
388
|
+
Remaining steps (NOT replayed):
|
|
389
|
+
${remaining}
|
|
390
|
+
|
|
391
|
+
Continue recording with browser_* tools, then call skyramp_export_zip to write the combined trace.`
|
|
392
|
+
}],
|
|
393
|
+
isError: result.stopReason === "error"
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Resolve a JSONL page alias to a live page. Numeric aliases ('page',
|
|
398
|
+
* 'pageN') map by tab index; semantic aliases ('popupPage') match by the
|
|
399
|
+
* popup URL pattern, falling back to the most recently opened tab.
|
|
400
|
+
*/
|
|
401
|
+
_pageForAlias(alias) {
|
|
402
|
+
const tabs = this._browserBackend.context?.tabs() ?? [];
|
|
403
|
+
if (!tabs.length)
|
|
404
|
+
return void 0;
|
|
405
|
+
const numeric = alias === "page" ? 0 : /^page(\d+)$/.exec(alias)?.[1];
|
|
406
|
+
if (numeric !== void 0 && numeric !== null) {
|
|
407
|
+
const idx = Number(numeric);
|
|
408
|
+
return (tabs[idx] ?? tabs[tabs.length - 1]).page;
|
|
409
|
+
}
|
|
410
|
+
const popupTab = tabs.find((t) => /chrome-extension:\/\/[a-p]{32}\/popup\//.test(t.page.url()));
|
|
411
|
+
return (popupTab ?? tabs[tabs.length - 1]).page;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Convert a replayed ActionInContext back into a TrackedAction so it merges
|
|
415
|
+
* with freshly-recorded actions. The export pipeline (buildJsonlContent)
|
|
416
|
+
* re-derives the JSONL selector from the `code` field, so we regenerate clean
|
|
417
|
+
* Playwright code from the stored internal: selector via asLocator.
|
|
418
|
+
*/
|
|
419
|
+
_seedTrackedAction(action) {
|
|
420
|
+
const a = action.action;
|
|
421
|
+
const pageAlias = action.frame.pageAlias || "page";
|
|
422
|
+
if (a.name === "navigate") {
|
|
423
|
+
this._trackedActions.push({
|
|
424
|
+
toolName: "browser_navigate",
|
|
425
|
+
args: { url: a.url },
|
|
426
|
+
code: "",
|
|
427
|
+
timestamp: action.startTime,
|
|
428
|
+
pageAlias
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const locatorExpr = a.selector ? (0, import_utils.asLocator)("javascript", a.selector) : "";
|
|
433
|
+
const seeded = this._seedTrackedActionFields(a, locatorExpr);
|
|
434
|
+
if (!seeded)
|
|
435
|
+
return;
|
|
436
|
+
this._trackedActions.push({
|
|
437
|
+
...seeded,
|
|
438
|
+
timestamp: action.startTime,
|
|
439
|
+
pageAlias,
|
|
440
|
+
framePath: action.frame.framePath?.length ? action.frame.framePath : void 0
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Build the { toolName, code, args } triple a seeded (replayed) action must
|
|
445
|
+
* carry so it round-trips through skyRampExport.buildJsonlContent exactly as
|
|
446
|
+
* a freshly-recorded action would. toolName, code and args are emitted
|
|
447
|
+
* together (not via separate mappers) because export keys on all three:
|
|
448
|
+
* trackedActionToJsonl dispatches on toolName, derives the locator from code,
|
|
449
|
+
* and reads specific args (checked, values, type). A mismatch silently
|
|
450
|
+
* mis-exports (e.g. hover as click) or drops the action.
|
|
451
|
+
*
|
|
452
|
+
* Code uses single quotes to match the live recorder's format
|
|
453
|
+
* (extractFillText / extractSelectOptions only match single-quoted calls).
|
|
454
|
+
* Returns null for unsupported actions so they are skipped, never defaulted.
|
|
455
|
+
*/
|
|
456
|
+
_seedTrackedActionFields(a, locatorExpr) {
|
|
457
|
+
const sq = (s) => `'${String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}'`;
|
|
458
|
+
const SELECTOR_REQUIRED = /* @__PURE__ */ new Set(["click", "hover", "fill", "pressSequentially", "check", "uncheck", "select"]);
|
|
459
|
+
if (SELECTOR_REQUIRED.has(a.name) && !locatorExpr)
|
|
460
|
+
return null;
|
|
461
|
+
const root = a.frameUrlMatch ? `page.frames().find(f => f.url().includes(${sq(a.frameUrlMatch)}))!` : "page";
|
|
462
|
+
switch (a.name) {
|
|
463
|
+
case "click": {
|
|
464
|
+
const isDouble = (a.clickCount ?? 1) > 1;
|
|
465
|
+
const verb = isDouble ? "dblclick" : "click";
|
|
466
|
+
const args = {};
|
|
467
|
+
if (a.button && a.button !== "left")
|
|
468
|
+
args.button = a.button;
|
|
469
|
+
const mods = (0, import_loadTraceTool.decodeModifiers)(a.modifiers);
|
|
470
|
+
if (mods.length)
|
|
471
|
+
args.modifiers = mods;
|
|
472
|
+
return { toolName: "browser_click", code: `await ${root}.${locatorExpr}.${verb}();`, args };
|
|
473
|
+
}
|
|
474
|
+
case "hover":
|
|
475
|
+
return { toolName: "browser_hover", code: `await ${root}.${locatorExpr}.hover();`, args: {} };
|
|
476
|
+
case "fill":
|
|
477
|
+
return { toolName: "browser_type", code: `await ${root}.${locatorExpr}.fill(${sq(a.text ?? "")});`, args: { text: a.text ?? "" } };
|
|
478
|
+
case "pressSequentially":
|
|
479
|
+
return { toolName: "browser_type", code: `await ${root}.${locatorExpr}.pressSequentially(${sq(a.text ?? "")});`, args: { text: a.text ?? "", slowly: true } };
|
|
480
|
+
case "press": {
|
|
481
|
+
const args = { key: a.key };
|
|
482
|
+
const mods = (0, import_loadTraceTool.decodeModifiers)(a.modifiers);
|
|
483
|
+
if (mods.length)
|
|
484
|
+
args.modifiers = mods;
|
|
485
|
+
return { toolName: "browser_press_key", code: "", args };
|
|
486
|
+
}
|
|
487
|
+
case "assertApiRequest":
|
|
488
|
+
return { toolName: "browser_assert_api_request", code: "", args: {} };
|
|
489
|
+
case "waitForTimeout":
|
|
490
|
+
return { toolName: "browser_wait_for", code: "", args: { time: (a.duration ?? 0) / 1e3 } };
|
|
491
|
+
case "check":
|
|
492
|
+
return { toolName: "browser_set_checked", code: `await ${root}.${locatorExpr}.check();`, args: { checked: true } };
|
|
493
|
+
case "uncheck":
|
|
494
|
+
return { toolName: "browser_set_checked", code: `await ${root}.${locatorExpr}.uncheck();`, args: { checked: false } };
|
|
495
|
+
case "select":
|
|
496
|
+
return { toolName: "browser_select_option", code: `await ${root}.${locatorExpr}.selectOption(${sq((a.options ?? [])[0] ?? "")});`, args: { values: a.options ?? [] } };
|
|
497
|
+
case "waitForSelector": {
|
|
498
|
+
const sel = a.selector ?? "";
|
|
499
|
+
const m = /^text=(.*)$/.exec(sel);
|
|
500
|
+
if (!m)
|
|
501
|
+
return null;
|
|
502
|
+
return a.state === "hidden" ? { toolName: "browser_wait_for", code: "", args: { textGone: m[1] } } : { toolName: "browser_wait_for", code: "", args: { text: m[1] } };
|
|
503
|
+
}
|
|
504
|
+
case "dragAndDrop":
|
|
505
|
+
case "dragTo": {
|
|
506
|
+
const srcSel = a.source ?? a.selector;
|
|
507
|
+
const tgtSel = a.target ?? a.targetSelector;
|
|
508
|
+
if (!srcSel || !tgtSel)
|
|
509
|
+
return null;
|
|
510
|
+
const srcExpr = (0, import_utils.asLocator)("javascript", srcSel);
|
|
511
|
+
const tgtExpr = (0, import_utils.asLocator)("javascript", tgtSel);
|
|
512
|
+
return { toolName: "browser_drag", code: `await page.${srcExpr}.dragTo(page.${tgtExpr});`, args: {} };
|
|
513
|
+
}
|
|
514
|
+
// Assertions are encoded the same way _handleAssert encodes them, so
|
|
515
|
+
// assertActionToJsonl in skyRampExport parses them identically. That
|
|
516
|
+
// parser dispatches on toolName === 'browser_assert' and keys on args.type.
|
|
517
|
+
// Assertions pass selector/expected as structured args (unambiguous even
|
|
518
|
+
// when the value contains ':'). assertActionToJsonl reads these directly;
|
|
519
|
+
// the legacy colon-encoded code string is kept only as a human-readable
|
|
520
|
+
// breadcrumb / backward-compat fallback.
|
|
521
|
+
case "assertText":
|
|
522
|
+
return { toolName: "browser_assert", code: `assertText:${a.selector}:${a.text}:${a.substring ?? true}`, args: { type: "text", selector: a.selector, expected: a.text, substring: a.substring ?? true } };
|
|
523
|
+
case "assertValue":
|
|
524
|
+
return { toolName: "browser_assert", code: `assertValue:${a.selector}:${a.value}`, args: { type: "value", selector: a.selector, expected: a.value } };
|
|
525
|
+
case "assertChecked":
|
|
526
|
+
return { toolName: "browser_assert", code: `assertChecked:${a.selector}:${!!a.checked}`, args: { type: "checked", selector: a.selector, checked: !!a.checked } };
|
|
527
|
+
case "assertVisible":
|
|
528
|
+
return { toolName: "browser_assert", code: `assertVisible:${a.selector}`, args: { type: "visible", selector: a.selector } };
|
|
529
|
+
default:
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
230
533
|
/**
|
|
231
534
|
* Handle browser_select_option with automatic fallback for custom dropdowns.
|
|
232
535
|
* 1. Try native selectOption first.
|
|
@@ -387,7 +690,10 @@ Could not parse locator: ${locatorExpr}` }], isError: true };
|
|
|
387
690
|
const assertName = params.type === "value" ? "assertValue" : "assertText";
|
|
388
691
|
this._trackedActions.push({
|
|
389
692
|
toolName: "browser_assert",
|
|
390
|
-
|
|
693
|
+
// Pass selector/expected as structured args so assertActionToJsonl can
|
|
694
|
+
// decode unambiguously (the colon-encoded code below corrupts values
|
|
695
|
+
// that contain ':', e.g. URLs). The code string is a fallback only.
|
|
696
|
+
args: { type: params.type, ref: params.ref, selector: parsed.selector, expected: params.expected, ...params.type === "text" ? { substring: params.substring } : {} },
|
|
391
697
|
code: `${assertName}:${parsed.selector}:${params.expected}${params.type === "text" ? ":" + params.substring : ""}`,
|
|
392
698
|
timestamp
|
|
393
699
|
});
|
|
@@ -563,7 +869,7 @@ ${details}` }]
|
|
|
563
869
|
* preClickHoverCode: hover result taken BEFORE the click (needed for navigation clicks
|
|
564
870
|
* where the element ref is no longer valid after navigation).
|
|
565
871
|
*/
|
|
566
|
-
async _improveActionSelector(toolName, args, result, preClickHoverCode = null) {
|
|
872
|
+
async _improveActionSelector(toolName, args, result, preClickHoverCode = null, preClickSnapshot = null, preClickLocatorCount = 0, preClickParentChain = null) {
|
|
567
873
|
if (result.isError)
|
|
568
874
|
return result;
|
|
569
875
|
const selectorTools = /* @__PURE__ */ new Set(["browser_click", "browser_hover", "browser_type", "browser_fill"]);
|
|
@@ -606,43 +912,64 @@ ${details}` }]
|
|
|
606
912
|
}
|
|
607
913
|
return result;
|
|
608
914
|
}
|
|
609
|
-
|
|
610
|
-
|
|
915
|
+
if (code.includes(".filter(") || code.includes(".nth(") || code.includes(".first(") || code.includes(".last("))
|
|
916
|
+
return result;
|
|
917
|
+
if (/page\.(getByTestId|locator|getByRole)\([^)]*\)\.getByRole/.test(code))
|
|
918
|
+
return result;
|
|
919
|
+
const bareRoleMatch = code.match(/getByRole\(\s*['"](\w+)['"]\s*\)/);
|
|
920
|
+
const namedRoleMatch = code.match(/getByRole\(\s*['"](\w+)['"]\s*,\s*\{[^}]*name:\s*(?:'([^']*)'|"([^"]*)")/);
|
|
921
|
+
if (!bareRoleMatch && !namedRoleMatch)
|
|
611
922
|
return result;
|
|
612
|
-
const role =
|
|
923
|
+
const role = bareRoleMatch?.[1] ?? namedRoleMatch[1];
|
|
924
|
+
const roleName = namedRoleMatch ? namedRoleMatch[2] ?? namedRoleMatch[3] : void 0;
|
|
613
925
|
const ref = args.ref;
|
|
614
926
|
if (!ref)
|
|
615
927
|
return result;
|
|
616
|
-
const snapText = parsed?.snapshot
|
|
617
|
-
|
|
618
|
-
return result;
|
|
619
|
-
const snapLines = snapText.split("\n");
|
|
928
|
+
const snapText = preClickSnapshot || parsed?.snapshot || "";
|
|
929
|
+
const snapLines = (snapText || "").split("\n");
|
|
620
930
|
const rolePattern = new RegExp(`^\\s*-\\s*${role}\\s+`, "m");
|
|
621
|
-
|
|
622
|
-
if (
|
|
623
|
-
|
|
624
|
-
|
|
931
|
+
let snapshotMatchCount = 0;
|
|
932
|
+
if (snapText) {
|
|
933
|
+
if (roleName) {
|
|
934
|
+
const escapedName = roleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
935
|
+
const namedPattern = new RegExp(`^\\s*-\\s*${role}\\s+"[^"]*${escapedName}`, "i");
|
|
936
|
+
snapshotMatchCount = snapLines.filter((l) => namedPattern.test(l)).length;
|
|
937
|
+
} else {
|
|
938
|
+
snapshotMatchCount = snapLines.filter((l) => rolePattern.test(l)).length;
|
|
939
|
+
}
|
|
625
940
|
}
|
|
626
|
-
|
|
941
|
+
const ambiguousCount = Math.max(preClickLocatorCount, snapshotMatchCount);
|
|
942
|
+
traceDebug(`[disambig] role="${role}" name="${roleName ?? ""}" preClickCount=${preClickLocatorCount} snapshotCount=${snapshotMatchCount} \u2192 ambiguousCount=${ambiguousCount}`);
|
|
943
|
+
traceDebug(`Role "${role}"${roleName ? ` name="${roleName}"` : ""} matches ${ambiguousCount} elements \u2014 attempting to disambiguate`);
|
|
627
944
|
const refPattern = `[ref=${ref}]`;
|
|
628
945
|
const refIdx = snapLines.findIndex((l) => l.includes(refPattern));
|
|
629
946
|
if (refIdx < 0)
|
|
630
947
|
return result;
|
|
631
948
|
const refLine = snapLines[refIdx];
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
949
|
+
if (!roleName && ambiguousCount > 1) {
|
|
950
|
+
const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
|
|
951
|
+
if (textMatch && textMatch[1]) {
|
|
952
|
+
const visibleText = textMatch[1];
|
|
953
|
+
const textPattern = new RegExp(`^\\s*-\\s*${role}\\s+"[^"]*${visibleText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
|
|
954
|
+
const textCount = snapLines.filter((l) => textPattern.test(l)).length;
|
|
955
|
+
if (textCount === 1) {
|
|
956
|
+
const improvedCode = code.replace(
|
|
957
|
+
/getByRole\((\s*['"](\w+)['"]\s*)\)/,
|
|
958
|
+
`getByRole($1).filter({ hasText: "${visibleText}" })`
|
|
959
|
+
);
|
|
960
|
+
traceDebug(`Improved selector with text filter: ${visibleText}`);
|
|
961
|
+
return this._modifyResultCode(result, improvedCode);
|
|
962
|
+
}
|
|
644
963
|
}
|
|
645
964
|
}
|
|
965
|
+
if (preClickParentChain) {
|
|
966
|
+
const improvedCode = code.replace(
|
|
967
|
+
/await\s+page\./,
|
|
968
|
+
`await page.${preClickParentChain}.`
|
|
969
|
+
);
|
|
970
|
+
traceDebug(`Improved selector by chaining from parent: ${preClickParentChain}`);
|
|
971
|
+
return this._modifyResultCode(result, improvedCode);
|
|
972
|
+
}
|
|
646
973
|
const refIndent = refLine.match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
647
974
|
for (let i = refIdx - 1; i >= 0; i--) {
|
|
648
975
|
const line = snapLines[i];
|
|
@@ -667,7 +994,7 @@ ${details}` }]
|
|
|
667
994
|
/await\s+page\./,
|
|
668
995
|
`await page.getByTestId("${testid}").`
|
|
669
996
|
);
|
|
670
|
-
traceDebug(`Improved selector by chaining from test-id: ${testid}`);
|
|
997
|
+
traceDebug(`Improved selector by chaining from test-id (snapshot walk): ${testid}`);
|
|
671
998
|
return this._modifyResultCode(result, improvedCode);
|
|
672
999
|
}
|
|
673
1000
|
}
|
|
@@ -676,22 +1003,24 @@ ${details}` }]
|
|
|
676
1003
|
if (indent === 0)
|
|
677
1004
|
break;
|
|
678
1005
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1006
|
+
if (ambiguousCount > 1) {
|
|
1007
|
+
const roleRefs = [];
|
|
1008
|
+
for (const line of snapLines) {
|
|
1009
|
+
if (rolePattern.test(line)) {
|
|
1010
|
+
const m = line.match(/\[ref=(\w+)\]/);
|
|
1011
|
+
if (m)
|
|
1012
|
+
roleRefs.push(m[1]);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
const index = roleRefs.indexOf(ref);
|
|
1016
|
+
if (index >= 0) {
|
|
1017
|
+
const improvedCode = code.replace(
|
|
1018
|
+
/getByRole\((\s*['"][^'"]+['"](?:\s*,\s*\{[^}]*\})?)\s*\)/,
|
|
1019
|
+
`getByRole($1).nth(${index})`
|
|
1020
|
+
);
|
|
1021
|
+
traceDebug(`Improved selector with .nth(${index})`);
|
|
1022
|
+
return this._modifyResultCode(result, improvedCode);
|
|
685
1023
|
}
|
|
686
|
-
}
|
|
687
|
-
const index = roleRefs.indexOf(ref);
|
|
688
|
-
if (index >= 0) {
|
|
689
|
-
const improvedCode = code.replace(
|
|
690
|
-
/getByRole\((\s*['"](\w+)['"]\s*)\)/,
|
|
691
|
-
`getByRole($1).nth(${index})`
|
|
692
|
-
);
|
|
693
|
-
traceDebug(`Improved selector with .nth(${index})`);
|
|
694
|
-
return this._modifyResultCode(result, improvedCode);
|
|
695
1024
|
}
|
|
696
1025
|
return result;
|
|
697
1026
|
}
|
|
@@ -779,6 +1108,51 @@ ${details}` }]
|
|
|
779
1108
|
});
|
|
780
1109
|
});
|
|
781
1110
|
}
|
|
1111
|
+
/**
|
|
1112
|
+
* SKYR-3728: detect a wrapper-button around args.ref and, if found, strip
|
|
1113
|
+
* role="button"/role="link" from every wrapper in the document so that
|
|
1114
|
+
* upstream's closestCrossShadow can't retarget UP during selector
|
|
1115
|
+
* regeneration. Returns a restore function the caller MUST invoke (via
|
|
1116
|
+
* try/finally) once the click has been recorded.
|
|
1117
|
+
*
|
|
1118
|
+
* Works for browser_click, browser_type, browser_fill, browser_hover —
|
|
1119
|
+
* anything that goes through tab.refLocator → upstream generateSelector.
|
|
1120
|
+
*/
|
|
1121
|
+
async _maybeStripWrapperRoles(toolName, args) {
|
|
1122
|
+
const interactiveTools = /* @__PURE__ */ new Set(["browser_click", "browser_type", "browser_fill", "browser_hover"]);
|
|
1123
|
+
if (!interactiveTools.has(toolName))
|
|
1124
|
+
return null;
|
|
1125
|
+
const ref = args?.ref;
|
|
1126
|
+
if (!ref)
|
|
1127
|
+
return null;
|
|
1128
|
+
const tab = this._browserBackend.context?.currentTab();
|
|
1129
|
+
const page = tab?.page;
|
|
1130
|
+
if (!page)
|
|
1131
|
+
return null;
|
|
1132
|
+
let stripped = false;
|
|
1133
|
+
try {
|
|
1134
|
+
const handle = await page.locator(`aria-ref=${ref}`).elementHandle({ timeout: 1e3 });
|
|
1135
|
+
if (!handle)
|
|
1136
|
+
return null;
|
|
1137
|
+
try {
|
|
1138
|
+
stripped = await page.evaluate(pageEvaluateStripWrapperRoles, handle);
|
|
1139
|
+
} finally {
|
|
1140
|
+
await handle.dispose();
|
|
1141
|
+
}
|
|
1142
|
+
} catch {
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
if (!stripped)
|
|
1146
|
+
return null;
|
|
1147
|
+
traceDebug(`Wrapper-leak detected for ref=${ref}; stripped role=button/link document-wide for the action`);
|
|
1148
|
+
return async () => {
|
|
1149
|
+
try {
|
|
1150
|
+
await page.evaluate(pageEvaluateRestoreWrapperRoles);
|
|
1151
|
+
traceDebug("Restored stripped roles");
|
|
1152
|
+
} catch {
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
782
1156
|
_maybeTrackAction(toolName, args, result, timestamp, pageAliasBeforeAction) {
|
|
783
1157
|
if (result.isError)
|
|
784
1158
|
return;
|
|
@@ -809,6 +1183,54 @@ ${details}` }]
|
|
|
809
1183
|
return url.replace(/\/$/, "");
|
|
810
1184
|
}
|
|
811
1185
|
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Enable Flutter web accessibility mode by clicking the hidden
|
|
1188
|
+
* flt-semantics-placeholder button. Flutter web apps render to canvas by
|
|
1189
|
+
* default, making DOM automation impossible. This creates a parallel ARIA
|
|
1190
|
+
* tree that Playwright can interact with.
|
|
1191
|
+
*/
|
|
1192
|
+
async _enableFlutterAccessibility() {
|
|
1193
|
+
const page = this._browserBackend.context?.currentTab()?.page;
|
|
1194
|
+
if (!page) return;
|
|
1195
|
+
const url = page.url();
|
|
1196
|
+
if (url.startsWith("chrome-extension://") || url.startsWith("about:") || url.startsWith("file://"))
|
|
1197
|
+
return;
|
|
1198
|
+
const alreadyEnabled = await page.evaluate(() => {
|
|
1199
|
+
const host = document.querySelector("flt-semantics-host");
|
|
1200
|
+
return host && host.children.length > 0;
|
|
1201
|
+
}).catch(() => false);
|
|
1202
|
+
if (alreadyEnabled) {
|
|
1203
|
+
traceDebug("Flutter accessibility already enabled");
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
let isFlutter = await page.evaluate(
|
|
1207
|
+
() => !!document.querySelector("flt-glass-pane, flutter-view, flt-semantics-placeholder")
|
|
1208
|
+
).catch(() => false);
|
|
1209
|
+
if (!isFlutter) {
|
|
1210
|
+
const hasCanvas = await page.evaluate(() => !!document.querySelector("canvas")).catch(() => false);
|
|
1211
|
+
if (hasCanvas) {
|
|
1212
|
+
isFlutter = await page.waitForFunction(
|
|
1213
|
+
() => !!document.querySelector("flt-glass-pane, flutter-view, flt-semantics-placeholder"),
|
|
1214
|
+
{ timeout: 5e3 }
|
|
1215
|
+
).then(() => true).catch(() => false);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (!isFlutter) return;
|
|
1219
|
+
const placeholder = page.locator("flt-semantics-placeholder").first();
|
|
1220
|
+
const clicked = await placeholder.click({ force: true }).then(() => true).catch(() => false);
|
|
1221
|
+
if (!clicked) {
|
|
1222
|
+
traceDebug("Flutter detected but placeholder click failed");
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const enabled = await page.waitForFunction(() => {
|
|
1226
|
+
const host = document.querySelector("flt-semantics-host");
|
|
1227
|
+
return host && host.children.length > 0;
|
|
1228
|
+
}, { timeout: 5e3 }).then(() => true).catch(() => false);
|
|
1229
|
+
if (enabled)
|
|
1230
|
+
traceDebug("Flutter accessibility mode enabled");
|
|
1231
|
+
else
|
|
1232
|
+
traceDebug("Flutter placeholder clicked but semantics host did not appear");
|
|
1233
|
+
}
|
|
812
1234
|
/** Clean up temp directory and optionally recreate it for next session. */
|
|
813
1235
|
_cleanupTempDir(recreate = false) {
|
|
814
1236
|
try {
|
|
@@ -838,6 +1260,51 @@ ${details}` }]
|
|
|
838
1260
|
this._cleanupTempDir(false);
|
|
839
1261
|
}
|
|
840
1262
|
}
|
|
1263
|
+
function pageEvaluateStripWrapperRoles(target) {
|
|
1264
|
+
const TOKEN = "__skyr_3728_stripped__";
|
|
1265
|
+
const w = window;
|
|
1266
|
+
if (w[TOKEN])
|
|
1267
|
+
return false;
|
|
1268
|
+
if (!target || !(target instanceof Element))
|
|
1269
|
+
return false;
|
|
1270
|
+
const labelSelector = 'input, textarea, select, label, [role="checkbox"], [role="radio"], [role="switch"], [role="textbox"], [role="combobox"]';
|
|
1271
|
+
let wrapper = null;
|
|
1272
|
+
for (let el = target; el; el = el.parentElement) {
|
|
1273
|
+
const role = el.getAttribute && el.getAttribute("role");
|
|
1274
|
+
if (role !== "button" && role !== "link")
|
|
1275
|
+
continue;
|
|
1276
|
+
if (el.children.length === 0)
|
|
1277
|
+
continue;
|
|
1278
|
+
if (!el.querySelector(labelSelector))
|
|
1279
|
+
continue;
|
|
1280
|
+
wrapper = el;
|
|
1281
|
+
break;
|
|
1282
|
+
}
|
|
1283
|
+
if (!wrapper)
|
|
1284
|
+
return false;
|
|
1285
|
+
const stripped = [];
|
|
1286
|
+
document.querySelectorAll('[role="button"], [role="link"]').forEach((el) => {
|
|
1287
|
+
if (el.children.length === 0)
|
|
1288
|
+
return;
|
|
1289
|
+
const role = el.getAttribute("role");
|
|
1290
|
+
stripped.push({ el, role });
|
|
1291
|
+
el.removeAttribute("role");
|
|
1292
|
+
});
|
|
1293
|
+
w[TOKEN] = stripped;
|
|
1294
|
+
return true;
|
|
1295
|
+
}
|
|
1296
|
+
function pageEvaluateRestoreWrapperRoles() {
|
|
1297
|
+
const TOKEN = "__skyr_3728_stripped__";
|
|
1298
|
+
const w = window;
|
|
1299
|
+
const stripped = w[TOKEN];
|
|
1300
|
+
if (!stripped)
|
|
1301
|
+
return;
|
|
1302
|
+
for (const { el, role } of stripped) {
|
|
1303
|
+
if (el.isConnected)
|
|
1304
|
+
el.setAttribute("role", role);
|
|
1305
|
+
}
|
|
1306
|
+
delete w[TOKEN];
|
|
1307
|
+
}
|
|
841
1308
|
function splitExtensionPaths(value) {
|
|
842
1309
|
if (!value)
|
|
843
1310
|
return void 0;
|