@elench/testkit 0.1.61 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ export const HTTP_WRAPPER_METHODS = {
9
9
  patchJson: "PATCH",
10
10
  deleteJson: "DELETE",
11
11
  };
12
+ export const DYNAMIC_SEGMENT_TOKEN = "__TESTKIT_DYNAMIC_SEGMENT__";
12
13
 
13
14
  export function createEmptyGraph() {
14
15
  return {
@@ -16,6 +17,7 @@ export function createEmptyGraph() {
16
17
  nodes: [],
17
18
  edges: [],
18
19
  evidence: [],
20
+ diagnostics: [],
19
21
  };
20
22
  }
21
23
 
@@ -195,3 +197,69 @@ export function dedupeDataImports(entries) {
195
197
  return true;
196
198
  });
197
199
  }
200
+
201
+ export function findMatchingApiRouteEntry(method, candidatePath, routeEntries = []) {
202
+ const normalizedMethod = String(method || "").trim().toUpperCase();
203
+ const normalizedCandidate = normalizeRequestPathCandidate(candidatePath);
204
+ if (!normalizedMethod || !normalizedCandidate) return null;
205
+
206
+ return [...routeEntries]
207
+ .filter((entry) => entry.method === normalizedMethod)
208
+ .sort(compareRouteSpecificity)
209
+ .find((entry) => routePatternMatchesCandidate(entry.requestPath, normalizedCandidate)) || null;
210
+ }
211
+
212
+ export function findMatchingRouteValue(candidateRoute, routeValues = []) {
213
+ const normalizedCandidate = normalizeRequestPathCandidate(candidateRoute);
214
+ if (!normalizedCandidate) return null;
215
+
216
+ return [...routeValues]
217
+ .sort((left, right) => compareRouteSpecificity({ requestPath: left }, { requestPath: right }))
218
+ .find((routeValue) => routePatternMatchesCandidate(routeValue, normalizedCandidate)) || null;
219
+ }
220
+
221
+ export function normalizeRequestPathCandidate(value) {
222
+ if (typeof value !== "string") return null;
223
+ const [withoutHash] = value.split("#");
224
+ const [withoutQuery] = withoutHash.split("?");
225
+ return normalizeRoute(withoutQuery || "/");
226
+ }
227
+
228
+ export function routePatternMatchesCandidate(patternPath, candidatePath) {
229
+ const normalizedPattern = normalizeRequestPathCandidate(patternPath);
230
+ const normalizedCandidate = normalizeRequestPathCandidate(candidatePath);
231
+ if (!normalizedPattern || !normalizedCandidate) return false;
232
+
233
+ const patternSegments = splitRouteSegments(normalizedPattern);
234
+ const candidateSegments = splitRouteSegments(normalizedCandidate);
235
+ if (patternSegments.length !== candidateSegments.length) return false;
236
+
237
+ for (let index = 0; index < patternSegments.length; index += 1) {
238
+ const patternSegment = patternSegments[index];
239
+ const candidateSegment = candidateSegments[index];
240
+ if (patternSegment === candidateSegment) continue;
241
+ if (isDynamicRouteSegment(patternSegment) && candidateSegment.length > 0) continue;
242
+ if (candidateSegment === DYNAMIC_SEGMENT_TOKEN && isDynamicRouteSegment(patternSegment)) continue;
243
+ return false;
244
+ }
245
+
246
+ return true;
247
+ }
248
+
249
+ export function isDynamicRouteSegment(segment) {
250
+ return /^\[[^/]+\]$/u.test(segment) || /^\[\.\.\.[^/]+\]$/u.test(segment) || /^\[\[\.\.\.[^/]+\]\]$/u.test(segment);
251
+ }
252
+
253
+ function splitRouteSegments(value) {
254
+ if (value === "/") return [];
255
+ return value.split("/").filter(Boolean);
256
+ }
257
+
258
+ function compareRouteSpecificity(left, right) {
259
+ const leftSegments = splitRouteSegments(left.requestPath || "");
260
+ const rightSegments = splitRouteSegments(right.requestPath || "");
261
+ const leftStaticSegments = leftSegments.filter((segment) => !isDynamicRouteSegment(segment)).length;
262
+ const rightStaticSegments = rightSegments.filter((segment) => !isDynamicRouteSegment(segment)).length;
263
+ if (leftStaticSegments !== rightStaticSegments) return rightStaticSegments - leftStaticSegments;
264
+ return rightSegments.length - leftSegments.length;
265
+ }
@@ -1,8 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ DYNAMIC_SEGMENT_TOKEN,
3
4
  dedupeTargets,
