@skyramp/mcp 0.2.9 → 0.2.10-rc.1

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 (76) hide show
  1. package/build/index.js +1 -1
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
  4. package/build/prompts/personas.d.ts +2 -2
  5. package/build/prompts/personas.js +2 -2
  6. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -0
  7. package/build/prompts/sut-setup/shared.js +1 -3
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +1 -1
  9. package/build/prompts/test-maintenance/drift-analysis-prompt.js +10 -5
  10. package/build/prompts/test-maintenance/driftAnalysisSections.js +1 -4
  11. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +7 -1
  12. package/build/prompts/test-maintenance/driftAnalysisShared.js +19 -1
  13. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +0 -6
  14. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +0 -16
  15. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  16. package/build/prompts/testbot/testbot-prompts.js +8 -8
  17. package/build/resources/testbotResource.js +1 -1
  18. package/build/services/TestDiscoveryService.d.ts +0 -1
  19. package/build/services/TestDiscoveryService.js +13 -33
  20. package/build/services/TestDiscoveryService.test.js +136 -1
  21. package/build/services/TestExecutionService.d.ts +1 -1
  22. package/build/tools/executeSkyrampTestTool.js +40 -71
  23. package/build/tools/submitReportTool.d.ts +20 -20
  24. package/build/tools/submitReportTool.js +113 -31
  25. package/build/tools/submitReportTool.test.js +411 -94
  26. package/build/tools/test-management/actionsTool.js +135 -53
  27. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  28. package/build/tools/test-management/actionsTool.test.js +297 -0
  29. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  30. package/build/tools/test-management/analyzeChangesTool.js +63 -152
  31. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  32. package/build/tools/test-management/analyzeTestHealthTool.js +24 -157
  33. package/build/tools/test-management/analyzeTestHealthTool.test.js +15 -215
  34. package/build/tools/workspace/initializeWorkspaceTool.js +35 -15
  35. package/build/types/FrontendIntegration.d.ts +25 -0
  36. package/build/types/FrontendIntegration.js +19 -0
  37. package/build/types/RepositoryAnalysis.d.ts +8 -8
  38. package/build/types/TestAnalysis.d.ts +16 -50
  39. package/build/types/TestAnalysis.js +0 -28
  40. package/build/types/TestTypes.d.ts +3 -1
  41. package/build/types/TestTypes.js +3 -1
  42. package/build/utils/AnalysisStateManager.d.ts +13 -1
  43. package/build/utils/docker.test.js +1 -1
  44. package/build/utils/featureFlags.d.ts +1 -1
  45. package/build/utils/featureFlags.js +1 -1
  46. package/build/utils/frontendIntegration.d.ts +9 -0
  47. package/build/utils/frontendIntegration.js +237 -0
  48. package/build/utils/frontendIntegration.test.d.ts +1 -0
  49. package/build/utils/frontendIntegration.test.js +229 -0
  50. package/build/utils/pr-comment-parser.d.ts +3 -3
  51. package/build/utils/pr-comment-parser.js +6 -6
  52. package/build/utils/pr-comment-parser.test.js +3 -3
  53. package/build/utils/repoScanner.d.ts +7 -0
  54. package/build/utils/repoScanner.js +14 -6
  55. package/build/utils/repoScanner.test.js +9 -9
  56. package/build/utils/routeParsers.d.ts +32 -0
  57. package/build/utils/routeParsers.js +205 -2
  58. package/build/utils/routeParsers.test.js +279 -29
  59. package/build/utils/sourceRouteExtractor.js +174 -5
  60. package/build/utils/sourceRouteExtractor.test.js +173 -0
  61. package/build/utils/utils.d.ts +11 -0
  62. package/build/utils/utils.js +19 -0
  63. package/build/utils/utils.test.js +23 -1
  64. package/build/utils/versions.d.ts +3 -3
  65. package/build/utils/versions.js +1 -1
  66. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  67. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  68. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  69. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  70. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  71. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  72. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  73. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  74. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  75. package/node_modules/playwright/package.json +1 -1
  76. package/package.json +3 -3
@@ -1,6 +1,5 @@
1
1
  // @ts-nocheck - Jest ESM type inference issues
2
2
  import { jest } from "@jest/globals";
3
- import { z } from "zod";
4
3
  // Mock all heavy dependencies so the module can be loaded in isolation.
5
4
  // Under native ESM, jest.mock is not hoisted — every mock the subject's import
6
5
  // graph touches must use unstable_mockModule before the dynamic import below.
