@skyramp/mcp 0.2.7 → 0.2.8

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 (90) hide show
  1. package/build/index.js +5 -2
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -5
  3. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.d.ts +2 -0
  4. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +114 -0
  5. package/build/prompts/sut-setup/modes/dockerComposePrompt.d.ts +2 -0
  6. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +292 -0
  7. package/build/prompts/sut-setup/shared.d.ts +39 -0
  8. package/build/prompts/sut-setup/shared.js +132 -0
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +9 -3
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -1
  11. package/build/prompts/test-recommendation/diffExecutionPlan.js +76 -2
  12. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +91 -0
  13. package/build/prompts/testbot/testbot-prompts.d.ts +16 -1
  14. package/build/prompts/testbot/testbot-prompts.js +113 -22
  15. package/build/prompts/testbot/testbot-prompts.test.js +95 -45
  16. package/build/resources/sutSetupResource.d.ts +2 -0
  17. package/build/resources/sutSetupResource.js +45 -0
  18. package/build/resources/testbotResource.js +2 -2
  19. package/build/services/TestDiscoveryService.d.ts +2 -2
  20. package/build/services/TestDiscoveryService.js +22 -12
  21. package/build/services/TestExecutionService.d.ts +10 -1
  22. package/build/services/TestExecutionService.js +158 -26
  23. package/build/services/TestExecutionService.test.js +306 -0
  24. package/build/tools/executeSkyrampTestTool.js +26 -7
  25. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +1 -1
  26. package/build/tools/submitReportTool.d.ts +10 -0
  27. package/build/tools/submitReportTool.js +106 -39
  28. package/build/tools/submitReportTool.test.js +296 -50
  29. package/build/tools/test-management/actionsTool.js +21 -7
  30. package/build/tools/test-management/analyzeChangesTool.d.ts +1 -1
  31. package/build/tools/test-management/analyzeChangesTool.js +106 -41
  32. package/build/tools/test-management/analyzeChangesTool.test.js +21 -41
  33. package/build/tools/test-management/analyzeTestHealthTool.js +16 -9
  34. package/build/tools/workspace/initializeWorkspaceTool.js +39 -22
  35. package/build/tools/workspace/serviceUpsert.d.ts +12 -0
  36. package/build/tools/workspace/serviceUpsert.js +23 -0
  37. package/build/tools/workspace/serviceUpsert.test.d.ts +1 -0
  38. package/build/tools/workspace/serviceUpsert.test.js +50 -0
  39. package/build/types/TestAnalysis.d.ts +2 -1
  40. package/build/types/TestExecution.d.ts +12 -2
  41. package/build/types/TestExecution.js +11 -1
  42. package/build/types/TestbotReport.d.ts +68 -0
  43. package/build/types/TestbotReport.js +1 -0
  44. package/build/types/index.d.ts +4 -0
  45. package/build/types/index.js +3 -0
  46. package/build/utils/AnalysisStateManager.d.ts +61 -1
  47. package/build/utils/AnalysisStateManager.js +144 -3
  48. package/build/utils/AnalysisStateManager.test.js +98 -0
  49. package/build/utils/docker.test.js +1 -1
  50. package/build/utils/routeParsers.d.ts +0 -10
  51. package/build/utils/routeParsers.js +31 -38
  52. package/build/utils/scenarioDrafting.d.ts +1 -1
  53. package/build/utils/scenarioDrafting.js +23 -1
  54. package/build/utils/utils.d.ts +7 -1
  55. package/build/utils/utils.js +10 -0
  56. package/build/utils/versions.d.ts +3 -3
  57. package/build/utils/versions.js +1 -1
  58. package/build/workspace/workspace.d.ts +10 -10
  59. package/build/workspace/workspace.js +7 -7
  60. package/build/workspace/workspace.test.js +6 -6
  61. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +31 -13
  62. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +88 -15
  63. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -3
  64. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
  65. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  66. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderApp.js +79 -19
  67. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/aliasRemapper.js +84 -0
  68. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/replayEngine.js +15 -4
  69. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-CPZKmjFR.js → codeMirrorModule-D0BjbCb7.js} +1 -1
  70. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-lvTRGFx-.js +193 -0
  71. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  72. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-B0JOjboO.js → codeMirrorModule-Cqp3cwEJ.js} +1 -1
  73. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-1anWeyDf.js → defaultSettingsView-CYf9adZh.js} +79 -79
  74. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.Cc5029a3.js → index.B_7ywgmr.js} +1 -1
  75. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  76. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  77. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.Wo5yvvVh.js → uiMode.u4_8VnCV.js} +1 -1
  78. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  79. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  80. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderApp.ts +129 -43
  81. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/aliasRemapper.ts +119 -0
  82. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/replayEngine.ts +33 -10
  83. package/node_modules/playwright/package.json +1 -1
  84. package/package.json +10 -3
  85. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/.bin/node-which +0 -52
  86. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/is-docker +0 -5
  87. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/mime +0 -46
  88. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/yaml +0 -11
  89. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-CsgPR2wy.js +0 -193
  90. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.CP3Y8Zsb.js +0 -2
