@elench/testkit 0.1.59 → 0.1.61

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 +146 -0
  18. package/lib/coverage/evidence.test.mjs +64 -0
  19. package/lib/coverage/fs-walk.mjs +64 -0
  20. package/lib/coverage/graph-builder.mjs +167 -0
  21. package/lib/coverage/index.mjs +1 -776
  22. package/lib/coverage/index.test.mjs +183 -14
  23. package/lib/coverage/next-discovery.mjs +174 -0
  24. package/lib/coverage/next-static-analysis.mjs +728 -0
  25. package/lib/coverage/routing.mjs +86 -0
  26. package/lib/coverage/routing.test.mjs +52 -0
  27. package/lib/coverage/shared.mjs +197 -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 +101 -15
  31. package/node_modules/@elench/testkit-bridge/src/index.test.mjs +36 -6
  32. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  33. package/node_modules/@elench/testkit-protocol/src/index.d.ts +1 -0
  34. package/node_modules/@elench/testkit-protocol/src/index.mjs +3 -1
  35. package/node_modules/@elench/testkit-protocol/src/index.test.mjs +14 -0
  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,197 @@
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
+ };
20
+ }
21
+
22
+ export function appendGraph(graph, fragment) {
23
+ const nodeIds = new Set(graph.nodes.map((node) => node.id));
24
+ for (const node of fragment.nodes) {
25
+ if (nodeIds.has(node.id)) continue;
26
+ nodeIds.add(node.id);
27
+ graph.nodes.push(node);
28
+ }
29
+ const edgeIds = new Set(graph.edges.map((edge) => edge.id));
30
+ for (const edge of fragment.edges) {
31
+ if (edgeIds.has(edge.id)) continue;
32
+ edgeIds.add(edge.id);
33
+ graph.edges.push(edge);
34
+ }
35
+ }
36
+
37
+ export function createTestFileNode(graph, entry) {
38
+ const nodeId = `test_file:${entry.serviceName}:${entry.filePath}`;
39
+ if (!graph.nodes.some((node) => node.id === nodeId)) {
40
+ graph.nodes.push({
41
+ id: nodeId,
42
+ kind: "test_file",
43
+ service: entry.serviceName,
44
+ label: path.basename(entry.filePath),
45
+ filePath: entry.filePath,
46
+ metadata: {
47
+ suiteName: entry.suiteName,
48
+ framework: entry.framework,
49
+ type: toSelectionType(entry.type, entry.framework),
50
+ },
51
+ });
52
+ }
53
+ return nodeId;
54
+ }
55
+
56
+ export function apiRouteLookupKey(method, route) {
57
+ return `${method}:${route}`;
58
+ }
59
+
60
+ export function toApiRequestPath(route) {
61
+ return normalizeRoute(`/api${route === "/" ? "" : route}`);
62
+ }
63
+
64
+ export function toSelectionType(type, framework) {
65
+ if (framework === "playwright") return "pw";
66
+ if (type === "integration") return "int";
67
+ return type;
68
+ }
69
+
70
+ export function pageLabelFromRoute(route) {
71
+ if (route === "/") return "Home";
72
+ return route
73
+ .split("/")
74
+ .filter(Boolean)
75
+ .map((segment) => {
76
+ if (segment.startsWith("[") && segment.endsWith("]")) {
77
+ return segment.slice(1, -1);
78
+ }
79
+ return segment;
80
+ })
81
+ .map((segment) => segment.replace(/[-_]+/gu, " "))
82
+ .map((segment) => segment.replace(/^\w/u, (char) => char.toUpperCase()))
83
+ .join(" ");
84
+ }
85
+
86
+ export function normalizeRoute(value) {
87
+ const trimmed = String(value || "/").trim();
88
+ if (!trimmed || trimmed === "/") return "/";
89
+ const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
90
+ return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, "") : withLeadingSlash;
91
+ }
92
+
93
+ export function normalizePath(filePath) {
94
+ return filePath.split(path.sep).join("/");
95
+ }
96
+
97
+ export function modulePathKey(filePath) {
98
+ const normalized = normalizePath(filePath);
99
+ const ext = path.extname(normalized);
100
+ if (path.basename(normalized, ext) === "index") {
101
+ return normalizePath(path.dirname(normalized));
102
+ }
103
+ return normalized.slice(0, normalized.length - ext.length);
104
+ }
105
+
106
+ export function escapeRegExp(value) {
107
+ return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
108
+ }
109
+
110
+ export function isServerActionFile(content) {
111
+ const trimmed = content.trimStart();
112
+ return trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'");
113
+ }
114
+
115
+ export function isBackendSpecifier(specifier) {
116
+ return (
117
+ specifier.startsWith("@/backend/server/") ||
118
+ specifier.includes("/backend/server/") ||
119
+ specifier.startsWith("./backend/server/") ||
120
+ specifier.startsWith("../backend/server/")
121
+ );
122
+ }
123
+
124
+ export function isDataSpecifier(specifier) {
125
+ return (
126
+ specifier.startsWith("@/backend/data/") ||
127
+ specifier.startsWith("@/backend/dal/") ||
128
+ specifier.includes("/backend/data/") ||
129
+ specifier.includes("/backend/dal/") ||
130
+ specifier.includes("/db/") ||
131
+ specifier.includes("/repository/") ||
132
+ specifier.includes("/repositories/")
133
+ );
134
+ }
135
+
136
+ export function hasWord(content, word) {
137
+ if (!content || !word) return false;
138
+ return new RegExp(`\\b${escapeRegExp(word)}\\b`, "u").test(content);
139
+ }
140
+
141
+ export function dedupeRequests(requests) {
142
+ const seen = new Set();
143
+ return requests.filter((entry) => {
144
+ const key = `${entry.method}:${entry.path}`;
145
+ if (seen.has(key)) return false;
146
+ seen.add(key);
147
+ return true;
148
+ });
149
+ }
150
+
151
+ export function dedupeNodes(nodes) {
152
+ const seen = new Set();
153
+ return nodes.filter((node) => {
154
+ if (seen.has(node.id)) return false;
155
+ seen.add(node.id);
156
+ return true;
157
+ });
158
+ }
159
+
160
+ export function dedupeEdges(edges) {
161
+ const seen = new Set();
162
+ return edges.filter((edge) => {
163
+ if (seen.has(edge.id)) return false;
164
+ seen.add(edge.id);
165
+ return true;
166
+ });
167
+ }
168
+
169
+ export function dedupeTargets(targets) {
170
+ const seen = new Set();
171
+ return targets.filter((target) => {
172
+ const key = `${target.kind}:${target.value}`;
173
+ if (seen.has(key)) return false;
174
+ seen.add(key);
175
+ return true;
176
+ });
177
+ }
178
+
179
+ export function dedupeBackendImports(entries) {
180
+ const seen = new Set();
181
+ return entries.filter((entry) => {
182
+ const key = `${entry.importName}:${entry.node.filePath || ""}:${entry.node.label}`;
183
+ if (seen.has(key)) return false;
184
+ seen.add(key);
185
+ return true;
186
+ });
187
+ }
188
+
189
+ export function dedupeDataImports(entries) {
190
+ const seen = new Set();
191
+ return entries.filter((entry) => {
192
+ const key = `${entry.importName}:${entry.node.filePath || ""}:${entry.node.label}`;
193
+ if (seen.has(key)) return false;
194
+ seen.add(key);
195
+ return true;
196
+ });
197
+ }
@@ -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.59",
3
+ "version": "0.1.61",
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.59"
14
+ "@elench/testkit-protocol": "0.1.61"
15
15
  },
