@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/src/oracle.ts ADDED
@@ -0,0 +1,152 @@
1
+ import type { OracleLevel } from "./types";
2
+ import { detectDomain, type AppDomain } from "./domain-detector";
3
+
4
+ export interface OracleResult {
5
+ level: OracleLevel;
6
+ l0: { ok: boolean; errors: string[] };
7
+ l1: { ok: boolean; signals: string[] };
8
+ l2: { ok: boolean; signals: string[] };
9
+ l3: { ok: boolean; notes: string[] };
10
+ }
11
+
12
+ export function createDefaultOracle(level: OracleLevel): OracleResult {
13
+ return {
14
+ level,
15
+ l0: { ok: true, errors: [] },
16
+ l1: { ok: level !== "L0", signals: [] },
17
+ l2: { ok: true, signals: [] },
18
+ l3: { ok: true, notes: [] },
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Generate L1 assertions based on detected domain
24
+ */
25
+ export function generateL1Assertions(domain: AppDomain, routePath: string): string[] {
26
+ const assertions: string[] = [];
27
+
28
+ // Common structural assertions for all domains
29
+ assertions.push(`// L1: Domain-aware structure signals (${domain})`);
30
+ assertions.push(`await expect(page.locator("main, [role='main']")).toBeVisible();`);
31
+
32
+ switch (domain) {
33
+ case "ecommerce":
34
+ if (routePath.includes("/cart")) {
35
+ assertions.push(`await expect(page.locator("[data-testid='cart-items'], .cart-item, [class*='cart']")).toBeVisible();`);
36
+ assertions.push(`await expect(page.locator("button:has-text('Checkout'), button:has-text('Proceed')")).toBeVisible();`);
37
+ } else if (routePath.includes("/product")) {
38
+ assertions.push(`await expect(page.locator("h1, [data-testid='product-title']")).toBeVisible();`);
39
+ assertions.push(`await expect(page.locator("button:has-text('Add to Cart'), button[class*='add']")).toBeVisible();`);
40
+ assertions.push(`await expect(page.locator("[data-testid='price'], .price, [class*='price']")).toBeVisible();`);
41
+ } else if (routePath.includes("/checkout")) {
42
+ assertions.push(`await expect(page.locator("form, [data-testid='checkout-form']")).toBeVisible();`);
43
+ assertions.push(`await expect(page.locator("input[type='email'], input[name*='email']")).toBeVisible();`);
44
+ } else if (routePath.includes("/shop")) {
45
+ assertions.push(`await expect(page.locator("[data-testid='product-card'], .product, [class*='product']")).toHaveCount({ min: 1 });`);
46
+ assertions.push(`await expect(page.locator("a[href*='/product'], [data-testid='product-link']")).toHaveCount({ min: 1 });`);
47
+ } else {
48
+ // Generic ecommerce page fallback
49
+ assertions.push(`await expect(page.locator("nav, [role='navigation']")).toBeVisible();`);
50
+ assertions.push(`await expect(page.locator("a, button")).toHaveCount({ min: 1 });`);
51
+ }
52
+ break;
53
+
54
+ case "blog":
55
+ if (routePath.includes("/post") || routePath.includes("/article")) {
56
+ assertions.push(`await expect(page.locator("article, [role='article']")).toBeVisible();`);
57
+ assertions.push(`await expect(page.locator("h1, [data-testid='post-title']")).toBeVisible();`);
58
+ assertions.push(`await expect(page.locator("[data-testid='post-content'], .content, [class*='content']")).toBeVisible();`);
59
+ } else if (routePath.includes("/author")) {
60
+ assertions.push(`await expect(page.locator("[data-testid='author-name'], .author")).toBeVisible();`);
61
+ assertions.push(`await expect(page.locator("[data-testid='author-posts'], .posts")).toBeVisible();`);
62
+ } else {
63
+ // Blog index/listing fallback
64
+ assertions.push(`await expect(page.locator("h1, h2")).toBeVisible();`);
65
+ assertions.push(`await expect(page.locator("a")).toHaveCount({ min: 1 });`);
66
+ }
67
+ break;
68
+
69
+ case "dashboard":
70
+ assertions.push(`await expect(page.locator("nav, [role='navigation'], aside, [data-testid='sidebar']")).toBeVisible();`);
71
+ if (routePath.includes("/analytics") || routePath.includes("/dashboard")) {
72
+ assertions.push(`await expect(page.locator("canvas, svg, [data-testid='chart']")).toHaveCount({ min: 1 });`);
73
+ assertions.push(`await expect(page.locator("[data-testid='metric'], .metric, [class*='stat']")).toHaveCount({ min: 1 });`);
74
+ } else if (routePath.includes("/settings")) {
75
+ assertions.push(`await expect(page.locator("form, [data-testid='settings-form']")).toBeVisible();`);
76
+ assertions.push(`await expect(page.locator("button:has-text('Save'), button[type='submit']")).toBeVisible();`);
77
+ } else {
78
+ // Generic dashboard page fallback
79
+ assertions.push(`await expect(page.locator("h1, h2")).toBeVisible();`);
80
+ assertions.push(`await expect(page.locator("a, button")).toHaveCount({ min: 1 });`);
81
+ }
82
+ break;
83
+
84
+ case "auth":
85
+ assertions.push(`await expect(page.locator("form, [data-testid='auth-form']")).toBeVisible();`);
86
+ if (routePath.includes("/login")) {
87
+ assertions.push(`await expect(page.locator("input[type='email'], input[name*='email']")).toBeVisible();`);
88
+ assertions.push(`await expect(page.locator("input[type='password']")).toBeVisible();`);
89
+ assertions.push(`await expect(page.locator("button:has-text('Login'), button:has-text('Sign in')")).toBeVisible();`);
90
+ } else if (routePath.includes("/signup") || routePath.includes("/register")) {
91
+ assertions.push(`await expect(page.locator("input[type='email'], input[name*='email']")).toBeVisible();`);
92
+ assertions.push(`await expect(page.locator("input[type='password']")).toBeVisible();`);
93
+ assertions.push(`await expect(page.locator("button:has-text('Sign up'), button:has-text('Register')")).toBeVisible();`);
94
+ } else if (routePath.includes("/forgot-password")) {
95
+ assertions.push(`await expect(page.locator("input[type='email'], input[name*='email']")).toBeVisible();`);
96
+ assertions.push(`await expect(page.locator("button:has-text('Reset'), button:has-text('Send')")).toBeVisible();`);
97
+ } else {
98
+ // Generic auth page fallback
99
+ assertions.push(`await expect(page.locator("input")).toHaveCount({ min: 1 });`);
100
+ assertions.push(`await expect(page.locator("button[type='submit'], button")).toHaveCount({ min: 1 });`);
101
+ }
102
+ break;
103
+
104
+ case "generic":
105
+ default:
106
+ // Generic fallback assertions
107
+ assertions.push(`await expect(page.locator("h1")).toBeVisible();`);
108
+ assertions.push(`await expect(page.locator("a, button")).toHaveCount({ min: 1 });`);
109
+ assertions.push(`await expect(page).toHaveTitle(/.+/);`);
110
+ break;
111
+ }
112
+
113
+ return assertions;
114
+ }
115
+
116
+ /**
117
+ * Upgrade L0 test code to L1 with domain-aware assertions
118
+ */
119
+ export function upgradeL0ToL1(testCode: string, routePath: string, sourceCode?: string): string {
120
+ const detection = detectDomain(routePath, sourceCode);
121
+ const l1Assertions = generateL1Assertions(detection.domain, routePath);
122
+
123
+ // Find the L0 error check assertion
124
+ const l0ErrorCheckRegex = /expect\(errors.*?\)\.toEqual\(\[\]\);/;
125
+ const match = testCode.match(l0ErrorCheckRegex);
126
+
127
+ if (!match) {
128
+ // If no L0 error check found, append L1 assertions before the closing braces
129
+ const closingBraceIndex = testCode.lastIndexOf("});");
130
+ if (closingBraceIndex === -1) return testCode;
131
+
132
+ const beforeClosing = testCode.slice(0, closingBraceIndex);
133
+ const afterClosing = testCode.slice(closingBraceIndex);
134
+
135
+ return `${beforeClosing}\n ${l1Assertions.join("\n ")}\n${afterClosing}`;
136
+ }
137
+
138
+ // Insert L1 assertions before the L0 error check
139
+ const insertIndex = match.index!;
140
+ const before = testCode.slice(0, insertIndex);
141
+ const after = testCode.slice(insertIndex);
142
+
143
+ return `${before}${l1Assertions.join("\n ")}\n ${after}`;
144
+ }
145
+
146
+ /**
147
+ * Get assertion count for a domain and route
148
+ */
149
+ export function getAssertionCount(domain: AppDomain, routePath: string): number {
150
+ const assertions = generateL1Assertions(domain, routePath);
151
+ return assertions.filter((a) => a.includes("expect(")).length;
152
+ }
@@ -0,0 +1,207 @@
1
+ import type { OracleLevel } from "./types";
2
+ import { ATEFileError } from "./fs";
3
+ import { ateExtract, ateGenerate, ateRun, ateReport, ateImpact, ateHeal } from "./index";
4
+
5
+ export interface AutoPipelineOptions {
6
+ repoRoot: string;
7
+ baseURL?: string;
8
+ oracleLevel?: OracleLevel;
9
+ ci?: boolean;
10
+ useImpactAnalysis?: boolean;
11
+ base?: string;
12
+ head?: string;
13
+ autoHeal?: boolean;
14
+ tsconfigPath?: string;
15
+ routeGlobs?: string[];
16
+ buildSalt?: string;
17
+ }
18
+
19
+ export interface AutoPipelineResult {
20
+ ok: boolean;
21
+ steps: {
22
+ extract: { ok: boolean; error?: string };
23
+ generate: { ok: boolean; error?: string };
24
+ impact?: { ok: boolean; mode: "full" | "subset"; selectedRoutes: string[]; error?: string };
25
+ run: { ok: boolean; runId: string; exitCode: number; error?: string };
26
+ report: { ok: boolean; summaryPath?: string; error?: string };
27
+ heal?: { ok: boolean; suggestionsCount: number; error?: string };
28
+ };
29
+ }
30
+
31
+ /**
32
+ * ATE 전체 파이프라인 자동 실행
33
+ * Extract → Generate → (Impact) → Run → Report → (Heal)
34
+ */
35
+ export async function runFullPipeline(options: AutoPipelineOptions): Promise<AutoPipelineResult> {
36
+ const result: AutoPipelineResult = {
37
+ ok: false,
38
+ steps: {
39
+ extract: { ok: false },
40
+ generate: { ok: false },
41
+ run: { ok: false, runId: "", exitCode: -1 },
42
+ report: { ok: false },
43
+ },
44
+ };
45
+
46
+ const oracleLevel = options.oracleLevel ?? "L1";
47
+
48
+ try {
49
+ // Step 1: Extract
50
+ console.log("📊 [ATE Pipeline] Step 1/5: Extract - 상호작용 그래프 추출 중...");
51
+ try {
52
+ await ateExtract({
53
+ repoRoot: options.repoRoot,
54
+ tsconfigPath: options.tsconfigPath,
55
+ routeGlobs: options.routeGlobs,
56
+ buildSalt: options.buildSalt,
57
+ });
58
+ result.steps.extract.ok = true;
59
+ console.log("✅ [ATE Pipeline] Extract 완료");
60
+ } catch (err: any) {
61
+ result.steps.extract.error = err.message;
62
+ console.error(`❌ [ATE Pipeline] Extract 실패: ${err.message}`);
63
+ return result;
64
+ }
65
+
66
+ // Step 2: Generate
67
+ console.log("🎬 [ATE Pipeline] Step 2/5: Generate - 시나리오 및 테스트 생성 중...");
68
+ try {
69
+ await ateGenerate({
70
+ repoRoot: options.repoRoot,
71
+ oracleLevel,
72
+ });
73
+ result.steps.generate.ok = true;
74
+ console.log("✅ [ATE Pipeline] Generate 완료");
75
+ } catch (err: any) {
76
+ result.steps.generate.error = err.message;
77
+ console.error(`❌ [ATE Pipeline] Generate 실패: ${err.message}`);
78
+ return result;
79
+ }
80
+
81
+ // Step 3 (Optional): Impact Analysis
82
+ let impactResult: { changedFiles: string[]; selectedRoutes: string[] } | undefined;
83
+ if (options.useImpactAnalysis) {
84
+ console.log("🔍 [ATE Pipeline] Step 3/5: Impact Analysis - 변경 영향 분석 중...");
85
+ try {
86
+ const impact = await ateImpact({
87
+ repoRoot: options.repoRoot,
88
+ base: options.base,
89
+ head: options.head,
90
+ });
91
+ result.steps.impact = {
92
+ ok: true,
93
+ mode: impact.selectedRoutes.length > 0 ? "subset" : "full",
94
+ selectedRoutes: impact.selectedRoutes,
95
+ };
96
+ impactResult = {
97
+ changedFiles: impact.changedFiles,
98
+ selectedRoutes: impact.selectedRoutes,
99
+ };
100
+ console.log(
101
+ `✅ [ATE Pipeline] Impact Analysis 완료 - ${impact.selectedRoutes.length}개 라우트 선택됨`,
102
+ );
103
+ } catch (err: any) {
104
+ result.steps.impact = {
105
+ ok: false,
106
+ mode: "full",
107
+ selectedRoutes: [],
108
+ error: err.message,
109
+ };
110
+ console.warn(`⚠️ [ATE Pipeline] Impact Analysis 실패, 전체 테스트 실행: ${err.message}`);
111
+ // Impact analysis 실패 시에도 계속 진행 (full test)
112
+ }
113
+ }
114
+
115
+ // Step 4: Run
116
+ console.log("🧪 [ATE Pipeline] Step 4/5: Run - Playwright 테스트 실행 중...");
117
+ let runId = "";
118
+ let exitCode = -1;
119
+ let startedAt = "";
120
+ let finishedAt = "";
121
+ try {
122
+ const runResult = await ateRun({
123
+ repoRoot: options.repoRoot,
124
+ baseURL: options.baseURL,
125
+ ci: options.ci,
126
+ });
127
+ runId = runResult.runId;
128
+ exitCode = runResult.exitCode;
129
+ startedAt = runResult.startedAt;
130
+ finishedAt = runResult.finishedAt;
131
+ result.steps.run = { ok: exitCode === 0, runId, exitCode };
132
+ console.log(
133
+ `${exitCode === 0 ? "✅" : "⚠️"} [ATE Pipeline] Run 완료 - exitCode: ${exitCode}`,
134
+ );
135
+ } catch (err: any) {
136
+ result.steps.run.error = err.message;
137
+ console.error(`❌ [ATE Pipeline] Run 실패: ${err.message}`);
138
+ return result;
139
+ }
140
+
141
+ // Step 5: Report
142
+ console.log("📝 [ATE Pipeline] Step 5/5: Report - 테스트 리포트 생성 중...");
143
+ try {
144
+ const reportResult = await ateReport({
145
+ repoRoot: options.repoRoot,
146
+ runId,
147
+ startedAt,
148
+ finishedAt,
149
+ exitCode,
150
+ oracleLevel,
151
+ impact: impactResult
152
+ ? {
153
+ mode: impactResult.selectedRoutes.length > 0 ? "subset" : "full",
154
+ changedFiles: impactResult.changedFiles,
155
+ selectedRoutes: impactResult.selectedRoutes,
156
+ }
157
+ : undefined,
158
+ });
159
+ result.steps.report = { ok: true, summaryPath: reportResult.summaryPath };
160
+ console.log(`✅ [ATE Pipeline] Report 완료 - ${reportResult.summaryPath}`);
161
+ } catch (err: any) {
162
+ result.steps.report.error = err.message;
163
+ console.error(`❌ [ATE Pipeline] Report 실패: ${err.message}`);
164
+ return result;
165
+ }
166
+
167
+ // Step 6 (Optional): Heal
168
+ if (options.autoHeal && exitCode !== 0) {
169
+ console.log("🔧 [ATE Pipeline] Step 6/6: Heal - 자동 복구 제안 생성 중...");
170
+ try {
171
+ const healResult = await ateHeal({
172
+ repoRoot: options.repoRoot,
173
+ runId,
174
+ });
175
+ result.steps.heal = {
176
+ ok: true,
177
+ suggestionsCount: healResult.suggestions?.length ?? 0,
178
+ };
179
+ console.log(
180
+ `✅ [ATE Pipeline] Heal 완료 - ${healResult.suggestions?.length ?? 0}개 제안 생성됨`,
181
+ );
182
+ } catch (err: any) {
183
+ result.steps.heal = {
184
+ ok: false,
185
+ suggestionsCount: 0,
186
+ error: err.message,
187
+ };
188
+ console.warn(`⚠️ [ATE Pipeline] Heal 실패: ${err.message}`);
189
+ // Heal 실패는 전체 파이프라인 실패로 보지 않음
190
+ }
191
+ }
192
+
193
+ // 최종 결과
194
+ result.ok = result.steps.extract.ok && result.steps.generate.ok && result.steps.report.ok;
195
+ console.log(
196
+ `\n${result.ok ? "✅" : "⚠️"} [ATE Pipeline] 파이프라인 완료 - 전체 성공: ${result.ok}`,
197
+ );
198
+
199
+ return result;
200
+ } catch (err: any) {
201
+ throw new ATEFileError(
202
+ `파이프라인 실행 중 예상치 못한 오류: ${err.message}`,
203
+ "PIPELINE_ERROR",
204
+ options.repoRoot,
205
+ );
206
+ }
207
+ }
package/src/report.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { join } from "node:path";
2
+ import { getAtePaths, ensureDir, writeJson } from "./fs";
3
+ import { createDefaultOracle } from "./oracle";
4
+ import type { SummaryJson, OracleLevel } from "./types";
5
+ import { generateHtmlReport, type HtmlReportOptions } from "./reporter/html";
6
+
7
+ export function composeSummary(params: {
8
+ repoRoot: string;
9
+ runId: string;
10
+ startedAt: string;
11
+ finishedAt: string;
12
+ exitCode: number;
13
+ oracleLevel: OracleLevel;
14
+ impact?: { changedFiles: string[]; selectedRoutes: string[]; mode: "full" | "subset" };
15
+ heal?: { suggestions: Array<{ kind: string; title: string; diff: string }> };
16
+ }): SummaryJson {
17
+ // Validate required params
18
+ if (!params.repoRoot) {
19
+ throw new Error("repoRoot는 필수입니다");
20
+ }
21
+ if (!params.runId) {
22
+ throw new Error("runId는 필수입니다");
23
+ }
24
+
25
+ const paths = getAtePaths(params.repoRoot);
26
+
27
+ let oracle;
28
+ try {
29
+ oracle = createDefaultOracle(params.oracleLevel);
30
+ } catch (err: any) {
31
+ throw new Error(`Oracle 생성 실패: ${err.message}`);
32
+ }
33
+
34
+ return {
35
+ schemaVersion: 1,
36
+ runId: params.runId,
37
+ startedAt: params.startedAt,
38
+ finishedAt: params.finishedAt,
39
+ ok: params.exitCode === 0,
40
+ oracle,
41
+ playwright: {
42
+ exitCode: params.exitCode,
43
+ reportDir: join(paths.reportsDir, params.runId),
44
+ jsonReportPath: join(paths.reportsDir, "latest", "playwright-report.json"),
45
+ junitPath: join(paths.reportsDir, "latest", "junit.xml"),
46
+ },
47
+ mandu: {
48
+ interactionGraphPath: paths.interactionGraphPath,
49
+ selectorMapPath: paths.selectorMapPath,
50
+ scenariosPath: paths.scenariosPath,
51
+ },
52
+ heal: {
53
+ attempted: true,
54
+ suggestions: params.heal?.suggestions ?? [],
55
+ },
56
+ impact: params.impact ?? {
57
+ mode: "full",
58
+ changedFiles: [],
59
+ selectedRoutes: [],
60
+ },
61
+ };
62
+ }
63
+
64
+ export function writeSummary(repoRoot: string, runId: string, summary: SummaryJson): string {
65
+ if (!repoRoot) {
66
+ throw new Error("repoRoot는 필수입니다");
67
+ }
68
+ if (!runId) {
69
+ throw new Error("runId는 필수입니다");
70
+ }
71
+
72
+ const paths = getAtePaths(repoRoot);
73
+ const runDir = join(paths.reportsDir, runId);
74
+
75
+ try {
76
+ ensureDir(runDir);
77
+ } catch (err: any) {
78
+ throw new Error(`Report 디렉토리 생성 실패: ${err.message}`);
79
+ }
80
+
81
+ const outPath = join(runDir, "summary.json");
82
+
83
+ try {
84
+ writeJson(outPath, summary);
85
+ } catch (err: any) {
86
+ throw new Error(`Summary 파일 저장 실패: ${err.message}`);
87
+ }
88
+
89
+ return outPath;
90
+ }
91
+
92
+ export type ReportFormat = "json" | "html" | "both";
93
+
94
+ export interface GenerateReportOptions {
95
+ repoRoot: string;
96
+ runId: string;
97
+ format?: ReportFormat;
98
+ includeScreenshots?: boolean;
99
+ includeTraces?: boolean;
100
+ }
101
+
102
+ export async function generateReport(options: GenerateReportOptions): Promise<{ json?: string; html?: string }> {
103
+ const { repoRoot, runId, format = "both", includeScreenshots = true, includeTraces = true } = options;
104
+
105
+ const result: { json?: string; html?: string } = {};
106
+
107
+ // JSON은 이미 writeSummary로 생성되었다고 가정
108
+ if (format === "json" || format === "both") {
109
+ const paths = getAtePaths(repoRoot);
110
+ result.json = join(paths.reportsDir, runId, "summary.json");
111
+ }
112
+
113
+ // HTML 생성
114
+ if (format === "html" || format === "both") {
115
+ try {
116
+ const htmlResult = await generateHtmlReport({
117
+ repoRoot,
118
+ runId,
119
+ includeScreenshots,
120
+ includeTraces,
121
+ });
122
+ result.html = htmlResult.path;
123
+ } catch (err: any) {
124
+ throw new Error(`HTML 리포트 생성 실패: ${err.message}`);
125
+ }
126
+ }
127
+
128
+ return result;
129
+ }