@skyramp/mcp 0.2.8 → 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 (88) hide show
  1. package/build/index.js +1 -8
  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/adaptWorkflowPrompt.js +3 -1
  7. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
  8. package/build/prompts/sut-setup/shared.d.ts +20 -0
  9. package/build/prompts/sut-setup/shared.js +67 -7
  10. package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
  11. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
  12. package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
  13. package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
  14. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
  15. package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
  16. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
  17. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
  18. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  19. package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
  20. package/build/prompts/testbot/testbot-prompts.js +10 -9
  21. package/build/resources/testbotResource.js +1 -1
  22. package/build/services/TestDiscoveryService.d.ts +12 -10
  23. package/build/services/TestDiscoveryService.js +137 -83
  24. package/build/services/TestDiscoveryService.test.js +370 -15
  25. package/build/services/TestExecutionService.d.ts +1 -1
  26. package/build/services/TestExecutionService.js +7 -7
  27. package/build/services/TestExecutionService.test.js +4 -1
  28. package/build/tools/executeSkyrampTestTool.js +40 -71
  29. package/build/tools/submitReportTool.d.ts +25 -25
  30. package/build/tools/submitReportTool.js +123 -34
  31. package/build/tools/submitReportTool.test.js +413 -94
  32. package/build/tools/test-management/actionsTool.js +177 -77
  33. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  34. package/build/tools/test-management/actionsTool.test.js +297 -0
  35. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  36. package/build/tools/test-management/analyzeChangesTool.js +74 -169
  37. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  38. package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
  39. package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
  40. package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
  41. package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
  42. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  43. package/build/types/FrontendIntegration.d.ts +25 -0
  44. package/build/types/FrontendIntegration.js +19 -0
  45. package/build/types/RepositoryAnalysis.d.ts +8 -8
  46. package/build/types/TestAnalysis.d.ts +18 -49
  47. package/build/types/TestAnalysis.js +0 -28
  48. package/build/types/TestTypes.d.ts +3 -1
  49. package/build/types/TestTypes.js +3 -1
  50. package/build/types/TestbotReport.d.ts +1 -1
  51. package/build/utils/AnalysisStateManager.d.ts +16 -22
  52. package/build/utils/docker.test.js +1 -1
  53. package/build/utils/featureFlags.d.ts +1 -1
  54. package/build/utils/featureFlags.js +1 -1
  55. package/build/utils/frontendIntegration.d.ts +9 -0
  56. package/build/utils/frontendIntegration.js +237 -0
  57. package/build/utils/frontendIntegration.test.d.ts +1 -0
  58. package/build/utils/frontendIntegration.test.js +229 -0
  59. package/build/utils/pr-comment-parser.d.ts +3 -3
  60. package/build/utils/pr-comment-parser.js +6 -6
  61. package/build/utils/pr-comment-parser.test.js +3 -3
  62. package/build/utils/repoScanner.d.ts +7 -0
  63. package/build/utils/repoScanner.js +14 -6
  64. package/build/utils/repoScanner.test.js +9 -9
  65. package/build/utils/routeParsers.d.ts +32 -0
  66. package/build/utils/routeParsers.js +205 -2
  67. package/build/utils/routeParsers.test.js +279 -29
  68. package/build/utils/sourceRouteExtractor.js +174 -5
  69. package/build/utils/sourceRouteExtractor.test.js +173 -0
  70. package/build/utils/utils.d.ts +11 -0
  71. package/build/utils/utils.js +19 -0
  72. package/build/utils/utils.test.js +23 -1
  73. package/build/utils/versions.d.ts +3 -3
  74. package/build/utils/versions.js +1 -1
  75. package/build/workspace/workspace.d.ts +21 -21
  76. package/build/workspace/workspace.js +7 -4
  77. package/build/workspace/workspace.test.js +65 -6
  78. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  79. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  80. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  81. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  82. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  83. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  84. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  85. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  86. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  87. package/node_modules/playwright/package.json +1 -1
  88. package/package.json +3 -3
@@ -4,34 +4,6 @@ export var RecommendationPriority;
4
4
  RecommendationPriority["Medium"] = "medium";
5
5
  RecommendationPriority["Low"] = "low";
6
6
  })(RecommendationPriority || (RecommendationPriority = {}));
