@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/src/extractor.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import fg from "fast-glob";
|
|
2
|
+
import { relative, join } from "node:path";
|
|
3
|
+
import { createEmptyGraph, addEdge, addNode } from "./ir";
|
|
4
|
+
import { getAtePaths, writeJson } from "./fs";
|
|
5
|
+
import type { ExtractInput, InteractionGraph } from "./types";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_ROUTE_GLOBS = ["app/**/page.tsx", "routes/**/page.tsx"]; // demo-first default
|
|
8
|
+
|
|
9
|
+
function isStringLiteral(node: any /* ts-morph Node */, SyntaxKind: any): boolean {
|
|
10
|
+
return node.getKind() === SyntaxKind.StringLiteral;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function tryExtractLiteralArg(callExpr: any /* ts-morph CallExpression */, argIndex = 0, SyntaxKind: any): string | null {
|
|
14
|
+
const args = callExpr.getArguments();
|
|
15
|
+
const arg = args[argIndex];
|
|
16
|
+
if (!arg) return null;
|
|
17
|
+
if (isStringLiteral(arg, SyntaxKind)) return arg.getLiteralValue();
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function extract(input: ExtractInput): Promise<{ ok: true; graphPath: string; summary: { nodes: number; edges: number }; warnings: string[] }> {
|
|
22
|
+
const repoRoot = input.repoRoot;
|
|
23
|
+
const buildSalt = input.buildSalt ?? process.env.MANDU_BUILD_SALT ?? "dev";
|
|
24
|
+
const paths = getAtePaths(repoRoot);
|
|
25
|
+
const warnings: string[] = [];
|
|
26
|
+
|
|
27
|
+
const graph: InteractionGraph = createEmptyGraph(buildSalt);
|
|
28
|
+
|
|
29
|
+
// Validate input
|
|
30
|
+
if (!repoRoot) {
|
|
31
|
+
throw new Error("repoRoot는 필수입니다");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const routeGlobs = input.routeGlobs?.length ? input.routeGlobs : DEFAULT_ROUTE_GLOBS;
|
|
35
|
+
|
|
36
|
+
let routeFiles: string[];
|
|
37
|
+
try {
|
|
38
|
+
routeFiles = await fg(routeGlobs, {
|
|
39
|
+
cwd: repoRoot,
|
|
40
|
+
absolute: true,
|
|
41
|
+
onlyFiles: true,
|
|
42
|
+
ignore: ["**/node_modules/**", "**/.mandu/**"],
|
|
43
|
+
});
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
throw new Error(`파일 검색 실패: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (routeFiles.length === 0) {
|
|
49
|
+
warnings.push(`경고: route 파일을 찾을 수 없습니다 (globs: ${routeGlobs.join(", ")})`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Lazy load ts-morph only when needed
|
|
53
|
+
const { Project, SyntaxKind } = await import("ts-morph");
|
|
54
|
+
|
|
55
|
+
let project: any; // ts-morph Project
|
|
56
|
+
try {
|
|
57
|
+
project = new Project({
|
|
58
|
+
tsConfigFilePath: input.tsconfigPath ? join(repoRoot, input.tsconfigPath) : undefined,
|
|
59
|
+
skipAddingFilesFromTsConfig: true,
|
|
60
|
+
});
|
|
61
|
+
} catch (err: any) {
|
|
62
|
+
throw new Error(`TypeScript 프로젝트 초기화 실패: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const filePath of routeFiles) {
|
|
66
|
+
try {
|
|
67
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
68
|
+
const rel = relative(repoRoot, filePath);
|
|
69
|
+
const relNormalized = rel.replace(/\\/g, "/");
|
|
70
|
+
|
|
71
|
+
// route node id: normalize to path without trailing /page.tsx
|
|
72
|
+
const routePath = relNormalized
|
|
73
|
+
.replace(/^app\//, "/")
|
|
74
|
+
.replace(/^routes\//, "/")
|
|
75
|
+
.replace(/\/page\.tsx$/, "")
|
|
76
|
+
.replace(/\/index\.tsx$/, "")
|
|
77
|
+
.replace(/\/page$/, "")
|
|
78
|
+
.replace(/\\/g, "/");
|
|
79
|
+
|
|
80
|
+
addNode(graph, { kind: "route", id: routePath === "" ? "/" : routePath, file: relNormalized, path: routePath === "" ? "/" : routePath });
|
|
81
|
+
|
|
82
|
+
// ManduLink / Link literal extraction: <Link href="/x"> or <ManduLink to="/x">
|
|
83
|
+
try {
|
|
84
|
+
const jsxAttrs = sourceFile.getDescendantsOfKind(SyntaxKind.JsxAttribute);
|
|
85
|
+
for (const attr of jsxAttrs) {
|
|
86
|
+
try {
|
|
87
|
+
const name = (attr as any).getNameNode?.().getText?.() ?? (attr as any).getName?.() ?? "";
|
|
88
|
+
if (name !== "to" && name !== "href") continue;
|
|
89
|
+
const init = (attr as any).getInitializer?.();
|
|
90
|
+
if (!init) continue;
|
|
91
|
+
if (init.getKind?.() === SyntaxKind.StringLiteral) {
|
|
92
|
+
const raw = (init as any).getLiteralValue?.() ?? init.getText?.();
|
|
93
|
+
const to = typeof raw === "string" ? raw.replace(/^"|"$/g, "") : null;
|
|
94
|
+
if (typeof to === "string" && to.startsWith("/")) {
|
|
95
|
+
addEdge(graph, { kind: "navigate", from: routePath || "/", to, file: relNormalized, source: `<jsx ${name}>` });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
// Skip invalid JSX attributes
|
|
100
|
+
warnings.push(`JSX 속성 파싱 실패 (${relNormalized}): ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch (err: any) {
|
|
104
|
+
warnings.push(`JSX 분석 실패 (${relNormalized}): ${err.message}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// mandu.navigate("/x") literal
|
|
108
|
+
try {
|
|
109
|
+
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
110
|
+
for (const call of calls) {
|
|
111
|
+
try {
|
|
112
|
+
const exprText = call.getExpression().getText();
|
|
113
|
+
if (exprText === "mandu.navigate" || exprText.endsWith(".navigate")) {
|
|
114
|
+
const to = tryExtractLiteralArg(call, 0, SyntaxKind);
|
|
115
|
+
if (to && to.startsWith("/")) {
|
|
116
|
+
addEdge(graph, { kind: "navigate", from: routePath || "/", to, file: relNormalized, source: "mandu.navigate" });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (exprText === "mandu.modal.open" || exprText.endsWith(".modal.open")) {
|
|
120
|
+
const modal = tryExtractLiteralArg(call, 0, SyntaxKind);
|
|
121
|
+
if (modal) {
|
|
122
|
+
addEdge(graph, { kind: "openModal", from: routePath || "/", modal, file: relNormalized, source: "mandu.modal.open" });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (exprText === "mandu.action.run" || exprText.endsWith(".action.run")) {
|
|
126
|
+
const action = tryExtractLiteralArg(call, 0, SyntaxKind);
|
|
127
|
+
if (action) {
|
|
128
|
+
addEdge(graph, { kind: "runAction", from: routePath || "/", action, file: relNormalized, source: "mandu.action.run" });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (err: any) {
|
|
132
|
+
// Skip invalid call expressions
|
|
133
|
+
warnings.push(`함수 호출 파싱 실패 (${relNormalized}): ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
warnings.push(`함수 호출 분석 실패 (${relNormalized}): ${err.message}`);
|
|
138
|
+
}
|
|
139
|
+
} catch (err: any) {
|
|
140
|
+
// Graceful degradation: skip this file and continue
|
|
141
|
+
warnings.push(`파일 파싱 실패 (${filePath}): ${err.message}`);
|
|
142
|
+
console.warn(`[ATE] 파일 스킵: ${filePath} - ${err.message}`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
writeJson(paths.interactionGraphPath, graph);
|
|
149
|
+
} catch (err: any) {
|
|
150
|
+
throw new Error(`Interaction graph 저장 실패: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
graphPath: paths.interactionGraphPath,
|
|
156
|
+
summary: { nodes: graph.nodes.length, edges: graph.edges.length },
|
|
157
|
+
warnings,
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/fs.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { AtePaths } from "./types";
|
|
4
|
+
|
|
5
|
+
export class ATEFileError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
message: string,
|
|
8
|
+
public readonly code: string,
|
|
9
|
+
public readonly path: string,
|
|
10
|
+
) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "ATEFileError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getAtePaths(repoRoot: string): AtePaths {
|
|
17
|
+
const manduDir = join(repoRoot, ".mandu");
|
|
18
|
+
return {
|
|
19
|
+
repoRoot,
|
|
20
|
+
manduDir,
|
|
21
|
+
interactionGraphPath: join(manduDir, "interaction-graph.json"),
|
|
22
|
+
selectorMapPath: join(manduDir, "selector-map.json"),
|
|
23
|
+
scenariosPath: join(manduDir, "scenarios", "generated.json"),
|
|
24
|
+
reportsDir: join(manduDir, "reports"),
|
|
25
|
+
autoE2eDir: join(repoRoot, "tests", "e2e", "auto"),
|
|
26
|
+
manualE2eDir: join(repoRoot, "tests", "e2e", "manual"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 디렉토리 생성 (재귀적)
|
|
32
|
+
* @throws ATEFileError - 권한 없음 또는 파일 시스템 에러
|
|
33
|
+
*/
|
|
34
|
+
export function ensureDir(path: string): void {
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(path, { recursive: true });
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
if (err.code === "EACCES") {
|
|
39
|
+
throw new ATEFileError(
|
|
40
|
+
`디렉토리 생성 권한이 없습니다: ${path}`,
|
|
41
|
+
"PERMISSION_DENIED",
|
|
42
|
+
path,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (err.code === "ENOSPC") {
|
|
46
|
+
throw new ATEFileError(
|
|
47
|
+
`디스크 공간이 부족합니다: ${path}`,
|
|
48
|
+
"NO_SPACE",
|
|
49
|
+
path,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
throw new ATEFileError(
|
|
53
|
+
`디렉토리 생성 실패: ${path} (${err.message})`,
|
|
54
|
+
err.code || "UNKNOWN",
|
|
55
|
+
path,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* JSON 파일 쓰기
|
|
62
|
+
* @throws ATEFileError - 파일 쓰기 실패
|
|
63
|
+
*/
|
|
64
|
+
export function writeJson(path: string, data: unknown): void {
|
|
65
|
+
try {
|
|
66
|
+
ensureDir(dirname(path));
|
|
67
|
+
writeFileSync(path, JSON.stringify(data, null, 2), "utf8");
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
if (err instanceof ATEFileError) {
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
if (err.code === "EACCES") {
|
|
73
|
+
throw new ATEFileError(
|
|
74
|
+
`파일 쓰기 권한이 없습니다: ${path}`,
|
|
75
|
+
"PERMISSION_DENIED",
|
|
76
|
+
path,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (err.code === "ENOSPC") {
|
|
80
|
+
throw new ATEFileError(
|
|
81
|
+
`디스크 공간이 부족합니다: ${path}`,
|
|
82
|
+
"NO_SPACE",
|
|
83
|
+
path,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
throw new ATEFileError(
|
|
87
|
+
`JSON 파일 쓰기 실패: ${path} (${err.message})`,
|
|
88
|
+
err.code || "UNKNOWN",
|
|
89
|
+
path,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* JSON 파일 읽기
|
|
96
|
+
* @throws ATEFileError - 파일 없음, 읽기 실패, JSON 파싱 실패
|
|
97
|
+
*/
|
|
98
|
+
export function readJson<T>(path: string): T {
|
|
99
|
+
try {
|
|
100
|
+
if (!existsSync(path)) {
|
|
101
|
+
throw new ATEFileError(
|
|
102
|
+
`파일을 찾을 수 없습니다: ${path}`,
|
|
103
|
+
"FILE_NOT_FOUND",
|
|
104
|
+
path,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const content = readFileSync(path, "utf8");
|
|
109
|
+
return JSON.parse(content) as T;
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
if (err instanceof ATEFileError) {
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
if (err.code === "EACCES") {
|
|
115
|
+
throw new ATEFileError(
|
|
116
|
+
`파일 읽기 권한이 없습니다: ${path}`,
|
|
117
|
+
"PERMISSION_DENIED",
|
|
118
|
+
path,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (err instanceof SyntaxError) {
|
|
122
|
+
throw new ATEFileError(
|
|
123
|
+
`잘못된 JSON 형식입니다: ${path}`,
|
|
124
|
+
"INVALID_JSON",
|
|
125
|
+
path,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
throw new ATEFileError(
|
|
129
|
+
`JSON 파일 읽기 실패: ${path} (${err.message})`,
|
|
130
|
+
err.code || "UNKNOWN",
|
|
131
|
+
path,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 파일 존재 여부 확인 (안전)
|
|
138
|
+
*/
|
|
139
|
+
export function fileExists(path: string): boolean {
|
|
140
|
+
try {
|
|
141
|
+
return existsSync(path);
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|