@skyramp/mcp 0.2.1 → 0.2.3
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-recommendation/scopeAssessment.js +106 -5
- package/build/prompts/test-recommendation/scopeAssessment.test.js +128 -1
- package/build/prompts/testbot/testbot-prompts.js +2 -2
- package/build/prompts/testbot/testbot-prompts.test.js +21 -0
- package/build/tools/test-management/analyzeChangesTool.js +8 -2
- 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/uiPageEnumerator.js +67 -0
- package/build/utils/uiPageEnumerator.test.js +222 -0
- package/node_modules/playwright/lib/mcp/skyramp/common/mouseActions.js +123 -0
- package/node_modules/playwright/lib/mcp/skyramp/index.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +359 -0
- package/node_modules/playwright/lib/mcp/skyramp/mouseActionTool.js +131 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +146 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +313 -5
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +32 -14
- package/package.json +1 -1
|
@@ -44,6 +44,10 @@ var import_skyRampExport = require("../test/skyRampExport");
|
|
|
44
44
|
var import_exportTool = require("./exportTool");
|
|
45
45
|
var import_assertTool = require("./assertTool");
|
|
46
46
|
var import_assertApiRequestTool = require("./assertApiRequestTool");
|
|
47
|
+
var import_loadTraceTool = require("./loadTraceTool");
|
|
48
|
+
var import_skyRampImport = require("./skyRampImport");
|
|
49
|
+
var import_mouseActionTool = require("./mouseActionTool");
|
|
50
|
+
var import_utils = require("playwright-core/lib/utils");
|
|
47
51
|
var import_types = require("./types");
|
|
48
52
|
const traceDebug = (0, import_utilsBundle.debug)("pw:mcp:trace");
|
|
49
53
|
class TraceRecordingBackend {
|
|
@@ -116,7 +120,7 @@ class TraceRecordingBackend {
|
|
|
116
120
|
}
|
|
117
121
|
async listTools() {
|
|
118
122
|
const browserTools = await this._browserBackend.listTools();
|
|
119
|
-
return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)()];
|
|
123
|
+
return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)(), (0, import_loadTraceTool.loadTraceMcpTool)(), (0, import_mouseActionTool.mouseActionMcpTool)()];
|
|
120
124
|
}
|
|
121
125
|
async callTool(name, args, progress) {
|
|
122
126
|
if (!this._initialized)
|
|
@@ -139,6 +143,14 @@ class TraceRecordingBackend {
|
|
|
139
143
|
}
|
|
140
144
|
return exportResult;
|
|
141
145
|
}
|
|
146
|
+
if (name === import_loadTraceTool.loadTraceSchema.name) {
|
|
147
|
+
const parsed = import_loadTraceTool.loadTraceSchema.inputSchema.parse(args || {});
|
|
148
|
+
return this._handleLoadTrace(parsed);
|
|
149
|
+
}
|
|
150
|
+
if (name === import_mouseActionTool.mouseActionSchema.name) {
|
|
151
|
+
const parsed = import_mouseActionTool.mouseActionSchema.inputSchema.parse(args || {});
|
|
152
|
+
return this._handleMouseAction(parsed);
|
|
153
|
+
}
|
|
142
154
|
if (name === import_assertTool.assertToolSchema.name) {
|
|
143
155
|
const parsed = import_assertTool.assertToolSchema.inputSchema.parse(args || {});
|
|
144
156
|
return this._handleAssert(parsed);
|
|
@@ -313,6 +325,280 @@ Reloaded current page: ${currentUrl}
|
|
|
313
325
|
serverClosed() {
|
|
314
326
|
void this._autoExportAndClose().catch(import_log.logUnhandledError);
|
|
315
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Execute a coordinate-based mouse action and track each decomposed sub-action
|
|
330
|
+
* (mouse.move/down/up/wheel or a position click) into _trackedActions so it is
|
|
331
|
+
* exported into the combined JSONL trace. The JSONL field shapes come straight
|
|
332
|
+
* from common/mouseActions, which is the single source of truth shared with
|
|
333
|
+
* the export and seeding paths.
|
|
334
|
+
*/
|
|
335
|
+
async _handleMouseAction(params) {
|
|
336
|
+
const page = this._browserBackend.context?.currentTab()?.page;
|
|
337
|
+
const timestamp = Date.now();
|
|
338
|
+
const pageAlias = this._currentPageAlias;
|
|
339
|
+
const { result, actions } = await (0, import_mouseActionTool.executeMouseAction)(page, params);
|
|
340
|
+
if (result.isError)
|
|
341
|
+
return result;
|
|
342
|
+
actions.forEach((a, i) => {
|
|
343
|
+
this._trackedActions.push({
|
|
344
|
+
toolName: "browser_mouse_action",
|
|
345
|
+
args: a,
|
|
346
|
+
code: "",
|
|
347
|
+
// Stagger timestamps so a decomposed drag keeps its move<down<move<up order.
|
|
348
|
+
timestamp: timestamp + i,
|
|
349
|
+
pageAlias
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
traceDebug(`Tracked ${actions.length} mouse sub-action(s) for "${params.action}" on ${pageAlias}`);
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Load a prior Skyramp trace and replay it against the live browser, honoring
|
|
357
|
+
* an optional stop point, then seed _trackedActions with the replayed actions
|
|
358
|
+
* so the model can continue recording and export a combined trace.
|
|
359
|
+
*/
|
|
360
|
+
async _handleLoadTrace(params) {
|
|
361
|
+
const stopParams = ["stopAtStep", "stopAtUrl", "stopBefore"].filter((k) => {
|
|
362
|
+
const v = params[k];
|
|
363
|
+
return v !== void 0 && v !== "";
|
|
364
|
+
});
|
|
365
|
+
if (stopParams.length > 1)
|
|
366
|
+
return { content: [{ type: "text", text: `### Error
|
|
367
|
+
Pass at most ONE of stopAtStep / stopAtUrl / stopBefore (got ${stopParams.join(", ")}). Omit all three to replay the whole trace.` }], isError: true };
|
|
368
|
+
let loadedActions;
|
|
369
|
+
try {
|
|
370
|
+
loadedActions = await (0, import_skyRampImport.actionsFromPath)(params.path);
|
|
371
|
+
} catch (e) {
|
|
372
|
+
return { content: [{ type: "text", text: `### Error
|
|
373
|
+
Failed to load trace from ${params.path}: ${e.message}` }], isError: true };
|
|
374
|
+
}
|
|
375
|
+
if (!loadedActions.length)
|
|
376
|
+
return { content: [{ type: "text", text: `### Error
|
|
377
|
+
No actions found in ${params.path}.` }], isError: true };
|
|
378
|
+
if (params.dryRun) {
|
|
379
|
+
const stepList = loadedActions.map((a, i) => (0, import_loadTraceTool.describeStep)(a, i)).join("\n");
|
|
380
|
+
return {
|
|
381
|
+
content: [{
|
|
382
|
+
type: "text",
|
|
383
|
+
text: `### Trace loaded (dry run)
|
|
384
|
+
${loadedActions.length} steps in ${params.path}. Nothing was replayed.
|
|
385
|
+
|
|
386
|
+
Steps:
|
|
387
|
+
${stepList}
|
|
388
|
+
|
|
389
|
+
Call skyramp_load_trace again with stopAtStep / stopAtUrl / stopBefore (or none of them to replay all) to replay.`
|
|
390
|
+
}]
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
await this._browserBackend.context.ensureTab();
|
|
395
|
+
} catch {
|
|
396
|
+
}
|
|
397
|
+
let result;
|
|
398
|
+
try {
|
|
399
|
+
result = await (0, import_loadTraceTool.replayActions)(loadedActions, { pageForAlias: (alias) => this._pageForAlias(alias) }, params);
|
|
400
|
+
} catch (e) {
|
|
401
|
+
return { content: [{ type: "text", text: `### Error
|
|
402
|
+
Replay failed: ${e.message}` }], isError: true };
|
|
403
|
+
}
|
|
404
|
+
for (let i = 0; i < result.stopIndex; i++)
|
|
405
|
+
this._seedTrackedAction(loadedActions[i]);
|
|
406
|
+
if (result.stopIndex > 0)
|
|
407
|
+
this._currentPageAlias = loadedActions[result.stopIndex - 1].frame.pageAlias || "page";
|
|
408
|
+
const remaining = (0, import_loadTraceTool.listStepsFrom)(loadedActions, result.stopIndex);
|
|
409
|
+
const stoppedNote = (0, import_loadTraceTool.describeStopReason)(result);
|
|
410
|
+
const errorNote = result.error ? `
|
|
411
|
+
|
|
412
|
+
\u26A0\uFE0F Replay stopped at step ${result.error.stepIndex} (${result.error.actionName}): ${result.error.message}
|
|
413
|
+
You can continue recording from this point or fix the issue.` : "";
|
|
414
|
+
return {
|
|
415
|
+
content: [{
|
|
416
|
+
type: "text",
|
|
417
|
+
text: `### Trace replayed
|
|
418
|
+
${result.completedCount} of ${loadedActions.length} steps replayed from ${params.path}. ${stoppedNote}${errorNote}
|
|
419
|
+
|
|
420
|
+
Remaining steps (NOT replayed):
|
|
421
|
+
${remaining}
|
|
422
|
+
|
|
423
|
+
Continue recording with browser_* tools, then call skyramp_export_zip to write the combined trace.`
|
|
424
|
+
}],
|
|
425
|
+
isError: result.stopReason === "error"
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Resolve a JSONL page alias to a live page. Numeric aliases ('page',
|
|
430
|
+
* 'pageN') map by tab index; semantic aliases ('popupPage') match by the
|
|
431
|
+
* popup URL pattern, falling back to the most recently opened tab.
|
|
432
|
+
*/
|
|
433
|
+
_pageForAlias(alias) {
|
|
434
|
+
const tabs = this._browserBackend.context?.tabs() ?? [];
|
|
435
|
+
if (!tabs.length)
|
|
436
|
+
return void 0;
|
|
437
|
+
const numeric = alias === "page" ? 0 : /^page(\d+)$/.exec(alias)?.[1];
|
|
438
|
+
if (numeric !== void 0 && numeric !== null) {
|
|
439
|
+
const idx = Number(numeric);
|
|
440
|
+
return (tabs[idx] ?? tabs[tabs.length - 1]).page;
|
|
441
|
+
}
|
|
442
|
+
const popupTab = tabs.find((t) => /chrome-extension:\/\/[a-p]{32}\/popup\//.test(t.page.url()));
|
|
443
|
+
return (popupTab ?? tabs[tabs.length - 1]).page;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Convert a replayed ActionInContext back into a TrackedAction so it merges
|
|
447
|
+
* with freshly-recorded actions. The export pipeline (buildJsonlContent)
|
|
448
|
+
* re-derives the JSONL selector from the `code` field, so we regenerate clean
|
|
449
|
+
* Playwright code from the stored internal: selector via asLocator.
|
|
450
|
+
*/
|
|
451
|
+
_seedTrackedAction(action) {
|
|
452
|
+
const a = action.action;
|
|
453
|
+
const pageAlias = action.frame.pageAlias || "page";
|
|
454
|
+
if (a.name === "navigate") {
|
|
455
|
+
this._trackedActions.push({
|
|
456
|
+
toolName: "browser_navigate",
|
|
457
|
+
args: { url: a.url },
|
|
458
|
+
code: "",
|
|
459
|
+
timestamp: action.startTime,
|
|
460
|
+
pageAlias
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const locatorExpr = a.selector ? (0, import_utils.asLocator)("javascript", a.selector) : "";
|
|
465
|
+
const seeded = this._seedTrackedActionFields(a, locatorExpr);
|
|
466
|
+
if (!seeded)
|
|
467
|
+
return;
|
|
468
|
+
this._trackedActions.push({
|
|
469
|
+
...seeded,
|
|
470
|
+
timestamp: action.startTime,
|
|
471
|
+
pageAlias,
|
|
472
|
+
framePath: action.frame.framePath?.length ? action.frame.framePath : void 0
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Build the { toolName, code, args } triple a seeded (replayed) action must
|
|
477
|
+
* carry so it round-trips through skyRampExport.buildJsonlContent exactly as
|
|
478
|
+
* a freshly-recorded action would. toolName, code and args are emitted
|
|
479
|
+
* together (not via separate mappers) because export keys on all three:
|
|
480
|
+
* trackedActionToJsonl dispatches on toolName, derives the locator from code,
|
|
481
|
+
* and reads specific args (checked, values, type). A mismatch silently
|
|
482
|
+
* mis-exports (e.g. hover as click) or drops the action.
|
|
483
|
+
*
|
|
484
|
+
* Code uses single quotes to match the live recorder's format
|
|
485
|
+
* (extractFillText / extractSelectOptions only match single-quoted calls).
|
|
486
|
+
* Returns null for unsupported actions so they are skipped, never defaulted.
|
|
487
|
+
*/
|
|
488
|
+
_seedTrackedActionFields(a, locatorExpr) {
|
|
489
|
+
const sq = (s) => `'${String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}'`;
|
|
490
|
+
if (a.name === "mouse.move" || a.name === "mouse.down" || a.name === "mouse.up" || a.name === "mouse.wheel") {
|
|
491
|
+
const args = this._seedMouseActionArgs(a);
|
|
492
|
+
if (!args)
|
|
493
|
+
return null;
|
|
494
|
+
return { toolName: "browser_mouse_action", code: "", args };
|
|
495
|
+
}
|
|
496
|
+
if (a.name === "click" && a.position && (!a.selector || a.selector === "body"))
|
|
497
|
+
return { toolName: "browser_mouse_action", code: "", args: { name: "click", selector: "body", position: a.position, button: a.button ?? "left", modifiers: a.modifiers ?? 0, clickCount: a.clickCount ?? 1 } };
|
|
498
|
+
const SELECTOR_REQUIRED = /* @__PURE__ */ new Set(["click", "hover", "fill", "pressSequentially", "check", "uncheck", "select"]);
|
|
499
|
+
if (SELECTOR_REQUIRED.has(a.name) && !locatorExpr)
|
|
500
|
+
return null;
|
|
501
|
+
const root = a.frameUrlMatch ? `page.frames().find(f => f.url().includes(${sq(a.frameUrlMatch)}))!` : "page";
|
|
502
|
+
switch (a.name) {
|
|
503
|
+
case "click": {
|
|
504
|
+
const isDouble = (a.clickCount ?? 1) > 1;
|
|
505
|
+
const verb = isDouble ? "dblclick" : "click";
|
|
506
|
+
const args = {};
|
|
507
|
+
if (a.button && a.button !== "left")
|
|
508
|
+
args.button = a.button;
|
|
509
|
+
const mods = (0, import_loadTraceTool.decodeModifiers)(a.modifiers);
|
|
510
|
+
if (mods.length)
|
|
511
|
+
args.modifiers = mods;
|
|
512
|
+
return { toolName: "browser_click", code: `await ${root}.${locatorExpr}.${verb}();`, args };
|
|
513
|
+
}
|
|
514
|
+
case "hover":
|
|
515
|
+
return { toolName: "browser_hover", code: `await ${root}.${locatorExpr}.hover();`, args: {} };
|
|
516
|
+
case "fill":
|
|
517
|
+
return { toolName: "browser_type", code: `await ${root}.${locatorExpr}.fill(${sq(a.text ?? "")});`, args: { text: a.text ?? "" } };
|
|
518
|
+
case "pressSequentially":
|
|
519
|
+
return { toolName: "browser_type", code: `await ${root}.${locatorExpr}.pressSequentially(${sq(a.text ?? "")});`, args: { text: a.text ?? "", slowly: true } };
|
|
520
|
+
case "press": {
|
|
521
|
+
const args = { key: a.key };
|
|
522
|
+
const mods = (0, import_loadTraceTool.decodeModifiers)(a.modifiers);
|
|
523
|
+
if (mods.length)
|
|
524
|
+
args.modifiers = mods;
|
|
525
|
+
return { toolName: "browser_press_key", code: "", args };
|
|
526
|
+
}
|
|
527
|
+
case "assertApiRequest":
|
|
528
|
+
return { toolName: "browser_assert_api_request", code: "", args: {} };
|
|
529
|
+
case "waitForTimeout":
|
|
530
|
+
return { toolName: "browser_wait_for", code: "", args: { time: (a.duration ?? 0) / 1e3 } };
|
|
531
|
+
case "check":
|
|
532
|
+
return { toolName: "browser_set_checked", code: `await ${root}.${locatorExpr}.check();`, args: { checked: true } };
|
|
533
|
+
case "uncheck":
|
|
534
|
+
return { toolName: "browser_set_checked", code: `await ${root}.${locatorExpr}.uncheck();`, args: { checked: false } };
|
|
535
|
+
case "select":
|
|
536
|
+
return { toolName: "browser_select_option", code: `await ${root}.${locatorExpr}.selectOption(${sq((a.options ?? [])[0] ?? "")});`, args: { values: a.options ?? [] } };
|
|
537
|
+
case "waitForSelector": {
|
|
538
|
+
const sel = a.selector ?? "";
|
|
539
|
+
const m = /^text=(.*)$/.exec(sel);
|
|
540
|
+
if (!m)
|
|
541
|
+
return null;
|
|
542
|
+
return a.state === "hidden" ? { toolName: "browser_wait_for", code: "", args: { textGone: m[1] } } : { toolName: "browser_wait_for", code: "", args: { text: m[1] } };
|
|
543
|
+
}
|
|
544
|
+
case "dragAndDrop":
|
|
545
|
+
case "dragTo": {
|
|
546
|
+
const srcSel = a.source ?? a.selector;
|
|
547
|
+
const tgtSel = a.target ?? a.targetSelector;
|
|
548
|
+
if (!srcSel || !tgtSel)
|
|
549
|
+
return null;
|
|
550
|
+
const srcExpr = (0, import_utils.asLocator)("javascript", srcSel);
|
|
551
|
+
const tgtExpr = (0, import_utils.asLocator)("javascript", tgtSel);
|
|
552
|
+
return { toolName: "browser_drag", code: `await page.${srcExpr}.dragTo(page.${tgtExpr});`, args: {} };
|
|
553
|
+
}
|
|
554
|
+
// Assertions are encoded the same way _handleAssert encodes them, so
|
|
555
|
+
// assertActionToJsonl in skyRampExport parses them identically. That
|
|
556
|
+
// parser dispatches on toolName === 'browser_assert' and keys on args.type.
|
|
557
|
+
// Assertions pass selector/expected as structured args (unambiguous even
|
|
558
|
+
// when the value contains ':'). assertActionToJsonl reads these directly;
|
|
559
|
+
// the legacy colon-encoded code string is kept only as a human-readable
|
|
560
|
+
// breadcrumb / backward-compat fallback.
|
|
561
|
+
case "assertText":
|
|
562
|
+
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 } };
|
|
563
|
+
case "assertValue":
|
|
564
|
+
return { toolName: "browser_assert", code: `assertValue:${a.selector}:${a.value}`, args: { type: "value", selector: a.selector, expected: a.value } };
|
|
565
|
+
case "assertChecked":
|
|
566
|
+
return { toolName: "browser_assert", code: `assertChecked:${a.selector}:${!!a.checked}`, args: { type: "checked", selector: a.selector, checked: !!a.checked } };
|
|
567
|
+
case "assertVisible":
|
|
568
|
+
return { toolName: "browser_assert", code: `assertVisible:${a.selector}`, args: { type: "visible", selector: a.selector } };
|
|
569
|
+
default:
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Build the normalized JSONL args for a re-seeded coordinate mouse action
|
|
575
|
+
* (mouse.move/down/up/wheel), following the MouseJsonlAction contract in
|
|
576
|
+
* common/mouseActions.ts. Only known fields are emitted (so stray fields from
|
|
577
|
+
* the loaded trace line are not forwarded), wheel deltas/modifiers default to
|
|
578
|
+
* 0, and a move/wheel missing its position is rejected (returns null) so the
|
|
579
|
+
* caller skips it rather than exporting an invalid shape for the Go consumer.
|
|
580
|
+
*/
|
|
581
|
+
_seedMouseActionArgs(a) {
|
|
582
|
+
switch (a.name) {
|
|
583
|
+
case "mouse.move": {
|
|
584
|
+
if (!a.position)
|
|
585
|
+
return null;
|
|
586
|
+
const args = { name: "mouse.move", position: a.position };
|
|
587
|
+
if (a.steps !== void 0)
|
|
588
|
+
args.steps = a.steps;
|
|
589
|
+
return args;
|
|
590
|
+
}
|
|
591
|
+
case "mouse.down":
|
|
592
|
+
case "mouse.up":
|
|
593
|
+
return a.button && a.button !== "left" ? { name: a.name, button: a.button } : { name: a.name };
|
|
594
|
+
case "mouse.wheel":
|
|
595
|
+
if (!a.position)
|
|
596
|
+
return null;
|
|
597
|
+
return { name: "mouse.wheel", position: a.position, deltaX: a.deltaX ?? 0, deltaY: a.deltaY ?? 0, modifiers: a.modifiers ?? 0 };
|
|
598
|
+
default:
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
316
602
|
/**
|
|
317
603
|
* Handle browser_select_option with automatic fallback for custom dropdowns.
|
|
318
604
|
* 1. Try native selectOption first.
|
|
@@ -437,14 +723,26 @@ Could not extract selector from hover result.` }], isError: true };
|
|
|
437
723
|
}
|
|
438
724
|
const locatorExpr = locatorMatch[1].trim();
|
|
439
725
|
let parsed = this._codeToLocator(locatorExpr);
|
|
440
|
-
|
|
726
|
+
const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
|
|
727
|
+
if (snapResult.isError) {
|
|
728
|
+
const errText = snapResult.content?.[0]?.type === "text" ? snapResult.content[0].text : "";
|
|
441
729
|
return { content: [{ type: "text", text: `### Assertion Failed
|
|
442
|
-
Could not
|
|
730
|
+
Could not capture page snapshot to verify the assertion. ${errText}` }], isError: true };
|
|
443
731
|
}
|
|
444
|
-
const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
|
|
445
732
|
const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
|
|
446
733
|
const snapLines = snapText.split("\n");
|
|
447
734
|
const refLine = snapLines.find((l) => l.includes(`[ref=${params.ref}]`)) || "";
|
|
735
|
+
if (!parsed || parsed.locator.kind === "css") {
|
|
736
|
+
const fromSnapshot = this._extractLocatorForRef(refLine);
|
|
737
|
+
if (fromSnapshot) {
|
|
738
|
+
traceDebug(`Resolved assert selector from snapshot accessible name (hover code was ${parsed ? "brittle CSS" : "unparseable"}: ${locatorExpr})`);
|
|
739
|
+
parsed = fromSnapshot;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (!parsed) {
|
|
743
|
+
return { content: [{ type: "text", text: `### Assertion Failed
|
|
744
|
+
Could not parse locator: ${locatorExpr}` }], isError: true };
|
|
745
|
+
}
|
|
448
746
|
const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
|
|
449
747
|
const elementText = textMatch?.[1] || "";
|
|
450
748
|
const valueMatch = refLine.match(/\]:\s*(.+)$/);
|
|
@@ -473,7 +771,10 @@ Could not parse locator: ${locatorExpr}` }], isError: true };
|
|
|
473
771
|
const assertName = params.type === "value" ? "assertValue" : "assertText";
|
|
474
772
|
this._trackedActions.push({
|
|
475
773
|
toolName: "browser_assert",
|
|
476
|
-
|
|
774
|
+
// Pass selector/expected as structured args so assertActionToJsonl can
|
|
775
|
+
// decode unambiguously (the colon-encoded code below corrupts values
|
|
776
|
+
// that contain ':', e.g. URLs). The code string is a fallback only.
|
|
777
|
+
args: { type: params.type, ref: params.ref, selector: parsed.selector, expected: params.expected, ...params.type === "text" ? { substring: params.substring } : {} },
|
|
477
778
|
code: `${assertName}:${parsed.selector}:${params.expected}${params.type === "text" ? ":" + params.substring : ""}`,
|
|
478
779
|
timestamp
|
|
479
780
|
});
|
|
@@ -519,6 +820,13 @@ ${details}` }]
|
|
|
519
820
|
if (ariaRefMatch) {
|
|
520
821
|
return null;
|
|
521
822
|
}
|
|
823
|
+
const cssMatch = expr.match(/locator\(\s*['"]([#.][^'"]+)['"]\s*\)/);
|
|
824
|
+
if (cssMatch) {
|
|
825
|
+
return {
|
|
826
|
+
selector: cssMatch[1],
|
|
827
|
+
locator: { kind: "css", body: cssMatch[1], options: {} }
|
|
828
|
+
};
|
|
829
|
+
}
|
|
522
830
|
return null;
|
|
523
831
|
}
|
|
524
832
|
/**
|
|
@@ -226,7 +226,12 @@ function trackedActionToJsonl(action, pageGuid, timestamp) {
|
|
|
226
226
|
return null;
|
|
227
227
|
}
|
|
228
228
|
if (toolName === "browser_press_key")
|
|
229
|
-
return JSON.stringify({ name: "press", key: args.key, selector: "", ...base });
|
|
229
|
+
return JSON.stringify({ name: "press", key: args.key, modifiers: modifiersArrayToMask(args.modifiers), selector: "", ...base });
|
|
230
|
+
if (toolName === "browser_mouse_action") {
|
|
231
|
+
if (!args.name)
|
|
232
|
+
return null;
|
|
233
|
+
return JSON.stringify({ ...args, ...base });
|
|
234
|
+
}
|
|
230
235
|
if (!code)
|
|
231
236
|
return null;
|
|
232
237
|
const extracted = extractLocatorFromCode(code);
|
|
@@ -304,23 +309,34 @@ function assertActionToJsonl(action, pageGuid, timestamp) {
|
|
|
304
309
|
signals: [],
|
|
305
310
|
timestamp: String(timestamp),
|
|
306
311
|
pageGuid,
|
|
307
|
-
pageAlias: DEFAULT_PAGE_ALIAS,
|
|
308
|
-
framePath: DEFAULT_FRAME_PATH
|
|
312
|
+
pageAlias: action.pageAlias ?? DEFAULT_PAGE_ALIAS,
|
|
313
|
+
framePath: action.framePath ?? DEFAULT_FRAME_PATH
|
|
309
314
|
};
|
|
310
|
-
const parts = code.split(":");
|
|
311
|
-
const selectorPart = parts[1] || "";
|
|
312
315
|
let selector = "";
|
|
313
316
|
let expected = "";
|
|
314
317
|
let substring = true;
|
|
315
|
-
if (
|
|
316
|
-
selector =
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
318
|
+
if (args.selector !== void 0) {
|
|
319
|
+
selector = args.selector;
|
|
320
|
+
if (assertType === "text") {
|
|
321
|
+
expected = args.expected ?? "";
|
|
322
|
+
substring = args.substring !== false;
|
|
323
|
+
} else if (assertType === "value") {
|
|
324
|
+
expected = args.expected ?? "";
|
|
325
|
+
} else if (assertType === "checked") {
|
|
326
|
+
expected = String(args.checked === true || args.expected === "true" || args.expected === true);
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
const parts = code.split(":");
|
|
330
|
+
if (assertType === "visible") {
|
|
331
|
+
selector = parts.slice(1).join(":");
|
|
332
|
+
} else if (assertType === "text") {
|
|
333
|
+
substring = parts[parts.length - 1] === "true";
|
|
334
|
+
expected = parts[parts.length - 2] || "";
|
|
335
|
+
selector = parts.slice(1, parts.length - 2).join(":");
|
|
336
|
+
} else if (assertType === "value" || assertType === "checked") {
|
|
337
|
+
expected = parts[parts.length - 1] || "";
|
|
338
|
+
selector = parts.slice(1, parts.length - 1).join(":");
|
|
339
|
+
}
|
|
324
340
|
}
|
|
325
341
|
const locator = selectorToLocator(selector);
|
|
326
342
|
switch (assertType) {
|
|
@@ -330,6 +346,8 @@ function assertActionToJsonl(action, pageGuid, timestamp) {
|
|
|
330
346
|
return JSON.stringify({ name: "assertVisible", selector, locator, ...base });
|
|
331
347
|
case "value":
|
|
332
348
|
return JSON.stringify({ name: "assertValue", selector, value: expected, locator, ...base });
|
|
349
|
+
case "checked":
|
|
350
|
+
return JSON.stringify({ name: "assertChecked", selector, checked: expected === "true", locator, ...base });
|
|
333
351
|
default:
|
|
334
352
|
return null;
|
|
335
353
|
}
|