7
- export var IssueSeverity;
8
- (function (IssueSeverity) {
9
- IssueSeverity["Low"] = "low";
10
- IssueSeverity["Medium"] = "medium";
11
- IssueSeverity["High"] = "high";
12
- IssueSeverity["Critical"] = "critical";
13
- })(IssueSeverity || (IssueSeverity = {}));
14
- export var DriftChangeType;
15
- (function (DriftChangeType) {
16
- DriftChangeType["EndpointAdded"] = "endpoint_added";
17
- DriftChangeType["EndpointRemoved"] = "endpoint_removed";
18
- DriftChangeType["EndpointRenamed"] = "endpoint_renamed";
19
- DriftChangeType["EndpointModified"] = "endpoint_modified";
20
- DriftChangeType["AuthenticationChanged"] = "authentication_changed";
21
- DriftChangeType["SchemaChanges"] = "schema_changes";
22
- DriftChangeType["RouteChanged"] = "route_changed";
23
- DriftChangeType["RouteAdded"] = "route_added";
24
- DriftChangeType["RouteRemoved"] = "route_removed";
25
- DriftChangeType["UiComponentAdded"] = "ui_component_added";
26
- DriftChangeType["UiComponentRemoved"] = "ui_component_removed";
27
- DriftChangeType["UiComponentModified"] = "ui_component_modified";
28
- DriftChangeType["UiComponentRestructured"] = "ui_component_restructured";
29
- DriftChangeType["DependencyChanged"] = "dependency_changed";
30
- DriftChangeType["FunctionChanged"] = "function_changed";
31
- DriftChangeType["ClassChanged"] = "class_changed";
32
- DriftChangeType["BreakingChange"] = "breaking_change";
33
- DriftChangeType["CodeChange"] = "code_change";
34
- })(DriftChangeType || (DriftChangeType = {}));
35
7
  /** Origin of a test file — whether it was generated by Skyramp or is user/third-party maintained. */
36
8
  export var TestSource;
