@elench/testkit 0.1.61 → 0.1.62
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 +36 -35
- package/lib/coverage/evidence.test.mjs +29 -16
- package/lib/coverage/graph-builder.mjs +14 -0
- package/lib/coverage/index.test.mjs +168 -0
- package/lib/coverage/next-static-analysis.mjs +35 -0
- package/lib/coverage/shared.mjs +1 -0
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-bridge/src/index.mjs +74 -0
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +9 -0
- 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,6 +1,6 @@
|
|
|
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,
|
|
@@ -15,14 +15,26 @@ import {
|
|
|
15
15
|
pathMatchesOwner,
|
|
16
16
|
} from "./routing.mjs";
|
|
17
17
|
|
|
18
|
+
function readTestFileContent(entry, context) {
|
|
19
|
+
if (!context?.serviceRoot) return null;
|
|
20
|
+
const absolutePath = path.join(context.serviceRoot, entry.filePath);
|
|
21
|
+
if (!fs.existsSync(absolutePath)) return null;
|
|
22
|
+
return fs.readFileSync(absolutePath, "utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
export function inferCoveredNodeIdsForTest(entry, context) {
|
|
19
26
|
const coveredNodeIds = new Set();
|
|
20
|
-
const
|
|
27
|
+
const content = readTestFileContent(entry, context);
|
|
21
28
|
|
|
29
|
+
// Capability: Playwright page routes (content-first, path-fallback)
|
|
22
30
|
if (entry.framework === "playwright") {
|
|
23
|
-
const
|
|
24
|
-
|
|
31
|
+
const gotoRoutes = content ? extractPlaywrightVisitedRoutes(content, entry.filePath) : [];
|
|
32
|
+
const routes = gotoRoutes.length > 0
|
|
33
|
+
? gotoRoutes
|
|
34
|
+
: (() => { const r = inferPageRouteFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot); return r ? [r] : []; })();
|
|
35
|
+
for (const route of routes) {
|
|
25
36
|
const pageEntry = context.pageByRoute.get(route);
|
|
37
|
+
if (!pageEntry) continue;
|
|
26
38
|
coveredNodeIds.add(pageEntry.node.id);
|
|
27
39
|
for (const target of extractPlaywrightTargets(entry, context)) {
|
|
28
40
|
const surfaceNode = pageEntry.surfacesByTargetValue?.get(target.value);
|
|
@@ -31,28 +43,30 @@ export function inferCoveredNodeIdsForTest(entry, context) {
|
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
// Capability: HTTP request extraction (any test type with rawReq/fetch/wrapper calls)
|
|
47
|
+
if (content) {
|
|
48
|
+
const requests = extractHttpSuiteRequests(content, entry.filePath);
|
|
49
|
+
for (const request of requests) {
|
|
50
|
+
if (!request.path.startsWith("/api/")) continue;
|
|
51
|
+
const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(request.method, request.path));
|
|
52
|
+
if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Capability: path-based API route inference (fallback when no content matches)
|
|
57
|
+
if (coveredNodeIds.size === 0) {
|
|
58
|
+
const apiRoutes = inferApiRoutesFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
|
|
59
|
+
for (const route of apiRoutes) {
|
|
60
|
+
for (const method of HTTP_METHODS) {
|
|
61
|
+
const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(method, toApiRequestPath(route)));
|
|
39
62
|
if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
|
|
40
63
|
}
|
|
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
64
|
}
|
|
50
65
|
}
|
|
51
66
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
67
|
+
// Capability: DAL owner directory matching
|
|
68
|
+
for (const nodeId of inferDataCapabilitiesFromTestFile(entry, context)) {
|
|
69
|
+
coveredNodeIds.add(nodeId);
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
return [...coveredNodeIds].sort();
|
|
@@ -118,21 +132,8 @@ export function extractPlaywrightTargetsFromContent(content) {
|
|
|
118
132
|
return dedupeTargets(targets);
|
|
119
133
|
}
|
|
120
134
|
|
|
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
135
|
export function inferDataCapabilitiesFromTestFile(entry, context) {
|
|
135
|
-
if (!entry
|
|
136
|
+
if (!entry) return [];
|
|
136
137
|
const ownerDirectory = inferOwnerDirectoryFromTestFile(entry.filePath);
|
|
137
138
|
if (!ownerDirectory) return [];
|
|
138
139
|
return inferDataCapabilitiesFromOwner(ownerDirectory, context.dataCapabilities);
|
|
@@ -1,10 +1,10 @@
|
|
|
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
8
|
|
|
9
9
|
describe("coverage evidence helpers", () => {
|
|
10
10
|
it("extracts and dedupes Playwright targets from source content", () => {
|
|
@@ -20,21 +20,6 @@ describe("coverage evidence helpers", () => {
|
|
|
20
20
|
]);
|
|
21
21
|
});
|
|
22
22
|
|
|
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
23
|
it("builds compact evidence details only when meaningful signals exist", () => {
|
|
39
24
|
expect(
|
|
40
25
|
buildEvidenceDetailsFromTargets({
|
|
@@ -62,3 +47,31 @@ describe("coverage evidence helpers", () => {
|
|
|
62
47
|
]);
|
|
63
48
|
});
|
|
64
49
|
});
|
|
50
|
+
|
|
51
|
+
describe("extractPlaywrightVisitedRoutes", () => {
|
|
52
|
+
it("extracts routes from page.goto() via AST", () => {
|
|
53
|
+
expect(extractPlaywrightVisitedRoutes(`
|
|
54
|
+
import { test } from "@playwright/test";
|
|
55
|
+
test("nav", async ({ page }) => {
|
|
56
|
+
await page.goto("/dashboard");
|
|
57
|
+
await page.goto("/settings/profile?tab=general");
|
|
58
|
+
await page.goto("/dashboard"); // duplicate
|
|
59
|
+
await page.goto("https://external.com/path"); // external
|
|
60
|
+
await page.goto("http://localhost:3000/events"); // same-origin absolute
|
|
61
|
+
});
|
|
62
|
+
`, "nav.pw.testkit.ts")).toEqual(["/dashboard", "/settings/profile", "/events"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles property chain: foo.page.goto()", () => {
|
|
66
|
+
expect(extractPlaywrightVisitedRoutes(`
|
|
67
|
+
await this.page.goto("/projects");
|
|
68
|
+
`, "test.pw.testkit.ts")).toEqual(["/projects"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("ignores dynamic goto expressions", () => {
|
|
72
|
+
expect(extractPlaywrightVisitedRoutes(`
|
|
73
|
+
await page.goto(\`/projects/\${id}\`);
|
|
74
|
+
await page.goto(someVar);
|
|
75
|
+
`, "test.pw.testkit.ts")).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -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
|
}
|
|
@@ -375,6 +375,174 @@ 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("emits zero-coverage-inferred diagnostic when no patterns match", () => {
|
|
505
|
+
const productDir = createProduct();
|
|
506
|
+
writeFile(productDir, "src/app/page.tsx", `export default function HomePage() { return null; }`);
|
|
507
|
+
writeFile(
|
|
508
|
+
productDir,
|
|
509
|
+
"__testkit__/misc.e2e.testkit.ts",
|
|
510
|
+
`// no recognizable patterns here\nconsole.log("hello");`
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const graph = buildCoverageGraph({
|
|
514
|
+
productDir,
|
|
515
|
+
services: {
|
|
516
|
+
web: {
|
|
517
|
+
local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
discoveryFiles: [
|
|
521
|
+
{
|
|
522
|
+
serviceName: "web",
|
|
523
|
+
type: "e2e",
|
|
524
|
+
framework: "k6",
|
|
525
|
+
suiteName: "misc",
|
|
526
|
+
filePath: "__testkit__/misc.e2e.testkit.ts",
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
expect(graph.diagnostics).toEqual(
|
|
532
|
+
expect.arrayContaining([
|
|
533
|
+
expect.objectContaining({
|
|
534
|
+
level: "info",
|
|
535
|
+
code: "zero-coverage-inferred",
|
|
536
|
+
filePath: "__testkit__/misc.e2e.testkit.ts",
|
|
537
|
+
service: "web",
|
|
538
|
+
}),
|
|
539
|
+
])
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
const evidence = graph.evidence.find((e) => e.testFilePath === "__testkit__/misc.e2e.testkit.ts");
|
|
543
|
+
expect(evidence.coveredNodeIds).toEqual([expect.stringContaining("test_file:web:")]);
|
|
544
|
+
});
|
|
545
|
+
|
|
378
546
|
it("does not exclude app/coverage source routes while still allowing top-level coverage output to be ignored", () => {
|
|
379
547
|
const productDir = createProduct();
|
|
380
548
|
writeFile(productDir, "app/coverage/page.tsx", `export default function CoveragePage() { return null; }`);
|
|
@@ -196,6 +196,41 @@ export function extractHttpSuiteRequests(content, filePath = "suite.int.testkit.
|
|
|
196
196
|
return dedupeRequests(requests);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
export function extractPlaywrightVisitedRoutes(content, filePath = "suite.pw.testkit.ts") {
|
|
200
|
+
const sourceFile = createSourceFile(filePath, content);
|
|
201
|
+
const routes = [];
|
|
202
|
+
|
|
203
|
+
const visit = (node) => {
|
|
204
|
+
if (ts.isCallExpression(node)) {
|
|
205
|
+
const route = resolveGotoRoute(node);
|
|
206
|
+
if (route) routes.push(route);
|
|
207
|
+
}
|
|
208
|
+
ts.forEachChild(node, visit);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
visit(sourceFile);
|
|
212
|
+
return [...new Set(routes)];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resolveGotoRoute(callExpression) {
|
|
216
|
+
const callee = callExpression.expression;
|
|
217
|
+
if (!ts.isPropertyAccessExpression(callee)) return null;
|
|
218
|
+
if (callee.name.text !== "goto") return null;
|
|
219
|
+
const pathArg = callExpression.arguments[0];
|
|
220
|
+
const literal = extractStringLiteral(pathArg);
|
|
221
|
+
if (!literal) return null;
|
|
222
|
+
if (/^https?:\/\//u.test(literal)) {
|
|
223
|
+
try {
|
|
224
|
+
const parsed = new URL(literal);
|
|
225
|
+
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
|
|
226
|
+
return defaultNormalizeRoute(parsed.pathname.split("?")[0]);
|
|
227
|
+
}
|
|
228
|
+
} catch { /* ignore malformed */ }
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return defaultNormalizeRoute(literal.split("?")[0]);
|
|
232
|
+
}
|
|
233
|
+
|
|
199
234
|
function walkJsx(sourceFile, visitor) {
|
|
200
235
|
const visit = (node, formContext = null) => {
|
|
201
236
|
let nextFormContext = formContext;
|
package/lib/coverage/shared.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.62",
|
|
4
4
|
"description": "Browser bridge helpers for testkit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"src/"
|
|
12
12
|
],
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@elench/testkit-protocol": "0.1.
|
|
14
|
+
"@elench/testkit-protocol": "0.1.62"
|
|
15
15
|
},
|
|
16
16
|
"private": false
|
|
17
17
|
}
|
|
@@ -143,6 +143,7 @@ export function buildPageOverlayResponse(context, pageUrl) {
|
|
|
143
143
|
const projection = buildGraphProjection(context, page, match.service?.name || null);
|
|
144
144
|
const relatedCoverageCount = projection.coverage.length;
|
|
145
145
|
const relatedFailureCount = projection.failures.length;
|
|
146
|
+
const coverageBreakdown = buildCoverageBreakdown(projection.coverage);
|
|
146
147
|
|
|
147
148
|
return {
|
|
148
149
|
protocolVersion: TESTKIT_BROWSER_PROTOCOL_VERSION,
|
|
@@ -162,6 +163,7 @@ export function buildPageOverlayResponse(context, pageUrl) {
|
|
|
162
163
|
coverageState: relatedCoverageCount > 0 ? "covered" : "missing",
|
|
163
164
|
relatedFailureCount,
|
|
164
165
|
relatedCoverageCount,
|
|
166
|
+
coverageBreakdown,
|
|
165
167
|
},
|
|
166
168
|
failures: projection.failures,
|
|
167
169
|
coverage: projection.coverage,
|
|
@@ -291,6 +293,7 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
291
293
|
.filter(Boolean);
|
|
292
294
|
|
|
293
295
|
if (supportingTests.length > 0) {
|
|
296
|
+
const supportKind = inferCoverageSupportKind(supportingTests);
|
|
294
297
|
coverage.push({
|
|
295
298
|
id: surfaceNode.id,
|
|
296
299
|
kind: surfaceNode.kind,
|
|
@@ -301,6 +304,10 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
301
304
|
supportingTests,
|
|
302
305
|
viaNodes: collectViaNodes(relevantEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
|
|
303
306
|
confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
|
|
307
|
+
importance: inferSurfaceImportance(surfaceNode),
|
|
308
|
+
surfaceKind: surfaceNode.metadata?.surfaceKind ? String(surfaceNode.metadata.surfaceKind) : null,
|
|
309
|
+
supportKind,
|
|
310
|
+
reason: buildCoverageReason(surfaceNode, supportKind, supportingTests, relevantEvidence),
|
|
304
311
|
});
|
|
305
312
|
}
|
|
306
313
|
|
|
@@ -329,6 +336,9 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
329
336
|
targets: collectTargetsForEvidence(failedEvidence, surfaceNode.target),
|
|
330
337
|
failedTests,
|
|
331
338
|
viaNodes: collectViaNodes(failedEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
|
|
339
|
+
importance: inferSurfaceImportance(surfaceNode),
|
|
340
|
+
surfaceKind: surfaceNode.metadata?.surfaceKind ? String(surfaceNode.metadata.surfaceKind) : null,
|
|
341
|
+
reason: buildFailureReason(surfaceNode, failedTests),
|
|
332
342
|
});
|
|
333
343
|
}
|
|
334
344
|
|
|
@@ -352,6 +362,10 @@ function buildPageLevelProjection({ pageNode, nodeById, relevantEvidence, pageRe
|
|
|
352
362
|
supportingTests,
|
|
353
363
|
viaNodes: collectViaNodes(relevantEvidence, pageReachableNodeIds, nodeById, new Set([pageNode.id])),
|
|
354
364
|
confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
|
|
365
|
+
importance: inferSurfaceImportance(pageNode),
|
|
366
|
+
surfaceKind: pageNode.kind,
|
|
367
|
+
supportKind: inferCoverageSupportKind(supportingTests),
|
|
368
|
+
reason: buildCoverageReason(pageNode, inferCoverageSupportKind(supportingTests), supportingTests, relevantEvidence),
|
|
355
369
|
},
|
|
356
370
|
]
|
|
357
371
|
: [];
|
|
@@ -370,6 +384,9 @@ function buildPageLevelProjection({ pageNode, nodeById, relevantEvidence, pageRe
|
|
|
370
384
|
targets: collectTargetsForEvidence([entry], pageNode.target),
|
|
371
385
|
failedTests: supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : [],
|
|
372
386
|
viaNodes: collectViaNodes([entry], pageReachableNodeIds, nodeById, new Set([pageNode.id])),
|
|
387
|
+
importance: inferSurfaceImportance(pageNode),
|
|
388
|
+
surfaceKind: pageNode.kind,
|
|
389
|
+
reason: buildFailureReason(pageNode, supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : []),
|
|
373
390
|
};
|
|
374
391
|
});
|
|
375
392
|
|
|
@@ -475,3 +492,60 @@ function pathBaseName(filePath) {
|
|
|
475
492
|
const parts = String(filePath || "").split("/");
|
|
476
493
|
return parts[parts.length - 1] || filePath;
|
|
477
494
|
}
|
|
495
|
+
|
|
496
|
+
function buildCoverageBreakdown(entries) {
|
|
497
|
+
const breakdown = { direct: 0, indirect: 0, mixed: 0 };
|
|
498
|
+
for (const entry of entries || []) {
|
|
499
|
+
if (entry.supportKind === "direct") breakdown.direct += 1;
|
|
500
|
+
else if (entry.supportKind === "indirect") breakdown.indirect += 1;
|
|
501
|
+
else breakdown.mixed += 1;
|
|
502
|
+
}
|
|
503
|
+
return breakdown;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function inferCoverageSupportKind(supportingTests) {
|
|
507
|
+
const hasPw = (supportingTests || []).some((entry) => entry.type === "pw");
|
|
508
|
+
const hasBackend = (supportingTests || []).some((entry) => entry.type !== "pw");
|
|
509
|
+
if (hasPw && hasBackend) return "mixed";
|
|
510
|
+
if (hasPw) return "direct";
|
|
511
|
+
return "indirect";
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function inferSurfaceImportance(node) {
|
|
515
|
+
const label = String(node?.label || "").toLowerCase();
|
|
516
|
+
const surfaceKind = String(node?.metadata?.surfaceKind || node?.kind || "").toLowerCase();
|
|
517
|
+
|
|
518
|
+
if (/\b(pay|purchase|checkout|publish|send|submit|confirm|delete|remove)\b/u.test(label)) {
|
|
519
|
+
return "critical";
|
|
520
|
+
}
|
|
521
|
+
if (/\b(save|create|update|refresh|retry|login|sign in|continue)\b/u.test(label)) {
|
|
522
|
+
return "high";
|
|
523
|
+
}
|
|
524
|
+
if (surfaceKind === "form" || surfaceKind === "button" || surfaceKind === "input") {
|
|
525
|
+
return "medium";
|
|
526
|
+
}
|
|
527
|
+
return "low";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function buildCoverageReason(node, supportKind, supportingTests, evidenceEntries) {
|
|
531
|
+
const directCount = supportingTests.filter((entry) => entry.type === "pw").length;
|
|
532
|
+
const backendCount = supportingTests.filter((entry) => entry.type !== "pw").length;
|
|
533
|
+
const requestPaths = new Set();
|
|
534
|
+
for (const entry of evidenceEntries || []) {
|
|
535
|
+
for (const path of entry?.details?.requestPaths || []) requestPaths.add(path);
|
|
536
|
+
}
|
|
537
|
+
const requestSummary = requestPaths.size > 0 ? ` via ${[...requestPaths].join(", ")}` : "";
|
|
538
|
+
if (supportKind === "mixed") {
|
|
539
|
+
return `${node.label} is covered directly by ${directCount} UI test${directCount === 1 ? "" : "s"} and indirectly by ${backendCount} backend test${backendCount === 1 ? "" : "s"}${requestSummary}.`;
|
|
540
|
+
}
|
|
541
|
+
if (supportKind === "direct") {
|
|
542
|
+
return `${node.label} is covered directly by ${directCount} UI test${directCount === 1 ? "" : "s"}${requestSummary}.`;
|
|
543
|
+
}
|
|
544
|
+
return `${node.label} is covered indirectly by ${backendCount} backend test${backendCount === 1 ? "" : "s"}${requestSummary}.`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function buildFailureReason(node, failedTests) {
|
|
548
|
+
const count = (failedTests || []).length;
|
|
549
|
+
if (count === 0) return `${node.label} has a related failing test.`;
|
|
550
|
+
return `${node.label} is implicated by ${count} failing test${count === 1 ? "" : "s"}.`;
|
|
551
|
+
}
|
|
@@ -179,11 +179,18 @@ describe("testkit bridge", () => {
|
|
|
179
179
|
coverageState: "covered",
|
|
180
180
|
relatedFailureCount: 1,
|
|
181
181
|
relatedCoverageCount: 1,
|
|
182
|
+
coverageBreakdown: {
|
|
183
|
+
direct: 0,
|
|
184
|
+
indirect: 0,
|
|
185
|
+
mixed: 1,
|
|
186
|
+
},
|
|
182
187
|
},
|
|
183
188
|
failures: [
|
|
184
189
|
{
|
|
185
190
|
kind: "ui_surface",
|
|
186
191
|
label: "Refresh overlay",
|
|
192
|
+
importance: "high",
|
|
193
|
+
reason: "Refresh overlay is implicated by 1 failing test.",
|
|
187
194
|
targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
|
|
188
195
|
failedTests: [
|
|
189
196
|
{
|
|
@@ -197,6 +204,8 @@ describe("testkit bridge", () => {
|
|
|
197
204
|
{
|
|
198
205
|
kind: "ui_surface",
|
|
199
206
|
label: "Refresh overlay",
|
|
207
|
+
importance: "high",
|
|
208
|
+
supportKind: "mixed",
|
|
200
209
|
targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
|
|
201
210
|
supportingTests: [
|
|
202
211
|
{
|
|
@@ -5,6 +5,8 @@ export type BrowserTargetKind = "testId" | "role" | "text" | "css" | "xpath" | "
|
|
|
5
5
|
export type BrowserConfidence = "low" | "medium" | "high";
|
|
6
6
|
export type BrowserFailureState = "failing" | "healthy" | "unavailable";
|
|
7
7
|
export type BrowserCoverageState = "covered" | "missing" | "unavailable";
|
|
8
|
+
export type BridgeSurfaceImportance = "critical" | "high" | "medium" | "low";
|
|
9
|
+
export type BridgeCoverageSupportKind = "direct" | "indirect" | "mixed";
|
|
8
10
|
|
|
9
11
|
export type CoverageNodeKind =
|
|
10
12
|
| "page_view"
|
|
@@ -86,11 +88,22 @@ export interface CoverageEvidence {
|
|
|
86
88
|
};
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
export type CoverageGraphDiagnosticLevel = "info" | "warn";
|
|
92
|
+
|
|
93
|
+
export interface CoverageGraphDiagnostic {
|
|
94
|
+
level: CoverageGraphDiagnosticLevel;
|
|
95
|
+
code: string;
|
|
96
|
+
filePath: string;
|
|
97
|
+
service: string;
|
|
98
|
+
message: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
export interface CoverageGraph {
|
|
90
102
|
schemaVersion: number;
|
|
91
103
|
nodes: CoverageGraphNode[];
|
|
92
104
|
edges: CoverageGraphEdge[];
|
|
93
105
|
evidence: CoverageEvidence[];
|
|
106
|
+
diagnostics: CoverageGraphDiagnostic[];
|
|
94
107
|
}
|
|
95
108
|
|
|
96
109
|
export interface BridgeProductRef {
|
|
@@ -149,6 +162,9 @@ export interface FailureOverlayEntry {
|
|
|
149
162
|
targets: BrowserTarget[];
|
|
150
163
|
failedTests: BridgeSupportingTestRef[];
|
|
151
164
|
viaNodes: BridgeCoverageViaNodeRef[];
|
|
165
|
+
importance?: BridgeSurfaceImportance;
|
|
166
|
+
surfaceKind?: string | null;
|
|
167
|
+
reason?: string | null;
|
|
152
168
|
}
|
|
153
169
|
|
|
154
170
|
export interface CoverageOverlayEntry {
|
|
@@ -161,6 +177,10 @@ export interface CoverageOverlayEntry {
|
|
|
161
177
|
supportingTests: BridgeSupportingTestRef[];
|
|
162
178
|
viaNodes: BridgeCoverageViaNodeRef[];
|
|
163
179
|
confidence: BrowserConfidence;
|
|
180
|
+
importance?: BridgeSurfaceImportance;
|
|
181
|
+
surfaceKind?: string | null;
|
|
182
|
+
supportKind?: BridgeCoverageSupportKind;
|
|
183
|
+
reason?: string | null;
|
|
164
184
|
}
|
|
165
185
|
|
|
166
186
|
export interface PageOverlayResponse {
|
|
@@ -177,6 +197,11 @@ export interface PageOverlayResponse {
|
|
|
177
197
|
coverageState: BrowserCoverageState;
|
|
178
198
|
relatedFailureCount: number;
|
|
179
199
|
relatedCoverageCount: number;
|
|
200
|
+
coverageBreakdown?: {
|
|
201
|
+
direct: number;
|
|
202
|
+
indirect: number;
|
|
203
|
+
mixed: number;
|
|
204
|
+
};
|
|
180
205
|
};
|
|
181
206
|
failures: FailureOverlayEntry[];
|
|
182
207
|
coverage: CoverageOverlayEntry[];
|
|
@@ -196,6 +221,7 @@ export declare function normalizeBrowserMetadataDocument(value: unknown): Browse
|
|
|
196
221
|
export declare function normalizeCoverageGraphNode(value: unknown): CoverageGraphNode | null;
|
|
197
222
|
export declare function normalizeCoverageGraphEdge(value: unknown): CoverageGraphEdge | null;
|
|
198
223
|
export declare function normalizeCoverageEvidence(value: unknown): CoverageEvidence | null;
|
|
224
|
+
export declare function normalizeCoverageGraphDiagnostic(value: unknown): CoverageGraphDiagnostic | null;
|
|
199
225
|
export declare function normalizeCoverageGraph(value: unknown): CoverageGraph | null;
|
|
200
226
|
export declare function normalizePageOverlayResponse(value: unknown): PageOverlayResponse | null;
|
|
201
227
|
export declare function isPageOverlayResponse(value: unknown): value is PageOverlayResponse;
|
|
@@ -158,6 +158,20 @@ export function normalizeCoverageEvidence(value) {
|
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
const DIAGNOSTIC_LEVELS = new Set(["info", "warn"]);
|
|
162
|
+
|
|
163
|
+
export function normalizeCoverageGraphDiagnostic(value) {
|
|
164
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
165
|
+
const level = normalizeOptionalString(value.level);
|
|
166
|
+
const code = normalizeOptionalString(value.code);
|
|
167
|
+
const filePath = normalizeOptionalString(value.filePath);
|
|
168
|
+
const service = normalizeOptionalString(value.service);
|
|
169
|
+
const message = normalizeOptionalString(value.message);
|
|
170
|
+
if (!level || !code || !filePath || !service || !message) return null;
|
|
171
|
+
if (!DIAGNOSTIC_LEVELS.has(level)) return null;
|
|
172
|
+
return { level, code, filePath, service, message };
|
|
173
|
+
}
|
|
174
|
+
|
|
161
175
|
export function normalizeCoverageGraph(value) {
|
|
162
176
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
163
177
|
const schemaVersion =
|
|
@@ -169,12 +183,16 @@ export function normalizeCoverageGraph(value) {
|
|
|
169
183
|
const evidence = Array.isArray(value.evidence)
|
|
170
184
|
? value.evidence.map(normalizeCoverageEvidence).filter(Boolean)
|
|
171
185
|
: [];
|
|
186
|
+
const diagnostics = Array.isArray(value.diagnostics)
|
|
187
|
+
? value.diagnostics.map(normalizeCoverageGraphDiagnostic).filter(Boolean)
|
|
188
|
+
: [];
|
|
172
189
|
if (nodes.length === 0) return null;
|
|
173
190
|
return {
|
|
174
191
|
schemaVersion,
|
|
175
192
|
nodes,
|
|
176
193
|
edges,
|
|
177
194
|
evidence,
|
|
195
|
+
diagnostics,
|
|
178
196
|
};
|
|
179
197
|
}
|
|
180
198
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
normalizeBrowserTarget,
|
|
7
7
|
normalizeCoverageEvidence,
|
|
8
8
|
normalizeCoverageGraph,
|
|
9
|
+
normalizeCoverageGraphDiagnostic,
|
|
9
10
|
normalizeCoverageGraphEdge,
|
|
10
11
|
normalizeCoverageGraphNode,
|
|
11
12
|
} from "./index.mjs";
|
|
@@ -107,7 +108,79 @@ describe("testkit browser protocol", () => {
|
|
|
107
108
|
});
|
|
108
109
|
});
|
|
109
110
|
|
|
110
|
-
it("normalizes graph
|
|
111
|
+
it("normalizes coverage graph diagnostics", () => {
|
|
112
|
+
expect(
|
|
113
|
+
normalizeCoverageGraphDiagnostic({
|
|
114
|
+
level: "warn",
|
|
115
|
+
code: "no-service-context",
|
|
116
|
+
filePath: "tests/example.int.testkit.ts",
|
|
117
|
+
service: "web",
|
|
118
|
+
message: 'No coverage context available for service "web".',
|
|
119
|
+
})
|
|
120
|
+
).toEqual({
|
|
121
|
+
level: "warn",
|
|
122
|
+
code: "no-service-context",
|
|
123
|
+
filePath: "tests/example.int.testkit.ts",
|
|
124
|
+
service: "web",
|
|
125
|
+
message: 'No coverage context available for service "web".',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(normalizeCoverageGraphDiagnostic({ level: "error", code: "x", filePath: "f", service: "s", message: "m" })).toBeNull();
|
|
129
|
+
expect(normalizeCoverageGraphDiagnostic({ level: "warn", code: "", filePath: "f", service: "s", message: "m" })).toBeNull();
|
|
130
|
+
expect(normalizeCoverageGraphDiagnostic(null)).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("normalizes graph payloads with diagnostics", () => {
|
|
134
|
+
expect(
|
|
135
|
+
normalizeCoverageGraph({
|
|
136
|
+
schemaVersion: TESTKIT_COVERAGE_GRAPH_VERSION,
|
|
137
|
+
nodes: [
|
|
138
|
+
{
|
|
139
|
+
id: "page_view:web:/coverage",
|
|
140
|
+
kind: "page_view",
|
|
141
|
+
service: "web",
|
|
142
|
+
label: "Coverage",
|
|
143
|
+
route: "/coverage",
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
edges: [],
|
|
147
|
+
evidence: [],
|
|
148
|
+
diagnostics: [
|
|
149
|
+
{
|
|
150
|
+
level: "info",
|
|
151
|
+
code: "zero-coverage-inferred",
|
|
152
|
+
filePath: "tests/example.pw.testkit.ts",
|
|
153
|
+
service: "web",
|
|
154
|
+
message: "No routes matched.",
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
})
|
|
158
|
+
).toEqual({
|
|
159
|
+
schemaVersion: TESTKIT_COVERAGE_GRAPH_VERSION,
|
|
160
|
+
nodes: [
|
|
161
|
+
{
|
|
162
|
+
id: "page_view:web:/coverage",
|
|
163
|
+
kind: "page_view",
|
|
164
|
+
service: "web",
|
|
165
|
+
label: "Coverage",
|
|
166
|
+
route: "/coverage",
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
edges: [],
|
|
170
|
+
evidence: [],
|
|
171
|
+
diagnostics: [
|
|
172
|
+
{
|
|
173
|
+
level: "info",
|
|
174
|
+
code: "zero-coverage-inferred",
|
|
175
|
+
filePath: "tests/example.pw.testkit.ts",
|
|
176
|
+
service: "web",
|
|
177
|
+
message: "No routes matched.",
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("normalizes graph payloads without diagnostics field", () => {
|
|
111
184
|
expect(
|
|
112
185
|
normalizeCoverageGraph({
|
|
113
186
|
schemaVersion: TESTKIT_COVERAGE_GRAPH_VERSION,
|
|
@@ -136,6 +209,7 @@ describe("testkit browser protocol", () => {
|
|
|
136
209
|
],
|
|
137
210
|
edges: [],
|
|
138
211
|
evidence: [],
|
|
212
|
+
diagnostics: [],
|
|
139
213
|
});
|
|
140
214
|
});
|
|
141
215
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.62",
|
|
4
4
|
"description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"vitest": "^3.2.4"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@elench/testkit-bridge": "0.1.
|
|
63
|
-
"@elench/testkit-protocol": "0.1.
|
|
62
|
+
"@elench/testkit-bridge": "0.1.62",
|
|
63
|
+
"@elench/testkit-protocol": "0.1.62",
|
|
64
64
|
"@babel/code-frame": "^7.29.0",
|
|
65
65
|
"@oclif/core": "^4.10.6",
|
|
66
66
|
"esbuild": "^0.25.11",
|