@skyramp/mcp 0.0.65 → 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) 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/test-recommendation-prompt.js +25 -4
  7. package/build/prompts/testbot/testbot-prompts.js +87 -97
  8. package/build/prompts/testbot/testbot-prompts.test.js +142 -0
  9. package/build/services/ScenarioGenerationService.js +2 -2
  10. package/build/services/ScenarioGenerationService.test.js +35 -0
  11. package/build/services/TestExecutionService.js +1 -1
  12. package/build/tools/code-refactor/modularizationTool.js +2 -2
  13. package/build/tools/executeSkyrampTestTool.js +4 -3
  14. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +49 -20
  15. package/build/tools/generate-tests/generateContractRestTool.js +26 -4
  16. package/build/tools/generate-tests/generateIntegrationRestTool.js +44 -13
  17. package/build/tools/generate-tests/generateScenarioRestTool.js +17 -39
  18. package/build/tools/generate-tests/generateUIRestTool.js +69 -4
  19. package/build/tools/submitReportTool.js +17 -12
  20. package/build/tools/test-management/analyzeChangesTool.js +8 -3
  21. package/build/tools/test-management/analyzeChangesTool.test.js +85 -0
  22. package/build/types/TestTypes.js +16 -7
  23. package/build/utils/AnalysisStateManager.js +13 -5
  24. package/build/utils/AnalysisStateManager.test.js +35 -0
  25. package/node_modules/playwright/lib/mcp/browser/browserServerBackend.js +3 -0
  26. package/node_modules/playwright/lib/mcp/browser/tab.js +8 -1
  27. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -2
  28. package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
  29. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +4 -4
  30. package/node_modules/playwright/lib/mcp/browser/tools/tabs.js +5 -4
  31. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  32. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -9
  33. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +304 -7
  34. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +128 -20
  35. package/package.json +2 -2
  36. package/node_modules/playwright/lib/mcp/terminal/help.json +0 -32
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { AnalyticsService } from "../../services/AnalyticsService.js";
4
4
  import { TestGenerationService, } from "../../services/TestGenerationService.js";
5
5
  import { normalizeLanguageParams, resolveParamAliases, } from "../../utils/normalizeParams.js";
6
+ import { getPersonaPrefix } from "../../prompts/architectPersona.js";
6
7
  const TOOL_NAME = "skyramp_ui_test_generation";
