@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.
- package/build/index.js +1 -1
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
- package/build/prompts/personas.d.ts +2 -2
- package/build/prompts/personas.js +2 -2
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -0
- package/build/prompts/sut-setup/shared.js +1 -3
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +1 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +10 -5
- package/build/prompts/test-maintenance/driftAnalysisSections.js +1 -4
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +7 -1
- package/build/prompts/test-maintenance/driftAnalysisShared.js +19 -1
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +0 -6
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +0 -16
- package/build/prompts/test-recommendation/recommendationShared.js +1 -1
- package/build/prompts/testbot/testbot-prompts.js +8 -8
- package/build/resources/testbotResource.js +1 -1
- package/build/services/TestDiscoveryService.d.ts +0 -1
- package/build/services/TestDiscoveryService.js +13 -33
- package/build/services/TestDiscoveryService.test.js +136 -1
- package/build/services/TestExecutionService.d.ts +1 -1
- package/build/tools/executeSkyrampTestTool.js +40 -71
- package/build/tools/submitReportTool.d.ts +20 -20
- package/build/tools/submitReportTool.js +113 -31
- package/build/tools/submitReportTool.test.js +411 -94
- package/build/tools/test-management/actionsTool.js +135 -53
- package/build/tools/test-management/actionsTool.test.d.ts +1 -0
- package/build/tools/test-management/actionsTool.test.js +297 -0
- package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
- package/build/tools/test-management/analyzeChangesTool.js +63 -152
- package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
- package/build/tools/test-management/analyzeTestHealthTool.js +24 -157
- package/build/tools/test-management/analyzeTestHealthTool.test.js +15 -215
- package/build/tools/workspace/initializeWorkspaceTool.js +35 -15
- package/build/types/FrontendIntegration.d.ts +25 -0
- package/build/types/FrontendIntegration.js +19 -0
- package/build/types/RepositoryAnalysis.d.ts +8 -8
- package/build/types/TestAnalysis.d.ts +16 -50
- package/build/types/TestAnalysis.js +0 -28
- package/build/types/TestTypes.d.ts +3 -1
- package/build/types/TestTypes.js +3 -1
- package/build/utils/AnalysisStateManager.d.ts +13 -1
- package/build/utils/docker.test.js +1 -1
- package/build/utils/featureFlags.d.ts +1 -1
- package/build/utils/featureFlags.js +1 -1
- package/build/utils/frontendIntegration.d.ts +9 -0
- package/build/utils/frontendIntegration.js +237 -0
- package/build/utils/frontendIntegration.test.d.ts +1 -0
- package/build/utils/frontendIntegration.test.js +229 -0
- package/build/utils/pr-comment-parser.d.ts +3 -3
- package/build/utils/pr-comment-parser.js +6 -6
- package/build/utils/pr-comment-parser.test.js +3 -3
- package/build/utils/repoScanner.d.ts +7 -0
- package/build/utils/repoScanner.js +14 -6
- package/build/utils/repoScanner.test.js +9 -9
- package/build/utils/routeParsers.d.ts +32 -0
- package/build/utils/routeParsers.js +205 -2
- package/build/utils/routeParsers.test.js +279 -29
- package/build/utils/sourceRouteExtractor.js +174 -5
- package/build/utils/sourceRouteExtractor.test.js +173 -0
- package/build/utils/utils.d.ts +11 -0
- package/build/utils/utils.js +19 -0
- package/build/utils/utils.test.js +23 -1
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
- package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +3 -3
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PR Comment Parser — extracts
|
|
2
|
+
* PR Comment Parser — extracts Testbot history from GitHub PR comments.
|
|
3
3
|
*
|
|
4
|
-
* Parses previous Skyramp
|
|
4
|
+
* Parses previous Skyramp Testbot comments on a PR to build context
|
|
5
5
|
* about what has already been recommended, implemented, and tested.
|
|
6
6
|
* This prevents re-recommendations and enables contextual awareness
|
|
7
7
|
* across the PR lifecycle.
|
|
@@ -23,7 +23,7 @@ export interface PRTestContext {
|
|
|
23
23
|
}>;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* Fetch and parse PR comments to build
|
|
26
|
+
* Fetch and parse PR comments to build Testbot history context.
|
|
27
27
|
*
|
|
28
28
|
* Uses the `gh` CLI (available in CI and local dev environments with
|
|
29
29
|
* GitHub CLI installed). Falls back gracefully if `gh` is unavailable.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PR Comment Parser — extracts
|
|
2
|
+
* PR Comment Parser — extracts Testbot history from GitHub PR comments.
|
|
3
3
|
*
|
|
4
|
-
* Parses previous Skyramp
|
|
4
|
+
* Parses previous Skyramp Testbot comments on a PR to build context
|
|
5
5
|
* about what has already been recommended, implemented, and tested.
|
|
6
6
|
* This prevents re-recommendations and enables contextual awareness
|
|
7
7
|
* across the PR lifecycle.
|
|
@@ -16,7 +16,7 @@ const TESTBOT_MARKERS = [
|
|
|
16
16
|
const TEST_TYPE_PATTERN = /\b(Smoke|Contract|Integration|Fuzz|Load|E2E|UI)\b/gi;
|
|
17
17
|
const TEST_FILE_PATTERN = /[\w/.-]+(?:_test|_smoke|_contract|_fuzz|_integration|_load|_e2e|_ui)\.\w+/gi;
|
|
18
18
|
const STATUS_PATTERN = /\b(Pass|Fail|Skipped)\b/gi;
|
|
19
|
-
function
|
|
19
|
+
function isTestbotComment(comment) {
|
|
20
20
|
const hasMarker = TESTBOT_MARKERS.some((marker) => comment.body.includes(marker));
|
|
21
21
|
if (!hasMarker)
|
|
22
22
|
return false;
|
|
@@ -191,7 +191,7 @@ function extractImplementedFiles(body) {
|
|
|
191
191
|
return [...new Set(matches.map((m) => m[0]))];
|
|
192
192
|
}
|
|
193
193
|
/**
|
|
194
|
-
* Fetch and parse PR comments to build
|
|
194
|
+
* Fetch and parse PR comments to build Testbot history context.
|
|
195
195
|
*
|
|
196
196
|
* Uses the `gh` CLI (available in CI and local dev environments with
|
|
197
197
|
* GitHub CLI installed). Falls back gracefully if `gh` is unavailable.
|
|
@@ -228,11 +228,11 @@ export async function parsePRComments(repoOwner, repoName, prNumber, _token) {
|
|
|
228
228
|
logger.warning("Failed to parse PR comments JSON");
|
|
229
229
|
return empty;
|
|
230
230
|
}
|
|
231
|
-
const testBotComments = comments.filter((c) =>
|
|
231
|
+
const testBotComments = comments.filter((c) => isTestbotComment(c));
|
|
232
232
|
if (testBotComments.length === 0) {
|
|
233
233
|
return empty;
|
|
234
234
|
}
|
|
235
|
-
// Use only the latest
|
|
235
|
+
// Use only the latest Testbot comment — older comments represent stale
|
|
236
236
|
// state from previous bot runs. The latest comment has the most accurate
|
|
237
237
|
// picture of what was recommended, generated, and executed.
|
|
238
238
|
const latestComment = testBotComments[testBotComments.length - 1];
|
|
@@ -163,7 +163,7 @@ describe("parsePRComments — error handling", () => {
|
|
|
163
163
|
expect(ctx.implementedTestFiles).toEqual([]);
|
|
164
164
|
expect(ctx.executionResults).toEqual([]);
|
|
165
165
|
});
|
|
166
|
-
it("returns empty context when no
|
|
166
|
+
it("returns empty context when no Testbot comments exist", async () => {
|
|
167
167
|
mockedExecFileSync.mockReturnValue(ghResponse([
|
|
168
168
|
{ id: 1, body: "LGTM!", login: "reviewer" },
|
|
169
169
|
{ id: 2, body: "Please fix the typo", login: "reviewer" },
|
|
@@ -173,7 +173,7 @@ describe("parsePRComments — error handling", () => {
|
|
|
173
173
|
expect(ctx.implementedTestFiles).toEqual([]);
|
|
174
174
|
expect(ctx.executionResults).toEqual([]);
|
|
175
175
|
});
|
|
176
|
-
it("uses only the latest
|
|
176
|
+
it("uses only the latest Testbot comment when multiple exist", async () => {
|
|
177
177
|
const olderReport = progressWrapper(`### 📋 Business Case Analysis
|
|
178
178
|
Old report.
|
|
179
179
|
|
|
@@ -347,7 +347,7 @@ describe("edge cases", () => {
|
|
|
347
347
|
const count = ctx.implementedTestFiles.filter((f) => f === "test_items_integration.py").length;
|
|
348
348
|
expect(count).toBe(1);
|
|
349
349
|
});
|
|
350
|
-
it("handles empty
|
|
350
|
+
it("handles empty Testbot comment body", async () => {
|
|
351
351
|
mockedExecFileSync.mockReturnValue(ghResponse([{ id: 1, body: "Skyramp Testbot — empty run" }]));
|
|
352
352
|
const ctx = await parsePRComments("owner", "repo", 1);
|
|
353
353
|
expect(ctx.previousRecommendations).toEqual([]);
|
|
@@ -2,6 +2,13 @@ export interface ScannedEndpoint {
|
|
|
2
2
|
path: string;
|
|
3
3
|
methods: string[];
|
|
4
4
|
sourceFile: string;
|
|
5
|
+
/**
|
|
6
|
+
* 1-based line number of the route's declaration in `sourceFile`, when known.
|
|
7
|
+
* Used by hunk-aware diff scoping to attribute a changed line to the single
|
|
8
|
+
* route that owns it (rather than flagging every route in a touched file).
|
|
9
|
+
* Optional — absence disables scoping and falls back to file-level matching.
|
|
10
|
+
*/
|
|
11
|
+
line?: number;
|
|
5
12
|
}
|
|
6
13
|
/**
|
|
7
14
|
* Returns the relative paths of entry-point files that define the routing
|
|
@@ -101,16 +101,22 @@ export function grepRouterMountingContext(repositoryPath) {
|
|
|
101
101
|
}
|
|
102
102
|
return found;
|
|
103
103
|
}
|
|
104
|
-
function addEndpointToMap(endpointMap, apiPath, method, sourceFile, repositoryPath) {
|
|
104
|
+
function addEndpointToMap(endpointMap, apiPath, method, sourceFile, repositoryPath, line) {
|
|
105
105
|
const relative = sourceFile.startsWith(repositoryPath)
|
|
106
106
|
? sourceFile.slice(repositoryPath.length + 1) : sourceFile;
|
|
107
107
|
const normalizedPath = apiPath.startsWith("/") ? apiPath : `/${apiPath}`;
|
|
108
108
|
const key = `${relative}::${normalizedPath}`;
|
|
109
109
|
const existing = endpointMap.get(key);
|
|
110
|
-
if (existing)
|
|
110
|
+
if (existing) {
|
|
111
111
|
existing.methods.add(method);
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// Keep the earliest declaration line so ownership-range scoping starts there.
|
|
113
|
+
if (typeof line === "number" && (existing.line === undefined || line < existing.line)) {
|
|
114
|
+
existing.line = line;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
endpointMap.set(key, { path: normalizedPath, methods: new Set([method]), sourceFile: relative, line });
|
|
119
|
+
}
|
|
114
120
|
}
|
|
115
121
|
function scanNextjsFile(file, repositoryPath, endpointMap) {
|
|
116
122
|
const relative = file.startsWith(repositoryPath)
|
|
@@ -239,13 +245,14 @@ export function scanAllRepoEndpoints(repositoryPath) {
|
|
|
239
245
|
if (content === null)
|
|
240
246
|
continue;
|
|
241
247
|
for (const ep of parseFileEndpoints(content, relative)) {
|
|
242
|
-
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
|
|
248
|
+
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath, ep.line);
|
|
243
249
|
}
|
|
244
250
|
}
|
|
245
251
|
return Array.from(endpointMap.values()).map((data) => ({
|
|
246
252
|
path: data.path,
|
|
247
253
|
methods: Array.from(data.methods),
|
|
248
254
|
sourceFile: data.sourceFile,
|
|
255
|
+
...(data.line !== undefined ? { line: data.line } : {}),
|
|
249
256
|
}));
|
|
250
257
|
}
|
|
251
258
|
export function scanRelatedEndpoints(repositoryPath, changedFiles) {
|
|
@@ -273,7 +280,7 @@ export function scanRelatedEndpoints(repositoryPath, changedFiles) {
|
|
|
273
280
|
if (fileContent === null)
|
|
274
281
|
continue;
|
|
275
282
|
for (const ep of parseFileEndpoints(fileContent, relative)) {
|
|
276
|
-
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
|
|
283
|
+
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath, ep.line);
|
|
277
284
|
}
|
|
278
285
|
}
|
|
279
286
|
}
|
|
@@ -281,5 +288,6 @@ export function scanRelatedEndpoints(repositoryPath, changedFiles) {
|
|
|
281
288
|
path: data.path,
|
|
282
289
|
methods: Array.from(data.methods),
|
|
283
290
|
sourceFile: data.sourceFile,
|
|
291
|
+
...(data.line !== undefined ? { line: data.line } : {}),
|
|
284
292
|
}));
|
|
285
293
|
}
|