@skyramp/mcp 0.2.3 → 0.2.4

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.
@@ -40,11 +40,6 @@ export async function registerPlaywrightTools(server, options) {
40
40
  'browser_select_option',
41
41
  'browser_hover',
42
42
  'browser_drag',
43
- 'browser_mouse_move_xy',
44
- 'browser_mouse_click_xy',
45
- 'browser_mouse_drag_xy',
46
- 'browser_mouse_down',
47
- 'browser_mouse_up',
48
43
  'browser_file_upload',
49
44
  'browser_evaluate',
50
45
  'browser_tabs',
@@ -54,6 +49,8 @@ export async function registerPlaywrightTools(server, options) {
54
49
  'browser_assert',
55
50
  'browser_assert_api_request',
56
51
  'browser_assert_table_cell',
52
+ 'browser_mouse_action',
53
+ 'browser_visual_snapshot',
57
54
  'skyramp_export_zip',
58
55
  'skyramp_load_trace',
59
56
  'browser_mouse_action',
@@ -93,26 +90,7 @@ function jsonSchemaToZod(schema) {
93
90
  const required = new Set(schema.required || []);
94
91
  const shape = {};
95
92
  for (const [key, prop] of Object.entries(properties)) {
96
- let field;
97
- switch (prop.type) {
98
- case "string":
99
- field = prop.enum
100
- ? z.enum(prop.enum)
101
- : z.string();
102
- break;
103
- case "number":
104
- case "integer":
105
- field = z.number();
106
- break;
107
- case "boolean":
108
- field = z.boolean();
109
- break;
110
- case "array":
111
- field = z.array(prop.items ? jsonSchemaPropertyToZod(prop.items) : z.unknown());
112
- break;
113
- default:
114
- field = z.unknown();
115
- }
93
+ let field = jsonSchemaPropertyToZod(prop);
116
94
  if (prop.description)
117
95
  field = field.describe(prop.description);
118
96
  if (prop.default !== undefined)
@@ -136,6 +114,24 @@ function jsonSchemaPropertyToZod(prop) {
136
114
  return z.boolean();
137
115
  case "array":
138
116
  return z.array(prop.items ? jsonSchemaPropertyToZod(prop.items) : z.unknown());
117
+ case "object": {
118
+ // Nested object (e.g. a clip rect). Recurse into its properties so the
119
+ // SDK registers a real object schema; without this it falls through to
120
+ // z.unknown(), which serializes the nested object as a string and the
121
+ // inner backend's z.object(...) then rejects it.
122
+ const nestedProps = prop.properties || {};
123
+ const nestedRequired = new Set(prop.required || []);
124
+ const nestedShape = {};
125
+ for (const [k, p] of Object.entries(nestedProps)) {
126
+ let f = jsonSchemaPropertyToZod(p);
127
+ if (p.description)
128
+ f = f.describe(p.description);
129
+ if (!nestedRequired.has(k))
130
+ f = f.optional();
131
+ nestedShape[k] = f;
132
+ }
133
+ return z.object(nestedShape);
134
+ }
139
135
  default:
140
136
  return z.unknown();
141
137
  }
@@ -128,6 +128,7 @@ ${userPrompt ? "Generate only the tests that the user requested from the Additio
128
128
  - Example: If enrichment reveals that sending \`discount_value\` without \`discount_type\` silently orphans the value (a concrete bug), complete all planned GENERATE items first, then generate this discovered scenario as an extra test and report it in \`newTestsCreated\`.
129
129
  - Total generated: Follow the "Budget: N generate" line in the Execution Plan. Process every GENERATE-tagged item in order. Backfill from ADDITIONAL candidates (highest-ranked first) until \`newTestsCreated\` reaches ${maxGenerate} or all candidates are exhausted.
130
130
  - **UI test priority**: If the PR scope assessment shows any UI/E2E budget OR \`uiContext.changedFrontendFiles\` is non-empty (the deterministic server signal — populated for all supported frontend file types including \`.tsx\`/\`.jsx\`/\`.vue\`/\`.svelte\`/\`.dart\`), you MUST attempt to generate at least one UI test. Use \`browser_navigate\` to the app's base URL — if the app responds, record a trace and generate the test.
131
+ **Flutter web apps:** Skyramp's Playwright tools automatically enable Flutter's accessibility semantics tree on every \`browser_navigate\` call — you do NOT need to manually click \`flt-semantics-placeholder\` or add any activation step to the trace. Do NOT log an \`issuesFound\` entry about Flutter canvas rendering or accessibility activation — this is handled transparently.
131
132
  **Skip only if one of these conditions is met:**
132
133
  - **(a) App is unreachable** — \`browser_navigate\` fails or connection is refused.
133
134
  - **(b) Unintegrated non-route component** — the changed file is a leaf component (not a framework route/entrypoint) that has no integration point in the running app. To confirm:
@@ -0,0 +1,95 @@
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 visualSnapshot_exports = {};
20
+ __export(visualSnapshot_exports, {
21
+ buildVisualSnapshotJsonl: () => buildVisualSnapshotJsonl,
22
+ nextSnapshotFilename: () => nextSnapshotFilename,
23
+ parseSnapshotCounter: () => parseSnapshotCounter,
24
+ visualSnapshotToCode: () => visualSnapshotToCode
25
+ });
26
+ module.exports = __toCommonJS(visualSnapshot_exports);
27
+ const FILENAME_PREFIX = {
28
+ page: "page",
29
+ element: "el",
30
+ region: "region"
31
+ };
32
+ function nextSnapshotFilename(type, counter) {
33
+ return `${FILENAME_PREFIX[type]}-${String(counter).padStart(3, "0")}.png`;
34
+ }
35
+ function parseSnapshotCounter(type, filename) {
36
+ const m = new RegExp(`^${FILENAME_PREFIX[type]}-(\\d+)\\.png$`, "i").exec(filename);
37
+ return m ? parseInt(m[1], 10) : null;
38
+ }
39
+ function buildVisualSnapshotJsonl(input) {
40
+ if (!input.filename.toLowerCase().endsWith(".png"))
41
+ return { error: `visual snapshot filename must end in .png (got "${input.filename}").` };
42
+ switch (input.snapshotType) {
43
+ case "page": {
44
+ const action = { name: "visualSnapshot", snapshotType: "page", filename: input.filename };
45
+ if (input.fullPage !== void 0)
46
+ action.fullPage = input.fullPage;
47
+ if (input.screenshotStyle !== void 0)
48
+ action.screenshotStyle = input.screenshotStyle;
49
+ return { action };
50
+ }
51
+ case "element": {
52
+ if (!input.selector)
53
+ return { error: 'visual snapshot "element" requires a resolved selector (pass a ref to the tool).' };
54
+ return { action: { name: "visualSnapshot", snapshotType: "element", filename: input.filename, selector: input.selector } };
55
+ }
56
+ case "region": {
57
+ const c = input.clip;
58
+ if (!c || c.width === void 0 || c.height === void 0 || c.x === void 0 || c.y === void 0)
59
+ return { error: 'visual snapshot "region" requires a clip { x, y, width, height }.' };
60
+ return { action: { name: "visualSnapshot", snapshotType: "region", filename: input.filename, clip: { x: c.x, y: c.y, width: c.width, height: c.height } } };
61
+ }
62
+ default:
63
+ return { error: `Unknown visual snapshot type: ${input.snapshotType}` };
64
+ }
65
+ }
66
+ function visualSnapshotToCode(a, locatorExpr) {
67
+ const q = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
68
+ const file = q(a.filename);
69
+ switch (a.snapshotType) {
70
+ case "page": {
71
+ const opts = [];
72
+ if (a.fullPage)
73
+ opts.push("fullPage: true");
74
+ if (a.screenshotStyle)
75
+ opts.push(`style: '${q(a.screenshotStyle)}'`);
76
+ const optsStr = opts.length ? `, { ${opts.join(", ")} }` : "";
77
+ return `await expect(page).toHaveScreenshot('${file}'${optsStr});`;
78
+ }
79
+ case "element": {
80
+ const target = locatorExpr ? `page.${locatorExpr}` : `page.locator('${q(a.selector)}')`;
81
+ return `await expect(${target}).toHaveScreenshot('${file}');`;
82
+ }
83
+ case "region": {
84
+ const { x, y, width, height } = a.clip;
85
+ return `await expect(page).toHaveScreenshot('${file}', { clip: { x: ${x}, y: ${y}, width: ${width}, height: ${height} } });`;
86
+ }
87
+ }
88
+ }
89
+ // Annotate the CommonJS export names for ESM import in node:
90
+ 0 && (module.exports = {
91
+ buildVisualSnapshotJsonl,
92
+ nextSnapshotFilename,
93
+ parseSnapshotCounter,
94
+ visualSnapshotToCode
95
+ });
@@ -104,6 +104,8 @@ function describeStep(action, index) {
104
104
  detail += ` = ${JSON.stringify(a.value)}`;
105
105
  else if (a.name === "press" && a.key !== void 0)
106
106
  detail += ` ${a.key}`;
107
+ else if (a.name === "visualSnapshot")
108
+ detail = `${a.snapshotType ?? ""}${a.filename ? ` ${a.filename}` : ""}`.trim();
107
109
  return `#${index + 1} ${a.name}${onPage}${detail ? ` ${detail}` : ""}`;
108
110
  }
109
111
  function listStepsFrom(allActions, fromIndex) {
@@ -47,11 +47,13 @@ var import_assertApiRequestTool = require("./assertApiRequestTool");
47
47
  var import_loadTraceTool = require("./loadTraceTool");
48
48
  var import_skyRampImport = require("./skyRampImport");
49
49
  var import_mouseActionTool = require("./mouseActionTool");
50
+ var import_visualSnapshotTool = require("./visualSnapshotTool");
51
+ var import_visualSnapshot = require("./common/visualSnapshot");
50
52
  var import_utils = require("playwright-core/lib/utils");
51
53
  var import_types = require("./types");
52
54
  const traceDebug = (0, import_utilsBundle.debug)("pw:mcp:trace");
53
55
  class TraceRecordingBackend {
54
- // true while page.reload() is in progress — suppresses spurious popup tracking
56
+ // per-type baseline filename counter
55
57
  constructor(options) {
56
58
  this._trackedActions = [];
57
59
  this._initialized = false;
@@ -62,6 +64,8 @@ class TraceRecordingBackend {
62
64
  this._pendingPopupAlias = null;
63
65
  // popup alias to stamp on the NEXT tracked click
64
66
  this._reloading = false;
67
+ // true while page.reload() is in progress — suppresses spurious popup tracking
68
+ this._visualSnapshotCounters = { page: 0, element: 0, region: 0 };
65
69
  this._options = options || {};
66
70
  this._outputDir = options?.outputDir || process.cwd();
67
71
  this._tempDir = import_fs.default.mkdtempSync(import_path.default.join(import_os.default.tmpdir(), "skyramp-trace-"));
@@ -120,7 +124,7 @@ class TraceRecordingBackend {
120
124
  }
121
125
  async listTools() {
122
126
  const browserTools = await this._browserBackend.listTools();
123
- return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)(), (0, import_loadTraceTool.loadTraceMcpTool)(), (0, import_mouseActionTool.mouseActionMcpTool)()];
127
+ return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)(), (0, import_loadTraceTool.loadTraceMcpTool)(), (0, import_mouseActionTool.mouseActionMcpTool)(), (0, import_visualSnapshotTool.visualSnapshotMcpTool)()];
124
128
  }
125
129
  async callTool(name, args, progress) {
126
130
  if (!this._initialized)
@@ -151,6 +155,10 @@ class TraceRecordingBackend {
151
155
  const parsed = import_mouseActionTool.mouseActionSchema.inputSchema.parse(args || {});
152
156
  return this._handleMouseAction(parsed);
153
157
  }
158
+ if (name === import_visualSnapshotTool.visualSnapshotSchema.name) {
159
+ const parsed = import_visualSnapshotTool.visualSnapshotSchema.inputSchema.parse(args || {});
160
+ return this._handleVisualSnapshot(parsed);
161
+ }
154
162
  if (name === import_assertTool.assertToolSchema.name) {
155
163
  const parsed = import_assertTool.assertToolSchema.inputSchema.parse(args || {});
156
164
  return this._handleAssert(parsed);
@@ -352,6 +360,93 @@ Reloaded current page: ${currentUrl}
352
360
  traceDebug(`Tracked ${actions.length} mouse sub-action(s) for "${params.action}" on ${pageAlias}`);
353
361
  return result;
354
362
  }
363
+ /**
364
+ * Handle browser_visual_snapshot: record a `visualSnapshot` marker that
365
+ * exports to expect(...).toHaveScreenshot(filename), so the generated test
366
+ * pixel-compares against a baseline.
367
+ *
368
+ * This is marker-only by design: the baseline image is created/updated by
369
+ * Playwright on the first test run (into its snapshot dir), NOT captured here.
370
+ * Taking a live screenshot at record time would be throwaway, and browser_
371
+ * take_screenshot has no clip parameter, so a region screenshot could not even
372
+ * be honored — it would mislead by returning a full-viewport image. So we only
373
+ * emit the marker, mirroring browser_assert_api_request.
374
+ *
375
+ * For an element snapshot, the ref is still resolved to a durable selector via
376
+ * the same hover->selector path browser_assert uses (testid > role > text, with
377
+ * the snapshot-accessible-name fallback for brittle/Flutter ids); that also
378
+ * validates the ref exists. Iframe and GoJS-diagram snapshots from the recorder
379
+ * are out of scope here (see common/visualSnapshot.ts).
380
+ */
381
+ async _handleVisualSnapshot(params) {
382
+ const timestamp = Date.now();
383
+ const pageAlias = this._currentPageAlias;
384
+ const input = {
385
+ snapshotType: params.snapshotType,
386
+ filename: params.filename ?? this._nextSnapshotFilename(params.snapshotType),
387
+ fullPage: params.fullPage,
388
+ clip: params.clip
389
+ };
390
+ if (params.snapshotType === "element") {
391
+ if (!params.ref)
392
+ return { content: [{ type: "text", text: '### Error\nsnapshotType "element" requires a ref (from the latest browser_snapshot).' }], isError: true };
393
+ const resolved = await this._resolveRefToLocator(params.ref, params.element ?? "");
394
+ if (!resolved)
395
+ return { content: [{ type: "text", text: `### Error
396
+ Could not resolve a durable selector for ref=${params.ref}. Take a fresh browser_snapshot and retry, or use snapshotType "region".` }], isError: true };
397
+ input.selector = resolved.selector;
398
+ }
399
+ const built = (0, import_visualSnapshot.buildVisualSnapshotJsonl)(input);
400
+ if ("error" in built)
401
+ return { content: [{ type: "text", text: `### Error
402
+ ${built.error}` }], isError: true };
403
+ this._visualSnapshotCounters[params.snapshotType]++;
404
+ this._advanceSnapshotCounterFor(params.snapshotType, input.filename);
405
+ this._trackedActions.push({
406
+ toolName: "browser_visual_snapshot",
407
+ args: built.action,
408
+ code: "",
409
+ timestamp,
410
+ pageAlias
411
+ });
412
+ traceDebug(`Tracked visualSnapshot (${params.snapshotType}) "${input.filename}" on ${pageAlias}`);
413
+ const targetDesc = params.snapshotType === "element" ? ` (${input.selector})` : params.snapshotType === "region" && params.clip ? ` (clip ${params.clip.width}x${params.clip.height} at ${params.clip.x},${params.clip.y})` : params.fullPage ? " (full page)" : "";
414
+ return { content: [{ type: "text", text: `### Visual snapshot recorded
415
+ Baseline "${input.filename}" (${params.snapshotType})${targetDesc} recorded; the generated test will assert toHaveScreenshot against it. The baseline image is created on the first test run.` }] };
416
+ }
417
+ /** Next auto-generated baseline filename for a snapshot type (page-NNN.png, etc.). */
418
+ _nextSnapshotFilename(type) {
419
+ return (0, import_visualSnapshot.nextSnapshotFilename)(type, this._visualSnapshotCounters[type] + 1);
420
+ }
421
+ /**
422
+ * Resolve a snapshot ref to a durable Playwright selector, mirroring the
423
+ * element-resolution path of _handleAssert: hover to get the resolved code,
424
+ * parse it to a selector, and prefer a snapshot-accessible-name selector over
425
+ * a brittle raw-CSS id (the Flutter-durable fallback). Returns null if the ref
426
+ * can't be resolved.
427
+ */
428
+ async _resolveRefToLocator(ref, element) {
429
+ const hoverResult = await this._browserBackend.callTool("browser_hover", { element, ref });
430
+ if (hoverResult.isError)
431
+ return null;
432
+ const hoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? "";
433
+ const locatorMatch = hoverCode.match(/await\s+page\.(.*?)\.hover\(\)/s);
434
+ if (!locatorMatch)
435
+ return null;
436
+ const locatorExpr = locatorMatch[1].trim();
437
+ let parsed = this._codeToLocator(locatorExpr);
438
+ if (!parsed || parsed.locator.kind === "css") {
439
+ const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
440
+ if (!snapResult.isError) {
441
+ const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
442
+ const refLine = snapText.split("\n").find((l) => l.includes(`[ref=${ref}]`)) || "";
443
+ const fromSnapshot = this._extractLocatorForRef(refLine);
444
+ if (fromSnapshot)
445
+ parsed = fromSnapshot;
446
+ }
447
+ }
448
+ return parsed;
449
+ }
355
450
  /**
356
451
  * Load a prior Skyramp trace and replay it against the live browser, honoring
357
452
  * an optional stop point, then seed _trackedActions with the replayed actions
@@ -465,6 +560,8 @@ Continue recording with browser_* tools, then call skyramp_export_zip to write t
465
560
  const seeded = this._seedTrackedActionFields(a, locatorExpr);
466
561
  if (!seeded)
467
562
  return;
563
+ if (seeded.toolName === "browser_visual_snapshot")
564
+ this._advanceSnapshotCounterFor(seeded.args.snapshotType, seeded.args.filename);
468
565
  this._trackedActions.push({
469
566
  ...seeded,
470
567
  timestamp: action.startTime,
@@ -472,6 +569,19 @@ Continue recording with browser_* tools, then call skyramp_export_zip to write t
472
569
  framePath: action.frame.framePath?.length ? action.frame.framePath : void 0
473
570
  });
474
571
  }
572
+ /**
573
+ * Bump the per-type snapshot counter to at least the number embedded in a
574
+ * seeded baseline filename (`<prefix>-NNN.png`), so subsequently-recorded
575
+ * snapshots of that type don't reuse a loaded trace's filename. No-op if the
576
+ * filename doesn't carry a parseable counter.
577
+ */
578
+ _advanceSnapshotCounterFor(snapshotType, filename) {
579
+ if (!snapshotType || !filename || this._visualSnapshotCounters[snapshotType] === void 0)
580
+ return;
581
+ const n = (0, import_visualSnapshot.parseSnapshotCounter)(snapshotType, filename);
582
+ if (n !== null && n > this._visualSnapshotCounters[snapshotType])
583
+ this._visualSnapshotCounters[snapshotType] = n;
584
+ }
475
585
  /**
476
586
  * Build the { toolName, code, args } triple a seeded (replayed) action must
477
587
  * carry so it round-trips through skyRampExport.buildJsonlContent exactly as
@@ -566,6 +676,44 @@ Continue recording with browser_* tools, then call skyramp_export_zip to write t
566
676
  return { toolName: "browser_assert", code: `assertChecked:${a.selector}:${!!a.checked}`, args: { type: "checked", selector: a.selector, checked: !!a.checked } };
567
677
  case "assertVisible":
568
678
  return { toolName: "browser_assert", code: `assertVisible:${a.selector}`, args: { type: "visible", selector: a.selector } };
679
+ case "visualSnapshot": {
680
+ const args = this._seedVisualSnapshotArgs(a);
681
+ return args ? { toolName: "browser_visual_snapshot", code: "", args } : null;
682
+ }
683
+ default:
684
+ return null;
685
+ }
686
+ }
687
+ /**
688
+ * Build the normalized JSONL args for a re-seeded visualSnapshot action,
689
+ * following the VisualSnapshotJsonl contract in common/visualSnapshot.ts. Only
690
+ * known fields per snapshotType are emitted; a snapshot missing its required
691
+ * field (element->selector, region->clip) or with an unsupported type
692
+ * (gojsDiagram is recorder-only) is rejected (returns null) so the caller
693
+ * skips it rather than exporting an invalid shape.
694
+ */
695
+ _seedVisualSnapshotArgs(a) {
696
+ if (!a.filename || !String(a.filename).toLowerCase().endsWith(".png"))
697
+ return null;
698
+ switch (a.snapshotType) {
699
+ case "page": {
700
+ const args = { name: "visualSnapshot", snapshotType: "page", filename: a.filename };
701
+ if (a.fullPage !== void 0)
702
+ args.fullPage = a.fullPage;
703
+ if (a.screenshotStyle !== void 0)
704
+ args.screenshotStyle = a.screenshotStyle;
705
+ return args;
706
+ }
707
+ case "element":
708
+ if (!a.selector)
709
+ return null;
710
+ return { name: "visualSnapshot", snapshotType: "element", filename: a.filename, selector: a.selector };
711
+ case "region": {
712
+ const c = a.clip;
713
+ if (!c || c.x === void 0 || c.y === void 0 || c.width === void 0 || c.height === void 0)
714
+ return null;
715
+ return { name: "visualSnapshot", snapshotType: "region", filename: a.filename, clip: { x: c.x, y: c.y, width: c.width, height: c.height } };
716
+ }
569
717
  default:
570
718
  return null;
571
719
  }
@@ -0,0 +1,63 @@
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 visualSnapshotTool_exports = {};
20
+ __export(visualSnapshotTool_exports, {
21
+ visualSnapshotMcpTool: () => visualSnapshotMcpTool,
22
+ visualSnapshotSchema: () => visualSnapshotSchema
23
+ });
24
+ module.exports = __toCommonJS(visualSnapshotTool_exports);
25
+ var import_mcpBundle = require("playwright-core/lib/mcpBundle");
26
+ var import_tool = require("../sdk/tool");
27
+ const visualSnapshotSchema = {
28
+ name: "browser_visual_snapshot",
29
+ title: "Visual snapshot (screenshot baseline)",
30
+ description: [
31
+ "Record a visual-regression baseline: stores a toHaveScreenshot() assertion in the trace so the generated test pixel-compares the page/element/region against a baseline image on every run (the baseline is created on the test's first run).",
32
+ "Use this to lock the visual appearance of a page, an element, or a screen region after a key action (e.g. a rendered chart, a styled component, a confirmation screen).",
33
+ "The `snapshotType` parameter selects the target:",
34
+ "- page: the whole page (set fullPage to capture the full scrollable page rather than just the viewport).",
35
+ "- element: a single element, identified by its snapshot ref. The ref is resolved to a durable selector for the generated test.",
36
+ "- region: a rectangular area given by clip (x, y, width, height) in viewport pixels, read from a normal (non-fullPage) screenshot.",
37
+ "This complements browser_assert (which checks text/value/state): use browser_visual_snapshot when the thing to verify is how it LOOKS, not its text."
38
+ ].join(" "),
39
+ inputSchema: import_mcpBundle.z.object({
40
+ snapshotType: import_mcpBundle.z.enum(["page", "element", "region"]).describe("What to capture: whole page, a single element (by ref), or a pixel region (by clip)."),
41
+ ref: import_mcpBundle.z.string().optional().describe('Element snapshot ref to capture. Required for snapshotType "element".'),
42
+ element: import_mcpBundle.z.string().optional().describe("Human-readable description of the element (paired with ref) for permission and logging."),
43
+ fullPage: import_mcpBundle.z.boolean().optional().describe('For snapshotType "page": capture the full scrollable page instead of just the viewport.'),
44
+ clip: import_mcpBundle.z.object({
45
+ x: import_mcpBundle.z.number().describe("Left edge, in viewport pixels (distance from the visible top-left, not the document top)."),
46
+ y: import_mcpBundle.z.number().describe("Top edge, in viewport pixels (distance from the visible top-left, not the document top)."),
47
+ width: import_mcpBundle.z.number().positive().describe("Region width in pixels."),
48
+ height: import_mcpBundle.z.number().positive().describe("Region height in pixels.")
49
+ }).optional().describe('For snapshotType "region": the rectangle to capture, in VIEWPORT pixels. Read these coordinates from a normal (viewport) browser_take_screenshot, NOT a fullPage one \u2014 the region is clipped to the visible viewport, so document/scrolled coordinates will be off.'),
50
+ filename: import_mcpBundle.z.string().optional().describe("Baseline filename. Auto-generated (page-NNN.png / el-NNN.png / region-NNN.png) when omitted.")
51
+ }),
52
+ // Marker-only: records a trace marker, does not mutate the page. Mirrors the
53
+ // other marker tools (browser_assert, browser_assert_api_request).
54
+ type: "readOnly"
55
+ };
56
+ function visualSnapshotMcpTool() {
57
+ return (0, import_tool.toMcpTool)(visualSnapshotSchema);
58
+ }
59
+ // Annotate the CommonJS export names for ESM import in node:
60
+ 0 && (module.exports = {
61
+ visualSnapshotMcpTool,
62
+ visualSnapshotSchema
63
+ });
@@ -352,6 +352,32 @@ function assertActionToJsonl(action, pageGuid, timestamp) {
352
352
  return null;
353
353
  }
354
354
  }
355
+ function visualSnapshotActionToJsonl(action, pageGuid, timestamp) {
356
+ const args = action.args;
357
+ if (!args || !args.filename || !String(args.filename).toLowerCase().endsWith(".png"))
358
+ return null;
359
+ const base = {
360
+ signals: [],
361
+ timestamp: String(timestamp),
362
+ pageGuid,
363
+ pageAlias: action.pageAlias ?? DEFAULT_PAGE_ALIAS,
364
+ framePath: action.framePath ?? DEFAULT_FRAME_PATH
365
+ };
366
+ if (args.snapshotType === "page")
367
+ return JSON.stringify({ name: "visualSnapshot", snapshotType: "page", filename: args.filename, ...args.fullPage ? { fullPage: true } : {}, ...args.screenshotStyle ? { screenshotStyle: args.screenshotStyle } : {}, ...base });
368
+ if (args.snapshotType === "element") {
369
+ if (!args.selector)
370
+ return null;
371
+ return JSON.stringify({ name: "visualSnapshot", snapshotType: "element", filename: args.filename, selector: args.selector, ...base });
372
+ }
373
+ if (args.snapshotType === "region") {
374
+ const c = args.clip;
375
+ if (!c || c.x === void 0 || c.y === void 0 || c.width === void 0 || c.height === void 0)
376
+ return null;
377
+ return JSON.stringify({ name: "visualSnapshot", snapshotType: "region", filename: args.filename, clip: { x: c.x, y: c.y, width: c.width, height: c.height }, ...base });
378
+ }
379
+ return null;
380
+ }
355
381
  function selectorToLocator(selector) {
356
382
  const testidMatch = selector.match(/internal:testid=\[data-testid="([^"]+)"/);
357
383
  if (testidMatch)
@@ -518,6 +544,16 @@ function buildJsonlContent(actions, browserName, harPath) {
518
544
  actionCount++;
519
545
  continue;
520
546
  }
547
+ if (action.toolName === "browser_visual_snapshot") {
548
+ const vsLine = visualSnapshotActionToJsonl(action, pageGuid, action.timestamp);
549
+ if (vsLine) {
550
+ lines.push(vsLine);
551
+ actionCount++;
552
+ } else {
553
+ skipped.push(action.toolName);
554
+ }
555
+ continue;
556
+ }
521
557
  if ((action.toolName === "browser_type" || action.toolName === "browser_press_sequentially") && action.args.submit) {
522
558
  const fillLine = trackedActionToJsonl(action, pageGuid, action.timestamp);
523
559
  if (fillLine) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "main": "build/index.js",
5
5
  "exports": {
6
6
  ".": "./build/index.js",