@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/ate",
3
- "version": "0.17.3",
3
+ "version": "0.18.0",
4
4
  "description": "Mandu ATE (Automation Test Engine) - extract/generate/run/report/heal/impact in one package",
5
5
  "type": "module",
6
6
  "sideEffects": false,
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(level: OracleLevel, routePath: string): { setup: string; assertions: string } {
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(`// L2: behavior signals (placeholder - extend per app)`);
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
- assertions.push(`// L3: domain hints (placeholder)`);
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 oracle = oracleTemplate(s.oracleLevel, s.route);
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: "route-smoke" | "api-smoke";
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[] = routes.map((r) => {
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
- return {
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
+ }