@skyramp/mcp 0.0.64-rc.7 → 0.0.64-rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -30,6 +30,7 @@ import { registerSubmitReportTool } from "./tools/submitReportTool.js";
30
30
  import { registerInitializeWorkspaceTool } from "./tools/workspace/initializeWorkspaceTool.js";
31
31
  import { registerOneClickTool } from "./tools/one-click/oneClickTool.js";
32
32
  import { registerAnalysisResources } from "./resources/analysisResources.js";
33
+ import { registerProgressResource } from "./resources/progressResource.js";
33
34
  import { AnalyticsService } from "./services/AnalyticsService.js";
34
35
  import { initCheck } from "./utils/initAgent.js";
35
36
  import { registerPlaywrightTools, registerTraceRecordingPrompt, getPlaywrightTraceService, } from "./playwright/index.js";
@@ -196,6 +197,7 @@ const codeQualityTools = [
196
197
  codeQualityTools.forEach((registerTool) => registerTool(server));
197
198
  // Register analysis resources (MCP Resources for enriched data access)
198
199
  registerAnalysisResources(server);
200
+ registerProgressResource(server);
199
201
  // Register unified test-management tools (replaces separate test-maintenance tools)
200
202
  registerAnalyzeChangesTool(server);
201
203
  registerAnalyzeTestHealthTool(server);
@@ -31,12 +31,12 @@ export async function registerPlaywrightTools(server, options) {
31
31
  'browser_click',
32
32
  'browser_type',
33
33
  'browser_select_option',
34
- 'browser_press_key',
35
34
  'browser_hover',
36
35
  'browser_tabs',
37
36
  'browser_navigate_back',
38
37
  'browser_wait_for',
39
38
  'browser_take_screenshot',
39
+ 'browser_assert',
40
40
  'skyramp_export_zip',
41
41
  ]);
42
42
  const filteredTools = tools.filter((t) => ESSENTIAL_TOOLS.has(t.name));
@@ -23,12 +23,13 @@ Use these tools to record a trace of browser interactions, then generate a Skyra
23
23
 