@@ -8,7 +8,7 @@ import { simpleGit } from "simple-git";
8
8
  import { logger } from "../../utils/logger.js";
9
9
  import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
10
10
  import { AnalyticsService } from "../../services/AnalyticsService.js";
11
- import { StateManager, registerSession, storeSessionData, setTestsRepoDir, } from "../../utils/AnalysisStateManager.js";
11
+ import { StateManager, registerSession, storeSessionData, setTestsRepoDir, getActiveRunStatePath, setActiveRunStatePath, } from "../../utils/AnalysisStateManager.js";
12
12
  import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
13
13
  import { hasFlutterSdkDep, isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
14
14
  import { enumerateCandidateUiPages } from "../../utils/uiPageEnumerator.js";
@@ -24,6 +24,32 @@ import { buildAnalysisOutputText } from "../../prompts/test-recommendation/analy
24
24
  import { parseTraceFile, discoverTraceFiles, discoverPlaywrightZips, } from "../../utils/trace-parser.js";
25
25
  import { TestSource } from "../../types/TestAnalysis.js";
26
26
  import { parsePRComments } from "../../utils/pr-comment-parser.js";
27
+ // Generic module names that appear as imports in virtually every router file.
28
+ // Matching against these would extract ALL prefix= tokens from mount files,
29
+ // flooding changedResources with unrelated resources.
30
+ const GENERIC_MODULE_NAMES = new Set([
31
+ "models", "utils", "helpers", "schemas", "types", "constants",
32
+ "config", "settings", "exceptions", "errors", "base", "common",
33
+ "app", "main", "index", "server", "init", "deps", "dependencies",
34
+ ]);
35
+ function resolveMountPrefixTokens(mountFileContents, changedFiles) {
36
+ const tokens = changedFiles.flatMap((file) => {
37
+ const mod = path.basename(file).replace(/\.[^.]+$/, "").toLowerCase();
38
+ if (GENERIC_MODULE_NAMES.has(mod))
39
+ return [];
40
+ const modRe = new RegExp(`\\b${mod.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
41
+ return mountFileContents.flatMap((content) => {
42
+ if (!modRe.test(content.toLowerCase()))
43
+ return [];
44
+ const matches = [...content.matchAll(/prefix\s*=\s*["'](\/?[^"']+)/g)];
45
+ return matches.flatMap((m) => {
46
+ const token = extractResourceFromPath(m[1]);
47
+ return token !== "unknown" ? [token] : [];
48
+ });
49
+ });
50
+ });
51
+ return [...new Set(tokens)];
52
+ }
27
53
  /** Exported for testing: maps a parsed trace result to a TraceFile. */
28
54
  export function buildTraceFileEntry(tracePath, result) {
29
55
  return {
@@ -339,11 +365,10 @@ export const analyzeChangesInputSchema = {
339
365
  .number()
340
366
  .optional()
341
367
  .describe("GitHub PR number. When provided, fetches previous TestBot comments on this PR and skips re-recommending tests already suggested in earlier commits — reduces duplicate recommendations across multiple pushes to the same PR."),
342
- stateOutputFile: z
368
+ repository: z
343
369
  .string()
344
- .refine((v) => path.isAbsolute(v), { message: "stateOutputFile must be an absolute path" })
345
370
  .optional()
346
- .describe("Absolute path where the state file should be written. When provided, overrides the default auto-generated temp path so the caller can locate it without log parsing."),
371
+ .describe("The owner/repo this call analyzes (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it — for the primary AND every related repo. All calls in one Testbot run share ONE state file automatically (you do NOT pass a file path); each repo's analysis is stored as its own section within it. The primary's analysis is kept at the root and tagged with its owner/repo; related repos are stored as keyed sections. Omit only for a single-repo run."),
347
372
  testsRepoDir: z
348
373
  .string()
349
374
  .refine((v) => path.isAbsolute(v), { message: "testsRepoDir must be an absolute path" })
@@ -568,6 +593,30 @@ export function registerAnalyzeChangesTool(server) {
568
593
  // testsRepoDir is a cross-repo path override — honour it when set.
569
594
  // Otherwise always scan the full repo root.
570
595
  const testDir = params.testsRepoDir ?? undefined;
596
+ const routerMountContext = grepRouterMountingContext(params.repositoryPath);
597
+ // Read contents once — used for token extraction and LLM prompt injection.
598
+ const ROUTER_INLINE_LIMIT = 4096;
599
+ const routerMountContextContents = routerMountContext.map((f) => {
600
+ try {
601
+ const abs = path.isAbsolute(f) ? f : path.join(params.repositoryPath, f);
602
+ if (fs.statSync(abs).size > ROUTER_INLINE_LIMIT * 10)
603
+ return ""; // skip very large files
604
+ return fs.readFileSync(abs, "utf-8");
605
+ }
606
+ catch {
607
+ return "";
608
+ }
609
+ });
610
+ // Size-capped subset for LLM prompt injection.
611
+ const ROUTER_INLINE_MAX_FILES = 3;
612
+ const routerFileContents = routerMountContext
613
+ .slice(0, ROUTER_INLINE_MAX_FILES)
614
+ .flatMap((f, i) => {
615
+ const content = routerMountContextContents[i] ?? "";
616
+ if (!content || content.length > ROUTER_INLINE_LIMIT)
617
+ return [];
618
+ return [{ file: f, content: content.trimEnd() }];
619
+ });
571
620
  // Compute changedResources from classified endpoints for test discovery filtering.
572
621
  // undefined → full-repo mode (no diff context)
573
622
  // [] → PR mode, no endpoints found → skip external tests
@@ -596,14 +645,22 @@ export function registerAnalyzeChangesTool(server) {
596
645
  }
597
646
  else {
598
647
  // All endpoints resolved to "unknown" (e.g. NestJS relative paths) —
599
- // extract resource names from changed file paths instead.
600
- const candidateFiles = classifiedEndpoints.unmatchedFiles.length > 0
601
- ? classifiedEndpoints.unmatchedFiles
602
- : classifiedEndpoints.changedFiles ?? [];
603
- const fromFiles = candidateFiles
604
- .map((f) => extractResourceFromPath(f.replace(/\.[^./]+$/, "").replace(FRAMEWORK_SUFFIX_RE, "")))
605
- .filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
606
- changedResources = fromFiles.length > 0 ? fromFiles : ["unknown"];
648
+ // first try to extract resource names from router mount prefixes.
649
+ const changedSourceFiles = classifiedEndpoints.changedFiles ?? [];
650
+ const mountedResources = resolveMountPrefixTokens(routerMountContextContents, changedSourceFiles);
651
+ if (mountedResources.length > 0) {
652
+ changedResources = mountedResources;
653
+ }
654
+ else {
655
+ // Fall back to extracting resource names from changed file paths.
656
+ const candidateFiles = classifiedEndpoints.unmatchedFiles.length > 0
657
+ ? classifiedEndpoints.unmatchedFiles
658
+ : classifiedEndpoints.changedFiles ?? [];
659
+ const fromFiles = candidateFiles
660
+ .map((f) => extractResourceFromPath(f.replace(/\.[^./]+$/, "").replace(FRAMEWORK_SUFFIX_RE, "")))
661
+ .filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
662
+ changedResources = fromFiles.length > 0 ? fromFiles : ["unknown"];
663
+ }
607
664
  }
608
665
  }
609
666
  else if (classifiedEndpoints.unmatchedFiles.length > 0) {
@@ -622,7 +679,15 @@ export function registerAnalyzeChangesTool(server) {
622
679
  changedResources = fromFiles.length > 0 ? fromFiles : ["unknown"];
623
680
  }
624
681
  else {
625
- changedResources = [];
682
+ // No endpoints and no unmatched files — the diff touches only non-route
683
+ // files (e.g. schema, model, DTO). Extract resource tokens from the
684
+ // changed file paths so tests that import the changed symbol are still
685
+ // surfaced for drift assessment. Fall back to [] only if extraction
686
+ // yields nothing (e.g. a file with a non-resource name like utils.py).
687
+ const fromChangedFiles = (classifiedEndpoints.changedFiles ?? [])
688
+ .map((f) => extractResourceFromPath(f.replace(/\.[^./]+$/, "").replace(FRAMEWORK_SUFFIX_RE, "")))
689
+ .filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
690
+ changedResources = fromChangedFiles.length > 0 ? fromChangedFiles : [];
626
691
  }
627
692
  }
628
693
  let existingTests = [];
@@ -903,10 +968,15 @@ export function registerAnalyzeChangesTool(server) {
903
968
  sourceFile: m.sourceFile ?? "",
904
969
  })))
905
970
  : [];
971
+ const removedEndpointsForDrafting = classifiedEndpoints?.removedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
972
+ method: m,
973
+ path: ep.path,
974
+ sourceFile: ep.sourceFile,
975
+ }))) ?? [];
906
976
  const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, scenarioDraftSeed, wsAuthType, {
907
977
  changedEndpoints: changedEndpointsForSecurityExpansion,
908
978
  securityRelevantDiff,
909
- });
979
+ }, removedEndpointsForDrafting);
910
980
  let allDraftedScenarios = codeInferredScenarios;
911
981
  if (traceResult && traceResult.userFlows.length > 0) {
912
982
  const traceScenarios = traceResult.userFlows
@@ -1109,7 +1179,6 @@ export function registerAnalyzeChangesTool(server) {
1109
1179
  // can be persisted to the state file for downstream tools (health, drift).
1110
1180
  // Without them, analyzeTestHealth would work only off the static catalog
1111
1181
  // which has wrong paths for nested resources and unsupported frameworks.
1112
- const routerMountContext = grepRouterMountingContext(params.repositoryPath);
1113
1182
  const routeLikeUnmatchedFiles = [];
1114
1183
  for (const file of classifiedEndpoints?.unmatchedFiles ?? []) {
1115
1184
  const routeLike = SOURCE_EXTS.test(file) &&
@@ -1138,25 +1207,6 @@ export function registerAnalyzeChangesTool(server) {
1138
1207
  diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
1139
1208
  await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
1140
1209
  }
1141
- // Read router mount files server-side (size-capped) so the LLM has them
1142
- // inline and doesn't need an extra read step when no spec is available.
1143
- const ROUTER_INLINE_LIMIT = 4096; // bytes — skip files larger than ~4 KB
1144
- const ROUTER_INLINE_MAX_FILES = 3;
1145
- const routerFileContents = routerMountContext
1146
- .slice(0, ROUTER_INLINE_MAX_FILES)
1147
- .flatMap((f) => {
1148
- try {
1149
- const absPath = path.isAbsolute(f) ? f : path.join(params.repositoryPath, f);
1150
- const stat = fs.statSync(absPath);
1151
- if (stat.size > ROUTER_INLINE_LIMIT)
1152
- return [];
1153
- const content = fs.readFileSync(absPath, "utf-8").trimEnd();
1154
- return [{ file: f, content }];
1155
- }
1156
- catch {
1157
- return [];
1158
- }
1159
- });
1160
1210
  // Compute UI context from the diff's changed files using the shared
1161
1211
  // `isFrontendFile` classifier. Persisting this in the stateFile lets
1162
1212
  // skyramp_analyze_test_health and the recommendation prompt consume the
@@ -1252,22 +1302,37 @@ export function registerAnalyzeChangesTool(server) {
1252
1302
  relevantExternalTestPaths,
1253
1303
  },
1254
1304
  };
1255
- // Clean up old state files (>24 hours) before creating new one
1256
- const stateDir = params.stateOutputFile
1257
- ? path.dirname(path.resolve(params.stateOutputFile))
1258
- : undefined;
1305
+ // Clean up old state files (>24 hours) before creating new one.
1259
1306
  try {
1260
- await StateManager.cleanupOldFiles(24, stateDir);
1307
+ await StateManager.cleanupOldFiles(24);
1261
1308
  }
1262
1309
  catch (error) {
1263
1310
  logger.warning(`Failed to cleanup old state files: ${error.message}`);
1264
1311
  }
1265
- const stateManager = new StateManager("analysis", sessionId, undefined, params.stateOutputFile);
1266
- await stateManager.writeData(unifiedState, {
1312
+ // Resolve the state file. Multi-repo (`repository` set): all calls in one
1313
+ // Testbot run share ONE state file — the first call creates it and records
1314
+ // the run anchor; later related-repo calls reuse it (no agent-provided path,
1315
+ // which previously caused filename hallucinations). Single-repo (`repository`
1316
+ // absent): always a fresh per-call file — byte-identical to prior behavior.
1317
+ const activeRunPath = params.repository
1318
+ ? getActiveRunStatePath()
1319
+ : undefined;
1320
+ const stateManager = activeRunPath
1321
+ ? StateManager.fromStatePath(activeRunPath)
1322
+ : new StateManager("analysis", sessionId);
1323
+ // `repository` identifies which repo this analysis belongs to. The primary
1324
+ // (first write of the run, or matching the root's recorded repository) is
1325
+ // stored at the root; a related repo is stored as a keyed section within the
1326
+ // same run-scoped stateFile.
1327
+ await stateManager.writeRepoData(unifiedState, {
1328
+ repo: params.repository,
1267
1329
  repositoryPath: params.repositoryPath,
1268
1330
  step: "analyze_changes",
1269
1331
  });
1270
1332
  const stateFile = stateManager.getStatePath();
1333
+ // Anchor the run's state file so subsequent related-repo calls reuse it.
1334
+ if (params.repository)
1335
+ setActiveRunStatePath(stateFile);
1271
1336
  registerSession(sessionId, stateFile);
1272
1337
  try {
1273
1338
  await server.server.sendResourceListChanged();
@@ -49,14 +49,24 @@ jest.unstable_mockModule("../../utils/trace-parser.js", () => ({
49
49
  jest.unstable_mockModule("../../utils/pr-comment-parser.js", () => ({
50
50
  parsePRComments: jest.fn(),
51
51
  }));
52
- jest.unstable_mockModule("../../utils/AnalysisStateManager.js", () => ({
53
- StateManager: jest.fn().mockImplementation(() => ({
52
+ jest.unstable_mockModule("../../utils/AnalysisStateManager.js", () => {
53
+ const makeInstance = () => ({
54
54
  writeData: jest.fn(),
55
- })),
56
- registerSession: jest.fn(),
57
- storeSessionData: jest.fn(),
58
- setTestsRepoDir: jest.fn(),
59
- }));
55
+ writeRepoData: jest.fn(),
56
+ getStatePath: jest.fn().mockReturnValue("/tmp/skyramp/analysis-state.json"),
57
+ });
58
+ const StateManager = jest.fn().mockImplementation(makeInstance);
59
+ StateManager.fromStatePath = jest.fn().mockImplementation(makeInstance);
60
+ return {
61
+ StateManager,
62
+ registerSession: jest.fn(),
63
+ storeSessionData: jest.fn(),
64
+ setTestsRepoDir: jest.fn(),
65
+ getActiveRunStatePath: jest.fn(),
66
+ setActiveRunStatePath: jest.fn(),
67
+ clearActiveRunStatePath: jest.fn(),
68
+ };
69
+ });
60
70
  // Keep every real workspaceAuth export the subject's import graph needs
61
71
  // (readWorkspaceConfigRaw, getDefaultAuthHeader, WorkspaceAuthType, …) and
62
72
  // override only parseWorkspaceAuthType. requireActual can't load ESM
@@ -99,28 +109,6 @@ describe("isSecurityRelevantDiff", () => {
99
109
  expect(isSecurityRelevantDiff("move compression middleware to server setup")).toBe(false);
100
110
  });
101
111
  });
102
- describe("analyzeChangesInputSchema — stateOutputFile validation", () => {
103
- it("accepts a valid absolute path", () => {
104
- const result = schema.safeParse({
105
- repositoryPath: "/repo",
106
- stateOutputFile: "/tmp/analyze-changes-state.json",
107
- });
108
- expect(result.success).toBe(true);
109
- });
110
- it("rejects a relative path for stateOutputFile", () => {
111
- // stateOutputFile must be absolute so the caller can guarantee the file location.
112
- // Relative paths are silently ambiguous and should be rejected.
113
- const result = schema.safeParse({
114
- repositoryPath: "/repo",
115
- stateOutputFile: "relative/path/state.json",
116
- });
117
- expect(result.success).toBe(false);
118
- });
119
- it("accepts absence of stateOutputFile (optional field)", () => {
120
- const result = schema.safeParse({ repositoryPath: "/repo" });
121
- expect(result.success).toBe(true);
122
- });
123
- });
124
112
  describe("automatic old files cleanup", () => {
125
113
  let cleanupOldFilesSpy;
126
114
  beforeEach(() => {
@@ -128,31 +116,23 @@ describe("automatic old files cleanup", () => {
128
116
  cleanupOldFilesSpy = jest.fn().mockResolvedValue(0);
129
117
  StateManager.cleanupOldFiles = cleanupOldFilesSpy;
130
118
  });
131
- it("calls cleanupOldFiles with default temp dir when no stateOutputFile provided", async () => {
132
- // This test verifies the cleanup call is made without stateDir when using default location
119
+ it("calls cleanupOldFiles with default temp dir", async () => {
133
120
  expect(StateManager.cleanupOldFiles).toBeDefined();
134
121
  await StateManager.cleanupOldFiles(24, undefined);
135
122
  expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, undefined);
136
123
  });
137
- it("calls cleanupOldFiles with custom dir when stateOutputFile is provided", async () => {
138
- // This test verifies the cleanup call is made with the directory of stateOutputFile
124
+ it("calls cleanupOldFiles with custom dir", async () => {
139
125
  const customPath = "/custom/dir";
140
126
  await StateManager.cleanupOldFiles(24, customPath);
141
127
  expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, customPath);
142
128
  });
143
- it("calls cleanupOldFiles with empty stateTypes to restrict to diff files only", async () => {
144
- // analyzeTestHealthTool passes [] so state files (skyramp-analysis-*, skyramp-recommendation-*)
145
- // are never deleted — the caller still needs args.stateFile for skyramp_actions.
129
+ it("calls cleanupOldFiles with empty stateTypes", async () => {
146
130
  await StateManager.cleanupOldFiles(24, undefined, []);
147
131
  expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, undefined, []);
148
132
  });
149
133
  it("continues execution if cleanup fails", async () => {
150
- // Cleanup failures should not crash the analyze flow
151
134
  cleanupOldFilesSpy.mockRejectedValue(new Error("permission denied"));
152
- await expect(StateManager.cleanupOldFiles(24).catch(() => {
153
- // In the real code, this is caught and logged as a warning
154
- return Promise.resolve();
155
- })).resolves.toBeUndefined();
135
+ await expect(StateManager.cleanupOldFiles(24).catch(() => Promise.resolve())).resolves.toBeUndefined();
156
136
  });
157
137
  });
158
138
  // ─────────────────────────────────────────────────────────────────────────────
@@ -15,11 +15,15 @@ export function registerAnalyzeTestHealthTool(server) {
15
15
  idempotentHint: true,
16
16
  openWorldHint: false,
17
17
  },
18
- description: `Generate drift assessment instructions for existing tests — second step of the unified Test Health Analysis Flow. Optionally execute tests with skyramp_execute_test before calling skyramp_actions to capture beforeStatus.`,
18
+ description: `Generate drift assessment instructions for existing tests — second step of the unified Test Health Analysis Flow.`,
19
19
  inputSchema: {
20
20
  stateFile: z
21
21
  .string()
22
- .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."),
22
+ .describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass summaryOutputFile or testbot-result.txt here."),
23
+ repository: z
24
+ .string()
25
+ .optional()
26
+ .describe("The owner/repo whose analysis section to assess (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it — the primary's owner/repo for the primary repo, or a related repo's owner/repo for that repo's section within the run-scoped stateFile. Omit only for a single-repo run."),
23
27
  },
24
28
  outputSchema: {
25
29
  prompt: z.string().describe("LLM drift analysis prompt and assessment instructions."),
@@ -27,17 +31,20 @@ export function registerAnalyzeTestHealthTool(server) {
27
31
  }, async (args) => {
28
32
  let errorResult;
29
33
  try {
30
- logger.info(`Analyzing test health from state file: ${args.stateFile}`);
31
- // Load UnifiedAnalysisState from state file
34
+ logger.info(`Analyzing test health from state file: ${args.stateFile}${args.repository ? ` (repository: ${args.repository})` : ""}`);
35
+ // Load UnifiedAnalysisState for the requested repo section (primary when
36
+ // repository omitted/matches the root) from the run-scoped state file.
32
37
  const stateManager = StateManager.fromStatePath(args.stateFile);
33
- const stateData = await stateManager.readData();
34
- const fullState = await stateManager.readFullState();
35
- const repositoryPath = fullState?.metadata.repositoryPath || "";
38
+ const stateData = await stateManager.readRepoData(args.repository);
39
+ const repositoryPath = (await stateManager.getRepoRepositoryPath(args.repository)) || "";
36
40
  if (!stateData) {
37
- return toolError(`State file is empty or invalid: ${args.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
41
+ return toolError(`State file is empty or invalid: ${args.stateFile}${args.repository ? ` (no section for repository ${args.repository})` : ""}. Call skyramp_analyze_changes first to generate a valid state file.`);
38
42
  }
39
43
  if (!repositoryPath || typeof repositoryPath !== "string") {
40
- return toolError(`repositoryPath not found in state file metadata. The state file was likely created by an older versionre-run skyramp_analyze_changes to regenerate it.`);
44
+ return toolError(`repositoryPath not found in state file metadata. The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes not summaryOutputFile or testbot-result.txt. Re-run skyramp_analyze_changes to regenerate it.`);
45
+ }
46
+ if (!Array.isArray(stateData.existingTests)) {
47
+ return toolError(`stateFile does not contain test discovery data (existingTests missing). The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not summaryOutputFile or testbot-result.txt.`);
41
48
  }
42
49
  // Skyramp tests: full drift analysis + all actions permitted.
43
50
  // Relevant external tests (user-written, relevant to this PR's endpoints): drift analysis
@@ -7,6 +7,7 @@ import { logger } from "../../utils/logger.js";
7
7
  import { AnalyticsService } from "../../services/AnalyticsService.js";
8
8
  import { SKYRAMP_IMAGE_VERSION } from "../../utils/versions.js";
9
9
  import { validateAndConsumeScanToken } from "./initScanWorkspaceTool.js";
10
+ import { upsertServicesByRepo } from "./serviceUpsert.js";
10
11
  const require = createRequire(import.meta.url);
11
12
  const MCP_VERSION = require("../../../package.json").version || "";
12
13
  function getExecutorVersion() {
@@ -16,8 +17,8 @@ const TOOL_NAME = "skyramp_init_workspace";
16
17
  // writeWorkspaceConfig() writes the YAML directly (rather than via
17
18
  // WorkspaceConfigManager.addService) so all services are written in a single
18
19
  // pass without N+1 reads. The workspace serviceSchema accepts the full authType
19
- // set (incl. cookie/session/token), so input services are validated against it
20
- // directly — no local schema extension needed.
20
+ // set (incl. cookie/session/token) and the multi-repo `repository` field, so input
21
+ // services are validated against it directly — no local schema extension needed.
21
22
  const initializeWorkspaceSchema = z.object({
22
23
  workspacePath: z
23
24
  .string()
@@ -32,6 +33,10 @@ const initializeWorkspaceSchema = z.object({
32
33
  .boolean()
33
34
  .default(false)
34
35
  .describe("Set to true to overwrite an existing workspace file. Use when the user explicitly requests it, or when recovering from a workspace validation failure (schema mismatch, unknown fields). Default is false."),
36
+ merge: z
37
+ .boolean()
38
+ .default(false)
39
+ .describe("Set to true to MERGE the provided services into an existing workspace.yml instead of overwriting it. The primary use is registering a related repository's services into one unified workspace file (each service carrying its `repository`), but it applies whenever you want to add services without discarding existing ones. Services are upserted by the composite (repository, serviceName), so same-named services in different repos coexist. Ignored on first init (no existing file). Default is false."),
35
40
  });
36
41
  /**
37
42
  * Write a YAML workspace config to disk, bypassing the library's restricted
@@ -72,13 +77,17 @@ export function registerInitializeWorkspaceTool(server) {
72
77
  try {
73
78
  const workspacePath = params.workspacePath;
74
79
  const manager = new WorkspaceConfigManager(workspacePath);
75
- // Check if already initialized
76
- if ((await manager.exists()) && !params.force) {
80
+ const alreadyExists = await manager.exists();
81
+ // Check if already initialized. In merge mode we proceed (read the
82
+ // existing file and upsert into it) instead of refusing — this is how a
83
+ // multi-repo run registers each additional repo's services into one
84
+ // unified workspace.yml without a per-repo file.
85
+ if (alreadyExists && !params.force && !params.merge) {
77
86
  return {
78
87
  content: [
79
88
  {
80
89
  type: "text",
81
- text: `Workspace already initialized at ${manager.getConfigPath()}. Set force: true to overwrite.`,
90
+ text: `Workspace already initialized at ${manager.getConfigPath()}. Set force: true to overwrite, or merge: true to add services to it.`,
82
91
  },
83
92
  ],
84
93
  isError: false,
@@ -107,24 +116,30 @@ export function registerInitializeWorkspaceTool(server) {
107
116
  isError: true,
108
117
  };
109
118
  }
110
- // Initialize workspace
111
- let config = await manager.initialize();
112
- config = await manager.updateMetadata({
113
- mcpVersion: MCP_VERSION,
114
- executorVersion: getExecutorVersion(),
115
- });
116
- // Batch all service upserts: update in memory, write once (direct YAML
117
- // write avoids N+1 reads and a mid-loop validation error).
118
- const services = config.services ?? [];
119
- for (const svc of params.services) {
120
- const idx = services.findIndex((s) => s.serviceName === svc.serviceName);
121
- if (idx >= 0) {
122
- services[idx] = svc;
123
- }
124
- else {
125
- services.push(svc);
126
- }
119
+ // Initialize (fresh) or read (merge into existing). Merge mode preserves
120
+ // the existing primary repo identity + previously-registered services;
121
+ // initialize() would reset both, dropping other repos' services.
122
+ let config;
123
+ if (params.merge && alreadyExists) {
124
+ config = await manager.read();
125
+ config = await manager.updateMetadata({
126
+ mcpVersion: MCP_VERSION,
127
+ executorVersion: getExecutorVersion(),
128
+ });
129
+ }
130
+ else {
131
+ config = await manager.initialize();
132
+ config = await manager.updateMetadata({
133
+ mcpVersion: MCP_VERSION,
134
+ executorVersion: getExecutorVersion(),
135
+ });
127
136
  }
137
+ // Batch all service upserts: update in memory, write once (direct YAML
138
+ // write avoids N+1 reads and a mid-loop validation error). Upsert is keyed
139
+ // on the composite (repo, serviceName) — see upsertServicesByRepo — so a
140
+ // same-named service from an additional repo is a distinct entry, not an
141
+ // overwrite of the primary's.
142
+ const services = upsertServicesByRepo(config.services ?? [], params.services);
128
143
  config.services = services;
129
144
  if (config.metadata) {
130
145
  config.metadata.updatedAt = new Date().toISOString();
@@ -142,6 +157,8 @@ export function registerInitializeWorkspaceTool(server) {
142
157
  lines.push(`\nServices (${config.services.length}):`);
143
158
  for (const svc of config.services) {
144
159
  const parts = [svc.serviceName];
160
+ if (svc.repository)
161
+ parts.push(svc.repository);
145
162
  if (svc.language)
146
163
  parts.push(svc.language);
147
164
  if (svc.framework)
@@ -0,0 +1,12 @@
1
+ import type { Service } from "../../workspace/workspace.js";
2
+ /**
3
+ * Upsert `incoming` services into `existing`, keyed on the composite
4
+ * (repository, serviceName). A service's `repository` (absent → primary,
5
+ * normalized to "") is part of its identity, so a same-named service from a
6
+ * different repo is a distinct entry rather than an overwrite. Mutates and
7
+ * returns `existing`.
8
+ *
9
+ * Lives in its own module (no `import.meta`) so it can be unit-tested under
10
+ * ts-jest's CommonJS transform without loading the tool's ESM-only deps.
11
+ */
12
+ export declare function upsertServicesByRepo(existing: Service[], incoming: Service[]): Service[];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Upsert `incoming` services into `existing`, keyed on the composite
3
+ * (repository, serviceName). A service's `repository` (absent → primary,
4
+ * normalized to "") is part of its identity, so a same-named service from a
5
+ * different repo is a distinct entry rather than an overwrite. Mutates and
6
+ * returns `existing`.
7
+ *
8
+ * Lives in its own module (no `import.meta`) so it can be unit-tested under
9
+ * ts-jest's CommonJS transform without loading the tool's ESM-only deps.
10
+ */
11
+ export function upsertServicesByRepo(existing, incoming) {
12
+ for (const svc of incoming) {
13
+ const repoKey = svc.repository || "";
14
+ const idx = existing.findIndex((s) => (s.repository || "") === repoKey && s.serviceName === svc.serviceName);
15
+ if (idx >= 0) {
16
+ existing[idx] = svc;
17
+ }
18
+ else {
19
+ existing.push(svc);
20
+ }
21
+ }
22
+ return existing;
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { upsertServicesByRepo } from "./serviceUpsert.js";
2
+ // upsertServicesByRepo is the merge=true core: it upserts incoming services into
3
+ // the existing list keyed on the composite (repository, serviceName), so a
4
+ // same-named service from a different repo is a distinct entry rather than an
5
+ // overwrite. This is what makes the unified multi-repo workspace.yml correct.
6
+ describe("upsertServicesByRepo", () => {
7
+ const svc = (serviceName, repository, testDirectory) => ({ serviceName, repository, testDirectory });
8
+ it("keeps same-named services from different repos as distinct entries", () => {
9
+ const existing = [svc("api", undefined, "tests/primary")];
10
+ const result = upsertServicesByRepo(existing, [
11
+ svc("api", "letsramp/api-insight", "tests/py"),
12
+ ]);
13
+ expect(result).toHaveLength(2);
14
+ expect(result.find((s) => !s.repository)?.testDirectory).toBe("tests/primary");
15
+ expect(result.find((s) => s.repository === "letsramp/api-insight")?.testDirectory).toBe("tests/py");
16
+ });
17
+ it("updates only the matching (repository, serviceName) entry in place", () => {
18
+ const existing = [
19
+ svc("api", undefined, "tests/primary"),
20
+ svc("api", "letsramp/api-insight", "tests/py"),
21
+ ];
22
+ // Re-register the related repo's service with a new testDirectory.
23
+ const result = upsertServicesByRepo(existing, [
24
+ svc("api", "letsramp/api-insight", "tests/py-v2"),
25
+ ]);
26
+ expect(result).toHaveLength(2);
27
+ // Primary untouched.
28
+ expect(result.find((s) => !s.repository)?.testDirectory).toBe("tests/primary");
29
+ // Related repo's entry updated in place (not duplicated).
30
+ const related = result.filter((s) => s.repository === "letsramp/api-insight");
31
+ expect(related).toHaveLength(1);
32
+ expect(related[0].testDirectory).toBe("tests/py-v2");
33
+ });
34
+ it("treats an absent repository as the primary (normalized to '')", () => {
35
+ const existing = [svc("api", undefined, "tests/primary")];
36
+ // A second primary write (no repository) upserts the SAME entry.
37
+ const result = upsertServicesByRepo(existing, [svc("api", undefined, "tests/primary-v2")]);
38
+ expect(result).toHaveLength(1);
39
+ expect(result[0].testDirectory).toBe("tests/primary-v2");
40
+ });
41
+ it("appends brand-new services from multiple repos", () => {
42
+ const result = upsertServicesByRepo([], [
43
+ svc("web", "letsramp/frontend", "tests/web"),
44
+ svc("api", "letsramp/backend", "tests/api"),
45
+ svc("api", undefined, "tests/primary"),
46
+ ]);
47
+ expect(result).toHaveLength(3);
48
+ expect(new Set(result.map((s) => `${s.repository ?? ""}|${s.serviceName}`))).toEqual(new Set(["letsramp/frontend|web", "letsramp/backend|api", "|api"]));
49
+ });
50
+ });
@@ -92,5 +92,6 @@ export interface TestAnalysisResult {
92
92
  changes: DriftChange[];
93
93
  affectedFiles: AffectedFiles;
94
94
  };
95
- execution?: TestExecutionResult;
95
+ executionBefore?: TestExecutionResult;
96
+ executionAfter?: TestExecutionResult;
96
97
  }
@@ -3,18 +3,28 @@
3
3
  *
4
4
  * These types define the structure for test execution results
5
5
  */
6
+ /** Execution outcome for a test run recorded in the maintenance report. */
7
+ export declare enum TestExecutionStatus {
8
+ Pass = "Pass",
9
+ Fail = "Fail",
10
+ Error = "Error",
11
+ /** Deliberately not executed — VERIFY/IGNORE actions or DELETE (file removed). */
12
+ Skipped = "Skipped",
13
+ /** Execution was expected (UPDATE/REGENERATE) but no result was recorded. */
14
+ Unknown = "Unknown"
15
+ }
6
16
  export interface TestExecutionData {
7
- passed: boolean;
17
+ status: TestExecutionStatus;
8
18
  executedAt: string;
9
19
  duration: number;
10
20
  errors: string[];
11
21
  warnings: string[];
12
- crashed: boolean;
13
22
  output?: string;
14
23
  exitCode?: number;
15
24
  }
16
25
  export interface TestExecutionResult extends TestExecutionData {
17
26
  testFile: string;
27
+ videoPath?: string;
18
28
  }
19
29
  export interface BatchExecutionResult {
20
30
  totalTests: number;
@@ -3,4 +3,14 @@
3
3
  *
4
4
  * These types define the structure for test execution results
5
5
  */
6
- export {};
6
+ /** Execution outcome for a test run recorded in the maintenance report. */
7
+ export var TestExecutionStatus;
8
+ (function (TestExecutionStatus) {
9
+ TestExecutionStatus["Pass"] = "Pass";
10
+ TestExecutionStatus["Fail"] = "Fail";
11
+ TestExecutionStatus["Error"] = "Error";
12
+ /** Deliberately not executed — VERIFY/IGNORE actions or DELETE (file removed). */
13
+ TestExecutionStatus["Skipped"] = "Skipped";
14
+ /** Execution was expected (UPDATE/REGENERATE) but no result was recorded. */
15
+ TestExecutionStatus["Unknown"] = "Unknown";
16
+ })(TestExecutionStatus || (TestExecutionStatus = {}));