@elench/testkit 0.1.60 → 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.
Files changed (36) hide show
  1. package/lib/config/database.mjs +53 -0
  2. package/lib/config/database.test.mjs +29 -0
  3. package/lib/config/discovery-config.mjs +13 -0
  4. package/lib/config/env.mjs +55 -0
  5. package/lib/config/env.test.mjs +40 -0
  6. package/lib/config/index.mjs +21 -807
  7. package/lib/config/paths.mjs +28 -0
  8. package/lib/config/paths.test.mjs +27 -0
  9. package/lib/config/runtime.mjs +241 -0
  10. package/lib/config/runtime.test.mjs +56 -0
  11. package/lib/config/skip-config.mjs +189 -0
  12. package/lib/config/skip-config.test.mjs +63 -0
  13. package/lib/config/telemetry.mjs +28 -0
  14. package/lib/config/validation.mjs +124 -0
  15. package/lib/coverage/backend-discovery.mjs +183 -0
  16. package/lib/coverage/backend-discovery.test.mjs +52 -0
  17. package/lib/coverage/evidence.mjs +147 -0
  18. package/lib/coverage/evidence.test.mjs +77 -0
  19. package/lib/coverage/fs-walk.mjs +64 -0
  20. package/lib/coverage/graph-builder.mjs +181 -0
  21. package/lib/coverage/index.mjs +1 -816
  22. package/lib/coverage/index.test.mjs +330 -14
  23. package/lib/coverage/next-discovery.mjs +174 -0
  24. package/lib/coverage/next-static-analysis.mjs +763 -0
  25. package/lib/coverage/routing.mjs +86 -0
  26. package/lib/coverage/routing.test.mjs +52 -0
  27. package/lib/coverage/shared.mjs +198 -0
  28. package/lib/coverage/shared.test.mjs +39 -0
  29. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  30. package/node_modules/@elench/testkit-bridge/src/index.mjs +156 -13
  31. package/node_modules/@elench/testkit-bridge/src/index.test.mjs +39 -5
  32. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  33. package/node_modules/@elench/testkit-protocol/src/index.d.ts +26 -0
  34. package/node_modules/@elench/testkit-protocol/src/index.mjs +18 -0
  35. package/node_modules/@elench/testkit-protocol/src/index.test.mjs +75 -1
  36. package/package.json +5 -4
