@skyramp/mcp 0.2.0-rc.2 → 0.2.0

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.
@@ -78,7 +78,15 @@ ${buildDriftAnalysisPrompt({ existingTests: [], scannedEndpoints: [], repository
78
78
  - Incorrect arithmetic in business logic (discount calculations, price aggregation)
79
79
  Log each finding in \`issuesFound\` with a \`severity\` (critical/high/medium/low). These bugs should inform your test design in Task 2.
80
80
 
81
- 5. **Apply the UI Recommendation Authoring Rules.** \`skyramp_analyze_changes\` returns an authoring-rules section that defines how UI recommendation \`reasoning\` fields should be written (natural prose, no internal-identifier syntax, ground in elements observed via earlier \`browser_blueprint\` calls, fall back to source-grounded prose when no captures are available). Apply those rules when authoring UI rec reasoning. Non-UI recommendations (contract / integration / e2e / batch-scenario) are unaffected by these rules and use their pre-existing formats do not reformat them.
81
+ 5. **Blueprint Citation Invariant** (UI test recommendations only). When step 2 returned recommendations grounded in the captured blueprints from step 1, every named UI element your recommendation \`reasoning\` mentions heading text, button label, link text, role descriptions must correspond to an element actually present in one of those captured blueprints.
82
+
83
+ Write the \`reasoning\` field in **natural prose** that names the elements as a human would describe them ("the Notifications heading", "the disabled Mark all as read button"). Do NOT use internal-identifier syntax like \`role=button, logicalName=...\` — that jargon leaks builder internals into a user-facing report.
84
+
85
+ Self-check before submitting: for each UI recommendation's \`reasoning\`, every element you mention by name should appear in one of the captured blueprints. If an element name doesn't appear in any blueprint, either rewrite the reasoning around an element that IS captured, or drop the element reference and describe the test target in higher-level terms ("the empty state of the notifications page"). Do not invent element names from the PR description, source diff, or component name.
86
+
87
+ **Non-UI entries (contract / integration / e2e / batch-scenario) are unaffected.** Their \`reasoning\` fields use the pre-existing formats — endpoint paths, request/response schemas, fixture chains. Do not reformat them.
88
+
89
+ **No upstream captures available?** If step 1 produced no candidate URLs or \`browser_blueprint\` failed on every candidate, all UI recommendations fall back to source-grounded prose drawn from the diff alone. Log the failure mode once in \`issuesFound\`. Non-UI work is unaffected.
82
90
 
83
91
  ---`;
84
92
  const serviceContext = services?.length ? buildServiceContext(services) : '';
@@ -323,7 +331,7 @@ Call \`skyramp_submit_report\` with \`summaryOutputFile\`: "${summaryOutputFile}
323
331
  - **additionalRecommendations**: AT MOST ${maxRecommendations - maxGenerate} items.
324
332
  - For \`testType: "contract"\` entries: **\`primaryEndpoint\` is required** (e.g. \`"GET /api/v1/users/{user_id}"\`). The tool will reject the submission without it — do not omit it or you will be forced to resubmit.
325
333
  - For \`testType: "integration"\` or \`"e2e"\` entries: omit \`primaryEndpoint\` — use \`description\` to list the endpoints involved instead.
326
- - **testMaintenance**: Use \`[]\` **only** if no existing Skyramp tests were found in the repository. If existing tests were found (any score), include one entry per test. For UPDATE/REGENERATE/DELETE tests that were modified and executed, populate all fields from real before/after execution results. For IGNORE-scored tests (not modified or executed), derive \`beforeStatus\` from the \`skyramp_analyze_test_health\` health score (typically \`"Pass"\` if drift score is 0 and no health issues were flagged), set \`afterStatus\` to \`"Skipped"\`, and use \`afterDetails\` to explain why (e.g. "IGNORE: drift score 0 — endpoint not modified in this PR"). Do **not** add entries for tests that were not returned by the health analysis.
334
+ - **testMaintenance**: Use \`[]\` **only** if no existing Skyramp tests were found in the repository. If existing tests were found (any score), include one entry per test. Set \`action\` to the exact drift action you chose from the Action Decision Matrix (\`UPDATE\`, \`REGENERATE\`, \`DELETE\`, \`VERIFY\`, or \`IGNORE\`). For UPDATE/REGENERATE/DELETE tests that were modified and executed, populate all fields from real before/after execution results. For VERIFY/IGNORE tests (not modified), derive \`beforeStatus\` from the \`skyramp_analyze_test_health\` health score (typically \`"Pass"\` if drift score is 0 and no health issues were flagged), set \`afterStatus\` to \`"Skipped"\`, and use \`afterDetails\` to explain why (e.g. "IGNORE: drift score 0 — endpoint not modified in this PR"). Do **not** add entries for tests that were not returned by the health analysis.
327
335
 
328
336
  ---
329
337
 
@@ -2,7 +2,7 @@ import { pushToolEvent } from "@skyramp/skyramp";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
- import { getEntryPoint, getCIPlatform } from "../utils/telemetry.js";
5
+ import { getEntryPoint, getCIPlatform, getRepositoryInfo, } from "../utils/telemetry.js";
6
6
  import { logger } from "../utils/logger.js";
7
7
  export class AnalyticsService {
8
8
  static async pushTestGenerationToolEvent(toolName, result, params) {
@@ -29,6 +29,10 @@ export class AnalyticsService {
29
29
  if (ciPlatform) {
30
30
  params.ciPlatform = ciPlatform;
31
31
  }
32
+ // Prefer a repo path the tool already supplied — the MCP server's own
33
+ // process.cwd() is set by the IDE and may not be the user's repo.
34
+ const repoPath = params.repositoryPath || params.workspacePath;
35
+ Object.assign(params, await getRepositoryInfo(repoPath));
32
36
  await pushToolEvent(getEntryPoint(), toolName, errorMessage, params);
33
37
  }
34
38
  catch (error) {
@@ -5,6 +5,15 @@ import * as path from "path";
5
5
  import { AnalyticsService } from "../services/AnalyticsService.js";
6
6
  import { TEST_CATEGORIES, externalCategory } from "../types/TestRecommendation.js";
7
7
  import { TestType, HttpMethod } from "../types/TestTypes.js";
8
+ import { DriftAction } from "../types/TestAnalysis.js";
9
+ // Drift actions that actually modify a test file. VERIFY and IGNORE are
10
+ // no-ops (the test was assessed but left unchanged), so they must not count
11
+ // toward "tests maintained" telemetry.
12
+ const MAINTENANCE_CHANGE_ACTIONS = new Set([
13
+ DriftAction.Update,
14
+ DriftAction.Regenerate,
15
+ DriftAction.Delete,
16
+ ]);
8
17
  const TOOL_NAME = "skyramp_submit_report";
9
18
  const DEFAULT_COMMIT_MESSAGE = "Added recommendations by Skyramp Testbot.";
10
19
  const testResultSchema = z.object({
@@ -73,12 +82,58 @@ const testMaintenanceSchema = z.object({
73
82
  testType: z.nativeEnum(TestType).describe("Type of test."),
74
83
  endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
75
84
  fileName: z.string().describe("Test file that was maintained, e.g. 'products_smoke_test.py'"),
85
+ action: z.nativeEnum(DriftAction).optional().describe("The drift action taken for this test, exactly as decided by the Action Decision Matrix: UPDATE, REGENERATE, or DELETE modify the test; VERIFY or IGNORE leave it unchanged (no-op)."),
76
86
  description: z.string().describe("What was changed and why"),
77
87
  beforeStatus: z.enum(["Pass", "Fail", "Error"]).describe("Test result BEFORE modification"),
78
88
  beforeDetails: z.string().describe("Execution output/timing before modification, or 'baseline from CI workflow <name>' if a parallel workflow provided the baseline"),
79
89
  afterStatus: z.enum(["Pass", "Fail", "Error", "Skipped"]).describe("Test result AFTER modification"),
80
90
  afterDetails: z.string().describe("Execution output/timing after modification"),
81
91
  });
92
+ /**
93
+ * Derive per-run analytics counts from a submitted report. These power the
94
+ * alpha-launch dashboards (tests generated/maintained, suite growth, bugs vs
95
+ * test failures, flakiness recovery). All values are stringified for Amplitude.
96
+ *
97
+ * Bug-vs-failure distinction:
98
+ * - issuesFound* = bugs/issues the agent flagged (real defects + severity)
99
+ * - testsFailed = test executions that returned "Fail" (test-level outcome)
100
+ * These are intentionally separate metrics — a failing test is not always a bug.
101
+ *
102
+ * maintenanceRecovered approximates flakiness/regression fixes: tests that were
103
+ * Fail/Error before maintenance and Pass afterward.
104
+ *
105
+ * testsMaintained counts only entries that actually changed a test file
106
+ * (action UPDATE/REGENERATE/DELETE). VERIFY/IGNORE entries are reported for
107
+ * transparency but are no-ops, so they are excluded. When `action` is absent
108
+ * (older reports), we fall back to the status heuristic: an IGNORE no-op sets
109
+ * afterStatus to "Skipped", so anything else is treated as a real change.
110
+ */
111
+ function isMaintenanceChange(m) {
112
+ if (m.action) {
113
+ return MAINTENANCE_CHANGE_ACTIONS.has(m.action);
114
+ }
115
+ return m.afterStatus !== "Skipped";
116
+ }
117
+ function computeReportMetrics(params) {
118
+ const recommendations = params.additionalRecommendations ?? [];
119
+ const countBy = (items, pred) => items.filter(pred).length;
120
+ const changedMaintenance = params.testMaintenance.filter(isMaintenanceChange);
121
+ const maintenanceRecovered = countBy(changedMaintenance, (m) => m.beforeStatus !== "Pass" && m.afterStatus === "Pass");
122
+ return {
123
+ testsGenerated: String(params.newTestsCreated.length),
124
+ testsMaintained: String(changedMaintenance.length),
125
+ recommendationsCount: String(recommendations.length),
126
+ maintenanceRecovered: String(maintenanceRecovered),
127
+ testsPassed: String(countBy(params.testResults, (t) => t.status === "Pass")),
128
+ testsFailed: String(countBy(params.testResults, (t) => t.status === "Fail")),
129
+ testsSkipped: String(countBy(params.testResults, (t) => t.status === "Skipped")),
130
+ issuesFound: String(params.issuesFound.length),
131
+ issuesCritical: String(countBy(params.issuesFound, (i) => i.severity === "critical")),
132
+ issuesHigh: String(countBy(params.issuesFound, (i) => i.severity === "high")),
133
+ issuesMedium: String(countBy(params.issuesFound, (i) => i.severity === "medium")),
134
+ issuesLow: String(countBy(params.issuesFound, (i) => i.severity === "low")),
135
+ };
136
+ }
82
137
  export function registerSubmitReportTool(server) {
83
138
  server.registerTool(TOOL_NAME, {
84
139
  description: "Submit the final testbot report. Call this tool once after completing all test analysis, generation, and execution. " +
@@ -201,6 +256,7 @@ export function registerSubmitReportTool(server) {
201
256
  summary_output_file: params.summaryOutputFile,
202
257
  testResultCount: String(params.testResults.length),
203
258
  payloadBytes: String(reportJson.length),
259
+ ...computeReportMetrics(params),
204
260
  }).catch(() => { });
205
261
  }
206
262
  });
@@ -1,6 +1,7 @@
1
1
  // @ts-ignore
2
2
  import { registerSubmitReportTool, additionalRecommendationSchema } from "./submitReportTool.js";
3
3
  import { TestType } from "../types/TestTypes.js";
4
+ import { AnalyticsService } from "../services/AnalyticsService.js";
4
5
  import * as fs from "fs/promises";
5
6
  import * as path from "path";
6
7
  import * as os from "os";
@@ -10,6 +11,7 @@ jest.mock("../utils/logger.js", () => ({
10
11
  jest.mock("../services/AnalyticsService.js", () => ({
11
12
  AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) },
12
13
  }));
14
+ const pushMCPToolEventMock = AnalyticsService.pushMCPToolEvent;
13
15
  function captureToolHandler() {
14
16
  let handler;
15
17
  const fakeServer = {
@@ -290,4 +292,108 @@ describe("registerSubmitReportTool", () => {
290
292
  expect(result.data.primaryEndpoint).toBeUndefined();
291
293
  }
292
294
  });
295
+ describe("analytics metrics", () => {
296
+ beforeEach(() => {
297
+ pushMCPToolEventMock.mockClear();
298
+ });
299
+ function lastAnalyticsParams() {
300
+ expect(pushMCPToolEventMock).toHaveBeenCalled();
301
+ const calls = pushMCPToolEventMock.mock.calls;
302
+ return calls[calls.length - 1][2];
303
+ }
304
+ it("emits per-run count metrics derived from the report", async () => {
305
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
306
+ tmpDirs.push(tmpDir);
307
+ const outputFile = path.join(tmpDir, "report.json");
308
+ // sampleReportParams: 1 new test, 1 maintenance (Fail→Pass), 2 results
309
+ // (1 Pass + 1 Fail), 1 issue (no severity).
310
+ await handler(sampleReportParams(outputFile));
311
+ const params = lastAnalyticsParams();
312
+ expect(params.testsGenerated).toBe("1");
313
+ expect(params.testsMaintained).toBe("1");
314
+ expect(params.recommendationsCount).toBe("0");
315
+ expect(params.maintenanceRecovered).toBe("1");
316
+ expect(params.testsPassed).toBe("1");
317
+ expect(params.testsFailed).toBe("1");
318
+ expect(params.testsSkipped).toBe("0");
319
+ expect(params.issuesFound).toBe("1");
320
+ });
321
+ it("breaks issues down by severity", async () => {
322
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
323
+ tmpDirs.push(tmpDir);
324
+ const outputFile = path.join(tmpDir, "report.json");
325
+ await handler({
326
+ ...sampleReportParams(outputFile),
327
+ issuesFound: [
328
+ { description: "Data corruption on checkout", severity: "critical" },
329
+ { description: "Wrong total returned", severity: "high" },
330
+ { description: "Another wrong total", severity: "high" },
331
+ { description: "Minor gap", severity: "medium" },
332
+ { description: "Unlabeled issue" },
333
+ ],
334
+ });
335
+ const params = lastAnalyticsParams();
336
+ expect(params.issuesFound).toBe("5");
337
+ expect(params.issuesCritical).toBe("1");
338
+ expect(params.issuesHigh).toBe("2");
339
+ expect(params.issuesMedium).toBe("1");
340
+ expect(params.issuesLow).toBe("0");
341
+ });
342
+ it("reports zero metrics for an empty report", async () => {
343
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
344
+ tmpDirs.push(tmpDir);
345
+ const outputFile = path.join(tmpDir, "report.json");
346
+ await handler({
347
+ summaryOutputFile: outputFile,
348
+ businessCaseAnalysis: "Config-only change.",
349
+ newTestsCreated: [],
350
+ testMaintenance: [],
351
+ testResults: [],
352
+ issuesFound: [],
353
+ });
354
+ const params = lastAnalyticsParams();
355
+ expect(params.testsGenerated).toBe("0");
356
+ expect(params.testsMaintained).toBe("0");
357
+ expect(params.maintenanceRecovered).toBe("0");
358
+ expect(params.testsPassed).toBe("0");
359
+ expect(params.issuesFound).toBe("0");
360
+ });
361
+ it("testsMaintained counts only change actions, not VERIFY/IGNORE no-ops", async () => {
362
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
363
+ tmpDirs.push(tmpDir);
364
+ const outputFile = path.join(tmpDir, "report.json");
365
+ await handler({
366
+ ...sampleReportParams(outputFile),
367
+ testMaintenance: [
368
+ // Real change
369
+ { action: "UPDATE", testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Patched auth", beforeStatus: "Fail", beforeDetails: "401 (1.5s)", afterStatus: "Pass", afterDetails: "passed (2.3s)" },
370
+ { action: "REGENERATE", testType: TestType.SMOKE, endpoint: "GET /api/v1/orders", fileName: "orders_smoke_test.py", description: "Rewrote for new shape", beforeStatus: "Fail", beforeDetails: "shape mismatch", afterStatus: "Pass", afterDetails: "passed (1.0s)" },
371
+ // No-ops — assessed but not modified
372
+ { action: "IGNORE", testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews", fileName: "reviews_smoke_test.py", description: "No action required", beforeStatus: "Pass", beforeDetails: "drift score 0", afterStatus: "Skipped", afterDetails: "IGNORE: endpoint not in PR" },
373
+ { action: "VERIFY", testType: TestType.SMOKE, endpoint: "GET /api/v1/coupons", fileName: "coupons_smoke_test.py", description: "Verified still valid", beforeStatus: "Pass", beforeDetails: "minor drift", afterStatus: "Skipped", afterDetails: "VERIFY: assertions still hold" },
374
+ ],
375
+ });
376
+ const params = lastAnalyticsParams();
377
+ // 2 change actions (UPDATE + REGENERATE), not 4
378
+ expect(params.testsMaintained).toBe("2");
379
+ // Both changes went Fail→Pass
380
+ expect(params.maintenanceRecovered).toBe("2");
381
+ });
382
+ it("falls back to afterStatus heuristic when action is absent", async () => {
383
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
384
+ tmpDirs.push(tmpDir);
385
+ const outputFile = path.join(tmpDir, "report.json");
386
+ await handler({
387
+ ...sampleReportParams(outputFile),
388
+ testMaintenance: [
389
+ // No action field — afterStatus !== "Skipped" → counts as a change
390
+ { testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Patched", beforeStatus: "Fail", beforeDetails: "401", afterStatus: "Pass", afterDetails: "passed" },
391
+ // No action field — afterStatus === "Skipped" → treated as no-op
392
+ { testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews", fileName: "reviews_smoke_test.py", description: "No action required", beforeStatus: "Pass", beforeDetails: "drift 0", afterStatus: "Skipped", afterDetails: "not in PR" },
393
+ ],
394
+ });
395
+ const params = lastAnalyticsParams();
396
+ expect(params.testsMaintained).toBe("1");
397
+ });
398
+ });
293
399
  });
@@ -3,7 +3,11 @@
3
3
  * "testbot" when running as part of the GitHub Action test bot,
4
4
  * "mcp" for regular IDE/MCP usage.
5
5
  */
6
+ import { execFile } from "child_process";
7
+ import { promisify } from "util";
6
8
  import { isTestbotEnabled } from "./featureFlags.js";
9
+ import { logger } from "./logger.js";
10
+ const execFileAsync = promisify(execFile);
7
11
  export function getEntryPoint() {
8
12
  return isTestbotEnabled() ? "testbot" : "mcp";
9
13
  }
@@ -22,3 +26,69 @@ export function getCIPlatform() {
22
26
  return "circleci";
23
27
  return undefined;
24
28
  }
29
+ /**
30
+ * Parses "owner/repo" out of a git remote URL. Handles both HTTPS
31
+ * (https://github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git)
32
+ * forms, with or without the trailing ".git". Returns undefined if no match.
33
+ */
34
+ export function parseRepositoryFromRemoteUrl(remoteUrl) {
35
+ const match = remoteUrl.trim().match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
36
+ if (!match)
37
+ return undefined;
38
+ const [, owner, repo] = match;
39
+ return { repository: `${owner}/${repo}`, repositoryOwner: owner };
40
+ }
41
+ // Memoize the local git lookup per working directory so we shell out at most
42
+ // once per cwd for the lifetime of the MCP server process. The value is the
43
+ // resolved RepositoryInfo (possibly empty when not a git repo / no remote).
44
+ const localRepoInfoCache = new Map();
45
+ async function getLocalRepositoryInfo(cwd) {
46
+ const cached = localRepoInfoCache.get(cwd);
47
+ if (cached)
48
+ return cached;
49
+ const lookup = (async () => {
50
+ try {
51
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "remote", "get-url", "origin"], { timeout: 5_000 });
52
+ return parseRepositoryFromRemoteUrl(stdout) ?? {};
53
+ }
54
+ catch (error) {
55
+ // Not a git repo, no "origin" remote, or git not installed — attribute
56
+ // nothing rather than failing the telemetry call.
57
+ logger.debug("Could not resolve local git repository for telemetry", {
58
+ cwd,
59
+ error: error instanceof Error ? error.message : String(error),
60
+ });
61
+ return {};
62
+ }
63
+ })();
64
+ localRepoInfoCache.set(cwd, lookup);
65
+ return lookup;
66
+ }
67
+ /**
68
+ * Resolves source-repository attribution for telemetry, segmenting usage per
69
+ * customer/repo.
70
+ *
71
+ * - In GitHub Actions (testbot), reads GITHUB_REPOSITORY / GITHUB_REPOSITORY_OWNER
72
+ * which the MCP subprocess inherits — fast, no shelling out.
73
+ * - In local/IDE usage, derives "owner/repo" from `git remote get-url origin`.
74
+ * The lookup directory is the caller-supplied `cwd` (a tool's repositoryPath
75
+ * or workspacePath) when available, since the MCP server's own process.cwd()
76
+ * is set by the IDE and is not necessarily the user's repo. Falls back to
77
+ * process.cwd(). Cached per directory so git is invoked at most once each.
78
+ *
79
+ * Returns an empty object when attribution can't be determined (e.g. not a git
80
+ * repo, no origin remote).
81
+ */
82
+ export async function getRepositoryInfo(cwd) {
83
+ if (process.env.GITHUB_ACTIONS === "true") {
84
+ const info = {};
85
+ if (process.env.GITHUB_REPOSITORY) {
86
+ info.repository = process.env.GITHUB_REPOSITORY;
87
+ }
88
+ if (process.env.GITHUB_REPOSITORY_OWNER) {
89
+ info.repositoryOwner = process.env.GITHUB_REPOSITORY_OWNER;
90
+ }
91
+ return info;
92
+ }
93
+ return getLocalRepositoryInfo(cwd || process.cwd());
94
+ }
@@ -0,0 +1,70 @@
1
+ import { getRepositoryInfo, parseRepositoryFromRemoteUrl } from "./telemetry.js";
2
+ describe("parseRepositoryFromRemoteUrl", () => {
3
+ it("parses an HTTPS remote with .git suffix", () => {
4
+ expect(parseRepositoryFromRemoteUrl("https://github.com/acme-corp/their-api.git")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
5
+ });
6
+ it("parses an HTTPS remote without .git suffix", () => {
7
+ expect(parseRepositoryFromRemoteUrl("https://github.com/acme-corp/their-api")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
8
+ });
9
+ it("parses an SSH remote", () => {
10
+ expect(parseRepositoryFromRemoteUrl("git@github.com:acme-corp/their-api.git")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
11
+ });
12
+ it("trims trailing whitespace/newline from git output", () => {
13
+ expect(parseRepositoryFromRemoteUrl("git@github.com:acme-corp/their-api.git\n")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
14
+ });
15
+ it("returns undefined for an unparseable URL", () => {
16
+ expect(parseRepositoryFromRemoteUrl("not-a-remote")).toBeUndefined();
17
+ });
18
+ });
19
+ describe("getRepositoryInfo", () => {
20
+ const originalEnv = { ...process.env };
21
+ afterEach(() => {
22
+ process.env = { ...originalEnv };
23
+ });
24
+ describe("in GitHub Actions", () => {
25
+ beforeEach(() => {
26
+ process.env.GITHUB_ACTIONS = "true";
27
+ });
28
+ it("returns repository and owner from env vars", async () => {
29
+ process.env.GITHUB_REPOSITORY = "acme-corp/their-api";
30
+ process.env.GITHUB_REPOSITORY_OWNER = "acme-corp";
31
+ await expect(getRepositoryInfo()).resolves.toEqual({
32
+ repository: "acme-corp/their-api",
33
+ repositoryOwner: "acme-corp",
34
+ });
35
+ });
36
+ it("includes only the fields that are present", async () => {
37
+ process.env.GITHUB_REPOSITORY = "acme-corp/their-api";
38
+ delete process.env.GITHUB_REPOSITORY_OWNER;
39
+ await expect(getRepositoryInfo()).resolves.toEqual({
40
+ repository: "acme-corp/their-api",
41
+ });
42
+ });
43
+ });
44
+ describe("local / IDE", () => {
45
+ beforeEach(() => {
46
+ delete process.env.GITHUB_ACTIONS;
47
+ });
48
+ it("derives repository info from the local git remote", async () => {
49
+ // The test suite itself runs inside the mcp git repo (origin = letsramp/mcp),
50
+ // so a local lookup should resolve owner=letsramp, repo=mcp.
51
+ const info = await getRepositoryInfo();
52
+ expect(info.repositoryOwner).toBe("letsramp");
53
+ expect(info.repository).toBe("letsramp/mcp");
54
+ });
55
+ it("uses the caller-supplied cwd when provided", async () => {
56
+ // Passing the repo path explicitly (as tools do via repositoryPath)
57
+ // resolves the same remote even if process.cwd() differs.
58
+ const info = await getRepositoryInfo(process.cwd());
59
+ expect(info.repository).toBe("letsramp/mcp");
60
+ });
61
+ it("ignores GITHUB_REPOSITORY env when not in GitHub Actions", async () => {
62
+ // A stray env var must not be trusted outside Actions; the git remote is
63
+ // the source of truth locally.
64
+ process.env.GITHUB_REPOSITORY = "spoofed/repo";
65
+ process.env.GITHUB_REPOSITORY_OWNER = "spoofed";
66
+ const info = await getRepositoryInfo();
67
+ expect(info.repository).not.toBe("spoofed/repo");
68
+ });
69
+ });
70
+ });
@@ -278,11 +278,12 @@ async function domEvaluationScript() {
278
278
  "option"
279
279
  ]);
280
280
  function findDescendantIconName(el) {
281
- const descendants = el.querySelectorAll('[data-icon], svg title, [href*="#icon-"]');
282
- let count = 0;
283
- for (const d of Array.from(descendants)) {
284
- if (++count > 6)
285
- break;
281
+ const descendants = el.querySelectorAll(
282
+ '[data-icon], svg > title, use[href*="#icon-"], use[*|href*="#icon-"]'
283
+ );
284
+ const cap = Math.min(descendants.length, 6);
285
+ for (let i = 0; i < cap; i++) {
286
+ const d = descendants[i];
286
287
  const dataIcon = d.getAttribute("data-icon");
287
288
  if (dataIcon && dataIcon.trim())
288
289
  return dataIcon.trim();
@@ -18,9 +18,24 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var blueprintCache_exports = {};
20
20
  __export(blueprintCache_exports, {
21
- BlueprintCache: () => BlueprintCache
21
+ BlueprintCache: () => BlueprintCache,
22
+ DEFAULT_BLUEPRINT_CACHE_SIZE: () => DEFAULT_BLUEPRINT_CACHE_SIZE,
23
+ resolveBlueprintCacheSize: () => resolveBlueprintCacheSize
22
24
  });
23
25
  module.exports = __toCommonJS(blueprintCache_exports);
26
+ const DEFAULT_BLUEPRINT_CACHE_SIZE = 10;
27
+ function resolveBlueprintCacheSize() {
28
+ const raw = process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE;
29
+ if (raw === void 0 || raw === "") return DEFAULT_BLUEPRINT_CACHE_SIZE;
30
+ const parsed = Number.parseInt(raw, 10);
31
+ if (!Number.isFinite(parsed) || parsed < 1) {
32
+ console.warn(
33
+ `SKYRAMP_BLUEPRINT_CACHE_SIZE=${raw} is invalid (expected positive integer); using default ${DEFAULT_BLUEPRINT_CACHE_SIZE}`
34
+ );
35
+ return DEFAULT_BLUEPRINT_CACHE_SIZE;
36
+ }
37
+ return parsed;
38
+ }
24
39
  class BlueprintCache {
25
40
  constructor(max) {
26
41
  this.map = /* @__PURE__ */ new Map();
@@ -53,5 +68,7 @@ class BlueprintCache {
53
68
  }
54
69
  // Annotate the CommonJS export names for ESM import in node:
55
70
  0 && (module.exports = {
56
- BlueprintCache
71
+ BlueprintCache,
72
+ DEFAULT_BLUEPRINT_CACHE_SIZE,
73
+ resolveBlueprintCacheSize
57
74
  });
@@ -55,3 +55,50 @@ function bp(url, pageHash) {
55
55
  (0, import_vitest.expect)(c.get("http://a/")).toBeUndefined();
56
56
  });
57
57
  });
58
+ (0, import_vitest.describe)("resolveBlueprintCacheSize", () => {
59
+ let originalEnv;
60
+ let warnSpy;
61
+ (0, import_vitest.beforeEach)(() => {
62
+ originalEnv = process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE;
63
+ warnSpy = import_vitest.vi.spyOn(console, "warn").mockImplementation(() => {
64
+ });
65
+ });
66
+ (0, import_vitest.afterEach)(() => {
67
+ if (originalEnv === void 0)
68
+ delete process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE;
69
+ else
70
+ process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = originalEnv;
71
+ warnSpy.mockRestore();
72
+ });
73
+ (0, import_vitest.it)("returns the default when env var is unset", () => {
74
+ delete process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE;
75
+ (0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
76
+ });
77
+ (0, import_vitest.it)("returns the default when env var is empty", () => {
78
+ process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "";
79
+ (0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
80
+ });
81
+ (0, import_vitest.it)("parses a valid positive integer override", () => {
82
+ process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "25";
83
+ (0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(25);
84
+ });
85
+ (0, import_vitest.it)("parses minimum value 1", () => {
86
+ process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "1";
87
+ (0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(1);
88
+ });
89
+ (0, import_vitest.it)("warns and falls back to default for non-numeric values", () => {
90
+ process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "not-a-number";
91
+ (0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
92
+ (0, import_vitest.expect)(warnSpy).toHaveBeenCalledOnce();
93
+ });
94
+ (0, import_vitest.it)("warns and falls back to default for zero", () => {
95
+ process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "0";
96
+ (0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
97
+ (0, import_vitest.expect)(warnSpy).toHaveBeenCalledOnce();
98
+ });
99
+ (0, import_vitest.it)("warns and falls back to default for negative values", () => {
100
+ process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "-5";
101
+ (0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
102
+ (0, import_vitest.expect)(warnSpy).toHaveBeenCalledOnce();
103
+ });
104
+ });
@@ -37,11 +37,19 @@ const INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
37
37
  "dialog",
38
38
  "alertdialog"
39
39
  ]);
40
+ const LIVE_REGION_ROLES = /* @__PURE__ */ new Set([
41
+ "alert",
42
+ "status",
43
+ "log",
44
+ "marquee",
45
+ "timer"
46
+ ]);
40
47
  function escapeForSingleQuote(s) {
41
- let cleaned = s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/[\r\n]+/g, " ");
42
- if (cleaned.length > 80)
43
- cleaned = cleaned.slice(0, 79) + "\u2026";
44
- return cleaned;
48
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/[\r\n]+/g, " ");
49
+ }
50
+ function truncateForDisplay(s, max = 80) {
51
+ if (s.length <= max) return s;
52
+ return s.slice(0, max - 1) + "\u2026";
45
53
  }
46
54
  function buildPossibleAssertions(delta) {
47
55
  const results = [];
@@ -53,14 +61,18 @@ function buildPossibleAssertions(delta) {
53
61
  const code = `await expect(page.getByRole('${el.role}', { name: '${escapedName}' })).toBeVisible();`;
54
62
  let rationale;
55
63
  let tier;
64
+ const displayName = truncateForDisplay(el.accessibleName);
56
65
  if (INTERACTIVE_ROLES.has(el.role)) {
57
- rationale = `Element added to DOM after action: ${el.role} "${el.accessibleName}"`;
66
+ rationale = `Element added to DOM after action: ${el.role} "${displayName}"`;
58
67
  tier = "MEDIUM";
59
68
  } else if (el.role === "heading") {
60
- rationale = `Heading appeared after action: "${el.accessibleName}"`;
69
+ rationale = `Heading appeared after action: "${displayName}"`;
61
70
  tier = "MEDIUM";
71
+ } else if (LIVE_REGION_ROLES.has(el.role)) {
72
+ rationale = `${el.role} live region appeared: "${displayName}"`;
73
+ tier = "LOW";
62
74
  } else {
63
- rationale = `${el.role} live region appeared: "${el.accessibleName}"`;
75
+ rationale = `${el.role} appeared after action: "${displayName}"`;
64
76
  tier = "LOW";
65
77
  }
66
78
  results.push({ code, rationale, tier });
@@ -69,15 +81,10 @@ function buildPossibleAssertions(delta) {
69
81
  if (!el.accessibleName.trim()) continue;
70
82
  const escapedName = escapeForSingleQuote(el.accessibleName);
71
83
  const code = `await expect(page.getByRole('${el.role}', { name: '${escapedName}' })).not.toBeVisible();`;
72
- let tier;
73
- if (INTERACTIVE_ROLES.has(el.role)) {
74
- tier = "MEDIUM";
75
- } else {
76
- tier = "LOW";
77
- }
84
+ const tier = INTERACTIVE_ROLES.has(el.role) ? "MEDIUM" : "LOW";
78
85
  results.push({
79
86
  code,
80
- rationale: `Element removed from DOM after action: ${el.role} "${el.accessibleName}"`,
87
+ rationale: `Element removed from DOM after action: ${el.role} "${truncateForDisplay(el.accessibleName)}"`,
81
88
  tier
82
89
  });
83
90
  }
@@ -89,15 +96,15 @@ function buildPossibleAssertions(delta) {
89
96
  const code = `await expect(page.getByRole('${tc.role}', { name: '${escapedAccessibleName}' })).toHaveText('${escapedAfter}');`;
90
97
  results.push({
91
98
  code,
92
- rationale: `Text changed: "${tc.before}" \u2192 "${tc.after}"`,
99
+ rationale: `Text changed: "${truncateForDisplay(tc.before)}" \u2192 "${truncateForDisplay(tc.after)}"`,
93
100
  tier: "HIGH"
94
101
  });
95
102
  }
96
103
  for (const rc of delta.repeatingCountChanges) {
97
104
  if (rc.before === rc.after) continue;
98
105
  if (!rc.accessibleNameTemplate.trim()) continue;
99
- const regexPattern = templateToRegex(rc.accessibleNameTemplate);
100
- const code = `await expect(page.getByRole('${rc.role}', { name: ${regexPattern} })).toHaveCount(${rc.after});`;
106
+ const regexExpr = templateToRegex(rc.accessibleNameTemplate);
107
+ const code = `await expect(page.getByRole('${rc.role}', { name: ${regexExpr} })).toHaveCount(${rc.after});`;
101
108
  results.push({
102
109
  code,
103
110
  rationale: `Repeating element count changed: ${rc.before} \u2192 ${rc.after}`,
@@ -139,9 +146,9 @@ function findFirstHeading(blueprint) {
139
146
  return null;
140
147
  }
141
148
  function templateToRegex(template) {
142
- let pattern = template.replace(/[.+*?^$()|[\]\\]/g, "\\$&");
149
+ let pattern = template.replace(/[.+*?^$()|[\]\\\/]/g, "\\$&");
143
150
  pattern = pattern.replace(/\{[a-zA-Z0-9_]+\}/g, ".+");
144
- return `/^${pattern}$/i`;
151
+ return `new RegExp('${escapeForSingleQuote(`^${pattern}$`)}', 'i')`;
145
152
  }
146
153
  // Annotate the CommonJS export names for ESM import in node:
147
154
  0 && (module.exports = {
@@ -104,8 +104,8 @@ test("repeatingCountChanges 12 \u2192 13 \u2192 toHaveCount(13), HIGH", () => {
104
104
  assertEqual(assertions.length, 1);
105
105
  if (!assertions[0].code.includes("toHaveCount(13)"))
106
106
  throw new Error("code should include toHaveCount(13)");
107
- if (!assertions[0].code.includes("/^View details for order .+$/i"))
108
- throw new Error("code should include regex pattern from template");
107
+ if (!assertions[0].code.includes(`new RegExp('^View details for order .+$', 'i')`))
108
+ throw new Error("code should include RegExp constructor with template-derived pattern");
109
109
  assertEqual(assertions[0].tier, "HIGH");
110
110
  if (!assertions[0].rationale.includes("12") || !assertions[0].rationale.includes("13"))
111
111
  throw new Error("rationale should mention before and after counts");
@@ -450,6 +450,129 @@ test("Full capture escapes single-quotes in URL and heading", () => {
450
450
  assertEqual(assertions[0].code.includes(`\\'`), true);
451
451
  assertEqual(assertions[1].code.includes(`\\'`), true);
452
452
  });
453
+ test("long accessibleName is preserved in generated code, not truncated", () => {
454
+ const longName = "a".repeat(150);
455
+ const delta = {
456
+ hasStructuralChange: true,
457
+ sectionsAdded: [],
458
+ sectionsRemoved: [],
459
+ elementsAdded: [{
460
+ logicalName: "long_btn",
461
+ sectionLogicalName: "main",
462
+ role: "button",
463
+ accessibleName: longName
464
+ }],
465
+ elementsRemoved: [],
466
+ repeatingCountChanges: [],
467
+ repeatingItemsChanged: [],
468
+ textChanges: [],
469
+ enrichmentChanges: []
470
+ };
471
+ const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
472
+ assertEqual(assertions.length, 1);
473
+ if (!assertions[0].code.includes(longName))
474
+ throw new Error("full accessibleName must appear in generated code");
475
+ if (assertions[0].rationale.length > 200)
476
+ throw new Error("rationale should be truncated for display");
477
+ });
478
+ test("long text-change values preserved in toHaveText, truncated in rationale", () => {
479
+ const longAfter = "b".repeat(120);
480
+ const delta = {
481
+ hasStructuralChange: false,
482
+ sectionsAdded: [],
483
+ sectionsRemoved: [],
484
+ elementsAdded: [],
485
+ elementsRemoved: [],
486
+ repeatingCountChanges: [],
487
+ repeatingItemsChanged: [],
488
+ textChanges: [{
489
+ logicalName: "msg",
490
+ sectionLogicalName: "main",
491
+ role: "status",
492
+ accessibleName: "Status",
493
+ before: "short",
494
+ after: longAfter
495
+ }],
496
+ enrichmentChanges: []
497
+ };
498
+ const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
499
+ assertEqual(assertions.length, 1);
500
+ if (!assertions[0].code.includes(longAfter))
501
+ throw new Error("full after-text must appear in toHaveText() argument");
502
+ });
503
+ test("templateToRegex emits new RegExp(...) so slashes in the template do not break the literal", () => {
504
+ const delta = {
505
+ hasStructuralChange: true,
506
+ sectionsAdded: [],
507
+ sectionsRemoved: [],
508
+ elementsAdded: [],
509
+ elementsRemoved: [],
510
+ repeatingCountChanges: [{
511
+ logicalName: "page_link",
512
+ sectionLogicalName: "main",
513
+ role: "link",
514
+ accessibleNameTemplate: "A/B page {n}",
515
+ before: 0,
516
+ after: 3,
517
+ delta: 3
518
+ }],
519
+ repeatingItemsChanged: [],
520
+ textChanges: [],
521
+ enrichmentChanges: []
522
+ };
523
+ const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
524
+ assertEqual(assertions.length, 1);
525
+ if (!assertions[0].code.includes(`new RegExp('^A\\\\/B page .+$', 'i')`))
526
+ throw new Error(
527
+ "code should use new RegExp(...) constructor with escaped slash; got:\n" + assertions[0].code
528
+ );
529
+ });
530
+ test('non-interactive non-heading roles get role-agnostic rationale (not "live region")', () => {
531
+ const delta = {
532
+ hasStructuralChange: true,
533
+ sectionsAdded: [],
534
+ sectionsRemoved: [],
535
+ elementsAdded: [{
536
+ logicalName: "embed",
537
+ sectionLogicalName: "main",
538
+ role: "figure",
539
+ accessibleName: "Embedded preview"
540
+ }],
541
+ elementsRemoved: [],
542
+ repeatingCountChanges: [],
543
+ repeatingItemsChanged: [],
544
+ textChanges: [],
545
+ enrichmentChanges: []
546
+ };
547
+ const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
548
+ assertEqual(assertions.length, 1);
549
+ if (assertions[0].rationale.includes("live region"))
550
+ throw new Error('rationale should not call non-live-region roles "live region"');
551
+ if (!assertions[0].rationale.startsWith("figure appeared"))
552
+ throw new Error('rationale should be role-agnostic ("figure appeared..."); got: ' + assertions[0].rationale);
553
+ });
554
+ test('genuine live-region roles (status/alert) keep the "live region" rationale', () => {
555
+ const delta = {
556
+ hasStructuralChange: true,
557
+ sectionsAdded: [],
558
+ sectionsRemoved: [],
559
+ elementsAdded: [{
560
+ logicalName: "toast",
561
+ sectionLogicalName: "main",
562
+ role: "status",
563
+ accessibleName: "Saved"
564
+ }],
565
+ elementsRemoved: [],
566
+ repeatingCountChanges: [],
567
+ repeatingItemsChanged: [],
568
+ textChanges: [],
569
+ enrichmentChanges: []
570
+ };
571
+ const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
572
+ assertEqual(assertions.length, 1);
573
+ if (!assertions[0].rationale.includes("live region"))
574
+ throw new Error('status role should keep the "live region" rationale; got: ' + assertions[0].rationale);
575
+ });
453
576
  let passed = 0;
454
577
  let failed = 0;
455
578
  const failures = [];
@@ -59,7 +59,7 @@ class Tab extends import_events.EventEmitter {
59
59
  * same URL instead of the full payload. Cleared on tab close (not on
60
60
  * navigation — same-URL revisits should reuse the prior blueprint).
61
61
  */
62
- this.blueprintCache = new import_blueprintCache.BlueprintCache(10);
62
+ this.blueprintCache = new import_blueprintCache.BlueprintCache((0, import_blueprintCache.resolveBlueprintCacheSize)());
63
63
  this.context = context;
64
64
  this.page = page;
65
65
  this._onPageClose = onPageClose;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.2.0-rc.2",
3
+ "version": "0.2.0",
4
4
  "main": "build/index.js",
5
5
  "exports": {
6
6
  ".": "./build/index.js",
@@ -61,6 +61,7 @@
61
61
  "js-yaml": "^4.1.1",
62
62
  "playwright": "file:vendor/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz",
63
63
  "simple-git": "^3.30.0",
64
+ "typescript": "^5.8.3",
64
65
  "zod": "^3.25.3"
65
66
  },
66
67
  "devDependencies": {
@@ -73,8 +74,7 @@
73
74
  "@typescript-eslint/parser": "^8.0.0",
74
75
  "eslint": "^9.0.0",
75
76
  "jest": "^29.7.0",
76
- "ts-jest": "^29.3.4",
77
- "typescript": "^5.8.3"
77
+ "ts-jest": "^29.3.4"
78
78
  },
79
79
  "engines": {
80
80
  "node": ">=18"