@elench/testkit 0.1.61 → 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.
@@ -1,10 +1,12 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { extractHttpSuiteRequests } from "./next-static-analysis.mjs";
3
+ import { extractHttpSuiteRequests, extractPlaywrightVisitedRoutes } from "./next-static-analysis.mjs";
4
4
  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";
@@ -15,14 +17,35 @@ import {
15
17
  pathMatchesOwner,
16
18
  } from "./routing.mjs";
17
19
 
20
+ function readTestFileContent(entry, context) {
21
+ if (!context?.serviceRoot) return null;
22
+ const absolutePath = path.join(context.serviceRoot, entry.filePath);
23
+ if (!fs.existsSync(absolutePath)) return null;
24
+ return fs.readFileSync(absolutePath, "utf8");
25
+ }
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
+
18
33
  export function inferCoveredNodeIdsForTest(entry, context) {
19
34
  const coveredNodeIds = new Set();
20
- const selectionType = toSelectionType(entry.type, entry.framework);
35
+ const content = readTestFileContent(entry, context);
21
36
 
37
+ // Capability: Playwright page routes (content-first, path-fallback)
22
38
  if (entry.framework === "playwright") {
23
- const route = inferPageRouteFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
24
- if (route && context.pageByRoute.has(route)) {
25
- const pageEntry = context.pageByRoute.get(route);
39
+ const gotoRoutes = content ? extractPlaywrightVisitedRoutes(content, entry.filePath) : [];
40
+ const routes = gotoRoutes.length > 0
41
+ ? gotoRoutes
42
+ : (() => { const r = inferPageRouteFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot); return r ? [r] : []; })();
43
+ for (const route of routes) {
44
+ const matchedRoute = context.pageByRoute.get(route)
45
+ ? route
46
+ : findMatchingRouteValue(route, [...context.pageByRoute.keys()]);
47
+ const pageEntry = matchedRoute ? context.pageByRoute.get(matchedRoute) : null;
48
+ if (!pageEntry) continue;
26
49
  coveredNodeIds.add(pageEntry.node.id);
27
50
  for (const target of extractPlaywrightTargets(entry, context)) {
28
51
  const surfaceNode = pageEntry.surfacesByTargetValue?.get(target.value);
@@ -31,28 +54,30 @@ export function inferCoveredNodeIdsForTest(entry, context) {
31
54
  }
32
55
  }
33
56
 
34
- if (selectionType === "int") {
35
- const explicitRequests = extractHttpSuiteRequestsFromTestFile(entry, context);
36
- if (explicitRequests.length > 0) {
37
- for (const request of explicitRequests) {
38
- const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(request.method, request.path));
57
+ // Capability: HTTP request extraction (any test type with rawReq/fetch/wrapper calls)
58
+ if (content) {
59
+ const requests = extractHttpSuiteRequests(content, entry.filePath);
60
+ for (const request of requests) {
61
+ if (!request.path.startsWith("/api/")) continue;
62
+ const routeEntry = resolveApiRouteEntry(context, request.method, request.path);
63
+ if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
64
+ }
65
+ }
66
+
67
+ // Capability: path-based API route inference (fallback when no content matches)
68
+ if (coveredNodeIds.size === 0) {
69
+ const apiRoutes = inferApiRoutesFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
70
+ for (const route of apiRoutes) {
71
+ for (const method of HTTP_METHODS) {
72
+ const routeEntry = resolveApiRouteEntry(context, method, toApiRequestPath(route));
39
73
  if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
40
74
  }
41
- } else {
42
- const apiRoutes = inferApiRoutesFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
43
- for (const route of apiRoutes) {
44
- for (const method of HTTP_METHODS) {
45
- const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(method, toApiRequestPath(route)));
46
- if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
47
- }
48
- }
49
75
  }
50
76
  }
51
77
 
52
- if (selectionType === "dal") {
53
- for (const nodeId of inferDataCapabilitiesFromTestFile(entry, context)) {
54
- coveredNodeIds.add(nodeId);
55
- }
78
+ // Capability: DAL owner directory matching
79
+ for (const nodeId of inferDataCapabilitiesFromTestFile(entry, context)) {
80
+ coveredNodeIds.add(nodeId);
56
81
  }
57
82
 
58
83
  return [...coveredNodeIds].sort();
@@ -118,21 +143,8 @@ export function extractPlaywrightTargetsFromContent(content) {
118
143
  return dedupeTargets(targets);
119
144
  }
120
145
 
121
- export function extractHttpSuiteRequestsFromTestFile(entry, context) {
122
- if (!entry || toSelectionType(entry.type, entry.framework) !== "int" || !context?.serviceRoot) return [];
123
- const absolutePath = path.join(context.serviceRoot, entry.filePath);
124
- if (!fs.existsSync(absolutePath)) return [];
125
- return extractHttpSuiteRequestsFromContent(fs.readFileSync(absolutePath, "utf8"), entry.filePath);
126
- }
127
-
128
- export function extractHttpSuiteRequestsFromContent(content, filePath = "unknown") {
129
- return extractHttpSuiteRequests(content, filePath)
130
- .filter((request) => request.path.startsWith("/api/"))
131
- .sort((left, right) => `${left.method}:${left.path}`.localeCompare(`${right.method}:${right.path}`));
132
- }
133
-
134
146
  export function inferDataCapabilitiesFromTestFile(entry, context) {
135
- if (!entry || toSelectionType(entry.type, entry.framework) !== "dal") return [];
147
+ if (!entry) return [];
136
148
  const ownerDirectory = inferOwnerDirectoryFromTestFile(entry.filePath);
137
149
  if (!ownerDirectory) return [];
138
150
  return inferDataCapabilitiesFromOwner(ownerDirectory, context.dataCapabilities);
@@ -1,10 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  buildEvidenceDetailsFromTargets,
4
- extractHttpSuiteRequestsFromContent,
5
4
  extractPlaywrightTargetsFromContent,
6
5
  inferDataCapabilitiesFromOwner,
7
6
  } from "./evidence.mjs";
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", () => {
@@ -20,21 +21,6 @@ describe("coverage evidence helpers", () => {
20
21
  ]);
21
22
  });
22
23
 
23
- it("extracts API requests from integration suite source content", () => {
24
- const content = `
25
- export default defineHttpSuite(({ rawReq }) => {
26
- rawReq("GET", "/api/campaigns");
27
- rawReq("POST", "/api/campaigns");
28
- rawReq("GET", "/health");
29
- });
30
- `;
31
-
32
- expect(extractHttpSuiteRequestsFromContent(content, "campaigns.int.testkit.ts")).toEqual([
33
- { method: "GET", path: "/api/campaigns", confidence: "high" },
34
- { method: "POST", path: "/api/campaigns", confidence: "high" },
35
- ]);
36
- });
37
-
38
24
  it("builds compact evidence details only when meaningful signals exist", () => {
39
25
  expect(
40
26
  buildEvidenceDetailsFromTargets({
@@ -62,3 +48,31 @@ describe("coverage evidence helpers", () => {
62
48
  ]);
63
49
  });
64
50
  });
51
+
52
+ describe("extractPlaywrightVisitedRoutes", () => {
53
+ it("extracts routes from page.goto() via AST", () => {
54
+ expect(extractPlaywrightVisitedRoutes(`
55
+ import { test } from "@playwright/test";
56
+ test("nav", async ({ page }) => {
57
+ await page.goto("/dashboard");
58
+ await page.goto("/settings/profile?tab=general");
59
+ await page.goto("/dashboard"); // duplicate
60
+ await page.goto("https://external.com/path"); // external
61
+ await page.goto("http://localhost:3000/events"); // same-origin absolute
62
+ });
63
+ `, "nav.pw.testkit.ts")).toEqual(["/dashboard", "/settings/profile", "/events"]);
64
+ });
65
+
66
+ it("handles property chain: foo.page.goto()", () => {
67
+ expect(extractPlaywrightVisitedRoutes(`
68
+ await this.page.goto("/projects");
69
+ `, "test.pw.testkit.ts")).toEqual(["/projects"]);
70
+ });
71
+
72
+ it("normalizes dynamic goto expressions into route patterns", () => {
73
+ expect(extractPlaywrightVisitedRoutes(`
74
+ await page.goto(\`/projects/\${id}\`);
75
+ await page.goto(someVar);
76
+ `, "test.pw.testkit.ts")).toEqual([`/projects/${DYNAMIC_SEGMENT_TOKEN}`]);
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";
@@ -29,12 +29,26 @@ export function buildCoverageGraph({ productDir, repoDiscovery = {}, services =
29
29
  const context = serviceContexts.get(entry.serviceName);
30
30
  const testNodeId = createTestFileNode(graph, entry);
31
31
  if (!context) {
32
+ graph.diagnostics.push({
33
+ level: "warn",
34
+ code: "no-service-context",
35
+ filePath: entry.filePath,
36
+ service: entry.serviceName,
37
+ message: `No coverage context available for service "${entry.serviceName}".`,
38
+ });
32
39
  evidence.push(createFallbackEvidence(entry, testNodeId));
33
40
  continue;
34
41
  }
35
42
 
36
43
  const coveredNodeIds = inferCoveredNodeIdsForTest(entry, context);
37
44
  if (coveredNodeIds.length === 0) {
45
+ graph.diagnostics.push({
46
+ level: "info",
47
+ code: "zero-coverage-inferred",
48
+ filePath: entry.filePath,
49
+ service: entry.serviceName,
50
+ message: `No routes, API endpoints, or data capabilities matched for "${entry.filePath}".`,
51
+ });
38
52
  evidence.push(createFallbackEvidence(entry, testNodeId));
39
53
  continue;
40
54
  }
@@ -108,7 +122,7 @@ export function buildServiceCoverageContext(productDir, serviceName, config, rep
108
122
 
109
123
  const pageByRoute = new Map(pages.pageEntries.map((entry) => [entry.route, entry]));
110
124
  const apiRouteByKey = new Map(
111
- apiRoutes.routeEntries.map((entry) => [apiRouteLookupKey(entry.method, entry.requestPath), entry])
125
+ apiRoutes.routeEntries.map((entry) => [`${entry.method}:${entry.requestPath}`, entry])
112
126
  );
113
127
  const serverActionByExportKey = new Map(
114
128
  serverActions.actionEntries.map((entry) => [`${entry.sourceFile}#${entry.exportName}`, entry])
@@ -116,7 +130,7 @@ export function buildServiceCoverageContext(productDir, serviceName, config, rep
116
130
 
117
131
  for (const pageEntry of pages.pageEntries) {
118
132
  for (const request of pageEntry.requests) {
119
- const apiRoute = apiRouteByKey.get(apiRouteLookupKey(request.method, request.path));
133
+ const apiRoute = findMatchingApiRouteEntry(request.method, request.path, apiRoutes.routeEntries);
120
134
  if (!apiRoute) continue;
121
135
  graph.edges.push({
122
136
  id: `handles:${request.node.id}:${apiRoute.node.id}`,
@@ -147,6 +161,7 @@ export function buildServiceCoverageContext(productDir, serviceName, config, rep
147
161
  graph,
148
162
  pageByRoute,
149
163
  apiRouteByKey,
164
+ apiRouteEntries: apiRoutes.routeEntries,
150
165
  serverActionByExportKey,
151
166
  dataCapabilities: dataAugmentation.nodes,
152
167
  };
@@ -375,6 +375,309 @@ describe("coverage graph builder", () => {
375
375
  expect(context.apiRouteByKey.get("GET:/api/projects/[projectId]").node).toBeTruthy();
376
376
  });
377
377
 
378
+ it("resolves root-level PW test via page.goto() content extraction", () => {
379
+ const productDir = createProduct();
380
+ writeFile(
381
+ productDir,
382
+ "src/app/dashboard/page.tsx",
383
+ `export default function DashboardPage() { return <button data-testid="dash-header">Dashboard</button>; }`
384
+ );
385
+ writeFile(
386
+ productDir,
387
+ "__testkit__/dashboard.pw.testkit.ts",
388
+ `
389
+ import { test, expect } from "@playwright/test";
390
+ test("dashboard loads", async ({ page }) => {
391
+ await page.goto("/dashboard");
392
+ await expect(page.getByTestId("dash-header")).toBeVisible();
393
+ });
394
+ `
395
+ );
396
+
397
+ const graph = buildCoverageGraph({
398
+ productDir,
399
+ services: {
400
+ web: {
401
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
402
+ },
403
+ },
404
+ discoveryFiles: [
405
+ {
406
+ serviceName: "web",
407
+ type: "e2e",
408
+ framework: "playwright",
409
+ suiteName: "dashboard",
410
+ filePath: "__testkit__/dashboard.pw.testkit.ts",
411
+ },
412
+ ],
413
+ });
414
+
415
+ const evidence = graph.evidence.find((e) => e.testFilePath === "__testkit__/dashboard.pw.testkit.ts");
416
+ expect(evidence.coveredNodeIds).toEqual(
417
+ expect.arrayContaining([
418
+ "page_view:web:/dashboard",
419
+ "ui_surface:web:/dashboard:dash-header",
420
+ ])
421
+ );
422
+ });
423
+
424
+ it("resolves root-level e2e test via rawReq content extraction", () => {
425
+ const productDir = createProduct();
426
+ writeFile(
427
+ productDir,
428
+ "src/app/api/projects/route.ts",
429
+ `export async function GET() { return Response.json({ ok: true }); }`
430
+ );
431
+ writeFile(
432
+ productDir,
433
+ "__testkit__/projects.e2e.testkit.ts",
434
+ `
435
+ import { defineHttpSuite } from "@elench/testkit";
436
+ export default defineHttpSuite(({ rawReq }) => {
437
+ rawReq("GET", "/api/projects");
438
+ });
439
+ `
440
+ );
441
+
442
+ const graph = buildCoverageGraph({
443
+ productDir,
444
+ services: {
445
+ web: {
446
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
447
+ },
448
+ },
449
+ discoveryFiles: [
450
+ {
451
+ serviceName: "web",
452
+ type: "e2e",
453
+ framework: "k6",
454
+ suiteName: "projects",
455
+ filePath: "__testkit__/projects.e2e.testkit.ts",
456
+ },
457
+ ],
458
+ });
459
+
460
+ const evidence = graph.evidence.find((e) => e.testFilePath === "__testkit__/projects.e2e.testkit.ts");
461
+ expect(evidence.coveredNodeIds).toEqual(["api_route:web:GET:/api/projects"]);
462
+ });
463
+
464
+ it("resolves scenario test via rawReq content extraction", () => {
465
+ const productDir = createProduct();
466
+ writeFile(
467
+ productDir,
468
+ "src/app/api/campaigns/route.ts",
469
+ `export async function POST() { return Response.json({ ok: true }); }`
470
+ );
471
+ writeFile(
472
+ productDir,
473
+ "__testkit__/campaign-flow.scenario.testkit.ts",
474
+ `
475
+ import { defineHttpSuite } from "@elench/testkit";
476
+ export default defineHttpSuite(({ rawReq }) => {
477
+ rawReq("POST", "/api/campaigns");
478
+ });
479
+ `
480
+ );
481
+
482
+ const graph = buildCoverageGraph({
483
+ productDir,
484
+ services: {
485
+ web: {
486
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
487
+ },
488
+ },
489
+ discoveryFiles: [
490
+ {
491
+ serviceName: "web",
492
+ type: "scenario",
493
+ framework: "k6",
494
+ suiteName: "campaign-flow",
495
+ filePath: "__testkit__/campaign-flow.scenario.testkit.ts",
496
+ },
497
+ ],
498
+ });
499
+
500
+ const evidence = graph.evidence.find((e) => e.testFilePath === "__testkit__/campaign-flow.scenario.testkit.ts");
501
+ expect(evidence.coveredNodeIds).toEqual(["api_route:web:POST:/api/campaigns"]);
502
+ });
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
+
639
+ it("emits zero-coverage-inferred diagnostic when no patterns match", () => {
640
+ const productDir = createProduct();
641
+ writeFile(productDir, "src/app/page.tsx", `export default function HomePage() { return null; }`);
642
+ writeFile(
643
+ productDir,
644
+ "__testkit__/misc.e2e.testkit.ts",
645
+ `// no recognizable patterns here\nconsole.log("hello");`
646
+ );
647
+
648
+ const graph = buildCoverageGraph({
649
+ productDir,
650
+ services: {
651
+ web: {
652
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
653
+ },
654
+ },
655
+ discoveryFiles: [
656
+ {
657
+ serviceName: "web",
658
+ type: "e2e",
659
+ framework: "k6",
660
+ suiteName: "misc",
661
+ filePath: "__testkit__/misc.e2e.testkit.ts",
662
+ },
663
+ ],
664
+ });
665
+
666
+ expect(graph.diagnostics).toEqual(
667
+ expect.arrayContaining([
668
+ expect.objectContaining({
669
+ level: "info",
670
+ code: "zero-coverage-inferred",
671
+ filePath: "__testkit__/misc.e2e.testkit.ts",
672
+ service: "web",
673
+ }),
674
+ ])
675
+ );
676
+
677
+ const evidence = graph.evidence.find((e) => e.testFilePath === "__testkit__/misc.e2e.testkit.ts");
678
+ expect(evidence.coveredNodeIds).toEqual([expect.stringContaining("test_file:web:")]);
679
+ });
680
+
378
681
  it("does not exclude app/coverage source routes while still allowing top-level coverage output to be ignored", () => {
379
682
  const productDir = createProduct();
380
683
  writeFile(productDir, "app/coverage/page.tsx", `export default function CoveragePage() { 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")