@skyramp/mcp 0.1.6 → 0.1.7

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.
@@ -291,7 +291,7 @@ function buildServiceContext(services) {
291
291
  if (svc.api?.baseUrl)
292
292
  parts.push(` <base_url>${escapeXml(svc.api.baseUrl)}</base_url>`);
293
293
  if (svc.testDirectory)
294
- parts.push(` <output_dir>${escapeXml(svc.testDirectory)}</output_dir>`);
294
+ parts.push(` <test_directory>${escapeXml(svc.testDirectory)}</test_directory>`);
295
295
  parts.push('</service>');
296
296
  return parts.join('\n');
297
297
  });
@@ -305,6 +305,9 @@ export async function readWorkspaceServices(repositoryPath) {
305
305
  const rawConfig = await readWorkspaceConfigRaw(repositoryPath);
306
306
  return (rawConfig?.services ?? []);
307
307
  }
308
+ export function buildWorkspaceRecoveryPrefix(repositoryPath) {
309
+ return `IMPORTANT: The existing .skyramp/workspace.yml failed to parse or validate. Before proceeding with any tasks below, you MUST call skyramp_init_scan with workspacePath "${repositoryPath}" and force: true, then call skyramp_init_workspace with workspacePath "${repositoryPath}", the discovered services, scanToken, and force: true to regenerate the workspace file.\n\n`;
310
+ }
308
311
  export function registerTestbotPrompt(server) {
309
312
  logger.info("Registering testbot prompt");
310
313
  server.registerPrompt("skyramp_testbot", {
@@ -351,10 +354,17 @@ export function registerTestbotPrompt(server) {
351
354
  .string()
352
355
  .optional()
353
356
  .describe("Browser login credentials for UI test recording (format: 'username:password', one per line). Injected into the prompt as a <ui-credentials> block so the agent logs in before recording traces."),
357
+ workspaceValidationFailed: z
358
+ .boolean()
359
+ .default(false)
360
+ .describe("Set to true when the testbot detected that .skyramp/workspace.yml exists but failed schema validation. Instructs the agent to regenerate the workspace file before proceeding."),
354
361
  },
355
362
  }, async (args) => {
356
363
  const services = await readWorkspaceServices(args.repositoryPath);
357
- const prompt = getTestbotPrompt(args.prTitle, args.prDescription, args.summaryOutputFile, args.repositoryPath, args.baseBranch, args.maxRecommendations, args.maxGenerate, args.maxCritical, args.prNumber, args.userPrompt, services.length ? services : undefined, args.stateOutputFile, args.uiCredentials);
364
+ let prompt = getTestbotPrompt(args.prTitle, args.prDescription, args.summaryOutputFile, args.repositoryPath, args.baseBranch, args.maxRecommendations, args.maxGenerate, args.maxCritical, args.prNumber, args.userPrompt, services.length ? services : undefined, args.stateOutputFile, args.uiCredentials);
365
+ if (args.workspaceValidationFailed) {
366
+ prompt = buildWorkspaceRecoveryPrefix(args.repositoryPath) + prompt;
367
+ }
358
368
  AnalyticsService.pushMCPToolEvent("skyramp_testbot_prompt", undefined, {}).catch(() => { });
359
369
  return {
360
370
  messages: [
@@ -59,7 +59,7 @@ describe("buildServiceContext (via getTestbotPrompt)", () => {
59
59
  expect(prompt).toContain("<language>python</language>");
60
60
  expect(prompt).toContain("<framework>pytest</framework>");
61
61
  expect(prompt).toContain("<base_url>http://localhost:8000</base_url>");
62
- expect(prompt).toContain("<output_dir>tests/python</output_dir>");
62
+ expect(prompt).toContain("<test_directory>tests/python</test_directory>");
63
63
  expect(prompt).toContain("</service>");
64
64
  expect(prompt).toContain("<services>");
65
65
  expect(prompt).toContain("</services>");
@@ -70,7 +70,7 @@ describe("buildServiceContext (via getTestbotPrompt)", () => {
70
70
  expect(prompt).not.toContain("<language>");
71
71
  expect(prompt).not.toContain("<framework>");
72
72
  expect(prompt).not.toContain("<base_url>");
73
- expect(prompt).not.toContain("<output_dir>");
73
+ expect(prompt).not.toContain("<test_directory>");
74
74
  });
75
75
  it("renders multiple services", () => {
76
76
  const prompt = callWithServices([
@@ -104,7 +104,7 @@ describe("buildServiceContext (via getTestbotPrompt)", () => {
104
104
  api: { baseUrl: "http://host?a=1&b=2" },
105
105
  },
106
106
  ]);
107
- expect(prompt).toContain("<output_dir>tests/a&amp;b</output_dir>");
107
+ expect(prompt).toContain("<test_directory>tests/a&amp;b</test_directory>");
108
108
  expect(prompt).toContain("<base_url>http://host?a=1&amp;b=2</base_url>");
109
109
  });
110
110
  it("places services block between REPOSITORY PATH and instruction line", () => {
@@ -231,3 +231,20 @@ describe("drift analysis inline embedding", () => {
231
231
  expect(prompt).toContain("rules in `<drift_analysis_rules>`");
232
232
  });
233
233
  });
234
+ describe("buildWorkspaceRecoveryPrefix", () => {
235
+ const { buildWorkspaceRecoveryPrefix } = require("./testbot-prompts.js");
236
+ it("includes repositoryPath in both init_scan and init_workspace instructions", () => {
237
+ const prefix = buildWorkspaceRecoveryPrefix("/home/user/repo");
238
+ expect(prefix).toContain('skyramp_init_scan with workspacePath "/home/user/repo"');
239
+ expect(prefix).toContain('skyramp_init_workspace with workspacePath "/home/user/repo"');
240
+ });
241
+ it("includes force: true for both tool calls", () => {
242
+ const prefix = buildWorkspaceRecoveryPrefix("/repo");
243
+ expect(prefix).toContain("force: true, then call skyramp_init_workspace");
244
+ expect(prefix).toContain("force: true to regenerate");
245
+ });
246
+ it("starts with IMPORTANT", () => {
247
+ const prefix = buildWorkspaceRecoveryPrefix("/repo");
248
+ expect(prefix).toMatch(/^IMPORTANT:/);
249
+ });
250
+ });
@@ -2,6 +2,7 @@ import { AUTH_PLACEHOLDER_TOKEN } from "../types/TestTypes.js";
2
2
  import { isAuthorizationHeaderName } from "../utils/workspaceAuth.js";
3
3
  import { inferExpectedStatus } from "../utils/httpDefaults.js";
4
4
  import { logger } from "../utils/logger.js";
5
+ import { stageGeneratedPaths } from "../utils/gitStaging.js";
5
6
  import fs from "fs";
6
7
  import path from "path";
7
8
  export class ScenarioGenerationService {
@@ -41,6 +42,8 @@ export class ScenarioGenerationService {
41
42
  }
42
43
  existingRequests.push(traceRequest);
43
44
  fs.writeFileSync(filePath, JSON.stringify(existingRequests, null, 2), "utf8");
45
+ // Stage so testbot includes the generated files in its output commit.
46
+ await stageGeneratedPaths(filePath);
44
47
  logger.info("Trace request added to file", {
45
48
  filePath,
46
49
  totalRequests: existingRequests.length,
@@ -8,6 +8,7 @@ import { getEntryPoint } from "../utils/telemetry.js";
8
8
  import { getLanguageSteps } from "../utils/language-helper.js";
9
9
  import { logger } from "../utils/logger.js";
10
10
  import { normalizeLanguageParams } from "../utils/normalizeParams.js";
11
+ import { stageGeneratedPaths } from "../utils/gitStaging.js";
11
12
  export class TestGenerationService {
12
13
  client;
13
14
  constructor() {
@@ -324,6 +325,8 @@ The generated test file remains unchanged and ready to use as-is.
324
325
  throw new Error(`Test generation failed: ${result}`);
325
326
  }
326
327
  }
328
+ // Stage so testbot includes the generated files in its output commit.
329
+ await stageGeneratedPaths(generateOptions.outputDir);
327
330
  return `
328
331
  **Generated Test Details:**
329
332
  - Test Type: ${this.getTestType()}
@@ -4,6 +4,7 @@ import { getCodeReusePrompt } from "../../prompts/code-reuse.js";
4
4
  import { codeRefactoringSchema, languageSchema, } from "../../types/TestTypes.js";
5
5
  import { SKYRAMP_UTILS_HEADER } from "../../utils/utils.js";
6
6
  import { AnalyticsService } from "../../services/AnalyticsService.js";
7
+ import { stageGeneratedPaths } from "../../utils/gitStaging.js";
7
8
  const codeReuseSchema = z.object({
8
9
  testFile: z
9
10
  .string()
@@ -70,6 +71,8 @@ export function registerCodeReuseTool(server) {
70
71
  language: params.language,
71
72
  framework: params.framework,
72
73
  });
74
+ // Stage so testbot includes the generated files in its output commit.
75
+ await stageGeneratedPaths(params.testFile);
73
76
  const codeReusePrompt = getCodeReusePrompt(params.testFile, params.language, params.framework);
74
77
  return {
75
78
  content: [
@@ -5,6 +5,7 @@ import { getContractProviderAssertionsPrompt } from "../../prompts/enhance-asser
5
5
  import { getIntegrationAssertionsPrompt } from "../../prompts/enhance-assertions/integrationAssertionsPrompt.js";
6
6
  import { getUIAssertionsPrompt } from "../../prompts/enhance-assertions/uiAssertionsPrompt.js";
7
7
  import { isTestbotEnabled } from "../../utils/featureFlags.js";
8
+ import { stageGeneratedPaths } from "../../utils/gitStaging.js";
8
9
  const TOOL_NAME = "skyramp_enhance_assertions";
9
10
  const TESTBOT_UI_CHECKS = `
10
11
  ### Additional Testbot-Specific Checks
@@ -34,6 +35,8 @@ export function registerEnhanceAssertionsTool(server) {
34
35
  inputSchema: enhanceAssertionsSchema,
35
36
  }, async (params) => {
36
37
  const { testFile, testType, enhanceType } = params;
38
+ // Stage so testbot includes the generated files in its output commit.
39
+ await stageGeneratedPaths(testFile);
37
40
  const enhanceCtx = enhanceType;
38
41
  let instructions;
39
42
  if (testType === TestType.UI) {
@@ -6,6 +6,7 @@ import { ModularizationService, } from "../../services/ModularizationService.js"
6
6
  import { AnalyticsService } from "../../services/AnalyticsService.js";
7
7
  import { normalizeLanguageParams, resolveParamAliases, } from "../../utils/normalizeParams.js";
8
8
  import { normalizeSkyrampImportsInFile } from "../../utils/normalizeSkyrampImports.js";
9
+ import { stageGeneratedPaths } from "../../utils/gitStaging.js";
9
10
  const modularizationSchema = {
10
11
  testFile: z
11
12
  .string()
@@ -79,6 +80,8 @@ After modularization, if errors remain, call skyramp_fix_errors.
79
80
  if (!params.isTraceBased && [TestType.UI, TestType.E2E, TestType.INTEGRATION].includes(params.testType))
80
81
  params.isTraceBased = true;
81
82
  normalizeSkyrampImportsInFile(params.testFile);
83
+ // Stage so testbot includes the generated files in its output commit.
84
+ await stageGeneratedPaths(params.testFile);
82
85
  // Default prompt to test file content
83
86
  if (!params.prompt && params.testFile) {
84
87
  try {
@@ -46,7 +46,7 @@ const initializeWorkspaceSchema = z.object({
46
46
  force: z
47
47
  .boolean()
48
48
  .default(false)
49
- .describe("Set to true ONLY if user explicitly requests to overwrite existing workspace configuration. NEVER auto-fill this as true. Default is false."),
49
+ .describe("Set to true to overwrite an existing workspace file. Use when the user explicitly requests it, or when recovering from a workspace validation failure (schema mismatch, unknown fields). Default is false."),
50
50
  });
51
51
  /**
52
52
  * Write a YAML workspace config to disk, bypassing the library's restricted
@@ -54,7 +54,7 @@ describe("dockerImageExistsLocally", () => {
54
54
  });
55
55
  });
56
56
  describe("pullDockerImage", () => {
57
- const IMAGE = "skyramp/executor:v1.3.23";
57
+ const IMAGE = "skyramp/executor:v1.3.24";
58
58
  beforeEach(() => jest.clearAllMocks());
59
59
  describe("on amd64 host", () => {
60
60
  const originalArch = process.arch;
@@ -41,11 +41,11 @@ describe("resolveServiceDetailsRef", () => {
41
41
  process.env.SKYRAMP_FEATURE_TESTBOT = "1";
42
42
  });
43
43
  it("returns <services> block reference for testDirRef", () => {
44
- expect(resolveServiceDetailsRef().testDirRef).toContain("<output_dir>");
44
+ expect(resolveServiceDetailsRef().testDirRef).toContain("<test_directory>");
45
45
  expect(resolveServiceDetailsRef().testDirRef).toContain("<services>");
46
46
  });
47
47
  it("returns <services> block reference for frontendTestDirRef", () => {
48
- expect(resolveServiceDetailsRef().frontendTestDirRef).toContain("<output_dir>");
48
+ expect(resolveServiceDetailsRef().frontendTestDirRef).toContain("<test_directory>");
49
49
  expect(resolveServiceDetailsRef().frontendTestDirRef).toContain("<services>");
50
50
  });
51
51
  it("returns <services> block reference for authSourceRef", () => {
@@ -0,0 +1,18 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import { logger } from "./logger.js";
4
+ import { isTestbotEnabled } from "./featureFlags.js";
5
+ const execFileAsync = promisify(execFile);
6
+ /**
7
+ * Stages a file path an MCP tool just wrote into the git index by
8
+ * running `git add -- <path>`.
9
+ *
10
+ * Gated by the SKYRAMP_FEATURE_TESTBOT=1 env var, which is set only
11
+ * inside a testbot CI run.
12
+ */
13
+ export async function stageGeneratedPaths(path, cwd) {
14
+ if (!isTestbotEnabled())
15
+ return;
16
+ await execFileAsync("git", ["add", "--", path], { cwd });
17
+ logger.info("Staged generated file", { path });
18
+ }
@@ -0,0 +1,87 @@
1
+ jest.mock("./logger.js", () => ({
2
+ logger: {
3
+ debug: jest.fn(),
4
+ info: jest.fn(),
5
+ warning: jest.fn(),
6
+ error: jest.fn(),
7
+ },
8
+ }));
9
+ const execFileMock = jest.fn();
10
+ jest.mock("child_process", () => ({
11
+ execFile: (cmd, args, opts, cb) => {
12
+ const result = execFileMock(cmd, args, opts);
13
+ const cbErr = result && typeof result === "object" && "err" in result
14
+ ? result.err
15
+ : null;
16
+ cb(cbErr, "", "");
17
+ },
18
+ }));
19
+ import { stageGeneratedPaths } from "./gitStaging.js";
20
+ import { logger } from "./logger.js";
21
+ const loggerInfoMock = logger.info;
22
+ let originalTestbotEnv;
23
+ beforeAll(() => {
24
+ originalTestbotEnv = process.env.SKYRAMP_FEATURE_TESTBOT;
25
+ });
26
+ afterAll(() => {
27
+ if (originalTestbotEnv === undefined) {
28
+ delete process.env.SKYRAMP_FEATURE_TESTBOT;
29
+ }
30
+ else {
31
+ process.env.SKYRAMP_FEATURE_TESTBOT = originalTestbotEnv;
32
+ }
33
+ });
34
+ beforeEach(() => {
35
+ execFileMock.mockReset();
36
+ loggerInfoMock.mockReset();
37
+ });
38
+ afterEach(() => {
39
+ delete process.env.SKYRAMP_FEATURE_TESTBOT;
40
+ });
41
+ describe("stageGeneratedPaths", () => {
42
+ describe("gated on (SKYRAMP_FEATURE_TESTBOT=1)", () => {
43
+ beforeEach(() => {
44
+ process.env.SKYRAMP_FEATURE_TESTBOT = "1";
45
+ });
46
+ it("runs `git add -- <path>` with the supplied path", async () => {
47
+ await stageGeneratedPaths("tests/a.py");
48
+ expect(execFileMock).toHaveBeenCalledTimes(1);
49
+ expect(execFileMock).toHaveBeenCalledWith("git", ["add", "--", "tests/a.py"], expect.objectContaining({}));
50
+ expect(loggerInfoMock).toHaveBeenCalledWith("Staged generated file", expect.objectContaining({ path: "tests/a.py" }));
51
+ });
52
+ it("passes through the cwd option when provided", async () => {
53
+ await stageGeneratedPaths("tests/a.py", "/repo/root");
54
+ expect(execFileMock).toHaveBeenCalledWith("git", ["add", "--", "tests/a.py"], expect.objectContaining({ cwd: "/repo/root" }));
55
+ });
56
+ it("propagates errors from git add", async () => {
57
+ const err = new Error("fatal: not a git repository");
58
+ execFileMock.mockImplementation(() => {
59
+ throw err;
60
+ });
61
+ await expect(stageGeneratedPaths("tests/a.py")).rejects.toThrow("fatal: not a git repository");
62
+ });
63
+ it("propagates async callback errors from git add", async () => {
64
+ const err = Object.assign(new Error("git: command not found"), {
65
+ code: "ENOENT",
66
+ });
67
+ execFileMock.mockReturnValueOnce({ err });
68
+ await expect(stageGeneratedPaths("tests/a.py", "/repo/root")).rejects.toThrow("git: command not found");
69
+ });
70
+ });
71
+ describe("gated off (SKYRAMP_FEATURE_TESTBOT unset)", () => {
72
+ it("does not invoke git add", async () => {
73
+ await stageGeneratedPaths("tests/a.py");
74
+ expect(execFileMock).not.toHaveBeenCalled();
75
+ expect(loggerInfoMock).not.toHaveBeenCalled();
76
+ });
77
+ });
78
+ describe("gated off (SKYRAMP_FEATURE_TESTBOT='0')", () => {
79
+ beforeEach(() => {
80
+ process.env.SKYRAMP_FEATURE_TESTBOT = "0";
81
+ });
82
+ it("does not invoke git add", async () => {
83
+ await stageGeneratedPaths("tests/a.py");
84
+ expect(execFileMock).not.toHaveBeenCalled();
85
+ });
86
+ });
87
+ });
@@ -144,8 +144,8 @@ export function generateSkyrampHeader(language) {
144
144
  export function resolveServiceDetailsRef() {
145
145
  if (isTestbotEnabled()) {
146
146
  return {
147
- testDirRef: "the `<output_dir>` from the `<services>` block",
148
- frontendTestDirRef: "the **frontend** service's `<output_dir>` from the `<services>` block",
147
+ testDirRef: "the `<test_directory>` from the `<services>` block",
148
+ frontendTestDirRef: "the **frontend** service's `<test_directory>` from the `<services>` block",
149
149
  baseUrlRef: "the `<base_url>` from the `<services>` block",
150
150
  authSourceRef: "the `<services>` block",
151
151
  };
@@ -1,3 +1,3 @@
1
- export const SKYRAMP_IMAGE_VERSION = "v1.3.23";
1
+ export const SKYRAMP_IMAGE_VERSION = "v1.3.24";
2
2
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
3
3
  export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;
@@ -200,6 +200,8 @@ class Context {
200
200
  _live: true
201
201
  });
202
202
  }
203
+ if (this.onBrowserContextCreated)
204
+ await this.onBrowserContextCreated(browserContext);
203
205
  return result;
204
206
  }
205
207
  lookupSecret(secretName) {
@@ -29,9 +29,9 @@ const pressKey = (0, import_tool.defineTabTool)({
29
29
  schema: {
30
30
  name: "browser_press_key",
31
31
  title: "Press a key",
32
- description: "Press a single key on the globally-focused element. This is a FALLBACK tool \u2014 prefer higher-level tools whenever possible: browser_click for buttons and links, browser_type for text entry, browser_type with submit: true for form submission (bundles the fill and the Enter into one recorded action). Only use browser_press_key when no semantic alternative exists: arrow-key navigation in an already-open listbox or combobox, Escape to close a modal that has no visible close button, Tab to shift focus between fields, or an app-specific keyboard shortcut the user actually relies on (e.g. Ctrl+K to open a command palette).",
32
+ description: "Press a key on the keyboard",
33
33
  inputSchema: import_mcpBundle.z.object({
34
- key: import_mcpBundle.z.string().describe('Key name or character, e.g. "ArrowDown", "Enter", "Escape", "a". Supports modifier combinations like "Control+a" or "Meta+v".')
34
+ key: import_mcpBundle.z.string().describe("Name of the key to press or a character to generate, such as `ArrowLeft` or `a`")
35
35
  }),
36
36
  type: "input"
37
37
  },
@@ -93,7 +93,7 @@ class TraceRecordingBackend {
93
93
  await this._browserBackend.initialize(clientInfo);
94
94
  this._initialized = true;
95
95
  traceDebug("TraceRecordingBackend initialized");
96
- this._setupPopupTracking();
96
+ this._browserBackend.context.onBrowserContextCreated = (browserContext) => this._installPopupListener(browserContext);
97
97
  }
98
98
  async listTools() {
99
99
  const browserTools = await this._browserBackend.listTools();
@@ -729,32 +729,23 @@ ${details}` }]
729
729
  * When a popup opens, find the most recent click that triggered it and mark it
730
730
  * with a popupAlias signal. Switch the current page alias to the new page.
731
731
  */
732
- _setupPopupTracking() {
733
- void (async () => {
734
- try {
735
- const context = this._browserBackend.context;
736
- if (!context)
737
- return;
738
- const browserContext = await context.ensureBrowserContext();
739
- let initialPageSeen = false;
740
- browserContext.on("page", () => {
741
- if (!initialPageSeen) {
742
- initialPageSeen = true;
743
- return;
744
- }
745
- if (this._reloading) {
746
- traceDebug("Ignoring spurious popup opened during reload");
747
- return;
748
- }
749
- this._pageCount++;
750
- const popupAlias = `page${this._pageCount}`;
751
- this._currentPageAlias = popupAlias;
752
- this._pendingPopupAlias = popupAlias;
753
- traceDebug(`Popup page opened: ${popupAlias} (pending stamp)`);
754
- });
755
- } catch {
732
+ _installPopupListener(browserContext) {
733
+ let initialPageSeen = false;
734
+ browserContext.on("page", () => {
735
+ if (!initialPageSeen) {
736
+ initialPageSeen = true;
737
+ return;
738
+ }
739
+ if (this._reloading) {
740
+ traceDebug("Ignoring spurious popup opened during reload");
741
+ return;
756
742
  }
757
- })();
743
+ this._pageCount++;
744
+ const popupAlias = `page${this._pageCount}`;
745
+ this._currentPageAlias = popupAlias;
746
+ this._pendingPopupAlias = popupAlias;
747
+ traceDebug(`Popup page opened: ${popupAlias} (pending stamp)`);
748
+ });
758
749
  }
759
750
  _maybeTrackAction(toolName, args, result, timestamp, pageAliasBeforeAction) {
760
751
  if (result.isError)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "main": "build/index.js",
5
5
  "exports": {
6
6
  ".": "./build/index.js",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@modelcontextprotocol/sdk": "^1.24.3",
57
57
  "@playwright/test": "^1.55.0",
58
- "@skyramp/skyramp": "1.3.23",
58
+ "@skyramp/skyramp": "1.3.24",
59
59
  "dockerode": "^5.0.0",
60
60
  "fast-glob": "^3.3.3",
61
61
  "js-yaml": "^4.1.1",