@elench/testkit 0.1.62 → 0.1.63

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.
@@ -5,6 +5,8 @@ import {
5
5
  apiRouteLookupKey,
6
6
  HTTP_METHODS,
7
7
  dedupeTargets,
8
+ findMatchingApiRouteEntry,
9
+ findMatchingRouteValue,
8
10
  toApiRequestPath,
9
11
  toSelectionType,
10
12
  } from "./shared.mjs";
@@ -22,6 +24,12 @@ function readTestFileContent(entry, context) {
22
24
  return fs.readFileSync(absolutePath, "utf8");
23
25
  }
24
26
 
27
+ function resolveApiRouteEntry(context, method, requestPath) {
28
+ const exact = context.apiRouteByKey.get(apiRouteLookupKey(method, requestPath));
29
+ if (exact) return exact;
30
+ return findMatchingApiRouteEntry(method, requestPath, context.apiRouteEntries || []);
31
+ }
32
+
25
33
  export function inferCoveredNodeIdsForTest(entry, context) {
26
34
  const coveredNodeIds = new Set();
27
35
  const content = readTestFileContent(entry, context);
@@ -33,7 +41,10 @@ export function inferCoveredNodeIdsForTest(entry, context) {
33
41
  ? gotoRoutes
34
42
  : (() => { const r = inferPageRouteFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot); return r ? [r] : []; })();
35
43
  for (const route of routes) {
36
- const pageEntry = context.pageByRoute.get(route);
44
+ const matchedRoute = context.pageByRoute.get(route)
45
+ ? route
46
+ : findMatchingRouteValue(route, [...context.pageByRoute.keys()]);
47
+ const pageEntry = matchedRoute ? context.pageByRoute.get(matchedRoute) : null;
37
48
  if (!pageEntry) continue;
38
49
  coveredNodeIds.add(pageEntry.node.id);
39
50
  for (const target of extractPlaywrightTargets(entry, context)) {
@@ -48,7 +59,7 @@ export function inferCoveredNodeIdsForTest(entry, context) {
48
59
  const requests = extractHttpSuiteRequests(content, entry.filePath);
49
60
  for (const request of requests) {
50
61
  if (!request.path.startsWith("/api/")) continue;
51
- const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(request.method, request.path));
62
+ const routeEntry = resolveApiRouteEntry(context, request.method, request.path);
52
63
  if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
53
64
  }
54
65
  }
@@ -58,7 +69,7 @@ export function inferCoveredNodeIdsForTest(entry, context) {
58
69
  const apiRoutes = inferApiRoutesFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
59
70
  for (const route of apiRoutes) {
60
71
  for (const method of HTTP_METHODS) {
61
- const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(method, toApiRequestPath(route)));
72
+ const routeEntry = resolveApiRouteEntry(context, method, toApiRequestPath(route));
62
73
  if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
63
74
  }
64
75
  }
@@ -5,6 +5,7 @@ import {
5
5
  inferDataCapabilitiesFromOwner,
6
6
  } from "./evidence.mjs";
7
7
  import { extractPlaywrightVisitedRoutes } from "./next-static-analysis.mjs";
8
+ import { DYNAMIC_SEGMENT_TOKEN } from "./shared.mjs";
8
9
 
9
10
  describe("coverage evidence helpers", () => {
10
11
  it("extracts and dedupes Playwright targets from source content", () => {
@@ -68,10 +69,10 @@ describe("extractPlaywrightVisitedRoutes", () => {
68
69
  `, "test.pw.testkit.ts")).toEqual(["/projects"]);
69
70
  });
70
71
 
71
- it("ignores dynamic goto expressions", () => {
72
+ it("normalizes dynamic goto expressions into route patterns", () => {
72
73
  expect(extractPlaywrightVisitedRoutes(`
73
74
  await page.goto(\`/projects/\${id}\`);
74
75
  await page.goto(someVar);
75
- `, "test.pw.testkit.ts")).toEqual([]);
76
+ `, "test.pw.testkit.ts")).toEqual([`/projects/${DYNAMIC_SEGMENT_TOKEN}`]);
76
77
  });
77
78
  });
@@ -3,10 +3,10 @@ import { buildEvidenceDetails, inferCoveredNodeIdsForTest } from "./evidence.mjs
3
3
  import { findNextAppRoot, resolveImportToSourceFile, resolveServiceRoot } from "./fs-walk.mjs";
4
4
  import { discoverApiRoutes, discoverPageViews, discoverServerActions } from "./next-discovery.mjs";
5
5
  import {
6
- apiRouteLookupKey,
7
6
  appendGraph,
8
7
  createEmptyGraph,
9
8
  createTestFileNode,
9
+ findMatchingApiRouteEntry,
10
10
  normalizePath,
11
11
  toSelectionType,
12
12
  } from "./shared.mjs";
@@ -122,7 +122,7 @@ export function buildServiceCoverageContext(productDir, serviceName, config, rep
122
122
 
123
123
  const pageByRoute = new Map(pages.pageEntries.map((entry) => [entry.route, entry]));
124
124
  const apiRouteByKey = new Map(
125
- apiRoutes.routeEntries.map((entry) => [apiRouteLookupKey(entry.method, entry.requestPath), entry])
125
+ apiRoutes.routeEntries.map((entry) => [`${entry.method}:${entry.requestPath}`, entry])
126
126
  );
127
127
  const serverActionByExportKey = new Map(
128
128
  serverActions.actionEntries.map((entry) => [`${entry.sourceFile}#${entry.exportName}`, entry])
@@ -130,7 +130,7 @@ export function buildServiceCoverageContext(productDir, serviceName, config, rep
130
130
 
131
131
  for (const pageEntry of pages.pageEntries) {
132
132
  for (const request of pageEntry.requests) {
133
- const apiRoute = apiRouteByKey.get(apiRouteLookupKey(request.method, request.path));
133
+ const apiRoute = findMatchingApiRouteEntry(request.method, request.path, apiRoutes.routeEntries);
134
134
  if (!apiRoute) continue;
135
135
  graph.edges.push({
136
136
  id: `handles:${request.node.id}:${apiRoute.node.id}`,
@@ -161,6 +161,7 @@ export function buildServiceCoverageContext(productDir, serviceName, config, rep
161
161
  graph,
162
162
  pageByRoute,
163
163
  apiRouteByKey,
164
+ apiRouteEntries: apiRoutes.routeEntries,
164
165
  serverActionByExportKey,
165
166
  dataCapabilities: dataAugmentation.nodes,
166
167
  };
@@ -501,6 +501,141 @@ describe("coverage graph builder", () => {
501
501
  expect(evidence.coveredNodeIds).toEqual(["api_route:web:POST:/api/campaigns"]);
502
502
  });
503
503
 
504
+ it("builds route-tree coverage from ancestor layouts, provider effects, local handlers, and dynamic request templates", () => {
505
+ const productDir = createProduct();
506
+ writeFile(
507
+ productDir,
508
+ "src/app/(dashboard)/layout.tsx",
509
+ `
510
+ import { ProjectProvider } from "@/components/project-context";
511
+
512
+ export default function DashboardLayout({ children }) {
513
+ return <ProjectProvider>{children}</ProjectProvider>;
514
+ }
515
+ `
516
+ );
517
+ writeFile(
518
+ productDir,
519
+ "src/components/project-context.tsx",
520
+ `
521
+ "use client";
522
+ import { useEffect } from "react";
523
+
524
+ export function ProjectProvider({ children }) {
525
+ const fetchProjects = async () => {
526
+ await fetch("/api/projects");
527
+ };
528
+
529
+ useEffect(() => {
530
+ fetchProjects();
531
+ }, []);
532
+
533
+ return <section>{children}</section>;
534
+ }
535
+ `
536
+ );
537
+ writeFile(
538
+ productDir,
539
+ "src/app/(dashboard)/projects/page.tsx",
540
+ `
541
+ "use client";
542
+ import { useState } from "react";
543
+
544
+ export default function ProjectsPage() {
545
+ const [name, setName] = useState("");
546
+
547
+ const handleCreate = async (event) => {
548
+ event.preventDefault();
549
+ await fetch("/api/projects", { method: "POST" });
550
+ setName("");
551
+ };
552
+
553
+ return (
554
+ <form onSubmit={handleCreate}>
555
+ <input value={name} onChange={(event) => setName(event.target.value)} />
556
+ <button data-testid="project-create-button" type="submit">Create Project</button>
557
+ </form>
558
+ );
559
+ }
560
+ `
561
+ );
562
+ writeFile(
563
+ productDir,
564
+ "src/app/(dashboard)/events/page.tsx",
565
+ `
566
+ "use client";
567
+ import { useEffect } from "react";
568
+
569
+ export default function EventsPage() {
570
+ const activeProject = { id: "project-1" };
571
+
572
+ useEffect(() => {
573
+ fetch(\`/api/projects/\${activeProject.id}/events?limit=100\`);
574
+ }, [activeProject.id]);
575
+
576
+ return <div>Events</div>;
577
+ }
578
+ `
579
+ );
580
+ writeFile(
581
+ productDir,
582
+ "src/app/api/projects/route.ts",
583
+ `
584
+ export async function GET() { return Response.json([]); }
585
+ export async function POST() { return Response.json({}, { status: 201 }); }
586
+ `
587
+ );
588
+ writeFile(
589
+ productDir,
590
+ "src/app/api/projects/[projectId]/events/route.ts",
591
+ `
592
+ export async function GET() { return Response.json([]); }
593
+ `
594
+ );
595
+
596
+ const context = buildServiceCoverageContext(productDir, "web", {
597
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
598
+ });
599
+
600
+ expect(context.graph.edges).toEqual(
601
+ expect.arrayContaining([
602
+ expect.objectContaining({
603
+ kind: "requests",
604
+ from: "page_view:web:/projects",
605
+ to: expect.stringContaining("client_request:web:src/components/project-context.tsx:page:/projects"),
606
+ }),
607
+ expect.objectContaining({
608
+ kind: "handles",
609
+ from: expect.stringContaining("client_request:web:src/components/project-context.tsx:page:/projects"),
610
+ to: "api_route:web:GET:/api/projects",
611
+ }),
612
+ expect.objectContaining({
613
+ kind: "triggers",
614
+ from: expect.stringContaining("ui_surface:web:/projects"),
615
+ to: "ui_action:web:/projects:handleCreate",
616
+ }),
617
+ expect.objectContaining({
618
+ kind: "requests",
619
+ from: "ui_action:web:/projects:handleCreate",
620
+ to: expect.stringContaining("client_request:web:src/app/(dashboard)/projects/page.tsx"),
621
+ }),
622
+ expect.objectContaining({
623
+ kind: "handles",
624
+ to: "api_route:web:POST:/api/projects",
625
+ }),
626
+ expect.objectContaining({
627
+ kind: "requests",
628
+ from: "page_view:web:/events",
629
+ to: expect.stringContaining("client_request:web:src/components/project-context.tsx:page:/events"),
630
+ }),
631
+ expect.objectContaining({
632
+ kind: "handles",
633
+ to: "api_route:web:GET:/api/projects/[projectId]/events",
634
+ }),
635
+ ])
636
+ );
637
+ });
638
+
504
639
  it("emits zero-coverage-inferred diagnostic when no patterns match", () => {
505
640
  const productDir = createProduct();
506
641
  writeFile(productDir, "src/app/page.tsx", `export default function HomePage() { return null; }`);
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { analyzeNextPageFile } from "./next-static-analysis.mjs";
3
+ import { analyzeNextRouteTree } from "./next-static-analysis.mjs";
4
4
  import { extractBackendImports, extractExportedFunctions, extractExportedMethodBodies } from "./backend-discovery.mjs";
5
5
  import { resolveImportToSourceFile, walkFiles } from "./fs-walk.mjs";
6
6
  import { routeFromApiFile, routeFromAppFile } from "./routing.mjs";
@@ -36,18 +36,22 @@ export function discoverPageViews({ serviceName, serviceRoot, nextAppRoot, exclu
36
36
  filePath: relativePath,
37
37
  };
38
38
  nodes.push(node);
39
+ const routeRootFiles = collectRouteRootFiles(nextAppRoot, absolutePath)
40
+ .map((absoluteRootPath) => ({
41
+ filePath: normalizePath(path.relative(serviceRoot, absoluteRootPath)),
42
+ content: fs.readFileSync(absoluteRootPath, "utf8"),
43
+ }));
39
44
 
40
- const pageAnalysis = analyzeNextPageFile({
45
+ const pageAnalysis = analyzeNextRouteTree({
41
46
  serviceName,
42
47
  serviceRoot,
43
- filePath: relativePath,
44
48
  route,
45
- content,
49
+ rootFiles: routeRootFiles,
46
50
  readSourceFile: (resolvedPath) => {
47
51
  const absoluteResolved = path.join(serviceRoot, resolvedPath);
48
52
  return fs.existsSync(absoluteResolved) ? fs.readFileSync(absoluteResolved, "utf8") : null;
49
53
  },
50
- resolveImportPath: (specifier) => resolveImportToSourceFile(serviceRoot, relativePath, specifier),
54
+ resolveImportPath: (fromFilePath, specifier) => resolveImportToSourceFile(serviceRoot, fromFilePath, specifier),
51
55
  isServerActionFile,
52
56
  normalizeRoute,
53
57
  });
@@ -68,6 +72,33 @@ export function discoverPageViews({ serviceName, serviceRoot, nextAppRoot, exclu
68
72
  return { nodes, edges, pageEntries };
69
73
  }
70
74
 
75
+ function collectRouteRootFiles(nextAppRoot, pageAbsolutePath) {
76
+ const rootFiles = [];
77
+ let currentDir = path.dirname(pageAbsolutePath);
78
+ const appRoot = path.resolve(nextAppRoot);
79
+
80
+ while (currentDir.startsWith(appRoot)) {
81
+ const layoutFile = resolveRouteCompanion(currentDir, "layout");
82
+ if (layoutFile) rootFiles.push(layoutFile);
83
+ if (currentDir === appRoot) break;
84
+ currentDir = path.dirname(currentDir);
85
+ }
86
+
87
+ rootFiles.reverse();
88
+ rootFiles.push(pageAbsolutePath);
89
+ return [...new Set(rootFiles)];
90
+ }
91
+
92
+ function resolveRouteCompanion(directoryPath, baseName) {
93
+ const candidates = [
94
+ path.join(directoryPath, `${baseName}.tsx`),
95
+ path.join(directoryPath, `${baseName}.ts`),
96
+ path.join(directoryPath, `${baseName}.jsx`),
97
+ path.join(directoryPath, `${baseName}.js`),
98
+ ];
99
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
100
+ }
101
+
71
102
  export function discoverApiRoutes({ serviceName, serviceRoot, nextAppRoot, exclude = [], resolveImportToSourceFile }) {
72
103
  const routeFiles = walkFiles(path.join(nextAppRoot, "api"), { baseDir: serviceRoot, exclude }).filter(
73
104
  (filePath) => filePath.endsWith("/route.ts") || filePath.endsWith("/route.tsx") || filePath.endsWith("/route.js")