@skyramp/mcp 0.0.65 → 0.1.0-rc.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.
Files changed (50) hide show
  1. package/build/playwright/traceRecordingPrompt.js +30 -36
  2. package/build/prompts/architectPersona.js +19 -0
  3. package/build/prompts/test-maintenance/drift-analysis-prompt.js +11 -6
  4. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +49 -0
  5. package/build/prompts/test-maintenance/driftAnalysisSections.js +4 -2
  6. package/build/prompts/test-recommendation/analysisOutputPrompt.js +42 -50
  7. package/build/prompts/test-recommendation/mergeEnrichedScenarios.test.js +125 -0
  8. package/build/prompts/test-recommendation/recommendationSections.js +121 -4
  9. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +151 -9
  10. package/build/prompts/test-recommendation/test-recommendation-prompt.js +416 -61
  11. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +455 -63
  12. package/build/prompts/testbot/testbot-prompts.js +111 -100
  13. package/build/prompts/testbot/testbot-prompts.test.js +142 -0
  14. package/build/resources/analysisResources.js +13 -5
  15. package/build/services/ScenarioGenerationService.js +2 -2
  16. package/build/services/ScenarioGenerationService.test.js +35 -0
  17. package/build/services/TestExecutionService.js +1 -1
  18. package/build/tools/code-refactor/modularizationTool.js +2 -2
  19. package/build/tools/executeSkyrampTestTool.js +4 -3
  20. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +51 -21
  21. package/build/tools/generate-tests/generateContractRestTool.js +26 -4
  22. package/build/tools/generate-tests/generateIntegrationRestTool.js +44 -13
  23. package/build/tools/generate-tests/generateScenarioRestTool.js +17 -39
  24. package/build/tools/generate-tests/generateUIRestTool.js +69 -4
  25. package/build/tools/submitReportTool.js +27 -13
  26. package/build/tools/test-management/analyzeChangesTool.js +32 -10
  27. package/build/tools/test-management/analyzeChangesTool.test.js +85 -0
  28. package/build/types/RepositoryAnalysis.js +25 -3
  29. package/build/types/TestRecommendation.js +5 -4
  30. package/build/types/TestTypes.js +44 -9
  31. package/build/utils/AnalysisStateManager.js +43 -9
  32. package/build/utils/AnalysisStateManager.test.js +35 -0
  33. package/build/utils/routeParsers.js +35 -0
  34. package/build/utils/routeParsers.test.js +66 -1
  35. package/build/utils/scenarioDrafting.js +207 -360
  36. package/build/utils/scenarioDrafting.test.js +191 -256
  37. package/build/utils/trace-parser.js +24 -6
  38. package/build/utils/trace-parser.test.js +140 -0
  39. package/node_modules/playwright/lib/mcp/browser/browserServerBackend.js +3 -0
  40. package/node_modules/playwright/lib/mcp/browser/tab.js +8 -1
  41. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -2
  42. package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
  43. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +4 -4
  44. package/node_modules/playwright/lib/mcp/browser/tools/tabs.js +5 -4
  45. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  46. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -9
  47. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +304 -7
  48. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +128 -20
  49. package/package.json +2 -2
  50. package/node_modules/playwright/lib/mcp/terminal/help.json +0 -32
@@ -46,9 +46,17 @@ var import_assertTool = require("./assertTool");
46
46
  var import_types = require("./types");
47
47
  const traceDebug = (0, import_utilsBundle.debug)("pw:mcp:trace");
