@elench/testkit 0.1.57 → 0.1.58
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/cli/commands/browser/serve.mjs +112 -0
- package/lib/cli/entrypoint.mjs +4 -0
- package/lib/config/discovery.test.mjs +3 -2
- package/lib/config/index.mjs +29 -0
- package/lib/coverage/index.mjs +774 -0
- package/lib/coverage/index.test.mjs +220 -0
- package/lib/discovery/index.d.ts +3 -0
- package/lib/discovery/index.mjs +14 -2
- package/lib/setup/index.d.ts +5 -0
- package/node_modules/@elench/testkit-bridge/package.json +17 -0
- package/node_modules/@elench/testkit-bridge/src/index.mjs +391 -0
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +183 -0
- package/node_modules/@elench/testkit-protocol/package.json +18 -0
- package/node_modules/@elench/testkit-protocol/src/index.d.ts +204 -0
- package/node_modules/@elench/testkit-protocol/src/index.mjs +245 -0
- package/node_modules/@elench/testkit-protocol/src/index.test.mjs +154 -0
- package/package.json +11 -2
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { TESTKIT_COVERAGE_GRAPH_VERSION } from "@elench/testkit-protocol";
|
|
4
|
+
|
|
5
|
+
const IGNORED_DIRS = new Set([
|
|
6
|
+
".cache",
|
|
7
|
+
".git",
|
|
8
|
+
".hg",
|
|
9
|
+
".next",
|
|
10
|
+
".nuxt",
|
|
11
|
+
".playwright-browsers",
|
|
12
|
+
".svn",
|
|
13
|
+
".testkit",
|
|
14
|
+
".turbo",
|
|
15
|
+
"build",
|
|
16
|
+
"coverage",
|
|
17
|
+
"dist",
|
|
18
|
+
"node_modules",
|
|
19
|
+
"playwright-report",
|
|
20
|
+
"test-results",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
24
|
+
const HTTP_WRAPPER_METHODS = {
|
|
25
|
+
getJson: "GET",
|
|
26
|
+
postJson: "POST",
|
|
27
|
+
putJson: "PUT",
|
|
28
|
+
patchJson: "PATCH",
|
|
29
|
+
deleteJson: "DELETE",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function buildCoverageGraph({ productDir, services = {}, discoveryFiles = [] }) {
|
|
33
|
+
const graph = createEmptyGraph();
|
|
34
|
+
const serviceContexts = new Map();
|
|
35
|
+
|
|
36
|
+
for (const [serviceName, config] of Object.entries(services)) {
|
|
37
|
+
const context = buildServiceCoverageContext(productDir, serviceName, config);
|
|
38
|
+
if (!context) continue;
|
|
39
|
+
serviceContexts.set(serviceName, context);
|
|
40
|
+
appendGraph(graph, context.graph);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const evidence = [];
|
|
44
|
+
for (const entry of discoveryFiles) {
|
|
45
|
+
const context = serviceContexts.get(entry.serviceName);
|
|
46
|
+
const testNodeId = createTestFileNode(graph, entry);
|
|
47
|
+
if (!context) {
|
|
48
|
+
evidence.push({
|
|
49
|
+
id: `evidence:${entry.serviceName}:${entry.filePath}`,
|
|
50
|
+
source: "convention",
|
|
51
|
+
confidence: "medium",
|
|
52
|
+
service: entry.serviceName,
|
|
53
|
+
suiteName: entry.suiteName,
|
|
54
|
+
selectionType: toSelectionType(entry.type, entry.framework),
|
|
55
|
+
framework: entry.framework,
|
|
56
|
+
testFilePath: entry.filePath,
|
|
57
|
+
coveredNodeIds: [testNodeId],
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const coveredNodeIds = inferCoveredNodeIdsForTest(entry, context);
|
|
63
|
+
if (coveredNodeIds.length === 0) {
|
|
64
|
+
evidence.push({
|
|
65
|
+
id: `evidence:${entry.serviceName}:${entry.filePath}`,
|
|
66
|
+
source: "convention",
|
|
67
|
+
confidence: "medium",
|
|
68
|
+
service: entry.serviceName,
|
|
69
|
+
suiteName: entry.suiteName,
|
|
70
|
+
selectionType: toSelectionType(entry.type, entry.framework),
|
|
71
|
+
framework: entry.framework,
|
|
72
|
+
testFilePath: entry.filePath,
|
|
73
|
+
coveredNodeIds: [testNodeId],
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const nodeId of coveredNodeIds) {
|
|
79
|
+
graph.edges.push({
|
|
80
|
+
id: `covers:${testNodeId}:${nodeId}`,
|
|
81
|
+
kind: "covers",
|
|
82
|
+
from: testNodeId,
|
|
83
|
+
to: nodeId,
|
|
84
|
+
confidence: "high",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
evidence.push({
|
|
89
|
+
id: `evidence:${entry.serviceName}:${entry.filePath}`,
|
|
90
|
+
source: "convention",
|
|
91
|
+
confidence: "high",
|
|
92
|
+
service: entry.serviceName,
|
|
93
|
+
suiteName: entry.suiteName,
|
|
94
|
+
selectionType: toSelectionType(entry.type, entry.framework),
|
|
95
|
+
framework: entry.framework,
|
|
96
|
+
testFilePath: entry.filePath,
|
|
97
|
+
coveredNodeIds,
|
|
98
|
+
details: buildEvidenceDetails(coveredNodeIds, graph),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
graph.evidence = evidence.sort((left, right) => left.id.localeCompare(right.id));
|
|
103
|
+
graph.nodes.sort((left, right) => left.id.localeCompare(right.id));
|
|
104
|
+
graph.edges.sort((left, right) => left.id.localeCompare(right.id));
|
|
105
|
+
return graph;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildServiceCoverageContext(productDir, serviceName, config) {
|
|
109
|
+
const serviceRoot = resolveServiceRoot(productDir, config);
|
|
110
|
+
const nextAppRoot = findNextAppRoot(serviceRoot);
|
|
111
|
+
if (!nextAppRoot) return null;
|
|
112
|
+
|
|
113
|
+
const graph = createEmptyGraph();
|
|
114
|
+
const pages = discoverPageViews(serviceName, serviceRoot, nextAppRoot);
|
|
115
|
+
const apiRoutes = discoverApiRoutes(serviceName, serviceRoot, nextAppRoot);
|
|
116
|
+
const serverActions = discoverServerActions(serviceName, serviceRoot);
|
|
117
|
+
|
|
118
|
+
for (const node of [...pages.nodes, ...apiRoutes.nodes, ...serverActions.nodes]) {
|
|
119
|
+
graph.nodes.push(node);
|
|
120
|
+
}
|
|
121
|
+
for (const edge of [...pages.edges, ...apiRoutes.edges, ...serverActions.edges]) {
|
|
122
|
+
graph.edges.push(edge);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const pageByRoute = new Map(pages.pageEntries.map((entry) => [entry.route, entry]));
|
|
126
|
+
const apiRouteByKey = new Map(
|
|
127
|
+
apiRoutes.routeEntries.map((entry) => [apiRouteLookupKey(entry.method, entry.requestPath), entry])
|
|
128
|
+
);
|
|
129
|
+
const serverActionByExportKey = new Map(
|
|
130
|
+
serverActions.actionEntries.map((entry) => [`${entry.sourceFile}#${entry.exportName}`, entry])
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
for (const pageEntry of pages.pageEntries) {
|
|
134
|
+
for (const request of pageEntry.requests) {
|
|
135
|
+
const apiRoute = apiRouteByKey.get(apiRouteLookupKey(request.method, request.path));
|
|
136
|
+
if (!apiRoute) continue;
|
|
137
|
+
graph.edges.push({
|
|
138
|
+
id: `requests:${pageEntry.node.id}:${request.node.id}`,
|
|
139
|
+
kind: "requests",
|
|
140
|
+
from: pageEntry.node.id,
|
|
141
|
+
to: request.node.id,
|
|
142
|
+
confidence: "high",
|
|
143
|
+
});
|
|
144
|
+
graph.edges.push({
|
|
145
|
+
id: `handles:${request.node.id}:${apiRoute.node.id}`,
|
|
146
|
+
kind: "handles",
|
|
147
|
+
from: request.node.id,
|
|
148
|
+
to: apiRoute.node.id,
|
|
149
|
+
confidence: "high",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const actionRef of pageEntry.serverActionRefs) {
|
|
154
|
+
const serverAction = serverActionByExportKey.get(actionRef.exportKey);
|
|
155
|
+
if (!serverAction) continue;
|
|
156
|
+
graph.edges.push({
|
|
157
|
+
id: `triggers:${pageEntry.node.id}:${serverAction.node.id}`,
|
|
158
|
+
kind: "triggers",
|
|
159
|
+
from: pageEntry.node.id,
|
|
160
|
+
to: serverAction.node.id,
|
|
161
|
+
confidence: actionRef.confidence,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
serviceName,
|
|
168
|
+
serviceRoot,
|
|
169
|
+
nextAppRoot,
|
|
170
|
+
graph,
|
|
171
|
+
pageByRoute,
|
|
172
|
+
apiRouteByKey,
|
|
173
|
+
serverActionByExportKey,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function createEmptyGraph() {
|
|
178
|
+
return {
|
|
179
|
+
schemaVersion: TESTKIT_COVERAGE_GRAPH_VERSION,
|
|
180
|
+
nodes: [],
|
|
181
|
+
edges: [],
|
|
182
|
+
evidence: [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function appendGraph(graph, fragment) {
|
|
187
|
+
const nodeIds = new Set(graph.nodes.map((node) => node.id));
|
|
188
|
+
for (const node of fragment.nodes) {
|
|
189
|
+
if (nodeIds.has(node.id)) continue;
|
|
190
|
+
nodeIds.add(node.id);
|
|
191
|
+
graph.nodes.push(node);
|
|
192
|
+
}
|
|
193
|
+
const edgeIds = new Set(graph.edges.map((edge) => edge.id));
|
|
194
|
+
for (const edge of fragment.edges) {
|
|
195
|
+
if (edgeIds.has(edge.id)) continue;
|
|
196
|
+
edgeIds.add(edge.id);
|
|
197
|
+
graph.edges.push(edge);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function createTestFileNode(graph, entry) {
|
|
202
|
+
const nodeId = `test_file:${entry.serviceName}:${entry.filePath}`;
|
|
203
|
+
if (!graph.nodes.some((node) => node.id === nodeId)) {
|
|
204
|
+
graph.nodes.push({
|
|
205
|
+
id: nodeId,
|
|
206
|
+
kind: "test_file",
|
|
207
|
+
service: entry.serviceName,
|
|
208
|
+
label: path.basename(entry.filePath),
|
|
209
|
+
filePath: entry.filePath,
|
|
210
|
+
metadata: {
|
|
211
|
+
suiteName: entry.suiteName,
|
|
212
|
+
framework: entry.framework,
|
|
213
|
+
type: toSelectionType(entry.type, entry.framework),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return nodeId;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function inferCoveredNodeIdsForTest(entry, context) {
|
|
221
|
+
const coveredNodeIds = new Set();
|
|
222
|
+
const selectionType = toSelectionType(entry.type, entry.framework);
|
|
223
|
+
|
|
224
|
+
if (entry.framework === "playwright") {
|
|
225
|
+
const route = inferPageRouteFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
|
|
226
|
+
if (route && context.pageByRoute.has(route)) {
|
|
227
|
+
coveredNodeIds.add(context.pageByRoute.get(route).node.id);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (selectionType === "int") {
|
|
232
|
+
const apiRoutes = inferApiRoutesFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
|
|
233
|
+
for (const route of apiRoutes) {
|
|
234
|
+
for (const method of HTTP_METHODS) {
|
|
235
|
+
const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(method, toApiRequestPath(route)));
|
|
236
|
+
if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return [...coveredNodeIds].sort();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildEvidenceDetails(coveredNodeIds, graph) {
|
|
245
|
+
const routes = new Set();
|
|
246
|
+
const requestPaths = new Set();
|
|
247
|
+
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
248
|
+
for (const nodeId of coveredNodeIds) {
|
|
249
|
+
const node = nodeById.get(nodeId);
|
|
250
|
+
if (!node) continue;
|
|
251
|
+
if (node.route) routes.add(node.route);
|
|
252
|
+
if (node.path) requestPaths.add(node.path);
|
|
253
|
+
}
|
|
254
|
+
const details = {};
|
|
255
|
+
if (routes.size === 1) {
|
|
256
|
+
details.route = [...routes][0];
|
|
257
|
+
}
|
|
258
|
+
if (requestPaths.size > 0) {
|
|
259
|
+
details.requestPaths = [...requestPaths].sort();
|
|
260
|
+
}
|
|
261
|
+
return Object.keys(details).length > 0 ? details : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function discoverPageViews(serviceName, serviceRoot, nextAppRoot) {
|
|
265
|
+
const pageFiles = walkFiles(nextAppRoot).filter((filePath) => filePath.endsWith("/page.tsx") || filePath.endsWith("/page.ts"));
|
|
266
|
+
const nodes = [];
|
|
267
|
+
const edges = [];
|
|
268
|
+
const pageEntries = [];
|
|
269
|
+
|
|
270
|
+
for (const filePath of pageFiles) {
|
|
271
|
+
const absolutePath = filePath;
|
|
272
|
+
const relativePath = normalizePath(path.relative(serviceRoot, absolutePath));
|
|
273
|
+
const route = routeFromAppFile(nextAppRoot, absolutePath);
|
|
274
|
+
const content = fs.readFileSync(absolutePath, "utf8");
|
|
275
|
+
const node = {
|
|
276
|
+
id: `page_view:${serviceName}:${route}`,
|
|
277
|
+
kind: "page_view",
|
|
278
|
+
service: serviceName,
|
|
279
|
+
label: pageLabelFromRoute(route),
|
|
280
|
+
route,
|
|
281
|
+
filePath: relativePath,
|
|
282
|
+
};
|
|
283
|
+
nodes.push(node);
|
|
284
|
+
|
|
285
|
+
const requests = extractClientRequests(serviceName, relativePath, content).map((request) => {
|
|
286
|
+
nodes.push(request.node);
|
|
287
|
+
return request;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const serverActionRefs = extractPageServerActionRefs(serviceRoot, relativePath, content);
|
|
291
|
+
pageEntries.push({
|
|
292
|
+
node,
|
|
293
|
+
route,
|
|
294
|
+
filePath: relativePath,
|
|
295
|
+
requests,
|
|
296
|
+
serverActionRefs,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { nodes, edges, pageEntries };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function discoverApiRoutes(serviceName, serviceRoot, nextAppRoot) {
|
|
304
|
+
const routeFiles = walkFiles(path.join(nextAppRoot, "api")).filter(
|
|
305
|
+
(filePath) => filePath.endsWith("/route.ts") || filePath.endsWith("/route.tsx") || filePath.endsWith("/route.js")
|
|
306
|
+
);
|
|
307
|
+
const nodes = [];
|
|
308
|
+
const edges = [];
|
|
309
|
+
const routeEntries = [];
|
|
310
|
+
|
|
311
|
+
for (const absoluteOrRelative of routeFiles) {
|
|
312
|
+
const filePath = normalizePath(path.relative(serviceRoot, absoluteOrRelative));
|
|
313
|
+
const route = routeFromApiFile(nextAppRoot, absoluteOrRelative);
|
|
314
|
+
const requestPath = toApiRequestPath(route);
|
|
315
|
+
const content = fs.readFileSync(path.join(serviceRoot, filePath), "utf8");
|
|
316
|
+
const methodBodies = extractExportedMethodBodies(content);
|
|
317
|
+
const backendImports = extractBackendImports(serviceName, serviceRoot, filePath, content);
|
|
318
|
+
|
|
319
|
+
for (const [method, body] of methodBodies) {
|
|
320
|
+
const node = {
|
|
321
|
+
id: `api_route:${serviceName}:${method}:${requestPath}`,
|
|
322
|
+
kind: "api_route",
|
|
323
|
+
service: serviceName,
|
|
324
|
+
label: `${method} ${requestPath}`,
|
|
325
|
+
route,
|
|
326
|
+
method,
|
|
327
|
+
path: requestPath,
|
|
328
|
+
filePath,
|
|
329
|
+
};
|
|
330
|
+
nodes.push(node);
|
|
331
|
+
|
|
332
|
+
const matchedCapabilities = backendImports.filter((entry) => hasWord(body, entry.importName));
|
|
333
|
+
for (const capability of matchedCapabilities) {
|
|
334
|
+
nodes.push(capability.node);
|
|
335
|
+
edges.push({
|
|
336
|
+
id: `delegates_to:${node.id}:${capability.node.id}`,
|
|
337
|
+
kind: "delegates_to",
|
|
338
|
+
from: node.id,
|
|
339
|
+
to: capability.node.id,
|
|
340
|
+
confidence: "high",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
routeEntries.push({ node, method, route, requestPath, filePath });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { nodes: dedupeNodes(nodes), edges, routeEntries };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function discoverServerActions(serviceName, serviceRoot) {
|
|
352
|
+
const appRoots = [path.join(serviceRoot, "app"), path.join(serviceRoot, "src", "app")].filter((candidate) =>
|
|
353
|
+
fs.existsSync(candidate)
|
|
354
|
+
);
|
|
355
|
+
const nodes = [];
|
|
356
|
+
const edges = [];
|
|
357
|
+
const actionEntries = [];
|
|
358
|
+
|
|
359
|
+
for (const appRoot of appRoots) {
|
|
360
|
+
for (const absolutePath of walkFiles(appRoot)) {
|
|
361
|
+
if (!absolutePath.endsWith(".ts") && !absolutePath.endsWith(".tsx")) continue;
|
|
362
|
+
const content = fs.readFileSync(absolutePath, "utf8");
|
|
363
|
+
if (!isServerActionFile(content)) continue;
|
|
364
|
+
|
|
365
|
+
const relativePath = normalizePath(path.relative(serviceRoot, absolutePath));
|
|
366
|
+
const backendImports = extractBackendImports(serviceName, serviceRoot, relativePath, content);
|
|
367
|
+
const exportedFunctions = extractExportedFunctions(content);
|
|
368
|
+
|
|
369
|
+
for (const exported of exportedFunctions) {
|
|
370
|
+
const node = {
|
|
371
|
+
id: `server_action:${serviceName}:${relativePath}#${exported.name}`,
|
|
372
|
+
kind: "server_action",
|
|
373
|
+
service: serviceName,
|
|
374
|
+
label: exported.name,
|
|
375
|
+
filePath: relativePath,
|
|
376
|
+
};
|
|
377
|
+
nodes.push(node);
|
|
378
|
+
actionEntries.push({
|
|
379
|
+
node,
|
|
380
|
+
exportName: exported.name,
|
|
381
|
+
sourceFile: relativePath,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const matchedCapabilities = backendImports.filter((entry) => hasWord(exported.body, entry.importName));
|
|
385
|
+
for (const capability of matchedCapabilities) {
|
|
386
|
+
nodes.push(capability.node);
|
|
387
|
+
edges.push({
|
|
388
|
+
id: `delegates_to:${node.id}:${capability.node.id}`,
|
|
389
|
+
kind: "delegates_to",
|
|
390
|
+
from: node.id,
|
|
391
|
+
to: capability.node.id,
|
|
392
|
+
confidence: "high",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { nodes: dedupeNodes(nodes), edges, actionEntries };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function extractClientRequests(serviceName, filePath, content) {
|
|
403
|
+
const requests = [];
|
|
404
|
+
for (const [functionName, method] of Object.entries(HTTP_WRAPPER_METHODS)) {
|
|
405
|
+
const regex = new RegExp(`\\b${functionName}\\(\\s*["'\\x60]([^"'\\x60]+)["'\\x60]`, "g");
|
|
406
|
+
for (const match of content.matchAll(regex)) {
|
|
407
|
+
const requestPath = normalizeRoute(match[1]);
|
|
408
|
+
if (!requestPath.startsWith("/api/")) continue;
|
|
409
|
+
requests.push({
|
|
410
|
+
method,
|
|
411
|
+
path: requestPath,
|
|
412
|
+
node: {
|
|
413
|
+
id: `client_request:${serviceName}:${filePath}:${method}:${requestPath}`,
|
|
414
|
+
kind: "client_request",
|
|
415
|
+
service: serviceName,
|
|
416
|
+
label: `${method} ${requestPath}`,
|
|
417
|
+
method,
|
|
418
|
+
path: requestPath,
|
|
419
|
+
filePath,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const fetchRegex =
|
|
426
|
+
/\bfetch\(\s*["'`]([^"'`]+)["'`](?:\s*,\s*\{[\s\S]*?method\s*:\s*["'`](GET|POST|PUT|PATCH|DELETE)["'`][\s\S]*?\})?/gu;
|
|
427
|
+
for (const match of content.matchAll(fetchRegex)) {
|
|
428
|
+
const requestPath = normalizeRoute(match[1]);
|
|
429
|
+
if (!requestPath.startsWith("/api/")) continue;
|
|
430
|
+
const method = match[2] || "GET";
|
|
431
|
+
requests.push({
|
|
432
|
+
method,
|
|
433
|
+
path: requestPath,
|
|
434
|
+
node: {
|
|
435
|
+
id: `client_request:${serviceName}:${filePath}:${method}:${requestPath}`,
|
|
436
|
+
kind: "client_request",
|
|
437
|
+
service: serviceName,
|
|
438
|
+
label: `${method} ${requestPath}`,
|
|
439
|
+
method,
|
|
440
|
+
path: requestPath,
|
|
441
|
+
filePath,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return dedupeRequests(requests);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function extractBackendImports(serviceName, serviceRoot, filePath, content) {
|
|
450
|
+
const imports = [];
|
|
451
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'`]([^"'`]+)["'`]/gu;
|
|
452
|
+
for (const match of content.matchAll(importRegex)) {
|
|
453
|
+
const rawNames = match[1]
|
|
454
|
+
.split(",")
|
|
455
|
+
.map((entry) => entry.trim())
|
|
456
|
+
.map((entry) => entry.split(/\s+as\s+/u)[0]?.trim())
|
|
457
|
+
.filter(Boolean);
|
|
458
|
+
const specifier = match[2].trim();
|
|
459
|
+
if (!isBackendSpecifier(specifier)) continue;
|
|
460
|
+
const resolvedFilePath = resolveImportToSourceFile(serviceRoot, filePath, specifier);
|
|
461
|
+
for (const importName of rawNames) {
|
|
462
|
+
imports.push({
|
|
463
|
+
importName,
|
|
464
|
+
node: {
|
|
465
|
+
id: `server_capability:${path.dirname(resolvedFilePath || filePath)}#${importName}`,
|
|
466
|
+
kind: "server_capability",
|
|
467
|
+
service: serviceName,
|
|
468
|
+
label: importName,
|
|
469
|
+
filePath: resolvedFilePath || null,
|
|
470
|
+
metadata: {
|
|
471
|
+
specifier,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return dedupeBackendImports(imports);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function extractExportedMethodBodies(content) {
|
|
481
|
+
const bodies = [];
|
|
482
|
+
for (const method of HTTP_METHODS) {
|
|
483
|
+
const body = extractExportedFunctionBody(content, method);
|
|
484
|
+
if (body) bodies.push([method, body]);
|
|
485
|
+
}
|
|
486
|
+
return bodies;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function extractExportedFunctions(content) {
|
|
490
|
+
const exported = [];
|
|
491
|
+
const functionRegex = /export\s+(?:async\s+)?function\s+([A-Za-z0-9_]+)\s*\(/gu;
|
|
492
|
+
for (const match of content.matchAll(functionRegex)) {
|
|
493
|
+
const name = match[1];
|
|
494
|
+
const body = extractExportedFunctionBody(content, name);
|
|
495
|
+
if (!body) continue;
|
|
496
|
+
exported.push({ name, body });
|
|
497
|
+
}
|
|
498
|
+
return exported;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function extractPageServerActionRefs(serviceRoot, filePath, content) {
|
|
502
|
+
const refs = [];
|
|
503
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'`]([^"'`]+)["'`]/gu;
|
|
504
|
+
for (const match of content.matchAll(importRegex)) {
|
|
505
|
+
const specifier = match[2].trim();
|
|
506
|
+
const resolvedFilePath = resolveImportToSourceFile(serviceRoot, filePath, specifier);
|
|
507
|
+
if (!resolvedFilePath) continue;
|
|
508
|
+
const absoluteResolved = path.join(serviceRoot, resolvedFilePath);
|
|
509
|
+
if (!fs.existsSync(absoluteResolved)) continue;
|
|
510
|
+
const importedContent = fs.readFileSync(absoluteResolved, "utf8");
|
|
511
|
+
if (!isServerActionFile(importedContent)) continue;
|
|
512
|
+
const names = match[1]
|
|
513
|
+
.split(",")
|
|
514
|
+
.map((entry) => entry.trim())
|
|
515
|
+
.map((entry) => entry.split(/\s+as\s+/u)[0]?.trim())
|
|
516
|
+
.filter(Boolean);
|
|
517
|
+
for (const name of names) {
|
|
518
|
+
refs.push({
|
|
519
|
+
exportKey: `${resolvedFilePath}#${name}`,
|
|
520
|
+
confidence: "medium",
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return refs;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function extractExportedFunctionBody(content, exportName) {
|
|
528
|
+
const functionStart = new RegExp(`export\\s+(?:async\\s+)?function\\s+${escapeRegExp(exportName)}\\s*\\(`, "u");
|
|
529
|
+
const startMatch = functionStart.exec(content);
|
|
530
|
+
if (!startMatch) return null;
|
|
531
|
+
const afterSignatureIndex = content.indexOf("{", startMatch.index);
|
|
532
|
+
if (afterSignatureIndex === -1) return null;
|
|
533
|
+
return readBalancedBlock(content, afterSignatureIndex);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function readBalancedBlock(content, startIndex) {
|
|
537
|
+
let depth = 0;
|
|
538
|
+
let inSingle = false;
|
|
539
|
+
let inDouble = false;
|
|
540
|
+
let inTemplate = false;
|
|
541
|
+
|
|
542
|
+
for (let index = startIndex; index < content.length; index += 1) {
|
|
543
|
+
const char = content[index];
|
|
544
|
+
const previous = content[index - 1];
|
|
545
|
+
if (char === "'" && !inDouble && !inTemplate && previous !== "\\") inSingle = !inSingle;
|
|
546
|
+
if (char === '"' && !inSingle && !inTemplate && previous !== "\\") inDouble = !inDouble;
|
|
547
|
+
if (char === "`" && !inSingle && !inDouble && previous !== "\\") inTemplate = !inTemplate;
|
|
548
|
+
if (inSingle || inDouble || inTemplate) continue;
|
|
549
|
+
if (char === "{") depth += 1;
|
|
550
|
+
if (char === "}") {
|
|
551
|
+
depth -= 1;
|
|
552
|
+
if (depth === 0) {
|
|
553
|
+
return content.slice(startIndex, index + 1);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function inferPageRouteFromTestFile(filePath, nextAppRoot, serviceRoot) {
|
|
561
|
+
const appRelativeSegments = extractRouteOwnerSegments(filePath, nextAppRoot, serviceRoot);
|
|
562
|
+
if (!appRelativeSegments) return null;
|
|
563
|
+
return normalizeRouteSegments(appRelativeSegments);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function inferApiRoutesFromTestFile(filePath, nextAppRoot, serviceRoot) {
|
|
567
|
+
const normalized = normalizePath(filePath);
|
|
568
|
+
const appRootRelative = normalizePath(path.relative(serviceRoot, nextAppRoot));
|
|
569
|
+
const appPrefix = appRootRelative === "." ? "" : `${appRootRelative}/`;
|
|
570
|
+
const apiMarker = `${appPrefix}api/`;
|
|
571
|
+
const markerIndex = normalized.indexOf(apiMarker);
|
|
572
|
+
if (markerIndex === -1) return [];
|
|
573
|
+
const rest = normalized.slice(markerIndex + apiMarker.length);
|
|
574
|
+
const segments = rest.split("/");
|
|
575
|
+
const testkitIndex = segments.indexOf("__testkit__");
|
|
576
|
+
if (testkitIndex === -1) return [];
|
|
577
|
+
return [normalizeRouteSegments(segments.slice(0, testkitIndex))];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function extractRouteOwnerSegments(filePath, nextAppRoot, serviceRoot) {
|
|
581
|
+
const normalized = normalizePath(filePath);
|
|
582
|
+
const appRootRelative = normalizePath(path.relative(serviceRoot, nextAppRoot));
|
|
583
|
+
const appPrefix = appRootRelative === "." ? "" : `${appRootRelative}/`;
|
|
584
|
+
if (!normalized.startsWith(appPrefix)) return null;
|
|
585
|
+
const rest = normalized.slice(appPrefix.length);
|
|
586
|
+
const segments = rest.split("/");
|
|
587
|
+
const testkitIndex = segments.indexOf("__testkit__");
|
|
588
|
+
if (testkitIndex === -1) return null;
|
|
589
|
+
return segments.slice(0, testkitIndex);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function routeFromAppFile(nextAppRoot, filePath) {
|
|
593
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(nextAppRoot, filePath);
|
|
594
|
+
const relative = normalizePath(path.relative(nextAppRoot, absolutePath));
|
|
595
|
+
const segments = relative.split("/");
|
|
596
|
+
segments.pop();
|
|
597
|
+
return normalizeRouteSegments(segments);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function routeFromApiFile(nextAppRoot, filePath) {
|
|
601
|
+
const appRelative = normalizePath(path.relative(nextAppRoot, path.dirname(filePath)));
|
|
602
|
+
const segments = appRelative.split("/").filter(Boolean);
|
|
603
|
+
if (segments[0] === "api") segments.shift();
|
|
604
|
+
return normalizeRouteSegments(segments);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function normalizeRouteSegments(segments) {
|
|
608
|
+
const normalizedSegments = segments
|
|
609
|
+
.filter(Boolean)
|
|
610
|
+
.filter((segment) => !segment.startsWith("(") || !segment.endsWith(")"))
|
|
611
|
+
.filter((segment) => !segment.startsWith("@"))
|
|
612
|
+
.filter((segment) => segment !== "page" && segment !== "route");
|
|
613
|
+
return normalizeRoute(`/${normalizedSegments.join("/")}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function normalizeRoute(value) {
|
|
617
|
+
const trimmed = String(value || "/").trim();
|
|
618
|
+
if (!trimmed || trimmed === "/") return "/";
|
|
619
|
+
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
620
|
+
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, "") : withLeadingSlash;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function findNextAppRoot(serviceRoot) {
|
|
624
|
+
const candidates = [path.join(serviceRoot, "app"), path.join(serviceRoot, "src", "app")];
|
|
625
|
+
return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) || null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function resolveServiceRoot(productDir, config) {
|
|
629
|
+
const cwd = config?.local?.cwd || ".";
|
|
630
|
+
return path.resolve(productDir, cwd);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function walkFiles(rootDir) {
|
|
634
|
+
if (!fs.existsSync(rootDir)) return [];
|
|
635
|
+
const results = [];
|
|
636
|
+
const queue = [rootDir];
|
|
637
|
+
while (queue.length > 0) {
|
|
638
|
+
const current = queue.pop();
|
|
639
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
640
|
+
if (entry.isSymbolicLink()) continue;
|
|
641
|
+
const absolutePath = path.join(current, entry.name);
|
|
642
|
+
if (entry.isDirectory()) {
|
|
643
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
644
|
+
queue.push(absolutePath);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (entry.isFile()) {
|
|
648
|
+
results.push(absolutePath);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return results.sort((left, right) => left.localeCompare(right));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function resolveImportToSourceFile(serviceRoot, fromFilePath, specifier) {
|
|
656
|
+
const candidates = [];
|
|
657
|
+
const sourceRoot = fs.existsSync(path.join(serviceRoot, "src")) ? path.join(serviceRoot, "src") : serviceRoot;
|
|
658
|
+
if (specifier.startsWith("@/")) {
|
|
659
|
+
candidates.push(path.join(sourceRoot, specifier.slice(2)));
|
|
660
|
+
} else if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
661
|
+
candidates.push(path.join(path.dirname(path.join(serviceRoot, fromFilePath)), specifier));
|
|
662
|
+
} else {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
for (const candidate of candidates) {
|
|
667
|
+
const resolved = resolveSourceCandidate(candidate);
|
|
668
|
+
if (resolved) return normalizePath(path.relative(serviceRoot, resolved));
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function resolveSourceCandidate(basePath) {
|
|
674
|
+
const direct = [basePath, `${basePath}.ts`, `${basePath}.tsx`, `${basePath}.js`, `${basePath}.mjs`];
|
|
675
|
+
for (const candidate of direct) {
|
|
676
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
677
|
+
}
|
|
678
|
+
const indexed = [
|
|
679
|
+
path.join(basePath, "index.ts"),
|
|
680
|
+
path.join(basePath, "index.tsx"),
|
|
681
|
+
path.join(basePath, "index.js"),
|
|
682
|
+
path.join(basePath, "index.mjs"),
|
|
683
|
+
];
|
|
684
|
+
for (const candidate of indexed) {
|
|
685
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function isServerActionFile(content) {
|
|
691
|
+
const trimmed = content.trimStart();
|
|
692
|
+
return trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'");
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function isBackendSpecifier(specifier) {
|
|
696
|
+
return (
|
|
697
|
+
specifier.startsWith("@/backend/server/") ||
|
|
698
|
+
specifier.includes("/backend/server/") ||
|
|
699
|
+
specifier.startsWith("./backend/server/") ||
|
|
700
|
+
specifier.startsWith("../backend/server/")
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function hasWord(content, word) {
|
|
705
|
+
if (!content || !word) return false;
|
|
706
|
+
return new RegExp(`\\b${escapeRegExp(word)}\\b`, "u").test(content);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function pageLabelFromRoute(route) {
|
|
710
|
+
if (route === "/") return "Home";
|
|
711
|
+
return route
|
|
712
|
+
.split("/")
|
|
713
|
+
.filter(Boolean)
|
|
714
|
+
.map((segment) => {
|
|
715
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
716
|
+
return segment.slice(1, -1);
|
|
717
|
+
}
|
|
718
|
+
return segment;
|
|
719
|
+
})
|
|
720
|
+
.map((segment) => segment.replace(/[-_]+/gu, " "))
|
|
721
|
+
.map((segment) => segment.replace(/^\w/u, (char) => char.toUpperCase()))
|
|
722
|
+
.join(" ");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function apiRouteLookupKey(method, route) {
|
|
726
|
+
return `${method}:${route}`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function toApiRequestPath(route) {
|
|
730
|
+
return normalizeRoute(`/api${route === "/" ? "" : route}`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function toSelectionType(type, framework) {
|
|
734
|
+
if (framework === "playwright") return "pw";
|
|
735
|
+
if (type === "integration") return "int";
|
|
736
|
+
return type;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function normalizePath(filePath) {
|
|
740
|
+
return filePath.split(path.sep).join("/");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function escapeRegExp(value) {
|
|
744
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function dedupeRequests(requests) {
|
|
748
|
+
const seen = new Set();
|
|
749
|
+
return requests.filter((entry) => {
|
|
750
|
+
const key = `${entry.method}:${entry.path}`;
|
|
751
|
+
if (seen.has(key)) return false;
|
|
752
|
+
seen.add(key);
|
|
753
|
+
return true;
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function dedupeNodes(nodes) {
|
|
758
|
+
const seen = new Set();
|
|
759
|
+
return nodes.filter((node) => {
|
|
760
|
+
if (seen.has(node.id)) return false;
|
|
761
|
+
seen.add(node.id);
|
|
762
|
+
return true;
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function dedupeBackendImports(entries) {
|
|
767
|
+
const seen = new Set();
|
|
768
|
+
return entries.filter((entry) => {
|
|
769
|
+
const key = `${entry.importName}:${entry.node.filePath || ""}:${entry.node.label}`;
|
|
770
|
+
if (seen.has(key)) return false;
|
|
771
|
+
seen.add(key);
|
|
772
|
+
return true;
|
|
773
|
+
});
|
|
774
|
+
}
|