@elench/testkit 0.1.64 → 0.1.65

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.
Files changed (74) hide show
  1. package/lib/coverage/evidence.mjs +15 -3
  2. package/lib/coverage/evidence.test.mjs +13 -4
  3. package/lib/coverage/graph-builder.mjs +23 -24
  4. package/lib/coverage/next-ir-to-graph.mjs +240 -0
  5. package/node_modules/@elench/next-analysis/package.json +14 -0
  6. package/node_modules/@elench/next-analysis/src/api-routes.mjs +81 -0
  7. package/node_modules/@elench/next-analysis/src/api-routes.test.mjs +22 -0
  8. package/node_modules/@elench/next-analysis/src/app-root.mjs +7 -0
  9. package/node_modules/@elench/next-analysis/src/backend-links.mjs +31 -0
  10. package/node_modules/@elench/next-analysis/src/index.mjs +21 -0
  11. package/node_modules/@elench/next-analysis/src/pages.mjs +68 -0
  12. package/node_modules/@elench/next-analysis/src/project.mjs +94 -0
  13. package/node_modules/@elench/next-analysis/src/project.test.mjs +35 -0
  14. package/node_modules/@elench/next-analysis/src/route-tree.mjs +621 -0
  15. package/node_modules/@elench/next-analysis/src/routes.mjs +41 -0
  16. package/node_modules/@elench/next-analysis/src/routes.test.mjs +25 -0
  17. package/node_modules/@elench/next-analysis/src/server-actions.mjs +53 -0
  18. package/node_modules/@elench/next-analysis/src/server-actions.test.mjs +37 -0
  19. package/node_modules/@elench/next-analysis/src/shared.mjs +209 -0
  20. package/node_modules/@elench/next-analysis/src/swc.mjs +388 -0
  21. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  22. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  23. package/node_modules/@elench/ts-analysis/package.json +1 -1
  24. package/node_modules/@next/routing/README.md +91 -0
  25. package/node_modules/@next/routing/dist/__tests__/captures.test.d.ts +1 -0
  26. package/node_modules/@next/routing/dist/__tests__/conditions.test.d.ts +1 -0
  27. package/node_modules/@next/routing/dist/__tests__/dynamic-after-rewrites.test.d.ts +1 -0
  28. package/node_modules/@next/routing/dist/__tests__/i18n-resolve-routes.test.d.ts +1 -0
  29. package/node_modules/@next/routing/dist/__tests__/i18n.test.d.ts +1 -0
  30. package/node_modules/@next/routing/dist/__tests__/middleware.test.d.ts +1 -0
  31. package/node_modules/@next/routing/dist/__tests__/normalize-next-data.test.d.ts +1 -0
  32. package/node_modules/@next/routing/dist/__tests__/redirects.test.d.ts +1 -0
  33. package/node_modules/@next/routing/dist/__tests__/resolve-routes.test.d.ts +1 -0
  34. package/node_modules/@next/routing/dist/__tests__/rewrites.test.d.ts +1 -0
  35. package/node_modules/@next/routing/dist/destination.d.ts +22 -0
  36. package/node_modules/@next/routing/dist/i18n.d.ts +48 -0
  37. package/node_modules/@next/routing/dist/index.d.ts +5 -0
  38. package/node_modules/@next/routing/dist/index.js +1 -0
  39. package/node_modules/@next/routing/dist/matchers.d.ts +12 -0
  40. package/node_modules/@next/routing/dist/middleware.d.ts +12 -0
  41. package/node_modules/@next/routing/dist/next-data.d.ts +10 -0
  42. package/node_modules/@next/routing/dist/resolve-routes.d.ts +2 -0
  43. package/node_modules/@next/routing/dist/types.d.ts +97 -0
  44. package/node_modules/@next/routing/package.json +39 -0
  45. package/node_modules/@swc/core/README.md +100 -0
  46. package/node_modules/@swc/core/Visitor.d.ts +218 -0
  47. package/node_modules/@swc/core/Visitor.js +1399 -0
  48. package/node_modules/@swc/core/binding.d.ts +59 -0
  49. package/node_modules/@swc/core/binding.js +368 -0
  50. package/node_modules/@swc/core/index.d.ts +120 -0
  51. package/node_modules/@swc/core/index.js +443 -0
  52. package/node_modules/@swc/core/package.json +120 -0
  53. package/node_modules/@swc/core/postinstall.js +148 -0
  54. package/node_modules/@swc/core/spack.d.ts +51 -0
  55. package/node_modules/@swc/core/spack.js +87 -0
  56. package/node_modules/@swc/core/util.d.ts +1 -0
  57. package/node_modules/@swc/core/util.js +104 -0
  58. package/node_modules/@swc/core-linux-x64-gnu/README.md +3 -0
  59. package/node_modules/@swc/core-linux-x64-gnu/package.json +46 -0
  60. package/node_modules/@swc/core-linux-x64-gnu/swc.linux-x64-gnu.node +0 -0
  61. package/node_modules/@swc/counter/CHANGELOG.md +7 -0
  62. package/node_modules/@swc/counter/README.md +7 -0
  63. package/node_modules/@swc/counter/index.js +1 -0
  64. package/node_modules/@swc/counter/package.json +27 -0
  65. package/node_modules/@swc/types/LICENSE +201 -0
  66. package/node_modules/@swc/types/README.md +4 -0
  67. package/node_modules/@swc/types/assumptions.d.ts +92 -0
  68. package/node_modules/@swc/types/assumptions.js +2 -0
  69. package/node_modules/@swc/types/index.d.ts +2049 -0
  70. package/node_modules/@swc/types/index.js +2 -0
  71. package/node_modules/@swc/types/package.json +40 -0
  72. package/package.json +6 -4
  73. package/lib/coverage/next-discovery.mjs +0 -205
  74. package/lib/coverage/next-static-analysis.mjs +0 -1045
