@skyramp/mcp 0.0.64 → 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 (40) 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 +32 -17
  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/services/TestGenerationService.js +1 -0
  13. package/build/tools/code-refactor/modularizationTool.js +2 -2
  14. package/build/tools/executeSkyrampTestTool.js +4 -3
  15. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +49 -20
  16. package/build/tools/generate-tests/generateContractRestTool.js +26 -4
  17. package/build/tools/generate-tests/generateIntegrationRestTool.js +44 -13
  18. package/build/tools/generate-tests/generateMockRestTool.js +1 -0
  19. package/build/tools/generate-tests/generateScenarioRestTool.js +17 -39
  20. package/build/tools/generate-tests/generateUIRestTool.js +69 -4
  21. package/build/tools/submitReportTool.js +20 -14
  22. package/build/tools/test-management/analyzeChangesTool.js +8 -3
  23. package/build/tools/test-management/analyzeChangesTool.test.js +85 -0
  24. package/build/types/RepositoryAnalysis.js +2 -12
  25. package/build/types/TestRecommendation.js +43 -1
  26. package/build/types/TestTypes.js +20 -7
  27. package/build/utils/AnalysisStateManager.js +13 -5
  28. package/build/utils/AnalysisStateManager.test.js +35 -0
  29. package/node_modules/playwright/lib/mcp/browser/browserServerBackend.js +3 -0
  30. package/node_modules/playwright/lib/mcp/browser/tab.js +8 -1
  31. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -2
  32. package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
  33. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +4 -4
  34. package/node_modules/playwright/lib/mcp/browser/tools/tabs.js +5 -4
  35. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  36. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -9
  37. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +304 -7
  38. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +128 -20
  39. package/package.json +2 -2
  40. package/node_modules/playwright/lib/mcp/terminal/help.json +0 -32
@@ -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,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { SCENARIO_CATEGORIES } from "./TestRecommendation.js";
2
3
  // ── Zod schemas ──
3
4
  export const analysisScopeSchema = z.enum(["full_repo", "current_branch_diff"]);
4
5
  export const paramInfoSchema = z.object({
@@ -76,18 +77,7 @@ export const scenarioStepSchema = z.object({
76
77
  export const draftedScenarioSchema = z.object({
77
78
  scenarioName: z.string(),
78
79
  description: z.string(),
79
- category: z.enum([
80
- "crud",
81
- "workflow",
82
- "auth",
83
- "error-handling",
84
- "data-validation",
85
- "security_boundary",
86
- "business_rule",
87
- "data_integrity",
88
- "breaking_change",
89
- "new_endpoint",
90
- ]),
80
+ category: z.enum(SCENARIO_CATEGORIES),
91
81
  priority: z.enum(["high", "medium", "low"]),
92
82
  steps: z.array(scenarioStepSchema),
93
83
  chainingKeys: z.array(z.string()),
@@ -1,5 +1,47 @@
1
1
  import { z } from "zod";
2
2
  import { TestType } from "./TestTypes.js";
3
+ /** Internal-only categories (not submitted to tools). */
4
+ const INTERNAL_CATEGORIES = [
5
+ "new_endpoint", // CRITICAL - diff-direct scenarios always fill GENERATE slots first
6
+ ];
7
+ /** External categories valid for tool submissions, ordered by priority. */
8
+ const CATEGORIES = [
9
+ // HIGH priority
10
+ "security_boundary", // auth, permission, cross-user isolation, idempotency
11
+ "business_rule", // unique constraints, range validation, state machines
12
+ "data_integrity", // cascade deletes, orphan prevention, referential integrity
13
+ "breaking_change", // route renames, auth migration, response shape changes
14
+ "auth", // authentication and authorization flows
15
+ // MEDIUM priority
16
+ "workflow", // cross-resource integration, user journeys
17
+ "error_handling", // error responses and edge cases
18
+ "data_validation", // input validation and schema enforcement
19
+ // LOW priority
20
+ "crud", // basic create/read/update/delete operations
21
+ ];
22
+ /** All categories including internal ones. */
23
+ export const SCENARIO_CATEGORIES = [...INTERNAL_CATEGORIES, ...CATEGORIES];
24
+ /** Categories valid for tool submissions (excludes internal-only categories). */
25
+ export const TEST_CATEGORIES = CATEGORIES;
26
+ /** Priority assignment for each category. */
27
+ export const CATEGORY_PRIORITY = {
28
+ new_endpoint: "CRITICAL",
29
+ security_boundary: "HIGH",
30
+ business_rule: "HIGH",
31
+ data_integrity: "HIGH",
32
+ breaking_change: "HIGH",
33
+ auth: "HIGH",
34
+ workflow: "MEDIUM",
35
+ error_handling: "MEDIUM",
36
+ data_validation: "MEDIUM",
37
+ crud: "LOW",
38
+ };
39
+ /** Map internal-only categories to their external equivalent for tool submission. */
40
+ export function externalCategory(cat) {
41
+ if (cat === "new_endpoint")
42
+ return "crud";
43
+ return cat;
44
+ }
3
45
  // Test type to documentation URL mapping
4
46
  export const TEST_TYPE_DOCS = {
5
47
  [TestType.SMOKE]: "https://www.skyramp.dev/docs/smoke-tests",
@@ -33,7 +75,7 @@ export const specificTestSchema = z.object({
33
75
  export const testTypeRecommendationSchema = z.object({
34
76
  priority: z.enum(["high", "medium", "low"]),
35
77
  testType: z.nativeEnum(TestType),
36
- category: z.enum(["security_boundary", "business_rule", "breaking_change", "data_integrity", "workflow"]),
78
+ category: z.enum(TEST_CATEGORIES),
37
79
  rationale: z.string(),
38
80
  reasoning: z.string(),
39
81
  specificTests: z.array(specificTestSchema),
@@ -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()
@@ -101,6 +110,10 @@ export const baseSchema = z.object({
101
110
  .number()
102
111
  .default(0)
103
112
  .describe("Port number for the mock server"),
113
+ optionalFields: z
114
+ .boolean()
115
+ .default(false)
116
+ .describe("Whether to include optional fields in the generated test/mock"),
104
117
  prompt: z.string().describe("The prompt user provided to generate the test"),
105
118
  });
106
119
  export const basePlaywrightSchema = z.object({
@@ -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 ? `