@skyramp/mcp 0.2.0 → 0.2.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.
@@ -45,6 +45,7 @@ export async function registerPlaywrightTools(server, options) {
45
45
  'browser_wait_for',
46
46
  'browser_take_screenshot',
47
47
  'browser_assert',
48
+ 'browser_assert_api_request',
48
49
  'skyramp_export_zip',
49
50
  // DOM Analyzer tools (Phase C)
50
51
  'browser_blueprint',
@@ -54,7 +54,7 @@ describe("dockerImageExistsLocally", () => {
54
54
  });
55
55
  });
56
56
  describe("pullDockerImage", () => {
57
- const IMAGE = "skyramp/executor:v1.3.25";
57
+ const IMAGE = "skyramp/executor:v1.3.26";
58
58
  beforeEach(() => jest.clearAllMocks());
59
59
  describe("on amd64 host", () => {
60
60
  const originalArch = process.arch;
@@ -1,3 +1,3 @@
1
- export const SKYRAMP_IMAGE_VERSION = "v1.3.25";
1
+ export const SKYRAMP_IMAGE_VERSION = "v1.3.26";
2
2
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
3
3
  export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;
@@ -0,0 +1,46 @@
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 assertApiRequestTool_exports = {};
20
+ __export(assertApiRequestTool_exports, {
21
+ assertApiRequestMcpTool: () => assertApiRequestMcpTool,
22
+ assertApiRequestSchema: () => assertApiRequestSchema
23
+ });
24
+ module.exports = __toCommonJS(assertApiRequestTool_exports);
25
+ var import_mcpBundle = require("playwright-core/lib/mcpBundle");
26
+ var import_tool = require("../sdk/tool");
27
+ const assertApiRequestSchema = {
28
+ name: "browser_assert_api_request",
29
+ title: "Assert API request payload",
30
+ description: [
31
+ "Mark the NEXT user action for API request payload assertion.",
32
+ "Call this tool IMMEDIATELY BEFORE the click or action that triggers an API request you want to validate.",
33
+ "The generated test will capture the request payload and assert it matches on replay.",
34
+ 'Example: call this tool, then call browser_click on a "Save" button that sends a POST request.'
35
+ ].join(" "),
36
+ inputSchema: import_mcpBundle.z.object({}),
37
+ type: "readOnly"
38
+ };
39
+ function assertApiRequestMcpTool() {
40
+ return (0, import_tool.toMcpTool)(assertApiRequestSchema);
41
+ }
42
+ // Annotate the CommonJS export names for ESM import in node:
43
+ 0 && (module.exports = {
44
+ assertApiRequestMcpTool,
45
+ assertApiRequestSchema
46
+ });
@@ -43,6 +43,7 @@ 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");
46
47
  var import_types = require("./types");
47
48
  const traceDebug = (0, import_utilsBundle.debug)("pw:mcp:trace");
48
49
  class TraceRecordingBackend {
@@ -115,7 +116,7 @@ class TraceRecordingBackend {
115
116
  }
116
117
  async listTools() {
117
118
  const browserTools = await this._browserBackend.listTools();
118
- return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)()];
119
+ return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)()];
119
120
  }
120
121
  async callTool(name, args, progress) {
121
122
  if (!this._initialized)
@@ -142,6 +143,19 @@ class TraceRecordingBackend {
142
143
  const parsed = import_assertTool.assertToolSchema.inputSchema.parse(args || {});
143
144
  return this._handleAssert(parsed);
144
145
  }
146
+ if (name === import_assertApiRequestTool.assertApiRequestSchema.name) {
147
+ this._trackedActions.push({
148
+ toolName: "browser_assert_api_request",
149
+ args: {},
150
+ code: "",
151
+ timestamp: Date.now(),
152
+ pageAlias: this._currentPageAlias
153
+ });
154
+ traceDebug("Assert API request marker recorded");
155
+ return {
156
+ 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." }]
157
+ };
158
+ }
145
159
  if (name === "browser_select_option") {
146
160
  const result2 = await this._handleSelectOption(args || {});
147
161
  return result2;
@@ -178,6 +192,7 @@ class TraceRecordingBackend {
178
192
  timestamp: ts,
179
193
  pageAlias: this._currentPageAlias
180
194
  });
195
+ await this._enableFlutterAccessibility();
181
196
  const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
182
197
  return {
183
198
  content: [
@@ -191,17 +206,88 @@ Reloaded current page: ${currentUrl}
191
206
  }
192
207
  const timestampBeforeAction = Date.now();
193
208
  const pageAliasBeforeAction = this._currentPageAlias;
209
+ const stripRestore = await this._maybeStripWrapperRoles(name, args);
194
210
  let preClickHoverCode = null;
195
- if (name === "browser_click" && args?.ref) {
196
- const hoverResult = await this._browserBackend.callTool("browser_hover", {
197
- element: args.element || "element",
198
- ref: args.ref
199
- });
200
- if (!hoverResult.isError)
201
- preClickHoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? null;
211
+ let preClickSnapshot = null;
212
+ let preClickLocatorCount = 0;
213
+ let preClickParentChain = null;
214
+ let result;
215
+ try {
216
+ if (name === "browser_click" && args?.ref) {
217
+ const hoverResult = await this._browserBackend.callTool("browser_hover", {
218
+ element: args.element || "element",
219
+ ref: args.ref
220
+ });
221
+ if (!hoverResult.isError) {
222
+ const parsed = (0, import_response.parseResponse)(hoverResult);
223
+ preClickHoverCode = parsed?.code ?? null;
224
+ preClickSnapshot = parsed?.snapshot ?? null;
225
+ if (preClickHoverCode) {
226
+ const roleCountMatch = preClickHoverCode.match(/getByRole\(\s*['"](\w+)['"]\s*(?:,\s*\{[^}]*name:\s*(?:'([^']*)'|"([^"]*)")\s*[^}]*\})?\s*\)/);
227
+ if (roleCountMatch) {
228
+ try {
229
+ const page = this._browserBackend.context?.currentTab()?.page;
230
+ if (page) {
231
+ const r = roleCountMatch[1];
232
+ const n = roleCountMatch[2] ?? roleCountMatch[3];
233
+ preClickLocatorCount = n ? await page.getByRole(r, { name: n }).count() : await page.getByRole(r).count();
234
+ traceDebug(`[pre-click count] getByRole("${r}"${n ? `, { name: "${n}" }` : ""}) = ${preClickLocatorCount}`);
235
+ }
236
+ } catch {
237
+ }
238
+ }
239
+ }
240
+ try {
241
+ const page = this._browserBackend.context?.currentTab()?.page;
242
+ if (page) {
243
+ preClickParentChain = await page.locator(`aria-ref=${args.ref}`).evaluate((el) => {
244
+ const ARIA_LANDMARKS = /* @__PURE__ */ new Set(["navigation", "main", "banner", "contentinfo", "complementary", "search", "form"]);
245
+ const SEMANTIC_TAGS_UNIQUE_CANDIDATES = ["nav", "main", "header", "footer", "aside"];
246
+ const SKIP_TESTID = /^(container|wrapper|content|main|root|app)$/i;
247
+ const SKIP_ID = /^(root|app|main|container|wrapper|content)$/i;
248
+ const isDynamic = (s) => /[-_]\d+$/.test(s) || /^[0-9a-f]{8,}/.test(s);
249
+ let cur = el.parentElement;
250
+ while (cur && cur !== el.ownerDocument.body) {
251
+ const tid = cur.getAttribute("data-testid") || cur.getAttribute("data-test-id");
252
+ if (tid && !isDynamic(tid) && !SKIP_TESTID.test(tid))
253
+ return `getByTestId(${JSON.stringify(tid)})`;
254
+ const id = cur.id;
255
+ if (id && !isDynamic(id) && !SKIP_ID.test(id)) {
256
+ const escapedId = id.replace(/(["\\])/g, "\\$1");
257
+ return `locator(${JSON.stringify("#" + escapedId)})`;
258
+ }
259
+ const role = cur.getAttribute("role");
260
+ if (role && ARIA_LANDMARKS.has(role)) {
261
+ const ariaLabel = cur.getAttribute("aria-label");
262
+ if (ariaLabel)
263
+ return `getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(ariaLabel)} })`;
264
+ if (el.ownerDocument.querySelectorAll(`[role="${role}"]`).length === 1)
265
+ return `getByRole(${JSON.stringify(role)})`;
266
+ }
267
+ const tag = cur.tagName.toLowerCase();
268
+ if (SEMANTIC_TAGS_UNIQUE_CANDIDATES.includes(tag)) {
269
+ if (el.ownerDocument.querySelectorAll(tag).length === 1)
270
+ return `locator(${JSON.stringify(tag)})`;
271
+ }
272
+ cur = cur.parentElement;
273
+ }
274
+ return null;
275
+ }).catch(() => null);
276
+ traceDebug(`[pre-click parent chain] ${preClickParentChain}`);
277
+ }
278
+ } catch {
279
+ }
280
+ }
281
+ }
282
+ result = await this._browserBackend.callTool(name, args);
283
+ } finally {
284
+ if (stripRestore)
285
+ await stripRestore();
286
+ }
287
+ if (name === "browser_navigate" && !result.isError) {
288
+ await this._enableFlutterAccessibility();
202
289
  }
203
- let result = await this._browserBackend.callTool(name, args);
204
- result = await this._improveActionSelector(name, args || {}, result, preClickHoverCode);
290
+ result = await this._improveActionSelector(name, args || {}, result, preClickHoverCode, preClickSnapshot, preClickLocatorCount, preClickParentChain);
205
291
  if (name === "browser_fill_form" && result.isError) {
206
292
  const resultText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
207
293
  if (resultText.includes("not a <select> element") || resultText.includes("selectOption") && resultText.includes("Timeout")) {
@@ -563,7 +649,7 @@ ${details}` }]
563
649
  * preClickHoverCode: hover result taken BEFORE the click (needed for navigation clicks
564
650
  * where the element ref is no longer valid after navigation).
565
651
  */
566
- async _improveActionSelector(toolName, args, result, preClickHoverCode = null) {
652
+ async _improveActionSelector(toolName, args, result, preClickHoverCode = null, preClickSnapshot = null, preClickLocatorCount = 0, preClickParentChain = null) {
567
653
  if (result.isError)
568
654
  return result;
569
655
  const selectorTools = /* @__PURE__ */ new Set(["browser_click", "browser_hover", "browser_type", "browser_fill"]);
@@ -606,43 +692,64 @@ ${details}` }]
606
692
  }
607
693
  return result;
608
694
  }
609
- const roleMatch = code.match(/getByRole\(\s*['"](\w+)['"]\s*\)(?!\s*\.(?:filter|nth|first|last))/);
610
- if (!roleMatch)
695
+ if (code.includes(".filter(") || code.includes(".nth(") || code.includes(".first(") || code.includes(".last("))
611
696
  return result;
612
- const role = roleMatch[1];
697
+ if (/page\.(getByTestId|locator|getByRole)\([^)]*\)\.getByRole/.test(code))
698
+ return result;
699
+ const bareRoleMatch = code.match(/getByRole\(\s*['"](\w+)['"]\s*\)/);
700
+ const namedRoleMatch = code.match(/getByRole\(\s*['"](\w+)['"]\s*,\s*\{[^}]*name:\s*(?:'([^']*)'|"([^"]*)")/);
701
+ if (!bareRoleMatch && !namedRoleMatch)
702
+ return result;
703
+ const role = bareRoleMatch?.[1] ?? namedRoleMatch[1];
704
+ const roleName = namedRoleMatch ? namedRoleMatch[2] ?? namedRoleMatch[3] : void 0;
613
705
  const ref = args.ref;
614
706
  if (!ref)
615
707
  return result;
616
- const snapText = parsed?.snapshot ?? "";
617
- if (!snapText)
618
- return result;
619
- const snapLines = snapText.split("\n");
708
+ const snapText = preClickSnapshot || parsed?.snapshot || "";
709
+ const snapLines = (snapText || "").split("\n");
620
710
  const rolePattern = new RegExp(`^\\s*-\\s*${role}\\s+`, "m");
621
- const roleCount = snapLines.filter((l) => rolePattern.test(l)).length;
622
- if (roleCount <= 1) {
623
- traceDebug(`Role "${role}" is unique, no improvement needed`);
624
- return result;
711
+ let snapshotMatchCount = 0;
712
+ if (snapText) {
713
+ if (roleName) {
714
+ const escapedName = roleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
715
+ const namedPattern = new RegExp(`^\\s*-\\s*${role}\\s+"[^"]*${escapedName}`, "i");
716
+ snapshotMatchCount = snapLines.filter((l) => namedPattern.test(l)).length;
717
+ } else {
718
+ snapshotMatchCount = snapLines.filter((l) => rolePattern.test(l)).length;
719
+ }
625
720
  }
626
- traceDebug(`Role "${role}" appears ${roleCount} times \u2014 attempting to disambiguate`);
721
+ const ambiguousCount = Math.max(preClickLocatorCount, snapshotMatchCount);
722
+ traceDebug(`[disambig] role="${role}" name="${roleName ?? ""}" preClickCount=${preClickLocatorCount} snapshotCount=${snapshotMatchCount} \u2192 ambiguousCount=${ambiguousCount}`);
723
+ traceDebug(`Role "${role}"${roleName ? ` name="${roleName}"` : ""} matches ${ambiguousCount} elements \u2014 attempting to disambiguate`);
627
724
  const refPattern = `[ref=${ref}]`;
628
725
  const refIdx = snapLines.findIndex((l) => l.includes(refPattern));
629
726
  if (refIdx < 0)
630
727
  return result;
631
728
  const refLine = snapLines[refIdx];
632
- const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
633
- if (textMatch && textMatch[1]) {
634
- const visibleText = textMatch[1];
635
- const textPattern = new RegExp(`^\\s*-\\s*${role}\\s+"[^"]*${visibleText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
636
- const textCount = snapLines.filter((l) => textPattern.test(l)).length;
637
- if (textCount === 1) {
638
- const improvedCode = code.replace(
639
- /getByRole\((\s*['"](\w+)['"]\s*)\)/,
640
- `getByRole($1).filter({ hasText: "${visibleText}" })`
641
- );
642
- traceDebug(`Improved selector with text filter: ${visibleText}`);
643
- return this._modifyResultCode(result, improvedCode);
729
+ if (!roleName && ambiguousCount > 1) {
730
+ const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
731
+ if (textMatch && textMatch[1]) {
732
+ const visibleText = textMatch[1];
733
+ const textPattern = new RegExp(`^\\s*-\\s*${role}\\s+"[^"]*${visibleText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
734
+ const textCount = snapLines.filter((l) => textPattern.test(l)).length;
735
+ if (textCount === 1) {
736
+ const improvedCode = code.replace(
737
+ /getByRole\((\s*['"](\w+)['"]\s*)\)/,
738
+ `getByRole($1).filter({ hasText: "${visibleText}" })`
739
+ );
740
+ traceDebug(`Improved selector with text filter: ${visibleText}`);
741
+ return this._modifyResultCode(result, improvedCode);
742
+ }
644
743
  }
645
744
  }
745
+ if (preClickParentChain) {
746
+ const improvedCode = code.replace(
747
+ /await\s+page\./,
748
+ `await page.${preClickParentChain}.`
749
+ );
750
+ traceDebug(`Improved selector by chaining from parent: ${preClickParentChain}`);
751
+ return this._modifyResultCode(result, improvedCode);
752
+ }
646
753
  const refIndent = refLine.match(/^(\s*)/)?.[1]?.length ?? 0;
647
754
  for (let i = refIdx - 1; i >= 0; i--) {
648
755
  const line = snapLines[i];
@@ -667,7 +774,7 @@ ${details}` }]
667
774
  /await\s+page\./,
668
775
  `await page.getByTestId("${testid}").`
669
776
  );
670
- traceDebug(`Improved selector by chaining from test-id: ${testid}`);
777
+ traceDebug(`Improved selector by chaining from test-id (snapshot walk): ${testid}`);
671
778
  return this._modifyResultCode(result, improvedCode);
672
779
  }
673
780
  }
@@ -676,22 +783,24 @@ ${details}` }]
676
783
  if (indent === 0)
677
784
  break;
678
785
  }
679
- const roleRefs = [];
680
- for (const line of snapLines) {
681
- if (rolePattern.test(line)) {
682
- const m = line.match(/\[ref=(\w+)\]/);
683
- if (m)
684
- roleRefs.push(m[1]);
786
+ if (ambiguousCount > 1) {
787
+ const roleRefs = [];
788
+ for (const line of snapLines) {
789
+ if (rolePattern.test(line)) {
790
+ const m = line.match(/\[ref=(\w+)\]/);
791
+ if (m)
792
+ roleRefs.push(m[1]);
793
+ }
794
+ }
795
+ const index = roleRefs.indexOf(ref);
796
+ if (index >= 0) {
797
+ const improvedCode = code.replace(
798
+ /getByRole\((\s*['"][^'"]+['"](?:\s*,\s*\{[^}]*\})?)\s*\)/,
799
+ `getByRole($1).nth(${index})`
800
+ );
801
+ traceDebug(`Improved selector with .nth(${index})`);
802
+ return this._modifyResultCode(result, improvedCode);
685
803
  }
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
804
  }
696
805
  return result;
697
806
  }
@@ -779,6 +888,51 @@ ${details}` }]
779
888
  });
780
889
  });
781
890
  }
891
+ /**
892
+ * SKYR-3728: detect a wrapper-button around args.ref and, if found, strip
893
+ * role="button"/role="link" from every wrapper in the document so that
894
+ * upstream's closestCrossShadow can't retarget UP during selector
895
+ * regeneration. Returns a restore function the caller MUST invoke (via
896
+ * try/finally) once the click has been recorded.
897
+ *
898
+ * Works for browser_click, browser_type, browser_fill, browser_hover —
899
+ * anything that goes through tab.refLocator → upstream generateSelector.
900
+ */
901
+ async _maybeStripWrapperRoles(toolName, args) {
902
+ const interactiveTools = /* @__PURE__ */ new Set(["browser_click", "browser_type", "browser_fill", "browser_hover"]);
903
+ if (!interactiveTools.has(toolName))
904
+ return null;
905
+ const ref = args?.ref;
906
+ if (!ref)
907
+ return null;
908
+ const tab = this._browserBackend.context?.currentTab();
909
+ const page = tab?.page;
910
+ if (!page)
911
+ return null;
912
+ let stripped = false;
913
+ try {
914
+ const handle = await page.locator(`aria-ref=${ref}`).elementHandle({ timeout: 1e3 });
915
+ if (!handle)
916
+ return null;
917
+ try {
918
+ stripped = await page.evaluate(pageEvaluateStripWrapperRoles, handle);
919
+ } finally {
920
+ await handle.dispose();
921
+ }
922
+ } catch {
923
+ return null;
924
+ }
925
+ if (!stripped)
926
+ return null;
927
+ traceDebug(`Wrapper-leak detected for ref=${ref}; stripped role=button/link document-wide for the action`);
928
+ return async () => {
929
+ try {
930
+ await page.evaluate(pageEvaluateRestoreWrapperRoles);
931
+ traceDebug("Restored stripped roles");
932
+ } catch {
933
+ }
934
+ };
935
+ }
782
936
  _maybeTrackAction(toolName, args, result, timestamp, pageAliasBeforeAction) {
783
937
  if (result.isError)
784
938
  return;
@@ -809,6 +963,54 @@ ${details}` }]
809
963
  return url.replace(/\/$/, "");
810
964
  }
811
965
  }
966
+ /**
967
+ * Enable Flutter web accessibility mode by clicking the hidden
968
+ * flt-semantics-placeholder button. Flutter web apps render to canvas by
969
+ * default, making DOM automation impossible. This creates a parallel ARIA
970
+ * tree that Playwright can interact with.
971
+ */
972
+ async _enableFlutterAccessibility() {
973
+ const page = this._browserBackend.context?.currentTab()?.page;
974
+ if (!page) return;
975
+ const url = page.url();
976
+ if (url.startsWith("chrome-extension://") || url.startsWith("about:") || url.startsWith("file://"))
977
+ return;
978
+ const alreadyEnabled = await page.evaluate(() => {
979
+ const host = document.querySelector("flt-semantics-host");
980
+ return host && host.children.length > 0;
981
+ }).catch(() => false);
982
+ if (alreadyEnabled) {
983
+ traceDebug("Flutter accessibility already enabled");
984
+ return;
985
+ }
986
+ let isFlutter = await page.evaluate(
987
+ () => !!document.querySelector("flt-glass-pane, flutter-view, flt-semantics-placeholder")
988
+ ).catch(() => false);
989
+ if (!isFlutter) {
990
+ const hasCanvas = await page.evaluate(() => !!document.querySelector("canvas")).catch(() => false);
991
+ if (hasCanvas) {
992
+ isFlutter = await page.waitForFunction(
993
+ () => !!document.querySelector("flt-glass-pane, flutter-view, flt-semantics-placeholder"),
994
+ { timeout: 5e3 }
995
+ ).then(() => true).catch(() => false);
996
+ }
997
+ }
998
+ if (!isFlutter) return;
999
+ const placeholder = page.locator("flt-semantics-placeholder").first();
1000
+ const clicked = await placeholder.click({ force: true }).then(() => true).catch(() => false);
1001
+ if (!clicked) {
1002
+ traceDebug("Flutter detected but placeholder click failed");
1003
+ return;
1004
+ }
1005
+ const enabled = await page.waitForFunction(() => {
1006
+ const host = document.querySelector("flt-semantics-host");
1007
+ return host && host.children.length > 0;
1008
+ }, { timeout: 5e3 }).then(() => true).catch(() => false);
1009
+ if (enabled)
1010
+ traceDebug("Flutter accessibility mode enabled");
1011
+ else
1012
+ traceDebug("Flutter placeholder clicked but semantics host did not appear");
1013
+ }
812
1014
  /** Clean up temp directory and optionally recreate it for next session. */
813
1015
  _cleanupTempDir(recreate = false) {
814
1016
  try {
@@ -838,6 +1040,51 @@ ${details}` }]
838
1040
  this._cleanupTempDir(false);
839
1041
  }
840
1042
  }
1043
+ function pageEvaluateStripWrapperRoles(target) {
1044
+ const TOKEN = "__skyr_3728_stripped__";
1045
+ const w = window;
1046
+ if (w[TOKEN])
1047
+ return false;
1048
+ if (!target || !(target instanceof Element))
1049
+ return false;
1050
+ const labelSelector = 'input, textarea, select, label, [role="checkbox"], [role="radio"], [role="switch"], [role="textbox"], [role="combobox"]';
1051
+ let wrapper = null;
1052
+ for (let el = target; el; el = el.parentElement) {
1053
+ const role = el.getAttribute && el.getAttribute("role");
1054
+ if (role !== "button" && role !== "link")
1055
+ continue;
1056
+ if (el.children.length === 0)
1057
+ continue;
1058
+ if (!el.querySelector(labelSelector))
1059
+ continue;
1060
+ wrapper = el;
1061
+ break;
1062
+ }
1063
+ if (!wrapper)
1064
+ return false;
1065
+ const stripped = [];
1066
+ document.querySelectorAll('[role="button"], [role="link"]').forEach((el) => {
1067
+ if (el.children.length === 0)
1068
+ return;
1069
+ const role = el.getAttribute("role");
1070
+ stripped.push({ el, role });
1071
+ el.removeAttribute("role");
1072
+ });
1073
+ w[TOKEN] = stripped;
1074
+ return true;
1075
+ }
1076
+ function pageEvaluateRestoreWrapperRoles() {
1077
+ const TOKEN = "__skyr_3728_stripped__";
1078
+ const w = window;
1079
+ const stripped = w[TOKEN];
1080
+ if (!stripped)
1081
+ return;
1082
+ for (const { el, role } of stripped) {
1083
+ if (el.isConnected)
1084
+ el.setAttribute("role", role);
1085
+ }
1086
+ delete w[TOKEN];
1087
+ }
841
1088
  function splitExtensionPaths(value) {
842
1089
  if (!value)
843
1090
  return void 0;
@@ -495,6 +495,11 @@ function buildJsonlContent(actions, browserName, harPath) {
495
495
  }
496
496
  continue;
497
497
  }
498
+ if (action.toolName === "browser_assert_api_request") {
499
+ lines.push(JSON.stringify({ name: "assertApiRequest", signals: [], timestamp: String(action.timestamp), pageGuid, pageAlias: alias, framePath: DEFAULT_FRAME_PATH }));
500
+ actionCount++;
501
+ continue;
502
+ }
498
503
  if ((action.toolName === "browser_type" || action.toolName === "browser_press_sequentially") && action.args.submit) {
499
504
  const fillLine = trackedActionToJsonl(action, pageGuid, action.timestamp);
500
505
  if (fillLine) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "main": "build/index.js",
5
5
  "exports": {
6
6
  ".": "./build/index.js",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@modelcontextprotocol/sdk": "^1.24.3",
57
57
  "@playwright/test": "^1.55.0",
58
- "@skyramp/skyramp": "1.3.25",
58
+ "@skyramp/skyramp": "1.3.26",
59
59
  "dockerode": "^5.0.0",
60
60
  "fast-glob": "^3.3.3",
61
61
  "js-yaml": "^4.1.1",
@@ -1,261 +0,0 @@
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];