37
9
  (function (TestSource) {
@@ -15,7 +15,9 @@ export declare enum TestType {
15
15
  INTEGRATION = "integration",
16
16
  E2E = "e2e",
17
17
  UI = "ui",
18
- MOCK = "mock"
18
+ MOCK = "mock",
19
+ UNIT = "unit",
20
+ UNKNOWN = "unknown"
19
21
  }
20
22
  export declare enum HttpMethod {
21
23
  GET = "GET",
@@ -18,6 +18,8 @@ export var TestType;
18
18
  TestType["E2E"] = "e2e";
19
19
  TestType["UI"] = "ui";
20
20
  TestType["MOCK"] = "mock";
21
+ TestType["UNIT"] = "unit";
22
+ TestType["UNKNOWN"] = "unknown";
21
23
  })(TestType || (TestType = {}));
22
24
  export var HttpMethod;
23
25
  (function (HttpMethod) {
@@ -80,7 +82,7 @@ export const baseSchema = z.object({
80
82
  "Common values: 'Authorization' (Bearer/Basic/Token auth), 'X-Api-Key', 'X-Auth-Token', 'Cookie'. " +
81
83
  "Other accepted values: 'X-Access-Token', 'X-OAuth-Token', 'X-Client-ID', 'X-Client-Secret', 'X-Client-Token', " +
82
84
  "'X-GitHub-Token', 'X-Firebase-AppCheck', 'Proxy-Authorization', 'Fastly-Key', 'Heroku-Bearer'. " +
83
- "Pass empty string to skip auth entirely for unauthenticated endpoints. " +
85
+ "Pass empty string to document an unauthenticated endpoint in the generated test's assertions. " +
84
86
  "This is the header KEY name (e.g., 'X-Api-Key'), NOT the token value."),
85
87
  authScheme: z
86
88
  .string()
@@ -14,7 +14,7 @@ export interface TestbotReport {
14
14
  endpoint: string;
15
15
  fileName: string;
16
16
  reasoning: string;
17
- description?: string;
17
+ description: string;
18
18
  scenarioFile?: string;
19
19
  traceFile?: string;
20
20
  frontendTrace?: string;
@@ -1,8 +1,9 @@
1
- import { TestAnalysisResult } from "../types/TestAnalysis.js";
1
+ import { TestAnalysisResult, MaintenanceActionCore } from "../types/TestAnalysis.js";
2
2
  import { RepositoryAnalysis, AnalysisScope } from "../types/RepositoryAnalysis.js";
3
3
  import { PRTestContext } from "./pr-comment-parser.js";
4
4
  import { ParsedDiffEndpoint } from "./routeParsers.js";
5
5
  import type { CandidateUiPage } from "./uiPageEnumerator.js";
6
+ import type { FrontendFileIntegration } from "../types/FrontendIntegration.js";
6
7
  export type { CandidateUiPage } from "./uiPageEnumerator.js";
7
8
  export declare function setTestsRepoDir(dir: string | undefined): void;
8
9
  export declare function getTestsRepoDir(): string | undefined;
@@ -49,32 +50,20 @@ type TestTypeScores = Record<string, number>;
49
50
  * Map of test type → grouping/label information used by downstream tools.
50
51
  */
51
52
  type TestTypeMapping = Record<string, string | string[]>;
52
- /**
53
- * UI-specific analysis context populated when the diff contains frontend files.
54
- *
55
- * Computed deterministically by `skyramp_analyze_changes` (via `isFrontendFile`
56
- * in scopeAssessment.ts). Persisted so downstream consumers —
57
- * `skyramp_analyze_test_health`'s UI drift detection, recommendation prompt
58
- * rendering, etc. — don't have to re-derive the classification.
59
- *
60
- * Absent on backend-only PRs (no frontend files in the diff).
61
- */
62
53
  export interface UiAnalysisContext {
63
- /**
64
- * Subset of `repositoryAnalysis.diff.changedFiles` that classified as
65
- * frontend by `isFrontendFile()`. Empty when no frontend files changed —
66
- * callers can derive `hasFrontendChanges` from `changedFrontendFiles.length > 0`.
67
- */
54
+ /** Frontend source files changed in this diff. */
68
55
  changedFrontendFiles: string[];
69
56
  /**
70
- * Candidate pages the testbot agent should capture blueprints for, enumerated
71
- * by the strategy ladder in `uiPageEnumerator.ts` (framework route grep,
72
- * source-grounded routes, root fallback). Each entry carries the candidate
73
- * URL plus the strategy + source files that surfaced it. May be empty when
74
- * the workspace has no resolvable frontend baseUrl — caller should fall back
75
- * to the agent's prose-driven enumeration.
57
+ * Candidate pages for blueprint capture, enumerated from route analysis.
58
+ * Empty when no resolvable frontend baseUrl agent falls back to prose-driven enumeration.
76
59
  */
77
60
  candidateUiPages: CandidateUiPage[];
61
+ /**
62
+ * Per-file production-importer check (SKYR-3855) — server-computed so the
63
+ * "is this component actually integrated?" skip decision doesn't rely on
64
+ * the agent running its own grep mid-task. Same order as changedFrontendFiles.
65
+ */
66
+ frontendFileIntegration?: FrontendFileIntegration[];
78
67
  }
79
68
  /**
80
69
  * Unified state data combining test discovery + endpoint scanning
@@ -92,6 +81,11 @@ export interface UnifiedAnalysisState {
92
81
  * Absent on backend-only PRs.
93
82
  */
94
83
  uiContext?: UiAnalysisContext;
84
+ /** Existing-test fileNames skyramp_actions assessed and decided need no action. */
85
+ ignoredTestFiles?: string[];
86
+ /** Maintenance actions applied via skyramp_actions — skyramp_submit_report derives
87
+ * testMaintenance from this. */
88
+ maintenanceVerdicts?: MaintenanceActionCore[];
95
89
  }
96
90
  /**
97
91
  * State file metadata
@@ -55,7 +55,7 @@ describe("dockerImageExistsLocally", () => {
55
55
  });
56
56
  });
57
57
  describe("pullDockerImage", () => {
58
- const IMAGE = "skyramp/executor:v1.3.29";
58
+ const IMAGE = "skyramp/executor:v1.3.32";
59
59
  beforeEach(() => jest.clearAllMocks());
60
60
  describe("on amd64 host", () => {
61
61
  const originalArch = process.arch;
@@ -31,7 +31,7 @@
31
31
  */
32
32
  export declare function isContractConsumerModeEnabled(): boolean;
33
33
  /**
34
- * Returns true when running inside a TestBot environment
34
+ * Returns true when running inside a Testbot environment
35
35
  * (SKYRAMP_FEATURE_TESTBOT=1).
36
36
  */
37
37
  export declare function isTestbotEnabled(): boolean;
@@ -33,7 +33,7 @@ export function isContractConsumerModeEnabled() {
33
33
  return process.env.SKYRAMP_FEATURE_CONTRACT_CONSUMER_MODE === "1";
34
34
  }
35
35
  /**
36
- * Returns true when running inside a TestBot environment
36
+ * Returns true when running inside a Testbot environment
37
37
  * (SKYRAMP_FEATURE_TESTBOT=1).
38
38
  */
39
39
  export function isTestbotEnabled() {
@@ -0,0 +1,9 @@
1
+ import { FrontendFileIntegration } from "../types/FrontendIntegration.js";
2
+ export type { FrontendFileIntegration } from "../types/FrontendIntegration.js";
3
+ /**
4
+ * Checks each changed frontend file for a production importer — the
5
+ * deterministic version of the grep the testbot prompt used to ask the
6
+ * agent to run mid-task. See module doc for the signature-based approach
7
+ * and the fail-open rationale.
8
+ */
9
+ export declare function checkFrontendFileIntegration(repositoryPath: string, changedFrontendFiles: string[]): FrontendFileIntegration[];
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Server-side "frontend component integration check" — SKYR-3855.
3
+ *
4
+ * Deterministically answers the question the testbot prompt previously asked
5
+ * the agent to answer via a mid-run grep: "is this changed frontend file
6
+ * actually rendered anywhere in the running app, or is it dead/orphaned
7
+ * code?" Computed here so the skip decision for UI test generation consumes
8
+ * a server fact instead of an LLM judgment call made under budget pressure.
9
+ *
10
+ * Detection greps for the module's PATH-REFERENCE SIGNATURE — the basename
11
+ * preceded by a slash and followed by a quote (`/Foo'`, `/Foo"`, `/Foo\``) —
12
+ * rather than the bare component name. Imports always reference the module
13
+ * path in quotes (`from './Foo'`, `require("components/Foo")`,
14
+ * `import('./Foo')`), so the signature carries import-context precision
15
+ * lexically: no per-line verification, no file reads in Node, and `git grep
16
+ * -l -F` returns only matching paths (bounded output regardless of file
17
+ * sizes). Named imports that go through a barrel resolve one hop: the
18
+ * barrel's `export { Foo } from './Foo'` contains the signature, and the
19
+ * barrel counts as the importer. Known miss: extensionless root-level
20
+ * imports with no slash (`from 'Foo'`) — root files are route/entrypoint
21
+ * short-circuited in practice.
22
+ *
23
+ * Fail-open by design: any scan failure reports `integrated: true` so a bug
24
+ * in this heuristic can never wrongly suppress UI test generation.
25
+ */
26
+ import { execFileSync } from "child_process";
27
+ import * as fs from "fs";
28
+ import * as path from "path";
29
+ import { isTestFile } from "../prompts/test-recommendation/scopeAssessment.js";
30
+ import { logger } from "./logger.js";
31
+ import { IntegrationReason } from "../types/FrontendIntegration.js";
32
+ const MAX_IMPORTERS = 10;
33
+ const MAX_FILES_WALKED = 3000;
34
+ const MAX_FILE_SIZE_BYTES = 512 * 1024;
35
+ // Framework route/page/entrypoint conventions — mirrors the testbot prompt's
36
+ // "Never apply the unintegrated heuristic to framework route/entrypoint files"
37
+ // rule. These are reachable by convention, so the grep is skipped entirely.
38
+ // `app` is deliberately NOT in the dir pattern: it is a common generic
39
+ // monorepo directory (e.g. appsmith's top-level app/), and matching it made
40
+ // the check inert for every file in such repos (eval run 28618761653).
41
+ // Next.js App Router route files are still short-circuited via their
42
+ // basenames (page.*, layout.*); non-route components under app/ resolve
43
+ // through the importer grep like any other file.
44
+ const ROUTE_DIR_PATTERN = /(^|\/)(pages|routes)(\/|$)/i;
45
+ // Generic entrypoint basenames (index/main/app/_app) are only reachable-by-
46
+ // convention at the repo root or directly under `src/` — e.g. Vite's
47
+ // `src/main.tsx`, CRA's `src/index.tsx`, Pages Router's root `_app.tsx`.
48
+ // Deeper in the tree these are ordinary component filenames (folder-per-
49
+ // component layouts commonly name the component file `index.tsx`), so
50
+ // matching them anywhere would short-circuit real, checkable components —
51
+ // the same class of bug as the `app/` dir match (see module doc).
52
+ const ROOT_ENTRYPOINT_BASENAME_PATTERN = /^(src\/)?(index|main|app|_app)\.[^/]+$/i;
53
+ // `page`/`layout` are App Router-specific and only meaningful under an
54
+ // `app/` segment; outside of one, a file named `page.tsx` or `layout.tsx`
55
+ // has no special framework meaning and should go through the normal check.
56
+ const APP_ROUTER_BASENAME_PATTERN = /(^|\/)app\/(.*\/)?(page|layout)\.[^/]+$/i;
57
+ // Frontend-importer file types only. Deliberately NOT shared with
58
+ // analyzeChangesTool's SOURCE_EXTS (backend-inclusive: py|java|go|…) or
59
+ // repoScanner's ROUTE_FILE_PATTERN — those detect backend route handlers;
60
+ // this detects files that can render a frontend component. The domains must
61
+ // stay independently tunable (see the `app/` route-dir lesson above).
62
+ const FRONTEND_SOURCE_EXT_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte)$/i;
63
+ const STORY_FILE_PATTERN = /\.stories\.[^/]+$/i;
64
+ const EXCLUDED_DIR_PATTERN = /(^|\/)(node_modules|dist|build|\.git)(\/|$)/;
65
+ function isRouteOrEntrypoint(file) {
66
+ return (ROUTE_DIR_PATTERN.test(file) ||
67
+ ROOT_ENTRYPOINT_BASENAME_PATTERN.test(file) ||
68
+ APP_ROUTER_BASENAME_PATTERN.test(file));
69
+ }
70
+ /**
71
+ * Fixed-string signatures that identify an import of `file` by module path.
72
+ * Extensionless (`/Foo'`) covers TS/JS convention; extension-included
73
+ * (`/Foo.vue'`) covers Vue/Svelte, where imports keep the extension.
74
+ */
75
+ function buildPathSignatures(file) {
76
+ // path.extname/basename treat ".d.ts" as ext ".ts" + basename "Foo.d",
77
+ // which would build a signature ("/Foo.d'") that never matches a real
78
+ // import of "./Foo" — special-case the compound extension.
79
+ const isDts = /\.d\.ts$/i.test(file);
80
+ const ext = isDts ? ".d.ts" : path.extname(file);
81
+ const basename = isDts ? path.basename(file, ".d.ts") : path.basename(file, ext);
82
+ const quotes = [`'`, `"`, "`"];
83
+ const stems = [`/${basename}`, `/${basename}${ext}`];
84
+ return stems.flatMap((stem) => quotes.map((q) => `${stem}${q}`));
85
+ }
86
+ function isExcludedImporterPath(relPath) {
87
+ return (EXCLUDED_DIR_PATTERN.test(relPath) ||
88
+ isTestFile(relPath) ||
89
+ STORY_FILE_PATTERN.test(relPath) ||
90
+ !FRONTEND_SOURCE_EXT_PATTERN.test(relPath));
91
+ }
92
+ /**
93
+ * Runs `git grep -l -F` for the given fixed-string signatures (OR'd
94
+ * together) scoped to the repository's tracked files. Returns file paths
95
+ * relative to `repositoryPath` — only paths, never matched lines, so output
96
+ * stays bounded no matter how large the matching files are. An exit code of
97
+ * 1 means "no matches" — a normal outcome, not an error. Throws on real
98
+ * failures (not a git repo, git not installed) so the caller can fall back
99
+ * to a manual walk.
100
+ */
101
+ function gitGrepFiles(repositoryPath, signatures) {
102
+ const args = ["grep", "-l", "-F", "-I"];
103
+ for (const signature of signatures)
104
+ args.push("-e", signature);
105
+ try {
106
+ const stdout = execFileSync("git", args, {
107
+ cwd: repositoryPath,
108
+ encoding: "utf8",
109
+ maxBuffer: 20 * 1024 * 1024,
110
+ });
111
+ return stdout.split("\n").filter(Boolean);
112
+ }
113
+ catch (err) {
114
+ if (err && err.status === 1)
115
+ return []; // no matches — not an error
116
+ throw err;
117
+ }
118
+ }
119
+ /**
120
+ * Bounded manual directory walk used only when `git grep` itself fails
121
+ * (e.g. not a git repository). Caps the number and size of files scanned so
122
+ * a huge repo can't turn a fallback path into a multi-minute scan or a
123
+ * large allocation.
124
+ *
125
+ * If the walk is cut off by `MAX_FILES_WALKED` before finding any importer,
126
+ * the "no importer" result can't be trusted — an importer may exist in the
127
+ * unscanned remainder. Per the module's fail-open contract, that ambiguity
128
+ * throws so the caller reports `scan-error` (`integrated: true`) instead of
129
+ * a false "no-importers".
130
+ */
131
+ function manualWalkGrep(repositoryPath, signatures) {
132
+ const results = [];
133
+ let filesScanned = 0;
134
+ let truncated = false;
135
+ const stack = [repositoryPath];
136
+ walk: while (stack.length > 0) {
137
+ if (filesScanned >= MAX_FILES_WALKED) {
138
+ truncated = true;
139
+ break;
140
+ }
141
+ const dir = stack.pop();
142
+ let entries;
143
+ try {
144
+ entries = fs.readdirSync(dir, { withFileTypes: true });
145
+ }
146
+ catch {
147
+ continue;
148
+ }
149
+ for (const entry of entries) {
150
+ const abs = path.join(dir, entry.name);
151
+ // Normalize to forward slashes: on Windows, path.relative() returns
152
+ // "\\"-separated paths, but EXCLUDED_DIR_PATTERN/isTestFile assume "/".
153
+ const rel = path.relative(repositoryPath, abs).replace(/\\/g, "/");
154
+ if (entry.isDirectory()) {
155
+ if (EXCLUDED_DIR_PATTERN.test(rel + "/"))
156
+ continue;
157
+ stack.push(abs);
158
+ continue;
159
+ }
160
+ if (!FRONTEND_SOURCE_EXT_PATTERN.test(entry.name))
161
+ continue;
162
+ if (filesScanned >= MAX_FILES_WALKED) {
163
+ truncated = true;
164
+ break walk;
165
+ }
166
+ filesScanned++;
167
+ try {
168
+ if (fs.statSync(abs).size > MAX_FILE_SIZE_BYTES)
169
+ continue;
170
+ const content = fs.readFileSync(abs, "utf-8");
171
+ if (signatures.some((s) => content.includes(s)))
172
+ results.push(rel);
173
+ }
174
+ catch {
175
+ continue;
176
+ }
177
+ }
178
+ }
179
+ if (truncated && results.length === 0) {
180
+ throw new Error(`manual-walk fallback truncated at ${MAX_FILES_WALKED} files with no importer found`);
181
+ }
182
+ return results;
183
+ }
184
+ function searchProductionImporters(repositoryPath, signatures) {
185
+ try {
186
+ return gitGrepFiles(repositoryPath, signatures);
187
+ }
188
+ catch (err) {
189
+ logger.warning("git grep failed for integration check — falling back to bounded manual walk", {
190
+ repositoryPath,
191
+ error: err instanceof Error ? err.message : String(err),
192
+ });
193
+ return manualWalkGrep(repositoryPath, signatures);
194
+ }
195
+ }
196
+ function checkOneFile(repositoryPath, file) {
197
+ if (isRouteOrEntrypoint(file)) {
198
+ return { file, integrated: true, importers: [], reason: IntegrationReason.RouteOrEntrypoint };
199
+ }
200
+ // A missing changed file (e.g. deleted, or an unexpected diff shape) means
201
+ // the classification can't be trusted — fail open rather than risk wrongly
202
+ // suppressing UI test generation.
203
+ if (!fs.existsSync(path.join(repositoryPath, file))) {
204
+ logger.warning("Integration check: changed file not found on disk — reporting integrated (fail-open)", {
205
+ file,
206
+ });
207
+ return { file, integrated: true, importers: [], reason: IntegrationReason.ScanError };
208
+ }
209
+ try {
210
+ const signatures = buildPathSignatures(file);
211
+ const importers = searchProductionImporters(repositoryPath, signatures)
212
+ .filter((candidate) => candidate !== file && !isExcludedImporterPath(candidate))
213
+ .slice(0, MAX_IMPORTERS);
214
+ return {
215
+ file,
216
+ integrated: importers.length > 0,
217
+ importers,
218
+ reason: importers.length > 0 ? IntegrationReason.Imported : IntegrationReason.NoImporters,
219
+ };
220
+ }
221
+ catch (err) {
222
+ logger.warning("Integration check scan failed — reporting integrated (fail-open)", {
223
+ file,
224
+ error: err instanceof Error ? err.message : String(err),
225
+ });
226
+ return { file, integrated: true, importers: [], reason: IntegrationReason.ScanError };
227
+ }
228
+ }
229
+ /**
230
+ * Checks each changed frontend file for a production importer — the
231
+ * deterministic version of the grep the testbot prompt used to ask the
232
+ * agent to run mid-task. See module doc for the signature-based approach
233
+ * and the fail-open rationale.
234
+ */
235
+ export function checkFrontendFileIntegration(repositoryPath, changedFrontendFiles) {
236
+ return changedFrontendFiles.map((file) => checkOneFile(repositoryPath, file));
237
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,229 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { execFileSync } from "child_process";
5
+ import { checkFrontendFileIntegration } from "./frontendIntegration.js";
6
+ /** Creates a temp git repo, writes the given files, and stages them so
7
+ * `git grep` (which only searches tracked files by default) can find them. */
8
+ function makeFixtureRepo(files) {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frontend-integration-"));
10
+ execFileSync("git", ["init", "-q"], { cwd: dir });
11
+ for (const [relPath, content] of Object.entries(files)) {
12
+ const abs = path.join(dir, relPath);
13
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
14
+ fs.writeFileSync(abs, content);
15
+ }
16
+ execFileSync("git", ["add", "-A"], { cwd: dir });
17
+ return dir;
18
+ }
19
+ /** Same as makeFixtureRepo but NOT a git repo, to exercise the manual-walk
20
+ * fallback (`git grep` fails outright when the directory isn't a repo). */
21
+ function makeNonGitFixture(files) {
22
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frontend-integration-nogit-"));
23
+ for (const [relPath, content] of Object.entries(files)) {
24
+ const abs = path.join(dir, relPath);
25
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
26
+ fs.writeFileSync(abs, content);
27
+ }
28
+ return dir;
29
+ }
30
+ describe("checkFrontendFileIntegration", () => {
31
+ it("marks a component imported by a production file as integrated, listing the importer", () => {
32
+ const repo = makeFixtureRepo({
33
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
34
+ "src/components/Bar.tsx": `import { Foo } from './Foo';\nexport function Bar() { return Foo(); }\n`,
35
+ });
36
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
37
+ expect(result.integrated).toBe(true);
38
+ expect(result.reason).toBe("imported");
39
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
40
+ });
41
+ it("marks a component imported ONLY by test files as unintegrated", () => {
42
+ const repo = makeFixtureRepo({
43
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
44
+ "src/components/Foo.test.tsx": `import { Foo } from './Foo';\ntest('renders', () => Foo());\n`,
45
+ "src/components/__tests__/Foo.spec.tsx": `import { Foo } from './Foo';\n`,
46
+ });
47
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
48
+ expect(result.integrated).toBe(false);
49
+ expect(result.reason).toBe("no-importers");
50
+ expect(result.importers).toEqual([]);
51
+ });
52
+ it("short-circuits route/entrypoint files as integrated without grepping", () => {
53
+ const repo = makeFixtureRepo({
54
+ "src/pages/Dashboard.tsx": `export default function Dashboard() { return null; }\n`,
55
+ });
56
+ const [result] = checkFrontendFileIntegration(repo, ["src/pages/Dashboard.tsx"]);
57
+ expect(result).toEqual({
58
+ file: "src/pages/Dashboard.tsx",
59
+ integrated: true,
60
+ importers: [],
61
+ reason: "route-or-entrypoint",
62
+ });
63
+ });
64
+ it("counts a re-export barrel as an importer", () => {
65
+ const repo = makeFixtureRepo({
66
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
67
+ "src/components/index.ts": `export { Foo } from './Foo';\n`,
68
+ });
69
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
70
+ expect(result.integrated).toBe(true);
71
+ expect(result.importers).toEqual(["src/components/index.ts"]);
72
+ });
73
+ it("counts an oversized single-line bundle referencing the module path as an importer, unread", () => {
74
+ // A minified >512KB one-line file. Node never opens candidates — git
75
+ // grep -l only returns the path — so file size cannot cause a large
76
+ // allocation or blow the subprocess buffer.
77
+ const hugeBundle = `var m=require("./components/Foo");` + "a".repeat(600 * 1024);
78
+ const repo = makeFixtureRepo({
79
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
80
+ "src/vendor/bundle.js": hugeBundle,
81
+ });
82
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
83
+ expect(result.integrated).toBe(true);
84
+ expect(result.importers).toEqual(["src/vendor/bundle.js"]);
85
+ });
86
+ it("resolves importers of an oversized changed file (the changed file is never read)", () => {
87
+ const hugeComponent = `export function Foo() { return null; }\n// ` + "b".repeat(600 * 1024) + `\n`;
88
+ const repo = makeFixtureRepo({
89
+ "src/components/Foo.tsx": hugeComponent,
90
+ "src/components/Bar.tsx": `import Foo from './Foo';\nexport function Bar() { return Foo(); }\n`,
91
+ });
92
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
93
+ expect(result.integrated).toBe(true);
94
+ expect(result.reason).toBe("imported");
95
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
96
+ });
97
+ it("catches multiline imports (the from-line carries the path signature)", () => {
98
+ const repo = makeFixtureRepo({
99
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
100
+ "src/components/Bar.tsx": `import {\n Foo,\n} from "./Foo";\nexport function Bar() { return Foo(); }\n`,
101
+ });
102
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
103
+ expect(result.integrated).toBe(true);
104
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
105
+ });
106
+ it("does not count bare-name mentions (comments, strings) as importers", () => {
107
+ // The precision property of the path-signature design: a dead component
108
+ // whose NAME appears in prose elsewhere must still read as unintegrated.
109
+ const repo = makeFixtureRepo({
110
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
111
+ "src/components/Other.tsx": `// TODO: consolidate with Foo someday\nconst label = "Foo";\nexport function Other() { return label; }\n`,
112
+ });
113
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
114
+ expect(result.integrated).toBe(false);
115
+ expect(result.reason).toBe("no-importers");
116
+ expect(result.importers).toEqual([]);
117
+ });
118
+ it("does not short-circuit components under a generic top-level app/ monorepo dir", () => {
119
+ // Regression: appsmith's repo root is `app/` — matching it as a framework
120
+ // route dir made the check inert for the whole repo (eval 28618761653).
121
+ const repo = makeFixtureRepo({
122
+ "app/client/src/components/Orphan.tsx": `export function Orphan() { return null; }\n`,
123
+ });
124
+ const [result] = checkFrontendFileIntegration(repo, ["app/client/src/components/Orphan.tsx"]);
125
+ expect(result.integrated).toBe(false);
126
+ expect(result.reason).toBe("no-importers");
127
+ });
128
+ it("still short-circuits Next.js App Router route files via their basenames", () => {
129
+ const repo = makeFixtureRepo({
130
+ "src/app/dashboard/page.tsx": `export default function Dashboard() { return null; }\n`,
131
+ });
132
+ const [result] = checkFrontendFileIntegration(repo, ["src/app/dashboard/page.tsx"]);
133
+ expect(result.integrated).toBe(true);
134
+ expect(result.reason).toBe("route-or-entrypoint");
135
+ });
136
+ it("fails open (scan-error, integrated: true) when the changed file does not exist on disk", () => {
137
+ const repo = makeFixtureRepo({
138
+ "src/components/Bar.tsx": `export function Bar() { return null; }\n`,
139
+ });
140
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Deleted.tsx"]);
141
+ expect(result).toEqual({
142
+ file: "src/components/Deleted.tsx",
143
+ integrated: true,
144
+ importers: [],
145
+ reason: "scan-error",
146
+ });
147
+ });
148
+ it("finds an importer of a .d.ts declaration file", () => {
149
+ // Regression: path.extname("Foo.d.ts") is ".ts", so a naive basename/ext
150
+ // split would build a signature for "Foo.d" instead of "Foo" and never
151
+ // match a real `from "./Foo"` import.
152
+ const repo = makeFixtureRepo({
153
+ "src/components/Foo.d.ts": `export declare function Foo(): null;\n`,
154
+ "src/components/Bar.tsx": `import { Foo } from './Foo';\nexport function Bar() { return Foo(); }\n`,
155
+ });
156
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.d.ts"]);
157
+ expect(result.integrated).toBe(true);
158
+ expect(result.reason).toBe("imported");
159
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
160
+ });
161
+ describe("basename short-circuit precision", () => {
162
+ it("does NOT short-circuit a folder-per-component index.tsx as a route/entrypoint", () => {
163
+ // Regression: matching "index"/"main"/"app" basenames anywhere in the
164
+ // tree (not just at the root or src/) would short-circuit the common
165
+ // React folder-per-component convention (Button/index.tsx) as always
166
+ // integrated, defeating the check for exactly the files it targets.
167
+ const repo = makeFixtureRepo({
168
+ "src/components/Button/index.tsx": `export function Button() { return null; }\n`,
169
+ });
170
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Button/index.tsx"]);
171
+ expect(result.integrated).toBe(false);
172
+ expect(result.reason).toBe("no-importers");
173
+ });
174
+ it("still short-circuits a root-level src/index.tsx entrypoint", () => {
175
+ const repo = makeFixtureRepo({
176
+ "src/index.tsx": `export default function App() { return null; }\n`,
177
+ });
178
+ const [result] = checkFrontendFileIntegration(repo, ["src/index.tsx"]);
179
+ expect(result).toEqual({
180
+ file: "src/index.tsx",
181
+ integrated: true,
182
+ importers: [],
183
+ reason: "route-or-entrypoint",
184
+ });
185
+ });
186
+ it("does NOT short-circuit a page.tsx outside an app/ segment", () => {
187
+ // "page"/"layout" are App Router-specific; outside an app/ segment they
188
+ // are ordinary component filenames with no framework meaning.
189
+ const repo = makeFixtureRepo({
190
+ "src/components/page.tsx": `export function Page() { return null; }\n`,
191
+ });
192
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/page.tsx"]);
193
+ expect(result.integrated).toBe(false);
194
+ expect(result.reason).toBe("no-importers");
195
+ });
196
+ });
197
+ describe("manual-walk fallback (non-git repos)", () => {
198
+ it("falls back to a manual walk and finds an importer when the directory isn't a git repo", () => {
199
+ const repo = makeNonGitFixture({
200
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
201
+ "src/components/Bar.tsx": `import { Foo } from './Foo';\nexport function Bar() { return Foo(); }\n`,
202
+ });
203
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
204
+ expect(result.integrated).toBe(true);
205
+ expect(result.reason).toBe("imported");
206
+ expect(result.importers).toEqual(["src/components/Bar.tsx"]);
207
+ });
208
+ it("fails open (scan-error) when the fallback walk is truncated before finding an importer", () => {
209
+ // Regression: the module doc promises fail-open on any scan error/
210
+ // uncertainty, but a truncated manual walk used to just return its
211
+ // partial (empty) result set, reporting a confident "no-importers"
212
+ // even though an importer could exist past the file cap.
213
+ const files = {
214
+ "src/components/Foo.tsx": `export function Foo() { return null; }\n`,
215
+ };
216
+ for (let i = 0; i < 3005; i++) {
217
+ files[`src/generated/File${i}.tsx`] = `export const noop${i} = 0;\n`;
218
+ }
219
+ const repo = makeNonGitFixture(files);
220
+ const [result] = checkFrontendFileIntegration(repo, ["src/components/Foo.tsx"]);
221
+ expect(result).toEqual({
222
+ file: "src/components/Foo.tsx",
223
+ integrated: true,
224
+ importers: [],
225
+ reason: "scan-error",
226
+ });
227
+ });
228
+ });
229
+ });