@elench/testkit 0.1.63 → 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 (90) hide show
  1. package/lib/coverage/backend-discovery.mjs +6 -30
  2. package/lib/coverage/evidence.mjs +15 -3
  3. package/lib/coverage/evidence.test.mjs +13 -4
  4. package/lib/coverage/fs-walk.mjs +2 -2
  5. package/lib/coverage/graph-builder.mjs +23 -24
  6. package/lib/coverage/next-ir-to-graph.mjs +240 -0
  7. package/node_modules/@elench/next-analysis/package.json +14 -0
  8. package/node_modules/@elench/next-analysis/src/api-routes.mjs +81 -0
  9. package/node_modules/@elench/next-analysis/src/api-routes.test.mjs +22 -0
  10. package/node_modules/@elench/next-analysis/src/app-root.mjs +7 -0
  11. package/node_modules/@elench/next-analysis/src/backend-links.mjs +31 -0
  12. package/node_modules/@elench/next-analysis/src/index.mjs +21 -0
  13. package/node_modules/@elench/next-analysis/src/pages.mjs +68 -0
  14. package/node_modules/@elench/next-analysis/src/project.mjs +94 -0
  15. package/node_modules/@elench/next-analysis/src/project.test.mjs +35 -0
  16. package/node_modules/@elench/next-analysis/src/route-tree.mjs +621 -0
  17. package/node_modules/@elench/next-analysis/src/routes.mjs +41 -0
  18. package/node_modules/@elench/next-analysis/src/routes.test.mjs +25 -0
  19. package/node_modules/@elench/next-analysis/src/server-actions.mjs +53 -0
  20. package/node_modules/@elench/next-analysis/src/server-actions.test.mjs +37 -0
  21. package/node_modules/@elench/next-analysis/src/shared.mjs +209 -0
  22. package/node_modules/@elench/next-analysis/src/swc.mjs +388 -0
  23. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  24. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  25. package/node_modules/@elench/ts-analysis/package.json +10 -0
  26. package/node_modules/@elench/ts-analysis/src/callables.mjs +135 -0
  27. package/node_modules/@elench/ts-analysis/src/callables.test.mjs +55 -0
  28. package/node_modules/@elench/ts-analysis/src/exports.mjs +69 -0
  29. package/node_modules/@elench/ts-analysis/src/exports.test.mjs +50 -0
  30. package/node_modules/@elench/ts-analysis/src/index.mjs +14 -0
  31. package/node_modules/@elench/ts-analysis/src/jsx.mjs +69 -0
  32. package/node_modules/@elench/ts-analysis/src/jsx.test.mjs +43 -0
  33. package/node_modules/@elench/ts-analysis/src/project.mjs +100 -0
  34. package/node_modules/@elench/ts-analysis/src/project.test.mjs +54 -0
  35. package/node_modules/@elench/ts-analysis/src/requests.mjs +141 -0
  36. package/node_modules/@elench/ts-analysis/src/requests.test.mjs +35 -0
  37. package/node_modules/@elench/ts-analysis/src/resolution.mjs +53 -0
  38. package/node_modules/@elench/ts-analysis/src/shared.mjs +32 -0
  39. package/node_modules/@elench/ts-analysis/src/syntax.mjs +27 -0
  40. package/node_modules/@next/routing/README.md +91 -0
  41. package/node_modules/@next/routing/dist/__tests__/captures.test.d.ts +1 -0
  42. package/node_modules/@next/routing/dist/__tests__/conditions.test.d.ts +1 -0
  43. package/node_modules/@next/routing/dist/__tests__/dynamic-after-rewrites.test.d.ts +1 -0
  44. package/node_modules/@next/routing/dist/__tests__/i18n-resolve-routes.test.d.ts +1 -0
  45. package/node_modules/@next/routing/dist/__tests__/i18n.test.d.ts +1 -0
  46. package/node_modules/@next/routing/dist/__tests__/middleware.test.d.ts +1 -0
  47. package/node_modules/@next/routing/dist/__tests__/normalize-next-data.test.d.ts +1 -0
  48. package/node_modules/@next/routing/dist/__tests__/redirects.test.d.ts +1 -0
  49. package/node_modules/@next/routing/dist/__tests__/resolve-routes.test.d.ts +1 -0
  50. package/node_modules/@next/routing/dist/__tests__/rewrites.test.d.ts +1 -0
  51. package/node_modules/@next/routing/dist/destination.d.ts +22 -0
  52. package/node_modules/@next/routing/dist/i18n.d.ts +48 -0
  53. package/node_modules/@next/routing/dist/index.d.ts +5 -0
  54. package/node_modules/@next/routing/dist/index.js +1 -0
  55. package/node_modules/@next/routing/dist/matchers.d.ts +12 -0
  56. package/node_modules/@next/routing/dist/middleware.d.ts +12 -0
  57. package/node_modules/@next/routing/dist/next-data.d.ts +10 -0
  58. package/node_modules/@next/routing/dist/resolve-routes.d.ts +2 -0
  59. package/node_modules/@next/routing/dist/types.d.ts +97 -0
  60. package/node_modules/@next/routing/package.json +39 -0
  61. package/node_modules/@swc/core/README.md +100 -0
  62. package/node_modules/@swc/core/Visitor.d.ts +218 -0
  63. package/node_modules/@swc/core/Visitor.js +1399 -0
  64. package/node_modules/@swc/core/binding.d.ts +59 -0
  65. package/node_modules/@swc/core/binding.js +368 -0
  66. package/node_modules/@swc/core/index.d.ts +120 -0
  67. package/node_modules/@swc/core/index.js +443 -0
  68. package/node_modules/@swc/core/package.json +120 -0
  69. package/node_modules/@swc/core/postinstall.js +148 -0
  70. package/node_modules/@swc/core/spack.d.ts +51 -0
  71. package/node_modules/@swc/core/spack.js +87 -0
  72. package/node_modules/@swc/core/util.d.ts +1 -0
  73. package/node_modules/@swc/core/util.js +104 -0
  74. package/node_modules/@swc/core-linux-x64-gnu/README.md +3 -0
  75. package/node_modules/@swc/core-linux-x64-gnu/package.json +46 -0
  76. package/node_modules/@swc/core-linux-x64-gnu/swc.linux-x64-gnu.node +0 -0
  77. package/node_modules/@swc/counter/CHANGELOG.md +7 -0
  78. package/node_modules/@swc/counter/README.md +7 -0
  79. package/node_modules/@swc/counter/index.js +1 -0
  80. package/node_modules/@swc/counter/package.json +27 -0
  81. package/node_modules/@swc/types/LICENSE +201 -0
  82. package/node_modules/@swc/types/README.md +4 -0
  83. package/node_modules/@swc/types/assumptions.d.ts +92 -0
  84. package/node_modules/@swc/types/assumptions.js +2 -0
  85. package/node_modules/@swc/types/index.d.ts +2049 -0
  86. package/node_modules/@swc/types/index.js +2 -0
  87. package/node_modules/@swc/types/package.json +40 -0
  88. package/package.json +7 -3
  89. package/lib/coverage/next-discovery.mjs +0 -205
  90. package/lib/coverage/next-static-analysis.mjs +0 -1047