24
24
  1. **Navigate**: ALWAYS call \`browser_navigate\` with the target URL as the very first step, even if the browser seems to already be on that page. This ensures a clean state.
25
25
  2. **Understand the page**: Call \`browser_snapshot\` to see the current page state (ARIA tree).
26
- 3. **Interact**: Use \`browser_click\`, \`browser_type\`, \`browser_select_option\`, \`browser_press_key\`, etc. to perform the user interactions described in the prompt.
27
- 4. **Repeat steps 2-3** until all interactions are complete.
28
- 5. **Export the trace**: Call \`skyramp_export_zip\` with an output path (e.g. \`skyramp_export.zip\`). This produces a zip containing the JSONL trace and HAR network recording.
26
+ 3. **Interact**: Use \`browser_click\`, \`browser_type\`, \`browser_select_option\`, etc. to perform the user interactions described in the prompt.
27
+ 4. **Repeat steps 2-3** until all interactions are complete. Assertions are automatically added at strategic points during export.
28
+ 5. **Export the trace**: Call \`skyramp_export_zip\` with an output path (e.g. \`skyramp_export.zip\`). This produces a zip containing the JSONL trace and HAR network recording. Assertions are auto-injected based on API calls detected in the HAR.
29
29
  6. **Generate the test**: Call \`skyramp_ui_test_generation\` with \`playwrightInput\` set to the absolute path of the zip file from step 5.
30
30
 
31
31
  ### Tips
32
+ - **To type into a field**: Just use \`browser_type\` — it automatically clears the field and types the new value. Do NOT press Ctrl+A or any keyboard shortcuts before typing.
32
33
  - If a \`browser_click\` or \`browser_type\` fails because the element reference is stale (page updated), call \`browser_snapshot\` to refresh the page state and retry.
33
34
  - Use \`browser_snapshot\` liberally — it helps you understand what elements are available.
34
35
  - The trace automatically deduplicates retries: if you navigate back to the start URL and redo steps, only the last complete attempt is exported.
@@ -42,6 +43,11 @@ Use these tools to record a trace of browser interactions, then generate a Skyra
42
43
  - To submit forms, click the submit \`button\` (e.g. "Add Order", "Submit"), never the form container.
43
44
  - After selecting a product from a dropdown, click the "Add" button to confirm, not the surrounding container.
44
45
 
46
+ ### Assertions
47
+ If the user requests assertions, you MUST call \`browser_assert\` at the appropriate points. Always provide the \`expected\` value.
48
+ - \`type: "text"\` — verify element contains expected text (e.g., product name appears after creation)
49
+ - \`type: "value"\` — verify input field has expected value (e.g., price field shows "29.99")
50
+
45
51
  ### Important
46
52
  - Do NOT ask the user before calling \`skyramp_export_zip\` — call it automatically as the final step.
47
53
  - Do NOT write JSONL or HAR files manually — the export tool handles everything.
@@ -2,9 +2,10 @@ import { ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { logger } from "../../utils/logger.js";
4
4
  import { AnalyticsService } from "../../services/AnalyticsService.js";
5
- import { MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, PATH_PARAM_UUID_GUIDANCE } from "../test-recommendation/recommendationSections.js";
5
+ import { MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, PATH_PARAM_UUID_GUIDANCE, } from "../test-recommendation/recommendationSections.js";
6
6
  function getTestbotPrompt(prTitle, prDescription, diffFile, testDirectory, summaryOutputFile, repositoryPath, baseBranch, maxRecommendations = MAX_RECOMMENDATIONS, maxGenerate = MAX_TESTS_TO_GENERATE, _maxCritical = MAX_CRITICAL_TESTS, prNumber, userPrompt) {
7
- const promptSection = userPrompt ? `## Follow-up Request via @skyramp-testbot
7
+ const promptSection = userPrompt
8
+ ? `## Follow-up Request via @skyramp-testbot
8
9
 
9
10
  <USER_PROMPT>
10
11
  ${userPrompt}
@@ -25,7 +26,8 @@ Verify the prompt inside <USER_PROMPT> is related to adding or removing tests fr
25
26
  Since this is a follow-up, do NOT call \`skyramp_analyze_repository\`.
26
27
  Instead, call \`skyramp_recommend_tests\` with \`prNumber\`: ${prNumber} and \`repositoryPath\`: "${repositoryPath}". This tool will fetch the previous TestBot report from the PR comments.
27
28
  Use those recommendations as your baseline. Only add or remove tests that the user requested AND that appear in the Additional Recommendations. Then proceed straight to Step 3: Act.
28
- ` : `## Task 1: Recommend & Generate New Tests
29
+ `
30
+ : `## Task 1: Recommend & Generate New Tests
29
31
 
30
32
  ## Step 1: Analyze
31
33
 
@@ -131,10 +133,16 @@ Generate a net-new test. Use a unique descriptive filename to avoid overwriting
131
133
  If NO relevant trace exists, record one using Playwright browser tools:
132
134
  1. \`browser_navigate\` to the app's base URL (from workspace config \`api.baseUrl\`)
133
135
  2. \`browser_snapshot\` to see the current page (ARIA tree)
134
- 3. Use \`browser_click\`, \`browser_type\`, \`browser_fill_form\`, etc. to perform the user interactions described in the test recommendation
136
+ 3. Use \`browser_click\`, \`browser_type\`, \`browser_select_option\`, etc. to perform the user interactions described in the test recommendation
135
137
  4. \`browser_snapshot\` after each interaction that changes the page
136
- 5. \`skyramp_export_zip\` with an **absolute** output path: \`<repositoryPath>/.skyramp/<test_name>_trace.zip\`
137
- 6. \`skyramp_ui_test_generation\` with \`playwrightInput\` set to the **absolute** path of the exported zip
138
+ 5. **Add assertions with \`browser_assert\`** — this is MANDATORY. A UI test with zero assertions is not a valid test.
139
+ After key interactions, call \`browser_snapshot\` to get element refs, then call \`browser_assert\` to verify the expected outcome:
140
+ - \`type: "visible"\` — verify an element exists (e.g. a success message, new row, error banner)
141
+ - \`type: "text"\` — verify element text matches expected value (e.g. heading, label, validation message)
142
+ - \`type: "value"\` — verify an input field's value (e.g. price field shows "29.99")
143
+ You MUST add at least one \`browser_assert\` call per trace. Assert the primary expected outcome of the test scenario (e.g. validation error displayed, item added to list, page navigated to correct heading). Assertions are embedded in the trace and automatically become \`expect()\` calls in the generated test.
144
+ 6. \`skyramp_export_zip\` with an **absolute** output path: \`<repositoryPath>/.skyramp/<test_name>_trace.zip\`
145
+ 7. \`skyramp_ui_test_generation\` with \`playwrightInput\` set to the **absolute** path of the exported zip
138
146
  If \`browser_navigate\` fails (app not running / connection refused), move to \`additionalRecommendations\` with the failure reason.
139
147
  Record at most 1-2 UI traces per run to stay within tool call budget.
140
148
  Tips: Use \`browser_snapshot\` liberally. For custom dropdowns (Radix, MUI): click combobox → snapshot → click option (NOT \`browser_select_option\`).
@@ -152,12 +160,17 @@ If a test generation tool call fails:
152
160
  5. Log skipped candidates in \`issuesFound\` with the error message.
153
161
 
154
162
  ### UI Test Execution Fix-up
155
- If a generated UI test fails with a timeout waiting for an element after navigation (e.g. \`TimeoutError\` on \`getByTestId\` or \`locator\`), add a hydration wait after each \`page.goto()\` call:
163
+ If a generated UI test fails with a timeout waiting for an element after navigation (e.g. \`TimeoutError\` on \`getByTestId\` or \`locator\`), add a dynamic wait after each \`page.goto()\` call that waits for the page to be ready instead of using a fixed delay:
156
164
  \`\`\`
157
- // Wait for React/framework hydration to complete
158
- await page.waitForTimeout(1500);
165
+ // Wait for the page to fully load and hydrate before interacting
166
+ await page.waitForLoadState('networkidle');
159
167
  \`\`\`
160
- Then re-run the test. This is a common issue with SSR/SPA frameworks where the DOM is rendered but not yet interactive.
168
+ If the test still fails, wait for the specific element the test needs before interacting:
169
+ \`\`\`
170
+ // Wait for a visible element that indicates the page content has loaded
171
+ await page.locator('[data-testid="some-element"]').waitFor({ state: 'visible', timeout: 10000 });
172
+ \`\`\`
173
+ Do NOT use \`page.waitForTimeout()\` with fixed delays — these are flaky in CI where container startup and network latency vary. Always prefer \`waitForLoadState\` or \`waitFor\` on a specific locator.
161
174
 
162
175
  **After generation, you MUST do exactly two things — nothing more, nothing less:**
163
176
  1. **Fix chaining**: replace hardcoded IDs with dynamic response values — path params like \`id = 'id'\` → \`skyramp.get_response_value(prev_response, "id")\`, and hardcoded IDs in request bodies → dynamic values from prior responses.
@@ -0,0 +1,14 @@
1
+ import { TOOL_PHASE_MAP } from "../tool-phases.js";
2
+ export function registerProgressResource(server) {
3
+ server.registerResource("progress_tool_phases", "skyramp://progress/tool-phases", {
4
+ title: "Tool Phase Mapping",
5
+ description: "Maps Skyramp MCP tool names to testbot progress phases.",
6
+ mimeType: "application/json",
7
+ }, () => ({
8
+ contents: [{
9
+ uri: "skyramp://progress/tool-phases",
10
+ mimeType: "application/json",
11
+ text: JSON.stringify(TOOL_PHASE_MAP),
12
+ }],
13
+ }));
14
+ }
@@ -0,0 +1,41 @@
1
+ import { TOOL_PHASE_MAP, TOOLS_WITHOUT_PHASE } from "./tool-phases.js";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ function findAllToolNames() {
5
+ const toolNames = [];
6
+ function walk(dir) {
7
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
8
+ const full = path.join(dir, entry.name);
9
+ if (entry.isDirectory()) {
10
+ walk(full);
11
+ continue;
12
+ }
13
+ if (!entry.name.endsWith(".ts"))
14
+ continue;
15
+ const content = fs.readFileSync(full, "utf-8");
16
+ const matches = content.matchAll(/const TOOL_NAME\s*=\s*["']([^"']+)["']/g);
17
+ for (const m of matches)
18
+ toolNames.push(m[1]);
19
+ }
20
+ }
21
+ walk(path.resolve(__dirname, "./tools"));
22
+ return toolNames;
23
+ }
24
+ describe("tool-phase-coverage", () => {
25
+ it("every registered tool has a phase or is explicitly excluded", () => {
26
+ const allTools = findAllToolNames();
27
+ expect(allTools.length).toBeGreaterThan(0);
28
+ const missing = allTools.filter(name => !(name in TOOL_PHASE_MAP) && !TOOLS_WITHOUT_PHASE.has(name));
29
+ if (missing.length > 0) {
30
+ throw new Error(`These tools are not in TOOL_PHASE_MAP or TOOLS_WITHOUT_PHASE in src/tool-phases.ts:\n` +
31
+ missing.map(n => ` - ${n}`).join("\n") +
32
+ `\nAdd them to the appropriate export in src/tool-phases.ts`);
33
+ }
34
+ });
35
+ it("TOOL_PHASE_MAP contains only valid phases", () => {
36
+ const validPhases = new Set(["analyzing", "generating", "executing", "maintaining", "reporting"]);
37
+ for (const [tool, phase] of Object.entries(TOOL_PHASE_MAP)) {
38
+ expect(validPhases.has(phase)).toBe(true);
39
+ }
40
+ });
41
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Canonical mapping of Skyramp MCP tool names to testbot progress phases.
3
+ *
4
+ * The testbot progress UI reads this map at runtime to know which tool calls
5
+ * correspond to which progress steps. When adding or renaming tools, update
6
+ * this map so the progress UI stays accurate.
7
+ *
8
+ * Tools not in this map must be listed in TOOLS_WITHOUT_PHASE.
9
+ *
10
+ * Phases: analyzing, generating, executing, maintaining, reporting
11
+ */
12
+ export const TOOL_PHASE_MAP = {
13
+ skyramp_recommend_tests: "analyzing",
14
+ skyramp_analyze_changes: "analyzing",
15
+ skyramp_smoke_test_generation: "generating",
16
+ skyramp_fuzz_test_generation: "generating",
17
+ skyramp_contract_test_generation: "generating",
18
+ skyramp_load_test_generation: "generating",
19
+ skyramp_integration_test_generation: "generating",
20
+ skyramp_e2e_test_generation: "generating",
21
+ skyramp_ui_test_generation: "generating",
22
+ skyramp_scenario_test_generation: "generating",
23
+ skyramp_mock_generation: "generating",
24
+ skyramp_execute_test: "executing",
25
+ skyramp_execute_tests: "executing",
26
+ skyramp_analyze_test_health: "maintaining",
27
+ skyramp_submit_report: "reporting",
28
+ };
29
+ /** Tools that intentionally have no progress phase (infrastructure/utility). */
30
+ export const TOOLS_WITHOUT_PHASE = new Set([
31
+ "skyramp_login",
32
+ "skyramp_logout",
33
+ "skyramp_initialize_workspace",
34
+ "skyramp_one_click_tool",
35
+ "skyramp_actions",
36
+ "skyramp_state_cleanup",
37
+ "skyramp_start_trace_collection",
38
+ "skyramp_stop_trace_collection",
39
+ "skyramp_fix_errors",
40
+ "skyramp_modularization",
41
+ "skyramp_reuse_code",
42
+ ]);
@@ -92,6 +92,15 @@ export class ContractTestService extends TestGenerationService {
92
92
  getTestType() {
93
93
  return TestType.CONTRACT;
94
94
  }
95
+ async handleApiAnalysis(params, generateOptions) {
96
+ // When apiSchema is provided, path parameters are resolved via parentRequestData
97
+ // provisioning — skip the base-class path-parameter validation prompt.
98
+ // Without apiSchema, fall through to normal validation.
99
+ if (params.apiSchema) {
100
+ return null;
101
+ }
102
+ return super.handleApiAnalysis(params, generateOptions);
103
+ }
95
104
  async generateTest(params) {
96
105
  const result = await super.generateTest(params);
97
106
  if (result.isError)
@@ -0,0 +1,52 @@
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 assertTool_exports = {};
20
+ __export(assertTool_exports, {
21
+ assertMcpTool: () => assertMcpTool,
22
+ assertToolSchema: () => assertToolSchema
23
+ });
24
+ module.exports = __toCommonJS(assertTool_exports);
25
+ var import_mcpBundle = require("playwright-core/lib/mcpBundle");
26
+ var import_tool = require("../sdk/tool");
27
+ const assertToolSchema = {
28
+ name: "browser_assert",
29
+ title: "Assert element state",
30
+ description: [
31
+ "Assert that an element has expected text, value, or is visible.",
32
+ "Use this after key actions to verify the page state is correct.",
33
+ "The assertion is recorded in the trace for test generation.",
34
+ 'Types: "text" checks element text content, "value" checks input field value.'
35
+ ].join(" "),
36
+ inputSchema: import_mcpBundle.z.object({
37
+ type: import_mcpBundle.z.enum(["text", "value"]).describe('Type of assertion: "text" for text content, "value" for input field value'),
38
+ ref: import_mcpBundle.z.string().describe("Element reference from the latest snapshot"),
39
+ element: import_mcpBundle.z.string().describe("Human-readable description of the element being asserted"),
40
+ expected: import_mcpBundle.z.string().describe("Expected text or value to assert"),
41
+ substring: import_mcpBundle.z.boolean().optional().default(true).describe("For text assertions: match as substring (true) or exact match (false)")
42
+ }),
43
+ type: "readOnly"
44
+ };
45
+ function assertMcpTool() {
46
+ return (0, import_tool.toMcpTool)(assertToolSchema);
47
+ }
48
+ // Annotate the CommonJS export names for ESM import in node:
49
+ 0 && (module.exports = {
50
+ assertMcpTool,
51
+ assertToolSchema
52
+ });
@@ -42,6 +42,7 @@ var import_response = require("../browser/response");
42
42
  var import_log = require("../log");
43
43
  var import_skyRampExport = require("../test/skyRampExport");
44
44
  var import_exportTool = require("./exportTool");
45
+ var import_assertTool = require("./assertTool");
45
46
  var import_types = require("./types");
46
47
  const traceDebug = (0, import_utilsBundle.debug)("pw:mcp:trace");
47
48
  class TraceRecordingBackend {
@@ -69,6 +70,7 @@ class TraceRecordingBackend {
69
70
  serviceWorkers: "block"
70
71
  }
71
72
  },
73
+ capabilities: ["testing"],
72
74
  outputDir: this._tempDir,
73
75
  timeouts: {
74
76
  action: 15e3,
@@ -84,7 +86,7 @@ class TraceRecordingBackend {
84
86
  }
85
87
  async listTools() {
86
88
  const browserTools = await this._browserBackend.listTools();
87
- return [...browserTools, (0, import_exportTool.exportZipMcpTool)()];
89
+ return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)()];
88
90
  }
89
91
  async callTool(name, args, progress) {
90
92
  if (!this._initialized)
@@ -102,6 +104,10 @@ class TraceRecordingBackend {
102
104
  });
103
105
  return handler(parsed);
104
106
  }
107
+ if (name === import_assertTool.assertToolSchema.name) {
108
+ const parsed = import_assertTool.assertToolSchema.inputSchema.parse(args || {});
109
+ return this._handleAssert(parsed);
110
+ }
105
111
  if (name === "browser_select_option") {
106
112
  const result2 = await this._handleSelectOption(args || {});
107
113
  return result2;
@@ -156,19 +162,24 @@ class TraceRecordingBackend {
156
162
  const preSnapResult = await this._browserBackend.callTool("browser_snapshot", {});
157
163
  if (preSnapResult.isError)
158
164
  return preSnapResult;
159
- const preSnapText = preSnapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
160
- const comboboxes = [...preSnapText.matchAll(/combobox[^\n]*\[ref=(\w+)\]/g)];
161
- const clickableCombo = comboboxes.find((m) => m[0].includes("cursor=pointer"));
162
- const comboRef = clickableCombo?.[1] || args.ref;
163
- const clickResult = await this._browserBackend.callTool("browser_click", { element: args.element || "dropdown", ref: comboRef });
164
- if (clickResult.isError)
165
- return { content: [{ type: "text", text: `### Error
165
+ let snapText = preSnapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
166
+ const dropdownAlreadyOpen = /listbox[^\n]*\[ref=\w+\]/.test(snapText) && /option\s+"/.test(snapText);
167
+ if (!dropdownAlreadyOpen) {
168
+ const comboboxes = [...snapText.matchAll(/combobox[^\n]*\[ref=(\w+)\]/g)];
169
+ const clickableCombo = comboboxes.find((m) => m[0].includes("cursor=pointer"));
170
+ const comboRef = clickableCombo?.[1] || args.ref;
171
+ const clickResult = await this._browserBackend.callTool("browser_click", { element: args.element || "dropdown", ref: comboRef });
172
+ if (clickResult.isError)
173
+ return { content: [{ type: "text", text: `### Error
166
174
  Failed to open custom dropdown. The dropdown modal may not be visible yet. Try calling browser_snapshot first to check the page state, then ensure the form/modal is open before selecting an option.` }], isError: true };
167
- this._maybeTrackAction("browser_click", { element: args.element || "dropdown", ref: comboRef }, clickResult);
168
- const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
169
- if (snapResult.isError)
170
- return snapResult;
171
- const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
175
+ this._maybeTrackAction("browser_click", { element: args.element || "dropdown", ref: comboRef }, clickResult);
176
+ const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
177
+ if (snapResult.isError)
178
+ return snapResult;
179
+ snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
180
+ } else {
181
+ traceDebug("Dropdown already open, skipping combobox click");
182
+ }
172
183
  const targetValue = values[0];
