@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.
- package/lib/coverage/evidence.mjs +48 -36
- package/lib/coverage/evidence.test.mjs +30 -16
- package/lib/coverage/graph-builder.mjs +18 -3
- package/lib/coverage/index.test.mjs +303 -0
- package/lib/coverage/next-discovery.mjs +36 -5
- package/lib/coverage/next-static-analysis.mjs +445 -126
- package/lib/coverage/shared.mjs +68 -0
- package/lib/coverage/shared.test.mjs +33 -0
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-bridge/src/index.mjs +108 -2
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +212 -16
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/testkit-protocol/src/index.d.ts +26 -0
- package/node_modules/@elench/testkit-protocol/src/index.mjs +18 -0
- package/node_modules/@elench/testkit-protocol/src/index.test.mjs +75 -1
- package/package.json +3 -3
package/lib/coverage/shared.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
287
|
-
|
|
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:
|
|
181
|
-
relatedCoverageCount:
|
|
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
|
});
|
|
@@ -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;
|