@elench/testkit 0.1.60 → 0.1.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/config/database.mjs +53 -0
- package/lib/config/database.test.mjs +29 -0
- package/lib/config/discovery-config.mjs +13 -0
- package/lib/config/env.mjs +55 -0
- package/lib/config/env.test.mjs +40 -0
- package/lib/config/index.mjs +21 -807
- package/lib/config/paths.mjs +28 -0
- package/lib/config/paths.test.mjs +27 -0
- package/lib/config/runtime.mjs +241 -0
- package/lib/config/runtime.test.mjs +56 -0
- package/lib/config/skip-config.mjs +189 -0
- package/lib/config/skip-config.test.mjs +63 -0
- package/lib/config/telemetry.mjs +28 -0
- package/lib/config/validation.mjs +124 -0
- package/lib/coverage/backend-discovery.mjs +183 -0
- package/lib/coverage/backend-discovery.test.mjs +52 -0
- package/lib/coverage/evidence.mjs +147 -0
- package/lib/coverage/evidence.test.mjs +77 -0
- package/lib/coverage/fs-walk.mjs +64 -0
- package/lib/coverage/graph-builder.mjs +181 -0
- package/lib/coverage/index.mjs +1 -816
- package/lib/coverage/index.test.mjs +330 -14
- package/lib/coverage/next-discovery.mjs +174 -0
- package/lib/coverage/next-static-analysis.mjs +763 -0
- package/lib/coverage/routing.mjs +86 -0
- package/lib/coverage/routing.test.mjs +52 -0
- package/lib/coverage/shared.mjs +198 -0
- package/lib/coverage/shared.test.mjs +39 -0
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-bridge/src/index.mjs +156 -13
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +39 -5
- 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 +5 -4
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { normalizePath, normalizeRoute } from "./shared.mjs";
|
|
3
|
+
|
|
4
|
+
export function inferPageRouteFromTestFile(filePath, nextAppRoot, serviceRoot) {
|
|
5
|
+
const appRelativeSegments = extractRouteOwnerSegments(filePath, nextAppRoot, serviceRoot);
|
|
6
|
+
if (!appRelativeSegments) return null;
|
|
7
|
+
return normalizeRouteSegments(appRelativeSegments);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function inferApiRoutesFromTestFile(filePath, nextAppRoot, serviceRoot) {
|
|
11
|
+
const normalized = normalizePath(filePath);
|
|
12
|
+
const appRootRelative = normalizePath(path.relative(serviceRoot, nextAppRoot));
|
|
13
|
+
const appPrefix = appRootRelative === "." ? "" : `${appRootRelative}/`;
|
|
14
|
+
const apiMarker = `${appPrefix}api/`;
|
|
15
|
+
const markerIndex = normalized.indexOf(apiMarker);
|
|
16
|
+
if (markerIndex === -1) return [];
|
|
17
|
+
const rest = normalized.slice(markerIndex + apiMarker.length);
|
|
18
|
+
const segments = rest.split("/");
|
|
19
|
+
const testkitIndex = segments.indexOf("__testkit__");
|
|
20
|
+
if (testkitIndex === -1) return [];
|
|
21
|
+
return [normalizeRouteSegments(segments.slice(0, testkitIndex))];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function inferOwnerDirectoryFromTestFile(filePath) {
|
|
25
|
+
const normalized = normalizePath(filePath);
|
|
26
|
+
const segments = normalized.split("/");
|
|
27
|
+
const testkitIndex = segments.indexOf("__testkit__");
|
|
28
|
+
if (testkitIndex <= 0) return null;
|
|
29
|
+
return segments.slice(0, testkitIndex).join("/");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function pathMatchesOwner(candidatePath, ownerDirectory) {
|
|
33
|
+
const normalizedCandidate = normalizePath(candidatePath);
|
|
34
|
+
const normalizedOwner = normalizePath(ownerDirectory);
|
|
35
|
+
return normalizedCandidate === normalizedOwner || normalizedCandidate.startsWith(`${normalizedOwner}/`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function extractRouteOwnerSegments(filePath, nextAppRoot, serviceRoot) {
|
|
39
|
+
const normalized = normalizePath(filePath);
|
|
40
|
+
const appRootRelative = normalizePath(path.relative(serviceRoot, nextAppRoot));
|
|
41
|
+
const appPrefix = appRootRelative === "." ? "" : `${appRootRelative}/`;
|
|
42
|
+
if (!normalized.startsWith(appPrefix)) return null;
|
|
43
|
+
const rest = normalized.slice(appPrefix.length);
|
|
44
|
+
const segments = rest.split("/");
|
|
45
|
+
const testkitIndex = segments.indexOf("__testkit__");
|
|
46
|
+
if (testkitIndex === -1) return null;
|
|
47
|
+
return segments.slice(0, testkitIndex);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function routeFromAppFile(nextAppRoot, filePath) {
|
|
51
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(nextAppRoot, filePath);
|
|
52
|
+
const relative = normalizePath(path.relative(nextAppRoot, absolutePath));
|
|
53
|
+
const segments = relative.split("/");
|
|
54
|
+
segments.pop();
|
|
55
|
+
return normalizeRouteSegments(segments);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function routeFromApiFile(nextAppRoot, filePath) {
|
|
59
|
+
const appRelative = normalizePath(path.relative(nextAppRoot, path.dirname(filePath)));
|
|
60
|
+
const segments = appRelative.split("/").filter(Boolean);
|
|
61
|
+
if (segments[0] === "api") segments.shift();
|
|
62
|
+
return normalizeRouteSegments(segments);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeRouteSegments(segments) {
|
|
66
|
+
const normalizedSegments = segments
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.filter((segment) => !segment.startsWith("(") || !segment.endsWith(")"))
|
|
69
|
+
.filter((segment) => !segment.startsWith("@"))
|
|
70
|
+
.filter((segment) => segment !== "page" && segment !== "route");
|
|
71
|
+
return normalizeRoute(`/${normalizedSegments.join("/")}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function apiRouteLookupKey(method, route) {
|
|
75
|
+
return `${method}:${route}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function toApiRequestPath(route) {
|
|
79
|
+
return normalizeRoute(`/api${route === "/" ? "" : route}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function toSelectionType(type, framework) {
|
|
83
|
+
if (framework === "playwright") return "pw";
|
|
84
|
+
if (type === "integration") return "int";
|
|
85
|
+
return type;
|
|
86
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
inferApiRoutesFromTestFile,
|
|
5
|
+
inferOwnerDirectoryFromTestFile,
|
|
6
|
+
inferPageRouteFromTestFile,
|
|
7
|
+
normalizeRouteSegments,
|
|
8
|
+
pathMatchesOwner,
|
|
9
|
+
routeFromApiFile,
|
|
10
|
+
routeFromAppFile,
|
|
11
|
+
} from "./routing.mjs";
|
|
12
|
+
|
|
13
|
+
describe("coverage routing helpers", () => {
|
|
14
|
+
it("normalizes route segments by stripping route groups and slots", () => {
|
|
15
|
+
expect(normalizeRouteSegments(["(marketing)", "@modal", "campaigns", "[campaignId]", "page"])).toBe(
|
|
16
|
+
"/campaigns/[campaignId]"
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("infers page ownership routes from colocated __testkit__ files", () => {
|
|
21
|
+
const serviceRoot = "/repo";
|
|
22
|
+
const nextAppRoot = path.join(serviceRoot, "src", "app");
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
inferPageRouteFromTestFile("src/app/campaigns/[campaignId]/__testkit__/details.pw.testkit.ts", nextAppRoot, serviceRoot)
|
|
26
|
+
).toBe("/campaigns/[campaignId]");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("infers API routes from colocated __testkit__ files", () => {
|
|
30
|
+
const serviceRoot = "/repo";
|
|
31
|
+
const nextAppRoot = path.join(serviceRoot, "src", "app");
|
|
32
|
+
|
|
33
|
+
expect(
|
|
34
|
+
inferApiRoutesFromTestFile("src/app/api/campaigns/__testkit__/create.int.testkit.ts", nextAppRoot, serviceRoot)
|
|
35
|
+
).toEqual(["/campaigns"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("derives page and API routes from Next app file paths", () => {
|
|
39
|
+
const nextAppRoot = "/repo/src/app";
|
|
40
|
+
|
|
41
|
+
expect(routeFromAppFile(nextAppRoot, "/repo/src/app/settings/page.tsx")).toBe("/settings");
|
|
42
|
+
expect(routeFromApiFile(nextAppRoot, "/repo/src/app/api/campaigns/route.ts")).toBe("/campaigns");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("matches nested ownership directories for DAL evidence", () => {
|
|
46
|
+
expect(inferOwnerDirectoryFromTestFile("src/backend/data/campaigns/__testkit__/save.dal.testkit.ts")).toBe(
|
|
47
|
+
"src/backend/data/campaigns"
|
|
48
|
+
);
|
|
49
|
+
expect(pathMatchesOwner("src/backend/data/campaigns/index.ts", "src/backend/data/campaigns")).toBe(true);
|
|
50
|
+
expect(pathMatchesOwner("src/backend/data/other/index.ts", "src/backend/data/campaigns")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { TESTKIT_COVERAGE_GRAPH_VERSION } from "@elench/testkit-protocol";
|
|
3
|
+
|
|
4
|
+
export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
5
|
+
export const HTTP_WRAPPER_METHODS = {
|
|
6
|
+
getJson: "GET",
|
|
7
|
+
postJson: "POST",
|
|
8
|
+
putJson: "PUT",
|
|
9
|
+
patchJson: "PATCH",
|
|
10
|
+
deleteJson: "DELETE",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createEmptyGraph() {
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: TESTKIT_COVERAGE_GRAPH_VERSION,
|
|
16
|
+
nodes: [],
|
|
17
|
+
edges: [],
|
|
18
|
+
evidence: [],
|
|
19
|
+
diagnostics: [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function appendGraph(graph, fragment) {
|
|
24
|
+
const nodeIds = new Set(graph.nodes.map((node) => node.id));
|
|
25
|
+
for (const node of fragment.nodes) {
|
|
26
|
+
if (nodeIds.has(node.id)) continue;
|
|
27
|
+
nodeIds.add(node.id);
|
|
28
|
+
graph.nodes.push(node);
|
|
29
|
+
}
|
|
30
|
+
const edgeIds = new Set(graph.edges.map((edge) => edge.id));
|
|
31
|
+
for (const edge of fragment.edges) {
|
|
32
|
+
if (edgeIds.has(edge.id)) continue;
|
|
33
|
+
edgeIds.add(edge.id);
|
|
34
|
+
graph.edges.push(edge);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createTestFileNode(graph, entry) {
|
|
39
|
+
const nodeId = `test_file:${entry.serviceName}:${entry.filePath}`;
|
|
40
|
+
if (!graph.nodes.some((node) => node.id === nodeId)) {
|
|
41
|
+
graph.nodes.push({
|
|
42
|
+
id: nodeId,
|
|
43
|
+
kind: "test_file",
|
|
44
|
+
service: entry.serviceName,
|
|
45
|
+
label: path.basename(entry.filePath),
|
|
46
|
+
filePath: entry.filePath,
|
|
47
|
+
metadata: {
|
|
48
|
+
suiteName: entry.suiteName,
|
|
49
|
+
framework: entry.framework,
|
|
50
|
+
type: toSelectionType(entry.type, entry.framework),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return nodeId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function apiRouteLookupKey(method, route) {
|
|
58
|
+
return `${method}:${route}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function toApiRequestPath(route) {
|
|
62
|
+
return normalizeRoute(`/api${route === "/" ? "" : route}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function toSelectionType(type, framework) {
|
|
66
|
+
if (framework === "playwright") return "pw";
|
|
67
|
+
if (type === "integration") return "int";
|
|
68
|
+
return type;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function pageLabelFromRoute(route) {
|
|
72
|
+
if (route === "/") return "Home";
|
|
73
|
+
return route
|
|
74
|
+
.split("/")
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.map((segment) => {
|
|
77
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
78
|
+
return segment.slice(1, -1);
|
|
79
|
+
}
|
|
80
|
+
return segment;
|
|
81
|
+
})
|
|
82
|
+
.map((segment) => segment.replace(/[-_]+/gu, " "))
|
|
83
|
+
.map((segment) => segment.replace(/^\w/u, (char) => char.toUpperCase()))
|
|
84
|
+
.join(" ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function normalizeRoute(value) {
|
|
88
|
+
const trimmed = String(value || "/").trim();
|
|
89
|
+
if (!trimmed || trimmed === "/") return "/";
|
|
90
|
+
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
91
|
+
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, "") : withLeadingSlash;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function normalizePath(filePath) {
|
|
95
|
+
return filePath.split(path.sep).join("/");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function modulePathKey(filePath) {
|
|
99
|
+
const normalized = normalizePath(filePath);
|
|
100
|
+
const ext = path.extname(normalized);
|
|
101
|
+
if (path.basename(normalized, ext) === "index") {
|
|
102
|
+
return normalizePath(path.dirname(normalized));
|
|
103
|
+
}
|
|
104
|
+
return normalized.slice(0, normalized.length - ext.length);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function escapeRegExp(value) {
|
|
108
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isServerActionFile(content) {
|
|
112
|
+
const trimmed = content.trimStart();
|
|
113
|
+
return trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isBackendSpecifier(specifier) {
|
|
117
|
+
return (
|
|
118
|
+
specifier.startsWith("@/backend/server/") ||
|
|
119
|
+
specifier.includes("/backend/server/") ||
|
|
120
|
+
specifier.startsWith("./backend/server/") ||
|
|
121
|
+
specifier.startsWith("../backend/server/")
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function isDataSpecifier(specifier) {
|
|
126
|
+
return (
|
|
127
|
+
specifier.startsWith("@/backend/data/") ||
|
|
128
|
+
specifier.startsWith("@/backend/dal/") ||
|
|
129
|
+
specifier.includes("/backend/data/") ||
|
|
130
|
+
specifier.includes("/backend/dal/") ||
|
|
131
|
+
specifier.includes("/db/") ||
|
|
132
|
+
specifier.includes("/repository/") ||
|
|
133
|
+
specifier.includes("/repositories/")
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function hasWord(content, word) {
|
|
138
|
+
if (!content || !word) return false;
|
|
139
|
+
return new RegExp(`\\b${escapeRegExp(word)}\\b`, "u").test(content);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function dedupeRequests(requests) {
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
return requests.filter((entry) => {
|
|
145
|
+
const key = `${entry.method}:${entry.path}`;
|
|
146
|
+
if (seen.has(key)) return false;
|
|
147
|
+
seen.add(key);
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function dedupeNodes(nodes) {
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
return nodes.filter((node) => {
|
|
155
|
+
if (seen.has(node.id)) return false;
|
|
156
|
+
seen.add(node.id);
|
|
157
|
+
return true;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function dedupeEdges(edges) {
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
return edges.filter((edge) => {
|
|
164
|
+
if (seen.has(edge.id)) return false;
|
|
165
|
+
seen.add(edge.id);
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function dedupeTargets(targets) {
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
return targets.filter((target) => {
|
|
173
|
+
const key = `${target.kind}:${target.value}`;
|
|
174
|
+
if (seen.has(key)) return false;
|
|
175
|
+
seen.add(key);
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function dedupeBackendImports(entries) {
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
return entries.filter((entry) => {
|
|
183
|
+
const key = `${entry.importName}:${entry.node.filePath || ""}:${entry.node.label}`;
|
|
184
|
+
if (seen.has(key)) return false;
|
|
185
|
+
seen.add(key);
|
|
186
|
+
return true;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function dedupeDataImports(entries) {
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
return entries.filter((entry) => {
|
|
193
|
+
const key = `${entry.importName}:${entry.node.filePath || ""}:${entry.node.label}`;
|
|
194
|
+
if (seen.has(key)) return false;
|
|
195
|
+
seen.add(key);
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
dedupeTargets,
|
|
4
|
+
modulePathKey,
|
|
5
|
+
pageLabelFromRoute,
|
|
6
|
+
toApiRequestPath,
|
|
7
|
+
toSelectionType,
|
|
8
|
+
} from "./shared.mjs";
|
|
9
|
+
|
|
10
|
+
describe("coverage shared helpers", () => {
|
|
11
|
+
it("builds readable page labels from routes", () => {
|
|
12
|
+
expect(pageLabelFromRoute("/")).toBe("Home");
|
|
13
|
+
expect(pageLabelFromRoute("/campaigns/[campaignId]")).toBe("Campaigns CampaignId");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("collapses index modules into stable module path keys", () => {
|
|
17
|
+
expect(modulePathKey("src/backend/server/campaigns/index.ts")).toBe("src/backend/server/campaigns");
|
|
18
|
+
expect(modulePathKey("src/backend/server/campaigns.ts")).toBe("src/backend/server/campaigns");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("dedupes overlay targets by kind and value", () => {
|
|
22
|
+
expect(
|
|
23
|
+
dedupeTargets([
|
|
24
|
+
{ kind: "testId", value: "save-button", confidence: "high" },
|
|
25
|
+
{ kind: "testId", value: "save-button", confidence: "medium" },
|
|
26
|
+
{ kind: "text", value: "Save", confidence: "medium" },
|
|
27
|
+
])
|
|
28
|
+
).toEqual([
|
|
29
|
+
{ kind: "testId", value: "save-button", confidence: "high" },
|
|
30
|
+
{ kind: "text", value: "Save", confidence: "medium" },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("normalizes request paths and selection types", () => {
|
|
35
|
+
expect(toApiRequestPath("/campaigns")).toBe("/api/campaigns");
|
|
36
|
+
expect(toSelectionType("integration", "http")).toBe("int");
|
|
37
|
+
expect(toSelectionType("ui", "playwright")).toBe("pw");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
14
|
+
"@elench/testkit-protocol": "0.1.62"
|
|
15
15
|
},
|
|
16
16
|
"private": false
|
|
17
17
|
}
|
|
@@ -143,6 +143,7 @@ export function buildPageOverlayResponse(context, pageUrl) {
|
|
|
143
143
|
const projection = buildGraphProjection(context, page, match.service?.name || null);
|
|
144
144
|
const relatedCoverageCount = projection.coverage.length;
|
|
145
145
|
const relatedFailureCount = projection.failures.length;
|
|
146
|
+
const coverageBreakdown = buildCoverageBreakdown(projection.coverage);
|
|
146
147
|
|
|
147
148
|
return {
|
|
148
149
|
protocolVersion: TESTKIT_BROWSER_PROTOCOL_VERSION,
|
|
@@ -162,6 +163,7 @@ export function buildPageOverlayResponse(context, pageUrl) {
|
|
|
162
163
|
coverageState: relatedCoverageCount > 0 ? "covered" : "missing",
|
|
163
164
|
relatedFailureCount,
|
|
164
165
|
relatedCoverageCount,
|
|
166
|
+
coverageBreakdown,
|
|
165
167
|
},
|
|
166
168
|
failures: projection.failures,
|
|
167
169
|
coverage: projection.coverage,
|
|
@@ -260,15 +262,94 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
260
262
|
return { coverage: [], failures: [] };
|
|
261
263
|
}
|
|
262
264
|
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
const failedFiles = collectFailedFiles(context.runArtifact);
|
|
266
|
+
const failureByFile = new Map(failedFiles.map((entry) => [entry.filePath, entry]));
|
|
267
|
+
const surfaceNodes = (outgoing.get(pageNode.id) || [])
|
|
268
|
+
.map((nodeId) => nodeById.get(nodeId))
|
|
269
|
+
.filter((node) => node?.kind === "ui_surface");
|
|
270
|
+
const pageReachableNodeIds = collectReachableNodeIds(pageNode.id, outgoing);
|
|
271
|
+
|
|
272
|
+
if (surfaceNodes.length === 0) {
|
|
273
|
+
const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], pageReachableNodeIds));
|
|
274
|
+
return buildPageLevelProjection({
|
|
275
|
+
pageNode,
|
|
276
|
+
nodeById,
|
|
277
|
+
relevantEvidence,
|
|
278
|
+
pageReachableNodeIds,
|
|
279
|
+
discoveryByFile,
|
|
280
|
+
failureByFile,
|
|
281
|
+
runArtifact: context.runArtifact,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const coverage = [];
|
|
286
|
+
const failures = [];
|
|
287
|
+
|
|
288
|
+
for (const surfaceNode of surfaceNodes.sort((left, right) => left.id.localeCompare(right.id))) {
|
|
289
|
+
const surfaceReachableNodeIds = collectReachableNodeIds(surfaceNode.id, outgoing);
|
|
290
|
+
const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], surfaceReachableNodeIds));
|
|
291
|
+
const supportingTests = relevantEvidence
|
|
292
|
+
.map((entry) => buildSupportingTestRef(entry, discoveryByFile, context.runArtifact))
|
|
293
|
+
.filter(Boolean);
|
|
294
|
+
|
|
295
|
+
if (supportingTests.length > 0) {
|
|
296
|
+
const supportKind = inferCoverageSupportKind(supportingTests);
|
|
297
|
+
coverage.push({
|
|
298
|
+
id: surfaceNode.id,
|
|
299
|
+
kind: surfaceNode.kind,
|
|
300
|
+
label: surfaceNode.label,
|
|
301
|
+
service: surfaceNode.service,
|
|
302
|
+
route: pageNode.route || null,
|
|
303
|
+
targets: collectTargetsForEvidence(relevantEvidence, surfaceNode.target),
|
|
304
|
+
supportingTests,
|
|
305
|
+
viaNodes: collectViaNodes(relevantEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
|
|
306
|
+
confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
|
|
307
|
+
importance: inferSurfaceImportance(surfaceNode),
|
|
308
|
+
surfaceKind: surfaceNode.metadata?.surfaceKind ? String(surfaceNode.metadata.surfaceKind) : null,
|
|
309
|
+
supportKind,
|
|
310
|
+
reason: buildCoverageReason(surfaceNode, supportKind, supportingTests, relevantEvidence),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const failedEvidence = relevantEvidence.filter((entry) => failureByFile.has(entry.testFilePath));
|
|
315
|
+
if (failedEvidence.length === 0) continue;
|
|
316
|
+
|
|
317
|
+
const failedTests = failedEvidence
|
|
318
|
+
.map((entry) => {
|
|
319
|
+
const supporting = buildSupportingTestRef(entry, discoveryByFile, context.runArtifact);
|
|
320
|
+
const failed = failureByFile.get(entry.testFilePath);
|
|
321
|
+
if (!supporting) return null;
|
|
322
|
+
return {
|
|
323
|
+
...supporting,
|
|
324
|
+
error: failed?.error || supporting.error || null,
|
|
325
|
+
status: "failed",
|
|
326
|
+
};
|
|
327
|
+
})
|
|
328
|
+
.filter(Boolean);
|
|
329
|
+
|
|
330
|
+
failures.push({
|
|
331
|
+
id: `failure:${surfaceNode.id}`,
|
|
332
|
+
kind: surfaceNode.kind,
|
|
333
|
+
label: surfaceNode.label,
|
|
334
|
+
service: surfaceNode.service,
|
|
335
|
+
route: pageNode.route || null,
|
|
336
|
+
targets: collectTargetsForEvidence(failedEvidence, surfaceNode.target),
|
|
337
|
+
failedTests,
|
|
338
|
+
viaNodes: collectViaNodes(failedEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
|
|
339
|
+
importance: inferSurfaceImportance(surfaceNode),
|
|
340
|
+
surfaceKind: surfaceNode.metadata?.surfaceKind ? String(surfaceNode.metadata.surfaceKind) : null,
|
|
341
|
+
reason: buildFailureReason(surfaceNode, failedTests),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { coverage, failures };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function buildPageLevelProjection({ pageNode, nodeById, relevantEvidence, pageReachableNodeIds, discoveryByFile, failureByFile, runArtifact }) {
|
|
267
349
|
const supportingTests = relevantEvidence
|
|
268
|
-
.map((entry) => buildSupportingTestRef(entry, discoveryByFile,
|
|
350
|
+
.map((entry) => buildSupportingTestRef(entry, discoveryByFile, runArtifact))
|
|
269
351
|
.filter(Boolean);
|
|
270
352
|
|
|
271
|
-
const viaNodes = collectViaNodes(relevantEvidence, reachableNodeIds, nodeById, pageNode.id);
|
|
272
353
|
const coverage = supportingTests.length > 0
|
|
273
354
|
? [
|
|
274
355
|
{
|
|
@@ -279,19 +360,21 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
279
360
|
route: pageNode.route || null,
|
|
280
361
|
targets: collectTargetsForEvidence(relevantEvidence, pageNode.target),
|
|
281
362
|
supportingTests,
|
|
282
|
-
viaNodes,
|
|
363
|
+
viaNodes: collectViaNodes(relevantEvidence, pageReachableNodeIds, nodeById, new Set([pageNode.id])),
|
|
283
364
|
confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
|
|
365
|
+
importance: inferSurfaceImportance(pageNode),
|
|
366
|
+
surfaceKind: pageNode.kind,
|
|
367
|
+
supportKind: inferCoverageSupportKind(supportingTests),
|
|
368
|
+
reason: buildCoverageReason(pageNode, inferCoverageSupportKind(supportingTests), supportingTests, relevantEvidence),
|
|
284
369
|
},
|
|
285
370
|
]
|
|
286
371
|
: [];
|
|
287
372
|
|
|
288
|
-
const failedFiles = collectFailedFiles(context.runArtifact);
|
|
289
|
-
const failureByFile = new Map(failedFiles.map((entry) => [entry.filePath, entry]));
|
|
290
373
|
const failures = relevantEvidence
|
|
291
374
|
.filter((entry) => failureByFile.has(entry.testFilePath))
|
|
292
375
|
.map((entry) => {
|
|
293
376
|
const failed = failureByFile.get(entry.testFilePath);
|
|
294
|
-
const supporting = buildSupportingTestRef(entry, discoveryByFile,
|
|
377
|
+
const supporting = buildSupportingTestRef(entry, discoveryByFile, runArtifact);
|
|
295
378
|
return {
|
|
296
379
|
id: `failure:${entry.testFilePath}`,
|
|
297
380
|
kind: pageNode.kind,
|
|
@@ -300,7 +383,10 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
300
383
|
route: pageNode.route || null,
|
|
301
384
|
targets: collectTargetsForEvidence([entry], pageNode.target),
|
|
302
385
|
failedTests: supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : [],
|
|
303
|
-
viaNodes: collectViaNodes([entry],
|
|
386
|
+
viaNodes: collectViaNodes([entry], pageReachableNodeIds, nodeById, new Set([pageNode.id])),
|
|
387
|
+
importance: inferSurfaceImportance(pageNode),
|
|
388
|
+
surfaceKind: pageNode.kind,
|
|
389
|
+
reason: buildFailureReason(pageNode, supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : []),
|
|
304
390
|
};
|
|
305
391
|
});
|
|
306
392
|
|
|
@@ -331,11 +417,11 @@ function collectReachableNodeIds(startNodeId, outgoing) {
|
|
|
331
417
|
return visited;
|
|
332
418
|
}
|
|
333
419
|
|
|
334
|
-
function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById,
|
|
420
|
+
function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, excludedNodeIds = new Set()) {
|
|
335
421
|
const nodes = new Map();
|
|
336
422
|
for (const entry of evidenceEntries) {
|
|
337
423
|
for (const nodeId of entry.coveredNodeIds || []) {
|
|
338
|
-
if (!reachableNodeIds.has(nodeId) || nodeId
|
|
424
|
+
if (!reachableNodeIds.has(nodeId) || excludedNodeIds.has(nodeId)) continue;
|
|
339
425
|
const node = nodeById.get(nodeId);
|
|
340
426
|
if (!node) continue;
|
|
341
427
|
nodes.set(nodeId, {
|
|
@@ -406,3 +492,60 @@ function pathBaseName(filePath) {
|
|
|
406
492
|
const parts = String(filePath || "").split("/");
|
|
407
493
|
return parts[parts.length - 1] || filePath;
|
|
408
494
|
}
|
|
495
|
+
|
|
496
|
+
function buildCoverageBreakdown(entries) {
|
|
497
|
+
const breakdown = { direct: 0, indirect: 0, mixed: 0 };
|
|
498
|
+
for (const entry of entries || []) {
|
|
499
|
+
if (entry.supportKind === "direct") breakdown.direct += 1;
|
|
500
|
+
else if (entry.supportKind === "indirect") breakdown.indirect += 1;
|
|
501
|
+
else breakdown.mixed += 1;
|
|
502
|
+
}
|
|
503
|
+
return breakdown;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function inferCoverageSupportKind(supportingTests) {
|
|
507
|
+
const hasPw = (supportingTests || []).some((entry) => entry.type === "pw");
|
|
508
|
+
const hasBackend = (supportingTests || []).some((entry) => entry.type !== "pw");
|
|
509
|
+
if (hasPw && hasBackend) return "mixed";
|
|
510
|
+
if (hasPw) return "direct";
|
|
511
|
+
return "indirect";
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function inferSurfaceImportance(node) {
|
|
515
|
+
const label = String(node?.label || "").toLowerCase();
|
|
516
|
+
const surfaceKind = String(node?.metadata?.surfaceKind || node?.kind || "").toLowerCase();
|
|
517
|
+
|
|
518
|
+
if (/\b(pay|purchase|checkout|publish|send|submit|confirm|delete|remove)\b/u.test(label)) {
|
|
519
|
+
return "critical";
|
|
520
|
+
}
|
|
521
|
+
if (/\b(save|create|update|refresh|retry|login|sign in|continue)\b/u.test(label)) {
|
|
522
|
+
return "high";
|
|
523
|
+
}
|
|
524
|
+
if (surfaceKind === "form" || surfaceKind === "button" || surfaceKind === "input") {
|
|
525
|
+
return "medium";
|
|
526
|
+
}
|
|
527
|
+
return "low";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function buildCoverageReason(node, supportKind, supportingTests, evidenceEntries) {
|
|
531
|
+
const directCount = supportingTests.filter((entry) => entry.type === "pw").length;
|
|
532
|
+
const backendCount = supportingTests.filter((entry) => entry.type !== "pw").length;
|
|
533
|
+
const requestPaths = new Set();
|
|
534
|
+
for (const entry of evidenceEntries || []) {
|
|
535
|
+
for (const path of entry?.details?.requestPaths || []) requestPaths.add(path);
|
|
536
|
+
}
|
|
537
|
+
const requestSummary = requestPaths.size > 0 ? ` via ${[...requestPaths].join(", ")}` : "";
|
|
538
|
+
if (supportKind === "mixed") {
|
|
539
|
+
return `${node.label} is covered directly by ${directCount} UI test${directCount === 1 ? "" : "s"} and indirectly by ${backendCount} backend test${backendCount === 1 ? "" : "s"}${requestSummary}.`;
|
|
540
|
+
}
|
|
541
|
+
if (supportKind === "direct") {
|
|
542
|
+
return `${node.label} is covered directly by ${directCount} UI test${directCount === 1 ? "" : "s"}${requestSummary}.`;
|
|
543
|
+
}
|
|
544
|
+
return `${node.label} is covered indirectly by ${backendCount} backend test${backendCount === 1 ? "" : "s"}${requestSummary}.`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function buildFailureReason(node, failedTests) {
|
|
548
|
+
const count = (failedTests || []).length;
|
|
549
|
+
if (count === 0) return `${node.label} has a related failing test.`;
|
|
550
|
+
return `${node.label} is implicated by ${count} failing test${count === 1 ? "" : "s"}.`;
|
|
551
|
+
}
|