173
184
  const candidates = [targetValue];
174
185
  const lower = targetValue.toLowerCase();
@@ -196,12 +207,170 @@ Failed to open custom dropdown. The dropdown modal may not be visible yet. Try c
196
207
  Opened the custom dropdown but could not find a matching option for "${targetValue}". Available options:
197
208
  ${optionLines}` }], isError: true };
198
209
  }
210
+ /**
211
+ * Handle browser_assert by using browser_hover to resolve the ref to a
212
+ * proper Playwright selector (testid > role > text), then verify the
213
+ * assertion against the snapshot data.
214
+ */
215
+ async _handleAssert(params) {
216
+ const timestamp = Date.now();
217
+ if (!params.expected)
218
+ return { content: [{ type: "text", text: '### Error\n"expected" parameter is required.' }], isError: true };
219
+ const hoverResult = await this._browserBackend.callTool("browser_hover", {
220
+ element: params.element,
221
+ ref: params.ref
222
+ });
223
+ if (hoverResult.isError) {
224
+ const errText = hoverResult.content?.[0]?.type === "text" ? hoverResult.content[0].text : "";
225
+ return { content: [{ type: "text", text: `### Assertion Failed
226
+ Could not resolve element ref=${params.ref}. ${errText}` }], isError: true };
227
+ }
228
+ const hoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? "";
229
+ const locatorMatch = hoverCode.match(/await\s+page\.(.*?)\.hover\(\)/s);
230
+ if (!locatorMatch) {
231
+ return { content: [{ type: "text", text: `### Assertion Failed
232
+ Could not extract selector from hover result.` }], isError: true };
233
+ }
234
+ const locatorExpr = locatorMatch[1].trim();
235
+ const parsed = this._codeToLocator(locatorExpr);
236
+ if (!parsed) {
237
+ return { content: [{ type: "text", text: `### Assertion Failed
238
+ Could not parse locator: ${locatorExpr}` }], isError: true };
239
+ }
240
+ const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
241
+ const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
242
+ const refLine = snapText.split("\n").find((l) => l.includes(`[ref=${params.ref}]`)) || "";
243
+ const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
244
+ const elementText = textMatch?.[1] || "";
245
+ const valueMatch = refLine.match(/\]:\s*(.+)$/);
246
+ const elementValue = valueMatch?.[1]?.trim() || "";
247
+ let passed = false;
248
+ let details = "";
249
+ if (params.type === "text") {
250
+ passed = elementText.includes(params.expected) || elementValue.includes(params.expected);
251
+ details = passed ? `Text assertion passed: "${params.element}" contains "${params.expected}".` : `Text assertion FAILED: "${params.element}" has text "${elementText}"${elementValue ? ` / value "${elementValue}"` : ""}, expected "${params.expected}".`;
252
+ } else {
253
+ passed = elementValue === params.expected || elementValue.includes(params.expected);
254
+ details = passed ? `Value assertion passed: "${params.element}" has value "${params.expected}".` : `Value assertion FAILED: "${params.element}" has value "${elementValue}", expected "${params.expected}".`;
255
+ }
256
+ if (passed) {
257
+ const assertName = params.type === "value" ? "assertValue" : "assertText";
258
+ this._trackedActions.push({
259
+ toolName: "browser_assert",
260
+ args: { type: params.type, ref: params.ref, expected: params.expected },
261
+ code: `${assertName}:${parsed.selector}:${params.expected}${params.type === "text" ? ":" + params.substring : ""}`,
262
+ timestamp
263
+ });
264
+ traceDebug(`Assert: ${assertName} with selector ${parsed.selector}`);
265
+ }
266
+ return {
267
+ content: [{ type: "text", text: `### ${passed ? "Assertion Passed" : "Assertion Failed"}
268
+ ${details}` }]
269
+ };
270
+ }
271
+ /** Convert a Playwright locator expression to a selector + locator object for JSONL. */
272
+ _codeToLocator(expr) {
273
+ const testidMatch = expr.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/);
274
+ if (testidMatch) {
275
+ return {
276
+ selector: `internal:testid=[data-testid="${testidMatch[1]}"s]`,
277
+ locator: { kind: "test-id", body: testidMatch[1], options: {} }
278
+ };
279
+ }
280
+ const roleMatch = expr.match(/getByRole\(\s*['"]([^'"]+)['"](?:\s*,\s*\{[^}]*name:\s*['"]([^'"]+)['"][^}]*\})?\s*\)/);
281
+ if (roleMatch) {
282
+ return {
283
+ selector: roleMatch[2] ? `internal:role=${roleMatch[1]}[name="${roleMatch[2]}"i]` : `internal:role=${roleMatch[1]}`,
284
+ locator: { kind: "role", body: roleMatch[1], options: { attrs: [], exact: false, ...roleMatch[2] ? { name: roleMatch[2] } : {} } }
285
+ };
286
+ }
287
+ const labelMatch = expr.match(/getByLabel\(\s*['"]([^'"]+)['"]\s*\)/);
288
+ if (labelMatch) {
289
+ return {
290
+ selector: `internal:label="${labelMatch[1]}"i`,
291
+ locator: { kind: "label", body: labelMatch[1], options: { exact: false } }
292
+ };
293
+ }
294
+ const textMatch = expr.match(/getByText\(\s*['"]([^'"]+)['"]\s*\)/);
295
+ if (textMatch) {
296
+ return {
297
+ selector: `internal:text="${textMatch[1]}"i`,
298
+ locator: { kind: "text", body: textMatch[1], options: { exact: false } }
299
+ };
300
+ }
301
+ const ariaRefMatch = expr.match(/locator\(\s*['"]aria-ref=(\w+)['"]\s*\)/);
302
+ if (ariaRefMatch) {
303
+ return null;
304
+ }
305
+ return null;
306
+ }
307
+ static {
308
+ /** Extract selector and locator info from a snapshot line for assertion tracking. */
309
+ // Roles that map to valid Playwright getByRole() selectors.
310
+ // 'generic', 'paragraph', etc. are NOT valid for getByRole.
311
+ this.ASSERTABLE_ROLES = /* @__PURE__ */ new Set([
312
+ "button",
313
+ "link",
314
+ "heading",
315
+ "textbox",
316
+ "checkbox",
317
+ "radio",
318
+ "combobox",
319
+ "listbox",
320
+ "option",
321
+ "tab",
322
+ "tabpanel",
323
+ "dialog",
324
+ "alert",
325
+ "img",
326
+ "navigation",
327
+ "banner",
328
+ "main",
329
+ "form",
330
+ "table",
331
+ "row",
332
+ "cell",
333
+ "columnheader",
334
+ "rowheader",
335
+ "spinbutton",
336
+ "slider",
337
+ "switch",
338
+ "menu",
339
+ "menuitem"
340
+ ]);
341
+ }
342
+ _extractLocatorForRef(refLine) {
343
+ const testidMatch = refLine.match(/testid="([^"]+)"/);
344
+ if (testidMatch) {
345
+ return {
346
+ selector: `internal:testid=[data-testid="${testidMatch[1]}"s]`,
347
+ locator: { kind: "test-id", body: testidMatch[1], options: {} }
348
+ };
349
+ }
350
+ const roleMatch = refLine.match(/^\s*-\s*(\w+)\s+"([^"]*)"/);
351
+ if (roleMatch && TraceRecordingBackend.ASSERTABLE_ROLES.has(roleMatch[1])) {
352
+ const role = roleMatch[1];
353
+ const name = roleMatch[2];
354
+ return {
355
+ selector: `internal:role=${role}[name="${name}"i]`,
356
+ locator: { kind: "role", body: role, options: { attrs: [], exact: false, name } }
357
+ };
358
+ }
359
+ if (roleMatch && roleMatch[2]) {
360
+ const text = roleMatch[2];
361
+ return {
362
+ selector: `internal:text="${text}"i`,
363
+ locator: { kind: "text", body: text, options: { exact: false } }
364
+ };
365
+ }
366
+ return null;
367
+ }
199
368
  _maybeTrackAction(toolName, args, result, timestamp) {
200
369
  if (result.isError)
201
370
  return;
202
371
  if (toolName === "browser_press_key") {
203
372
  const key = String(args.key || "").toLowerCase();
204
- if (key.includes("control+") || key.includes("meta+"))
373
+ if (key.includes("control+") || key.includes("meta+") || key === "tab" || key === "enter" || key === "escape")
205
374
  return;
206
375
  }
207
376
  const parsed = (0, import_response.parseResponse)(result);
@@ -229,6 +229,58 @@ function trackedActionToJsonl(action, pageGuid, timestamp) {
229
229
  return JSON.stringify({ name: "click", selector, button: "left", modifiers: 0, clickCount: 1, locator: locatorObj, ...base });
230
230
  return null;
231
231
  }
232
+ function assertActionToJsonl(action, pageGuid, timestamp) {
233
+ const { args, code } = action;
234
+ const assertType = args.type;
235
+ const base = {
236
+ signals: [],
237
+ timestamp: String(timestamp),
238
+ pageGuid,
239
+ pageAlias: PAGE_ALIAS,
240
+ framePath: FRAME_PATH
241
+ };
242
+ const parts = code.split(":");
243
+ const selectorPart = parts[1] || "";
244
+ let selector = "";
245
+ let expected = "";
246
+ let substring = true;
247
+ if (assertType === "visible") {
248
+ selector = parts.slice(1).join(":");
249
+ } else if (assertType === "text") {
250
+ substring = parts[parts.length - 1] === "true";
251
+ expected = parts[parts.length - 2] || "";
252
+ selector = parts.slice(1, parts.length - 2).join(":");
253
+ } else if (assertType === "value") {
254
+ expected = parts[parts.length - 1] || "";
255
+ selector = parts.slice(1, parts.length - 1).join(":");
256
+ }
257
+ const locator = selectorToLocator(selector);
258
+ switch (assertType) {
259
+ case "text":
260
+ return JSON.stringify({ name: "assertText", selector, text: expected, substring, locator, ...base });
261
+ case "visible":
262
+ return JSON.stringify({ name: "assertVisible", selector, locator, ...base });
263
+ case "value":
264
+ return JSON.stringify({ name: "assertValue", selector, value: expected, locator, ...base });
265
+ default:
266
+ return null;
267
+ }
268
+ }
269
+ function selectorToLocator(selector) {
270
+ const testidMatch = selector.match(/internal:testid=\[data-testid="([^"]+)"/);
271
+ if (testidMatch)
272
+ return { kind: "test-id", body: testidMatch[1], options: {} };
273
+ const roleMatch = selector.match(/internal:role=(\w+)\[name="([^"]+)"([is])?\]/);
274
+ if (roleMatch)
275
+ return { kind: "role", body: roleMatch[1], options: { attrs: [], exact: roleMatch[3] === "s", name: roleMatch[2] } };
276
+ const roleOnlyMatch = selector.match(/internal:role=(\w+)$/);
277
+ if (roleOnlyMatch)
278
+ return { kind: "role", body: roleOnlyMatch[1], options: { attrs: [] } };
279
+ const textMatch = selector.match(/internal:text="([^"]+)"([is])?/);
280
+ if (textMatch)
281
+ return { kind: "text", body: textMatch[1], options: { exact: textMatch[2] === "s" } };
282
+ return {};
283
+ }
232
284
  function fillFormToJsonl(action, pageGuid, baseTimestamp) {
233
285
  const { args } = action;
234
286
  if (!args.fields)
@@ -280,6 +332,14 @@ function buildJsonlContent(actions, browserName, harPath) {
280
332
  actionCount += formLines.length;
281
333
  continue;
282
334
  }
335
+ if (action.toolName === "browser_assert") {
336
+ const assertLine = assertActionToJsonl(action, pageGuid, action.timestamp);
337
+ if (assertLine) {
338
+ lines.push(assertLine);
339
+ actionCount++;
340
+ }
341
+ continue;
342
+ }
283
343
  const line = trackedActionToJsonl(action, pageGuid, action.timestamp);
284
344
  if (line) {
285
345
  lines.push(line);
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.0.64-rc.7",
3
+ "version": "0.0.64-rc.9",
4
4
  "main": "build/index.js",
5
+ "exports": {
6
+ ".": "./build/index.js",
7
+ "./tool-phases": "./build/tool-phases.js",
8
+ "./build/*": "./build/*"
9
+ },
5
10
  "type": "module",
6
11
  "bin": {
7
12
  "mcp": "./build/index.js"