@@ -0,0 +1,86 @@
1
+ import path from "path";
2
+ import { normalizePath, normalizeRoute } from "./shared.mjs";
3
+
4
+ export function inferPageRouteFromTestFile(filePath, nextAppRoot, serviceRoot) {
5
+ const appRelativeSegments = extractRouteOwnerSegments(filePath, nextAppRoot, serviceRoot);
6
+ if (!appRelativeSegments) return null;
7
+ return normalizeRouteSegments(appRelativeSegments);
8
+ }
9
+
10
+ export function inferApiRoutesFromTestFile(filePath, nextAppRoot, serviceRoot) {
11
+ const normalized = normalizePath(filePath);
12
+ const appRootRelative = normalizePath(path.relative(serviceRoot, nextAppRoot));
13
+ const appPrefix = appRootRelative === "." ? "" : `${appRootRelative}/`;
14
+ const apiMarker = `${appPrefix}api/`;
15
+ const markerIndex = normalized.indexOf(apiMarker);
16
+ if (markerIndex === -1) return [];
17
+ const rest = normalized.slice(markerIndex + apiMarker.length);
18
+ const segments = rest.split("/");
19
+ const testkitIndex = segments.indexOf("__testkit__");
20
+ if (testkitIndex === -1) return [];
21
+ return [normalizeRouteSegments(segments.slice(0, testkitIndex))];
22
+ }
23
+
24
+ export function inferOwnerDirectoryFromTestFile(filePath) {
25
+ const normalized = normalizePath(filePath);
26
+ const segments = normalized.split("/");
27
+ const testkitIndex = segments.indexOf("__testkit__");
28
+ if (testkitIndex <= 0) return null;
29
+ return segments.slice(0, testkitIndex).join("/");
30
+ }
31
+
32
+ export function pathMatchesOwner(candidatePath, ownerDirectory) {
33
+ const normalizedCandidate = normalizePath(candidatePath);
34
+ const normalizedOwner = normalizePath(ownerDirectory);
35
+ return normalizedCandidate === normalizedOwner || normalizedCandidate.startsWith(`${normalizedOwner}/`);
36
+ }
37
+
38
+ export function extractRouteOwnerSegments(filePath, nextAppRoot, serviceRoot) {
39
+ const normalized = normalizePath(filePath);
40
+ const appRootRelative = normalizePath(path.relative(serviceRoot, nextAppRoot));
41
+ const appPrefix = appRootRelative === "." ? "" : `${appRootRelative}/`;
42
+ if (!normalized.startsWith(appPrefix)) return null;
43
+ const rest = normalized.slice(appPrefix.length);
44
+ const segments = rest.split("/");
45
+ const testkitIndex = segments.indexOf("__testkit__");
46
+ if (testkitIndex === -1) return null;
47
+ return segments.slice(0, testkitIndex);
48
+ }
49
+
50
+ export function routeFromAppFile(nextAppRoot, filePath) {
51
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(nextAppRoot, filePath);
52
+ const relative = normalizePath(path.relative(nextAppRoot, absolutePath));
53
+ const segments = relative.split("/");
54
+ segments.pop();
55
+ return normalizeRouteSegments(segments);
56
+ }
57
+
58
+ export function routeFromApiFile(nextAppRoot, filePath) {
59
+ const appRelative = normalizePath(path.relative(nextAppRoot, path.dirname(filePath)));
60
+ const segments = appRelative.split("/").filter(Boolean);
61
+ if (segments[0] === "api") segments.shift();
62
+ return normalizeRouteSegments(segments);
63
+ }
64
+
65
+ export function normalizeRouteSegments(segments) {
66
+ const normalizedSegments = segments
67
+ .filter(Boolean)
68
+ .filter((segment) => !segment.startsWith("(") || !segment.endsWith(")"))
69
+ .filter((segment) => !segment.startsWith("@"))
70
+ .filter((segment) => segment !== "page" && segment !== "route");
71
+ return normalizeRoute(`/${normalizedSegments.join("/")}`);
72
+ }
73
+
74
+ export function apiRouteLookupKey(method, route) {
75
+ return `${method}:${route}`;
76
+ }
77
+
78
+ export function toApiRequestPath(route) {
79
+ return normalizeRoute(`/api${route === "/" ? "" : route}`);
80
+ }
81
+
82
+ export function toSelectionType(type, framework) {
83
+ if (framework === "playwright") return "pw";
84
+ if (type === "integration") return "int";
85
+ return type;
86
+ }
@@ -0,0 +1,52 @@
1
+ import path from "path";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ inferApiRoutesFromTestFile,
5
+ inferOwnerDirectoryFromTestFile,
6
+ inferPageRouteFromTestFile,
7
+ normalizeRouteSegments,
8
+ pathMatchesOwner,
9
+ routeFromApiFile,
10
+ routeFromAppFile,
11
+ } from "./routing.mjs";
12
+
13
+ describe("coverage routing helpers", () => {
14
+ it("normalizes route segments by stripping route groups and slots", () => {
15
+ expect(normalizeRouteSegments(["(marketing)", "@modal", "campaigns", "[campaignId]", "page"])).toBe(
16
+ "/campaigns/[campaignId]"
17
+ );
18
+ });
19
+
20
+ it("infers page ownership routes from colocated __testkit__ files", () => {
21
+ const serviceRoot = "/repo";
22
+ const nextAppRoot = path.join(serviceRoot, "src", "app");
23
+
24
+ expect(
25
+ inferPageRouteFromTestFile("src/app/campaigns/[campaignId]/__testkit__/details.pw.testkit.ts", nextAppRoot, serviceRoot)
26
+ ).toBe("/campaigns/[campaignId]");
27
+ });
28
+
29
+ it("infers API routes from colocated __testkit__ files", () => {
30
+ const serviceRoot = "/repo";
31
+ const nextAppRoot = path.join(serviceRoot, "src", "app");
32
+
33
+ expect(
34
+ inferApiRoutesFromTestFile("src/app/api/campaigns/__testkit__/create.int.testkit.ts", nextAppRoot, serviceRoot)
35
+ ).toEqual(["/campaigns"]);
36
+ });
37
+
38
+ it("derives page and API routes from Next app file paths", () => {
39
+ const nextAppRoot = "/repo/src/app";
40
+
41
+ expect(routeFromAppFile(nextAppRoot, "/repo/src/app/settings/page.tsx")).toBe("/settings");
42
+ expect(routeFromApiFile(nextAppRoot, "/repo/src/app/api/campaigns/route.ts")).toBe("/campaigns");
43
+ });
44
+
45
+ it("matches nested ownership directories for DAL evidence", () => {
46
+ expect(inferOwnerDirectoryFromTestFile("src/backend/data/campaigns/__testkit__/save.dal.testkit.ts")).toBe(
47
+ "src/backend/data/campaigns"
48
+ );
49
+ expect(pathMatchesOwner("src/backend/data/campaigns/index.ts", "src/backend/data/campaigns")).toBe(true);
50
+ expect(pathMatchesOwner("src/backend/data/other/index.ts", "src/backend/data/campaigns")).toBe(false);
51
+ });
52
+ });
@@ -0,0 +1,198 @@
1
+ import path from "path";
2
+ import { TESTKIT_COVERAGE_GRAPH_VERSION } from "@elench/testkit-protocol";
3
+
4
+ export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
5
+ export const HTTP_WRAPPER_METHODS = {
6
+ getJson: "GET",
7
+ postJson: "POST",
8
+ putJson: "PUT",
9
+ patchJson: "PATCH",
10
+ deleteJson: "DELETE",
11
+ };
12
+
13
+ export function createEmptyGraph() {
14
+ return {
15
+ schemaVersion: TESTKIT_COVERAGE_GRAPH_VERSION,
16
+ nodes: [],
17
+ edges: [],
18
+ evidence: [],
19
+ diagnostics: [],
20
+ };
21
+ }
22
+
23
+ export function appendGraph(graph, fragment) {
24
+ const nodeIds = new Set(graph.nodes.map((node) => node.id));
25
+ for (const node of fragment.nodes) {
26
+ if (nodeIds.has(node.id)) continue;
27
+ nodeIds.add(node.id);
28
+ graph.nodes.push(node);
29
+ }
30
+ const edgeIds = new Set(graph.edges.map((edge) => edge.id));
31
+ for (const edge of fragment.edges) {
32
+ if (edgeIds.has(edge.id)) continue;
33
+ edgeIds.add(edge.id);
34
+ graph.edges.push(edge);
35
+ }
36
+ }
37
+
38
+ export function createTestFileNode(graph, entry) {
39
+ const nodeId = `test_file:${entry.serviceName}:${entry.filePath}`;
40
+ if (!graph.nodes.some((node) => node.id === nodeId)) {
41
+ graph.nodes.push({
42
+ id: nodeId,
43
+ kind: "test_file",
44
+ service: entry.serviceName,
45
+ label: path.basename(entry.filePath),
46
+ filePath: entry.filePath,
47
+ metadata: {
48
+ suiteName: entry.suiteName,
49
+ framework: entry.framework,
50
+ type: toSelectionType(entry.type, entry.framework),
51
+ },
52
+ });
53
+ }
54
+ return nodeId;
55
+ }
56
+
57
+ export function apiRouteLookupKey(method, route) {
58
+ return `${method}:${route}`;
59
+ }
60
+
61
+ export function toApiRequestPath(route) {
62
+ return normalizeRoute(`/api${route === "/" ? "" : route}`);
63
+ }
64
+
65
+ export function toSelectionType(type, framework) {
66
+ if (framework === "playwright") return "pw";
67
+ if (type === "integration") return "int";
68
+ return type;
69
+ }
70
+
71
+ export function pageLabelFromRoute(route) {
72
+ if (route === "/") return "Home";
73
+ return route
74
+ .split("/")
75
+ .filter(Boolean)
76
+ .map((segment) => {
77
+ if (segment.startsWith("[") && segment.endsWith("]")) {
78
+ return segment.slice(1, -1);
79
+ }
80
+ return segment;
81
+ })
82
+ .map((segment) => segment.replace(/[-_]+/gu, " "))
83
+ .map((segment) => segment.replace(/^\w/u, (char) => char.toUpperCase()))
84
+ .join(" ");
85
+ }
86
+
87
+ export function normalizeRoute(value) {
88
+ const trimmed = String(value || "/").trim();
89
+ if (!trimmed || trimmed === "/") return "/";
90
+ const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
91
+ return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, "") : withLeadingSlash;
92
+ }
93
+
94
+ export function normalizePath(filePath) {
95
+ return filePath.split(path.sep).join("/");
96
+ }
97
+
98
+ export function modulePathKey(filePath) {
99
+ const normalized = normalizePath(filePath);
100
+ const ext = path.extname(normalized);
101
+ if (path.basename(normalized, ext) === "index") {
102
+ return normalizePath(path.dirname(normalized));
103
+ }
104
+ return normalized.slice(0, normalized.length - ext.length);
105
+ }
106
+
107
+ export function escapeRegExp(value) {
108
+ return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
109
+ }
110
+
111
+ export function isServerActionFile(content) {
112
+ const trimmed = content.trimStart();
113
+ return trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'");
114
+ }
115
+
116
+ export function isBackendSpecifier(specifier) {
117
+ return (
118
+ specifier.startsWith("@/backend/server/") ||
119
+ specifier.includes("/backend/server/") ||
120
+ specifier.startsWith("./backend/server/") ||
121
+ specifier.startsWith("../backend/server/")
122
+ );
123
+ }
124
+
125
+ export function isDataSpecifier(specifier) {
126
+ return (
127
+ specifier.startsWith("@/backend/data/") ||
128
+ specifier.startsWith("@/backend/dal/") ||
129
+ specifier.includes("/backend/data/") ||
130
+ specifier.includes("/backend/dal/") ||
131
+ specifier.includes("/db/") ||
132
+ specifier.includes("/repository/") ||
133
+ specifier.includes("/repositories/")
134
+ );
135
+ }
136
+
137
+ export function hasWord(content, word) {
138
+ if (!content || !word) return false;
139
+ return new RegExp(`\\b${escapeRegExp(word)}\\b`, "u").test(content);
140
+ }
141
+
142
+ export function dedupeRequests(requests) {
143
+ const seen = new Set();
144
+ return requests.filter((entry) => {
145
+ const key = `${entry.method}:${entry.path}`;
146
+ if (seen.has(key)) return false;
147
+ seen.add(key);
148
+ return true;
149
+ });
150
+ }
151
+
152
+ export function dedupeNodes(nodes) {
153
+ const seen = new Set();
154
+ return nodes.filter((node) => {
155
+ if (seen.has(node.id)) return false;
156
+ seen.add(node.id);
157
+ return true;
158
+ });
159
+ }
160
+
161
+ export function dedupeEdges(edges) {
162
+ const seen = new Set();
163
+ return edges.filter((edge) => {
164
+ if (seen.has(edge.id)) return false;
165
+ seen.add(edge.id);
166
+ return true;
167
+ });
168
+ }
169
+
170
+ export function dedupeTargets(targets) {
171
+ const seen = new Set();
172
+ return targets.filter((target) => {
173
+ const key = `${target.kind}:${target.value}`;
174
+ if (seen.has(key)) return false;
175
+ seen.add(key);
176
+ return true;
177
+ });
178
+ }
179
+
180
+ export function dedupeBackendImports(entries) {
181
+ const seen = new Set();
182
+ return entries.filter((entry) => {
183
+ const key = `${entry.importName}:${entry.node.filePath || ""}:${entry.node.label}`;
184
+ if (seen.has(key)) return false;
185
+ seen.add(key);
186
+ return true;
187
+ });
188
+ }
189
+
190
+ export function dedupeDataImports(entries) {
191
+ const seen = new Set();
192
+ return entries.filter((entry) => {
193
+ const key = `${entry.importName}:${entry.node.filePath || ""}:${entry.node.label}`;
194
+ if (seen.has(key)) return false;
195
+ seen.add(key);
196
+ return true;
197
+ });
198
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ dedupeTargets,
4
+ modulePathKey,
5
+ pageLabelFromRoute,
6
+ toApiRequestPath,
7
+ toSelectionType,
8
+ } from "./shared.mjs";
9
+
10
+ describe("coverage shared helpers", () => {
11
+ it("builds readable page labels from routes", () => {
12
+ expect(pageLabelFromRoute("/")).toBe("Home");
13
+ expect(pageLabelFromRoute("/campaigns/[campaignId]")).toBe("Campaigns CampaignId");
14
+ });
15
+
16
+ it("collapses index modules into stable module path keys", () => {
17
+ expect(modulePathKey("src/backend/server/campaigns/index.ts")).toBe("src/backend/server/campaigns");
18
+ expect(modulePathKey("src/backend/server/campaigns.ts")).toBe("src/backend/server/campaigns");
19
+ });
20
+
21
+ it("dedupes overlay targets by kind and value", () => {
22
+ expect(
23
+ dedupeTargets([
24
+ { kind: "testId", value: "save-button", confidence: "high" },
25
+ { kind: "testId", value: "save-button", confidence: "medium" },
26
+ { kind: "text", value: "Save", confidence: "medium" },
27
+ ])
28
+ ).toEqual([
29
+ { kind: "testId", value: "save-button", confidence: "high" },
30
+ { kind: "text", value: "Save", confidence: "medium" },
31
+ ]);
32
+ });
33
+
34
+ it("normalizes request paths and selection types", () => {
35
+ expect(toApiRequestPath("/campaigns")).toBe("/api/campaigns");
36
+ expect(toSelectionType("integration", "http")).toBe("int");
37
+ expect(toSelectionType("ui", "playwright")).toBe("pw");
38
+ });
39
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.60",
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.60"
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,
@@ -260,15 +262,94 @@ function buildGraphProjection(context, page, matchedServiceName) {
260
262
  return { coverage: [], failures: [] };
261
263
  }
262
264
 
263
- const reachableNodeIds = collectReachableNodeIds(pageNode.id, outgoing);
264
- const relevantEvidence = evidence.filter((entry) =>
265
- intersects(entry.coveredNodeIds || [], reachableNodeIds)
266
- );
265
+ const failedFiles = collectFailedFiles(context.runArtifact);
266
+ const failureByFile = new Map(failedFiles.map((entry) => [entry.filePath, entry]));
267
+ const surfaceNodes = (outgoing.get(pageNode.id) || [])
268
+ .map((nodeId) => nodeById.get(nodeId))
269
+ .filter((node) => node?.kind === "ui_surface");
270
+ const pageReachableNodeIds = collectReachableNodeIds(pageNode.id, outgoing);
271
+
272
+ if (surfaceNodes.length === 0) {
273
+ const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], pageReachableNodeIds));
274
+ return buildPageLevelProjection({
275
+ pageNode,
276
+ nodeById,
277
+ relevantEvidence,
278
+ pageReachableNodeIds,
279
+ discoveryByFile,
280
+ failureByFile,
281
+ runArtifact: context.runArtifact,
282
+ });
283
+ }
284
+
285
+ const coverage = [];
286
+ const failures = [];
287
+
288
+ for (const surfaceNode of surfaceNodes.sort((left, right) => left.id.localeCompare(right.id))) {
289
+ const surfaceReachableNodeIds = collectReachableNodeIds(surfaceNode.id, outgoing);
290
+ const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], surfaceReachableNodeIds));
291
+ const supportingTests = relevantEvidence
292
+ .map((entry) => buildSupportingTestRef(entry, discoveryByFile, context.runArtifact))
293
+ .filter(Boolean);
294
+
295
+ if (supportingTests.length > 0) {
296
+ const supportKind = inferCoverageSupportKind(supportingTests);
297
+ coverage.push({
298
+ id: surfaceNode.id,
299
+ kind: surfaceNode.kind,
300
+ label: surfaceNode.label,
301
+ service: surfaceNode.service,
302
+ route: pageNode.route || null,
303
+ targets: collectTargetsForEvidence(relevantEvidence, surfaceNode.target),
304
+ supportingTests,
305
+ viaNodes: collectViaNodes(relevantEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
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),
311
+ });
312
+ }
313
+
314
+ const failedEvidence = relevantEvidence.filter((entry) => failureByFile.has(entry.testFilePath));
315
+ if (failedEvidence.length === 0) continue;
316
+
317
+ const failedTests = failedEvidence
318
+ .map((entry) => {
319
+ const supporting = buildSupportingTestRef(entry, discoveryByFile, context.runArtifact);
320
+ const failed = failureByFile.get(entry.testFilePath);
321
+ if (!supporting) return null;
322
+ return {
323
+ ...supporting,
324
+ error: failed?.error || supporting.error || null,
325
+ status: "failed",
326
+ };
327
+ })
328
+ .filter(Boolean);
329
+
330
+ failures.push({
331
+ id: `failure:${surfaceNode.id}`,
332
+ kind: surfaceNode.kind,
333
+ label: surfaceNode.label,
334
+ service: surfaceNode.service,
335
+ route: pageNode.route || null,
336
+ targets: collectTargetsForEvidence(failedEvidence, surfaceNode.target),
337
+ failedTests,
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),
342
+ });
343
+ }
344
+
345
+ return { coverage, failures };
346
+ }
347
+
348
+ function buildPageLevelProjection({ pageNode, nodeById, relevantEvidence, pageReachableNodeIds, discoveryByFile, failureByFile, runArtifact }) {
267
349
  const supportingTests = relevantEvidence
268
- .map((entry) => buildSupportingTestRef(entry, discoveryByFile, context.runArtifact))
350
+ .map((entry) => buildSupportingTestRef(entry, discoveryByFile, runArtifact))
269
351
  .filter(Boolean);
270
352
 
271
- const viaNodes = collectViaNodes(relevantEvidence, reachableNodeIds, nodeById, pageNode.id);
272
353
  const coverage = supportingTests.length > 0
273
354
  ? [
274
355
  {
@@ -279,19 +360,21 @@ function buildGraphProjection(context, page, matchedServiceName) {
279
360
  route: pageNode.route || null,
280
361
  targets: collectTargetsForEvidence(relevantEvidence, pageNode.target),
281
362
  supportingTests,
282
- viaNodes,
363
+ viaNodes: collectViaNodes(relevantEvidence, pageReachableNodeIds, nodeById, new Set([pageNode.id])),
283
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),
284
369
  },
285
370
  ]
286
371
  : [];
287
372
 
288
- const failedFiles = collectFailedFiles(context.runArtifact);
289
- const failureByFile = new Map(failedFiles.map((entry) => [entry.filePath, entry]));
290
373
  const failures = relevantEvidence
291
374
  .filter((entry) => failureByFile.has(entry.testFilePath))
292
375
  .map((entry) => {
293
376
  const failed = failureByFile.get(entry.testFilePath);
294
- const supporting = buildSupportingTestRef(entry, discoveryByFile, context.runArtifact);
377
+ const supporting = buildSupportingTestRef(entry, discoveryByFile, runArtifact);
295
378
  return {
296
379
  id: `failure:${entry.testFilePath}`,
297
380
  kind: pageNode.kind,
@@ -300,7 +383,10 @@ function buildGraphProjection(context, page, matchedServiceName) {
300
383
  route: pageNode.route || null,
301
384
  targets: collectTargetsForEvidence([entry], pageNode.target),
302
385
  failedTests: supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : [],
303
- viaNodes: collectViaNodes([entry], reachableNodeIds, nodeById, pageNode.id),
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" }] : []),
304
390
  };
305
391
  });
306
392
 
@@ -331,11 +417,11 @@ function collectReachableNodeIds(startNodeId, outgoing) {
331
417
  return visited;
332
418
  }
333
419
 
334
- function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, pageNodeId) {
420
+ function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, excludedNodeIds = new Set()) {
335
421
  const nodes = new Map();
336
422
  for (const entry of evidenceEntries) {
337
423
  for (const nodeId of entry.coveredNodeIds || []) {
338
- if (!reachableNodeIds.has(nodeId) || nodeId === pageNodeId) continue;
424
+ if (!reachableNodeIds.has(nodeId) || excludedNodeIds.has(nodeId)) continue;
339
425
  const node = nodeById.get(nodeId);
340
426
  if (!node) continue;
341
427
  nodes.set(nodeId, {
@@ -406,3 +492,60 @@ function pathBaseName(filePath) {
406
492
  const parts = String(filePath || "").split("/");
407
493
  return parts[parts.length - 1] || filePath;
408
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
+ }