@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.
@@ -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 selectionType = toSelectionType(entry.type, entry.framework);
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 route = inferPageRouteFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
24
- if (route && context.pageByRoute.has(route)) {
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
- if (selectionType === "int") {
35
- const explicitRequests = extractHttpSuiteRequestsFromTestFile(entry, context);
36
- if (explicitRequests.length > 0) {
37
- for (const request of explicitRequests) {
38
- const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(request.method, request.path));
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
- if (selectionType === "dal") {
53
- for (const nodeId of inferDataCapabilitiesFromTestFile(entry, context)) {
54
- coveredNodeIds.add(nodeId);
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 || toSelectionType(entry.type, entry.framework) !== "dal") return [];
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;
@@ -16,6 +16,7 @@ export function createEmptyGraph() {
16
16
  nodes: [],
17
17
  edges: [],
18
18
  evidence: [],
19
+ diagnostics: [],
19
20
  };
20
21
  }
21
22
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.61",
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.61"
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
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.61",
3
+ "version": "0.1.62",
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;
@@ -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 payloads", () => {
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.61",
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.61",
63
- "@elench/testkit-protocol": "0.1.61",
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",