5
+ findMatchingApiRouteEntry,
6
+ findMatchingRouteValue,
4
7
  modulePathKey,
5
8
  pageLabelFromRoute,
9
+ routePatternMatchesCandidate,
6
10
  toApiRequestPath,
7
11
  toSelectionType,
8
12
  } from "./shared.mjs";
@@ -36,4 +40,33 @@ describe("coverage shared helpers", () => {
36
40
  expect(toSelectionType("integration", "http")).toBe("int");
37
41
  expect(toSelectionType("ui", "playwright")).toBe("pw");
38
42
  });
43
+
44
+ it("matches dynamic API route patterns against concrete and template-like request paths", () => {
45
+ expect(routePatternMatchesCandidate("/api/projects/[projectId]/overview", "/api/projects/123/overview")).toBe(true);
46
+ expect(
47
+ routePatternMatchesCandidate(
48
+ "/api/projects/[projectId]/events",
49
+ `/api/projects/${DYNAMIC_SEGMENT_TOKEN}/events`
50
+ )
51
+ ).toBe(true);
52
+ expect(routePatternMatchesCandidate("/api/projects/[projectId]/events", "/api/projects/123/stats")).toBe(false);
53
+ });
54
+
55
+ it("prefers the most specific matching API route entry", () => {
56
+ const routeEntry = findMatchingApiRouteEntry("GET", "/api/projects/123/events", [
57
+ { method: "GET", requestPath: "/api/projects/[projectId]" },
58
+ { method: "GET", requestPath: "/api/projects/[projectId]/events" },
59
+ ]);
60
+
61
+ expect(routeEntry).toEqual({
62
+ method: "GET",
63
+ requestPath: "/api/projects/[projectId]/events",
64
+ });
65
+ });
66
+
67
+ it("matches dynamic page routes against discovered route patterns", () => {
68
+ expect(
69
+ findMatchingRouteValue(`/projects/${DYNAMIC_SEGMENT_TOKEN}`, ["/projects/[projectId]", "/projects"])
70
+ ).toBe("/projects/[projectId]");
71
+ });
39
72
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
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.61"
14
+ "@elench/testkit-protocol": "0.1.63"
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,
@@ -267,6 +269,11 @@ function buildGraphProjection(context, page, matchedServiceName) {
267
269
  .filter((node) => node?.kind === "ui_surface");
268
270
  const pageReachableNodeIds = collectReachableNodeIds(pageNode.id, outgoing);
269
271
 
272
+ const surfaceReachableEntries = surfaceNodes.map((surfaceNode) => ({
273
+ surfaceNode,
274
+ reachableNodeIds: collectReachableNodeIds(surfaceNode.id, outgoing),
275
+ }));
276
+
270
277
  if (surfaceNodes.length === 0) {
271
278
  const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], pageReachableNodeIds));
