@skyramp/mcp 0.1.8 → 0.2.0-rc.2

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 (122) hide show
  1. package/build/index.js +4 -2
  2. package/build/playwright/registerPlaywrightTools.js +12 -0
  3. package/build/playwright/traceRecordingPrompt.js +15 -0
  4. package/build/prompts/code-reuse.js +106 -7
  5. package/build/prompts/pom-aware-code-reuse.js +106 -7
  6. package/build/prompts/startTraceCollectionPrompts.js +37 -15
  7. package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
  11. package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
  12. package/build/prompts/test-recommendation/diffExecutionPlan.js +246 -117
  13. package/build/prompts/test-recommendation/promptPlan.js +290 -0
  14. package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
  15. package/build/prompts/test-recommendation/recommendationSections.js +4 -3
  16. package/build/prompts/test-recommendation/recommendationShared.js +23 -1
  17. package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
  18. package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
  19. package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
  20. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +316 -1
  21. package/build/prompts/testbot/testbot-prompts.js +73 -13
  22. package/build/prompts/testbot/testbot-prompts.test.js +114 -1
  23. package/build/resources/testbotResource.js +1 -1
  24. package/build/services/ScenarioGenerationService.integration.test.js +158 -0
  25. package/build/services/ScenarioGenerationService.js +47 -4
  26. package/build/services/ScenarioGenerationService.test.js +158 -22
  27. package/build/services/TestExecutionService.js +73 -15
  28. package/build/services/TestExecutionService.test.js +105 -0
  29. package/build/services/TestGenerationService.js +11 -1
  30. package/build/tools/executeSkyrampTestTool.js +1 -10
  31. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
  32. package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
  33. package/build/tools/generate-tests/generateUIRestTool.js +2 -0
  34. package/build/tools/test-management/actionsTool.js +152 -63
  35. package/build/tools/test-management/analyzeChangesTool.js +178 -64
  36. package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
  37. package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
  38. package/build/tools/test-management/index.js +1 -0
  39. package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
  40. package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
  41. package/build/tools/trace/resolveSaveStoragePath.js +16 -0
  42. package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
  43. package/build/tools/trace/resolveSessionPaths.js +39 -0
  44. package/build/tools/trace/resolveSessionPaths.test.js +103 -0
  45. package/build/tools/trace/sessionState.js +14 -0
  46. package/build/tools/trace/sessionState.test.js +17 -0
  47. package/build/tools/trace/startTraceCollectionTool.js +84 -14
  48. package/build/tools/trace/stopTraceCollectionTool.js +9 -2
  49. package/build/types/TestAnalysis.js +50 -0
  50. package/build/types/TestRecommendation.js +6 -58
  51. package/build/types/TestTypes.js +1 -1
  52. package/build/utils/AnalysisStateManager.js +22 -11
  53. package/build/utils/branchDiff.js +11 -2
  54. package/build/utils/docker.test.js +1 -1
  55. package/build/utils/gitStaging.js +52 -3
  56. package/build/utils/gitStaging.test.js +19 -1
  57. package/build/utils/repoScanner.js +18 -10
  58. package/build/utils/repoScanner.test.js +92 -0
  59. package/build/utils/routeParsers.js +180 -25
  60. package/build/utils/routeParsers.test.js +180 -1
  61. package/build/utils/scenarioDrafting.js +220 -17
  62. package/build/utils/scenarioDrafting.test.js +182 -9
  63. package/build/utils/sourceRouteExtractor.js +806 -0
  64. package/build/utils/sourceRouteExtractor.test.js +565 -0
  65. package/build/utils/uiPageEnumerator.js +319 -0
  66. package/build/utils/uiPageEnumerator.test.js +422 -0
  67. package/build/utils/utils.js +27 -0
  68. package/build/utils/versions.js +1 -1
  69. package/build/utils/workspaceAuth.js +33 -4
  70. package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
  71. package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
  72. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
  73. package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
  74. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
  75. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
  76. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
  77. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
  78. package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
  79. package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
  80. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
  81. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
  82. package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
  83. package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
  84. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
  85. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
  86. package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
  87. package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
  88. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
  89. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
  90. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
  91. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
  92. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
  93. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
  94. package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
  95. package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
  96. package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
  97. package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
  98. package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
  99. package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
  100. package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
  101. package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
  102. package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
  103. package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
  104. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
  105. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
  106. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
  107. package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
  108. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
  109. package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
  110. package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
  111. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
  112. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
  113. package/node_modules/playwright/package.json +1 -1
  114. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
  115. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
  116. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
  117. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
  118. package/package.json +3 -3
  119. package/build/services/TestHealthService.js +0 -694
  120. package/build/services/TestHealthService.test.js +0 -241
  121. package/build/types/TestDriftAnalysis.js +0 -1
  122. package/build/types/TestHealth.js +0 -4
