@elench/testkit 0.1.60 → 0.1.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +146 -0
- package/lib/coverage/evidence.test.mjs +64 -0
- package/lib/coverage/fs-walk.mjs +64 -0
- package/lib/coverage/graph-builder.mjs +167 -0
- package/lib/coverage/index.mjs +1 -816
- package/lib/coverage/index.test.mjs +162 -14
- package/lib/coverage/next-discovery.mjs +174 -0
- package/lib/coverage/next-static-analysis.mjs +728 -0
- package/lib/coverage/routing.mjs +86 -0
- package/lib/coverage/routing.test.mjs +52 -0
- package/lib/coverage/shared.mjs +197 -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 +82 -13
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +30 -5
- package/node_modules/@elench/testkit-protocol/package.json +1 -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,197 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { TESTKIT_COVERAGE_GRAPH_VERSION } from "@elench/testkit-protocol";
|
|
3
|
+
|
|
4
|
+
export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
5
|
+
export const HTTP_WRAPPER_METHODS = {
|
|
6
|
+
getJson: "GET",
|
|
7
|
+
postJson: "POST",
|
|
8
|
+
putJson: "PUT",
|
|
9
|
+
patchJson: "PATCH",
|
|
10
|
+
deleteJson: "DELETE",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createEmptyGraph() {
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: TESTKIT_COVERAGE_GRAPH_VERSION,
|
|
16
|
+
nodes: [],
|
|
17
|
+
edges: [],
|
|
18
|
+
evidence: [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function appendGraph(graph, fragment) {
|
|
23
|
+
const nodeIds = new Set(graph.nodes.map((node) => node.id));
|
|
24
|
+
for (const node of fragment.nodes) {
|
|
25
|
+
if (nodeIds.has(node.id)) continue;
|
|
26
|
+
nodeIds.add(node.id);
|
|
27
|
+
graph.nodes.push(node);
|
|
28
|
+
}
|
|
29
|
+
const edgeIds = new Set(graph.edges.map((edge) => edge.id));
|
|
30
|
+
for (const edge of fragment.edges) {
|
|
31
|
+
if (edgeIds.has(edge.id)) continue;
|
|
32
|
+
edgeIds.add(edge.id);
|
|
33
|
+
graph.edges.push(edge);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createTestFileNode(graph, entry) {
|
|
38
|
+
const nodeId = `test_file:${entry.serviceName}:${entry.filePath}`;
|
|
39
|
+
if (!graph.nodes.some((node) => node.id === nodeId)) {
|
|
40
|
+
graph.nodes.push({
|
|
41
|
+
id: nodeId,
|
|
42
|
+
kind: "test_file",
|
|
43
|
+
service: entry.serviceName,
|
|
44
|
+
label: path.basename(entry.filePath),
|
|
45
|
+
filePath: entry.filePath,
|
|
46
|
+
metadata: {
|
|
47
|
+
suiteName: entry.suiteName,
|
|
48
|
+
framework: entry.framework,
|
|
49
|
+
type: toSelectionType(entry.type, entry.framework),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return nodeId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function apiRouteLookupKey(method, route) {
|
|
57
|
+
return `${method}:${route}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function toApiRequestPath(route) {
|
|
61
|
+
return normalizeRoute(`/api${route === "/" ? "" : route}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function toSelectionType(type, framework) {
|
|
65
|
+
if (framework === "playwright") return "pw";
|
|
66
|
+
if (type === "integration") return "int";
|
|
67
|
+
return type;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function pageLabelFromRoute(route) {
|
|
71
|
+
if (route === "/") return "Home";
|
|
72
|
+
return route
|
|
73
|
+
.split("/")
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.map((segment) => {
|
|
76
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
77
|
+
return segment.slice(1, -1);
|
|
78
|
+
}
|
|
79
|
+
return segment;
|
|
80
|
+
})
|
|
81
|
+
.map((segment) => segment.replace(/[-_]+/gu, " "))
|
|
82
|
+
.map((segment) => segment.replace(/^\w/u, (char) => char.toUpperCase()))
|
|
83
|
+
.join(" ");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function normalizeRoute(value) {
|
|
87
|
+
const trimmed = String(value || "/").trim();
|
|
88
|
+
if (!trimmed || trimmed === "/") return "/";
|
|
89
|
+
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
90
|
+
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, "") : withLeadingSlash;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function normalizePath(filePath) {
|
|
94
|
+
return filePath.split(path.sep).join("/");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function modulePathKey(filePath) {
|
|
98
|
+
const normalized = normalizePath(filePath);
|
|
99
|
+
const ext = path.extname(normalized);
|
|
100
|
+
if (path.basename(normalized, ext) === "index") {
|
|
101
|
+
return normalizePath(path.dirname(normalized));
|
|
102
|
+
}
|
|
103
|
+
return normalized.slice(0, normalized.length - ext.length);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function escapeRegExp(value) {
|
|
107
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function isServerActionFile(content) {
|
|
111
|
+
const trimmed = content.trimStart();
|
|
112
|
+
return trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isBackendSpecifier(specifier) {
|
|
116
|
+
return (
|
|
117
|
+
specifier.startsWith("@/backend/server/") ||
|
|
118
|
+
specifier.includes("/backend/server/") ||
|
|
119
|
+
specifier.startsWith("./backend/server/") ||
|
|
120
|
+
specifier.startsWith("../backend/server/")
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isDataSpecifier(specifier) {
|
|
125
|
+
return (
|
|
126
|
+
specifier.startsWith("@/backend/data/") ||
|
|
127
|
+
specifier.startsWith("@/backend/dal/") ||
|
|
128
|
+
specifier.includes("/backend/data/") ||
|
|
129
|
+
specifier.includes("/backend/dal/") ||
|
|
130
|
+
specifier.includes("/db/") ||
|
|
131
|
+
specifier.includes("/repository/") ||
|
|
132
|
+
specifier.includes("/repositories/")
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function hasWord(content, word) {
|
|
137
|
+
if (!content || !word) return false;
|
|
138
|
+
return new RegExp(`\\b${escapeRegExp(word)}\\b`, "u").test(content);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function dedupeRequests(requests) {
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
return requests.filter((entry) => {
|
|
144
|
+
const key = `${entry.method}:${entry.path}`;
|
|
145
|
+
if (seen.has(key)) return false;
|
|
146
|
+
seen.add(key);
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function dedupeNodes(nodes) {
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
return nodes.filter((node) => {
|
|
154
|
+
if (seen.has(node.id)) return false;
|
|
155
|
+
seen.add(node.id);
|
|
156
|
+
return true;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function dedupeEdges(edges) {
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
return edges.filter((edge) => {
|
|
163
|
+
if (seen.has(edge.id)) return false;
|
|
164
|
+
seen.add(edge.id);
|
|
165
|
+
return true;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function dedupeTargets(targets) {
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
return targets.filter((target) => {
|
|
172
|
+
const key = `${target.kind}:${target.value}`;
|
|
173
|
+
if (seen.has(key)) return false;
|
|
174
|
+
seen.add(key);
|
|
175
|
+
return true;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function dedupeBackendImports(entries) {
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
return entries.filter((entry) => {
|
|
182
|
+
const key = `${entry.importName}:${entry.node.filePath || ""}:${entry.node.label}`;
|
|
183
|
+
if (seen.has(key)) return false;
|
|
184
|
+
seen.add(key);
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function dedupeDataImports(entries) {
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
return entries.filter((entry) => {
|
|
192
|
+
const key = `${entry.importName}:${entry.node.filePath || ""}:${entry.node.label}`;
|
|
193
|
+
if (seen.has(key)) return false;
|
|
194
|
+
seen.add(key);
|
|
195
|
+
return true;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
dedupeTargets,
|
|
4
|
+
modulePathKey,
|
|
5
|
+
pageLabelFromRoute,
|
|
6
|
+
toApiRequestPath,
|
|
7
|
+
toSelectionType,
|
|
8
|
+
} from "./shared.mjs";
|
|
9
|
+
|
|
10
|
+
describe("coverage shared helpers", () => {
|
|
11
|
+
it("builds readable page labels from routes", () => {
|
|
12
|
+
expect(pageLabelFromRoute("/")).toBe("Home");
|
|
13
|
+
expect(pageLabelFromRoute("/campaigns/[campaignId]")).toBe("Campaigns CampaignId");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("collapses index modules into stable module path keys", () => {
|
|
17
|
+
expect(modulePathKey("src/backend/server/campaigns/index.ts")).toBe("src/backend/server/campaigns");
|
|
18
|
+
expect(modulePathKey("src/backend/server/campaigns.ts")).toBe("src/backend/server/campaigns");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("dedupes overlay targets by kind and value", () => {
|
|
22
|
+
expect(
|
|
23
|
+
dedupeTargets([
|
|
24
|
+
{ kind: "testId", value: "save-button", confidence: "high" },
|
|
25
|
+
{ kind: "testId", value: "save-button", confidence: "medium" },
|
|
26
|
+
{ kind: "text", value: "Save", confidence: "medium" },
|
|
27
|
+
])
|
|
28
|
+
).toEqual([
|
|
29
|
+
{ kind: "testId", value: "save-button", confidence: "high" },
|
|
30
|
+
{ kind: "text", value: "Save", confidence: "medium" },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("normalizes request paths and selection types", () => {
|
|
35
|
+
expect(toApiRequestPath("/campaigns")).toBe("/api/campaigns");
|
|
36
|
+
expect(toSelectionType("integration", "http")).toBe("int");
|
|
37
|
+
expect(toSelectionType("ui", "playwright")).toBe("pw");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.61",
|
|
4
4
|
"description": "Browser bridge helpers for testkit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"src/"
|
|
12
12
|
],
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@elench/testkit-protocol": "0.1.
|
|
14
|
+
"@elench/testkit-protocol": "0.1.61"
|
|
15
15
|
},
|
|
16
16
|
"private": false
|
|
17
17
|
}
|
|
@@ -260,15 +260,86 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
260
260
|
return { coverage: [], failures: [] };
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
263
|
+
const failedFiles = collectFailedFiles(context.runArtifact);
|
|
264
|
+
const failureByFile = new Map(failedFiles.map((entry) => [entry.filePath, entry]));
|
|
265
|
+
const surfaceNodes = (outgoing.get(pageNode.id) || [])
|
|
266
|
+
.map((nodeId) => nodeById.get(nodeId))
|
|
267
|
+
.filter((node) => node?.kind === "ui_surface");
|
|
268
|
+
const pageReachableNodeIds = collectReachableNodeIds(pageNode.id, outgoing);
|
|
269
|
+
|
|
270
|
+
if (surfaceNodes.length === 0) {
|
|
271
|
+
const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], pageReachableNodeIds));
|
|
272
|
+
return buildPageLevelProjection({
|
|
273
|
+
pageNode,
|
|
274
|
+
nodeById,
|
|
275
|
+
relevantEvidence,
|
|
276
|
+
pageReachableNodeIds,
|
|
277
|
+
discoveryByFile,
|
|
278
|
+
failureByFile,
|
|
279
|
+
runArtifact: context.runArtifact,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const coverage = [];
|
|
284
|
+
const failures = [];
|
|
285
|
+
|
|
286
|
+
for (const surfaceNode of surfaceNodes.sort((left, right) => left.id.localeCompare(right.id))) {
|
|
287
|
+
const surfaceReachableNodeIds = collectReachableNodeIds(surfaceNode.id, outgoing);
|
|
288
|
+
const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], surfaceReachableNodeIds));
|
|
289
|
+
const supportingTests = relevantEvidence
|
|
290
|
+
.map((entry) => buildSupportingTestRef(entry, discoveryByFile, context.runArtifact))
|
|
291
|
+
.filter(Boolean);
|
|
292
|
+
|
|
293
|
+
if (supportingTests.length > 0) {
|
|
294
|
+
coverage.push({
|
|
295
|
+
id: surfaceNode.id,
|
|
296
|
+
kind: surfaceNode.kind,
|
|
297
|
+
label: surfaceNode.label,
|
|
298
|
+
service: surfaceNode.service,
|
|
299
|
+
route: pageNode.route || null,
|
|
300
|
+
targets: collectTargetsForEvidence(relevantEvidence, surfaceNode.target),
|
|
301
|
+
supportingTests,
|
|
302
|
+
viaNodes: collectViaNodes(relevantEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
|
|
303
|
+
confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const failedEvidence = relevantEvidence.filter((entry) => failureByFile.has(entry.testFilePath));
|
|
308
|
+
if (failedEvidence.length === 0) continue;
|
|
309
|
+
|
|
310
|
+
const failedTests = failedEvidence
|
|
311
|
+
.map((entry) => {
|
|
312
|
+
const supporting = buildSupportingTestRef(entry, discoveryByFile, context.runArtifact);
|
|
313
|
+
const failed = failureByFile.get(entry.testFilePath);
|
|
314
|
+
if (!supporting) return null;
|
|
315
|
+
return {
|
|
316
|
+
...supporting,
|
|
317
|
+
error: failed?.error || supporting.error || null,
|
|
318
|
+
status: "failed",
|
|
319
|
+
};
|
|
320
|
+
})
|
|
321
|
+
.filter(Boolean);
|
|
322
|
+
|
|
323
|
+
failures.push({
|
|
324
|
+
id: `failure:${surfaceNode.id}`,
|
|
325
|
+
kind: surfaceNode.kind,
|
|
326
|
+
label: surfaceNode.label,
|
|
327
|
+
service: surfaceNode.service,
|
|
328
|
+
route: pageNode.route || null,
|
|
329
|
+
targets: collectTargetsForEvidence(failedEvidence, surfaceNode.target),
|
|
330
|
+
failedTests,
|
|
331
|
+
viaNodes: collectViaNodes(failedEvidence, surfaceReachableNodeIds, nodeById, new Set([pageNode.id, surfaceNode.id])),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { coverage, failures };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildPageLevelProjection({ pageNode, nodeById, relevantEvidence, pageReachableNodeIds, discoveryByFile, failureByFile, runArtifact }) {
|
|
267
339
|
const supportingTests = relevantEvidence
|
|
268
|
-
.map((entry) => buildSupportingTestRef(entry, discoveryByFile,
|
|
340
|
+
.map((entry) => buildSupportingTestRef(entry, discoveryByFile, runArtifact))
|
|
269
341
|
.filter(Boolean);
|
|
270
342
|
|
|
271
|
-
const viaNodes = collectViaNodes(relevantEvidence, reachableNodeIds, nodeById, pageNode.id);
|
|
272
343
|
const coverage = supportingTests.length > 0
|
|
273
344
|
? [
|
|
274
345
|
{
|
|
@@ -279,19 +350,17 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
279
350
|
route: pageNode.route || null,
|
|
280
351
|
targets: collectTargetsForEvidence(relevantEvidence, pageNode.target),
|
|
281
352
|
supportingTests,
|
|
282
|
-
viaNodes,
|
|
353
|
+
viaNodes: collectViaNodes(relevantEvidence, pageReachableNodeIds, nodeById, new Set([pageNode.id])),
|
|
283
354
|
confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
|
|
284
355
|
},
|
|
285
356
|
]
|
|
286
357
|
: [];
|
|
287
358
|
|
|
288
|
-
const failedFiles = collectFailedFiles(context.runArtifact);
|
|
289
|
-
const failureByFile = new Map(failedFiles.map((entry) => [entry.filePath, entry]));
|
|
290
359
|
const failures = relevantEvidence
|
|
291
360
|
.filter((entry) => failureByFile.has(entry.testFilePath))
|
|
292
361
|
.map((entry) => {
|
|
293
362
|
const failed = failureByFile.get(entry.testFilePath);
|
|
294
|
-
const supporting = buildSupportingTestRef(entry, discoveryByFile,
|
|
363
|
+
const supporting = buildSupportingTestRef(entry, discoveryByFile, runArtifact);
|
|
295
364
|
return {
|
|
296
365
|
id: `failure:${entry.testFilePath}`,
|
|
297
366
|
kind: pageNode.kind,
|
|
@@ -300,7 +369,7 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
300
369
|
route: pageNode.route || null,
|
|
301
370
|
targets: collectTargetsForEvidence([entry], pageNode.target),
|
|
302
371
|
failedTests: supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : [],
|
|
303
|
-
viaNodes: collectViaNodes([entry],
|
|
372
|
+
viaNodes: collectViaNodes([entry], pageReachableNodeIds, nodeById, new Set([pageNode.id])),
|
|
304
373
|
};
|
|
305
374
|
});
|
|
306
375
|
|
|
@@ -331,11 +400,11 @@ function collectReachableNodeIds(startNodeId, outgoing) {
|
|
|
331
400
|
return visited;
|
|
332
401
|
}
|
|
333
402
|
|
|
334
|
-
function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById,
|
|
403
|
+
function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, excludedNodeIds = new Set()) {
|
|
335
404
|
const nodes = new Map();
|
|
336
405
|
for (const entry of evidenceEntries) {
|
|
337
406
|
for (const nodeId of entry.coveredNodeIds || []) {
|
|
338
|
-
if (!reachableNodeIds.has(nodeId) || nodeId
|
|
407
|
+
if (!reachableNodeIds.has(nodeId) || excludedNodeIds.has(nodeId)) continue;
|
|
339
408
|
const node = nodeById.get(nodeId);
|
|
340
409
|
if (!node) continue;
|
|
341
410
|
nodes.set(nodeId, {
|
|
@@ -45,6 +45,19 @@ const context = {
|
|
|
45
45
|
route: "/coverage",
|
|
46
46
|
filePath: "app/coverage/page.tsx",
|
|
47
47
|
},
|
|
48
|
+
{
|
|
49
|
+
id: "ui_surface:web:/coverage:coverage-refresh-button",
|
|
50
|
+
kind: "ui_surface",
|
|
51
|
+
service: "web",
|
|
52
|
+
label: "Refresh overlay",
|
|
53
|
+
route: "/coverage",
|
|
54
|
+
filePath: "app/coverage/page.tsx",
|
|
55
|
+
target: {
|
|
56
|
+
kind: "testId",
|
|
57
|
+
value: "coverage-refresh-button",
|
|
58
|
+
confidence: "high",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
48
61
|
{
|
|
49
62
|
id: "api_route:web:GET:/api/coverage",
|
|
50
63
|
kind: "api_route",
|
|
@@ -72,9 +85,16 @@ const context = {
|
|
|
72
85
|
],
|
|
73
86
|
edges: [
|
|
74
87
|
{
|
|
75
|
-
id: "
|
|
76
|
-
kind: "
|
|
88
|
+
id: "contains:page_view:web:/coverage:ui_surface:web:/coverage:coverage-refresh-button",
|
|
89
|
+
kind: "contains",
|
|
77
90
|
from: "page_view:web:/coverage",
|
|
91
|
+
to: "ui_surface:web:/coverage:coverage-refresh-button",
|
|
92
|
+
confidence: "high",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "requests:ui_surface:web:/coverage:coverage-refresh-button:api_route:web:GET:/api/coverage",
|
|
96
|
+
kind: "requests",
|
|
97
|
+
from: "ui_surface:web:/coverage:coverage-refresh-button",
|
|
78
98
|
to: "api_route:web:GET:/api/coverage",
|
|
79
99
|
confidence: "high",
|
|
80
100
|
},
|
|
@@ -89,7 +109,10 @@ const context = {
|
|
|
89
109
|
selectionType: "pw",
|
|
90
110
|
framework: "playwright",
|
|
91
111
|
testFilePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
|
|
92
|
-
coveredNodeIds: [
|
|
112
|
+
coveredNodeIds: [
|
|
113
|
+
"page_view:web:/coverage",
|
|
114
|
+
"ui_surface:web:/coverage:coverage-refresh-button",
|
|
115
|
+
],
|
|
93
116
|
details: {
|
|
94
117
|
route: "/coverage",
|
|
95
118
|
targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
|
|
@@ -159,7 +182,8 @@ describe("testkit bridge", () => {
|
|
|
159
182
|
},
|
|
160
183
|
failures: [
|
|
161
184
|
{
|
|
162
|
-
|
|
185
|
+
kind: "ui_surface",
|
|
186
|
+
label: "Refresh overlay",
|
|
163
187
|
targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
|
|
164
188
|
failedTests: [
|
|
165
189
|
{
|
|
@@ -171,7 +195,8 @@ describe("testkit bridge", () => {
|
|
|
171
195
|
],
|
|
172
196
|
coverage: [
|
|
173
197
|
{
|
|
174
|
-
|
|
198
|
+
kind: "ui_surface",
|
|
199
|
+
label: "Refresh overlay",
|
|
175
200
|
targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
|
|
176
201
|
supportingTests: [
|
|
177
202
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.61",
|
|
4
4
|
"description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -59,15 +59,16 @@
|
|
|
59
59
|
"vitest": "^3.2.4"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@elench/testkit-bridge": "0.1.
|
|
63
|
-
"@elench/testkit-protocol": "0.1.
|
|
62
|
+
"@elench/testkit-bridge": "0.1.61",
|
|
63
|
+
"@elench/testkit-protocol": "0.1.61",
|
|
64
64
|
"@babel/code-frame": "^7.29.0",
|
|
65
65
|
"@oclif/core": "^4.10.6",
|
|
66
66
|
"esbuild": "^0.25.11",
|
|
67
67
|
"execa": "^9.5.0",
|
|
68
68
|
"ink": "^7.0.1",
|
|
69
69
|
"picocolors": "^1.1.1",
|
|
70
|
-
"react": "^19.2.5"
|
|
70
|
+
"react": "^19.2.5",
|
|
71
|
+
"typescript": "^5.9.3"
|
|
71
72
|
},
|
|
72
73
|
"engines": {
|
|
73
74
|
"node": ">=18"
|