@@ -0,0 +1,53 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { extractBackendRefs } from "./backend-links.mjs";
4
+ import { isServerActionFile, isSourceModuleFile, normalizePath } from "./shared.mjs";
5
+ import { collectExportedCallables, collectTopLevelFunctions, parseModule } from "./swc.mjs";
6
+
7
+ export function discoverServerActions({ rootDir, appRoot }) {
8
+ const actionEntries = [];
9
+ for (const absolutePath of walkFiles(appRoot)) {
10
+ if (!isSourceModuleFile(absolutePath)) continue;
11
+ const content = fs.readFileSync(absolutePath, "utf8");
12
+ if (!isServerActionFile(content)) continue;
13
+ const filePath = normalizePath(path.relative(rootDir, absolutePath));
14
+ const ast = parseModule(content, filePath);
15
+ const topLevelFunctions = collectTopLevelFunctions(ast);
16
+ const { exports } = collectExportedCallables(ast, topLevelFunctions);
17
+ const backendRefs = extractBackendRefs({
18
+ ast,
19
+ rootDir,
20
+ filePath,
21
+ readSourceFile: (targetPath) => fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null,
22
+ });
23
+
24
+ for (const [exportName] of exports.entries()) {
25
+ actionEntries.push({
26
+ id: `server-action:${filePath}#${exportName}`,
27
+ kind: "server_action",
28
+ filePath,
29
+ exportName,
30
+ backendRefs,
31
+ });
32
+ }
33
+ }
34
+ return actionEntries.sort((left, right) => left.filePath.localeCompare(right.filePath) || left.exportName.localeCompare(right.exportName));
35
+ }
36
+
37
+ function walkFiles(rootDir) {
38
+ const results = [];
39
+ const queue = [rootDir];
40
+ while (queue.length > 0) {
41
+ const current = queue.pop();
42
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
43
+ if (entry.isSymbolicLink()) continue;
44
+ const absolutePath = path.join(current, entry.name);
45
+ if (entry.isDirectory()) {
46
+ queue.push(absolutePath);
47
+ } else if (entry.isFile()) {
48
+ results.push(absolutePath);
49
+ }
50
+ }
51
+ }
52
+ return results.sort((left, right) => left.localeCompare(right));
53
+ }
@@ -0,0 +1,37 @@
1
+ import path from "path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { createNextAnalysisProject } from "./index.mjs";
4
+
5
+ const fixtureRoot = path.resolve(
6
+ "/home/georgedlr/workspace/elench/testkit/test/fixtures/integration/next-server-actions-product"
7
+ );
8
+
9
+ describe("@elench/next-analysis server actions", () => {
10
+ it("discovers server action exports and links them from route trees", () => {
11
+ const project = createNextAnalysisProject({ rootDir: fixtureRoot });
12
+ const actions = project.discoverServerActions();
13
+ expect(actions).toEqual([
14
+ expect.objectContaining({
15
+ exportName: "saveSettings",
16
+ filePath: "src/app/settings/actions.ts",
17
+ }),
18
+ ]);
19
+
20
+ const routeTree = project.analyzeRouteTree("/settings");
21
+ expect(routeTree.surfaces).toEqual(
22
+ expect.arrayContaining([
23
+ expect.objectContaining({
24
+ targetHint: expect.objectContaining({ value: "settings-save-button" }),
25
+ }),
26
+ ])
27
+ );
28
+ expect(routeTree.serverActionRefs).toEqual(
29
+ expect.arrayContaining([
30
+ expect.objectContaining({
31
+ exportKey: "src/app/settings/actions.ts#saveSettings",
32
+ ownerKind: "action",
33
+ }),
34
+ ])
35
+ );
36
+ });
37
+ });
@@ -0,0 +1,209 @@
1
+ import fs from "fs";
2
+ import path from "path";
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
+ export const DYNAMIC_SEGMENT_TOKEN = "__TESTKIT_DYNAMIC_SEGMENT__";
13
+ export const SURFACE_COMPONENT_NAMES = new Set(["button", "Button", "form", "Form", "a", "Link", "input"]);
14
+
15
+ export function normalizePath(filePath) {
16
+ return String(filePath || "").split(path.sep).join("/");
17
+ }
18
+
19
+ export function normalizeRoute(value) {
20
+ const trimmed = String(value || "/").trim();
21
+ if (!trimmed || trimmed === "/") return "/";
22
+ const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
23
+ return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, "") : withLeadingSlash;
24
+ }
25
+
26
+ export function normalizeRouteSegments(segments) {
27
+ const normalizedSegments = [...segments]
28
+ .filter(Boolean)
29
+ .map(String)
30
+ .filter((segment) => !segment.startsWith("(") || !segment.endsWith(")"))
31
+ .filter((segment) => !segment.startsWith("@"))
32
+ .filter((segment) => segment !== "page" && segment !== "route" && segment !== "layout");
33
+ return normalizeRoute(`/${normalizedSegments.join("/")}`);
34
+ }
35
+
36
+ export function toApiRequestPath(route) {
37
+ return normalizeRoute(`/api${route === "/" ? "" : route}`);
38
+ }
39
+
40
+ export function pageLabelFromRoute(route) {
41
+ if (route === "/") return "Home";
42
+ return route
43
+ .split("/")
44
+ .filter(Boolean)
45
+ .map((segment) => {
46
+ if (segment.startsWith("[") && segment.endsWith("]")) return segment.slice(1, -1);
47
+ return segment;
48
+ })
49
+ .map((segment) => segment.replace(/[-_]+/gu, " "))
50
+ .map((segment) => segment.replace(/^\w/u, (char) => char.toUpperCase()))
51
+ .join(" ");
52
+ }
53
+
54
+ export function humanizeTagName(tagName) {
55
+ return String(tagName).replace(/([a-z])([A-Z])/gu, "$1 $2").replace(/^\w/u, (char) => char.toUpperCase());
56
+ }
57
+
58
+ export function dedupeById(entries) {
59
+ const seen = new Set();
60
+ return entries.filter((entry) => {
61
+ if (seen.has(entry.id)) return false;
62
+ seen.add(entry.id);
63
+ return true;
64
+ });
65
+ }
66
+
67
+ export function dedupeRequests(requests) {
68
+ const seen = new Set();
69
+ return requests.filter((request) => {
70
+ const key = `${request.ownerId}:${request.method}:${request.path}`;
71
+ if (seen.has(key)) return false;
72
+ seen.add(key);
73
+ return true;
74
+ });
75
+ }
76
+
77
+ export function dedupeRefs(refs) {
78
+ const seen = new Set();
79
+ return refs.filter((ref) => {
80
+ const key = `${ref.ownerId || ""}:${ref.exportKey || ref.id || ""}`;
81
+ if (seen.has(key)) return false;
82
+ seen.add(key);
83
+ return true;
84
+ });
85
+ }
86
+
87
+ export function modulePathKey(filePath) {
88
+ const normalized = normalizePath(filePath);
89
+ const ext = path.extname(normalized);
90
+ if (path.basename(normalized, ext) === "index") {
91
+ return normalizePath(path.dirname(normalized));
92
+ }
93
+ return normalized.slice(0, normalized.length - ext.length);
94
+ }
95
+
96
+ export function isServerActionFile(content) {
97
+ const trimmed = String(content || "").trimStart();
98
+ return trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'");
99
+ }
100
+
101
+ export function isBackendSpecifier(specifier) {
102
+ return (
103
+ specifier.startsWith("@/backend/server/") ||
104
+ specifier.includes("/backend/server/") ||
105
+ specifier.startsWith("./backend/server/") ||
106
+ specifier.startsWith("../backend/server/")
107
+ );
108
+ }
109
+
110
+ export function isDataSpecifier(specifier) {
111
+ return (
112
+ specifier.startsWith("@/backend/data/") ||
113
+ specifier.startsWith("@/backend/dal/") ||
114
+ specifier.includes("/backend/data/") ||
115
+ specifier.includes("/backend/dal/") ||
116
+ specifier.includes("/db/") ||
117
+ specifier.includes("/repository/") ||
118
+ specifier.includes("/repositories/")
119
+ );
120
+ }
121
+
122
+ export function isSourceModuleFile(filePath) {
123
+ return /\.(?:ts|tsx|js|jsx|mjs)$/u.test(filePath);
124
+ }
125
+
126
+ export function isPageFile(filePath) {
127
+ return /\/page\.(?:ts|tsx|js|jsx)$/u.test(normalizePath(filePath));
128
+ }
129
+
130
+ export function isLayoutFile(filePath) {
131
+ return /\/layout\.(?:ts|tsx|js|jsx)$/u.test(normalizePath(filePath));
132
+ }
133
+
134
+ export function isRouteHandlerFile(filePath) {
135
+ return /\/route\.(?:ts|tsx|js|jsx)$/u.test(normalizePath(filePath));
136
+ }
137
+
138
+ export function fileExists(filePath) {
139
+ try {
140
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ export function directoryExists(filePath) {
147
+ try {
148
+ return fs.existsSync(filePath) && fs.statSync(filePath).isDirectory();
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ export function readFileIfExists(filePath) {
155
+ return fileExists(filePath) ? fs.readFileSync(filePath, "utf8") : null;
156
+ }
157
+
158
+ export function resolveSourceCandidate(basePath) {
159
+ const direct = [basePath, `${basePath}.ts`, `${basePath}.tsx`, `${basePath}.js`, `${basePath}.jsx`, `${basePath}.mjs`];
160
+ for (const candidate of direct) {
161
+ if (fileExists(candidate)) return candidate;
162
+ }
163
+
164
+ const indexed = [
165
+ path.join(basePath, "index.ts"),
166
+ path.join(basePath, "index.tsx"),
167
+ path.join(basePath, "index.js"),
168
+ path.join(basePath, "index.jsx"),
169
+ path.join(basePath, "index.mjs"),
170
+ ];
171
+ for (const candidate of indexed) {
172
+ if (fileExists(candidate)) return candidate;
173
+ }
174
+
175
+ return null;
176
+ }
177
+
178
+ export function findLineNumber(content, spanStart) {
179
+ const source = String(content || "");
180
+ const target = Math.max(0, Number(spanStart || 0) - 1);
181
+ let line = 1;
182
+ for (let index = 0; index < source.length && index < target; index += 1) {
183
+ if (source[index] === "\n") line += 1;
184
+ }
185
+ return line;
186
+ }
187
+
188
+ export function ensureArray(value) {
189
+ return Array.isArray(value) ? value : [];
190
+ }
191
+
192
+ export function createDiagnostic({ level = "info", code, message, filePath = null, line = null, relatedIds = [] }) {
193
+ return {
194
+ level,
195
+ code,
196
+ message,
197
+ ...(filePath ? { filePath } : {}),
198
+ ...(line ? { line } : {}),
199
+ ...(relatedIds.length > 0 ? { relatedIds } : {}),
200
+ };
201
+ }
202
+
203
+ export function compareByRoute(left, right) {
204
+ return left.route.localeCompare(right.route) || left.filePath.localeCompare(right.filePath);
205
+ }
206
+
207
+ export function compareByPath(left, right) {
208
+ return left.filePath.localeCompare(right.filePath);
209
+ }
@@ -0,0 +1,388 @@
1
+ import { parseSync } from "@swc/core";
2
+ import {
3
+ DYNAMIC_SEGMENT_TOKEN,
4
+ directoryExists,
5
+ findLineNumber,
6
+ HTTP_WRAPPER_METHODS,
7
+ isServerActionFile,
8
+ normalizeRoute,
9
+ readFileIfExists,
10
+ resolveSourceCandidate,
11
+ } from "./shared.mjs";
12
+ import { requestPathPatternFromLiteral } from "./routes.mjs";
13
+
14
+ export function parseModule(content, filePath) {
15
+ return parseSync(content, {
16
+ syntax: "typescript",
17
+ tsx: /\.(?:tsx|jsx)$/u.test(filePath),
18
+ target: "es2022",
19
+ });
20
+ }
21
+
22
+ export function resolveImportToSourceFile(rootDir, fromFilePath, specifier) {
23
+ const sourceRoot = directoryExists(`${rootDir}/src`) ? `${rootDir}/src` : rootDir;
24
+ const absoluteFrom = `${rootDir}/${fromFilePath}`;
25
+ const candidates = [];
26
+
27
+ if (specifier.startsWith("@/")) {
28
+ candidates.push(`${sourceRoot}/${specifier.slice(2)}`);
29
+ } else if (specifier.startsWith("./") || specifier.startsWith("../")) {
30
+ candidates.push(new URL(specifier, `file://${absoluteFrom}`).pathname);
31
+ } else {
32
+ return null;
33
+ }
34
+
35
+ for (const candidate of candidates) {
36
+ const resolved = resolveSourceCandidate(candidate);
37
+ if (resolved) return resolved.slice(rootDir.length + 1).split("\\").join("/");
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ export function collectImports(ast, { rootDir, filePath, readSourceFile = readFileIfExists } = {}) {
44
+ const imports = new Map();
45
+ for (const statement of ast.body || []) {
46
+ if (statement.type !== "ImportDeclaration") continue;
47
+ const specifier = statement.source?.value || null;
48
+ if (!specifier) continue;
49
+ const resolvedFilePath = rootDir && filePath ? resolveImportToSourceFile(rootDir, filePath, specifier) : null;
50
+ const sourceContent = resolvedFilePath ? readSourceFile(`${rootDir}/${resolvedFilePath}`) : null;
51
+ const serverActionImport = Boolean(sourceContent && isServerActionFile(sourceContent));
52
+
53
+ for (const entry of statement.specifiers || []) {
54
+ if (entry.type === "ImportDefaultSpecifier") {
55
+ imports.set(entry.local.value, {
56
+ importedName: "default",
57
+ specifier,
58
+ resolvedFilePath,
59
+ isServerAction: serverActionImport,
60
+ });
61
+ continue;
62
+ }
63
+
64
+ if (entry.type === "ImportSpecifier") {
65
+ imports.set(entry.local.value, {
66
+ importedName: entry.imported?.value || entry.local.value,
67
+ specifier,
68
+ resolvedFilePath,
69
+ isServerAction: serverActionImport,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ return imports;
75
+ }
76
+
77
+ export function collectTopLevelFunctions(ast) {
78
+ const functions = new Map();
79
+ for (const statement of ast.body || []) {
80
+ if (statement.type === "FunctionDeclaration" && statement.identifier) {
81
+ functions.set(statement.identifier.value, statement);
82
+ continue;
83
+ }
84
+
85
+ if (statement.type === "VariableDeclaration") {
86
+ collectFunctionVariableDeclarations(statement, functions);
87
+ continue;
88
+ }
89
+
90
+ if (statement.type === "ExportDeclaration") {
91
+ const { declaration } = statement;
92
+ if (declaration?.type === "FunctionDeclaration" && declaration.identifier) {
93
+ functions.set(declaration.identifier.value, declaration);
94
+ } else if (declaration?.type === "VariableDeclaration") {
95
+ collectFunctionVariableDeclarations(declaration, functions);
96
+ }
97
+ }
98
+ }
99
+ return functions;
100
+ }
101
+
102
+ function collectFunctionVariableDeclarations(statement, target) {
103
+ for (const declaration of statement.declarations || []) {
104
+ if (declaration.id?.type !== "Identifier" || !declaration.init) continue;
105
+ if (declaration.init.type === "ArrowFunctionExpression" || declaration.init.type === "FunctionExpression") {
106
+ target.set(declaration.id.value, declaration.init);
107
+ } else if (declaration.init.type === "Identifier") {
108
+ target.set(declaration.id.value, declaration.init);
109
+ }
110
+ }
111
+ }
112
+
113
+ export function collectExportedCallables(ast, topLevelFunctions) {
114
+ const exports = new Map();
115
+ let defaultExport = null;
116
+
117
+ for (const statement of ast.body || []) {
118
+ if (statement.type === "ExportDeclaration") {
119
+ const { declaration } = statement;
120
+ if (declaration?.type === "FunctionDeclaration" && declaration.identifier) {
121
+ exports.set(declaration.identifier.value, declaration);
122
+ } else if (declaration?.type === "VariableDeclaration") {
123
+ for (const declarator of declaration.declarations || []) {
124
+ if (declarator.id?.type !== "Identifier") continue;
125
+ const name = declarator.id.value;
126
+ if (declarator.init?.type === "ArrowFunctionExpression" || declarator.init?.type === "FunctionExpression") {
127
+ exports.set(name, declarator.init);
128
+ } else if (declarator.init?.type === "Identifier") {
129
+ const referenced = topLevelFunctions.get(declarator.init.value);
130
+ if (referenced) exports.set(name, referenced);
131
+ }
132
+ }
133
+ }
134
+ continue;
135
+ }
136
+
137
+ if (statement.type === "ExportNamedDeclaration") {
138
+ for (const specifier of statement.specifiers || []) {
139
+ if (specifier.type !== "ExportSpecifier") continue;
140
+ const exportedName = specifier.exported?.value || specifier.orig?.value;
141
+ const origName = specifier.orig?.value;
142
+ if (exportedName && origName && topLevelFunctions.get(origName)) {
143
+ exports.set(exportedName, topLevelFunctions.get(origName));
144
+ }
145
+ }
146
+ continue;
147
+ }
148
+
149
+ if (statement.type === "ExportDefaultDeclaration") {
150
+ defaultExport = statement.decl;
151
+ continue;
152
+ }
153
+
154
+ if (statement.type === "ExportDefaultExpression" && statement.expression?.type === "Identifier") {
155
+ defaultExport = topLevelFunctions.get(statement.expression.value) || null;
156
+ }
157
+ }
158
+
159
+ return { exports, defaultExport };
160
+ }
161
+
162
+ export function collectScopeLocalFunctions(node) {
163
+ const functions = new Map();
164
+ const body = node?.body?.stmts ? node.body : node?.body?.type === "BlockStatement" ? node.body : null;
165
+ if (!body) return functions;
166
+
167
+ for (const statement of body.stmts || []) {
168
+ if (statement.type === "FunctionDeclaration" && statement.identifier) {
169
+ functions.set(statement.identifier.value, statement);
170
+ } else if (statement.type === "VariableDeclaration") {
171
+ collectFunctionVariableDeclarations(statement, functions);
172
+ }
173
+ }
174
+
175
+ return functions;
176
+ }
177
+
178
+ export function collectJsxAttributes(opening) {
179
+ const attributes = {};
180
+ for (const attribute of opening.attributes || []) {
181
+ if (attribute.type !== "JSXAttribute") continue;
182
+ const name = attribute.name?.value || attribute.name?.name || null;
183
+ if (!name) continue;
184
+ const value = attribute.value;
185
+ if (!value) {
186
+ attributes[name] = { stringValue: "true", expression: null };
187
+ } else if (value.type === "StringLiteral") {
188
+ attributes[name] = { stringValue: value.value, expression: null };
189
+ } else if (value.type === "JSXExpressionContainer") {
190
+ attributes[name] = {
191
+ stringValue: extractStringLiteral(value.expression),
192
+ expression: value.expression || null,
193
+ };
194
+ }
195
+ }
196
+ return attributes;
197
+ }
198
+
199
+ export function flattenJsxText(children = []) {
200
+ let text = "";
201
+ for (const child of children) {
202
+ if (child.type === "JSXText") {
203
+ text += child.value.replace(/\s+/gu, " ");
204
+ } else if (child.type === "JSXExpressionContainer" && child.expression?.type === "StringLiteral") {
205
+ text += child.expression.value;
206
+ } else if (child.type === "JSXElement") {
207
+ text += flattenJsxText(child.children);
208
+ }
209
+ }
210
+ return text;
211
+ }
212
+
213
+ export function extractJsxLabel(node, attributes) {
214
+ const ariaLabel = attributes["aria-label"]?.stringValue || attributes.title?.stringValue || null;
215
+ if (ariaLabel) return ariaLabel;
216
+ if (node.type === "JSXElement") {
217
+ const text = flattenJsxText(node.children).trim();
218
+ if (text) return text;
219
+ }
220
+ return null;
221
+ }
222
+
223
+ export function walkJsx(node, visitor) {
224
+ const visit = (current, formContext = null) => {
225
+ let nextFormContext = formContext;
226
+ if (current?.type === "JSXElement" || current?.type === "JSXOpeningElement") {
227
+ nextFormContext = visitor(current, formContext);
228
+ }
229
+
230
+ if (current?.type === "JSXElement") {
231
+ for (const child of current.children || []) visit(child, nextFormContext);
232
+ return;
233
+ }
234
+
235
+ forEachChild(current, (child) => visit(child, nextFormContext));
236
+ };
237
+
238
+ visit(node, null);
239
+ }
240
+
241
+ export function forEachChild(node, visitor) {
242
+ if (!node || typeof node !== "object") return;
243
+ for (const value of Object.values(node)) {
244
+ if (!value) continue;
245
+ if (Array.isArray(value)) {
246
+ for (const item of value) {
247
+ if (item && typeof item === "object" && item.type) visitor(item);
248
+ }
249
+ continue;
250
+ }
251
+ if (value && typeof value === "object" && value.type) visitor(value);
252
+ }
253
+ }
254
+
255
+ export function extractStringLiteral(node) {
256
+ if (!node) return null;
257
+ if (node.type === "StringLiteral") return node.value;
258
+ if (node.type === "TemplateLiteral") {
259
+ let value = node.quasis?.[0]?.raw || "";
260
+ for (let index = 0; index < (node.expressions || []).length; index += 1) {
261
+ value += DYNAMIC_SEGMENT_TOKEN;
262
+ value += node.quasis?.[index + 1]?.raw || "";
263
+ }
264
+ return value;
265
+ }
266
+ return null;
267
+ }
268
+
269
+ export function extractRouteLiteral(node) {
270
+ const literal = extractStringLiteral(node);
271
+ if (!literal) return null;
272
+ const normalized = literal.split("?")[0];
273
+ const requestPath = requestPathPatternFromLiteral(normalized);
274
+ return requestPath ? normalizeRoute(requestPath) : null;
275
+ }
276
+
277
+ export function extractFetchMethod(node) {
278
+ if (!node || node.type !== "ObjectExpression") return null;
279
+ for (const property of node.properties || []) {
280
+ if (property.type !== "KeyValueProperty") continue;
281
+ const key = property.key?.value || property.key?.name?.value || property.key?.name || property.key?.value;
282
+ if (key !== "method") continue;
283
+ const value = extractStringLiteral(property.value);
284
+ return value ? value.toUpperCase() : null;
285
+ }
286
+ return null;
287
+ }
288
+
289
+ export function extractHttpRequests(rootNode) {
290
+ const requests = [];
291
+ walkNode(rootNode, (node) => {
292
+ if (node.type !== "CallExpression") return;
293
+ const request = resolveHttpRequestCall(node);
294
+ if (request) requests.push(request);
295
+ });
296
+ return requests;
297
+ }
298
+
299
+ export function resolveHttpRequestCall(callExpression) {
300
+ const callee = callExpression.callee;
301
+ if (callee?.type === "Identifier") {
302
+ if (callee.value === "fetch") {
303
+ const path = extractRouteLiteral(callExpression.arguments?.[0]?.expression);
304
+ if (!path || !path.startsWith("/api/")) return null;
305
+ return {
306
+ method: extractFetchMethod(callExpression.arguments?.[1]?.expression) || "GET",
307
+ path,
308
+ confidence: "high",
309
+ };
310
+ }
311
+
312
+ const method = HTTP_WRAPPER_METHODS[callee.value];
313
+ if (method) {
314
+ const path = extractRouteLiteral(callExpression.arguments?.[0]?.expression);
315
+ if (!path || !path.startsWith("/api/")) return null;
316
+ return {
317
+ method,
318
+ path,
319
+ confidence: "high",
320
+ };
321
+ }
322
+
323
+ if (callee.value === "rawReq") {
324
+ const methodLiteral = extractStringLiteral(callExpression.arguments?.[0]?.expression);
325
+ const path = extractRouteLiteral(callExpression.arguments?.[1]?.expression);
326
+ if (!methodLiteral || !path) return null;
327
+ return {
328
+ method: methodLiteral.toUpperCase(),
329
+ path,
330
+ confidence: "high",
331
+ };
332
+ }
333
+ }
334
+
335
+ if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && callee.property.value === "rawReq") {
336
+ const methodLiteral = extractStringLiteral(callExpression.arguments?.[0]?.expression);
337
+ const path = extractRouteLiteral(callExpression.arguments?.[1]?.expression);
338
+ if (!methodLiteral || !path) return null;
339
+ return {
340
+ method: methodLiteral.toUpperCase(),
341
+ path,
342
+ confidence: "high",
343
+ };
344
+ }
345
+
346
+ return null;
347
+ }
348
+
349
+ export function extractPlaywrightVisitedRoutes(rootNode) {
350
+ const routes = [];
351
+ walkNode(rootNode, (node) => {
352
+ if (node.type !== "CallExpression") return;
353
+ const route = resolveGotoRoute(node);
354
+ if (route) routes.push(route);
355
+ });
356
+ return [...new Set(routes)];
357
+ }
358
+
359
+ function resolveGotoRoute(callExpression) {
360
+ const callee = callExpression.callee;
361
+ if (callee?.type !== "MemberExpression") return null;
362
+ const property = callee.property;
363
+ if (property?.type !== "Identifier" || property.value !== "goto") return null;
364
+ const literal = extractStringLiteral(callExpression.arguments?.[0]?.expression);
365
+ if (!literal) return null;
366
+ if (/^https?:\/\//u.test(literal)) {
367
+ try {
368
+ const parsed = new URL(literal);
369
+ if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
370
+ return normalizeRoute(parsed.pathname.split("?")[0]);
371
+ }
372
+ } catch {
373
+ return null;
374
+ }
375
+ return null;
376
+ }
377
+ return normalizeRoute(literal.split("?")[0]);
378
+ }
379
+
380
+ export function walkNode(node, visitor) {
381
+ if (!node || typeof node !== "object") return;
382
+ visitor(node);
383
+ forEachChild(node, (child) => walkNode(child, visitor));
384
+ }
385
+
386
+ export function extractLineNumber(content, node) {
387
+ return findLineNumber(content, node?.span?.start);
388
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
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.63"
14
+ "@elench/testkit-protocol": "0.1.65"
15
15
  },
16
16
  "private": false
17
17
  }