272
279
  return buildPageLevelProjection({
@@ -283,14 +290,16 @@ function buildGraphProjection(context, page, matchedServiceName) {
283
290
  const coverage = [];
284
291
  const failures = [];
285
292
 
286
- for (const surfaceNode of surfaceNodes.sort((left, right) => left.id.localeCompare(right.id))) {
287
- const surfaceReachableNodeIds = collectReachableNodeIds(surfaceNode.id, outgoing);
293
+ for (const { surfaceNode, reachableNodeIds: surfaceReachableNodeIds } of surfaceReachableEntries.sort((left, right) =>
294
+ left.surfaceNode.id.localeCompare(right.surfaceNode.id)
295
+ )) {
288
296
  const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], surfaceReachableNodeIds));
289
297
  const supportingTests = relevantEvidence
290
298
  .map((entry) => buildSupportingTestRef(entry, discoveryByFile, context.runArtifact))
291
299
  .filter(Boolean);
292
300
 
293
301
  if (supportingTests.length > 0) {
302
+ const supportKind = inferCoverageSupportKind(supportingTests);
294
303
  coverage.push({
295
304
  id: surfaceNode.id,
296
305
  kind: surfaceNode.kind,
@@ -301,6 +310,10 @@ function buildGraphProjection(context, page, matchedServiceName) {
301
310
  supportingTests,
302
311
  viaNodes: collectViaNodes(relevantEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
303
312
  confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
313
+ importance: inferSurfaceImportance(surfaceNode),
314
+ surfaceKind: surfaceNode.metadata?.surfaceKind ? String(surfaceNode.metadata.surfaceKind) : null,
315
+ supportKind,
316
+ reason: buildCoverageReason(surfaceNode, supportKind, supportingTests, relevantEvidence),
304
317
  });
305
318
  }
306
319
 
@@ -329,7 +342,26 @@ function buildGraphProjection(context, page, matchedServiceName) {
329
342
  targets: collectTargetsForEvidence(failedEvidence, surfaceNode.target),
330
343
  failedTests,
331
344
  viaNodes: collectViaNodes(failedEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
345
+ importance: inferSurfaceImportance(surfaceNode),
346
+ surfaceKind: surfaceNode.metadata?.surfaceKind ? String(surfaceNode.metadata.surfaceKind) : null,
347
+ reason: buildFailureReason(surfaceNode, failedTests),
348
+ });
349
+ }
350
+
351
+ const routeLevelNodeIds = collectRouteLevelNodeIds(pageReachableNodeIds, surfaceReachableEntries);
352
+ if (routeLevelNodeIds.size > 0) {
353
+ const routeRelevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], routeLevelNodeIds));
354
+ const pageProjection = buildPageLevelProjection({
355
+ pageNode,
356
+ nodeById,
357
+ relevantEvidence: routeRelevantEvidence,
358
+ pageReachableNodeIds: routeLevelNodeIds,
359
+ discoveryByFile,
360
+ failureByFile,
361
+ runArtifact: context.runArtifact,
332
362
  });
363
+ coverage.unshift(...pageProjection.coverage);
364
+ failures.unshift(...pageProjection.failures);
333
365
  }
334
366
 
335
367
  return { coverage, failures };
@@ -352,6 +384,10 @@ function buildPageLevelProjection({ pageNode, nodeById, relevantEvidence, pageRe
352
384
  supportingTests,
353
385
  viaNodes: collectViaNodes(relevantEvidence, pageReachableNodeIds, nodeById, new Set([pageNode.id])),
354
386
  confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
387
+ importance: inferSurfaceImportance(pageNode),
388
+ surfaceKind: pageNode.kind,
389
+ supportKind: inferCoverageSupportKind(supportingTests),
390
+ reason: buildCoverageReason(pageNode, inferCoverageSupportKind(supportingTests), supportingTests, relevantEvidence),
355
391
  },
356
392
  ]
357
393
  : [];
@@ -370,6 +406,9 @@ function buildPageLevelProjection({ pageNode, nodeById, relevantEvidence, pageRe
370
406
  targets: collectTargetsForEvidence([entry], pageNode.target),
371
407
  failedTests: supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : [],
372
408
  viaNodes: collectViaNodes([entry], pageReachableNodeIds, nodeById, new Set([pageNode.id])),
409
+ importance: inferSurfaceImportance(pageNode),
410
+ surfaceKind: pageNode.kind,
411
+ reason: buildFailureReason(pageNode, supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : []),
373
412
  };
374
413
  });
375
414
 
@@ -400,6 +439,16 @@ function collectReachableNodeIds(startNodeId, outgoing) {
400
439
  return visited;
401
440
  }
402
441
 
