@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.
- package/lib/coverage/evidence.mjs +14 -3
- package/lib/coverage/evidence.test.mjs +3 -2
- package/lib/coverage/graph-builder.mjs +4 -3
- package/lib/coverage/index.test.mjs +135 -0
- package/lib/coverage/next-discovery.mjs +36 -5
- package/lib/coverage/next-static-analysis.mjs +420 -136
- package/lib/coverage/shared.mjs +67 -0
- package/lib/coverage/shared.test.mjs +33 -0
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-bridge/src/index.mjs +34 -2
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +204 -17
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
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
|
|
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("
|
|
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) => [
|
|
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 =
|
|
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 {
|
|
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 =
|
|
45
|
+
const pageAnalysis = analyzeNextRouteTree({
|
|
41
46
|
serviceName,
|
|
42
47
|
serviceRoot,
|
|
43
|
-
filePath: relativePath,
|
|
44
48
|
route,
|
|
45
|
-
|
|
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,
|
|
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")
|