@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 +2 -0
- package/build/playwright/registerPlaywrightTools.js +1 -1
- package/build/playwright/traceRecordingPrompt.js +9 -3
- package/build/prompts/testbot/testbot-prompts.js +23 -10
- package/build/resources/progressResource.js +14 -0
- package/build/tool-phase-coverage.test.js +41 -0
- package/build/tool-phases.js +42 -0
- package/build/tools/generate-tests/generateContractRestTool.js +9 -0
- package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +52 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +183 -14
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +60 -0
- package/package.json +6 -1
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\`,
|
|
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
|
|
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
|
-
`
|
|
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\`, \`
|
|
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. \`
|
|
137
|
-
|
|
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
|
|
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
|
|
158
|
-
await page.
|
|
165
|
+
// Wait for the page to fully load and hydrate before interacting
|
|
166
|
+
await page.waitForLoadState('networkidle');
|
|
159
167
|
\`\`\`
|
|
160
|
-
|
|
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
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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.
|
|
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"
|