@mandujs/ate 0.17.0
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/README.md +1103 -0
- package/package.json +46 -0
- package/src/codegen.ts +140 -0
- package/src/dep-graph.ts +279 -0
- package/src/domain-detector.ts +194 -0
- package/src/extractor.ts +159 -0
- package/src/fs.ts +145 -0
- package/src/heal.ts +427 -0
- package/src/impact.ts +146 -0
- package/src/index.ts +112 -0
- package/src/ir.ts +24 -0
- package/src/oracle.ts +152 -0
- package/src/pipeline.ts +207 -0
- package/src/report.ts +129 -0
- package/src/reporter/html-template.ts +275 -0
- package/src/reporter/html.test.ts +155 -0
- package/src/reporter/html.ts +83 -0
- package/src/runner.ts +100 -0
- package/src/scenario.ts +71 -0
- package/src/selector-map.ts +191 -0
- package/src/trace-parser.ts +270 -0
- package/src/types.ts +106 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mandujs/ate",
|
|
3
|
+
"version": "0.17.0",
|
|
4
|
+
"description": "Mandu ATE (Automation Test Engine) - extract/generate/run/report/heal/impact in one package",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./extractor": "./src/extractor.ts",
|
|
11
|
+
"./ir": "./src/ir.ts",
|
|
12
|
+
"./scenario": "./src/scenario.ts",
|
|
13
|
+
"./codegen": "./src/codegen.ts",
|
|
14
|
+
"./runner": "./src/runner.ts",
|
|
15
|
+
"./oracle": "./src/oracle.ts",
|
|
16
|
+
"./report": "./src/report.ts",
|
|
17
|
+
"./heal": "./src/heal.ts",
|
|
18
|
+
"./trace-parser": "./src/trace-parser.ts",
|
|
19
|
+
"./impact": "./src/impact.ts",
|
|
20
|
+
"./fs": "./src/fs.ts",
|
|
21
|
+
"./types": "./src/types.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src/**/*"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"mandu",
|
|
28
|
+
"ate",
|
|
29
|
+
"automation",
|
|
30
|
+
"e2e",
|
|
31
|
+
"playwright",
|
|
32
|
+
"ts-morph"
|
|
33
|
+
],
|
|
34
|
+
"license": "MPL-2.0",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"fast-glob": "^3.3.2",
|
|
37
|
+
"ts-morph": "^26.0.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@playwright/test": ">=1.40.0",
|
|
41
|
+
"playwright": ">=1.40.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"bun": ">=1.0.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/codegen.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { getAtePaths, ensureDir, readJson, writeJson } from "./fs";
|
|
4
|
+
import type { ScenarioBundle } from "./scenario";
|
|
5
|
+
import type { OracleLevel } from "./types";
|
|
6
|
+
import { readSelectorMap, buildPlaywrightLocatorChain } from "./selector-map";
|
|
7
|
+
import { generateL1Assertions } from "./oracle";
|
|
8
|
+
import { detectDomain } from "./domain-detector";
|
|
9
|
+
|
|
10
|
+
function specHeader(): string {
|
|
11
|
+
return `import { test, expect } from "@playwright/test";\n\n`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function oracleTemplate(level: OracleLevel, routePath: string): { setup: string; assertions: string } {
|
|
15
|
+
const setup: string[] = [];
|
|
16
|
+
const assertions: string[] = [];
|
|
17
|
+
|
|
18
|
+
// L0 baseline always
|
|
19
|
+
setup.push(`// L0: no console.error / uncaught exception / 5xx`);
|
|
20
|
+
setup.push(`const errors: string[] = [];`);
|
|
21
|
+
setup.push(`page.on("console", (msg) => { if (msg.type() === "error") errors.push(msg.text()); });`);
|
|
22
|
+
setup.push(`page.on("pageerror", (err) => errors.push(String(err)));`);
|
|
23
|
+
|
|
24
|
+
if (level === "L1" || level === "L2" || level === "L3") {
|
|
25
|
+
// Use domain-aware L1 assertions
|
|
26
|
+
const domain = detectDomain(routePath).domain;
|
|
27
|
+
const l1Assertions = generateL1Assertions(domain, routePath);
|
|
28
|
+
assertions.push(...l1Assertions);
|
|
29
|
+
}
|
|
30
|
+
if (level === "L2" || level === "L3") {
|
|
31
|
+
assertions.push(`// L2: behavior signals (placeholder - extend per app)`);
|
|
32
|
+
assertions.push(`await expect(page).toHaveURL(/.*/);`);
|
|
33
|
+
}
|
|
34
|
+
if (level === "L3") {
|
|
35
|
+
assertions.push(`// L3: domain hints (placeholder)`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
assertions.push(`expect(errors, "console/page errors").toEqual([]);`);
|
|
39
|
+
|
|
40
|
+
return { setup: setup.join("\n"), assertions: assertions.join("\n") };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function generatePlaywrightSpecs(repoRoot: string, opts?: { onlyRoutes?: string[] }): { files: string[]; warnings: string[] } {
|
|
44
|
+
const paths = getAtePaths(repoRoot);
|
|
45
|
+
const warnings: string[] = [];
|
|
46
|
+
|
|
47
|
+
let bundle: ScenarioBundle;
|
|
48
|
+
try {
|
|
49
|
+
bundle = readJson<ScenarioBundle>(paths.scenariosPath);
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
throw new Error(`시나리오 번들 읽기 실패: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!bundle.scenarios || bundle.scenarios.length === 0) {
|
|
55
|
+
warnings.push("경고: 생성할 시나리오가 없습니다");
|
|
56
|
+
return { files: [], warnings };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let selectorMap;
|
|
60
|
+
try {
|
|
61
|
+
selectorMap = readSelectorMap(repoRoot);
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
// Selector map is optional
|
|
64
|
+
warnings.push(`Selector map 읽기 실패 (무시): ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
ensureDir(paths.autoE2eDir);
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
throw new Error(`E2E 디렉토리 생성 실패: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const files: string[] = [];
|
|
74
|
+
for (const s of bundle.scenarios) {
|
|
75
|
+
if (opts?.onlyRoutes?.length && !opts.onlyRoutes.includes(s.route)) continue;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const safeId = s.id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
79
|
+
const filePath = join(paths.autoE2eDir, `${safeId}.spec.ts`);
|
|
80
|
+
|
|
81
|
+
const oracle = oracleTemplate(s.oracleLevel, s.route);
|
|
82
|
+
|
|
83
|
+
// Generate selector examples if selector map exists
|
|
84
|
+
let selectorExamples = "";
|
|
85
|
+
if (selectorMap && selectorMap.entries.length > 0) {
|
|
86
|
+
const exampleEntry = selectorMap.entries[0];
|
|
87
|
+
const locatorChain = buildPlaywrightLocatorChain(exampleEntry);
|
|
88
|
+
selectorExamples = ` // Example: Selector with fallback chain\n // const loginBtn = ${locatorChain};\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const code = [
|
|
92
|
+
specHeader(),
|
|
93
|
+
`test.describe(${JSON.stringify(s.id)}, () => {`,
|
|
94
|
+
` test(${JSON.stringify(`smoke ${s.route}`)}, async ({ page, baseURL }) => {`,
|
|
95
|
+
` const url = (baseURL ?? "http://localhost:3333") + ${JSON.stringify(s.route === "/" ? "/" : s.route)};`,
|
|
96
|
+
` ${oracle.setup.split("\n").join("\n ")}`,
|
|
97
|
+
` await page.goto(url);`,
|
|
98
|
+
selectorExamples,
|
|
99
|
+
` ${oracle.assertions.split("\n").join("\n ")}`,
|
|
100
|
+
` });`,
|
|
101
|
+
`});`,
|
|
102
|
+
"",
|
|
103
|
+
].join("\n");
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
writeFileSync(filePath, code, "utf8");
|
|
107
|
+
files.push(filePath);
|
|
108
|
+
} catch (err: any) {
|
|
109
|
+
warnings.push(`Spec 파일 쓰기 실패 (${filePath}): ${err.message}`);
|
|
110
|
+
console.error(`[ATE] Spec 생성 실패: ${filePath} - ${err.message}`);
|
|
111
|
+
}
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
warnings.push(`Spec 생성 실패 (${s.id}): ${err.message}`);
|
|
114
|
+
console.error(`[ATE] Spec 생성 에러: ${s.id} - ${err.message}`);
|
|
115
|
+
// Continue with next scenario
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ensure playwright config exists (minimal)
|
|
120
|
+
try {
|
|
121
|
+
const configPath = join(repoRoot, "tests", "e2e", "playwright.config.ts");
|
|
122
|
+
ensureDir(join(repoRoot, "tests", "e2e"));
|
|
123
|
+
const desiredConfig = `import { defineConfig } from "@playwright/test";\n\nexport default defineConfig({\n // NOTE: resolved relative to this config file (tests/e2e).\n testDir: ".",\n timeout: 60_000,\n use: {\n baseURL: process.env.BASE_URL ?? "http://localhost:3333",\n trace: process.env.CI ? "on-first-retry" : "retain-on-failure",\n video: process.env.CI ? "retain-on-failure" : "off",\n screenshot: "only-on-failure",\n },\n reporter: [\n ["html", { outputFolder: "../../.mandu/reports/latest/playwright-html", open: "never" }],\n ["json", { outputFile: "../../.mandu/reports/latest/playwright-report.json" }],\n ["junit", { outputFile: "../../.mandu/reports/latest/junit.xml" }],\n ],\n});\n`;
|
|
124
|
+
|
|
125
|
+
if (!existsSync(configPath)) {
|
|
126
|
+
Bun.write(configPath, desiredConfig);
|
|
127
|
+
} else {
|
|
128
|
+
// migrate older auto-generated config that used testDir: "tests/e2e" (breaks because config is already under tests/e2e)
|
|
129
|
+
const current = readFileSync(configPath, "utf8");
|
|
130
|
+
if (current.includes('testDir: "tests/e2e"')) {
|
|
131
|
+
Bun.write(configPath, desiredConfig);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
warnings.push(`Playwright config 생성 실패: ${err.message}`);
|
|
136
|
+
console.warn(`[ATE] Playwright config 생성 실패: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { files, warnings };
|
|
140
|
+
}
|
package/src/dep-graph.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { normalize, resolve, relative, dirname, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dependency graph: file → Set of files it depends on
|
|
5
|
+
*/
|
|
6
|
+
export interface DependencyGraph {
|
|
7
|
+
/** Map: normalized file path → Set of normalized dependency paths */
|
|
8
|
+
dependencies: Map<string, Set<string>>;
|
|
9
|
+
/** Map: normalized file path → Set of files that depend on it (reverse) */
|
|
10
|
+
dependents: Map<string, Set<string>>;
|
|
11
|
+
/** All files in the graph */
|
|
12
|
+
files: Set<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build options for dependency graph
|
|
17
|
+
*/
|
|
18
|
+
export interface BuildGraphOptions {
|
|
19
|
+
/** Root directory to resolve relative paths */
|
|
20
|
+
rootDir: string;
|
|
21
|
+
/** tsconfig.json path for TypeScript configuration */
|
|
22
|
+
tsconfigPath?: string;
|
|
23
|
+
/** File glob patterns to include */
|
|
24
|
+
include?: string[];
|
|
25
|
+
/** File glob patterns to exclude */
|
|
26
|
+
exclude?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalize path to forward slashes and resolve to absolute
|
|
31
|
+
*/
|
|
32
|
+
function normalizePath(path: string, rootDir: string): string {
|
|
33
|
+
const abs = resolve(rootDir, path);
|
|
34
|
+
return abs.replace(/\\/g, "/");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve import specifier to file path
|
|
39
|
+
*/
|
|
40
|
+
function resolveImport(
|
|
41
|
+
sourceFile: any, // ts-morph SourceFile (lazy loaded)
|
|
42
|
+
importSpecifier: string,
|
|
43
|
+
rootDir: string,
|
|
44
|
+
project: any // ts-morph Project (lazy loaded)
|
|
45
|
+
): string | null {
|
|
46
|
+
// Skip external modules (no relative/absolute path)
|
|
47
|
+
if (!importSpecifier.startsWith(".") && !importSpecifier.startsWith("/")) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sourceDir = dirname(sourceFile.getFilePath());
|
|
52
|
+
let targetPath = resolve(sourceDir, importSpecifier);
|
|
53
|
+
|
|
54
|
+
// Try extensions: .ts, .tsx, .js, .jsx
|
|
55
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx", ""];
|
|
56
|
+
for (const ext of extensions) {
|
|
57
|
+
const candidate = targetPath + ext;
|
|
58
|
+
const sourceFileFound = project.getSourceFile(candidate);
|
|
59
|
+
if (sourceFileFound) {
|
|
60
|
+
return normalizePath(candidate, rootDir);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Try index files
|
|
65
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
|
|
66
|
+
const indexPath = resolve(targetPath, `index${ext}`);
|
|
67
|
+
const sourceFileFound = project.getSourceFile(indexPath);
|
|
68
|
+
if (sourceFileFound) {
|
|
69
|
+
return normalizePath(indexPath, rootDir);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build dependency graph from TypeScript project
|
|
78
|
+
*/
|
|
79
|
+
function shouldExcludeFile(filePath: string, excludePatterns: string[]): boolean {
|
|
80
|
+
if (excludePatterns.length === 0) return false;
|
|
81
|
+
|
|
82
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
83
|
+
return excludePatterns.some((pattern) => {
|
|
84
|
+
// **/*.spec.ts → remove **/ and * to get .spec.ts
|
|
85
|
+
let processedPattern = pattern;
|
|
86
|
+
|
|
87
|
+
// Remove **/ prefix
|
|
88
|
+
if (processedPattern.startsWith("**/")) {
|
|
89
|
+
processedPattern = processedPattern.substring(3);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Remove * prefix
|
|
93
|
+
if (processedPattern.startsWith("*")) {
|
|
94
|
+
processedPattern = processedPattern.substring(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Now check if file ends with the processed pattern
|
|
98
|
+
return normalizedPath.endsWith(processedPattern);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function buildDependencyGraph(options: BuildGraphOptions): Promise<DependencyGraph> {
|
|
103
|
+
const { rootDir, tsconfigPath, include, exclude = [] } = options;
|
|
104
|
+
|
|
105
|
+
// Lazy load ts-morph only when needed
|
|
106
|
+
const { Project } = await import("ts-morph");
|
|
107
|
+
|
|
108
|
+
const project = new Project({
|
|
109
|
+
tsConfigFilePath: tsconfigPath,
|
|
110
|
+
skipAddingFilesFromTsConfig: !tsconfigPath,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Add files if no tsconfig
|
|
114
|
+
if (!tsconfigPath && include) {
|
|
115
|
+
project.addSourceFilesAtPaths(include);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const dependencies = new Map<string, Set<string>>();
|
|
119
|
+
const dependents = new Map<string, Set<string>>();
|
|
120
|
+
const files = new Set<string>();
|
|
121
|
+
|
|
122
|
+
const sourceFiles = project.getSourceFiles();
|
|
123
|
+
|
|
124
|
+
// First pass: collect all files (excluding filtered ones)
|
|
125
|
+
for (const sourceFile of sourceFiles) {
|
|
126
|
+
const filePath = normalizePath(sourceFile.getFilePath(), rootDir);
|
|
127
|
+
|
|
128
|
+
// Skip excluded files
|
|
129
|
+
if (shouldExcludeFile(filePath, exclude)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
files.add(filePath);
|
|
134
|
+
dependencies.set(filePath, new Set());
|
|
135
|
+
dependents.set(filePath, new Set());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Second pass: extract imports
|
|
139
|
+
for (const sourceFile of sourceFiles) {
|
|
140
|
+
const filePath = normalizePath(sourceFile.getFilePath(), rootDir);
|
|
141
|
+
|
|
142
|
+
// Skip excluded files
|
|
143
|
+
if (shouldExcludeFile(filePath, exclude)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const deps = dependencies.get(filePath)!;
|
|
148
|
+
|
|
149
|
+
// Process import declarations
|
|
150
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
151
|
+
const specifier = importDecl.getModuleSpecifierValue();
|
|
152
|
+
const resolvedPath = resolveImport(sourceFile, specifier, rootDir, project);
|
|
153
|
+
|
|
154
|
+
if (resolvedPath && files.has(resolvedPath)) {
|
|
155
|
+
deps.add(resolvedPath);
|
|
156
|
+
dependents.get(resolvedPath)?.add(filePath);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Process export declarations with from clause
|
|
161
|
+
for (const exportDecl of sourceFile.getExportDeclarations()) {
|
|
162
|
+
const moduleSpecifier = exportDecl.getModuleSpecifier();
|
|
163
|
+
if (moduleSpecifier) {
|
|
164
|
+
const specifier = moduleSpecifier.getLiteralText();
|
|
165
|
+
const resolvedPath = resolveImport(sourceFile, specifier, rootDir, project);
|
|
166
|
+
|
|
167
|
+
if (resolvedPath && files.has(resolvedPath)) {
|
|
168
|
+
deps.add(resolvedPath);
|
|
169
|
+
dependents.get(resolvedPath)?.add(filePath);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { dependencies, dependents, files };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Find all files that transitively depend on the given file
|
|
180
|
+
* Uses DFS to traverse the dependency graph in reverse
|
|
181
|
+
*/
|
|
182
|
+
export function findDependents(
|
|
183
|
+
graph: DependencyGraph,
|
|
184
|
+
changedFile: string,
|
|
185
|
+
options?: { maxDepth?: number }
|
|
186
|
+
): Set<string> {
|
|
187
|
+
const result = new Set<string>();
|
|
188
|
+
const visited = new Set<string>();
|
|
189
|
+
const maxDepth = options?.maxDepth ?? Infinity;
|
|
190
|
+
|
|
191
|
+
function dfs(file: string, depth: number) {
|
|
192
|
+
if (visited.has(file)) return; // Prevent infinite loop (circular deps)
|
|
193
|
+
visited.add(file);
|
|
194
|
+
|
|
195
|
+
const deps = graph.dependents.get(file);
|
|
196
|
+
if (!deps) return;
|
|
197
|
+
|
|
198
|
+
for (const dependent of deps) {
|
|
199
|
+
if (depth < maxDepth) {
|
|
200
|
+
result.add(dependent);
|
|
201
|
+
dfs(dependent, depth + 1);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
dfs(changedFile, 0);
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Find all files that the given file transitively depends on
|
|
212
|
+
* Uses DFS to traverse the dependency graph forward
|
|
213
|
+
*/
|
|
214
|
+
export function findDependencies(
|
|
215
|
+
graph: DependencyGraph,
|
|
216
|
+
file: string,
|
|
217
|
+
options?: { maxDepth?: number }
|
|
218
|
+
): Set<string> {
|
|
219
|
+
const result = new Set<string>();
|
|
220
|
+
const visited = new Set<string>();
|
|
221
|
+
const maxDepth = options?.maxDepth ?? Infinity;
|
|
222
|
+
|
|
223
|
+
function dfs(currentFile: string, depth: number) {
|
|
224
|
+
if (depth > maxDepth) return;
|
|
225
|
+
if (visited.has(currentFile)) return;
|
|
226
|
+
visited.add(currentFile);
|
|
227
|
+
|
|
228
|
+
const deps = graph.dependencies.get(currentFile);
|
|
229
|
+
if (!deps) return;
|
|
230
|
+
|
|
231
|
+
for (const dep of deps) {
|
|
232
|
+
result.add(dep);
|
|
233
|
+
dfs(dep, depth + 1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
dfs(file, 0);
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Detect circular dependencies in the graph
|
|
243
|
+
*/
|
|
244
|
+
export function detectCircularDependencies(graph: DependencyGraph): string[][] {
|
|
245
|
+
const cycles: string[][] = [];
|
|
246
|
+
const visited = new Set<string>();
|
|
247
|
+
const recursionStack = new Set<string>();
|
|
248
|
+
|
|
249
|
+
function dfs(file: string, path: string[]) {
|
|
250
|
+
if (recursionStack.has(file)) {
|
|
251
|
+
// Found a cycle
|
|
252
|
+
const cycleStart = path.indexOf(file);
|
|
253
|
+
const cycle = path.slice(cycleStart);
|
|
254
|
+
cycles.push([...cycle, file]);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (visited.has(file)) return;
|
|
259
|
+
visited.add(file);
|
|
260
|
+
recursionStack.add(file);
|
|
261
|
+
|
|
262
|
+
const deps = graph.dependencies.get(file);
|
|
263
|
+
if (deps) {
|
|
264
|
+
for (const dep of deps) {
|
|
265
|
+
dfs(dep, [...path, file]);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
recursionStack.delete(file);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const file of graph.files) {
|
|
273
|
+
if (!visited.has(file)) {
|
|
274
|
+
dfs(file, []);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return cycles;
|
|
279
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain detection for context-aware test assertions
|
|
3
|
+
* Analyzes route paths, imports, and keywords to identify application domain
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type AppDomain = "ecommerce" | "blog" | "dashboard" | "auth" | "generic";
|
|
7
|
+
|
|
8
|
+
export interface DomainDetectionResult {
|
|
9
|
+
domain: AppDomain;
|
|
10
|
+
confidence: number; // 0-1
|
|
11
|
+
signals: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface DomainPattern {
|
|
15
|
+
domain: AppDomain;
|
|
16
|
+
routePatterns: RegExp[];
|
|
17
|
+
importKeywords: string[];
|
|
18
|
+
codeKeywords: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DOMAIN_PATTERNS: DomainPattern[] = [
|
|
22
|
+
{
|
|
23
|
+
domain: "ecommerce",
|
|
24
|
+
routePatterns: [
|
|
25
|
+
/\/shop/i,
|
|
26
|
+
/\/cart/i,
|
|
27
|
+
/\/checkout/i,
|
|
28
|
+
/\/product/i,
|
|
29
|
+
/\/order/i,
|
|
30
|
+
/\/payment/i,
|
|
31
|
+
],
|
|
32
|
+
importKeywords: ["stripe", "commerce", "cart", "checkout", "payment"],
|
|
33
|
+
codeKeywords: ["addToCart", "removeFromCart", "checkout", "price", "product", "order"],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
domain: "blog",
|
|
37
|
+
routePatterns: [
|
|
38
|
+
/\/blog/i,
|
|
39
|
+
/\/post/i,
|
|
40
|
+
/\/article/i,
|
|
41
|
+
/\/author/i,
|
|
42
|
+
/\/category/i,
|
|
43
|
+
],
|
|
44
|
+
importKeywords: ["markdown", "mdx", "contentlayer", "sanity", "cms"],
|
|
45
|
+
codeKeywords: ["post", "article", "author", "publish", "content", "comment"],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
domain: "dashboard",
|
|
49
|
+
routePatterns: [
|
|
50
|
+
/\/dashboard/i,
|
|
51
|
+
/\/admin/i,
|
|
52
|
+
/\/analytics/i,
|
|
53
|
+
/\/settings/i,
|
|
54
|
+
/\/profile/i,
|
|
55
|
+
],
|
|
56
|
+
importKeywords: ["chart", "recharts", "d3", "analytics", "table", "zustand", "react-query"],
|
|
57
|
+
codeKeywords: ["chart", "table", "sidebar", "analytics", "report", "metric"],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
domain: "auth",
|
|
61
|
+
routePatterns: [
|
|
62
|
+
/\/login/i,
|
|
63
|
+
/\/signup/i,
|
|
64
|
+
/\/register/i,
|
|
65
|
+
/\/auth/i,
|
|
66
|
+
/\/forgot-password/i,
|
|
67
|
+
],
|
|
68
|
+
importKeywords: ["auth", "clerk", "nextauth", "supabase", "firebase"],
|
|
69
|
+
codeKeywords: ["login", "logout", "signup", "register", "authenticate", "session"],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Detect application domain from route path
|
|
75
|
+
*/
|
|
76
|
+
export function detectDomainFromRoute(routePath: string): DomainDetectionResult {
|
|
77
|
+
const results: Array<{ domain: AppDomain; score: number; signals: string[] }> = [];
|
|
78
|
+
|
|
79
|
+
for (const pattern of DOMAIN_PATTERNS) {
|
|
80
|
+
const signals: string[] = [];
|
|
81
|
+
let score = 0;
|
|
82
|
+
|
|
83
|
+
for (const regex of pattern.routePatterns) {
|
|
84
|
+
if (regex.test(routePath)) {
|
|
85
|
+
score += 1;
|
|
86
|
+
signals.push(`route pattern: ${regex.source}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (score > 0) {
|
|
91
|
+
results.push({ domain: pattern.domain, score, signals });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Sort by score descending
|
|
96
|
+
results.sort((a, b) => b.score - a.score);
|
|
97
|
+
|
|
98
|
+
if (results.length > 0 && results[0].score > 0) {
|
|
99
|
+
const best = results[0];
|
|
100
|
+
return {
|
|
101
|
+
domain: best.domain,
|
|
102
|
+
confidence: Math.min(best.score / 2, 1), // Normalize confidence
|
|
103
|
+
signals: best.signals,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
domain: "generic",
|
|
109
|
+
confidence: 1,
|
|
110
|
+
signals: ["no domain-specific patterns detected"],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Detect application domain from source code content
|
|
116
|
+
*/
|
|
117
|
+
export function detectDomainFromSource(sourceCode: string): DomainDetectionResult {
|
|
118
|
+
const results: Array<{ domain: AppDomain; score: number; signals: string[] }> = [];
|
|
119
|
+
|
|
120
|
+
for (const pattern of DOMAIN_PATTERNS) {
|
|
121
|
+
const signals: string[] = [];
|
|
122
|
+
let score = 0;
|
|
123
|
+
|
|
124
|
+
// Check import keywords
|
|
125
|
+
for (const keyword of pattern.importKeywords) {
|
|
126
|
+
const importRegex = new RegExp(`import\\s+.*from\\s+["'].*${keyword}.*["']`, "i");
|
|
127
|
+
if (importRegex.test(sourceCode)) {
|
|
128
|
+
score += 2;
|
|
129
|
+
signals.push(`import: ${keyword}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check code keywords
|
|
134
|
+
for (const keyword of pattern.codeKeywords) {
|
|
135
|
+
const codeRegex = new RegExp(`\\b${keyword}\\b`, "i");
|
|
136
|
+
if (codeRegex.test(sourceCode)) {
|
|
137
|
+
score += 1;
|
|
138
|
+
signals.push(`code keyword: ${keyword}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (score > 0) {
|
|
143
|
+
results.push({ domain: pattern.domain, score, signals });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Sort by score descending
|
|
148
|
+
results.sort((a, b) => b.score - a.score);
|
|
149
|
+
|
|
150
|
+
if (results.length > 0 && results[0].score >= 3) {
|
|
151
|
+
const best = results[0];
|
|
152
|
+
return {
|
|
153
|
+
domain: best.domain,
|
|
154
|
+
confidence: Math.min(best.score / 10, 1), // Normalize confidence
|
|
155
|
+
signals: best.signals.slice(0, 5), // Limit signals
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
domain: "generic",
|
|
161
|
+
confidence: 1,
|
|
162
|
+
signals: ["no strong domain signals in source code"],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Combine multiple detection strategies
|
|
168
|
+
*/
|
|
169
|
+
export function detectDomain(routePath: string, sourceCode?: string): DomainDetectionResult {
|
|
170
|
+
const routeResult = detectDomainFromRoute(routePath);
|
|
171
|
+
|
|
172
|
+
if (!sourceCode) {
|
|
173
|
+
return routeResult;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const sourceResult = detectDomainFromSource(sourceCode);
|
|
177
|
+
|
|
178
|
+
// If both agree, combine confidence
|
|
179
|
+
if (routeResult.domain === sourceResult.domain) {
|
|
180
|
+
return {
|
|
181
|
+
domain: routeResult.domain,
|
|
182
|
+
confidence: Math.min((routeResult.confidence + sourceResult.confidence) / 2 + 0.2, 1),
|
|
183
|
+
signals: [...routeResult.signals, ...sourceResult.signals],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// If they disagree, prefer route result if it's not generic
|
|
188
|
+
if (routeResult.domain !== "generic") {
|
|
189
|
+
return routeResult;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Otherwise use source result
|
|
193
|
+
return sourceResult;
|
|
194
|
+
}
|