7
8
  export class UITestService extends TestGenerationService {
8
9
  getTestType() {
@@ -18,6 +19,53 @@ export class UITestService extends TestGenerationService {
18
19
  async handleApiAnalysis(params, generateOptions) {
19
20
  return null;
20
21
  }
22
+ async generateTest(params) {
23
+ const result = await super.generateTest(params);
24
+ if (result.isError)
25
+ return result;
26
+ const content = [...result.content];
27
+ content.push({
28
+ type: "text",
29
+ text: this.buildUIAssertionInstructions(),
30
+ });
31
+ return { ...result, content };
32
+ }
33
+ buildUIAssertionInstructions() {
34
+ return `
35
+ ⏭️ **CRITICAL NEXT STEP — Review and fix assertions in the generated UI test:**
36
+
37
+ After generating a UI test from a recorded trace, you MUST review and fix assertions to catch real app bugs — not just replay what happened.
38
+
39
+ **Process:**
40
+ 1. **Replay the scenario mentally**: At each state-changing action (form submit, item delete/add/edit), ask: "What is the EXPECTED outcome based on the action performed?"
41
+ 2. **Identify expectation mismatches**: If the recorded trace shows a result that contradicts the action (e.g., removing 1 item from 2 but the page shows 3 items, submitting a form but getting a blank page, editing a field but the old value persists), this is an app bug the test should catch.
42
+ 3. **Fix or add assertions**: For each mismatch:
43
+ - If the generated test has an assertion using the WRONG (buggy) value, edit it to assert the CORRECT expected value.
44
+ - If no assertion exists for the buggy behavior, ADD one immediately after the action that triggers it.
45
+ - Use \`toContainText\`, \`toHaveText\`, or \`toBeVisible\`/\`toBeHidden\` as appropriate.
46
+ - **CRITICAL**: Only use selectors that already appear in the generated test file. Do NOT invent new data-testid values or guess aria attributes. If no suitable selector exists, call \`browser_assert\` on the live page to record one, then re-export and regenerate.
47
+
48
+ **Strategic assertion placement — call at key checkpoints only (3–5 per test):**
49
+ - **After the main action completes**: verify the outcome is visible (new item appears, form saves, confirmation shows)
50
+ - **State transitions**: verify counts, totals, or status fields update correctly after add/remove/edit
51
+ - **Navigation results**: verify you landed on the right page after a redirect
52
+ - **List integrity after form save**: after any form submit that modifies a list (order items, cart), assert the item count is unchanged unless the action explicitly added or removed items — catches duplication bugs
53
+
54
+ **Common bug patterns to assert against:**
55
+ - Item count not updating after add/remove
56
+ - Form values not persisting after save
57
+ - Page crashes or blank renders after navigation
58
+ - Stale data showing after state changes
59
+
60
+ **What NOT to assert:**
61
+ - Static page headings or boilerplate labels
62
+ - Intermediate states (typing, dropdown opening)
63
+ - Values already guaranteed by the action you just took
64
+ - The same value with multiple selectors
65
+
66
+ The goal is tests that FAIL when the app has bugs, not tests that simply replay what happened.
67
+ `;
68
+ }
21
69
  }
22
70
  // Only include the original params in the schema
23
71
  const uiTestSchema = {
@@ -39,10 +87,27 @@ const uiTestSchema = {
39
87
  };
40
88
  export function registerUITestTool(server) {
41
89
  server.registerTool(TOOL_NAME, {
42
- description: `Generate a UI test using Skyramp's deterministic test generation platform.
43
-
44
- UI tests validate user interface functionality by simulating real user interactions with your web application. They test user workflows, form submissions, navigation, responsive design, and ensure that your frontend works correctly across different browsers and devices. UI tests use Playwright recordings as input to generate comprehensive test suites that replay user interactions, validate UI elements, and verify expected behaviors in browser environments.
45
- **CRITICAL: To collect a Playwright trace, use the browser_* tools (browser_navigate, browser_click, browser_type, etc.) to interact with the application, then call skyramp_export_zip to export the trace zip. Do NOT use skyramp_start_trace_collection/skyramp_stop_trace_collection.**`,
90
+ description: `${getPersonaPrefix()}Before calling this tool, you MUST output a <thinking> block that covers:
91
+ 1. The user-facing flow(s) captured in the Playwright trace (pages visited, actions taken)
92
+ 2. Why a UI test (Playwright-based interaction replay) is the right choice for this intent
93
+ 3. Which assertions this test should validate (page content, element state, navigation results)
94
+ 4. The absolute path to the trace zip and the output directory, with source confirmation
95
+ If the trace zip path cannot be confirmed as an absolute path to an existing file, STOP and re-export before calling this tool.
96
+
97
+ ---
98
+
99
+ Generate a UI test using Skyramp's deterministic test generation platform.
100
+
101
+ UI tests validate user interface functionality by simulating real user interactions with your web application. They test user workflows, form submissions, navigation, and ensure that your frontend works correctly across different browsers. UI tests use Playwright recordings as input to generate comprehensive test suites that replay user interactions, validate UI elements, and verify expected behaviors.
102
+
103
+ **Dynamic context (use this before recording):**
104
+ If \`skyramp_analyze_changes\` has already run and returned a \`sessionId\`, check the diff summary before deciding which flows to record:
105
+ \`skyramp://analysis/{sessionId}/diff\`
106
+ This tells you exactly which frontend files changed so you record traces for the right user flows — not just any page.
107
+
108
+ **Typical pipeline:** Use the \`browser_*\` tools (\`browser_navigate\`, \`browser_click\`, \`browser_type\`, etc.) to record user interactions, then call \`skyramp_export_zip\` to export a trace zip, then pass the absolute path to that zip as \`playwrightInput\` here.
109
+
110
+ **CRITICAL: Do NOT use skyramp_start_trace_collection/skyramp_stop_trace_collection for UI test recording — use browser_* tools + skyramp_export_zip instead.**`,
46
111
  inputSchema: uiTestSchema,
47
112
  _meta: {
48
113
  keywords: ["ui test", "playwright"],
@@ -4,17 +4,18 @@ import * as fs from "fs/promises";
4
4
  import * as path from "path";
5
5
  import { AnalyticsService } from "../services/AnalyticsService.js";
6
6
  import { TEST_CATEGORIES, externalCategory } from "../types/TestRecommendation.js";
7
+ import { TestType, HttpMethod } from "../types/TestTypes.js";
7
8
  const TOOL_NAME = "skyramp_submit_report";
8
9
  const DEFAULT_COMMIT_MESSAGE = "Added recommendations by Skyramp Testbot.";
9
10
  const testResultSchema = z.object({
10
- testType: z.string().describe("Type of test: Smoke, Contract, Integration, E2E, Load, etc. Do not include priority or other metadata in this field."),
11
+ testType: z.nativeEnum(TestType).describe("Type of test. Do not include priority or other metadata in this field."),
11
12
  endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
12
13
  status: z.enum(["Pass", "Fail", "Skipped"]).describe("Test execution result"),
13
- details: z.string().describe("Execution time and test file name, e.g. '10.8s, products_smoke_test.py'"),
14
+ details: z.string().describe("One sentence no embedded newlines, no markdown. e.g. '10.8s, products_contract_test.py' or 'failed: <one-line error summary>, products_contract_test.py'"),
14
15
  });
15
16
  const newTestSchema = z.object({
16
17
  testId: z.string().describe("Human-readable kebab-case identifier, e.g. 'contract-get-products' or 'integration-users-orders-workflow'. Format: '<testType>-<method>-<resource>' for single-endpoint tests or '<testType>-<scenario-slug>' for multi-step tests. Must be unique within the report."),
17
- testType: z.string().describe("Type of test created: Smoke, Contract, Integration, etc. Do not include priority or other metadata in this field."),
18
+ testType: z.nativeEnum(TestType).describe("Type of test created. Do not include priority or other metadata in this field."),
18
19
  category: z.preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES)).describe("Test category — critical categories (security_boundary, business_rule, data_integrity, breaking_change) get generation priority over workflow"),
19
20
  endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
20
21
  fileName: z.string().describe("Name of the generated test file"),
@@ -25,7 +26,7 @@ const newTestSchema = z.object({
25
26
  reasoning: z.string().describe("Why this test was created: what production risk it mitigates, what code pattern it targets, or what coverage gap it fills"),
26
27
  });
27
28
  const descriptionSchema = z.object({
28
- description: z.string().describe("One-line description. Do NOT prefix with the severity level — severity is a separate field."),
29
+ description: z.string().describe("One-line description. Do NOT prefix with the severity level — severity is a separate field. Include code logic bugs from the diff, test generation/execution failures, and environment misconfiguration."),
29
30
  severity: z
30
31
  .enum(["critical", "high", "medium", "low"])
31
32
  .optional()
@@ -34,7 +35,7 @@ const descriptionSchema = z.object({
34
35
  "medium = minor functional gap. low = cosmetic or informational."),
35
36
  });
36
37
  const scenarioStepSchema = z.object({
37
- method: z.string().optional().describe("HTTP method (e.g. 'POST', 'GET'). Required for API steps, omit for UI/E2E actions."),
38
+ method: z.nativeEnum(HttpMethod).optional().describe("HTTP method. Required for API steps, omit for UI/E2E actions."),
38
39
  path: z.string().optional().describe("Endpoint or page path (e.g. '/api/v1/products' or '/products'). Required for API steps, omit for UI actions."),
39
40
  description: z.string().describe("What this step does, e.g. 'Create a product' or 'Click checkout button and verify confirmation'"),
40
41
  expectedStatusCode: z.number().optional().describe("Expected HTTP status code, e.g. 200, 201, 404"),
@@ -43,10 +44,11 @@ const scenarioStepSchema = z.object({
43
44
  });
44
45
  const additionalRecommendationSchema = z.object({
45
46
  testId: z.string().describe("Human-readable kebab-case identifier, e.g. 'integration-products-orders-workflow' or 'e2e-checkout-flow'. Format: '<testType>-<scenario-slug>'. Must be unique within the report."),
46
- testType: z.string().describe("Type of test: Integration, E2E, Contract, UI, etc. Do not include priority or other metadata in this field."),
47
+ testType: z.nativeEnum(TestType).describe("Type of test. Do not include priority or other metadata in this field."),
47
48
  category: z.preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES)).describe("Test category — critical categories get generation priority over workflow"),
48
49
  scenarioName: z.string().describe("Name of the scenario, e.g. 'products_orders_workflow'"),
49
- steps: z.array(scenarioStepSchema).describe("Ordered sequence of API/UI steps in this test scenario"),
50
+ // TODO: replace text with max(3) and check for regression
51
+ steps: z.array(scenarioStepSchema).describe("Ordered sequence of API/UI steps in this test scenario (at most 3). Omit requestBody and responseBody from steps. Include at most 3 steps per recommendation."),
50
52
  description: z.string().describe("Why this test is valuable and what it would cover"),
51
53
  priority: z.preprocess((val) => (typeof val === "string" ? val.toLowerCase() : val), z.enum(["high", "medium", "low"])).describe("Priority level: high, medium, or low. First check diff relevance — does the test target an endpoint changed in this PR? HIGH: diff-relevant security/auth/error tests, cross-resource isolation for diff endpoints, CRUD lifecycle for NEW endpoints in the diff. MEDIUM: diff-relevant business-rule happy paths, multi-resource workflows involving diff endpoints, security/error tests for NON-diff endpoints. LOW: tests targeting only unchanged endpoints, trivially discoverable happy paths duplicating generated tests."),
52
54
  openApiSpec: z.string().optional().describe("Path to OpenAPI/Swagger spec file if available, e.g. 'openapi.yaml'"),
@@ -55,7 +57,7 @@ const additionalRecommendationSchema = z.object({
55
57
  reasoning: z.string().describe("Why this test is recommended: the specific production risk, business rule, or security boundary it would validate"),
56
58
  });
57
59
  const testMaintenanceSchema = z.object({
58
- testType: z.string().describe("Type of test: Contract, Integration, UI, etc."),
60
+ testType: z.nativeEnum(TestType).describe("Type of test."),
59
61
  endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
60
62
  fileName: z.string().describe("Test file that was maintained, e.g. 'products_smoke_test.py'"),
61
63
  description: z.string().describe("What was changed and why"),
@@ -74,7 +76,7 @@ export function registerSubmitReportTool(server) {
74
76
  .describe("The file path where the report should be written (provided in the task instructions)"),
75
77
  businessCaseAnalysis: z
76
78
  .string()
77
- .describe("2-3 sentence business justification for this PR"),
79
+ .describe("1-2 sentences describing what user-facing interactions this PR enables or changes (e.g. 'customers can now leave and view product reviews'). Focus on the user journey, not technical implementation. Flag backend-only or frontend-only gaps."),
78
80
  newTestsCreated: z
79
81
  .array(newTestSchema)
80
82
  .describe("List of new tests created. Use empty array [] if none."),
@@ -82,7 +84,7 @@ export function registerSubmitReportTool(server) {
82
84
  .array(additionalRecommendationSchema)
83
85
  .optional()
84
86
  .default([])
85
- .describe("Recommended tests that were not generated (lower priority). Include the remaining recommendations from skyramp_recommend_tests that were not implemented."),
87
+ .describe("Recommended tests that were not generated (lower priority). Only include recommendations that add distinct coverage beyond generated tests — do not pad with variants testing the same endpoint and flow."),
86
88
  testMaintenance: z
87
89
  .array(testMaintenanceSchema)
88
90
  .describe("List of existing test modifications with before/after execution results. Use empty array [] if none."),
@@ -96,8 +98,11 @@ export function registerSubmitReportTool(server) {
96
98
  .array(z.string())
97
99
  .optional()
98
100
  .default([])
99
- .describe("Actionable next steps for the user. Populate when test failures suggest misconfiguration " +
100
- "(e.g. 404s on endpoints that exist in the diff check targetSetupCommand)."),
101
+ .describe("Actionable follow-ups for the PR author. Each entry must be a single-line string (no embedded newlines). " +
102
+ "Include a next step for every critical/high severity issue in issuesFound. No next steps for low-severity issues. " +
103
+ "If multiple tests fail with 404 or connection refused: suggest checking targetSetupCommand/targetReadyCheckCommand. " +
104
+ "If 401/403 on auth endpoints: suggest authTokenCommand. " +
105
+ "When referencing code, use file name and relevant code pattern — no line numbers unless certain."),
101
106
  commitMessage: z
102
107
  .string()
103
108
  .optional()
@@ -150,7 +150,7 @@ const NON_APP_PATTERNS = [
150
150
  function isNonApplicationFile(filePath) {
151
151
  return NON_APP_PATTERNS.some((p) => p.test(filePath));
152
152
  }
153
- const analyzeChangesSchema = {
153
+ export const analyzeChangesInputSchema = {
154
154
  repositoryPath: z
155
155
  .string()
156
156
  .describe("Absolute path to the repository root"),
@@ -182,6 +182,11 @@ const analyzeChangesSchema = {
182
182
  .number()
183
183
  .optional()
184
184
  .describe("GitHub PR number. When provided, fetches previous TestBot comments for recommendation deduplication across commits."),
185
+ stateOutputFile: z
186
+ .string()
187
+ .refine((v) => path.isAbsolute(v), { message: "stateOutputFile must be an absolute path" })
188
+ .optional()
189
+ .describe("Absolute path where the state file should be written. When provided, overrides the default auto-generated temp path so the caller can locate it without log parsing."),
185
190
  };
186
191
  export function registerAnalyzeChangesTool(server) {
187
192
  server.registerTool(TOOL_NAME, {
@@ -197,7 +202,7 @@ to produce a unified state file for the test health workflow.
197
202
  4. Call \`skyramp_actions\` with stateFile → execute UPDATE/REGENERATE/ADD recommendations
198
203
 
199
204
  **Output:** stateFile path + LLM instructions for enrichment and calling skyramp_analyze_test_health`,
200
- inputSchema: analyzeChangesSchema,
205
+ inputSchema: analyzeChangesInputSchema,
201
206
  }, async (params, extra) => {
202
207
  let errorResult;
203
208
  const sendProgress = async (progress, total, message) => {
@@ -660,7 +665,7 @@ to produce a unified state file for the test health workflow.
660
665
  sessionId, // expose sessionId for optional skyramp_recommend_tests call
661
666
  },
662
667
  };
663
- const stateManager = new StateManager("analysis", sessionId);
668
+ const stateManager = new StateManager("analysis", sessionId, undefined, params.stateOutputFile);
664
669
  await stateManager.writeData(unifiedState, {
665
670
  repositoryPath: params.repositoryPath,
666
671
  step: "analyze_changes",
@@ -0,0 +1,85 @@
1
+ // Mock all heavy dependencies so the module can be loaded in isolation
2
+ jest.mock("@skyramp/skyramp", () => ({}));
3
+ jest.mock("simple-git", () => ({ simpleGit: jest.fn() }));
4
+ jest.mock("../../services/AnalyticsService.js", () => ({
5
+ AnalyticsService: { pushMCPToolEvent: jest.fn() },
6
+ }));
7
+ jest.mock("../../prompts/test-recommendation/test-recommendation-prompt.js", () => ({
8
+ buildRecommendationPrompt: jest.fn(),
9
+ }));
10
+ jest.mock("../../prompts/test-recommendation/recommendationSections.js", () => ({
11
+ MAX_RECOMMENDATIONS: 10,
12
+ MAX_TESTS_TO_GENERATE: 3,
13
+ }));
14
+ jest.mock("../../prompts/test-recommendation/analysisOutputPrompt.js", () => ({
15
+ buildAnalysisOutputText: jest.fn(),
16
+ }));
17
+ jest.mock("../../services/TestDiscoveryService.js", () => ({
18
+ TestDiscoveryService: jest.fn(),
19
+ }));
20
+ jest.mock("../../utils/branchDiff.js", () => ({
21
+ computeBranchDiff: jest.fn(),
22
+ }));
23
+ jest.mock("../../utils/routeParsers.js", () => ({
24
+ parseEndpointsFromDiff: jest.fn(),
25
+ }));
26
+ jest.mock("../../utils/repoScanner.js", () => ({
27
+ scanAllRepoEndpoints: jest.fn(),
28
+ scanRelatedEndpoints: jest.fn(),
29
+ grepRouterMountingContext: jest.fn(),
30
+ }));
31
+ jest.mock("../../utils/projectMetadata.js", () => ({
32
+ detectProjectMetadata: jest.fn(),
33
+ }));
34
+ jest.mock("../../utils/scenarioDrafting.js", () => ({
35
+ draftScenariosFromEndpoints: jest.fn(),
36
+ }));
37
+ jest.mock("../../utils/trace-parser.js", () => ({
38
+ parseTraceFile: jest.fn(),
39
+ discoverTraceFiles: jest.fn(),
40
+ discoverPlaywrightZips: jest.fn(),
41
+ }));
42
+ jest.mock("../../utils/pr-comment-parser.js", () => ({
43
+ parsePRComments: jest.fn(),
44
+ }));
45
+ jest.mock("../../utils/AnalysisStateManager.js", () => ({
46
+ StateManager: jest.fn(),
47
+ registerSession: jest.fn(),
48
+ storeSessionData: jest.fn(),
49
+ }));
50
+ jest.mock("../../utils/workspaceAuth.js", () => ({
51
+ parseWorkspaceAuthType: jest.fn(),
52
+ }));
53
+ jest.mock("../../utils/logger.js", () => ({
54
+ logger: { info: jest.fn(), debug: jest.fn(), error: jest.fn(), warn: jest.fn() },
55
+ }));
56
+ jest.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({
57
+ McpServer: jest.fn(),
58
+ }));
59
+ jest.mock("@modelcontextprotocol/sdk/types.js", () => ({}));
60
+ jest.mock("@modelcontextprotocol/sdk/shared/protocol.js", () => ({}));
61
+ import { z } from "zod";
62
+ import { analyzeChangesInputSchema } from "./analyzeChangesTool.js";
63
+ const schema = z.object(analyzeChangesInputSchema);
64
+ describe("analyzeChangesInputSchema — stateOutputFile validation", () => {
65
+ it("accepts a valid absolute path", () => {
66
+ const result = schema.safeParse({
67
+ repositoryPath: "/repo",
68
+ stateOutputFile: "/tmp/analyze-changes-state.json",
69
+ });
70
+ expect(result.success).toBe(true);
71
+ });
72
+ it("rejects a relative path for stateOutputFile", () => {
73
+ // stateOutputFile must be absolute so the caller can guarantee the file location.
74
+ // Relative paths are silently ambiguous and should be rejected.
75
+ const result = schema.safeParse({
76
+ repositoryPath: "/repo",
77
+ stateOutputFile: "relative/path/state.json",
78
+ });
79
+ expect(result.success).toBe(false);
80
+ });
81
+ it("accepts absence of stateOutputFile (optional field)", () => {
82
+ const result = schema.safeParse({ repositoryPath: "/repo" });
83
+ expect(result.success).toBe(true);
84
+ });
85
+ });
@@ -1,6 +1,13 @@
1
1
  import { z } from "zod";
2
2
  export const SESSION_STORAGE_FILENAME = "skyramp_session_storage.json";
3
3
  export const AUTH_PLACEHOLDER_TOKEN = "SKYRAMP_PLACEHOLDER_TOKEN";
4
+ export var ProgrammingLanguage;
5
+ (function (ProgrammingLanguage) {
6
+ ProgrammingLanguage["PYTHON"] = "python";
7
+ ProgrammingLanguage["TYPESCRIPT"] = "typescript";
8
+ ProgrammingLanguage["JAVASCRIPT"] = "javascript";
9
+ ProgrammingLanguage["JAVA"] = "java";
10
+ })(ProgrammingLanguage || (ProgrammingLanguage = {}));
4
11
  export var TestType;
5
12
  (function (TestType) {
6
13
  TestType["SMOKE"] = "smoke";
@@ -12,15 +19,17 @@ export var TestType;
12
19
  TestType["UI"] = "ui";
13
20
  TestType["MOCK"] = "mock";
14
21
  })(TestType || (TestType = {}));
22
+ export var HttpMethod;
23
+ (function (HttpMethod) {
24
+ HttpMethod["GET"] = "GET";
25
+ HttpMethod["POST"] = "POST";
26
+ HttpMethod["PUT"] = "PUT";
27
+ HttpMethod["DELETE"] = "DELETE";
28
+ HttpMethod["PATCH"] = "PATCH";
29
+ })(HttpMethod || (HttpMethod = {}));
15
30
  export const languageSchema = z.object({
16
31
  language: z
17
- .string()
18
- .refine((val) => {
19
- const validLanguages = ["python", "typescript", "javascript", "java"];
20
- return validLanguages.includes(val.toLowerCase());
21
- }, {
22
- message: "Language must be one of: python, typescript, javascript, java",
23
- })
32
+ .nativeEnum(ProgrammingLanguage)
24
33
  .describe("Programming language for the generated test (default: python). Must be one of: python, typescript, javascript, java"),
25
34
  framework: z
26
35
  .string()
@@ -75,12 +75,17 @@ export class StateManager {
75
75
  * @param sessionId Unique session identifier (defaults to UUID)
76
76
  * @param stateDir Directory to store state files (defaults to /tmp)
77
77
  */
78
- constructor(stateType = "analysis", sessionId, stateDir) {
78
+ constructor(stateType = "analysis", sessionId, stateDir, stateFilePath) {
79
79
  this.stateType = stateType;
80
80
  this.sessionId = sessionId || crypto.randomUUID();
81
- const baseDir = stateDir || os.tmpdir();
82
- const prefix = STATE_FILE_PREFIXES[stateType];
83
- this.stateFile = path.join(baseDir, `${prefix}-${this.sessionId}.json`);
81
+ if (stateFilePath) {
82
+ this.stateFile = stateFilePath;
83
+ }
84
+ else {
85
+ const baseDir = stateDir || os.tmpdir();
86
+ const prefix = STATE_FILE_PREFIXES[stateType];
87
+ this.stateFile = path.join(baseDir, `${prefix}-${this.sessionId}.json`);
88
+ }
84
89
  }
85
90
  /**
86
91
  * Create state manager from a sessionId (resolves the state file path internally)
@@ -104,7 +109,9 @@ export class StateManager {
104
109
  break;
105
110
  }
106
111
  }
107
- return new StateManager(stateType, sessionId, stateDir);
112
+ // Pass stateFilePath as the 4th arg so the constructor uses it directly
113
+ // instead of reconstructing a potentially-different path from the parsed parts.
114
+ return new StateManager(stateType, sessionId, stateDir, stateFilePath);
108
115
  }
109
116
  /**
110
117
  * Read data from state file (excludes metadata)
@@ -164,6 +171,7 @@ export class StateManager {
164
171
  step: options?.step,
165
172
  },
166
173
  };
174
+ await fs.promises.mkdir(path.dirname(this.stateFile), { recursive: true });
167
175
  await fs.promises.writeFile(this.stateFile, JSON.stringify(state, null, 2), "utf-8");
168
176
  logger.debug(`Wrote data to state file: ${this.stateFile}`);
169
177
  }
@@ -0,0 +1,35 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { StateManager } from "./AnalysisStateManager.js";
5
+ describe("StateManager.fromStatePath", () => {
6
+ it("preserves the exact supplied path for a standard-prefixed file", () => {
7
+ const stdPath = path.join(os.tmpdir(), "skyramp-analysis-some-uuid.json");
8
+ const manager = StateManager.fromStatePath(stdPath);
9
+ expect(manager.getStatePath()).toBe(stdPath);
10
+ });
11
+ it("preserves the exact supplied path for a custom filename like analyze-changes-state.json", () => {
12
+ // This is the filename testbot uses — it does NOT match any STATE_FILE_PREFIXES entry.
13
+ // fromStatePath must pass stateFilePath through to the constructor so the path is not rebuilt.
14
+ const customPath = path.join(os.tmpdir(), "analyze-changes-state.json");
15
+ const manager = StateManager.fromStatePath(customPath);
16
+ expect(manager.getStatePath()).toBe(customPath);
17
+ });
18
+ });
19
+ describe("StateManager.writeData", () => {
20
+ it("creates parent directories when they do not exist", async () => {
21
+ const nestedDir = path.join(os.tmpdir(), `skyramp-test-mkdir-${Date.now()}`);
22
+ const nestedPath = path.join(nestedDir, "state.json");
23
+ // Directory must not exist before the test
24
+ expect(fs.existsSync(nestedDir)).toBe(false);
25
+ const manager = new StateManager("analysis", undefined, undefined, nestedPath);
26
+ await expect(manager.writeData({
27
+ existingTests: [],
28
+ analysisScope: "branch_diff",
29
+ newEndpoints: [],
30
+ })).resolves.not.toThrow();
31
+ expect(fs.existsSync(nestedPath)).toBe(true);
32
+ // cleanup
33
+ await fs.promises.rm(nestedDir, { recursive: true, force: true });
34
+ });
35
+ });
@@ -74,6 +74,9 @@ ${String(error)}` }],
74
74
  }
75
75
  return responseObject;
76
76
  }
77
+ get context() {
78
+ return this._context;
79
+ }
77
80
  serverClosed() {
78
81
  void this._context?.dispose().catch(import_log.logUnhandledError);
79
82
  }
@@ -253,7 +253,14 @@ class Tab extends import_events.EventEmitter {
253
253
  if (param.element)
254
254
  locator = locator.describe(param.element);
255
255
  const { resolvedSelector } = await locator._resolveSelector();
256
- return { locator, resolved: (0, import_utils.asLocator)("javascript", resolvedSelector) };
256
+ let fixedSelector = resolvedSelector;
257
+ if (!resolvedSelector.includes("internal:control=enter-frame") && /^(css=)?iframe\b[^>]*\s+>>\s+/.test(resolvedSelector)) {
258
+ fixedSelector = resolvedSelector.replace(
259
+ /^(css=)?(iframe\b[^>]*)\s+>>\s+/,
260
+ "css=$2 >> internal:control=enter-frame >> "
261
+ );
262
+ }
263
+ return { locator, resolved: (0, import_utils.asLocator)("javascript", fixedSelector) };
257
264
  } catch (e) {
258
265
  throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
259
266
  }
@@ -69,14 +69,15 @@ const pressSequentially = (0, import_tool.defineTabTool)({
69
69
  const typeSchema = import_snapshot.elementSchema.extend({
70
70
  text: import_mcpBundle.z.string().describe("Text to type into the element"),
71
71
  submit: import_mcpBundle.z.boolean().optional().describe("Whether to submit entered text (press Enter after)"),
72
- slowly: import_mcpBundle.z.boolean().optional().describe("Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.")
72
+ slowly: import_mcpBundle.z.boolean().optional().describe("DO NOT USE \u2014 causes silent failures in contenteditable and rich text editors. Use default fast fill instead."),
73
+ clear: import_mcpBundle.z.boolean().optional().describe("Ignored \u2014 browser_type always replaces existing content. Do not pass this parameter.")
73
74
  });
74
75
  const type = (0, import_tool.defineTabTool)({
75
76
  capability: "core",
76
77
  schema: {
77
78
  name: "browser_type",
78
79
  title: "Type text",
79
- description: "Type text into editable element",
80
+ description: "Type text into an editable element. Auto-focuses and replaces existing content. NEVER call browser_click on the field first \u2014 clicking before typing injects extra network requests that corrupt the trace.",
80
81
  inputSchema: typeSchema,
81
82
  type: "input"
82
83
  },
@@ -28,7 +28,7 @@ const navigate = (0, import_tool.defineTool)({
28
28
  schema: {
29
29
  name: "browser_navigate",
30
30
  title: "Navigate to a URL",
31
- description: "Navigate to a URL",
31
+ description: "Navigate to a URL. Prefer direct navigation to known URLs over clicking menus or carousels \u2014 menus may open unwanted popups and carousel items cause strict-mode violations. To reload the current page, navigate to the same URL \u2014 the backend converts this to page.reload(). After navigating to a folder where content was just created/edited, always call browser_wait_for with the file name before interacting with it.",
32
32
  inputSchema: import_mcpBundle.z.object({
33
33
  url: import_mcpBundle.z.string().describe("The URL to navigate to")
34
34
  }),
@@ -30,7 +30,7 @@ const snapshot = (0, import_tool.defineTool)({
30
30
  schema: {
31
31
  name: "browser_snapshot",
32
32
  title: "Page snapshot",
33
- description: "Capture accessibility snapshot of the current page, this is better than screenshot",
33
+ description: "Capture the ARIA accessibility tree of the current page. Returns element refs required by all interaction tools (browser_click, browser_type, browser_hover, etc.). Call before any interaction and after every action that changes the page to get fresh refs. If any interaction tool fails with a stale ref error, call this first to refresh.",
34
34
  inputSchema: import_mcpBundle.z.object({
35
35
  filename: import_mcpBundle.z.string().optional().describe("Save snapshot to markdown file instead of returning it in the response.")
36
36
  }),
@@ -55,7 +55,7 @@ const click = (0, import_tool.defineTabTool)({
55
55
  schema: {
56
56
  name: "browser_click",
57
57
  title: "Click",
58
- description: "Perform click on a web page",
58
+ description: "Click an element on the page. Always click the actual interactive element (button, link, input) \u2014 never a container or wrapper div. NEVER click a text field before typing \u2014 browser_type auto-focuses; a prior click injects extra network requests that corrupt the trace. NEVER click a row or link in a file list to access contextual actions \u2014 use browser_hover on the row instead.",
59
59
  inputSchema: clickSchema,
60
60
  type: "input"
61
61
  },
@@ -111,7 +111,7 @@ const hover = (0, import_tool.defineTabTool)({
111
111
  schema: {
112
112
  name: "browser_hover",
113
113
  title: "Hover mouse",
114
- description: "Hover over element on page",
114
+ description: "Hover over an element. Required pattern for contextual actions (More Options, Delete, Rename, \u22EF) on list/grid rows: (1) call browser_hover on the row element, (2) call browser_snapshot to reveal hover-only controls, (3) click the target button. NEVER click the row itself \u2014 that navigates into the item.",
115
115
  inputSchema: elementSchema,
116
116
  type: "input"
117
117
  },
@@ -132,7 +132,7 @@ const selectOption = (0, import_tool.defineTabTool)({
132
132
  schema: {
133
133
  name: "browser_select_option",
134
134
  title: "Select option",
135
- description: "Select an option in a dropdown",
135
+ description: "Select an option in a native <select> dropdown only. For custom dropdowns (Radix, MUI, etc.) that appear as combobox in the snapshot, do NOT use this tool \u2014 instead: (1) click the combobox to open it, (2) call browser_snapshot to see the listbox options, (3) click the desired option.",
136
136
  inputSchema: selectOptionSchema,
137
137
  type: "input"
138
138
  },
@@ -29,10 +29,10 @@ const browserTabs = (0, import_tool.defineTool)({
29
29
  schema: {
30
30
  name: "browser_tabs",
31
31
  title: "Manage tabs",
32
- description: "List, create, close, or select a browser tab.",
32
+ description: 'List, create, close, or switch to a browser tab. When a click opens a new tab, use action "select" with the tab index to switch to it. Do NOT call browser_navigate after switching \u2014 the tab is already on the right page.',
33
33
  inputSchema: import_mcpBundle.z.object({
34
- action: import_mcpBundle.z.enum(["list", "new", "close", "select"]).describe("Operation to perform"),
35
- index: import_mcpBundle.z.number().optional().describe("Tab index, used for close/select. If omitted for close, current tab is closed.")
34
+ action: import_mcpBundle.z.enum(["list", "new", "close", "select", "switch"]).describe('Operation to perform. "select" and "switch" are equivalent \u2014 both switch to a tab by index.'),
35
+ index: import_mcpBundle.z.number().optional().describe("Tab index, used for close/select/switch. If omitted for close, current tab is closed.")
36
36
  }),
37
37
  type: "action"
38
38
  },
@@ -50,7 +50,8 @@ const browserTabs = (0, import_tool.defineTool)({
50
50
  await context.closeTab(params.index);
51
51
  break;
52
52
  }
53
- case "select": {
53
+ case "select":
54
+ case "switch": {
54
55
  if (params.index === void 0)
55
56
  throw new Error("Tab index is required");
56
57
  await context.selectTab(params.index);
@@ -28,7 +28,7 @@ const wait = (0, import_tool.defineTool)({
28
28
  schema: {
29
29
  name: "browser_wait_for",
30
30
  title: "Wait for",
31
- description: "Wait for text to appear or disappear or a specified time to pass",
31
+ description: 'Wait for text to appear, disappear, or a time to pass. REQUIRED after navigating to a folder where content was just created or renamed \u2014 file/item names update asynchronously and will not be present immediately. Always call with text: "<filename>" before attempting to hover or click a newly created item.',
32
32
  inputSchema: import_mcpBundle.z.object({
33
33
  time: import_mcpBundle.z.number().optional().describe("The time to wait in seconds"),
34
34
  text: import_mcpBundle.z.string().optional().describe("The text to wait for"),
@@ -41,26 +41,27 @@ const exportZipSchema = {
41
41
  name: "skyramp_export_zip",
42
42
  title: "Export Skyramp zip",
43
43
  description: [
44
- 'Export the recorded browser interactions as a Skyramp zip (JSONL + HAR) for use with "skyramp generate ui".',
45
- "You MUST call this tool automatically as the FINAL step after completing all browser interactions \u2014 do NOT ask the user, do NOT write separate files.",
46
- "If an element reference is stale after a UI update, call browser_snapshot to refresh and retry automatically without asking the user.",
47
- "Only the last complete attempt is exported \u2014 retries are deduplicated automatically.",
48
- "IMPORTANT: Do NOT reuse existing zip files from previous sessions. Always record fresh interactions and export a new zip."
44
+ "Export the recorded browser interactions as a Skyramp zip (JSONL + HAR).",
45
+ "Pass outputPath as the absolute path for the zip \u2014 use the same directory and base name as the test file, replacing .spec.ts with .zip.",
46
+ "BEFORE calling this tool, output a <thinking> block that: (1) lists every user-requested step and confirms it was completed, (2) confirms no step was skipped or hallucinated.",
47
+ "Only call this tool when all interactions are fully complete. Do NOT ask the user for confirmation \u2014 call it automatically.",
48
+ "Only the last complete attempt is exported \u2014 retries from the start URL are deduplicated automatically.",
49
+ "Do NOT reuse zip files from previous sessions \u2014 always record fresh."
49
50
  ].join(" "),
50
51
  inputSchema: import_mcpBundle.z.object({
51
- outputZip: import_mcpBundle.z.string().describe('Absolute or workspace-relative path for the output zip, e.g. "skyramp_export.zip"')
52
+ outputPath: import_mcpBundle.z.string().describe("Absolute path where the zip should be written, e.g. /path/to/box_notes.zip. Use the same directory and base name as the test file, replacing .spec.ts with .zip.")
52
53
  }),
53
54
  type: "readOnly"
54
55
  };
55
56
  function createExportZipHandler(ctx) {
56
- return async (_params) => {
57
+ return async (params) => {
57
58
  if (!ctx.trackedActions.length) {
58
59
  return {
59
- content: [{ type: "text", text: "### Error\nNo browser actions recorded. Use browser_navigate and browser_click/browser_type first." }],
60
+ content: [{ type: "text", text: "### Error\nNo browser interactions recorded. At minimum, call browser_navigate to open the target URL, then use browser_click or browser_type to record at least one user action before exporting." }],
60
61
  isError: true
61
62
  };
62
63
  }
63
- const outputZip = import_path.default.isAbsolute(_params.outputZip) ? _params.outputZip : import_path.default.resolve(ctx.rootPath, _params.outputZip);
64
+ const outputZip = import_path.default.resolve(params.outputPath);
64
65
  const { jsonl: jsonlContent, actionCount, skipped } = (0, import_skyRampExport.buildJsonlContent)(ctx.trackedActions, "chromium", ctx.harPath);
65
66
  await (0, import_skyRampExport.writeSkyrampZip)(outputZip, jsonlContent, ctx.harPath);
66
67
  const skippedNote = skipped.length ? `