@@ -1,1045 +0,0 @@
1
- import path from "path";
2
- import ts from "typescript";
3
- import {
4
- collectExportedCallables as collectExportedCallablesFromTsAnalysis,
5
- collectImports as collectImportsFromTsAnalysis,
6
- collectJsxAttributes as collectJsxAttributesFromTsAnalysis,
7
- collectScopeLocalFunctions as collectScopeLocalFunctionsFromTsAnalysis,
8
- collectTopLevelFunctions as collectTopLevelFunctionsFromTsAnalysis,
9
- createSourceFile as createSourceFileFromTsAnalysis,
10
- extractHttpRequests as extractHttpRequestsFromTsAnalysis,
11
- extractJsxLabel as extractJsxLabelFromTsAnalysis,
12
- extractLineNumber as extractLineNumberFromTsAnalysis,
13
- extractPlaywrightVisitedRoutes as extractPlaywrightVisitedRoutesFromTsAnalysis,
14
- extractStringLiteral as extractStringLiteralFromTsAnalysis,
15
- findDefaultExportCallable as findDefaultExportCallableFromTsAnalysis,
16
- } from "@elench/ts-analysis";
17
- import { DYNAMIC_SEGMENT_TOKEN } from "./shared.mjs";
18
-
19
- const HTTP_WRAPPER_METHODS = {
20
- getJson: "GET",
21
- postJson: "POST",
22
- putJson: "PUT",
23
- patchJson: "PATCH",
24
- deleteJson: "DELETE",
25
- };
26
-
27
- const SURFACE_COMPONENT_NAMES = new Set(["button", "Button", "form", "Form", "a", "Link", "input"]);
28
- const SURFACE_ACTION_PROPS = new Set(["onClick", "action", "onSubmit"]);
29
-
30
- export function analyzeNextPageFile({
31
- serviceName,
32
- serviceRoot,
33
- filePath,
34
- route,
35
- content,
36
- readSourceFile,
37
- resolveImportPath,
38
- isServerActionFile,
39
- normalizeRoute,
40
- }) {
41
- return analyzeNextRouteTree({
42
- serviceName,
43
- serviceRoot,
44
- route,
45
- rootFiles: [{ filePath, content }],
46
- readSourceFile,
47
- resolveImportPath: (fromFilePath, specifier) =>
48
- fromFilePath === filePath ? resolveImportPath(specifier) : resolveImportToSourceFile(serviceRoot, fromFilePath, specifier),
49
- isServerActionFile,
50
- normalizeRoute,
51
- });
52
- }
53
-
54
- export function analyzeNextRouteTree({
55
- serviceName,
56
- serviceRoot,
57
- route,
58
- rootFiles = [],
59
- readSourceFile,
60
- resolveImportPath,
61
- isServerActionFile,
62
- normalizeRoute,
63
- }) {
64
- const directRequests = [];
65
- const directServerActionRefs = [];
66
- const nodes = [];
67
- const edges = [];
68
- const surfacesByTargetValue = new Map();
69
- const actionNodeIds = new Set();
70
- const requestNodeIds = new Set();
71
- const pageNodeId = `page_view:${serviceName}:${route}`;
72
- const moduleCache = new Map();
73
- const visitedExports = new Set();
74
-
75
- const analyzerContext = {
76
- serviceName,
77
- serviceRoot,
78
- route,
79
- pageNodeId,
80
- readSourceFile,
81
- resolveImportPath,
82
- isServerActionFile,
83
- normalizeRoute,
84
- moduleCache,
85
- visitedExports,
86
- state: {
87
- nodes,
88
- edges,
89
- directRequests,
90
- directServerActionRefs,
91
- surfacesByTargetValue,
92
- actionNodeIds,
93
- requestNodeIds,
94
- },
95
- };
96
-
97
- for (const rootFile of rootFiles) {
98
- const rootContent =
99
- typeof rootFile.content === "string"
100
- ? rootFile.content
101
- : readSourceFile(rootFile.filePath);
102
- if (!rootContent) continue;
103
- const moduleInfo = loadModuleInfo(rootFile.filePath, rootContent, analyzerContext);
104
- analyzeModuleExport(moduleInfo, "default", analyzerContext);
105
- }
106
-
107
- return {
108
- nodes: dedupeById(nodes),
109
- edges: dedupeById(edges),
110
- requests: directRequests,
111
- serverActionRefs: dedupeServerActionRefs(directServerActionRefs),
112
- surfacesByTargetValue,
113
- };
114
- }
115
-
116
- export function extractHttpSuiteRequests(content, filePath = "suite.int.testkit.ts") {
117
- return extractHttpRequestsFromTsAnalysis(content, {
118
- filePath,
119
- normalizeRoute: defaultNormalizeRoute,
120
- dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN,
121
- });
122
- }
123
-
124
- export function extractPlaywrightVisitedRoutes(content, filePath = "suite.pw.testkit.ts") {
125
- return extractPlaywrightVisitedRoutesFromTsAnalysis(content, {
126
- filePath,
127
- normalizeRoute: defaultNormalizeRoute,
128
- dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN,
129
- });
130
- }
131
-
132
- function analyzeModuleExport(moduleInfo, contextExportName, analyzerContext) {
133
- if (!moduleInfo) return;
134
- const visitKey = `${moduleInfo.filePath}#${contextExportName}`;
135
- if (analyzerContext.visitedExports.has(visitKey)) return;
136
- analyzerContext.visitedExports.add(visitKey);
137
-
138
- const callable = resolveModuleExportCallable(moduleInfo, contextExportName);
139
- if (!callable) return;
140
-
141
- const localFunctions = new Map(moduleInfo.topLevelFunctions);
142
- for (const [name, node] of collectScopeLocalFunctionsFromTsAnalysis(callable)) {
143
- localFunctions.set(name, node);
144
- }
145
-
146
- const actionAnalysis = analyzeCallableLikeNode(
147
- callable,
148
- {
149
- imports: moduleInfo.imports,
150
- localFunctions,
151
- normalizeRoute: analyzerContext.normalizeRoute,
152
- moduleLoader: (filePath) => loadModuleInfoFromFile(filePath, analyzerContext),
153
- resolveImportedExport: resolveImportedExportCallable,
154
- analyzeImportedCallable,
155
- },
156
- new Set(),
157
- { skipNestedFunctions: true }
158
- );
159
-
160
- for (const request of actionAnalysis.requests) {
161
- const node = createClientRequestNode(
162
- analyzerContext.serviceName,
163
- moduleInfo.filePath,
164
- request,
165
- `page:${analyzerContext.route}:${moduleInfo.filePath}`
166
- );
167
- if (!analyzerContext.state.requestNodeIds.has(node.id)) {
168
- analyzerContext.state.requestNodeIds.add(node.id);
169
- analyzerContext.state.nodes.push(node);
170
- }
171
- analyzerContext.state.directRequests.push({
172
- originNodeId: analyzerContext.pageNodeId,
173
- node,
174
- method: request.method,
175
- path: request.path,
176
- });
177
- analyzerContext.state.edges.push({
178
- id: `requests:${analyzerContext.pageNodeId}:${node.id}`,
179
- kind: "requests",
180
- from: analyzerContext.pageNodeId,
181
- to: node.id,
182
- confidence: request.confidence,
183
- });
184
- }
185
-
186
- for (const serverActionRef of actionAnalysis.serverActionRefs) {
187
- analyzerContext.state.directServerActionRefs.push({
188
- originNodeId: analyzerContext.pageNodeId,
189
- ...serverActionRef,
190
- });
191
- }
192
-
193
- walkJsx(callable, (element, formContext) => {
194
- const descriptor = describeSurface(element, formContext);
195
- if (descriptor) {
196
- const surfaceKey = descriptor.target?.value || descriptor.actionName || `${moduleInfo.filePath}:${descriptor.tagName}:${descriptor.line}`;
197
- const surfaceId = `ui_surface:${analyzerContext.serviceName}:${analyzerContext.route}:${surfaceKey}`;
198
- const surfaceNode = {
199
- id: surfaceId,
200
- kind: "ui_surface",
201
- service: analyzerContext.serviceName,
202
- label: descriptor.label,
203
- route: analyzerContext.route,
204
- filePath: moduleInfo.filePath,
205
- ...(descriptor.target ? { target: descriptor.target } : {}),
206
- metadata: {
207
- surfaceKind: descriptor.surfaceKind,
208
- tagName: descriptor.tagName,
209
- },
210
- };
211
-
212
- analyzerContext.state.nodes.push(surfaceNode);
213
- analyzerContext.state.edges.push({
214
- id: `contains:${analyzerContext.pageNodeId}:${surfaceId}`,
215
- kind: "contains",
216
- from: analyzerContext.pageNodeId,
217
- to: surfaceId,
218
- confidence: "high",
219
- });
220
-
221
- if (descriptor.target?.kind === "testId") {
222
- analyzerContext.state.surfacesByTargetValue.set(descriptor.target.value, surfaceNode);
223
- }
224
-
225
- if (descriptor.actionBinding) {
226
- const actionId = `ui_action:${analyzerContext.serviceName}:${analyzerContext.route}:${descriptor.actionBinding.key}`;
227
- if (!analyzerContext.state.actionNodeIds.has(actionId)) {
228
- analyzerContext.state.actionNodeIds.add(actionId);
229
- analyzerContext.state.nodes.push({
230
- id: actionId,
231
- kind: "ui_action",
232
- service: analyzerContext.serviceName,
233
- label: descriptor.actionBinding.label,
234
- route: analyzerContext.route,
235
- filePath: moduleInfo.filePath,
236
- metadata: {
237
- bindingKind: descriptor.actionBinding.kind,
238
- actionProp: descriptor.actionBinding.actionProp,
239
- },
240
- });
241
- }
242
-
243
- analyzerContext.state.edges.push({
244
- id: `triggers:${surfaceId}:${actionId}`,
245
- kind: "triggers",
246
- from: surfaceId,
247
- to: actionId,
248
- confidence: descriptor.actionBinding.confidence,
249
- });
250
-
251
- const bindingAnalysis = analyzeActionBinding(descriptor.actionBinding, {
252
- imports: moduleInfo.imports,
253
- localFunctions,
254
- normalizeRoute: analyzerContext.normalizeRoute,
255
- moduleLoader: (filePath) => loadModuleInfoFromFile(filePath, analyzerContext),
256
- resolveImportedExport: resolveImportedExportCallable,
257
- analyzeImportedCallable,
258
- });
259
-
260
- for (const request of bindingAnalysis.requests) {
261
- const node = createClientRequestNode(
262
- analyzerContext.serviceName,
263
- moduleInfo.filePath,
264
- request,
265
- descriptor.actionBinding.key
266
- );
267
- if (!analyzerContext.state.requestNodeIds.has(node.id)) {
268
- analyzerContext.state.requestNodeIds.add(node.id);
269
- analyzerContext.state.nodes.push(node);
270
- }
271
- analyzerContext.state.directRequests.push({
272
- originNodeId: actionId,
273
- node,
274
- method: request.method,
275
- path: request.path,
276
- });
277
- analyzerContext.state.edges.push({
278
- id: `requests:${actionId}:${node.id}`,
279
- kind: "requests",
280
- from: actionId,
281
- to: node.id,
282
- confidence: request.confidence,
283
- });
284
- }
285
-
286
- for (const serverActionRef of bindingAnalysis.serverActionRefs) {
287
- analyzerContext.state.directServerActionRefs.push({
288
- originNodeId: actionId,
289
- ...serverActionRef,
290
- });
291
- }
292
- }
293
-
294
- return descriptor.formContext ?? formContext;
295
- }
296
-
297
- const componentRef = resolveImportedComponentRef(element, moduleInfo.imports);
298
- if (componentRef) {
299
- analyzeImportedComponent(componentRef, analyzerContext);
300
- }
301
- return formContext;
302
- });
303
- }
304
-
305
- function analyzeImportedComponent(componentRef, analyzerContext) {
306
- const moduleInfo = loadModuleInfoFromFile(componentRef.filePath, analyzerContext);
307
- if (!moduleInfo) return;
308
- analyzeModuleExport(moduleInfo, componentRef.exportName, analyzerContext);
309
- }
310
-
311
- function analyzeImportedCallable(imported, options, visited) {
312
- if (!imported?.resolvedFilePath || imported.isServerAction || visited.has(imported.resolvedFilePath)) {
313
- return emptyActionAnalysis();
314
- }
315
- const moduleInfo = options.moduleLoader(imported.resolvedFilePath);
316
- if (!moduleInfo) return emptyActionAnalysis();
317
- const callable = options.resolveImportedExport(moduleInfo, imported.importedName);
318
- if (!callable) return emptyActionAnalysis();
319
-
320
- const localFunctions = new Map(moduleInfo.topLevelFunctions);
321
- for (const [name, node] of collectScopeLocalFunctionsFromTsAnalysis(callable)) {
322
- localFunctions.set(name, node);
323
- }
324
-
325
- const nextVisited = new Set(visited);
326
- nextVisited.add(imported.resolvedFilePath);
327
- return analyzeCallableLikeNode(
328
- callable,
329
- {
330
- imports: moduleInfo.imports,
331
- localFunctions,
332
- normalizeRoute: options.normalizeRoute,
333
- moduleLoader: options.moduleLoader,
334
- resolveImportedExport: options.resolveImportedExport,
335
- analyzeImportedCallable,
336
- },
337
- nextVisited
338
- );
339
- }
340
-
341
- function loadModuleInfoFromFile(filePath, analyzerContext) {
342
- const cached = analyzerContext.moduleCache.get(filePath);
343
- if (cached) return cached;
344
- const content = analyzerContext.readSourceFile(filePath);
345
- if (!content) return null;
346
- return loadModuleInfo(filePath, content, analyzerContext);
347
- }
348
-
349
- function loadModuleInfo(filePath, content, analyzerContext) {
350
- const cached = analyzerContext.moduleCache.get(filePath);
351
- if (cached) return cached;
352
-
353
- const sourceFile = createSourceFileFromTsAnalysis(filePath, content);
354
- const imports = collectImportsFromTsAnalysis(sourceFile, {
355
- readSourceFile: analyzerContext.readSourceFile,
356
- resolveImportPath: (specifier) => analyzerContext.resolveImportPath(filePath, specifier),
357
- isServerActionFile: analyzerContext.isServerActionFile,
358
- });
359
- const topLevelFunctions = collectTopLevelFunctionsFromTsAnalysis(sourceFile);
360
- const exports = collectExportedCallablesFromTsAnalysis(sourceFile, topLevelFunctions);
361
- const moduleInfo = {
362
- filePath,
363
- sourceFile,
364
- imports,
365
- topLevelFunctions,
366
- exports,
367
- defaultExport: findDefaultExportCallableFromTsAnalysis(sourceFile, topLevelFunctions),
368
- };
369
- analyzerContext.moduleCache.set(filePath, moduleInfo);
370
- return moduleInfo;
371
- }
372
-
373
- function resolveModuleExportCallable(moduleInfo, exportName) {
374
- if (!moduleInfo) return null;
375
- if (exportName === "default") return moduleInfo.defaultExport || null;
376
- return moduleInfo.exports.get(exportName) || null;
377
- }
378
-
379
- function resolveImportedExportCallable(moduleInfo, importedName) {
380
- if (!moduleInfo) return null;
381
- if (importedName === "default") return moduleInfo.defaultExport || null;
382
- return moduleInfo.exports.get(importedName) || null;
383
- }
384
-
385
- function resolveImportedComponentRef(node, imports) {
386
- const opening = ts.isJsxElement(node) ? node.openingElement : node;
387
- const tagName = opening.tagName.getText();
388
- if (!/^[A-Z]/u.test(tagName)) return null;
389
- const imported = imports.get(tagName);
390
- if (!imported?.resolvedFilePath || imported.isServerAction) return null;
391
- if (imported.resolvedFilePath.includes("/components/ui/")) return null;
392
- return {
393
- filePath: imported.resolvedFilePath,
394
- exportName: imported.importedName,
395
- };
396
- }
397
-
398
- function resolveGotoRoute(callExpression) {
399
- const callee = callExpression.expression;
400
- if (!ts.isPropertyAccessExpression(callee)) return null;
401
- if (callee.name.text !== "goto") return null;
402
- const pathArg = callExpression.arguments[0];
403
- const literal = extractStringLiteral(pathArg);
404
- if (!literal) return null;
405
- if (/^https?:\/\//u.test(literal)) {
406
- try {
407
- const parsed = new URL(literal);
408
- if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
409
- return defaultNormalizeRoute(parsed.pathname.split("?")[0]);
410
- }
411
- } catch { /* ignore malformed */ }
412
- return null;
413
- }
414
- return defaultNormalizeRoute(literal.split("?")[0]);
415
- }
416
-
417
- function walkJsx(sourceFile, visitor) {
418
- const visit = (node, formContext = null) => {
419
- let nextFormContext = formContext;
420
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
421
- nextFormContext = visitor(node, formContext);
422
- }
423
-
424
- if (ts.isJsxElement(node)) {
425
- for (const child of node.children) {
426
- visit(child, nextFormContext);
427
- }
428
- return;
429
- }
430
-
431
- ts.forEachChild(node, (child) => visit(child, nextFormContext));
432
- };
433
-
434
- visit(sourceFile, null);
435
- }
436
-
437
- function describeSurface(node, inheritedFormContext) {
438
- const opening = ts.isJsxElement(node) ? node.openingElement : node;
439
- const tagName = opening.tagName.getText();
440
- if (!SURFACE_COMPONENT_NAMES.has(tagName)) return null;
441
-
442
- const attributes = collectJsxAttributesFromTsAnalysis(opening);
443
- const targetValue = attributes["data-testid"]?.stringValue || null;
444
- const role = tagName === "form" || tagName === "Form" ? "form" : tagName === "a" || tagName === "Link" ? "link" : "button";
445
- const surfaceKind =
446
- role === "form" ? "form" : role === "link" ? "link" : attributes.type?.stringValue === "submit" ? "submit" : "control";
447
-
448
- const ownBinding =
449
- buildActionBinding(attributes.onClick?.expression, "onClick") ||
450
- buildActionBinding(attributes.action?.expression, "action") ||
451
- buildActionBinding(attributes.onSubmit?.expression, "onSubmit");
452
-
453
- const inheritedSubmitBinding =
454
- !ownBinding && isSubmitSurface(tagName, attributes) && inheritedFormContext?.actionBinding
455
- ? inheritedFormContext.actionBinding
456
- : null;
457
-
458
- const actionBinding = ownBinding || inheritedSubmitBinding;
459
- if (!actionBinding && !targetValue) {
460
- return tagName === "form" || tagName === "Form"
461
- ? { formContext: buildFormContext(attributes) }
462
- : null;
463
- }
464
-
465
- const label =
466
- extractJsxLabelFromTsAnalysis(node, attributes) ||
467
- actionBinding?.label ||
468
- targetValue ||
469
- humanizeTagName(tagName);
470
- const line = extractLineNumberFromTsAnalysis(node);
471
- const target = targetValue
472
- ? {
473
- kind: "testId",
474
- value: targetValue,
475
- label,
476
- confidence: "high",
477
- }
478
- : null;
479
-
480
- return {
481
- tagName,
482
- surfaceKind,
483
- label,
484
- line,
485
- target,
486
- actionName: actionBinding?.key || null,
487
- actionBinding,
488
- formContext: buildFormContext(attributes, actionBinding),
489
- };
490
- }
491
-
492
- function buildFormContext(attributes, explicitBinding = null) {
493
- const actionBinding =
494
- explicitBinding ||
495
- buildActionBinding(attributes.action?.expression, "action") ||
496
- buildActionBinding(attributes.onSubmit?.expression, "onSubmit");
497
- return actionBinding ? { actionBinding } : null;
498
- }
499
-
500
- function buildActionBinding(expression, actionProp) {
501
- if (!expression) return null;
502
- if (ts.isIdentifier(expression)) {
503
- return {
504
- kind: "identifier",
505
- actionProp,
506
- key: expression.text,
507
- label: expression.text,
508
- node: expression,
509
- confidence: actionProp === "action" ? "high" : "medium",
510
- };
511
- }
512
-
513
- if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
514
- const key = `inline:${expression.pos}`;
515
- return {
516
- kind: "inline",
517
- actionProp,
518
- key,
519
- label: actionProp === "action" ? "Inline action" : "Inline handler",
520
- node: expression,
521
- confidence: "medium",
522
- };
523
- }
524
-
525
- return null;
526
- }
527
-
528
- function analyzeActionBinding(binding, options) {
529
- if (!binding) return emptyActionAnalysis();
530
- if (binding.kind === "identifier" && ts.isIdentifier(binding.node)) {
531
- const identifier = binding.node.text;
532
- const imported = options.imports.get(identifier);
533
- if (imported?.isServerAction) {
534
- return {
535
- requests: [],
536
- serverActionRefs: [
537
- {
538
- exportKey: `${imported.resolvedFilePath}#${imported.importedName}`,
539
- confidence: "high",
540
- },
541
- ],
542
- };
543
- }
544
-
545
- if (imported?.resolvedFilePath) {
546
- return options.analyzeImportedCallable(imported, options, new Set([imported.resolvedFilePath]));
547
- }
548
-
549
- const localFunction = options.localFunctions.get(identifier);
550
- if (!localFunction) return emptyActionAnalysis();
551
- return analyzeCallableLikeNode(localFunction, options, new Set([identifier]));
552
- }
553
-
554
- if (binding.kind === "inline") {
555
- return analyzeCallableLikeNode(binding.node, options, new Set());
556
- }
557
-
558
- return emptyActionAnalysis();
559
- }
560
-
561
- function analyzeCallableLikeNode(node, options, visited = new Set(), settings = {}) {
562
- const requests = [];
563
- const serverActionRefs = [];
564
-
565
- const visit = (child) => {
566
- if (
567
- settings.skipNestedFunctions &&
568
- child !== node &&
569
- (ts.isArrowFunction(child) || ts.isFunctionExpression(child) || ts.isFunctionDeclaration(child))
570
- ) {
571
- return;
572
- }
573
- if (ts.isCallExpression(child)) {
574
- const request = resolveHttpRequestCall(child, options);
575
- if (request) {
576
- requests.push(request);
577
- } else {
578
- const serverAction = resolveServerActionCall(child, options);
579
- if (serverAction) {
580
- serverActionRefs.push(serverAction);
581
- } else {
582
- const localCall = resolveLocalFunctionCall(child, options, visited);
583
- if (localCall) {
584
- for (const requestEntry of localCall.requests) requests.push(requestEntry);
585
- for (const serverActionRef of localCall.serverActionRefs) serverActionRefs.push(serverActionRef);
586
- } else {
587
- const importedCall = resolveImportedLocalCall(child, options, visited);
588
- if (importedCall) {
589
- for (const requestEntry of importedCall.requests) requests.push(requestEntry);
590
- for (const serverActionRef of importedCall.serverActionRefs) serverActionRefs.push(serverActionRef);
591
- } else {
592
- for (const callbackAnalysis of resolveExecutedCallbackAnalyses(child, options, visited)) {
593
- for (const requestEntry of callbackAnalysis.requests) requests.push(requestEntry);
594
- for (const serverActionRef of callbackAnalysis.serverActionRefs) serverActionRefs.push(serverActionRef);
595
- }
596
- }
597
- }
598
- }
599
- }
600
- }
601
- ts.forEachChild(child, visit);
602
- };
603
-
604
- if (ts.isSourceFile(node)) {
605
- ts.forEachChild(node, visit);
606
- } else if ("body" in node && node.body) {
607
- visit(node.body);
608
- } else if (ts.isBlock(node) || ts.isExpression(node)) {
609
- visit(node);
610
- }
611
-
612
- return {
613
- requests: dedupeRequests(requests),
614
- serverActionRefs: dedupeServerActionRefs(serverActionRefs),
615
- };
616
- }
617
-
618
- function resolveLocalFunctionCall(callExpression, options, visited) {
619
- const callee = callExpression.expression;
620
- if (!ts.isIdentifier(callee)) return null;
621
- const functionName = callee.text;
622
- const localFunction = options.localFunctions.get(functionName);
623
- if (!localFunction || visited.has(functionName)) return null;
624
- const nextVisited = new Set(visited);
625
- nextVisited.add(functionName);
626
- return analyzeCallableLikeNode(localFunction, options, nextVisited);
627
- }
628
-
629
- function resolveImportedLocalCall(callExpression, options, visited) {
630
- const callee = callExpression.expression;
631
- if (!ts.isIdentifier(callee)) return null;
632
- const imported = options.imports.get(callee.text);
633
- if (!imported?.resolvedFilePath || imported.isServerAction) return null;
634
- return options.analyzeImportedCallable(imported, options, visited);
635
- }
636
-
637
- function resolveExecutedCallbackAnalyses(callExpression, options, visited) {
638
- const callbacks = [];
639
- const callee = callExpression.expression;
640
- if (ts.isIdentifier(callee) && (callee.text === "useEffect" || callee.text === "useLayoutEffect")) {
641
- const callbackArg = callExpression.arguments[0];
642
- if (callbackArg && (ts.isArrowFunction(callbackArg) || ts.isFunctionExpression(callbackArg))) {
643
- callbacks.push(analyzeCallableLikeNode(callbackArg, options, new Set(visited)));
644
- }
645
- }
646
- return callbacks;
647
- }
648
-
649
- function resolveServerActionCall(callExpression, options) {
650
- const callee = callExpression.expression;
651
- if (!ts.isIdentifier(callee)) return null;
652
- const imported = options.imports.get(callee.text);
653
- if (!imported?.isServerAction) return null;
654
- return {
655
- exportKey: `${imported.resolvedFilePath}#${imported.importedName}`,
656
- confidence: "high",
657
- };
658
- }
659
-
660
- function resolveHttpRequestCall(callExpression, options) {
661
- const callee = callExpression.expression;
662
- if (ts.isIdentifier(callee)) {
663
- if (callee.text === "fetch") {
664
- const path = extractRouteLiteral(callExpression.arguments[0], options.normalizeRoute);
665
- if (!path || !path.startsWith("/api/")) return null;
666
- return {
667
- method: extractFetchMethod(callExpression.arguments[1]) || "GET",
668
- path,
669
- confidence: "high",
670
- };
671
- }
672
-
673
- const method = HTTP_WRAPPER_METHODS[callee.text];
674
- if (method) {
675
- const path = extractRouteLiteral(callExpression.arguments[0], options.normalizeRoute);
676
- if (!path || !path.startsWith("/api/")) return null;
677
- return {
678
- method,
679
- path,
680
- confidence: "high",
681
- };
682
- }
683
-
684
- if (callee.text === "rawReq") {
685
- const methodLiteral = extractStringLiteral(callExpression.arguments[0]);
686
- const path = extractRouteLiteral(callExpression.arguments[1], options.normalizeRoute);
687
- if (!methodLiteral || !path) return null;
688
- return {
689
- method: methodLiteral.toUpperCase(),
690
- path,
691
- confidence: "high",
692
- };
693
- }
694
- }
695
-
696
- if (ts.isPropertyAccessExpression(callee) && callee.name.text === "rawReq") {
697
- const methodLiteral = extractStringLiteral(callExpression.arguments[0]);
698
- const path = extractRouteLiteral(callExpression.arguments[1], options.normalizeRoute);
699
- if (!methodLiteral || !path) return null;
700
- return {
701
- method: methodLiteral.toUpperCase(),
702
- path,
703
- confidence: "high",
704
- };
705
- }
706
-
707
- return null;
708
- }
709
-
710
- function collectImports(sourceFile, options) {
711
- const imports = new Map();
712
- for (const statement of sourceFile.statements) {
713
- if (!ts.isImportDeclaration(statement) || !statement.importClause) continue;
714
- const specifier = extractStringLiteral(statement.moduleSpecifier);
715
- if (!specifier) continue;
716
- const resolvedFilePath = options.resolveImportPath(specifier);
717
- const sourceContent = resolvedFilePath ? options.readSourceFile(resolvedFilePath) : null;
718
- const serverActionImport = Boolean(sourceContent && options.isServerActionFile(sourceContent));
719
-
720
- if (statement.importClause.name) {
721
- imports.set(statement.importClause.name.text, {
722
- importedName: "default",
723
- specifier,
724
- resolvedFilePath,
725
- isServerAction: serverActionImport,
726
- });
727
- }
728
-
729
- const bindings = statement.importClause.namedBindings;
730
- if (!bindings || !ts.isNamedImports(bindings)) continue;
731
- for (const element of bindings.elements) {
732
- const localName = element.name.text;
733
- const importedName = element.propertyName?.text || localName;
734
- imports.set(localName, {
735
- importedName,
736
- specifier,
737
- resolvedFilePath,
738
- isServerAction: serverActionImport,
739
- });
740
- }
741
- }
742
- return imports;
743
- }
744
-
745
- function collectTopLevelFunctions(sourceFile) {
746
- const functions = new Map();
747
- for (const statement of sourceFile.statements) {
748
- if (ts.isFunctionDeclaration(statement) && statement.name) {
749
- functions.set(statement.name.text, statement);
750
- continue;
751
- }
752
-
753
- if (!ts.isVariableStatement(statement)) continue;
754
- for (const declaration of statement.declarationList.declarations) {
755
- if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
756
- if (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) {
757
- functions.set(declaration.name.text, declaration.initializer);
758
- }
759
- }
760
- }
761
- return functions;
762
- }
763
-
764
- function collectExportedCallables(sourceFile, topLevelFunctions) {
765
- const exports = new Map();
766
- for (const statement of sourceFile.statements) {
767
- if (ts.isFunctionDeclaration(statement) && statement.name && hasExportModifier(statement.modifiers)) {
768
- exports.set(statement.name.text, statement);
769
- continue;
770
- }
771
-
772
- if (!ts.isVariableStatement(statement) || !hasExportModifier(statement.modifiers)) continue;
773
- for (const declaration of statement.declarationList.declarations) {
774
- if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
775
- if (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) {
776
- exports.set(declaration.name.text, declaration.initializer);
777
- } else if (ts.isIdentifier(declaration.initializer)) {
778
- const referenced = topLevelFunctions.get(declaration.initializer.text);
779
- if (referenced) exports.set(declaration.name.text, referenced);
780
- }
781
- }
782
- }
783
- return exports;
784
- }
785
-
786
- function collectScopeLocalFunctions(node) {
787
- const functions = new Map();
788
- const callableBody = "body" in node ? node.body : null;
789
- if (!callableBody) return functions;
790
-
791
- const visit = (child) => {
792
- if (
793
- child !== callableBody &&
794
- (ts.isFunctionDeclaration(child) || ts.isArrowFunction(child) || ts.isFunctionExpression(child))
795
- ) {
796
- return;
797
- }
798
-
799
- if (ts.isFunctionDeclaration(child) && child.name) {
800
- functions.set(child.name.text, child);
801
- }
802
-
803
- if (ts.isVariableDeclaration(child) && ts.isIdentifier(child.name) && child.initializer) {
804
- if (ts.isArrowFunction(child.initializer) || ts.isFunctionExpression(child.initializer)) {
805
- functions.set(child.name.text, child.initializer);
806
- }
807
- }
808
-
809
- ts.forEachChild(child, visit);
810
- };
811
-
812
- visit(callableBody);
813
- return functions;
814
- }
815
-
816
- function findDefaultPageComponent(sourceFile, localFunctions) {
817
- for (const statement of sourceFile.statements) {
818
- if (ts.isFunctionDeclaration(statement) && hasDefaultModifier(statement.modifiers)) {
819
- return statement;
820
- }
821
-
822
- if (ts.isExportAssignment(statement)) {
823
- if (ts.isIdentifier(statement.expression)) {
824
- return localFunctions.get(statement.expression.text) || null;
825
- }
826
- if (ts.isArrowFunction(statement.expression) || ts.isFunctionExpression(statement.expression)) {
827
- return statement.expression;
828
- }
829
- }
830
- }
831
- return null;
832
- }
833
-
834
- function hasExportModifier(modifiers) {
835
- return Boolean(modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
836
- }
837
-
838
- function collectJsxAttributes(openingElement) {
839
- const attributes = {};
840
- for (const property of openingElement.attributes.properties) {
841
- if (!ts.isJsxAttribute(property)) continue;
842
- const name = property.name.text;
843
- if (!property.initializer) {
844
- attributes[name] = { stringValue: "true", expression: null };
845
- continue;
846
- }
847
- if (ts.isStringLiteral(property.initializer)) {
848
- attributes[name] = { stringValue: property.initializer.text, expression: null };
849
- continue;
850
- }
851
- if (ts.isJsxExpression(property.initializer)) {
852
- attributes[name] = {
853
- stringValue: extractStringLiteral(property.initializer.expression),
854
- expression: property.initializer.expression || null,
855
- };
856
- }
857
- }
858
- return attributes;
859
- }
860
-
861
- function extractSurfaceLabel(node, attributes) {
862
- const ariaLabel = attributes["aria-label"]?.stringValue || attributes.title?.stringValue || null;
863
- if (ariaLabel) return ariaLabel;
864
-
865
- if (ts.isJsxElement(node)) {
866
- const text = flattenJsxText(node.children).trim();
867
- if (text) return text;
868
- }
869
-
870
- return null;
871
- }
872
-
873
- function flattenJsxText(children) {
874
- let text = "";
875
- for (const child of children) {
876
- if (ts.isJsxText(child)) {
877
- text += child.getText().replace(/\s+/gu, " ");
878
- } else if (ts.isJsxExpression(child) && child.expression && ts.isStringLiteralLike(child.expression)) {
879
- text += child.expression.text;
880
- } else if (ts.isJsxElement(child)) {
881
- text += flattenJsxText(child.children);
882
- }
883
- }
884
- return text;
885
- }
886
-
887
- function extractRouteLiteral(node, normalizeRoute) {
888
- const literal = extractStringLiteralFromTsAnalysis(node, { dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN });
889
- if (!literal) return null;
890
- return normalizeRoute(literal.split("?")[0]);
891
- }
892
-
893
- function extractFetchMethod(node) {
894
- if (!node || !ts.isObjectLiteralExpression(node)) return null;
895
- for (const property of node.properties) {
896
- if (!ts.isPropertyAssignment(property)) continue;
897
- if (property.name.getText() !== "method") continue;
898
- const value = extractStringLiteralFromTsAnalysis(property.initializer, { dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN });
899
- return value ? value.toUpperCase() : null;
900
- }
901
- return null;
902
- }
903
-
904
- function extractStringLiteral(node) {
905
- if (!node) return null;
906
- if (ts.isStringLiteralLike(node)) return node.text;
907
- if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
908
- if (ts.isTemplateExpression(node)) {
909
- let value = node.head.text;
910
- for (const span of node.templateSpans) {
911
- value += DYNAMIC_SEGMENT_TOKEN;
912
- value += span.literal.text;
913
- }
914
- return value;
915
- }
916
- return null;
917
- }
918
-
919
- function createClientRequestNode(serviceName, filePath, request, ownerKey) {
920
- return {
921
- id: `client_request:${serviceName}:${filePath}:${ownerKey}:${request.method}:${request.path}`,
922
- kind: "client_request",
923
- service: serviceName,
924
- label: `${request.method} ${request.path}`,
925
- method: request.method,
926
- path: request.path,
927
- filePath,
928
- };
929
- }
930
-
931
- function createSourceFile(filePath, content) {
932
- const scriptKind = filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
933
- return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKind);
934
- }
935
-
936
- function hasDefaultModifier(modifiers) {
937
- return Boolean(modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword));
938
- }
939
-
940
- function defaultNormalizeRoute(value) {
941
- const trimmed = String(value || "/").trim();
942
- if (!trimmed || trimmed === "/") return "/";
943
- const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
944
- return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, "") : withLeadingSlash;
945
- }
946
-
947
- function isSubmitSurface(tagName, attributes) {
948
- const type = attributes.type?.stringValue || "";
949
- return tagName === "button" || tagName === "Button" || (tagName === "input" && type === "submit");
950
- }
951
-
952
- function humanizeTagName(tagName) {
953
- return tagName.replace(/([a-z])([A-Z])/gu, "$1 $2").replace(/^\w/u, (char) => char.toUpperCase());
954
- }
955
-
956
- function dedupeRequests(entries) {
957
- const seen = new Set();
958
- return entries.filter((entry) => {
959
- const key = `${entry.method}:${entry.path}`;
960
- if (seen.has(key)) return false;
961
- seen.add(key);
962
- return true;
963
- });
964
- }
965
-
966
- function dedupeById(entries) {
967
- const seen = new Set();
968
- return entries.filter((entry) => {
969
- if (seen.has(entry.id)) return false;
970
- seen.add(entry.id);
971
- return true;
972
- });
973
- }
974
-
975
- function dedupeServerActionRefs(entries) {
976
- const seen = new Set();
977
- return entries.filter((entry) => {
978
- const key = `${entry.originNodeId || ""}:${entry.exportKey}`;
979
- if (seen.has(key)) return false;
980
- seen.add(key);
981
- return true;
982
- });
983
- }
984
-
985
- function emptyActionAnalysis() {
986
- return {
987
- requests: [],
988
- serverActionRefs: [],
989
- };
990
- }
991
-
992
- export function resolveImportToSourceFile(serviceRoot, fromFilePath, specifier) {
993
- const candidates = [];
994
- const sourceRoot = pathExists(path.join(serviceRoot, "src")) ? path.join(serviceRoot, "src") : serviceRoot;
995
- if (specifier.startsWith("@/")) {
996
- candidates.push(path.join(sourceRoot, specifier.slice(2)));
997
- } else if (specifier.startsWith("./") || specifier.startsWith("../")) {
998
- candidates.push(path.join(path.dirname(path.join(serviceRoot, fromFilePath)), specifier));
999
- } else {
1000
- return null;
1001
- }
1002
-
1003
- for (const candidate of candidates) {
1004
- const resolved = resolveSourceCandidate(candidate);
1005
- if (resolved) return normalizePath(path.relative(serviceRoot, resolved));
1006
- }
1007
- return null;
1008
- }
1009
-
1010
- function resolveSourceCandidate(basePath) {
1011
- const direct = [basePath, `${basePath}.ts`, `${basePath}.tsx`, `${basePath}.js`, `${basePath}.mjs`];
1012
- for (const candidate of direct) {
1013
- if (fileExists(candidate)) return candidate;
1014
- }
1015
- const indexed = [
1016
- path.join(basePath, "index.ts"),
1017
- path.join(basePath, "index.tsx"),
1018
- path.join(basePath, "index.js"),
1019
- path.join(basePath, "index.mjs"),
1020
- ];
1021
- for (const candidate of indexed) {
1022
- if (fileExists(candidate)) return candidate;
1023
- }
1024
- return null;
1025
- }
1026
-
1027
- function pathExists(value) {
1028
- try {
1029
- return Boolean(ts.sys.fileExists(value) || ts.sys.directoryExists(value));
1030
- } catch {
1031
- return false;
1032
- }
1033
- }
1034
-
1035
- function fileExists(value) {
1036
- try {
1037
- return Boolean(ts.sys.fileExists(value));
1038
- } catch {
1039
- return false;
1040
- }
1041
- }
1042
-
1043
- function normalizePath(filePath) {
1044
- return filePath.split(path.sep).join("/");
1045
- }