16
16
  "private": false
17
17
  }
@@ -260,15 +260,86 @@ function buildGraphProjection(context, page, matchedServiceName) {
260
260
  return { coverage: [], failures: [] };
261
261
  }
262
262
 
263
- const reachableNodeIds = collectReachableNodeIds(pageNode.id, outgoing);
264
- const relevantEvidence = evidence.filter((entry) =>
265
- intersects(entry.coveredNodeIds || [], reachableNodeIds)
266
- );
263
+ const failedFiles = collectFailedFiles(context.runArtifact);
264
+ const failureByFile = new Map(failedFiles.map((entry) => [entry.filePath, entry]));
265
+ const surfaceNodes = (outgoing.get(pageNode.id) || [])
266
+ .map((nodeId) => nodeById.get(nodeId))
267
+ .filter((node) => node?.kind === "ui_surface");
268
+ const pageReachableNodeIds = collectReachableNodeIds(pageNode.id, outgoing);
269
+
270
+ if (surfaceNodes.length === 0) {
271
+ const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], pageReachableNodeIds));
272
+ return buildPageLevelProjection({
273
+ pageNode,
274
+ nodeById,
275
+ relevantEvidence,
276
+ pageReachableNodeIds,
277
+ discoveryByFile,
278
+ failureByFile,
279
+ runArtifact: context.runArtifact,
280
+ });
281
+ }
282
+
283
+ const coverage = [];
284
+ const failures = [];
285
+
286
+ for (const surfaceNode of surfaceNodes.sort((left, right) => left.id.localeCompare(right.id))) {
287
+ const surfaceReachableNodeIds = collectReachableNodeIds(surfaceNode.id, outgoing);
288
+ const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], surfaceReachableNodeIds));
289
+ const supportingTests = relevantEvidence
290
+ .map((entry) => buildSupportingTestRef(entry, discoveryByFile, context.runArtifact))
291
+ .filter(Boolean);
292
+
293
+ if (supportingTests.length > 0) {
294
+ coverage.push({
295
+ id: surfaceNode.id,
296
+ kind: surfaceNode.kind,
297
+ label: surfaceNode.label,
298
+ service: surfaceNode.service,
299
+ route: pageNode.route || null,
300
+ targets: collectTargetsForEvidence(relevantEvidence, surfaceNode.target),
301
+ supportingTests,
302
+ viaNodes: collectViaNodes(relevantEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
303
+ confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
304
+ });
305
+ }
306
+
307
+ const failedEvidence = relevantEvidence.filter((entry) => failureByFile.has(entry.testFilePath));
308
+ if (failedEvidence.length === 0) continue;
309
+
310
+ const failedTests = failedEvidence
311
+ .map((entry) => {
312
+ const supporting = buildSupportingTestRef(entry, discoveryByFile, context.runArtifact);
313
+ const failed = failureByFile.get(entry.testFilePath);
314
+ if (!supporting) return null;
315
+ return {
316
+ ...supporting,
317
+ error: failed?.error || supporting.error || null,
318
+ status: "failed",
319
+ };
320
+ })
321
+ .filter(Boolean);
322
+
323
+ failures.push({
324
+ id: `failure:${surfaceNode.id}`,
325
+ kind: surfaceNode.kind,
326
+ label: surfaceNode.label,
327
+ service: surfaceNode.service,
328
+ route: pageNode.route || null,
329
+ targets: collectTargetsForEvidence(failedEvidence, surfaceNode.target),
330
+ failedTests,
331
+ viaNodes: collectViaNodes(failedEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
332
+ });
333
+ }
334
+
335
+ return { coverage, failures };
336
+ }
337
+
338
+ function buildPageLevelProjection({ pageNode, nodeById, relevantEvidence, pageReachableNodeIds, discoveryByFile, failureByFile, runArtifact }) {
267
339
  const supportingTests = relevantEvidence
268
- .map((entry) => buildSupportingTestRef(entry, discoveryByFile, context.runArtifact))
340
+ .map((entry) => buildSupportingTestRef(entry, discoveryByFile, runArtifact))
269
341
  .filter(Boolean);
270
342
 
271
- const viaNodes = collectViaNodes(relevantEvidence, reachableNodeIds, nodeById, pageNode.id);
272
343
  const coverage = supportingTests.length > 0
273
344
  ? [
274
345
  {
@@ -277,30 +348,28 @@ function buildGraphProjection(context, page, matchedServiceName) {
277
348
  label: pageNode.label,
278
349
  service: pageNode.service,
279
350
  route: pageNode.route || null,
280
- targets: pageNode.target ? [pageNode.target] : [],
351
+ targets: collectTargetsForEvidence(relevantEvidence, pageNode.target),
281
352
  supportingTests,
282
- viaNodes,
353
+ viaNodes: collectViaNodes(relevantEvidence, pageReachableNodeIds, nodeById, new Set([pageNode.id])),
283
354
  confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
284
355
  },
285
356
  ]
286
357
  : [];
287
358
 
288
- const failedFiles = collectFailedFiles(context.runArtifact);
289
- const failureByFile = new Map(failedFiles.map((entry) => [entry.filePath, entry]));
290
359
  const failures = relevantEvidence
291
360
  .filter((entry) => failureByFile.has(entry.testFilePath))
292
361
  .map((entry) => {
293
362
  const failed = failureByFile.get(entry.testFilePath);
294
- const supporting = buildSupportingTestRef(entry, discoveryByFile, context.runArtifact);
363
+ const supporting = buildSupportingTestRef(entry, discoveryByFile, runArtifact);
295
364
  return {
296
365
  id: `failure:${entry.testFilePath}`,
297
366
  kind: pageNode.kind,
298
367
  label: supporting?.label || pageNode.label,
299
368
  service: pageNode.service,
300
369
  route: pageNode.route || null,
301
- targets: pageNode.target ? [pageNode.target] : [],
370
+ targets: collectTargetsForEvidence([entry], pageNode.target),
302
371
  failedTests: supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : [],
303
- viaNodes: collectViaNodes([entry], reachableNodeIds, nodeById, pageNode.id),
372
+ viaNodes: collectViaNodes([entry], pageReachableNodeIds, nodeById, new Set([pageNode.id])),
304
373
  };
305
374
  });
306
375
 
@@ -331,11 +400,11 @@ function collectReachableNodeIds(startNodeId, outgoing) {
331
400
  return visited;
332
401
  }
333
402
 
334
- function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, pageNodeId) {
403
+ function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, excludedNodeIds = new Set()) {
335
404
  const nodes = new Map();
336
405
  for (const entry of evidenceEntries) {
337
406
  for (const nodeId of entry.coveredNodeIds || []) {
338
- if (!reachableNodeIds.has(nodeId) || nodeId === pageNodeId) continue;
407
+ if (!reachableNodeIds.has(nodeId) || excludedNodeIds.has(nodeId)) continue;
339
408
  const node = nodeById.get(nodeId);
340
409
  if (!node) continue;
341
410
  nodes.set(nodeId, {
@@ -351,6 +420,23 @@ function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, pageNodeId
351
420
  return [...nodes.values()].sort((left, right) => left.id.localeCompare(right.id));
352
421
  }
353
422
 
423
+ function collectTargetsForEvidence(evidenceEntries, pageTarget) {
424
+ const targets = [];
425
+ if (pageTarget) targets.push(pageTarget);
426
+ for (const entry of evidenceEntries || []) {
427
+ for (const target of entry?.details?.targets || []) {
428
+ targets.push(target);
429
+ }
430
+ }
431
+ const seen = new Set();
432
+ return targets.filter((target) => {
433
+ const key = `${target.kind}:${target.value}`;
434
+ if (seen.has(key)) return false;
435
+ seen.add(key);
436
+ return true;
437
+ });
438
+ }
439
+
354
440
  function buildSupportingTestRef(evidence, discoveryByFile, runArtifact) {
355
441
  const discovery = discoveryByFile.get(evidence.testFilePath);
356
442
  const runFile = findRunFileResult(runArtifact, evidence.testFilePath);
@@ -45,6 +45,19 @@ const context = {
45
45
  route: "/coverage",
46
46
  filePath: "app/coverage/page.tsx",
47
47
  },
48
+ {
49
+ id: "ui_surface:web:/coverage:coverage-refresh-button",
50
+ kind: "ui_surface",
51
+ service: "web",
52
+ label: "Refresh overlay",
53
+ route: "/coverage",
54
+ filePath: "app/coverage/page.tsx",
55
+ target: {
56
+ kind: "testId",
57
+ value: "coverage-refresh-button",
58
+ confidence: "high",
59
+ },
60
+ },
48
61
  {
49
62
  id: "api_route:web:GET:/api/coverage",
50
63
  kind: "api_route",
@@ -72,9 +85,16 @@ const context = {
72
85
  ],
73
86
  edges: [
74
87
  {
75
- id: "requests:page_view:web:/coverage:api_route:web:GET:/api/coverage",
76
- kind: "requests",
88
+ id: "contains:page_view:web:/coverage:ui_surface:web:/coverage:coverage-refresh-button",
89
+ kind: "contains",
77
90
  from: "page_view:web:/coverage",
91
+ to: "ui_surface:web:/coverage:coverage-refresh-button",
92
+ confidence: "high",
93
+ },
94
+ {
95
+ id: "requests:ui_surface:web:/coverage:coverage-refresh-button:api_route:web:GET:/api/coverage",
96
+ kind: "requests",
97
+ from: "ui_surface:web:/coverage:coverage-refresh-button",
78
98
  to: "api_route:web:GET:/api/coverage",
79
99
  confidence: "high",
80
100
  },
@@ -89,8 +109,14 @@ const context = {
89
109
  selectionType: "pw",
90
110
  framework: "playwright",
91
111
  testFilePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
92
- coveredNodeIds: ["page_view:web:/coverage"],
93
- details: { route: "/coverage" },
112
+ coveredNodeIds: [
113
+ "page_view:web:/coverage",
114
+ "ui_surface:web:/coverage:coverage-refresh-button",
115
+ ],
116
+ details: {
117
+ route: "/coverage",
118
+ targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
119
+ },
94
120
  },
95
121
  {
96
122
  id: "evidence:web:app/api/coverage/__testkit__/coverage.int.testkit.ts",
@@ -156,7 +182,9 @@ describe("testkit bridge", () => {
156
182
  },
157
183
  failures: [
158
184
  {
159
- label: "Coverage",
185
+ kind: "ui_surface",
186
+ label: "Refresh overlay",
187
+ targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
160
188
  failedTests: [
161
189
  {
162
190
  filePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
@@ -167,7 +195,9 @@ describe("testkit bridge", () => {
167
195
  ],
168
196
  coverage: [
169
197
  {
170
- label: "Coverage",
198
+ kind: "ui_surface",
199
+ label: "Refresh overlay",
200
+ targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
171
201
  supportingTests: [
172
202
  {
173
203
  filePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -82,6 +82,7 @@ export interface CoverageEvidence {
82
82
  details?: {
83
83
  requestPaths?: string[];
84
84
  route?: string;
85
+ targets?: BrowserTarget[];
85
86
  };
86
87
  }
87
88
 
@@ -208,10 +208,12 @@ function normalizeEvidenceDetails(value) {
208
208
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
209
209
  const requestPaths = normalizeStringArray(value.requestPaths);
210
210
  const route = normalizeOptionalString(value.route);
211
- if (requestPaths.length === 0 && !route) return null;
211
+ const targets = Array.isArray(value.targets) ? value.targets.map(normalizeBrowserTarget).filter(Boolean) : [];
212
+ if (requestPaths.length === 0 && !route && targets.length === 0) return null;
212
213
  return {
213
214
  ...(requestPaths.length > 0 ? { requestPaths } : {}),
214
215
  ...(route ? { route } : {}),
216
+ ...(targets.length > 0 ? { targets } : {}),
215
217
  };
216
218
  }
217
219