48
48
  class TraceRecordingBackend {
49
+ // true while page.reload() is in progress — suppresses spurious popup tracking
49
50
  constructor(options) {
50
51
  this._trackedActions = [];
51
52
  this._initialized = false;
53
+ this._pageCount = 0;
54
+ // number of popup pages seen
55
+ this._currentPageAlias = "page";
56
+ // alias of the currently active page
57
+ this._pendingPopupAlias = null;
58
+ // popup alias to stamp on the NEXT tracked click
59
+ this._reloading = false;
52
60
  this._options = options || {};
53
61
  this._outputDir = options?.outputDir || process.cwd();
54
62
  this._tempDir = import_fs.default.mkdtempSync(import_path.default.join(import_os.default.tmpdir(), "skyramp-trace-"));
@@ -72,6 +80,8 @@ class TraceRecordingBackend {
72
80
  },
73
81
  capabilities: ["testing"],
74
82
  outputDir: this._tempDir,
83
+ snapshot: { mode: "full" },
84
+ // Always return full snapshots — incremental diffs cause empty snapshots after clicks
75
85
  timeouts: {
76
86
  action: 15e3,
77
87
  // 15s — modals and dynamic content need more time
@@ -83,6 +93,7 @@ class TraceRecordingBackend {
83
93
  await this._browserBackend.initialize(clientInfo);
84
94
  this._initialized = true;
85
95
  traceDebug("TraceRecordingBackend initialized");
96
+ this._setupPopupTracking();
86
97
  }
87
98
  async listTools() {
88
99
  const browserTools = await this._browserBackend.listTools();
@@ -99,7 +110,6 @@ class TraceRecordingBackend {
99
110
  }
100
111
  const handler = (0, import_exportTool.createExportZipHandler)({
101
112
  trackedActions: this._trackedActions,
102
- rootPath: this._outputDir,
103
113
  harPath: this._harPath
104
114
  });
105
115
  const exportResult = await handler(parsed);
@@ -115,8 +125,60 @@ class TraceRecordingBackend {
115
125
  const result2 = await this._handleSelectOption(args || {});
116
126
  return result2;
117
127
  }
128
+ if (name === "browser_tabs" && ["select", "switch"].includes(args?.action)) {
129
+ const index = args?.index ?? 0;
130
+ this._currentPageAlias = index === 0 ? "page" : `page${index}`;
131
+ traceDebug(`Tab switched to index ${index} \u2192 pageAlias: ${this._currentPageAlias}`);
132
+ const tabResult = await this._browserBackend.callTool(name, args);
133
+ return tabResult;
134
+ }
135
+ if (name === "browser_navigate") {
136
+ const targetUrl = args?.url;
137
+ const currentUrl = this._browserBackend.context?.currentTab()?.page.url();
138
+ if (targetUrl && currentUrl && this._normalizeUrl(targetUrl) === this._normalizeUrl(currentUrl)) {
139
+ traceDebug(`Navigate to same URL detected (${targetUrl}), converting to reload`);
140
+ const ts = Date.now();
141
+ const reloadAlias = this._currentPageAlias;
142
+ const tab = this._browserBackend.context.currentTab();
143
+ this._reloading = true;
144
+ try {
145
+ await tab.page.reload({ waitUntil: "domcontentloaded" });
146
+ } finally {
147
+ this._reloading = false;
148
+ this._currentPageAlias = reloadAlias;
149
+ this._pendingPopupAlias = null;
150
+ }
151
+ this._trackedActions.push({
152
+ toolName: "browser_navigate",
153
+ args: { url: currentUrl },
154
+ code: "",
155
+ timestamp: ts,
156
+ pageAlias: this._currentPageAlias
157
+ });
158
+ const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
159
+ return {
160
+ content: [
161
+ { type: "text", text: `### Navigation (reload)
162
+ Reloaded current page: ${currentUrl}
163
+ ` },
164
+ ...snapResult.content || []
165
+ ]
166
+ };
167
+ }
168
+ }
118
169
  const timestampBeforeAction = Date.now();
119
- const result = await this._browserBackend.callTool(name, args);
170
+ const pageAliasBeforeAction = this._currentPageAlias;
171
+ let preClickHoverCode = null;
172
+ if (name === "browser_click" && args?.ref) {
173
+ const hoverResult = await this._browserBackend.callTool("browser_hover", {
174
+ element: args.element || "element",
175
+ ref: args.ref
176
+ });
177
+ if (!hoverResult.isError)
178
+ preClickHoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? null;
179
+ }
180
+ let result = await this._browserBackend.callTool(name, args);
181
+ result = await this._improveActionSelector(name, args || {}, result, preClickHoverCode);
120
182
  if (name === "browser_fill_form" && result.isError) {
121
183
  const resultText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
122
184
  if (resultText.includes("not a <select> element") || resultText.includes("selectOption") && resultText.includes("Timeout")) {
@@ -125,7 +187,7 @@ class TraceRecordingBackend {
125
187
  const comboField = fields.find((f) => f.type === "combobox");
126
188
  if (comboField) {
127
189
  traceDebug("fill_form failed on combobox field, retrying with select fallback");
128
- this._maybeTrackAction(name, args || {}, { content: result.content, isError: false }, timestampBeforeAction);
190
+ this._maybeTrackAction(name, args || {}, { content: result.content, isError: false }, timestampBeforeAction, pageAliasBeforeAction);
129
191
  const selectResult = await this._handleSelectOption({
130
192
  element: comboField.name || "dropdown",
131
193
  ref: comboField.ref,
@@ -136,7 +198,7 @@ class TraceRecordingBackend {
136
198
  }
137
199
  }
138
200
  }
139
- this._maybeTrackAction(name, args || {}, result, timestampBeforeAction);
201
+ this._maybeTrackAction(name, args || {}, result, timestampBeforeAction, pageAliasBeforeAction);
140
202
  return result;
141
203
  }
142
204
  serverClosed() {
@@ -471,7 +533,227 @@ ${details}` }]
471
533
  }
472
534
  return null;
473
535
  }
474
- _maybeTrackAction(toolName, args, result, timestamp) {
536
+ /**
537
+ * Improve ambiguous selectors in action code before tracking.
538
+ * Handles role-based selectors like getByRole("combobox") that match multiple elements.
539
+ * Also upgrades raw CSS locators (locator('.class')) to semantic selectors.
540
+ * preClickHoverCode: hover result taken BEFORE the click (needed for navigation clicks
541
+ * where the element ref is no longer valid after navigation).
542
+ */
543
+ async _improveActionSelector(toolName, args, result, preClickHoverCode = null) {
544
+ if (result.isError)
545
+ return result;
546
+ const selectorTools = /* @__PURE__ */ new Set(["browser_click", "browser_hover", "browser_type", "browser_fill"]);
547
+ if (!selectorTools.has(toolName))
548
+ return result;
549
+ const parsed = (0, import_response.parseResponse)(result);
550
+ const code = parsed?.code ?? "";
551
+ if (!code)
552
+ return result;
553
+ const cssLocatorMatch = code.match(/await\s+page\.locator\(\s*(['"])[.#][^'"]+\1\s*\)\./);
554
+ if (cssLocatorMatch) {
555
+ const actionMatch = code.match(/\.(click|fill|hover|dblclick|check|uncheck)\s*\(/);
556
+ const action = actionMatch?.[1] ?? "click";
557
+ if (preClickHoverCode) {
558
+ const locatorMatch = preClickHoverCode.match(/await\s+page\.(.*?)\.hover\(\)/s);
559
+ if (locatorMatch) {
560
+ const locatorExpr = locatorMatch[1].trim();
561
+ if (!locatorExpr.startsWith("locator(")) {
562
+ const improvedCode = `await page.${locatorExpr}.${action}();`;
563
+ traceDebug(`Improved CSS locator via pre-hover: ${locatorExpr}`);
564
+ return this._modifyResultCode(result, improvedCode);
565
+ }
566
+ }
567
+ }
568
+ const ref2 = args.ref;
569
+ const snapText2 = parsed?.snapshot ?? "";
570
+ if (ref2 && snapText2) {
571
+ const refLine2 = snapText2.split("\n").find((l) => l.includes(`[ref=${ref2}]`)) || "";
572
+ if (refLine2) {
573
+ const locatorData = this._extractLocatorForRef(refLine2);
574
+ if (locatorData) {
575
+ const locatorCode = this._locatorToCode(locatorData);
576
+ if (locatorCode) {
577
+ const improvedCode = `await page.${locatorCode}.${action}();`;
578
+ traceDebug(`Improved CSS locator from snapshot: ${locatorCode}`);
579
+ return this._modifyResultCode(result, improvedCode);
580
+ }
581
+ }
582
+ }
583
+ }
584
+ return result;
585
+ }
586
+ const roleMatch = code.match(/getByRole\(\s*['"](\w+)['"]\s*\)(?!\s*\.(?:filter|nth|first|last))/);
587
+ if (!roleMatch)
588
+ return result;
589
+ const role = roleMatch[1];
590
+ const ref = args.ref;
591
+ if (!ref)
592
+ return result;
593
+ const snapText = parsed?.snapshot ?? "";
594
+ if (!snapText)
595
+ return result;
596
+ const snapLines = snapText.split("\n");
597
+ const rolePattern = new RegExp(`^\\s*-\\s*${role}\\s+`, "m");
598
+ const roleCount = snapLines.filter((l) => rolePattern.test(l)).length;
599
+ if (roleCount <= 1) {
600
+ traceDebug(`Role "${role}" is unique, no improvement needed`);
601
+ return result;
602
+ }
603
+ traceDebug(`Role "${role}" appears ${roleCount} times \u2014 attempting to disambiguate`);
604
+ const refPattern = `[ref=${ref}]`;
605
+ const refIdx = snapLines.findIndex((l) => l.includes(refPattern));
606
+ if (refIdx < 0)
607
+ return result;
608
+ const refLine = snapLines[refIdx];
609
+ const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
610
+ if (textMatch && textMatch[1]) {
611
+ const visibleText = textMatch[1];
612
+ const textPattern = new RegExp(`^\\s*-\\s*${role}\\s+"[^"]*${visibleText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
613
+ const textCount = snapLines.filter((l) => textPattern.test(l)).length;
614
+ if (textCount === 1) {
615
+ const improvedCode = code.replace(
616
+ /getByRole\((\s*['"](\w+)['"]\s*)\)/,
617
+ `getByRole($1).filter({ hasText: "${visibleText}" })`
618
+ );
619
+ traceDebug(`Improved selector with text filter: ${visibleText}`);
620
+ return this._modifyResultCode(result, improvedCode);
621
+ }
622
+ }
623
+ const refIndent = refLine.match(/^(\s*)/)?.[1]?.length ?? 0;
624
+ for (let i = refIdx - 1; i >= 0; i--) {
625
+ const line = snapLines[i];
626
+ const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0;
627
+ if (indent >= refIndent)
628
+ continue;
629
+ const ancestorRefMatch = line.match(/\[ref=(\w+)\]/);
630
+ if (!ancestorRefMatch)
631
+ continue;
632
+ const ancestorRef = ancestorRefMatch[1];
633
+ try {
634
+ const ancestorHover = await this._browserBackend.callTool("browser_hover", {
635
+ element: "parent element",
636
+ ref: ancestorRef
637
+ });
638
+ if (!ancestorHover.isError) {
639
+ const ancestorCode = (0, import_response.parseResponse)(ancestorHover)?.code ?? "";
640
+ const testidMatch = ancestorCode.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/);
641
+ if (testidMatch) {
642
+ const testid = testidMatch[1];
643
+ const improvedCode = code.replace(
644
+ /await\s+page\./,
645
+ `await page.getByTestId("${testid}").`
646
+ );
647
+ traceDebug(`Improved selector by chaining from test-id: ${testid}`);
648
+ return this._modifyResultCode(result, improvedCode);
649
+ }
650
+ }
651
+ } catch {
652
+ }
653
+ if (indent === 0)
654
+ break;
655
+ }
656
+ const roleRefs = [];
657
+ for (const line of snapLines) {
658
+ if (rolePattern.test(line)) {
659
+ const m = line.match(/\[ref=(\w+)\]/);
660
+ if (m)
661
+ roleRefs.push(m[1]);
662
+ }
663
+ }
664
+ const index = roleRefs.indexOf(ref);
665
+ if (index >= 0) {
666
+ const improvedCode = code.replace(
667
+ /getByRole\((\s*['"](\w+)['"]\s*)\)/,
668
+ `getByRole($1).nth(${index})`
669
+ );
670
+ traceDebug(`Improved selector with .nth(${index})`);
671
+ return this._modifyResultCode(result, improvedCode);
672
+ }
673
+ return result;
674
+ }
675
+ /** Helper to modify the code in a CallToolResult. */
676
+ _modifyResultCode(result, newCode) {
677
+ const modified = { ...result, content: [...result.content] };
678
+ for (let i = 0; i < modified.content.length; i++) {
679
+ if (modified.content[i].type === "text") {
680
+ const text = modified.content[i].text || "";
681
+ if (text.includes("```javascript") || text.includes("await page.")) {
682
+ const codeBlockMatch = text.match(/([\s\S]*```javascript\n)([\s\S]*?)(```[\s\S]*)/);
683
+ if (codeBlockMatch) {
684
+ modified.content[i] = {
685
+ ...modified.content[i],
686
+ text: codeBlockMatch[1] + newCode + "\n" + codeBlockMatch[3]
687
+ };
688
+ } else {
689
+ const lines = text.split("\n");
690
+ const codeLineIdx = lines.findIndex((l) => l.includes("await page."));
691
+ if (codeLineIdx >= 0) {
692
+ lines[codeLineIdx] = newCode;
693
+ modified.content[i] = {
694
+ ...modified.content[i],
695
+ text: lines.join("\n")
696
+ };
697
+ }
698
+ }
699
+ }
700
+ }
701
+ }
702
+ return modified;
703
+ }
704
+ /** Convert a locator object to a Playwright code expression like getByRole('link', { name: 'X' }). */
705
+ _locatorToCode(parsed) {
706
+ const { locator } = parsed;
707
+ switch (locator.kind) {
708
+ case "test-id":
709
+ return `getByTestId('${locator.body}')`;
710
+ case "role": {
711
+ const name = locator.options?.name;
712
+ const exact = locator.options?.exact;
713
+ const opts = name ? `, { name: '${name}'${exact ? ", exact: true" : ""} }` : "";
714
+ return `getByRole('${locator.body}'${opts})`;
715
+ }
716
+ case "label":
717
+ return `getByLabel('${locator.body}')`;
718
+ case "text":
719
+ return `getByText('${locator.body}')`;
720
+ default:
721
+ return null;
722
+ }
723
+ }
724
+ /**
725
+ * Listen for new pages (popups) on the browser context.
726
+ * When a popup opens, find the most recent click that triggered it and mark it
727
+ * with a popupAlias signal. Switch the current page alias to the new page.
728
+ */
729
+ _setupPopupTracking() {
730
+ void (async () => {
731
+ try {
732
+ const context = this._browserBackend.context;
733
+ if (!context)
734
+ return;
735
+ const browserContext = await context.ensureBrowserContext();
736
+ let initialPageSeen = false;
737
+ browserContext.on("page", () => {
738
+ if (!initialPageSeen) {
739
+ initialPageSeen = true;
740
+ return;
741
+ }
742
+ if (this._reloading) {
743
+ traceDebug("Ignoring spurious popup opened during reload");
744
+ return;
745
+ }
746
+ this._pageCount++;
747
+ const popupAlias = `page${this._pageCount}`;
748
+ this._currentPageAlias = popupAlias;
749
+ this._pendingPopupAlias = popupAlias;
750
+ traceDebug(`Popup page opened: ${popupAlias} (pending stamp)`);
751
+ });
752
+ } catch {
753
+ }
754
+ })();
755
+ }
756
+ _maybeTrackAction(toolName, args, result, timestamp, pageAliasBeforeAction) {
475
757
  if (result.isError)
476
758
  return;
477
759
  if (toolName === "browser_press_key") {
@@ -482,13 +764,28 @@ ${details}` }]
482
764
  const parsed = (0, import_response.parseResponse)(result);
483
765
  const code = parsed?.code ?? "";
484
766
  if (code || import_types.ARGS_ONLY_TOOLS.has(toolName)) {
767
+ const popupAlias = this._pendingPopupAlias ?? void 0;
768
+ this._pendingPopupAlias = null;
769
+ const pageAlias = popupAlias ? pageAliasBeforeAction ?? this._currentPageAlias : this._currentPageAlias;
485
770
  this._trackedActions.push({
486
771
  toolName,
487
772
  args,
488
773
  code,
489
- timestamp: timestamp ?? Date.now()
774
+ timestamp: timestamp ?? Date.now(),
775
+ pageAlias,
776
+ popupAlias
490
777
  });
491
- traceDebug(`Tracked action: ${toolName} (total: ${this._trackedActions.length})`);
778
+ traceDebug(`Tracked action: ${toolName} on ${pageAlias}${popupAlias ? ` \u2192 popup:${popupAlias}` : ""} (total: ${this._trackedActions.length})`);
779
+ }
780
+ }
781
+ /** Normalize a URL for comparison: strip trailing slash and fragment. */
782
+ _normalizeUrl(url) {
783
+ try {
784
+ const u = new URL(url);
785
+ u.hash = "";
786
+ return u.toString().replace(/\/$/, "");
787
+ } catch {
788
+ return url.replace(/\/$/, "");
492
789
  }
493
790
  }
494
791
  async _autoExportAndClose() {
@@ -38,8 +38,8 @@ var import_fs = __toESM(require("fs"));
38
38
  var import_path = __toESM(require("path"));
39
39
  var import_zipBundle = require("playwright-core/lib/zipBundle");
40
40
  var import_utils = require("playwright-core/lib/utils");
41
- const PAGE_ALIAS = "page";
42
- const FRAME_PATH = [];
41
+ const DEFAULT_PAGE_ALIAS = "page";
42
+ const DEFAULT_FRAME_PATH = [];
43
43
  function jsonlHeader(browserName, harPath) {
44
44
  return JSON.stringify({
45
45
  browserName,
@@ -53,10 +53,18 @@ function jsonlHeader(browserName, harPath) {
53
53
  });
54
54
  }
55
55
  function extractLocatorFromCode(code) {
56
+ const contentFrameMatch = code.match(/await\s+page\.locator\((['"])(iframe[^\n]*?)\1\)(?:\.contentFrame\(\))+\.(.*?)\.(click|dblclick|fill|pressSequentially|check|uncheck|selectOption|hover|dragTo)\s*\(/s);
57
+ if (contentFrameMatch) {
58
+ return { locatorExpr: contentFrameMatch[3].trim(), framePath: [contentFrameMatch[2]] };
59
+ }
60
+ const chainedIframeMatch = code.match(/await\s+page\.locator\((['"])(iframe[^\n]*?)\1\)\.(get\w+\(.*?)\.(click|dblclick|fill|pressSequentially|check|uncheck|selectOption|hover|dragTo)\s*\(/s);
61
+ if (chainedIframeMatch) {
62
+ return { locatorExpr: chainedIframeMatch[3].trim(), framePath: [chainedIframeMatch[2]] };
63
+ }
56
64
  const match = code.match(/await\s+page\.(.*?)\.(click|dblclick|fill|pressSequentially|check|uncheck|selectOption|hover|dragTo)\s*\(/s);
57
65
  if (!match)
58
66
  return void 0;
59
- return match[1].trim();
67
+ return { locatorExpr: match[1].trim(), framePath: [] };
60
68
  }
61
69
  function locatorToJsonl(locatorExpr) {
62
70
  const chainParts = locatorExpr.split(/\)\.(?=get)/);
@@ -169,25 +177,39 @@ function extractSelectOptions(code) {
169
177
  }
170
178
  function trackedActionToJsonl(action, pageGuid, timestamp) {
171
179
  const { toolName, args, code } = action;
180
+ const pageAlias = action.pageAlias ?? DEFAULT_PAGE_ALIAS;
181
+ const signals = action.popupAlias ? [{ name: "popup", popupAlias: action.popupAlias }] : [];
172
182
  const base = {
173
- signals: [],
183
+ signals,
174
184
  timestamp: String(timestamp),
175
185
  pageGuid,
176
- pageAlias: PAGE_ALIAS,
177
- framePath: FRAME_PATH
186
+ pageAlias,
187
+ framePath: action.framePath ?? DEFAULT_FRAME_PATH
178
188
  };
179
189
  if (toolName === "browser_navigate")
180
190
  return JSON.stringify({ name: "navigate", url: args.url, ...base });
181
191
  if (toolName === "browser_navigate_back")
182
192
  return null;
193
+ if (toolName === "browser_wait_for") {
194
+ const text = args.text;
195
+ const textGone = args.textGone;
196
+ if (text)
197
+ return JSON.stringify({ name: "waitForSelector", selector: `text=${text}`, ...base });
198
+ if (textGone)
199
+ return JSON.stringify({ name: "waitForSelector", selector: `text=${textGone}`, state: "hidden", ...base });
200
+ return null;
201
+ }
183
202
  if (toolName === "browser_press_key")
184
203
  return JSON.stringify({ name: "press", key: args.key, selector: "", ...base });
185
204
  if (!code)
186
205
  return null;
187
- const locatorStr = extractLocatorFromCode(code);
188
- if (!locatorStr)
206
+ const extracted = extractLocatorFromCode(code);
207
+ if (!extracted)
189
208
  return null;
190
- const parsed = locatorToJsonl(locatorStr);
209
+ const { locatorExpr, framePath: codeFramePath } = extracted;
210
+ if (codeFramePath.length > 0)
211
+ base.framePath = codeFramePath;
212
+ const parsed = locatorToJsonl(locatorExpr);
191
213
  if (!parsed)
192
214
  return null;
193
215
  const { selector, locator: locatorObj } = parsed;
@@ -236,8 +258,8 @@ function assertActionToJsonl(action, pageGuid, timestamp) {
236
258
  signals: [],
237
259
  timestamp: String(timestamp),
238
260
  pageGuid,
239
- pageAlias: PAGE_ALIAS,
240
- framePath: FRAME_PATH
261
+ pageAlias: DEFAULT_PAGE_ALIAS,
262
+ framePath: DEFAULT_FRAME_PATH
241
263
  };
242
264
  const parts = code.split(":");
243
265
  const selectorPart = parts[1] || "";
@@ -298,11 +320,27 @@ function fillFormToJsonl(action, pageGuid, baseTimestamp) {
298
320
  signals: [],
299
321
  timestamp: String(baseTimestamp + i),
300
322
  pageGuid,
301
- pageAlias: PAGE_ALIAS,
302
- framePath: FRAME_PATH
323
+ pageAlias: DEFAULT_PAGE_ALIAS,
324
+ framePath: DEFAULT_FRAME_PATH
303
325
  });
304
326
  });
305
327
  }
328
+ function removeRedundantClicks(actions) {
329
+ return actions.filter((action, i) => {
330
+ if (action.toolName !== "browser_click")
331
+ return true;
332
+ const next = actions[i + 1];
333
+ if (!next || !["browser_type", "browser_fill"].includes(next.toolName))
334
+ return true;
335
+ const clickRef = action.args?.ref;
336
+ const fillRef = next.args?.ref;
337
+ if (!clickRef || !fillRef || clickRef !== fillRef)
338
+ return true;
339
+ if (next.args?.slowly || next.code.includes(".pressSequentially("))
340
+ return true;
341
+ return false;
342
+ });
343
+ }
306
344
  function deduplicateRetries(actions) {
307
345
  const firstNavigate = actions.find((a) => a.toolName === "browser_navigate");
308
346
  if (!firstNavigate)
@@ -316,16 +354,52 @@ function deduplicateRetries(actions) {
316
354
  return lastRestartIdx > 0 ? actions.slice(lastRestartIdx) : actions;
317
355
  }
318
356
  function buildJsonlContent(actions, browserName, harPath) {
319
- const deduplicated = deduplicateRetries(actions);
320
- const pageGuid = `page@${import_crypto.default.randomBytes(16).toString("hex")}`;
357
+ const deduplicated = removeRedundantClicks(deduplicateRetries(actions));
321
358
  const startTime = deduplicated[0]?.timestamp ?? Date.now();
359
+ const pageGuids = /* @__PURE__ */ new Map();
360
+ const getPageGuid = (alias) => {
361
+ if (!pageGuids.has(alias))
362
+ pageGuids.set(alias, `page@${import_crypto.default.randomBytes(16).toString("hex")}`);
363
+ return pageGuids.get(alias);
364
+ };
365
+ const mainGuid = getPageGuid(DEFAULT_PAGE_ALIAS);
322
366
  const lines = [
323
367
  jsonlHeader(browserName, harPath),
324
- JSON.stringify({ name: "openPage", url: "about:blank", signals: [], timestamp: String(startTime - 1e3), pageGuid, pageAlias: PAGE_ALIAS, framePath: FRAME_PATH })
368
+ JSON.stringify({ name: "openPage", url: "about:blank", signals: [], timestamp: String(startTime - 1e3), pageGuid: mainGuid, pageAlias: DEFAULT_PAGE_ALIAS, framePath: DEFAULT_FRAME_PATH })
325
369
  ];
326
370
  let actionCount = 0;
327
371
  const skipped = [];
372
+ const openedAliases = /* @__PURE__ */ new Set([DEFAULT_PAGE_ALIAS]);
373
+ let hadPopupFills = false;
328
374
  for (const action of deduplicated) {
375
+ const alias = action.pageAlias ?? DEFAULT_PAGE_ALIAS;
376
+ const pageGuid = getPageGuid(alias);
377
+ if (action.popupAlias && !openedAliases.has(action.popupAlias)) {
378
+ const popupGuid = getPageGuid(action.popupAlias);
379
+ lines.push(JSON.stringify({ name: "openPage", url: "about:blank", signals: [], timestamp: String(action.timestamp), pageGuid: popupGuid, pageAlias: action.popupAlias, framePath: DEFAULT_FRAME_PATH }));
380
+ openedAliases.add(action.popupAlias);
381
+ }
382
+ if (alias !== DEFAULT_PAGE_ALIAS && !openedAliases.has(alias)) {
383
+ for (let i = lines.length - 1; i >= 0; i--) {
384
+ try {
385
+ const obj = JSON.parse(lines[i]);
386
+ if (obj.name === "openPage" || obj.name === "closePage")
387
+ continue;
388
+ if (!obj.pageAlias || obj.pageAlias !== alias) {
389
+ obj.signals = [...obj.signals || [], { name: "popup", popupAlias: alias }];
390
+ lines[i] = JSON.stringify(obj);
391
+ break;
392
+ }
393
+ } catch {
394
+ break;
395
+ }
396
+ }
397
+ const popupGuid = getPageGuid(alias);
398
+ lines.push(JSON.stringify({ name: "openPage", url: "about:blank", signals: [], timestamp: String(action.timestamp), pageGuid: popupGuid, pageAlias: alias, framePath: DEFAULT_FRAME_PATH }));
399
+ openedAliases.add(alias);
400
+ }
401
+ if ((action.toolName === "browser_type" || action.toolName === "browser_fill") && alias !== DEFAULT_PAGE_ALIAS)
402
+ hadPopupFills = true;
329
403
  if (action.toolName === "browser_fill_form") {
330
404
  const formLines = fillFormToJsonl(action, pageGuid, action.timestamp);
331
405
  lines.push(...formLines);
@@ -340,6 +414,11 @@ function buildJsonlContent(actions, browserName, harPath) {
340
414
  }
341
415
  continue;
342
416
  }
417
+ if (action.toolName === "browser_navigate" && alias === DEFAULT_PAGE_ALIAS && hadPopupFills) {
418
+ lines.push(JSON.stringify({ name: "waitForTimeout", duration: 3e4, signals: [], timestamp: String(action.timestamp - 1), pageGuid, pageAlias: alias, framePath: DEFAULT_FRAME_PATH }));
419
+ actionCount++;
420
+ hadPopupFills = false;
421
+ }
343
422
  const line = trackedActionToJsonl(action, pageGuid, action.timestamp);
344
423
  if (line) {
345
424
  lines.push(line);
@@ -348,18 +427,47 @@ function buildJsonlContent(actions, browserName, harPath) {
348
427
  skipped.push(action.toolName);
349
428
  }
350
429
  }
351
- lines.push(JSON.stringify({ name: "closePage", signals: [], timestamp: String(Date.now()), pageGuid, pageAlias: PAGE_ALIAS, framePath: FRAME_PATH }));
430
+ lines.push(JSON.stringify({ name: "closePage", signals: [], timestamp: String(Date.now()), pageGuid: mainGuid, pageAlias: DEFAULT_PAGE_ALIAS, framePath: DEFAULT_FRAME_PATH }));
352
431
  return { jsonl: lines.join("\n") + "\n", actionCount, skipped };
353
432
  }
433
+ const CROSS_PAGE_URL_BLOCKLIST = [
434
+ "/app_init",
435
+ "notes.services.box.com"
436
+ ];
437
+ function filterHarEntries(harContent) {
438
+ try {
439
+ const har = JSON.parse(harContent);
440
+ const entries = har?.log?.entries ?? [];
441
+ har.log.entries = entries.filter((entry) => {
442
+ const url = entry?.request?.url ?? "";
443
+ if (CROSS_PAGE_URL_BLOCKLIST.some((pattern) => url.includes(pattern)))
444
+ return false;
445
+ const text = entry?.response?.content?.text;
446
+ if (!text || text.trim() === "")
447
+ return false;
448
+ try {
449
+ JSON.parse(text);
450
+ return true;
451
+ } catch {
452
+ return false;
453
+ }
454
+ });
455
+ return JSON.stringify(har);
456
+ } catch {
457
+ return harContent;
458
+ }
459
+ }
354
460
  async function writeSkyrampZip(outputZipPath, jsonlContent, harPath) {
355
461
  const zipFile = new import_zipBundle.yazl.ZipFile();
356
462
  const done = new import_utils.ManualPromise();
357
463
  zipFile.on("error", (e) => done.reject(e));
358
464
  zipFile.addBuffer(Buffer.from(jsonlContent, "utf-8"), "skyramp_playwright.txt");
359
- if (await import_fs.default.promises.stat(harPath).then(() => true).catch(() => false))
360
- zipFile.addFile(harPath, "skyramp_network.har");
361
- else
465
+ if (await import_fs.default.promises.stat(harPath).then(() => true).catch(() => false)) {
466
+ const rawHar = await import_fs.default.promises.readFile(harPath, "utf-8");
467
+ zipFile.addBuffer(Buffer.from(filterHarEntries(rawHar), "utf-8"), "skyramp_network.har");
468
+ } else {
362
469
  zipFile.addBuffer(Buffer.from('{"log":{"version":"1.2","creator":{"name":"Playwright","version":"1.0"},"entries":[]}}', "utf-8"), "skyramp_network.har");
470
+ }
363
471
  zipFile.end();
364
472
  await import_fs.default.promises.mkdir(import_path.default.dirname(outputZipPath), { recursive: true });
365
473
  zipFile.outputStream.pipe(import_fs.default.createWriteStream(outputZipPath)).on("close", () => done.resolve()).on("error", (e) => done.reject(e));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.0.65",
3
+ "version": "0.1.0-rc.2",
4
4
  "main": "build/index.js",
5
5
  "exports": {
6
6
  ".": "./build/index.js",
@@ -54,7 +54,7 @@
54
54
  "dependencies": {
55
55
  "@modelcontextprotocol/sdk": "^1.24.3",
56
56
  "@playwright/test": "^1.55.0",
57
- "@skyramp/skyramp": "1.3.18",
57
+ "@skyramp/skyramp": "1.3.19",
58
58
  "dockerode": "^4.0.6",
59
59
  "fast-glob": "^3.3.3",
60
60
  "playwright": "file:vendor/skyramp-playwright-1.58.2-skyramp.8.9.0.tgz",
@@ -1,32 +0,0 @@
1
- {
2
- "global": "Usage: playwright-cli <command> [options]\nCommands:\n click <ref> perform click on a web page\n close close the page\n dblclick <ref> perform double click on a web page\n console <level> returns all console messages\n drag <startRef> <endRef> perform drag and drop between two elements\n evaluate <function> <ref> evaluate javascript expression on page or element\n upload-file upload one or multiple files\n handle-dialog <accept> <promptText> handle a dialog\n hover <ref> hover over element on page\n open <url> open url\n go-back go back to the previous page\n network-requests returns all network requests since loading the page\n press <key> press a key on the keyboard\n resize <width> <height> resize the browser window\n run-code <code> run playwright code snippet\n select-option <ref> <values> select an option in a dropdown\n snapshot capture accessibility snapshot of the current page, this is better than screenshot\n screenshot <ref> take a screenshot of the current page. you can't perform actions based on the screenshot, use browser_snapshot for actions.\n type <text> type text into editable element\n wait-for wait for text to appear or disappear or a specified time to pass\n tab <action> <index> close a browser tab\n mouse-click-xy <x> <y> click left mouse button at a given position\n mouse-drag-xy <startX> <startY> <endX> <endY> drag left mouse button to a given position\n mouse-move-xy <x> <y> move mouse to a given position\n pdf-save save page as pdf\n start-tracing start trace recording\n stop-tracing stop trace recording",
3
- "commands": {
4
- "click": "playwright-cli click <ref>\n\nPerform click on a web page\n\nArguments:\n <ref>\tExact target element reference from the page snapshot\nOptions:\n --button\tbutton to click, defaults to left\n --modifiers\tmodifier keys to press",
5
- "close": "playwright-cli close \n\nClose the page\n",
6
- "dblclick": "playwright-cli dblclick <ref>\n\nPerform double click on a web page\n\nArguments:\n <ref>\tExact target element reference from the page snapshot\nOptions:\n --button\tbutton to click, defaults to left\n --modifiers\tmodifier keys to press",
7
- "console": "playwright-cli console <level>\n\nReturns all console messages\n\nArguments:\n <level>\tLevel of the console messages to return. Each level includes the messages of more severe levels. Defaults to \"info\".",
8
- "drag": "playwright-cli drag <startRef> <endRef>\n\nPerform drag and drop between two elements\n\nArguments:\n <startRef>\tExact source element reference from the page snapshot\n <endRef>\tExact target element reference from the page snapshot\nOptions:\n --headed\trun browser in headed mode",
9
- "evaluate": "playwright-cli evaluate <function> <ref>\n\nEvaluate JavaScript expression on page or element\n\nArguments:\n <function>\t() => { /* code */ } or (element) => { /* code */ } when element is provided\n <ref>\tExact target element reference from the page snapshot",
10
- "upload-file": "playwright-cli upload-file \n\nUpload one or multiple files\n\nOptions:\n --paths\tthe absolute paths to the files to upload. can be single file or multiple files. if omitted, file chooser is cancelled.",
11
- "handle-dialog": "playwright-cli handle-dialog <accept> <promptText>\n\nHandle a dialog\n\nArguments:\n <accept>\tWhether to accept the dialog.\n <promptText>\tThe text of the prompt in case of a prompt dialog.",
12
- "hover": "playwright-cli hover <ref>\n\nHover over element on page\n\nArguments:\n <ref>\tExact target element reference from the page snapshot",
13
- "open": "playwright-cli open <url>\n\nOpen URL\n\nArguments:\n <url>\tThe URL to navigate to\nOptions:\n --headed\trun browser in headed mode",
14
- "go-back": "playwright-cli go-back \n\nGo back to the previous page\n",
15
- "network-requests": "playwright-cli network-requests \n\nReturns all network requests since loading the page\n\nOptions:\n --includeStatic\twhether to include successful static resources like images, fonts, scripts, etc. defaults to false.",
16
- "press": "playwright-cli press <key>\n\nPress a key on the keyboard\n\nArguments:\n <key>\tName of the key to press or a character to generate, such as `ArrowLeft` or `a`",
17
- "resize": "playwright-cli resize <width> <height>\n\nResize the browser window\n\nArguments:\n <width>\tWidth of the browser window\n <height>\tHeight of the browser window",
18
- "run-code": "playwright-cli run-code <code>\n\nRun Playwright code snippet\n\nArguments:\n <code>\tA JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction.",
19
- "select-option": "playwright-cli select-option <ref> <values>\n\nSelect an option in a dropdown\n\nArguments:\n <ref>\tExact target element reference from the page snapshot\n <values>\tArray of values to select in the dropdown. This can be a single value or multiple values.",
20
- "snapshot": "playwright-cli snapshot \n\nCapture accessibility snapshot of the current page, this is better than screenshot\n\nOptions:\n --filename\tsave snapshot to markdown file instead of returning it in the response.",
21
- "screenshot": "playwright-cli screenshot <ref>\n\nTake a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.\n\nArguments:\n <ref>\tExact target element reference from the page snapshot.\nOptions:\n --filename\tfile name to save the screenshot to. defaults to `page-{timestamp}.{png|jpeg}` if not specified.\n --fullPage\twhen true, takes a screenshot of the full scrollable page, instead of the currently visible viewport.",
22
- "type": "playwright-cli type <text>\n\nType text into editable element\n\nArguments:\n <text>\tText to type into the element\nOptions:\n --submit\twhether to submit entered text (press enter after)",
23
- "wait-for": "playwright-cli wait-for \n\nWait for text to appear or disappear or a specified time to pass\n\nOptions:\n --time\tthe time to wait in seconds\n --text\tthe text to wait for\n --textGone\tthe text to wait for to disappear",
24
- "tab": "playwright-cli tab <action> <index>\n\nClose a browser tab\n\nArguments:\n <action>\tAction to perform on tabs, 'list' | 'new' | 'close' | 'select'\n <index>\tTab index. If omitted, current tab is closed.",
25
- "mouse-click-xy": "playwright-cli mouse-click-xy <x> <y>\n\nClick left mouse button at a given position\n\nArguments:\n <x>\tX coordinate\n <y>\tY coordinate",
26
- "mouse-drag-xy": "playwright-cli mouse-drag-xy <startX> <startY> <endX> <endY>\n\nDrag left mouse button to a given position\n\nArguments:\n <startX>\tStart X coordinate\n <startY>\tStart Y coordinate\n <endX>\tEnd X coordinate\n <endY>\tEnd Y coordinate",
27
- "mouse-move-xy": "playwright-cli mouse-move-xy <x> <y>\n\nMove mouse to a given position\n\nArguments:\n <x>\tX coordinate\n <y>\tY coordinate",
28
- "pdf-save": "playwright-cli pdf-save \n\nSave page as PDF\n\nOptions:\n --filename\tfile name to save the pdf to. defaults to `page-{timestamp}.pdf` if not specified.",
29
- "start-tracing": "playwright-cli start-tracing \n\nStart trace recording\n",
30
- "stop-tracing": "playwright-cli stop-tracing \n\nStop trace recording\n"
31
- }
32
- }