@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.
- package/lib/coverage/evidence.mjs +48 -36
- package/lib/coverage/evidence.test.mjs +30 -16
- package/lib/coverage/graph-builder.mjs +18 -3
- package/lib/coverage/index.test.mjs +303 -0
- package/lib/coverage/next-discovery.mjs +36 -5
- package/lib/coverage/next-static-analysis.mjs +445 -126
- package/lib/coverage/shared.mjs +68 -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 +108 -2
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +212 -16
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/testkit-protocol/src/index.d.ts +26 -0
- package/node_modules/@elench/testkit-protocol/src/index.mjs +18 -0
- package/node_modules/@elench/testkit-protocol/src/index.test.mjs +75 -1
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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) => [
|
|
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 =
|
|
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 {
|
|
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")
|