@@ -5,9 +5,16 @@ import path from "path";
5
5
  import { AnalyticsService } from "../../services/AnalyticsService.js";
6
6
  import { buildDriftAnalysisPrompt } from "../../prompts/test-maintenance/drift-analysis-prompt.js";
7
7
  import { TestSource } from "../../types/TestAnalysis.js";
8
+ import { toolError } from "../../utils/utils.js";
8
9
  const TOOL_NAME = "skyramp_analyze_test_health";
9
10
  export function registerAnalyzeTestHealthTool(server) {
10
11
  server.registerTool(TOOL_NAME, {
12
+ annotations: {
13
+ readOnlyHint: false, // deletes old state files via StateManager.cleanupOldFiles
14
+ destructiveHint: false,
15
+ idempotentHint: true,
16
+ openWorldHint: false,
17
+ },
11
18
  description: `Generate drift and health assessment instructions for existing tests — second step of the unified Test Health Analysis Flow.
12
19
 
13
20
  **PREREQUISITE:** Call \`skyramp_analyze_changes\` first to get a stateFile.
@@ -15,19 +22,16 @@ export function registerAnalyzeTestHealthTool(server) {
15
22
  This tool reads existing tests, the branch diff, and scanned endpoints from the stateFile,
16
23
  then returns a structured prompt for the LLM to assess each test for drift and health.
17
24
 
18
- The LLM follows the returned prompt to assign health scores (0–100), drift details, and
19
- actions (UPDATE / REGENERATE / VERIFY / DELETE) for each test, then calls \`skyramp_actions\`.
20
-
21
- **This tool does not write drift or health data to the stateFile.** Assessment results
22
- exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\`.
25
+ The LLM follows the returned prompt to assign drift details and actions (UPDATE / REGENERATE / VERIFY / DELETE / IGNORE) for each test, then calls \`skyramp_actions\`.
23
26
 
24
- (Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` parameter before \`skyramp_actions\` to validate tests live. Pass the stateFile path so execution results are written back for execution-aware health scoring and prioritization.
25
-
26
- **Output:** LLM drift analysis prompt and assessment instructions.`,
27
+ (Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` parameter before \`skyramp_actions\` to validate tests live.`,
27
28
  inputSchema: {
28
29
  stateFile: z
29
30
  .string()
30
- .describe("Path to state file from skyramp_analyze_changes"),
31
+ .describe("Path to state file from skyramp_analyze_changes. Assessment results exist only in the LLM's reasoning context — this tool does not write back to the stateFile."),
32
+ },
33
+ outputSchema: {
34
+ prompt: z.string().describe("LLM drift analysis prompt and assessment instructions."),
31
35
  },
32
36
  }, async (args) => {
33
37
  let errorResult;
@@ -39,19 +43,7 @@ exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\
39
43
  const fullState = await stateManager.readFullState();
40
44
  const repositoryPath = fullState?.metadata.repositoryPath || "";
41
45
  if (!stateData) {
42
- errorResult = {
43
- content: [
44
- {
45
- type: "text",
46
- text: JSON.stringify({
47
- error: "State file is empty or invalid",
48
- stateFile: args.stateFile,
49
- }, null, 2),
50
- },
51
- ],
52
- isError: true,
53
- };
54
- return errorResult;
46
+ return toolError(`State file is empty or invalid: ${args.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
55
47
  }
56
48
  // Only Skyramp tests are candidates for drift analysis and maintenance actions.
57
49
  // External (user-written) tests are used only for recommendation deduplication.
@@ -59,83 +51,40 @@ exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\
59
51
  const existingTests = (stateData.existingTests || []).filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External);
60
52
  logger.info(`Loaded ${existingTests.length} existing Skyramp tests from state file (excluded external)`);
61
53
  if (!repositoryPath || typeof repositoryPath !== "string") {
62
- errorResult = {
63
- content: [
64
- {
65
- type: "text",
66
- text: JSON.stringify({
67
- error: "repositoryPath not found in state file metadata",
68
- details: "State file must contain repositoryPath in metadata",
69
- }, null, 2),
70
- },
71
- ],
72
- isError: true,
73
- };
74
- return errorResult;
54
+ return toolError(`repositoryPath not found in state file metadata. The state file was likely created by an older version — re-run skyramp_analyze_changes to regenerate it.`);
75
55
  }
76
56
  const absoluteRepoPath = path.resolve(repositoryPath);
77
- // ── Build and return drift analysis prompt ──
78
- const diff = stateData.repositoryAnalysis?.diff;
79
- // Build structured diff summary for the prompt
80
- let parsedDiffText = "";
81
- if (diff) {
82
- const lines = [];
83
- if (diff.currentBranch)
84
- lines.push(`**Branch**: ${diff.currentBranch} → base: ${diff.baseBranch}`);
85
- if (diff.changedFiles?.length > 0)
86
- lines.push(`**Changed Files** (${diff.changedFiles.length}): ${diff.changedFiles.join(", ")}`);
87
- if (diff.newEndpoints?.length > 0) {
88
- lines.push(`**New Endpoints** (${diff.newEndpoints.length}): ${diff.newEndpoints
89
- .map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
90
- .join(", ")}`);
91
- }
92
- if (diff.modifiedEndpoints?.length > 0) {
93
- lines.push(`**Modified Endpoints** (${diff.modifiedEndpoints.length}): ${diff.modifiedEndpoints
94
- .map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
95
- .join(", ")}`);
96
- }
97
- if (diff.removedEndpoints?.length > 0) {
98
- lines.push(`**Removed Endpoints** (${diff.removedEndpoints.length}): ${diff.removedEndpoints
99
- .map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
100
- .join(", ")}`);
101
- }
102
- parsedDiffText = lines.join("\n");
103
- }
104
57
  const scannedEndpoints = stateData.repositoryAnalysis?.skeletonEndpoints || [];
105
58
  const routerMountContext = stateData.repositoryAnalysis?.routerMountContext;
106
59
  const candidateRouteFiles = stateData.repositoryAnalysis?.candidateRouteFiles;
60
+ const diffFilePath = stateData.repositoryAnalysis?.diffFilePath;
61
+ // Sweep stale diff files on this natural follow-up call so they don't accumulate.
62
+ // Pass [] for stateTypes so only skyramp-diff-*.diff files are deleted — state files
63
+ // (skyramp-analysis-*, skyramp-recommendation-*) must not be removed here because the
64
+ // caller still needs args.stateFile to pass to skyramp_actions.
65
+ try {
66
+ await StateManager.cleanupOldFiles(24, undefined, []);
67
+ }
68
+ catch (error) {
69
+ logger.warning(`Failed to cleanup old diff files: ${error.message}`);
70
+ }
107
71
  const promptText = buildDriftAnalysisPrompt({
108
72
  existingTests,
109
- parsedDiff: parsedDiffText || undefined,
110
73
  scannedEndpoints,
111
74
  repositoryPath: absoluteRepoPath,
112
75
  stateFile: stateManager.getStatePath(),
113
76
  routerMountContext,
114
77
  candidateRouteFiles,
78
+ diffFilePath,
115
79
  });
116
80
  return {
117
- content: [
118
- {
119
- type: "text",
120
- text: promptText,
121
- },
122
- ],
81
+ structuredContent: { prompt: promptText },
82
+ content: [{ type: "text", text: "Drift analysis prompt generated. Follow the prompt field to assess each test." }],
123
83
  };
124
84
  }
125
85
  catch (error) {
126
86
  logger.error(`Test health analysis failed: ${error.message}`, error);
127
- errorResult = {
128
- content: [
129
- {
130
- type: "text",
131
- text: JSON.stringify({
132
- error: error.message,
133
- details: error.stack,
134
- }, null, 2),
135
- },
136
- ],
137
- isError: true,
138
- };
87
+ errorResult = toolError(`Test health analysis failed: ${error.message}`);
139
88
  return errorResult;
140
89
  }
141
90
  finally {
@@ -1,3 +1,4 @@
1
1
  export { registerAnalyzeChangesTool } from "./analyzeChangesTool.js";
2
+ export { registerUiAnalyzeChangesTool } from "./uiAnalyzeChangesTool.js";
2
3
  export { registerAnalyzeTestHealthTool } from "./analyzeTestHealthTool.js";
3
4
  export { registerActionsTool } from "./actionsTool.js";
@@ -0,0 +1,149 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { z } from "zod";
4
+ import { logger } from "../../utils/logger.js";
5
+ import { enumerateCandidateUiPages } from "../../utils/uiPageEnumerator.js";
6
+ import { isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
7
+ import { parseChangedFilesFromDiff } from "../../utils/branchDiff.js";
8
+ import { toolText } from "../../utils/utils.js";
9
+ import { isTestbotEnabled } from "../../utils/featureFlags.js";
10
+ /**
11
+ * skyramp_ui_analyze_changes — runs before skyramp_analyze_changes when the
12
+ * caller (typically the testbot agent) wants blueprint-grounded recommendations.
13
+ *
14
+ * Computes candidateUiPages[] using the same source-grounded logic that
15
+ * analyze_changes uses internally (shared enumerator), then emits explicit
16
+ * instructions for the agent: navigate to each candidate URL, capture a
17
+ * browser_blueprint, then call skyramp_analyze_changes with the captures
18
+ * available as element vocabulary when the agent writes UI rec reasoning.
19
+ *
20
+ * Graceful degradation: backend-only PRs produce a "no UI work" instruction;
21
+ * unresolvable frontend baseUrl produces "skip captures, call analyze_changes
22
+ * with empty array." analyze_changes still works in either case — same
23
+ * source-grounded output as before slice 4.
24
+ */
25
+ export async function runUiAnalyzeChanges(params) {
26
+ const repoPath = params.repositoryPath;
27
+ // Read changedFiles from the canonical PR diff file. The testbot runtime
28
+ // writes this before the prompt runs.
29
+ const diffPath = path.join(repoPath, ".skyramp_git_diff");
30
+ let changedFiles = [];
31
+ let diffFileMissing = false;
32
+ try {
33
+ const raw = fs.readFileSync(diffPath, "utf-8");
34
+ changedFiles = parseChangedFilesFromDiff(raw);
35
+ }
36
+ catch {
37
+ // No diff file — for testbot this would be a runtime bug (it always
38
+ // writes one); for non-testbot callers it means "diff unavailable",
39
+ // which is NOT the same as "no UI changes". Surface that distinction.
40
+ diffFileMissing = true;
41
+ }
42
+ if (diffFileMissing) {
43
+ const uiContext = {
44
+ changedFrontendFiles: [],
45
+ candidateUiPages: [],
46
+ };
47
+ return {
48
+ uiContext,
49
+ instructions: DIFF_FILE_MISSING_INSTRUCTIONS,
50
+ };
51
+ }
52
+ const frontendFiles = changedFiles.filter((f) => isFrontendFile(f) && !isTestFile(f));
53
+ if (frontendFiles.length === 0) {
54
+ const uiContext = {
55
+ changedFrontendFiles: [],
56
+ candidateUiPages: [],
57
+ };
58
+ return {
59
+ uiContext,
60
+ instructions: NO_UI_INSTRUCTIONS,
61
+ };
62
+ }
63
+ const candidateUiPages = await enumerateCandidateUiPages(repoPath, frontendFiles);
64
+ const uiContext = {
65
+ changedFrontendFiles: frontendFiles,
66
+ candidateUiPages,
67
+ };
68
+ const instructions = candidateUiPages.length === 0
69
+ ? NO_RESOLVABLE_URLS_INSTRUCTIONS
70
+ : buildCaptureInstructions(candidateUiPages, params.uiCredentials);
71
+ logger.info("ui_analyze_changes computed candidate UI pages", {
72
+ repositoryPath: repoPath,
73
+ frontendFileCount: frontendFiles.length,
74
+ candidateUiPageCount: candidateUiPages.length,
75
+ hasUiCredentials: !!params.uiCredentials,
76
+ });
77
+ return { uiContext, instructions };
78
+ }
79
+ const NO_UI_INSTRUCTIONS = `No UI changes detected in this PR. Proceed directly to \`skyramp_analyze_changes\`.`;
80
+ const NO_RESOLVABLE_URLS_INSTRUCTIONS = `Frontend changes detected but no candidate URLs could be resolved (workspace baseUrl missing or no router files matched). Proceed to \`skyramp_analyze_changes\`; UI recommendations will be source-grounded only.`;
81
+ const DIFF_FILE_MISSING_INSTRUCTIONS = `\`.skyramp_git_diff\` not found in the repository. This file is normally written by the testbot runtime before the prompt runs; without it, this tool cannot determine which files changed. **Do not skip blueprint capture on the assumption there are no UI changes** — that conclusion is not supported by the available data. If you have access to the changed-files list elsewhere (e.g. a \`gh pr diff\` command, an environment variable), determine UI candidates yourself and capture their blueprints before proceeding. Otherwise proceed to \`skyramp_analyze_changes\` and add an \`issuesFound\` info entry: "ui_analyze_changes could not read .skyramp_git_diff; recommendations are source-grounded only".`;
82
+ function buildCaptureInstructions(pages, uiCredentials) {
83
+ // The `<ui-credentials>` tag is only emitted by the testbot orchestration
84
+ // prompt. When this tool is called from an IDE (Cursor, etc.) outside
85
+ // testbot, the agent has no such tag — embed the credentials inline
86
+ // instead. uiCredentials is "email:password" formatted by the caller.
87
+ const loginStep = uiCredentials
88
+ ? (isTestbotEnabled()
89
+ ? `\n0. **Log in first.** \`browser_navigate\` to the app's login URL, then use \`<ui-credentials>\` from the testbot prompt to authenticate (you have those credentials in context). Without authentication, the candidate URLs below may redirect to login and the captured blueprints will be of the login page, not the feature.\n`
90
+ : `\n0. **Log in first.** \`browser_navigate\` to the app's login URL, then authenticate with the credentials \`${uiCredentials}\` (formatted as \`email:password\`). Without authentication, the candidate URLs below may redirect to login and the captured blueprints will be of the login page, not the feature.\n`)
91
+ : "";
92
+ const pagesYaml = pages
93
+ .map((p, i) => ` ${i + 1}. ${p.url} (sourcedFrom: ${p.sourcedFrom.join(", ") || "(none)"}, strategy: ${p.strategy})`)
94
+ .join("\n");
95
+ return `Frontend changes detected. **Before calling \`skyramp_analyze_changes\`, capture blueprints on the candidate UI pages below.** Those captures stay in your tool-result history and serve as element vocabulary when you write UI test recommendations — \`skyramp_analyze_changes\` returns the authoring rules; you bring the observed elements.
96
+ ${loginStep}
97
+ **Candidate URLs:**
98
+ ${pagesYaml}
99
+
100
+ **For each candidate URL:**
101
+ - \`browser_navigate\` to the URL
102
+ - \`browser_blueprint\` to capture the page
103
+
104
+ You don't need to thread the blueprints back into a tool call — they're in your context once captured. **Then call \`skyramp_analyze_changes\`** to get the recommendation catalog and the UI authoring rules.
105
+
106
+ If a candidate URL 404s or redirects unexpectedly, navigate from the workspace baseUrl and explore (admin apps mount routes under base prefixes the source extraction can't see). If the page rendered but lacks the changed feature (gated UI: modal, dropdown, accordion), do NOT iterate further during this step — UI recs will fall back to source-grounded prose for those, and the agent's later trace recording (Task 2) will navigate into the gate via capture-act-capture.
107
+
108
+ If \`browser_blueprint\` fails on every candidate URL (app unreachable, all 404s), proceed to \`skyramp_analyze_changes\` and log an \`issuesFound\` info entry. Recommendations will be source-grounded; non-UI work is unaffected.`;
109
+ }
110
+ export const uiAnalyzeChangesInputSchema = {
111
+ repositoryPath: z
112
+ .string()
113
+ .describe("Absolute path to the repository root"),
114
+ uiCredentials: z
115
+ .string()
116
+ .optional()
117
+ .describe("Optional ui-credentials for authed apps. When present, the returned " +
118
+ "instructions include a login step before blueprint capture."),
119
+ };
120
+ export function registerUiAnalyzeChangesTool(server) {
121
+ server.registerTool("skyramp_ui_analyze_changes", {
122
+ description: [
123
+ "Run BEFORE skyramp_analyze_changes when the PR may contain frontend changes.",
124
+ "Enumerates candidate UI pages from the diff and instructs the agent to",
125
+ "capture browser_blueprints on each before calling analyze_changes. The",
126
+ "captures stay in the agent's tool-result history and serve as the",
127
+ "element vocabulary for UI rec reasoning.",
128
+ "",
129
+ "On backend-only PRs, returns a 'no UI work' instruction — caller can",
130
+ "proceed directly to skyramp_analyze_changes.",
131
+ ].join("\n"),
132
+ inputSchema: uiAnalyzeChangesInputSchema,
133
+ annotations: {
134
+ readOnlyHint: true, // reads .skyramp_git_diff + workspace.yml; no writes
135
+ idempotentHint: true, // pure function of repo state + uiCredentials
136
+ openWorldHint: false, // no external service calls
137
+ },
138
+ }, async (params) => {
139
+ const result = await runUiAnalyzeChanges(params);
140
+ const summary = JSON.stringify({ uiContext: result.uiContext }, null, 2);
141
+ return toolText(`<summary>
142
+ ${summary}
143
+ </summary>
144
+
145
+ <instructions>
146
+ ${result.instructions}
147
+ </instructions>`);
148
+ });
149
+ }
@@ -0,0 +1,100 @@
1
+ jest.mock("@skyramp/skyramp", () => ({
2
+ WorkspaceConfigManager: { create: jest.fn() },
3
+ }));
4
+ import { runUiAnalyzeChanges } from "./uiAnalyzeChangesTool.js";
5
+ import * as workspaceAuth from "../../utils/workspaceAuth.js";
6
+ import * as fs from "fs";
7
+ import * as os from "os";
8
+ import * as path from "path";
9
+ function makeRepoWithDiff(changedFiles) {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-uiac-test-"));
11
+ // Write .skyramp_git_diff with the requested file changes — match the
12
+ // format the runtime uses (raw `diff --git a/X b/X` block).
13
+ const diff = changedFiles
14
+ .map((f) => `diff --git a/${f} b/${f}\n+++ b/${f}\n@@ -0,0 +1,1 @@\n+x`)
15
+ .join("\n");
16
+ fs.writeFileSync(path.join(dir, ".skyramp_git_diff"), diff);
17
+ return dir;
18
+ }
19
+ describe("runUiAnalyzeChanges", () => {
20
+ let readSpy;
21
+ beforeEach(() => {
22
+ readSpy = jest.spyOn(workspaceAuth, "readWorkspaceConfigRaw");
23
+ });
24
+ afterEach(() => {
25
+ readSpy.mockRestore();
26
+ });
27
+ it("returns empty changedFrontendFiles for backend-only diffs", async () => {
28
+ readSpy.mockResolvedValue({ services: [] });
29
+ const repo = makeRepoWithDiff(["api/handlers/users.ts"]);
30
+ const result = await runUiAnalyzeChanges({ repositoryPath: repo });
31
+ expect(result.uiContext.changedFrontendFiles).toEqual([]);
32
+ expect(result.uiContext.candidateUiPages).toEqual([]);
33
+ expect(result.instructions).toMatch(/no UI changes/i);
34
+ fs.rmSync(repo, { recursive: true, force: true });
35
+ });
36
+ it("returns candidateUiPages and capture instructions for frontend diffs", async () => {
37
+ readSpy.mockResolvedValue({
38
+ services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
39
+ });
40
+ const repo = makeRepoWithDiff(["frontend/src/components/Cart.tsx"]);
41
+ const result = await runUiAnalyzeChanges({ repositoryPath: repo });
42
+ expect(result.uiContext.changedFrontendFiles.length).toBeGreaterThan(0);
43
+ expect(result.uiContext.candidateUiPages.length).toBeGreaterThan(0);
44
+ expect(result.instructions).toMatch(/browser_navigate/);
45
+ expect(result.instructions).toMatch(/browser_blueprint/);
46
+ expect(result.instructions).toMatch(/skyramp_analyze_changes/);
47
+ // Captures stay in agent's tool-result history; we no longer thread them
48
+ // back through analyze_changes as a param.
49
+ expect(result.instructions).not.toMatch(/capturedBlueprints/);
50
+ fs.rmSync(repo, { recursive: true, force: true });
51
+ });
52
+ it("includes inline-credentials login step in non-testbot mode", async () => {
53
+ // SKYRAMP_FEATURE_TESTBOT is not set here, so the tool emits the
54
+ // credentials inline rather than referencing the testbot's
55
+ // <ui-credentials> context tag (which doesn't exist outside testbot).
56
+ delete process.env.SKYRAMP_FEATURE_TESTBOT;
57
+ readSpy.mockResolvedValue({
58
+ services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
59
+ });
60
+ const repo = makeRepoWithDiff(["frontend/src/components/Cart.tsx"]);
61
+ const result = await runUiAnalyzeChanges({
62
+ repositoryPath: repo,
63
+ uiCredentials: "admin@example.com:admin",
64
+ });
65
+ expect(result.instructions).toMatch(/log in/i);
66
+ expect(result.instructions).toContain("admin@example.com:admin");
67
+ fs.rmSync(repo, { recursive: true, force: true });
68
+ });
69
+ it("references the <ui-credentials> testbot context tag in testbot mode", async () => {
70
+ process.env.SKYRAMP_FEATURE_TESTBOT = "1";
71
+ try {
72
+ readSpy.mockResolvedValue({
73
+ services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
74
+ });
75
+ const repo = makeRepoWithDiff(["frontend/src/components/Cart.tsx"]);
76
+ const result = await runUiAnalyzeChanges({
77
+ repositoryPath: repo,
78
+ uiCredentials: "admin@example.com:admin",
79
+ });
80
+ expect(result.instructions).toMatch(/log in/i);
81
+ expect(result.instructions).toContain("ui-credentials");
82
+ // Inline credentials should NOT leak in testbot mode (the
83
+ // <ui-credentials> tag is the secure carrier in that flow).
84
+ expect(result.instructions).not.toContain("admin@example.com:admin");
85
+ fs.rmSync(repo, { recursive: true, force: true });
86
+ }
87
+ finally {
88
+ delete process.env.SKYRAMP_FEATURE_TESTBOT;
89
+ }
90
+ });
91
+ it("instructs the agent to proceed to analyze_changes (source-grounded) when frontend baseUrl unresolvable", async () => {
92
+ readSpy.mockResolvedValue(null); // no workspace yml
93
+ const repo = makeRepoWithDiff(["frontend/src/components/Cart.tsx"]);
94
+ const result = await runUiAnalyzeChanges({ repositoryPath: repo });
95
+ expect(result.uiContext.changedFrontendFiles.length).toBeGreaterThan(0);
96
+ expect(result.uiContext.candidateUiPages).toEqual([]);
97
+ expect(result.instructions).toMatch(/source-grounded/i);
98
+ fs.rmSync(repo, { recursive: true, force: true });
99
+ });
100
+ });
@@ -0,0 +1,16 @@
1
+ import path from "path";
2
+ /**
3
+ * Resolve the user-supplied `playwrightSaveStoragePath` against `outputDir`:
4
+ * - A bare filename (no separators, not absolute) is joined with `outputDir`.
5
+ * - Anything else (absolute path, or a relative path containing separators)
6
+ * is returned unchanged.
7
+ */
8
+ export function resolveSaveStoragePath(input, outputDir) {
9
+ if (!outputDir) {
10
+ return input;
11
+ }
12
+ if (!path.isAbsolute(input) && !input.includes(path.sep)) {
13
+ return path.join(outputDir, input);
14
+ }
15
+ return input;
16
+ }
@@ -0,0 +1,17 @@
1
+ import path from "path";
2
+ import { resolveSaveStoragePath } from "./resolveSaveStoragePath.js";
3
+ describe("resolveSaveStoragePath", () => {
4
+ const outputDir = "/abs/output";
5
+ it("joins a bare filename with outputDir", () => {
6
+ expect(resolveSaveStoragePath("auth.json", outputDir)).toBe(path.join(outputDir, "auth.json"));
7
+ });
8
+ it("returns an absolute path unchanged", () => {
9
+ expect(resolveSaveStoragePath("/tmp/storage.json", outputDir)).toBe("/tmp/storage.json");
10
+ });
11
+ it("returns a relative path with separators unchanged", () => {
12
+ expect(resolveSaveStoragePath("subdir/storage.json", outputDir)).toBe("subdir/storage.json");
13
+ });
14
+ it("returns input unchanged when outputDir is undefined", () => {
15
+ expect(resolveSaveStoragePath("auth.json", undefined)).toBe("auth.json");
16
+ });
17
+ });
@@ -0,0 +1,39 @@
1
+ import path from "path";
2
+ import { SESSION_STORAGE_FILENAME } from "../../types/TestTypes.js";
3
+ import { resolveSaveStoragePath } from "./resolveSaveStoragePath.js";
4
+ export function resolveSessionPaths(args) {
5
+ const defaultPath = path.join(args.outputDir, SESSION_STORAGE_FILENAME);
6
+ const explicitLoad = args.loadOverride
7
+ ? resolveSaveStoragePath(args.loadOverride, args.outputDir)
8
+ : undefined;
9
+ const explicitSave = args.saveOverride
10
+ ? resolveSaveStoragePath(args.saveOverride, args.outputDir)
11
+ : undefined;
12
+ switch (args.mode) {
13
+ case "capture":
14
+ // Login capture: clean slate (never load), save at default unless overridden.
15
+ return { savePath: explicitSave ?? defaultPath };
16
+ case "reuse":
17
+ // Reuse existing session: load by default, do NOT save (preserves the file).
18
+ // Caller may force a save by setting `playwrightSaveStoragePath` explicitly.
19
+ return {
20
+ loadPath: explicitLoad ?? defaultPath,
21
+ savePath: explicitSave,
22
+ };
23
+ case "ignore":
24
+ // Honor only explicit overrides; otherwise neither load nor save.
25
+ return { loadPath: explicitLoad, savePath: explicitSave };
26
+ case "auto":
27
+ default:
28
+ // Explicit overrides win.
29
+ if (explicitLoad || explicitSave) {
30
+ return { loadPath: explicitLoad, savePath: explicitSave };
31
+ }
32
+ // No overrides — decide from filesystem state. If a session file is
33
+ // already present, reuse it without overwriting; otherwise capture
34
+ // into the default location.
35
+ return args.sessionExists
36
+ ? { loadPath: defaultPath }
37
+ : { savePath: defaultPath };
38
+ }
39
+ }
@@ -0,0 +1,103 @@
1
+ import path from "path";
2
+ import { SESSION_STORAGE_FILENAME } from "../../types/TestTypes.js";
3
+ import { resolveSessionPaths } from "./resolveSessionPaths.js";
4
+ describe("resolveSessionPaths", () => {
5
+ const outputDir = "/abs/output";
6
+ const defaultPath = path.join(outputDir, SESSION_STORAGE_FILENAME);
7
+ const baseArgs = {
8
+ loadOverride: undefined,
9
+ saveOverride: undefined,
10
+ outputDir,
11
+ sessionExists: false,
12
+ };
13
+ describe("mode=auto", () => {
14
+ it("captures into the default path when no session exists", () => {
15
+ expect(resolveSessionPaths({ ...baseArgs, mode: "auto" })).toEqual({
16
+ savePath: defaultPath,
17
+ });
18
+ });
19
+ it("reuses (loads) the default path when a session exists, without saving", () => {
20
+ expect(resolveSessionPaths({ ...baseArgs, mode: "auto", sessionExists: true })).toEqual({ loadPath: defaultPath });
21
+ });
22
+ it("respects an explicit load override when given (auto + sessionExists)", () => {
23
+ expect(resolveSessionPaths({
24
+ ...baseArgs,
25
+ mode: "auto",
26
+ sessionExists: true,
27
+ loadOverride: "/elsewhere/session.json",
28
+ })).toEqual({ loadPath: "/elsewhere/session.json" });
29
+ });
30
+ it("respects an explicit save override (auto + no session)", () => {
31
+ expect(resolveSessionPaths({
32
+ ...baseArgs,
33
+ mode: "auto",
34
+ saveOverride: "auth.json",
35
+ })).toEqual({ savePath: path.join(outputDir, "auth.json") });
36
+ });
37
+ });
38
+ describe("mode=capture", () => {
39
+ it("always saves at the default path, never loads", () => {
40
+ expect(resolveSessionPaths({ ...baseArgs, mode: "capture" })).toEqual({
41
+ savePath: defaultPath,
42
+ });
43
+ });
44
+ it("ignores a load override (capture is a clean slate)", () => {
45
+ expect(resolveSessionPaths({
46
+ ...baseArgs,
47
+ mode: "capture",
48
+ loadOverride: "/elsewhere/session.json",
49
+ })).toEqual({ savePath: defaultPath });
50
+ });
51
+ it("honors an explicit save override", () => {
52
+ expect(resolveSessionPaths({
53
+ ...baseArgs,
54
+ mode: "capture",
55
+ saveOverride: "/tmp/custom.json",
56
+ })).toEqual({ savePath: "/tmp/custom.json" });
57
+ });
58
+ it("still captures even when a session file already exists (we're recapturing)", () => {
59
+ expect(resolveSessionPaths({
60
+ ...baseArgs,
61
+ mode: "capture",
62
+ sessionExists: true,
63
+ })).toEqual({ savePath: defaultPath });
64
+ });
65
+ });
66
+ describe("mode=reuse", () => {
67
+ it("loads the default path, does not save (preserves the session file)", () => {
68
+ expect(resolveSessionPaths({ ...baseArgs, mode: "reuse" })).toEqual({
69
+ loadPath: defaultPath,
70
+ });
71
+ });
72
+ it("honors an explicit load override", () => {
73
+ expect(resolveSessionPaths({
74
+ ...baseArgs,
75
+ mode: "reuse",
76
+ loadOverride: "/elsewhere/session.json",
77
+ })).toEqual({ loadPath: "/elsewhere/session.json" });
78
+ });
79
+ it("allows an explicit save override even in reuse (caller wants to refresh)", () => {
80
+ expect(resolveSessionPaths({
81
+ ...baseArgs,
82
+ mode: "reuse",
83
+ saveOverride: "/tmp/refreshed.json",
84
+ })).toEqual({ loadPath: defaultPath, savePath: "/tmp/refreshed.json" });
85
+ });
86
+ });
87
+ describe("mode=ignore", () => {
88
+ it("loads and saves nothing by default", () => {
89
+ expect(resolveSessionPaths({ ...baseArgs, mode: "ignore" })).toEqual({});
90
+ });
91
+ it("honors explicit overrides only", () => {
92
+ expect(resolveSessionPaths({
93
+ ...baseArgs,
94
+ mode: "ignore",
95
+ loadOverride: "/in/session.json",
96
+ saveOverride: "/out/session.json",
97
+ })).toEqual({
98
+ loadPath: "/in/session.json",
99
+ savePath: "/out/session.json",
100
+ });
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,14 @@
1
+ // Module-level state acting as a singleton: the start-trace tool stashes the
2
+ // resolved save path here so the stop-trace tool can surface it without
3
+ // re-resolving. ES modules are cached and singleton per Node process, so this
4
+ // is the idiomatic TS pattern for cross-call state — equivalent to a class
5
+ // with private static fields, without the extra wrapper.
6
+ let savedPath;
7
+ export function setSavedSessionPath(p) {
8
+ savedPath = p;
9
+ }
10
+ export function consumeSavedSessionPath() {
11
+ const v = savedPath;
12
+ savedPath = undefined;
13
+ return v;
14
+ }
@@ -0,0 +1,17 @@
1
+ import { consumeSavedSessionPath, setSavedSessionPath, } from "./sessionState.js";
2
+ describe("sessionState", () => {
3
+ afterEach(() => {
4
+ // Ensure module-level state is cleared between tests.
5
+ setSavedSessionPath(undefined);
6
+ });
7
+ it("returns the set value on first consume and undefined on the second", () => {
8
+ setSavedSessionPath("/abs/session.json");
9
+ expect(consumeSavedSessionPath()).toBe("/abs/session.json");
10
+ expect(consumeSavedSessionPath()).toBeUndefined();
11
+ });
12
+ it("setting undefined clears any prior value", () => {
13
+ setSavedSessionPath("/abs/session.json");
14
+ setSavedSessionPath(undefined);
15
+ expect(consumeSavedSessionPath()).toBeUndefined();
16
+ });
17
+ });