@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.
- package/build/index.js +1 -8
- 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/adaptWorkflowPrompt.js +3 -1
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
- package/build/prompts/sut-setup/shared.d.ts +20 -0
- package/build/prompts/sut-setup/shared.js +67 -7
- package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
- package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
- package/build/prompts/test-recommendation/recommendationShared.js +1 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
- package/build/prompts/testbot/testbot-prompts.js +10 -9
- package/build/resources/testbotResource.js +1 -1
- package/build/services/TestDiscoveryService.d.ts +12 -10
- package/build/services/TestDiscoveryService.js +137 -83
- package/build/services/TestDiscoveryService.test.js +370 -15
- package/build/services/TestExecutionService.d.ts +1 -1
- package/build/services/TestExecutionService.js +7 -7
- package/build/services/TestExecutionService.test.js +4 -1
- package/build/tools/executeSkyrampTestTool.js +40 -71
- package/build/tools/submitReportTool.d.ts +25 -25
- package/build/tools/submitReportTool.js +123 -34
- package/build/tools/submitReportTool.test.js +413 -94
- package/build/tools/test-management/actionsTool.js +177 -77
- 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 +74 -169
- package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
- package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
- package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
- package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
- package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
- package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
- 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 +18 -49
- package/build/types/TestAnalysis.js +0 -28
- package/build/types/TestTypes.d.ts +3 -1
- package/build/types/TestTypes.js +3 -1
- package/build/types/TestbotReport.d.ts +1 -1
- package/build/utils/AnalysisStateManager.d.ts +16 -22
- 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/build/workspace/workspace.d.ts +21 -21
- package/build/workspace/workspace.js +7 -4
- package/build/workspace/workspace.test.js +65 -6
- 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
|
@@ -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) {
|
package/build/types/TestTypes.js
CHANGED
|
@@ -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
|
|
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()
|
|
@@ -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
|
|
71
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
});
|