@@ -27,7 +26,10 @@ jest.unstable_mockModule("../../utils/branchDiff.js", () => ({
27
26
  jest.unstable_mockModule("../../utils/routeParsers.js", () => ({
28
27
  classifyEndpointsByChangedFiles: jest.fn(),
29
28
  extractResourceFromPath: jest.fn(),
29
+ extractTopLevelResourceFromPath: jest.fn(),
30
30
  parseFileEndpoints: jest.fn(),
31
+ selectRemovalCandidateFiles: jest.fn(() => []),
32
+ recoverRemovedEndpointsFromBase: jest.fn(async () => []),
31
33
  }));
32
34
  jest.unstable_mockModule("../../utils/repoScanner.js", () => ({
33
35
  scanAllRepoEndpoints: jest.fn(),
@@ -87,7 +89,7 @@ jest.unstable_mockModule("@modelcontextprotocol/sdk/shared/protocol.js", () => (
87
89
  // All subject + mocked-module imports must be dynamic and live AFTER the
88
90
  // unstable_mockModule calls above — static imports hoist above the mocks under
89
91
  // native ESM, loading the real (heavy) modules unmocked.
90
- const { analyzeChangesInputSchema, isSecurityRelevantDiff, filterEndpointsBySpec, filterUnsupportedGraphQLEndpoints, isGraphQLFile, registerAnalyzeChangesTool, buildTraceFileEntry, buildCaptureInstructions, NO_UI_INSTRUCTIONS, NO_RESOLVABLE_URLS_INSTRUCTIONS, } = await import("./analyzeChangesTool.js");
92
+ const { analyzeChangesInputSchema, isSecurityRelevantDiff, filterEndpointsBySpec, filterUnsupportedGraphQLEndpoints, isGraphQLFile, registerAnalyzeChangesTool, buildTraceFileEntry, buildCaptureInstructions, buildUnintegratedInstructions, NO_UI_INSTRUCTIONS, NO_RESOLVABLE_URLS_INSTRUCTIONS, } = await import("./analyzeChangesTool.js");
91
93
  const { computeBranchDiff } = await import("../../utils/branchDiff.js");
92
94
  const { classifyEndpointsByChangedFiles } = await import("../../utils/routeParsers.js");
93
95
  const { scanRelatedEndpoints, scanAllRepoEndpoints } = await import("../../utils/repoScanner.js");
@@ -96,7 +98,6 @@ const os = await import("os");
96
98
  const path = await import("path");
97
99
  const fsSync = await import("fs");
98
100
  const fsModule = fsSync;
99
- const schema = z.object(analyzeChangesInputSchema);
100
101
  describe("isSecurityRelevantDiff", () => {
101
102
  it("matches auth-specific middleware and admin-key signals", () => {
102
103
  expect(isSecurityRelevantDiff("const authMiddleware = requireAuth();")).toBe(true);
@@ -454,3 +455,33 @@ describe("UI blueprint-capture instructions (merged from ui_analyze_changes)", (
454
455
  expect(text).not.toMatch(/ui-credentials/i);
455
456
  });
456
457
  });
458
+ // ─────────────────────────────────────────────────────────────────────────────
459
+ // buildUnintegratedInstructions — SKYR-3855 server-side integration check.
460
+ // Per .claude/CLAUDE.md, only assert structural branching (which files are
461
+ // listed), not prose wording.
462
+ // ─────────────────────────────────────────────────────────────────────────────
463
+ describe("buildUnintegratedInstructions", () => {
464
+ it("lists a single unintegrated file when only one is passed (all-unintegrated case)", () => {
465
+ const text = buildUnintegratedInstructions([
466
+ { file: "src/components/EntityExplorerSidebar.tsx", integrated: false, importers: [], reason: "no-importers" },
467
+ ]);
468
+ expect(text).toContain("src/components/EntityExplorerSidebar.tsx");
469
+ expect(text).not.toContain("src/components/OtherPanel.tsx");
470
+ });
471
+ it("lists every file passed in — mixed-PR call sites scope this to just the unintegrated subset", () => {
472
+ const text = buildUnintegratedInstructions([
473
+ { file: "src/components/A.tsx", integrated: false, importers: [], reason: "no-importers" },
474
+ { file: "src/components/B.tsx", integrated: false, importers: [], reason: "no-importers" },
475
+ ]);
476
+ expect(text).toContain("src/components/A.tsx");
477
+ expect(text).toContain("src/components/B.tsx");
478
+ });
479
+ it("filters out integrated files even if accidentally included by a caller", () => {
480
+ const text = buildUnintegratedInstructions([
481
+ { file: "src/components/Unintegrated.tsx", integrated: false, importers: [], reason: "no-importers" },
482
+ { file: "src/pages/Dashboard.tsx", integrated: true, importers: ["src/App.tsx"], reason: "imported" },
483
+ ]);
484
+ expect(text).toContain("src/components/Unintegrated.tsx");
485
+ expect(text).not.toContain("src/pages/Dashboard.tsx");
486
+ });
487
+ });
@@ -1,8 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { logger } from "../../utils/logger.js";
3
3
  import { StateManager, } from "../../utils/AnalysisStateManager.js";
4
- import path from "path";
5
- import * as fs from "fs/promises";
6
4
  import { AnalyticsService } from "../../services/AnalyticsService.js";
7
5
  import { buildDriftAnalysisPrompt } from "../../prompts/test-maintenance/drift-analysis-prompt.js";
8
6
  import { TestSource } from "../../types/TestAnalysis.js";
@@ -22,110 +20,11 @@ const UI_COMPONENT_FRAMEWORKS = new Set([
22
20
  ]);
23
21
  // No REST API test is ever written in TSX/JSX — extension alone is sufficient.
24
22
  const UI_TEST_EXTENSIONS = /\.(test|spec)\.(tsx|jsx)$/;
25
- // Snapshot files (.snap) are secondary artifacts of UI tests — send them through
26
- // the UI drift path so stale snapshots are detected when frontend selectors change.
27
23
  const UI_SNAPSHOT_EXT = /\.snap$/;
28
- // Playwright/Cypress without the "-ui" suffix = REST API usage — must not be routed
29
- // to UI drift even when detectExternalTestType() labels the file as "e2e".
30
- const API_ONLY_FRAMEWORKS = new Set(["playwright", "cypress"]);
31
24
  function isUiTest(t) {
32
25
  const fw = t.framework?.toLowerCase() ?? "";
33
- // testType==="e2e" can be set by detectExternalTestType() for any file under
34
- // playwright/ or cypress/ dirs, including API-only specs. Exclude those.
35
- if (t.testType === "e2e" && API_ONLY_FRAMEWORKS.has(fw))
36
- return false;
37
- // .snap files are secondary artifacts — only route them to UI drift when
38
- // accompanied by a UI testType or framework signal. API test snapshots
39
- // (e.g. __snapshots__/orders_smoke.test.ts.snap) must remain in the API path.
40
- const isUiSnap = UI_SNAPSHOT_EXT.test(t.testFile) &&
41
- (t.testType === "ui" || UI_COMPONENT_FRAMEWORKS.has(fw));
42
- return t.testType === "ui" || t.testType === "e2e" ||
43
- UI_COMPONENT_FRAMEWORKS.has(fw) ||
44
- UI_TEST_EXTENSIONS.test(t.testFile) ||
45
- isUiSnap;
46
- }
47
- // Grep exported symbols + diff UI signals across external tests to find tests that
48
- // reference changed frontend files when relevance scoring returns nothing (UI-only PRs).
49
- const SYMBOL_GREP_MAX_READS = 150;
50
- const SYMBOL_GREP_MAX_RESULTS = 20;
51
- const GENERIC_SLUGS = new Set([
52
- "index", "app", "main", "utils", "util", "lib", "helpers", "helper",
53
- "common", "shared", "base", "core", "types", "constants", "config",
54
- "hooks", "store", "context", "layout", "page", "view", "component",
55
- ]);
56
- async function findUiTestsBySymbolGrep(changedFrontendFiles, allTests, repositoryPath, alreadyIncluded, diffFilePath) {
57
- const symbols = new Set();
58
- for (const file of changedFrontendFiles) {
59
- const abs = path.isAbsolute(file) ? file : path.resolve(repositoryPath, file);
60
- const content = await fs.readFile(abs, "utf-8").catch(() => "");
61
- for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|class|const|let|var)\s+(\w{3,})/g)) {
62
- symbols.add(m[1]);
63
- }
64
- for (const m of content.matchAll(/(?:name|__name):\s*['"](\w[\w-]{2,})['"]/g)) {
65
- symbols.add(m[1]);
66
- }
67
- const slug = path.basename(file).replace(/\.\w+$/, "");
68
- if (!GENERIC_SLUGS.has(slug.toLowerCase()) && (slug.includes("-") || slug.length > 6)) {
69
- symbols.add(slug);
70
- const pascal = slug.replace(/(^|[-_])(\w)/g, (_, _sep, c) => c.toUpperCase());
71
- symbols.add(pascal);
72
- }
73
- }
74
- // Also extract quoted string literals from diff +/- lines for changed frontend files.
75
- // This catches tests referencing OLD values (renamed testid, aria-label, CSS class) that
76
- // only appear in `-` diff lines, not in the current file content.
77
- if (diffFilePath) {
78
- const diffContent = await fs.readFile(diffFilePath, "utf-8").catch(() => "");
79
- // Exact path markers prevent basename substring false-positives
80
- // (e.g. "personal-calendar-view.test.tsx" matching "personal-calendar-view.tsx").
81
- const changedFileMarkers = changedFrontendFiles.flatMap(f => {
82
- const rel = f.replace(/\\/g, "/");
83
- return [` a/${rel} `, ` b/${rel}`];
84
- });
85
- let inChangedFile = false;
86
- for (const line of diffContent.split("\n")) {
87
- if (line.startsWith("diff --git ")) {
88
- inChangedFile = changedFileMarkers.length > 0 &&
89
- changedFileMarkers.some(marker => line.includes(marker));
90
- }
91
- if (!inChangedFile)
92
- continue;
93
- if (!line.startsWith("+") && !line.startsWith("-"))
94
- continue;
95
- // Min 10 chars skips generic values ("true","false","continue") that match
96
- // hundreds of unrelated test files.
97
- for (const m of line.matchAll(/["']([^"'\n]{10,60})["']/g)) {
98
- symbols.add(m[1]);
99
- }
100
- }
101
- }
102
- if (symbols.size === 0)
103
- return [];
104
- const symArray = [...symbols];
105
- logger.debug(`Symbol grep: ${symArray.length} symbol(s) — ${symArray.slice(0, 5).join(", ")}${symArray.length > 5 ? "..." : ""}`);
106
- // Normalize paths — stateData.existingTests may contain relative paths in
107
- // older state files. Mirror the re-absolutization done for relevantExternalPaths.
108
- const candidates = allTests
109
- .filter(t => (t.source ?? TestSource.Skyramp) === TestSource.External &&
110
- isUiTest(t) &&
111
- !alreadyIncluded.has(t.testFile))
112
- .map(t => ({
113
- ...t,
114
- testFile: path.isAbsolute(t.testFile)
115
- ? t.testFile
116
- : path.resolve(repositoryPath, t.testFile),
117
- }))
118
- .slice(0, SYMBOL_GREP_MAX_READS);
119
- // Read in batches to avoid EMFILE/ENFILE errors on large test suites.
120
- const BATCH = 20;
121
- const contents = [];
122
- for (let i = 0; i < candidates.length; i += BATCH) {
123
- const batch = await Promise.all(candidates.slice(i, i + BATCH).map(t => fs.readFile(t.testFile, "utf-8").catch(() => "")));
124
- contents.push(...batch);
125
- }
126
- return candidates
127
- .filter((_, i) => symArray.some(sym => contents[i].includes(sym)))
128
- .slice(0, SYMBOL_GREP_MAX_RESULTS);
26
+ const isUiSnap = UI_SNAPSHOT_EXT.test(t.testFile);
27
+ return UI_COMPONENT_FRAMEWORKS.has(fw) || UI_TEST_EXTENSIONS.test(t.testFile) || isUiSnap;
129
28
  }
130
29
  export function registerAnalyzeTestHealthTool(server) {
131
30
  server.registerTool(TOOL_NAME, {
@@ -170,19 +69,10 @@ export function registerAnalyzeTestHealthTool(server) {
170
69
  if (!Array.isArray(stateData.existingTests)) {
171
70
  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.`);
172
71
  }
173
- // Skyramp tests: full drift analysis + all actions permitted.
174
- // Relevant external tests (user-written, relevant to this PR's endpoints): drift analysis
175
- // + UPDATE only REGENERATE and DELETE are report-only (enforced in skyramp_actions).
176
- // Other external tests: excluded entirely (deduplication only, not analysed).
177
- // relevantExternalTestPaths are stored relative to repositoryPath in the state file.
178
- // Re-absolutize here so has() comparisons against t.testFile (absolute) work correctly.
179
- const relevantExternalPaths = new Set((stateData.repositoryAnalysis?.relevantExternalTestPaths ?? []).map((p) => path.isAbsolute(p) ? p : path.resolve(repositoryPath, p)));
180
- const existingTests = (stateData.existingTests || []).filter((t) => {
181
- if ((t.source ?? TestSource.Skyramp) !== TestSource.External)
182
- return true;
183
- return relevantExternalPaths.has(t.testFile);
184
- });
185
- const skyrampCount = existingTests.filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External).length;
72
+ // External tests: UPDATE only REGENERATE/DELETE are report-only (enforced in
73
+ // skyramp_actions). Already scoped to relevant ones by discovery.
74
+ const existingTests = stateData.existingTests || [];
75
+ const skyrampCount = existingTests.filter((t) => t.source !== TestSource.External).length;
186
76
  const externalCount = existingTests.length - skyrampCount;
187
77
  logger.info(`Loaded ${skyrampCount} Skyramp + ${externalCount} relevant external tests from state file`);
188
78
  // Delete stale diff files only — state files must remain for the caller's skyramp_actions call.
@@ -192,49 +82,15 @@ export function registerAnalyzeTestHealthTool(server) {
192
82
  catch (error) {
193
83
  logger.warning(`Failed to cleanup old diff files: ${error.message}`);
194
84
  }
85
+ const uiContext = stateData.uiContext;
86
+ const hasUiFrontend = !!(uiContext?.changedFrontendFiles?.length);
195
87
  const uiTests = [];
196
88
  const apiTests = [];
197
89
  for (const t of existingTests) {
198
- (isUiTest(t) ? uiTests : apiTests).push(t);
199
- }
200
- const uiContext = stateData.uiContext;
201
- const hasUiFrontend = !!(uiContext?.changedFrontendFiles?.length);
202
- // When frontend files changed, use the symbol grep to rank uiTests by relevance
203
- // and cap the list to avoid token overflow in the drift prompt.
204
- // All bulk-promoted UI tests are now in relevantExternalTestPaths and therefore
205
- // in uiTests — the grep selects the most relevant subset rather than being the
206
- // sole discovery path (which could silently drop tests that need UPDATE).
207
- if (hasUiFrontend && uiTests.length >= SYMBOL_GREP_MAX_RESULTS) {
208
- // Skyramp-generated UI tests always pass through — grep only ranks externals.
209
- const skyrampUi = uiTests.filter(t => (t.source ?? TestSource.Skyramp) !== TestSource.External);
210
- const externalUi = uiTests.filter(t => (t.source ?? TestSource.Skyramp) === TestSource.External);
211
- const externalCap = Math.max(0, SYMBOL_GREP_MAX_RESULTS - skyrampUi.length);
212
- // Grep the full discovered pool with empty alreadyIncluded so both:
213
- // (a) externalUi members are ranked by relevance, and
214
- // (b) tests not yet in uiTests (e.g. onboarding.e2e.ts missing from
215
- // relevantExternalTestPaths) can still be found via diff signals.
216
- // Previous bug: alreadyInUiTests excluded all candidates → grep returned [].
217
- const grepped = await findUiTestsBySymbolGrep(uiContext.changedFrontendFiles, stateData.existingTests || [], repositoryPath, new Set(), stateData.repositoryAnalysis?.diffFilePath);
218
- const rankedExternal = grepped.length > 0
219
- ? grepped.slice(0, externalCap)
220
- : externalUi.slice(0, externalCap); // fallback: discovery order
221
- if (grepped.length > 0) {
222
- logger.info(`Symbol grep ranked ${Math.min(grepped.length, externalCap)} external UI test(s) from ${stateData.existingTests?.length ?? 0} candidates`);
223
- }
224
- else {
225
- logger.info(`Symbol grep found no matches; capping ${externalUi.length} external UI tests to ${externalCap}`);
226
- }
227
- uiTests.splice(0, uiTests.length, ...skyrampUi, ...rankedExternal);
228
- }
229
- else if (hasUiFrontend && uiTests.length === 0) {
230
- // No UI tests found — grep the full discovered pool.
231
- // Pass empty alreadyIncluded: we want to discover tests from stateData.existingTests,
232
- // not exclude them. uiTests is empty so there's nothing to avoid re-adding.
233
- const grepped = await findUiTestsBySymbolGrep(uiContext.changedFrontendFiles, stateData.existingTests || [], repositoryPath, new Set(), stateData.repositoryAnalysis?.diffFilePath);
234
- if (grepped.length > 0) {
235
- logger.info(`Symbol grep found ${grepped.length} UI test(s) referencing changed frontend files`);
236
- uiTests.push(...grepped);
237
- }
90
+ // Only route to UI drift when frontend files changed — otherwise UI tests
91
+ // (playwright-ui, RTL, etc.) fall through to API drift so they are not
92
+ // silently dropped on API-only PRs.
93
+ (hasUiFrontend && isUiTest(t) ? uiTests : apiTests).push(t);
238
94
  }
239
95
  let uiDriftParams;
240
96
  if (hasUiFrontend) {
@@ -245,7 +101,18 @@ export function registerAnalyzeTestHealthTool(server) {
245
101
  blueprintCaptured: args.blueprintCaptured,
246
102
  };
247
103
  }
248
- const promptText = buildDriftAnalysisPrompt(stateManager.getStatePath(), apiTests.map((t) => ({ testFile: t.testFile, source: t.source })), uiDriftParams);
104
+ // Collect all checked-out repo paths so the grep instruction covers
105
+ // every repo in a multi-repo run (endpoints in Repo A, tests in Repo B).
106
+ // TODO(multi-repo): existingTests is scoped to the current repo only — related
107
+ // repo tests are not pre-loaded, discoverable only via the grep instruction.
108
+ // Skip the async lookups when there are no tests — symbol discovery is a no-op.
109
+ let allRepoPaths = [repositoryPath];
110
+ if (existingTests.length > 0) {
111
+ const relatedRepoKeys = await stateManager.listRelatedRepos();
112
+ const relatedRepoPaths = (await Promise.all(relatedRepoKeys.map(r => stateManager.getRepoRepositoryPath(r)))).filter((p) => typeof p === "string");
113
+ allRepoPaths = [repositoryPath, ...relatedRepoPaths];
114
+ }
115
+ const promptText = buildDriftAnalysisPrompt(stateManager.getStatePath(), apiTests.map((t) => ({ testFile: t.testFile, source: t.source })), uiDriftParams, allRepoPaths);
249
116
  return {
250
117
  structuredContent: { prompt: promptText },
251
118
  content: [{ type: "text", text: "Drift analysis prompt generated. Follow the prompt field to assess each test." }],
@@ -1,8 +1,5 @@
1
1
  // @ts-nocheck - Jest ESM type inference issues
2
- import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals";
3
- import * as fs from "fs";
4
- import * as os from "os";
5
- import * as path from "path";
2
+ import { jest, describe, it, expect, beforeEach } from "@jest/globals";
6
3
  jest.unstable_mockModule("@modelcontextprotocol/sdk/server/mcp.js", () => ({
7
4
  McpServer: jest.fn(),
8
5
  }));
@@ -68,6 +65,7 @@ function makeStateManager(stateData) {
68
65
  // Multi-repo API added in SKYR-3786 — analyzeTestHealthTool now uses these.
69
66
  readRepoData: jest.fn().mockResolvedValue(stateData),
70
67
  getRepoRepositoryPath: jest.fn().mockResolvedValue("/repo"),
68
+ listRelatedRepos: jest.fn().mockResolvedValue([]),
71
69
  };
72
70
  }
73
71
  // ---------------------------------------------------------------------------
@@ -93,7 +91,7 @@ describe("analyzeTestHealthTool — UI drift gate logic", () => {
93
91
  it("includes UI drift when uiContext has changedFrontendFiles and a UI test", async () => {
94
92
  const mockStateManager = makeStateManager({
95
93
  existingTests: [
96
- { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
94
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright-ui" },
97
95
  ],
98
96
  uiContext: {
99
97
  changedFrontendFiles: ["/repo/src/components/Cart.tsx"],
@@ -109,7 +107,7 @@ describe("analyzeTestHealthTool — UI drift gate logic", () => {
109
107
  });
110
108
  it("passes blueprintCaptured=true to buildUiDriftAnalysisPrompt", async () => {
111
109
  const mockStateManager = makeStateManager({
112
- existingTests: [{ testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" }],
110
+ existingTests: [{ testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright-ui" }],
113
111
  uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
114
112
  });
115
113
  mockFromStatePath.mockReturnValue(mockStateManager);
@@ -119,7 +117,7 @@ describe("analyzeTestHealthTool — UI drift gate logic", () => {
119
117
  });
120
118
  it("passes blueprintCaptured=false to buildUiDriftAnalysisPrompt", async () => {
121
119
  const mockStateManager = makeStateManager({
122
- existingTests: [{ testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" }],
120
+ existingTests: [{ testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright-ui" }],
123
121
  uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
124
122
  });
125
123
  mockFromStatePath.mockReturnValue(mockStateManager);
@@ -196,7 +194,7 @@ describe("analyzeTestHealthTool — UI drift gate logic", () => {
196
194
  it("passes only UI tests to buildUiDriftAnalysisPrompt when mixed test types exist", async () => {
197
195
  const mockStateManager = makeStateManager({
198
196
  existingTests: [
199
- { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
197
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright-ui" },
200
198
  { testFile: "/repo/tests/products_contract.py", source: "skyramp", testType: "contract", framework: "pytest" },
201
199
  { testFile: "/repo/tests/smoke.py", source: "skyramp", testType: "smoke", framework: "pytest" },
202
200
  ],
@@ -212,7 +210,7 @@ describe("analyzeTestHealthTool — UI drift gate logic", () => {
212
210
  it("excludes UI tests from API drift prompt when UI drift is also running", async () => {
213
211
  const mockStateManager = makeStateManager({
214
212
  existingTests: [
215
- { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
213
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright-ui" },
216
214
  { testFile: "/repo/tests/products_contract.py", source: "skyramp", testType: "contract", framework: "pytest" },
217
215
  ],
218
216
  uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
@@ -223,20 +221,21 @@ describe("analyzeTestHealthTool — UI drift gate logic", () => {
223
221
  expect(apiTestsArg).toHaveLength(1);
224
222
  expect(apiTestsArg[0]).toMatchObject({ testFile: "/repo/tests/products_contract.py" });
225
223
  });
226
- it("excludes UI tests from API drift prompt even when no frontend changes", async () => {
224
+ it("includes UI tests in API drift prompt when no frontend changes", async () => {
227
225
  const mockStateManager = makeStateManager({
228
226
  existingTests: [
229
- { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
227
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright-ui" },
230
228
  { testFile: "/repo/tests/products_contract.py", source: "skyramp", testType: "contract", framework: "pytest" },
231
229
  ],
232
230
  });
233
231
  mockFromStatePath.mockReturnValue(mockStateManager);
234
232
  await server.getHandler()({ stateFile: "/tmp/state.json" });
235
- // UI tests are always partitioned out they are never assessed by API drift checks
236
- // (endpoint existence, response shape, auth) regardless of whether frontend changed.
233
+ // No frontend changes no UI drift section UI tests fall through to API drift
234
+ // so they are not silently dropped on API-only PRs.
237
235
  const apiTestsArg = mockBuildDriftAnalysisPrompt.mock.calls[0][1];
238
- expect(apiTestsArg).toHaveLength(1);
239
- expect(apiTestsArg[0]).toMatchObject({ testFile: "/repo/tests/products_contract.py" });
236
+ expect(apiTestsArg).toHaveLength(2);
237
+ expect(apiTestsArg).toContainEqual(expect.objectContaining({ testFile: "/repo/tests/cart.spec.ts" }));
238
+ expect(apiTestsArg).toContainEqual(expect.objectContaining({ testFile: "/repo/tests/products_contract.py" }));
240
239
  });
241
240
  });
242
241
  describe("analyzeTestHealthTool — prompt concatenation", () => {
@@ -249,7 +248,7 @@ describe("analyzeTestHealthTool — prompt concatenation", () => {
249
248
  it("concatenates API and UI drift prompts when both are included", async () => {
250
249
  const mockStateManager = makeStateManager({
251
250
  existingTests: [
252
- { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
251
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright-ui" },
253
252
  { testFile: "/repo/tests/contract.py", source: "skyramp", testType: "contract", framework: "pytest" },
254
253
  ],
255
254
  uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
@@ -267,202 +266,3 @@ describe("analyzeTestHealthTool — prompt concatenation", () => {
267
266
  expect(result.structuredContent?.prompt).not.toContain("UI drift");
268
267
  });
269
268
  });
270
- // ---------------------------------------------------------------------------
271
- // Discovery layer tests — catch regressions in findUiTestsBySymbolGrep
272
- // These tests use real temp files on disk to exercise the actual file-reading
273
- // logic, catching bugs that mock-only tests would miss.
274
- // ---------------------------------------------------------------------------
275
- describe("analyzeTestHealthTool — findUiTestsBySymbolGrep discovery", () => {
276
- let server;
277
- let tmpDir;
278
- beforeEach(() => {
279
- jest.clearAllMocks();
280
- server = makeFakeServer();
281
- registerAnalyzeTestHealthTool(server);
282
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-grep-test-"));
283
- });
284
- afterEach(() => {
285
- fs.rmSync(tmpDir, { recursive: true, force: true });
286
- });
287
- function writeFile(name, content) {
288
- const filePath = path.join(tmpDir, name);
289
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
290
- fs.writeFileSync(filePath, content, "utf-8");
291
- return filePath;
292
- }
293
- function makeState(opts) {
294
- return {
295
- existingTests: opts.existingTests ?? [],
296
- repositoryAnalysis: {
297
- relevantExternalTestPaths: (opts.existingTests ?? []).map(t => t.testFile),
298
- diffFilePath: opts.diffFilePath,
299
- },
300
- uiContext: {
301
- changedFrontendFiles: opts.changedFrontendFiles,
302
- candidateUiPages: [],
303
- },
304
- };
305
- }
306
- async function runHandler(state, repoPath) {
307
- const stateManager = {
308
- getStatePath: jest.fn().mockReturnValue("/tmp/state.json"),
309
- readData: jest.fn().mockResolvedValue(state),
310
- readFullState: jest.fn().mockResolvedValue({ metadata: { repositoryPath: repoPath } }),
311
- readRepoData: jest.fn().mockResolvedValue(state),
312
- getRepoRepositoryPath: jest.fn().mockResolvedValue(repoPath),
313
- };
314
- mockFromStatePath.mockReturnValue(stateManager);
315
- await server.getHandler()({ stateFile: "/tmp/state.json" });
316
- return mockBuildDriftAnalysisPrompt.mock.calls[0]?.[2]?.tests ?? [];
317
- }
318
- it("CC-UC02: finds e2e test via diff signal when testid was renamed", async () => {
319
- // Use relative paths for changedFrontendFiles — matches how diffs are formatted
320
- const relFrontend = "apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx";
321
- writeFile(relFrontend, `data-testid="onboarding-submit-btn"`);
322
- const e2eTest = writeFile("apps/web/playwright/onboarding.e2e.ts", `await page.getByTestId("onboarding-continue-btn").click();`);
323
- const diffFile = writeFile("cc-uc02.diff", [
324
- `diff --git a/${relFrontend} b/${relFrontend}`,
325
- `- data-testid="onboarding-continue-btn"`,
326
- `+ data-testid="onboarding-submit-btn"`,
327
- ].join("\n"));
328
- const state = makeState({
329
- changedFrontendFiles: [relFrontend], // relative — code resolves against repositoryPath
330
- existingTests: [{ testFile: e2eTest, testType: "e2e", framework: "playwright-ui", source: "external" }],
331
- diffFilePath: diffFile,
332
- });
333
- state.repositoryAnalysis.relevantExternalTestPaths = []; // grep fallback fires
334
- const uiTests = await runHandler(state, tmpDir);
335
- expect(uiTests.some(t => t.testFile === e2eTest)).toBe(true);
336
- });
337
- it("does not extract diff strings shorter than 10 chars (min-length filter)", async () => {
338
- const frontendFile = writeFile("button.tsx", ``);
339
- const genericTest = writeFile("Button.test.tsx", `it("renders", () => { expect(true).toBe(true); /* continue finish true */ });`);
340
- const diffFile = writeFile("short.diff", [
341
- `diff --git a/button.tsx b/button.tsx`,
342
- `- label="continue"`,
343
- `+ label="finish"`,
344
- `- value={true}`,
345
- ].join("\n"));
346
- const state = makeState({
347
- changedFrontendFiles: [frontendFile],
348
- existingTests: [{ testFile: genericTest, testType: "unknown", framework: "", source: "external" }],
349
- diffFilePath: diffFile,
350
- });
351
- state.repositoryAnalysis.relevantExternalTestPaths = [];
352
- const uiTests = await runHandler(state, tmpDir);
353
- expect(uiTests.some(t => t.testFile === genericTest)).toBe(false);
354
- });
355
- it("GENERIC_SLUGS blocks generic slugs longer than 6 chars (operator precedence)", async () => {
356
- // "component" is in GENERIC_SLUGS, length=9 — must NOT be added to symbols
357
- const frontendFile = writeFile("component.tsx", ``);
358
- const unrelatedTest = writeFile("Component.test.tsx", `import { component } from './utils'; describe("component", () => {});`);
359
- const state = makeState({
360
- changedFrontendFiles: [frontendFile],
361
- existingTests: [{ testFile: unrelatedTest, testType: "unknown", framework: "rtl", source: "external" }],
362
- });
363
- state.repositoryAnalysis.relevantExternalTestPaths = [];
364
- const uiTests = await runHandler(state, tmpDir);
365
- expect(uiTests.some(t => t.testFile === unrelatedTest)).toBe(false);
366
- });
367
- it("grep fires when uiTests.length === SYMBOL_GREP_MAX_RESULTS (boundary >= not >)", async () => {
368
- const relFrontend = "apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx";
369
- writeFile(relFrontend, `export const PersonalCalendarView = () => {};`);
370
- const targetTest = writeFile("apps/web/playwright/onboarding.e2e.ts", `await page.getByTestId("onboarding-continue-btn").click();`);
371
- const diffFile = writeFile("rename.diff", [
372
- `diff --git a/${relFrontend} b/${relFrontend}`,
373
- `- data-testid="onboarding-continue-btn"`,
374
- `+ data-testid="onboarding-submit-btn"`,
375
- ].join("\n"));
376
- // Exactly 20 wrong tests in relevantExternalTestPaths
377
- const wrongTests = Array.from({ length: 20 }, (_, i) => {
378
- const f = writeFile(`packages/ui/Test${i}.test.tsx`, `describe("Test${i}", () => {});`);
379
- return { testFile: f, testType: "unknown", framework: "rtl", source: "external" };
380
- });
381
- const state = makeState({
382
- changedFrontendFiles: [relFrontend],
383
- existingTests: [
384
- ...wrongTests,
385
- // targetTest is in existingTests (discovered) but NOT in relevantExternalTestPaths
386
- // (it's a .e2e.ts file not yet in the promoted set) — the >= branch must find it
387
- { testFile: targetTest, testType: "e2e", framework: "playwright-ui", source: "external" },
388
- ],
389
- diffFilePath: diffFile,
390
- });
391
- // 20 wrong tests in relevantExternalTestPaths — targetTest deliberately excluded
392
- state.repositoryAnalysis.relevantExternalTestPaths = wrongTests.map(t => t.testFile);
393
- const uiTests = await runHandler(state, tmpDir);
394
- // Grep must have fired (>= condition) and ranked targetTest to top
395
- expect(uiTests.some(t => t.testFile === targetTest)).toBe(true);
396
- expect(uiTests.length).toBeLessThanOrEqual(20);
397
- });
398
- it("exact path match prevents test diff sections from contaminating symbols", async () => {
399
- const relFrontend = "apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx";
400
- writeFile(relFrontend, `export const PersonalCalendarView = () => {};`);
401
- const targetTest = writeFile("apps/web/playwright/onboarding.e2e.ts", `await page.getByTestId("onboarding-continue-btn").click();`);
402
- const falsePositiveTest = writeFile("apps/web/playwright/false-positive.e2e.ts", `await page.getByTestId("injected-from-test-file").click();`);
403
- // Diff has TWO sections: the test file first (substring match trap), then the frontend file
404
- const diffFile = writeFile("both-sections.diff", [
405
- `diff --git a/personal-calendar-view.test.tsx b/personal-calendar-view.test.tsx`,
406
- `+ expect(screen.getByTestId("injected-from-test-file")).toBeTruthy();`,
407
- `diff --git a/${relFrontend} b/${relFrontend}`,
408
- `- data-testid="onboarding-continue-btn"`,
409
- ].join("\n"));
410
- const state = makeState({
411
- changedFrontendFiles: [relFrontend],
412
- existingTests: [
413
- { testFile: targetTest, testType: "e2e", framework: "playwright-ui", source: "external" },
414
- { testFile: falsePositiveTest, testType: "e2e", framework: "playwright-ui", source: "external" },
415
- ],
416
- diffFilePath: diffFile,
417
- });
418
- state.repositoryAnalysis.relevantExternalTestPaths = [];
419
- const uiTests = await runHandler(state, tmpDir);
420
- expect(uiTests.some(t => t.testFile === targetTest)).toBe(true);
421
- expect(uiTests.some(t => t.testFile === falsePositiveTest)).toBe(false);
422
- });
423
- // CC-UC04: bookings-results testid (>=10 chars) in diff → booking-pages.e2e.ts found
424
- it("CC-UC04: finds e2e test when new data-testid added to diff + lines", async () => {
425
- const relFrontend = "apps/web/modules/bookings/components/BookingList.tsx";
426
- writeFile(relFrontend, `export function BookingList() { return <div data-testid="bookings-results" />; }`);
427
- const bookingPagesTest = writeFile("apps/web/playwright/booking-pages.e2e.ts", `await page.getByTestId("bookings-results").waitFor({ state: "visible" });`);
428
- const diffFile = writeFile("cc-uc04.diff", [
429
- `diff --git a/${relFrontend} b/${relFrontend}`,
430
- `+ <div data-testid="bookings-results" data-state={dataState}>`,
431
- ].join("\n"));
432
- const state = makeState({
433
- changedFrontendFiles: [relFrontend],
434
- existingTests: [{ testFile: bookingPagesTest, testType: "e2e", framework: "playwright-ui", source: "external" }],
435
- diffFilePath: diffFile,
436
- });
437
- state.repositoryAnalysis.relevantExternalTestPaths = [];
438
- const uiTests = await runHandler(state, tmpDir);
439
- expect(uiTests.some(t => t.testFile === bookingPagesTest)).toBe(true);
440
- });
441
- // CC-UC03 known gap: booking-sheet-keyboard.e2e.ts NOT found for additive PR
442
- // The PR adds aria-selected and booking-row-cell (new + lines, no - lines with old values).
443
- // The test uses [role="dialog"] and booking-sheet-title — no overlap with diff signals.
444
- // This documents the known limitation: additive PRs with no renamed selectors
445
- // cannot be discovered via diff-signal extraction alone.
446
- it("CC-UC03 known gap: additive-only diff (no removed selectors) does not find e2e test", async () => {
447
- const relFrontend = "apps/web/components/booking/BookingListItem.tsx";
448
- writeFile(relFrontend, `export function BookingListItem() { return <div aria-selected="false" />; }`);
449
- const keyboardTest = writeFile("apps/web/playwright/booking-sheet-keyboard.e2e.ts", `const sheet = page.locator('[role="dialog"]');\nawait expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1");`);
450
- // Only + lines (additive) — no - lines with old values to match
451
- const diffFile = writeFile("cc-uc03.diff", [
452
- `diff --git a/${relFrontend} b/${relFrontend}`,
453
- `+ aria-selected={isSelected ? "true" : "false"}`,
454
- `+ data-testid="booking-row-cell"`,
455
- ].join("\n"));
456
- const state = makeState({
457
- changedFrontendFiles: [relFrontend],
458
- existingTests: [{ testFile: keyboardTest, testType: "e2e", framework: "playwright-ui", source: "external" }],
459
- diffFilePath: diffFile,
460
- });
461
- state.repositoryAnalysis.relevantExternalTestPaths = [];
462
- const uiTests = await runHandler(state, tmpDir);
463
- // Known gap: additive-only PRs cannot surface tests via diff signals.
464
- // The test content ([role="dialog"], booking-sheet-title) has no overlap with
465
- // the added attributes (aria-selected, booking-row-cell).
466
- expect(uiTests.some(t => t.testFile === keyboardTest)).toBe(false);
467
- });
468
- });