442
+ function collectRouteLevelNodeIds(pageReachableNodeIds, surfaceReachableEntries) {
443
+ const routeLevelNodeIds = new Set(pageReachableNodeIds);
444
+ for (const { reachableNodeIds } of surfaceReachableEntries) {
445
+ for (const nodeId of reachableNodeIds) {
446
+ routeLevelNodeIds.delete(nodeId);
447
+ }
448
+ }
449
+ return routeLevelNodeIds;
450
+ }
451
+
403
452
  function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, excludedNodeIds = new Set()) {
404
453
  const nodes = new Map();
405
454
  for (const entry of evidenceEntries) {
@@ -475,3 +524,60 @@ function pathBaseName(filePath) {
475
524
  const parts = String(filePath || "").split("/");
476
525
  return parts[parts.length - 1] || filePath;
477
526
  }
527
+
528
+ function buildCoverageBreakdown(entries) {
529
+ const breakdown = { direct: 0, indirect: 0, mixed: 0 };
530
+ for (const entry of entries || []) {
531
+ if (entry.supportKind === "direct") breakdown.direct += 1;
532
+ else if (entry.supportKind === "indirect") breakdown.indirect += 1;
533
+ else breakdown.mixed += 1;
534
+ }
535
+ return breakdown;
536
+ }
537
+
538
+ function inferCoverageSupportKind(supportingTests) {
539
+ const hasPw = (supportingTests || []).some((entry) => entry.type === "pw");
540
+ const hasBackend = (supportingTests || []).some((entry) => entry.type !== "pw");
541
+ if (hasPw && hasBackend) return "mixed";
542
+ if (hasPw) return "direct";
543
+ return "indirect";
544
+ }
545
+
546
+ function inferSurfaceImportance(node) {
547
+ const label = String(node?.label || "").toLowerCase();
548
+ const surfaceKind = String(node?.metadata?.surfaceKind || node?.kind || "").toLowerCase();
549
+
550
+ if (/\b(pay|purchase|checkout|publish|send|submit|confirm|delete|remove)\b/u.test(label)) {
551
+ return "critical";
552
+ }
553
+ if (/\b(save|create|update|refresh|retry|login|sign in|continue)\b/u.test(label)) {
554
+ return "high";
555
+ }
556
+ if (surfaceKind === "form" || surfaceKind === "button" || surfaceKind === "input") {
557
+ return "medium";
558
+ }
559
+ return "low";
560
+ }
561
+
562
+ function buildCoverageReason(node, supportKind, supportingTests, evidenceEntries) {
563
+ const directCount = supportingTests.filter((entry) => entry.type === "pw").length;
564
+ const backendCount = supportingTests.filter((entry) => entry.type !== "pw").length;
565
+ const requestPaths = new Set();
566
+ for (const entry of evidenceEntries || []) {
567
+ for (const path of entry?.details?.requestPaths || []) requestPaths.add(path);
568
+ }
569
+ const requestSummary = requestPaths.size > 0 ? ` via ${[...requestPaths].join(", ")}` : "";
570
+ if (supportKind === "mixed") {
571
+ return `${node.label} is covered directly by ${directCount} UI test${directCount === 1 ? "" : "s"} and indirectly by ${backendCount} backend test${backendCount === 1 ? "" : "s"}${requestSummary}.`;
572
+ }
573
+ if (supportKind === "direct") {
574
+ return `${node.label} is covered directly by ${directCount} UI test${directCount === 1 ? "" : "s"}${requestSummary}.`;
575
+ }
576
+ return `${node.label} is covered indirectly by ${backendCount} backend test${backendCount === 1 ? "" : "s"}${requestSummary}.`;
577
+ }
578
+
579
+ function buildFailureReason(node, failedTests) {
580
+ const count = (failedTests || []).length;
581
+ if (count === 0) return `${node.label} has a related failing test.`;
582
+ return `${node.label} is implicated by ${count} failing test${count === 1 ? "" : "s"}.`;
583
+ }
@@ -161,6 +161,163 @@ const context = {
161
161
  },
162
162
  };
163
163
 
164
+ const routeLevelContext = {
165
+ product: {
166
+ name: "dashboard",
167
+ directory: "/tmp/dashboard",
168
+ },
169
+ services: [
170
+ {
171
+ name: "dashboard",
172
+ baseUrl: "http://localhost:3000",
173
+ origin: "http://localhost:3000",
174
+ },
175
+ ],
176
+ discovery: {
177
+ files: [
178
+ {
179
+ path: "__testkit__/projects/projects.int.testkit.ts",
180
+ displayName: "Projects",
181
+ service: "dashboard",
182
+ suiteName: "projects",
183
+ selectionType: "int",
184
+ framework: "k6",
185
+ skipped: false,
186
+ },
187
+ ],
188
+ coverageGraph: {
189
+ schemaVersion: 1,
190
+ nodes: [
191
+ {
192
+ id: "page_view:dashboard:/projects",
193
+ kind: "page_view",
194
+ service: "dashboard",
195
+ label: "Projects",
196
+ route: "/projects",
197
+ filePath: "src/app/projects/page.tsx",
198
+ },
199
+ {
200
+ id: "ui_surface:dashboard:/projects:project-create-button",
201
+ kind: "ui_surface",
202
+ service: "dashboard",
203
+ label: "Create Project",
204
+ route: "/projects",
205
+ filePath: "src/app/projects/page.tsx",
206
+ target: { kind: "testId", value: "project-create-button", confidence: "high" },
207
+ },
208
+ {
209
+ id: "ui_action:dashboard:/projects:handleCreate",
210
+ kind: "ui_action",
211
+ service: "dashboard",
212
+ label: "handleCreate",
213
+ route: "/projects",
214
+ filePath: "src/app/projects/page.tsx",
215
+ },
216
+ {
217
+ id: "client_request:dashboard:provider:GET:/api/projects",
218
+ kind: "client_request",
219
+ service: "dashboard",
220
+ label: "GET /api/projects",
221
+ route: "/projects",
222
+ method: "GET",
223
+ path: "/api/projects",
224
+ filePath: "src/components/project-context.tsx",
225
+ },
226
+ {
227
+ id: "client_request:dashboard:projects:POST:/api/projects",
228
+ kind: "client_request",
229
+ service: "dashboard",
230
+ label: "POST /api/projects",
231
+ route: "/projects",
232
+ method: "POST",
233
+ path: "/api/projects",
234
+ filePath: "src/app/projects/page.tsx",
235
+ },
236
+ {
237
+ id: "api_route:dashboard:GET:/api/projects",
238
+ kind: "api_route",
239
+ service: "dashboard",
240
+ label: "GET /api/projects",
241
+ route: "/projects",
242
+ method: "GET",
243
+ path: "/api/projects",
244
+ filePath: "src/app/api/projects/route.ts",
245
+ },
246
+ {
247
+ id: "api_route:dashboard:POST:/api/projects",
248
+ kind: "api_route",
249
+ service: "dashboard",
250
+ label: "POST /api/projects",
251
+ route: "/projects",
252
+ method: "POST",
253
+ path: "/api/projects",
254
+ filePath: "src/app/api/projects/route.ts",
255
+ },
256
+ ],
257
+ edges: [
258
+ {
259
+ id: "contains:page_view:dashboard:/projects:ui_surface:dashboard:/projects:project-create-button",
260
+ kind: "contains",
261
+ from: "page_view:dashboard:/projects",
262
+ to: "ui_surface:dashboard:/projects:project-create-button",
263
+ },
264
+ {
265
+ id: "triggers:ui_surface:dashboard:/projects:project-create-button:ui_action:dashboard:/projects:handleCreate",
266
+ kind: "triggers",
267
+ from: "ui_surface:dashboard:/projects:project-create-button",
268
+ to: "ui_action:dashboard:/projects:handleCreate",
269
+ },
270
+ {
271
+ id: "requests:page_view:dashboard:/projects:client_request:dashboard:provider:GET:/api/projects",
272
+ kind: "requests",
273
+ from: "page_view:dashboard:/projects",
274
+ to: "client_request:dashboard:provider:GET:/api/projects",
275
+ },
276
+ {
277
+ id: "requests:ui_action:dashboard:/projects:handleCreate:client_request:dashboard:projects:POST:/api/projects",
278
+ kind: "requests",
279
+ from: "ui_action:dashboard:/projects:handleCreate",
280
+ to: "client_request:dashboard:projects:POST:/api/projects",
281
+ },
282
+ {
283
+ id: "handles:client_request:dashboard:provider:GET:/api/projects:api_route:dashboard:GET:/api/projects",
284
+ kind: "handles",
285
+ from: "client_request:dashboard:provider:GET:/api/projects",
286
+ to: "api_route:dashboard:GET:/api/projects",
287
+ },
288
+ {
289
+ id: "handles:client_request:dashboard:projects:POST:/api/projects:api_route:dashboard:POST:/api/projects",
290
+ kind: "handles",
291
+ from: "client_request:dashboard:projects:POST:/api/projects",
292
+ to: "api_route:dashboard:POST:/api/projects",
293
+ },
294
+ ],
295
+ evidence: [
296
+ {
297
+ id: "evidence:dashboard:__testkit__/projects/projects.int.testkit.ts",
298
+ source: "convention",
299
+ confidence: "high",
300
+ service: "dashboard",
301
+ suiteName: "projects",
302
+ selectionType: "int",
303
+ framework: "k6",
304
+ testFilePath: "__testkit__/projects/projects.int.testkit.ts",
305
+ coveredNodeIds: [
306
+ "api_route:dashboard:GET:/api/projects",
307
+ "api_route:dashboard:POST:/api/projects",
308
+ ],
309
+ details: {
310
+ route: "/projects",
311
+ requestPaths: ["/api/projects"],
312
+ },
313
+ },
314
+ ],
315
+ diagnostics: [],
316
+ },
317
+ },
318
+ runArtifact: null,
319
+ };
320
+
164
321
  describe("testkit bridge", () => {
165
322
  it("matches pages against service base URLs", () => {
166
323
  expect(buildMatchResponse(context, "http://localhost:3000/coverage")).toMatchObject({
@@ -177,37 +334,76 @@ describe("testkit bridge", () => {
177
334
  summary: {
178
335
  failureState: "failing",
179
336
  coverageState: "covered",
180
- relatedFailureCount: 1,
181
- relatedCoverageCount: 1,
337
+ relatedFailureCount: 2,
338
+ relatedCoverageCount: 2,
339
+ coverageBreakdown: {
340
+ direct: 1,
341
+ indirect: 0,
342
+ mixed: 1,
343
+ },
182
344
  },
183
- failures: [
184
- {
345
+ failures: expect.arrayContaining([
346
+ expect.objectContaining({
347
+ kind: "page_view",
348
+ label: "Coverage",
349
+ }),
350
+ expect.objectContaining({
185
351
  kind: "ui_surface",
186
352
  label: "Refresh overlay",
353
+ importance: "high",
354
+ reason: "Refresh overlay is implicated by 1 failing test.",
187
355
  targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
188
356
  failedTests: [
189
- {
357
+ expect.objectContaining({
190
358
  filePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
191
359
  type: "pw",
192
- },
360
+ }),
193
361
  ],
194
- },
195
- ],
196
- coverage: [
197
- {
362
+ }),
363
+ ]),
364
+ coverage: expect.arrayContaining([
365
+ expect.objectContaining({
366
+ kind: "page_view",
367
+ label: "Coverage",
368
+ supportKind: "direct",
369
+ }),
370
+ expect.objectContaining({
198
371
  kind: "ui_surface",
199
372
  label: "Refresh overlay",
373
+ importance: "high",
374
+ supportKind: "mixed",
200
375
  targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
201
376
  supportingTests: [
202
- {
377
+ expect.objectContaining({
203
378
  filePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
204
- },
205
- {
379
+ }),
380
+ expect.objectContaining({
206
381
  filePath: "app/api/coverage/__testkit__/coverage.int.testkit.ts",
207
- },
382
+ }),
208
383
  ],
209
- },
210
- ],
384
+ }),
385
+ ]),
386
+ });
387
+ });
388
+
389
+ it("retains route-level coverage when support comes from page-owned requests outside any surfaced control", () => {
390
+ expect(buildPageOverlayResponse(routeLevelContext, "http://localhost:3000/projects")).toMatchObject({
391
+ summary: {
392
+ coverageState: "covered",
393
+ relatedCoverageCount: 2,
394
+ },
395
+ coverage: expect.arrayContaining([
396
+ expect.objectContaining({
397
+ kind: "page_view",
398
+ label: "Projects",
399
+ supportKind: "indirect",
400
+ }),
401
+ expect.objectContaining({
402
+ kind: "ui_surface",
403
+ label: "Create Project",
404
+ supportKind: "indirect",
405
+ }),
406
+ ]),
211
407
  });
212
408
  });
213
409
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -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;