@skyramp/mcp 0.2.0-rc.2 → 0.2.0-rc.3

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) : '';
@@ -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-rc.3",
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"