@mandujs/ate 0.17.3 → 0.18.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/package.json +1 -1
- package/src/codegen.ts +120 -13
- package/src/extractor.ts +19 -1
- package/src/index.ts +4 -1
- package/src/oracle.ts +85 -1
- package/src/pipeline.ts +1 -0
- package/src/runner.ts +8 -0
- package/src/scenario.ts +57 -6
- package/src/types.ts +3 -1
- package/src/unit-codegen.ts +83 -0
package/package.json
CHANGED
package/src/codegen.ts
CHANGED
|
@@ -2,16 +2,21 @@ import { join } from "node:path";
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { getAtePaths, ensureDir, readJson, writeJson } from "./fs";
|
|
4
4
|
import type { ScenarioBundle } from "./scenario";
|
|
5
|
-
import type { OracleLevel } from "./types";
|
|
5
|
+
import type { InteractionEdge, InteractionGraph, InteractionNode, OracleLevel } from "./types";
|
|
6
6
|
import { readSelectorMap, buildPlaywrightLocatorChain } from "./selector-map";
|
|
7
|
-
import { generateL1Assertions } from "./oracle";
|
|
7
|
+
import { generateL1Assertions, generateL2Assertions, generateL3Assertions } from "./oracle";
|
|
8
8
|
import { detectDomain } from "./domain-detector";
|
|
9
9
|
|
|
10
10
|
function specHeader(): string {
|
|
11
11
|
return `import { test, expect } from "@playwright/test";\n\n`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
function oracleTemplate(
|
|
14
|
+
function oracleTemplate(
|
|
15
|
+
level: OracleLevel,
|
|
16
|
+
routePath: string,
|
|
17
|
+
node?: InteractionNode,
|
|
18
|
+
edges?: InteractionEdge[],
|
|
19
|
+
): { setup: string; assertions: string } {
|
|
15
20
|
const setup: string[] = [];
|
|
16
21
|
const assertions: string[] = [];
|
|
17
22
|
|
|
@@ -22,17 +27,16 @@ function oracleTemplate(level: OracleLevel, routePath: string): { setup: string;
|
|
|
22
27
|
setup.push(`page.on("pageerror", (err) => errors.push(String(err)));`);
|
|
23
28
|
|
|
24
29
|
if (level === "L1" || level === "L2" || level === "L3") {
|
|
25
|
-
// Use domain-aware L1 assertions
|
|
26
30
|
const domain = detectDomain(routePath).domain;
|
|
27
31
|
const l1Assertions = generateL1Assertions(domain, routePath);
|
|
28
32
|
assertions.push(...l1Assertions);
|
|
29
33
|
}
|
|
30
|
-
if (level === "L2" || level === "L3") {
|
|
31
|
-
assertions.push(
|
|
32
|
-
assertions.push(`await expect(page).toHaveURL(/.*/);`);
|
|
34
|
+
if ((level === "L2" || level === "L3") && node) {
|
|
35
|
+
assertions.push(...generateL2Assertions(node));
|
|
33
36
|
}
|
|
34
|
-
if (level === "L3") {
|
|
35
|
-
|
|
37
|
+
if (level === "L3" && node) {
|
|
38
|
+
const nodeEdges = (edges ?? []).filter(e => "from" in e && e.from === (node.kind === "route" ? node.path : ""));
|
|
39
|
+
assertions.push(...generateL3Assertions(node, nodeEdges));
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
assertions.push(`expect(errors, "console/page errors").toEqual([]);`);
|
|
@@ -56,6 +60,14 @@ export function generatePlaywrightSpecs(repoRoot: string, opts?: { onlyRoutes?:
|
|
|
56
60
|
return { files: [], warnings };
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
// Load interaction graph for L2/L3 node metadata
|
|
64
|
+
let graph: InteractionGraph | undefined;
|
|
65
|
+
try {
|
|
66
|
+
graph = readJson<InteractionGraph>(paths.interactionGraphPath);
|
|
67
|
+
} catch {
|
|
68
|
+
// Graph is optional; L2/L3 will degrade gracefully without node metadata
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
let selectorMap;
|
|
60
72
|
try {
|
|
61
73
|
selectorMap = readSelectorMap(repoRoot);
|
|
@@ -83,9 +95,10 @@ export function generatePlaywrightSpecs(repoRoot: string, opts?: { onlyRoutes?:
|
|
|
83
95
|
if (s.kind === "api-smoke") {
|
|
84
96
|
// API route: fetch-based test
|
|
85
97
|
const methods = s.methods ?? ["GET"];
|
|
98
|
+
const apiNode = graph?.nodes.find(n => n.kind === "route" && n.path === s.route);
|
|
86
99
|
const testCases = methods.map((method) => {
|
|
87
100
|
return [
|
|
88
|
-
` test(${JSON.stringify(`${method} ${s.route}`)}, async ({ baseURL }) => {`,
|
|
101
|
+
` test(${JSON.stringify(`${method} ${s.route}`)}, async ({ request, baseURL }) => {`,
|
|
89
102
|
` const url = (baseURL ?? "http://localhost:3333") + ${JSON.stringify(s.route)};`,
|
|
90
103
|
` const res = await fetch(url, { method: ${JSON.stringify(method)} });`,
|
|
91
104
|
` expect(res.status).toBeLessThan(500);`,
|
|
@@ -95,16 +108,110 @@ export function generatePlaywrightSpecs(repoRoot: string, opts?: { onlyRoutes?:
|
|
|
95
108
|
].filter(Boolean).join("\n");
|
|
96
109
|
});
|
|
97
110
|
|
|
111
|
+
// L2/L3 assertions for API routes
|
|
112
|
+
const apiOracleTests: string[] = [];
|
|
113
|
+
if ((s.oracleLevel === "L2" || s.oracleLevel === "L3") && apiNode) {
|
|
114
|
+
const l2 = generateL2Assertions(apiNode);
|
|
115
|
+
if (l2.length > 0) {
|
|
116
|
+
apiOracleTests.push([
|
|
117
|
+
` test(${JSON.stringify(`L2 contract: ${s.route}`)}, async ({ request }) => {`,
|
|
118
|
+
...l2.map(line => ` ${line}`),
|
|
119
|
+
` });`,
|
|
120
|
+
].join("\n"));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (s.oracleLevel === "L3" && apiNode) {
|
|
124
|
+
const l3 = generateL3Assertions(apiNode, graph?.edges ?? []);
|
|
125
|
+
if (l3.length > 0) {
|
|
126
|
+
apiOracleTests.push([
|
|
127
|
+
` test(${JSON.stringify(`L3 behavior: ${s.route}`)}, async ({ request }) => {`,
|
|
128
|
+
...l3.map(line => ` ${line}`),
|
|
129
|
+
` });`,
|
|
130
|
+
].join("\n"));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
98
134
|
code = [
|
|
99
135
|
specHeader(),
|
|
100
136
|
`test.describe(${JSON.stringify(s.id)}, () => {`,
|
|
101
137
|
...testCases,
|
|
138
|
+
...apiOracleTests,
|
|
139
|
+
`});`,
|
|
140
|
+
"",
|
|
141
|
+
].join("\n");
|
|
142
|
+
} else if (s.kind === "ssr-verify") {
|
|
143
|
+
// SSR output verification
|
|
144
|
+
const routeUrl = s.route === "/" ? "/" : s.route;
|
|
145
|
+
const lines: string[] = [
|
|
146
|
+
specHeader(),
|
|
147
|
+
`test.describe(${JSON.stringify(s.id)}, () => {`,
|
|
148
|
+
` test(${JSON.stringify(`ssr-verify ${s.route}`)}, async ({ page, baseURL }) => {`,
|
|
149
|
+
` const url = (baseURL ?? "http://localhost:3333") + ${JSON.stringify(routeUrl)};`,
|
|
150
|
+
` await page.goto(url);`,
|
|
151
|
+
` const html = await page.content();`,
|
|
152
|
+
` expect(html).toContain("<!DOCTYPE html>");`,
|
|
153
|
+
` expect(html).toContain("<html");`,
|
|
154
|
+
];
|
|
155
|
+
if (!s.hasIsland) {
|
|
156
|
+
lines.push(` expect(html).not.toContain("data-mandu-island");`);
|
|
157
|
+
}
|
|
158
|
+
lines.push(` });`, `});`, "");
|
|
159
|
+
code = lines.join("\n");
|
|
160
|
+
} else if (s.kind === "island-hydration") {
|
|
161
|
+
// Island hydration verification
|
|
162
|
+
const routeUrl = s.route === "/" ? "/" : s.route;
|
|
163
|
+
code = [
|
|
164
|
+
specHeader(),
|
|
165
|
+
`test.describe(${JSON.stringify(s.id)}, () => {`,
|
|
166
|
+
` test(${JSON.stringify(`island-hydration ${s.route}`)}, async ({ page, baseURL }) => {`,
|
|
167
|
+
` const url = (baseURL ?? "http://localhost:3333") + ${JSON.stringify(routeUrl)};`,
|
|
168
|
+
` await page.goto(url);`,
|
|
169
|
+
` await page.waitForSelector("[data-mandu-island]", { timeout: 5000 });`,
|
|
170
|
+
` const count = await page.locator("[data-mandu-island]").count();`,
|
|
171
|
+
` expect(count).toBeGreaterThan(0);`,
|
|
172
|
+
` });`,
|
|
173
|
+
`});`,
|
|
174
|
+
"",
|
|
175
|
+
].join("\n");
|
|
176
|
+
} else if (s.kind === "sse-stream") {
|
|
177
|
+
// SSE streaming test
|
|
178
|
+
code = [
|
|
179
|
+
specHeader(),
|
|
180
|
+
`test.describe(${JSON.stringify(s.id)}, () => {`,
|
|
181
|
+
` test(${JSON.stringify(`sse-stream ${s.route}`)}, async ({ baseURL }) => {`,
|
|
182
|
+
` const url = (baseURL ?? "http://localhost:3333") + ${JSON.stringify(s.route)};`,
|
|
183
|
+
` const res = await fetch(url, { headers: { Accept: "text/event-stream" } });`,
|
|
184
|
+
` expect(res.status).toBeLessThan(500);`,
|
|
185
|
+
` const ct = res.headers.get("content-type") ?? "";`,
|
|
186
|
+
` expect(ct).toContain("text/event-stream");`,
|
|
187
|
+
` const body = await res.text();`,
|
|
188
|
+
` expect(body.length).toBeGreaterThan(0);`,
|
|
189
|
+
` });`,
|
|
190
|
+
`});`,
|
|
191
|
+
"",
|
|
192
|
+
].join("\n");
|
|
193
|
+
} else if (s.kind === "form-action") {
|
|
194
|
+
// Form action test (POST with _action)
|
|
195
|
+
code = [
|
|
196
|
+
specHeader(),
|
|
197
|
+
`test.describe(${JSON.stringify(s.id)}, () => {`,
|
|
198
|
+
` test(${JSON.stringify(`form-action ${s.route}`)}, async ({ baseURL }) => {`,
|
|
199
|
+
` const url = (baseURL ?? "http://localhost:3333") + ${JSON.stringify(s.route)};`,
|
|
200
|
+
` const res = await fetch(url, {`,
|
|
201
|
+
` method: "POST",`,
|
|
202
|
+
` headers: { "Content-Type": "application/x-www-form-urlencoded" },`,
|
|
203
|
+
` body: "_action=default",`,
|
|
204
|
+
` });`,
|
|
205
|
+
` expect(res.status).toBeLessThan(500);`,
|
|
206
|
+
` expect(res.headers.get("content-type")).toBeTruthy();`,
|
|
207
|
+
` });`,
|
|
102
208
|
`});`,
|
|
103
209
|
"",
|
|
104
210
|
].join("\n");
|
|
105
211
|
} else {
|
|
106
|
-
// Page route: browser-based test
|
|
107
|
-
const
|
|
212
|
+
// Page route: browser-based test (route-smoke)
|
|
213
|
+
const graphNode = graph?.nodes.find(n => n.kind === "route" && n.path === s.route);
|
|
214
|
+
const oracle = oracleTemplate(s.oracleLevel, s.route, graphNode, graph?.edges);
|
|
108
215
|
|
|
109
216
|
// Generate selector examples if selector map exists
|
|
110
217
|
let selectorExamples = "";
|
|
@@ -117,7 +224,7 @@ export function generatePlaywrightSpecs(repoRoot: string, opts?: { onlyRoutes?:
|
|
|
117
224
|
code = [
|
|
118
225
|
specHeader(),
|
|
119
226
|
`test.describe(${JSON.stringify(s.id)}, () => {`,
|
|
120
|
-
` test(${JSON.stringify(`smoke ${s.route}`)}, async ({ page, baseURL }) => {`,
|
|
227
|
+
` test(${JSON.stringify(`smoke ${s.route}`)}, async ({ page, request, baseURL }) => {`,
|
|
121
228
|
` const url = (baseURL ?? "http://localhost:3333") + ${JSON.stringify(s.route === "/" ? "/" : s.route)};`,
|
|
122
229
|
` ${oracle.setup.split("\n").join("\n ")}`,
|
|
123
230
|
` await page.goto(url);`,
|
package/src/extractor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fg from "fast-glob";
|
|
2
|
-
import { relative, join } from "node:path";
|
|
2
|
+
import { relative, join, dirname } from "node:path";
|
|
3
3
|
import { createEmptyGraph, addEdge, addNode } from "./ir";
|
|
4
4
|
import { getAtePaths, writeJson } from "./fs";
|
|
5
5
|
import type { ExtractInput, InteractionGraph } from "./types";
|
|
@@ -89,6 +89,8 @@ export async function extract(input: ExtractInput): Promise<{ ok: true; graphPat
|
|
|
89
89
|
|
|
90
90
|
// API route: extract HTTP methods from exports (GET, POST, PUT, PATCH, DELETE)
|
|
91
91
|
let methods: string[] = [];
|
|
92
|
+
let hasSse = false;
|
|
93
|
+
let hasAction = false;
|
|
92
94
|
if (isApiRoute) {
|
|
93
95
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
94
96
|
const exportDecls = sourceFile.getExportedDeclarations();
|
|
@@ -98,14 +100,30 @@ export async function extract(input: ExtractInput): Promise<{ ok: true; graphPat
|
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
if (methods.length === 0) methods = ["GET"]; // default
|
|
103
|
+
|
|
104
|
+
// Detect SSE and _action patterns from source text
|
|
105
|
+
const sourceText = sourceFile.getFullText();
|
|
106
|
+
hasSse = /ctx\.sse|text\/event-stream|EventSource|new\s+ReadableStream/.test(sourceText);
|
|
107
|
+
hasAction = methods.includes("POST") && /_action/.test(sourceText);
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
// Detect companion island and contract files
|
|
111
|
+
const routeDir = dirname(filePath);
|
|
112
|
+
const hasIsland = [".island.tsx", ".island.ts", ".client.tsx", ".client.ts"]
|
|
113
|
+
.some(ext => fg.sync(`*${ext}`, { cwd: routeDir, onlyFiles: true }).length > 0);
|
|
114
|
+
const hasContract = [".contract.ts", ".contract.tsx"]
|
|
115
|
+
.some(ext => fg.sync(`*${ext}`, { cwd: routeDir, onlyFiles: true }).length > 0);
|
|
116
|
+
|
|
103
117
|
addNode(graph, {
|
|
104
118
|
kind: "route",
|
|
105
119
|
id: routePath === "" ? "/" : routePath,
|
|
106
120
|
file: relNormalized,
|
|
107
121
|
path: routePath === "" ? "/" : routePath,
|
|
108
122
|
...(isApiRoute ? { methods } : {}),
|
|
123
|
+
...(hasIsland ? { hasIsland: true } : {}),
|
|
124
|
+
...(hasContract ? { hasContract: true } : {}),
|
|
125
|
+
...(hasSse ? { hasSse: true } : {}),
|
|
126
|
+
...(hasAction ? { hasAction: true } : {}),
|
|
109
127
|
});
|
|
110
128
|
|
|
111
129
|
// API route에는 JSX/navigation이 없으므로 건너뜀
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { ATEFileError, ensureDir, readJson, writeJson, fileExists, getAtePaths }
|
|
|
4
4
|
|
|
5
5
|
export { extract } from "./extractor";
|
|
6
6
|
export { generateAndWriteScenarios } from "./scenario";
|
|
7
|
+
export type { ScenarioKind, GeneratedScenario, ScenarioBundle } from "./scenario";
|
|
7
8
|
export { generatePlaywrightSpecs } from "./codegen";
|
|
8
9
|
export { runPlaywright } from "./runner";
|
|
9
10
|
export { composeSummary, writeSummary, generateReport } from "./report";
|
|
@@ -20,6 +21,8 @@ export type {
|
|
|
20
21
|
FailureCategory,
|
|
21
22
|
} from "./heal";
|
|
22
23
|
export { computeImpact } from "./impact";
|
|
24
|
+
export { generateUnitSpec, generateUnitSpecs } from "./unit-codegen";
|
|
25
|
+
export type { UnitCodegenResult } from "./unit-codegen";
|
|
23
26
|
export * from "./selector-map";
|
|
24
27
|
export { parseTrace, generateAlternativeSelectors } from "./trace-parser";
|
|
25
28
|
export type { TraceAction, FailedLocator, TraceParseResult } from "./trace-parser";
|
|
@@ -27,7 +30,7 @@ export type { TraceAction, FailedLocator, TraceParseResult } from "./trace-parse
|
|
|
27
30
|
// Oracle and domain detection
|
|
28
31
|
export { detectDomain, detectDomainFromRoute, detectDomainFromSource } from "./domain-detector";
|
|
29
32
|
export type { AppDomain, DomainDetectionResult } from "./domain-detector";
|
|
30
|
-
export { generateL1Assertions, upgradeL0ToL1, getAssertionCount, createDefaultOracle } from "./oracle";
|
|
33
|
+
export { generateL1Assertions, generateL2Assertions, generateL3Assertions, upgradeL0ToL1, getAssertionCount, createDefaultOracle } from "./oracle";
|
|
31
34
|
export type { OracleResult } from "./oracle";
|
|
32
35
|
|
|
33
36
|
import type { ExtractInput, GenerateInput, RunInput, ImpactInput, HealInput, OracleLevel } from "./types";
|
package/src/oracle.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OracleLevel } from "./types";
|
|
1
|
+
import type { InteractionNode, OracleLevel } from "./types";
|
|
2
2
|
import { detectDomain, type AppDomain } from "./domain-detector";
|
|
3
3
|
|
|
4
4
|
export interface OracleResult {
|
|
@@ -150,3 +150,87 @@ export function getAssertionCount(domain: AppDomain, routePath: string): number
|
|
|
150
150
|
const assertions = generateL1Assertions(domain, routePath);
|
|
151
151
|
return assertions.filter((a) => a.includes("expect(")).length;
|
|
152
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Generate L2 assertions: contract schema validation and SSR data verification
|
|
156
|
+
*/
|
|
157
|
+
export function generateL2Assertions(node: InteractionNode): string[] {
|
|
158
|
+
if (node.kind !== "route") return [];
|
|
159
|
+
const assertions: string[] = [];
|
|
160
|
+
const isApi = node.path.startsWith("/api/") || (node.methods && node.methods.length > 0);
|
|
161
|
+
|
|
162
|
+
if (isApi) {
|
|
163
|
+
assertions.push(`// L2: API contract validation`);
|
|
164
|
+
assertions.push(`const response = await request.get("${node.path}");`);
|
|
165
|
+
assertions.push(`expect(response.status()).toBeLessThan(500);`);
|
|
166
|
+
assertions.push(`const contentType = response.headers()["content-type"] ?? "";`);
|
|
167
|
+
assertions.push(`expect(contentType).toContain("application/json");`);
|
|
168
|
+
assertions.push(`const responseBody = await response.json();`);
|
|
169
|
+
assertions.push(`expect(responseBody).toBeDefined();`);
|
|
170
|
+
// Edge case: malformed body on POST endpoints
|
|
171
|
+
if (node.methods?.includes("POST") || node.methods?.includes("PUT")) {
|
|
172
|
+
assertions.push(`// Edge case: reject empty body on mutation endpoint`);
|
|
173
|
+
assertions.push(`const badResponse = await request.${node.methods.includes("POST") ? "post" : "put"}("${node.path}", { data: {} });`);
|
|
174
|
+
assertions.push(`expect(badResponse.status()).toBeGreaterThanOrEqual(400);`);
|
|
175
|
+
assertions.push(`expect(badResponse.status()).toBeLessThan(500);`);
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
// Page route: verify SSR data injection
|
|
179
|
+
assertions.push(`// L2: SSR data injection verification`);
|
|
180
|
+
assertions.push(`const manduDataEl = page.locator("#__MANDU_DATA__");`);
|
|
181
|
+
assertions.push(`const dataCount = await manduDataEl.count();`);
|
|
182
|
+
assertions.push(`if (dataCount > 0) {`);
|
|
183
|
+
assertions.push(` const raw = await manduDataEl.textContent();`);
|
|
184
|
+
assertions.push(` expect(() => JSON.parse(raw!)).not.toThrow();`);
|
|
185
|
+
assertions.push(`}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return assertions;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Generate L3 assertions: behavioral verification (state changes, island hydration, navigation)
|
|
193
|
+
*/
|
|
194
|
+
export function generateL3Assertions(node: InteractionNode, edges: { kind: string; to?: string }[]): string[] {
|
|
195
|
+
if (node.kind !== "route") return [];
|
|
196
|
+
const assertions: string[] = [];
|
|
197
|
+
const isApi = node.path.startsWith("/api/") || (node.methods && node.methods.length > 0);
|
|
198
|
+
|
|
199
|
+
if (isApi && node.methods?.includes("POST")) {
|
|
200
|
+
assertions.push(`// L3: POST state change verification`);
|
|
201
|
+
assertions.push(`const beforeRes = await request.get("${node.path}");`);
|
|
202
|
+
assertions.push(`const beforeStatus = beforeRes.status();`);
|
|
203
|
+
assertions.push(`if (beforeStatus < 400) {`);
|
|
204
|
+
assertions.push(` const beforeBody = await beforeRes.json();`);
|
|
205
|
+
assertions.push(` const beforeCount = Array.isArray(beforeBody) ? beforeBody.length : 0;`);
|
|
206
|
+
assertions.push(` await request.post("${node.path}", { data: { _ate: true } });`);
|
|
207
|
+
assertions.push(` const afterBody = await (await request.get("${node.path}")).json();`);
|
|
208
|
+
assertions.push(` const afterCount = Array.isArray(afterBody) ? afterBody.length : 0;`);
|
|
209
|
+
assertions.push(` expect(afterCount).toBeGreaterThanOrEqual(beforeCount);`);
|
|
210
|
+
assertions.push(`}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!isApi && node.hasIsland) {
|
|
214
|
+
assertions.push(`// L3: Island hydration verification`);
|
|
215
|
+
assertions.push(`const islands = page.locator("[data-mandu-island]");`);
|
|
216
|
+
assertions.push(`const islandCount = await islands.count();`);
|
|
217
|
+
assertions.push(`if (islandCount > 0) {`);
|
|
218
|
+
assertions.push(` await expect(islands.first()).toBeVisible();`);
|
|
219
|
+
assertions.push(` // Verify island has been hydrated (script loaded)`);
|
|
220
|
+
assertions.push(` const hydrated = await page.evaluate(() => typeof window.__MANDU_ISLANDS__ === "object");`);
|
|
221
|
+
assertions.push(` expect(hydrated).toBe(true);`);
|
|
222
|
+
assertions.push(`}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Navigation flow: verify that outgoing links resolve to valid pages
|
|
226
|
+
const navTargets = edges.filter(e => e.kind === "navigate" && e.to).slice(0, 3);
|
|
227
|
+
if (!isApi && navTargets.length > 0) {
|
|
228
|
+
assertions.push(`// L3: Navigation flow verification`);
|
|
229
|
+
for (const nav of navTargets) {
|
|
230
|
+
assertions.push(`const navRes_${nav.to!.replace(/[^a-zA-Z0-9]/g, "_")} = await request.get("${nav.to}");`);
|
|
231
|
+
assertions.push(`expect(navRes_${nav.to!.replace(/[^a-zA-Z0-9]/g, "_")}.status()).toBeLessThan(500);`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return assertions;
|
|
236
|
+
}
|
package/src/pipeline.ts
CHANGED
|
@@ -126,6 +126,7 @@ export async function runFullPipeline(options: AutoPipelineOptions): Promise<Aut
|
|
|
126
126
|
repoRoot: options.repoRoot,
|
|
127
127
|
baseURL: options.baseURL,
|
|
128
128
|
ci: options.ci,
|
|
129
|
+
onlyRoutes: impactResult?.selectedRoutes,
|
|
129
130
|
});
|
|
130
131
|
runId = runResult.runId;
|
|
131
132
|
exitCode = runResult.exitCode;
|
package/src/runner.ts
CHANGED
|
@@ -56,6 +56,14 @@ export async function runPlaywright(input: RunInput): Promise<RunResult> {
|
|
|
56
56
|
"tests/e2e/playwright.config.ts",
|
|
57
57
|
];
|
|
58
58
|
|
|
59
|
+
// Route filtering: pass --grep to Playwright so only matching specs run
|
|
60
|
+
if (input.onlyRoutes && input.onlyRoutes.length > 0) {
|
|
61
|
+
const grepPattern = input.onlyRoutes
|
|
62
|
+
.map((r) => r.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
63
|
+
.join("|");
|
|
64
|
+
args.push("--grep", grepPattern);
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
const env = {
|
|
60
68
|
...process.env,
|
|
61
69
|
CI: input.ci ? "true" : process.env.CI,
|
package/src/scenario.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { InteractionGraph, OracleLevel } from "./types";
|
|
2
2
|
import { getAtePaths, readJson, writeJson } from "./fs";
|
|
3
3
|
|
|
4
|
+
export type ScenarioKind = "route-smoke" | "api-smoke" | "ssr-verify" | "island-hydration" | "sse-stream" | "form-action";
|
|
5
|
+
|
|
4
6
|
export interface GeneratedScenario {
|
|
5
7
|
id: string;
|
|
6
|
-
kind:
|
|
8
|
+
kind: ScenarioKind;
|
|
7
9
|
route: string;
|
|
8
10
|
methods?: string[];
|
|
11
|
+
hasIsland?: boolean;
|
|
9
12
|
oracleLevel: OracleLevel;
|
|
10
13
|
}
|
|
11
14
|
|
|
@@ -29,22 +32,70 @@ export function generateScenariosFromGraph(graph: InteractionGraph, oracleLevel:
|
|
|
29
32
|
throw new Error("빈 interaction graph입니다 (nodes가 없습니다)");
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
const routes = graph.nodes.filter((n) => n.kind === "route") as Array<{ kind: "route"; id: string; path: string; methods?: string[] }>;
|
|
35
|
+
const routes = graph.nodes.filter((n) => n.kind === "route") as Array<{ kind: "route"; id: string; path: string; methods?: string[]; hasIsland?: boolean; hasSse?: boolean; hasAction?: boolean }>;
|
|
33
36
|
|
|
34
37
|
if (routes.length === 0) {
|
|
35
38
|
console.warn("[ATE] 경고: route가 없습니다. 빈 시나리오 번들을 생성합니다.");
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
const scenarios: GeneratedScenario[] =
|
|
41
|
+
const scenarios: GeneratedScenario[] = [];
|
|
42
|
+
|
|
43
|
+
for (const r of routes) {
|
|
39
44
|
const isApi = r.path.startsWith("/api/") || (r.methods && r.methods.length > 0);
|
|
40
|
-
|
|
45
|
+
|
|
46
|
+
// Baseline smoke test for every route
|
|
47
|
+
scenarios.push({
|
|
41
48
|
id: `${isApi ? "api" : "route"}:${r.id}`,
|
|
42
49
|
kind: isApi ? "api-smoke" : "route-smoke",
|
|
43
50
|
route: r.id,
|
|
44
51
|
...(isApi && r.methods ? { methods: r.methods } : {}),
|
|
45
52
|
oracleLevel,
|
|
46
|
-
};
|
|
47
|
-
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!isApi) {
|
|
56
|
+
// SSR verification for all page routes
|
|
57
|
+
scenarios.push({
|
|
58
|
+
id: `${r.id}--ssr-verify`,
|
|
59
|
+
kind: "ssr-verify",
|
|
60
|
+
route: r.id,
|
|
61
|
+
hasIsland: !!r.hasIsland,
|
|
62
|
+
oracleLevel,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Island hydration for pages with islands
|
|
66
|
+
if (r.hasIsland) {
|
|
67
|
+
scenarios.push({
|
|
68
|
+
id: `${r.id}--island-hydration`,
|
|
69
|
+
kind: "island-hydration",
|
|
70
|
+
route: r.id,
|
|
71
|
+
oracleLevel,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (isApi) {
|
|
77
|
+
// SSE stream test for API routes with SSE
|
|
78
|
+
if (r.hasSse) {
|
|
79
|
+
scenarios.push({
|
|
80
|
+
id: `${r.id}--sse-stream`,
|
|
81
|
+
kind: "sse-stream",
|
|
82
|
+
route: r.id,
|
|
83
|
+
oracleLevel,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Form action test for API routes with POST + _action
|
|
88
|
+
if (r.hasAction) {
|
|
89
|
+
scenarios.push({
|
|
90
|
+
id: `${r.id}--form-action`,
|
|
91
|
+
kind: "form-action",
|
|
92
|
+
route: r.id,
|
|
93
|
+
methods: r.methods,
|
|
94
|
+
oracleLevel,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
48
99
|
|
|
49
100
|
return {
|
|
50
101
|
schemaVersion: 1,
|
package/src/types.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface InteractionGraph {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export type InteractionNode =
|
|
39
|
-
| { kind: "route"; id: string; file: string; path: string; methods?: string[] }
|
|
39
|
+
| { kind: "route"; id: string; file: string; path: string; methods?: string[]; hasIsland?: boolean; hasContract?: boolean; hasSse?: boolean; hasAction?: boolean }
|
|
40
40
|
| { kind: "modal"; id: string; file: string; name: string }
|
|
41
41
|
| { kind: "action"; id: string; file: string; name: string };
|
|
42
42
|
|
|
@@ -57,6 +57,8 @@ export interface RunInput {
|
|
|
57
57
|
ci?: boolean;
|
|
58
58
|
headless?: boolean;
|
|
59
59
|
browsers?: ("chromium" | "firefox" | "webkit")[];
|
|
60
|
+
/** Filter test execution to specs matching these route paths (e.g. ["/api/users", "/dashboard"]) */
|
|
61
|
+
onlyRoutes?: string[];
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
export interface ImpactInput {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import { ensureDir, getAtePaths, readJson } from "./fs";
|
|
4
|
+
import type { InteractionGraph, InteractionNode } from "./types";
|
|
5
|
+
|
|
6
|
+
type RouteNode = Extract<InteractionNode, { kind: "route" }>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate a bun:test unit spec for a single route node using testFilling.
|
|
10
|
+
*/
|
|
11
|
+
export function generateUnitSpec(route: RouteNode): string {
|
|
12
|
+
const lines: string[] = [];
|
|
13
|
+
lines.push(`import { testFilling } from "@mandujs/core/testing";`);
|
|
14
|
+
lines.push(`import { describe, it, expect } from "bun:test";`);
|
|
15
|
+
lines.push(`import route from "${route.file}";`);
|
|
16
|
+
lines.push(``);
|
|
17
|
+
lines.push(`describe("${route.id}", () => {`);
|
|
18
|
+
|
|
19
|
+
const methods = route.methods ?? ["GET"];
|
|
20
|
+
|
|
21
|
+
if (methods.includes("GET")) {
|
|
22
|
+
lines.push(` it("GET returns 200", async () => {`);
|
|
23
|
+
lines.push(` const res = await testFilling(route, { method: "GET" });`);
|
|
24
|
+
lines.push(` expect(res.status).toBe(200);`);
|
|
25
|
+
lines.push(` });`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (methods.includes("POST")) {
|
|
29
|
+
lines.push(` it("POST with valid body returns 200/201", async () => {`);
|
|
30
|
+
lines.push(` const res = await testFilling(route, { method: "POST", body: {} });`);
|
|
31
|
+
lines.push(` expect([200, 201]).toContain(res.status);`);
|
|
32
|
+
lines.push(` });`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
lines.push(`});`);
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UnitCodegenResult {
|
|
40
|
+
files: string[];
|
|
41
|
+
warnings: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generate bun:test unit specs for all route nodes in the interaction graph.
|
|
46
|
+
* Writes files to tests/unit/auto/ under the repo root.
|
|
47
|
+
*/
|
|
48
|
+
export function generateUnitSpecs(repoRoot: string, opts?: { onlyRoutes?: string[] }): UnitCodegenResult {
|
|
49
|
+
const paths = getAtePaths(repoRoot);
|
|
50
|
+
const warnings: string[] = [];
|
|
51
|
+
|
|
52
|
+
let graph: InteractionGraph;
|
|
53
|
+
try {
|
|
54
|
+
graph = readJson<InteractionGraph>(paths.interactionGraphPath);
|
|
55
|
+
} catch (err: unknown) {
|
|
56
|
+
throw new Error(`Interaction graph read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const routes = graph.nodes.filter((n): n is RouteNode => n.kind === "route");
|
|
60
|
+
if (routes.length === 0) {
|
|
61
|
+
warnings.push("No route nodes found in interaction graph");
|
|
62
|
+
return { files: [], warnings };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const outDir = join(repoRoot, "tests", "unit", "auto");
|
|
66
|
+
ensureDir(outDir);
|
|
67
|
+
|
|
68
|
+
const files: string[] = [];
|
|
69
|
+
for (const route of routes) {
|
|
70
|
+
if (opts?.onlyRoutes?.length && !opts.onlyRoutes.includes(route.id)) continue;
|
|
71
|
+
|
|
72
|
+
const safeId = route.id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
73
|
+
const filePath = join(outDir, `${safeId}.test.ts`);
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(filePath, generateUnitSpec(route), "utf8");
|
|
76
|
+
files.push(filePath);
|
|
77
|
+
} catch (err: unknown) {
|
|
78
|
+
warnings.push(`Failed to write ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { files, warnings };
|
|
83
|
+
}
|