@elench/testkit 0.1.59 → 0.1.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/config/database.mjs +53 -0
- package/lib/config/database.test.mjs +29 -0
- package/lib/config/discovery-config.mjs +13 -0
- package/lib/config/env.mjs +55 -0
- package/lib/config/env.test.mjs +40 -0
- package/lib/config/index.mjs +21 -807
- package/lib/config/paths.mjs +28 -0
- package/lib/config/paths.test.mjs +27 -0
- package/lib/config/runtime.mjs +241 -0
- package/lib/config/runtime.test.mjs +56 -0
- package/lib/config/skip-config.mjs +189 -0
- package/lib/config/skip-config.test.mjs +63 -0
- package/lib/config/telemetry.mjs +28 -0
- package/lib/config/validation.mjs +124 -0
- package/lib/coverage/backend-discovery.mjs +183 -0
- package/lib/coverage/backend-discovery.test.mjs +52 -0
- package/lib/coverage/evidence.mjs +146 -0
- package/lib/coverage/evidence.test.mjs +64 -0
- package/lib/coverage/fs-walk.mjs +64 -0
- package/lib/coverage/graph-builder.mjs +167 -0
- package/lib/coverage/index.mjs +1 -776
- package/lib/coverage/index.test.mjs +183 -14
- package/lib/coverage/next-discovery.mjs +174 -0
- package/lib/coverage/next-static-analysis.mjs +728 -0
- package/lib/coverage/routing.mjs +86 -0
- package/lib/coverage/routing.test.mjs +52 -0
- package/lib/coverage/shared.mjs +197 -0
- package/lib/coverage/shared.test.mjs +39 -0
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-bridge/src/index.mjs +101 -15
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +36 -6
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/testkit-protocol/src/index.d.ts +1 -0
- package/node_modules/@elench/testkit-protocol/src/index.mjs +3 -1
- package/node_modules/@elench/testkit-protocol/src/index.test.mjs +14 -0
- package/package.json +5 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ensureExistingPath, resolveServiceCwd } from "./paths.mjs";
|
|
2
|
+
import { parseModuleSpecifier } from "./runtime.mjs";
|
|
3
|
+
|
|
4
|
+
export function validateConfigCoverage(configs) {
|
|
5
|
+
const names = new Set(configs.map((config) => config.name));
|
|
6
|
+
for (const config of configs) {
|
|
7
|
+
for (const depName of config.testkit.dependsOn || []) {
|
|
8
|
+
if (!names.has(depName)) {
|
|
9
|
+
throw new Error(`Service "${config.name}" depends on "${depName}", but ${depName} is not defined`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const databaseFrom = config.testkit.databaseFrom;
|
|
13
|
+
if (databaseFrom && !names.has(databaseFrom)) {
|
|
14
|
+
throw new Error(`Service "${config.name}" databaseFrom "${databaseFrom}" is not defined`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateServiceConfig({
|
|
20
|
+
name,
|
|
21
|
+
local,
|
|
22
|
+
database,
|
|
23
|
+
databaseFrom,
|
|
24
|
+
runtime,
|
|
25
|
+
dependsOn,
|
|
26
|
+
suites,
|
|
27
|
+
productDir,
|
|
28
|
+
}) {
|
|
29
|
+
const usesLocalExecution = Object.entries(suites).some(([suiteType, discoveredSuites]) =>
|
|
30
|
+
discoveredSuites.some((suite) => (suite.framework && suite.framework !== "k6") || suiteType !== "dal")
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (usesLocalExecution && !local) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Service "${name}" defines non-DAL suites but no local runtime could be resolved. Add it in testkit.setup.ts.`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (database && databaseFrom) {
|
|
40
|
+
throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
|
|
41
|
+
}
|
|
42
|
+
if (runtime.instances < 1) {
|
|
43
|
+
throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
|
|
44
|
+
}
|
|
45
|
+
if (runtime.maxConcurrentTasks <= 0) {
|
|
46
|
+
throw new Error(`Service "${name}" runtime.maxConcurrentTasks must be a positive integer when provided`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const depName of dependsOn || []) {
|
|
50
|
+
if (depName === name) {
|
|
51
|
+
throw new Error(`Service "${name}" cannot depend on itself`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (local?.cwd) {
|
|
56
|
+
ensureExistingPath(productDir, local.cwd, `Service "${name}" local.cwd`);
|
|
57
|
+
}
|
|
58
|
+
if (runtime.toolchain?.cwd) {
|
|
59
|
+
ensureExistingPath(productDir, runtime.toolchain.cwd, `Service "${name}" runtime.toolchain.cwd`);
|
|
60
|
+
}
|
|
61
|
+
for (const [stageName, steps] of Object.entries(database?.template || {})) {
|
|
62
|
+
if (stageName === "inputs") continue;
|
|
63
|
+
for (const step of steps || []) {
|
|
64
|
+
if (step.cwd) {
|
|
65
|
+
ensureExistingPath(productDir, step.cwd, `Service "${name}" database.template.${stageName} step cwd`);
|
|
66
|
+
}
|
|
67
|
+
if (step.kind === "sql-file") {
|
|
68
|
+
ensureExistingPath(
|
|
69
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
70
|
+
step.path,
|
|
71
|
+
`Service "${name}" database.template.${stageName} sql file`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (step.kind === "module") {
|
|
75
|
+
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
76
|
+
ensureExistingPath(
|
|
77
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
78
|
+
modulePath,
|
|
79
|
+
`Service "${name}" database.template.${stageName} module`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
for (const input of step.inputs || []) {
|
|
83
|
+
ensureExistingPath(
|
|
84
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
85
|
+
input,
|
|
86
|
+
`Service "${name}" database.template.${stageName} step input`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const input of database?.template?.inputs || []) {
|
|
92
|
+
ensureExistingPath(productDir, input, `Service "${name}" database.template input`);
|
|
93
|
+
}
|
|
94
|
+
for (const step of runtime.prepare?.steps || []) {
|
|
95
|
+
if (step.cwd) {
|
|
96
|
+
ensureExistingPath(productDir, step.cwd, `Service "${name}" runtime.prepare step cwd`);
|
|
97
|
+
}
|
|
98
|
+
if (step.kind === "sql-file") {
|
|
99
|
+
ensureExistingPath(
|
|
100
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
101
|
+
step.path,
|
|
102
|
+
`Service "${name}" runtime.prepare sql file`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (step.kind === "module") {
|
|
106
|
+
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
107
|
+
ensureExistingPath(
|
|
108
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
109
|
+
modulePath,
|
|
110
|
+
`Service "${name}" runtime.prepare module`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
for (const input of step.inputs || []) {
|
|
114
|
+
ensureExistingPath(
|
|
115
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
116
|
+
input,
|
|
117
|
+
`Service "${name}" runtime.prepare step input`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const input of runtime.prepare?.inputs || []) {
|
|
122
|
+
ensureExistingPath(productDir, input, `Service "${name}" runtime.prepare input`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import {
|
|
4
|
+
dedupeBackendImports,
|
|
5
|
+
dedupeDataImports,
|
|
6
|
+
dedupeEdges,
|
|
7
|
+
dedupeNodes,
|
|
8
|
+
escapeRegExp,
|
|
9
|
+
hasWord,
|
|
10
|
+
isBackendSpecifier,
|
|
11
|
+
isDataSpecifier,
|
|
12
|
+
modulePathKey,
|
|
13
|
+
} from "./shared.mjs";
|
|
14
|
+
import { resolveImportToSourceFile } from "./fs-walk.mjs";
|
|
15
|
+
|
|
16
|
+
export function extractBackendImports({
|
|
17
|
+
serviceName,
|
|
18
|
+
serviceRoot,
|
|
19
|
+
filePath,
|
|
20
|
+
content,
|
|
21
|
+
resolveImport = resolveImportToSourceFile,
|
|
22
|
+
resolveImportToSourceFile: resolveImportAlias,
|
|
23
|
+
}) {
|
|
24
|
+
const resolveImportFn = resolveImportAlias || resolveImport;
|
|
25
|
+
const imports = [];
|
|
26
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'`]([^"'`]+)["'`]/gu;
|
|
27
|
+
for (const match of content.matchAll(importRegex)) {
|
|
28
|
+
const rawNames = match[1]
|
|
29
|
+
.split(",")
|
|
30
|
+
.map((entry) => entry.trim())
|
|
31
|
+
.map((entry) => entry.split(/\s+as\s+/u)[0]?.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
const specifier = match[2].trim();
|
|
34
|
+
if (!isBackendSpecifier(specifier)) continue;
|
|
35
|
+
const resolvedFilePath = resolveImportFn(serviceRoot, filePath, specifier);
|
|
36
|
+
for (const importName of rawNames) {
|
|
37
|
+
imports.push({
|
|
38
|
+
importName,
|
|
39
|
+
node: {
|
|
40
|
+
id: `server_capability:${modulePathKey(resolvedFilePath || filePath)}#${importName}`,
|
|
41
|
+
kind: "server_capability",
|
|
42
|
+
service: serviceName,
|
|
43
|
+
label: importName,
|
|
44
|
+
filePath: resolvedFilePath || null,
|
|
45
|
+
metadata: {
|
|
46
|
+
specifier,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return dedupeBackendImports(imports);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function discoverDataCapabilities({ serviceName, serviceRoot, nodes, readFile = defaultReadFile }) {
|
|
56
|
+
const dataNodes = [];
|
|
57
|
+
const dataEdges = [];
|
|
58
|
+
const serverCapabilities = nodes.filter((node) => node.kind === "server_capability" && node.filePath);
|
|
59
|
+
|
|
60
|
+
for (const capability of serverCapabilities) {
|
|
61
|
+
const absolutePath = path.join(serviceRoot, capability.filePath);
|
|
62
|
+
const content = readFile(absolutePath);
|
|
63
|
+
if (!content) continue;
|
|
64
|
+
const dataImports = extractDataImports({ serviceName, serviceRoot, filePath: capability.filePath, content });
|
|
65
|
+
const exportedBody = extractExportedFunctionBody(content, capability.label);
|
|
66
|
+
if (!exportedBody) continue;
|
|
67
|
+
|
|
68
|
+
const matchedDataImports = dataImports.filter((entry) => hasWord(exportedBody, entry.importName));
|
|
69
|
+
for (const dataImport of matchedDataImports) {
|
|
70
|
+
dataNodes.push(dataImport.node);
|
|
71
|
+
dataEdges.push({
|
|
72
|
+
id: `delegates_to:${capability.id}:${dataImport.node.id}`,
|
|
73
|
+
kind: "delegates_to",
|
|
74
|
+
from: capability.id,
|
|
75
|
+
to: dataImport.node.id,
|
|
76
|
+
confidence: "high",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
nodes: dedupeNodes(dataNodes),
|
|
83
|
+
edges: dedupeEdges(dataEdges),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function extractDataImports({
|
|
88
|
+
serviceName,
|
|
89
|
+
serviceRoot,
|
|
90
|
+
filePath,
|
|
91
|
+
content,
|
|
92
|
+
resolveImport = resolveImportToSourceFile,
|
|
93
|
+
resolveImportToSourceFile: resolveImportAlias,
|
|
94
|
+
}) {
|
|
95
|
+
const resolveImportFn = resolveImportAlias || resolveImport;
|
|
96
|
+
const imports = [];
|
|
97
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'`]([^"'`]+)["'`]/gu;
|
|
98
|
+
for (const match of content.matchAll(importRegex)) {
|
|
99
|
+
const rawNames = match[1]
|
|
100
|
+
.split(",")
|
|
101
|
+
.map((entry) => entry.trim())
|
|
102
|
+
.map((entry) => entry.split(/\s+as\s+/u)[0]?.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
const specifier = match[2].trim();
|
|
105
|
+
if (!isDataSpecifier(specifier)) continue;
|
|
106
|
+
const resolvedFilePath = resolveImportFn(serviceRoot, filePath, specifier);
|
|
107
|
+
for (const importName of rawNames) {
|
|
108
|
+
imports.push({
|
|
109
|
+
importName,
|
|
110
|
+
node: {
|
|
111
|
+
id: `data_capability:${modulePathKey(resolvedFilePath || filePath)}#${importName}`,
|
|
112
|
+
kind: "data_capability",
|
|
113
|
+
service: serviceName,
|
|
114
|
+
label: importName,
|
|
115
|
+
filePath: resolvedFilePath || null,
|
|
116
|
+
metadata: {
|
|
117
|
+
specifier,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return dedupeDataImports(imports);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function extractExportedMethodBodies(content, methods) {
|
|
127
|
+
const bodies = [];
|
|
128
|
+
for (const method of methods) {
|
|
129
|
+
const body = extractExportedFunctionBody(content, method);
|
|
130
|
+
if (body) bodies.push([method, body]);
|
|
131
|
+
}
|
|
132
|
+
return bodies;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function extractExportedFunctions(content) {
|
|
136
|
+
const exported = [];
|
|
137
|
+
const functionRegex = /export\s+(?:async\s+)?function\s+([A-Za-z0-9_]+)\s*\(/gu;
|
|
138
|
+
for (const match of content.matchAll(functionRegex)) {
|
|
139
|
+
const name = match[1];
|
|
140
|
+
const body = extractExportedFunctionBody(content, name);
|
|
141
|
+
if (!body) continue;
|
|
142
|
+
exported.push({ name, body });
|
|
143
|
+
}
|
|
144
|
+
return exported;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function extractExportedFunctionBody(content, exportName) {
|
|
148
|
+
const functionStart = new RegExp(`export\\s+(?:async\\s+)?function\\s+${escapeRegExp(exportName)}\\s*\\(`, "u");
|
|
149
|
+
const startMatch = functionStart.exec(content);
|
|
150
|
+
if (!startMatch) return null;
|
|
151
|
+
const afterSignatureIndex = content.indexOf("{", startMatch.index);
|
|
152
|
+
if (afterSignatureIndex === -1) return null;
|
|
153
|
+
return readBalancedBlock(content, afterSignatureIndex);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function readBalancedBlock(content, startIndex) {
|
|
157
|
+
let depth = 0;
|
|
158
|
+
let inSingle = false;
|
|
159
|
+
let inDouble = false;
|
|
160
|
+
let inTemplate = false;
|
|
161
|
+
|
|
162
|
+
for (let index = startIndex; index < content.length; index += 1) {
|
|
163
|
+
const char = content[index];
|
|
164
|
+
const previous = content[index - 1];
|
|
165
|
+
if (char === "'" && !inDouble && !inTemplate && previous !== "\\") inSingle = !inSingle;
|
|
166
|
+
if (char === '"' && !inSingle && !inTemplate && previous !== "\\") inDouble = !inDouble;
|
|
167
|
+
if (char === "`" && !inSingle && !inDouble && previous !== "\\") inTemplate = !inTemplate;
|
|
168
|
+
if (inSingle || inDouble || inTemplate) continue;
|
|
169
|
+
if (char === "{") depth += 1;
|
|
170
|
+
if (char === "}") {
|
|
171
|
+
depth -= 1;
|
|
172
|
+
if (depth === 0) {
|
|
173
|
+
return content.slice(startIndex, index + 1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function defaultReadFile(filePath) {
|
|
181
|
+
if (!fs.existsSync(filePath)) return null;
|
|
182
|
+
return fs.readFileSync(filePath, "utf8");
|
|
183
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
extractExportedFunctionBody,
|
|
4
|
+
extractExportedFunctions,
|
|
5
|
+
extractExportedMethodBodies,
|
|
6
|
+
readBalancedBlock,
|
|
7
|
+
} from "./backend-discovery.mjs";
|
|
8
|
+
|
|
9
|
+
describe("coverage backend discovery helpers", () => {
|
|
10
|
+
it("extracts exported HTTP method bodies without being confused by braces in strings", () => {
|
|
11
|
+
const content = `
|
|
12
|
+
export async function GET() {
|
|
13
|
+
const template = "{not a block}";
|
|
14
|
+
return template;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function POST() {
|
|
18
|
+
return { ok: true };
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
expect(extractExportedMethodBodies(content, ["GET", "POST"])).toEqual([
|
|
23
|
+
["GET", expect.stringContaining('"{not a block}"')],
|
|
24
|
+
["POST", expect.stringContaining("{ ok: true }")],
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("extracts named exported functions and their bodies", () => {
|
|
29
|
+
const content = `
|
|
30
|
+
export async function saveSettings() {
|
|
31
|
+
return updateSettings();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listSettings() {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
expect(extractExportedFunctions(content)).toEqual([
|
|
40
|
+
{ name: "saveSettings", body: expect.stringContaining("updateSettings") },
|
|
41
|
+
{ name: "listSettings", body: expect.stringContaining("return [];") },
|
|
42
|
+
]);
|
|
43
|
+
expect(extractExportedFunctionBody(content, "saveSettings")).toContain("updateSettings");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("reads a balanced block from a brace offset", () => {
|
|
47
|
+
const content = `before { const tpl = \`{nested}\`; return tpl; } after`;
|
|
48
|
+
const startIndex = content.indexOf("{");
|
|
49
|
+
|
|
50
|
+
expect(readBalancedBlock(content, startIndex)).toBe("{ const tpl = `{nested}`; return tpl; }");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { extractHttpSuiteRequests } from "./next-static-analysis.mjs";
|
|
4
|
+
import {
|
|
5
|
+
apiRouteLookupKey,
|
|
6
|
+
HTTP_METHODS,
|
|
7
|
+
dedupeTargets,
|
|
8
|
+
toApiRequestPath,
|
|
9
|
+
toSelectionType,
|
|
10
|
+
} from "./shared.mjs";
|
|
11
|
+
import {
|
|
12
|
+
inferApiRoutesFromTestFile,
|
|
13
|
+
inferOwnerDirectoryFromTestFile,
|
|
14
|
+
inferPageRouteFromTestFile,
|
|
15
|
+
pathMatchesOwner,
|
|
16
|
+
} from "./routing.mjs";
|
|
17
|
+
|
|
18
|
+
export function inferCoveredNodeIdsForTest(entry, context) {
|
|
19
|
+
const coveredNodeIds = new Set();
|
|
20
|
+
const selectionType = toSelectionType(entry.type, entry.framework);
|
|
21
|
+
|
|
22
|
+
if (entry.framework === "playwright") {
|
|
23
|
+
const route = inferPageRouteFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
|
|
24
|
+
if (route && context.pageByRoute.has(route)) {
|
|
25
|
+
const pageEntry = context.pageByRoute.get(route);
|
|
26
|
+
coveredNodeIds.add(pageEntry.node.id);
|
|
27
|
+
for (const target of extractPlaywrightTargets(entry, context)) {
|
|
28
|
+
const surfaceNode = pageEntry.surfacesByTargetValue?.get(target.value);
|
|
29
|
+
if (surfaceNode) coveredNodeIds.add(surfaceNode.id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (selectionType === "int") {
|
|
35
|
+
const explicitRequests = extractHttpSuiteRequestsFromTestFile(entry, context);
|
|
36
|
+
if (explicitRequests.length > 0) {
|
|
37
|
+
for (const request of explicitRequests) {
|
|
38
|
+
const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(request.method, request.path));
|
|
39
|
+
if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
const apiRoutes = inferApiRoutesFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot);
|
|
43
|
+
for (const route of apiRoutes) {
|
|
44
|
+
for (const method of HTTP_METHODS) {
|
|
45
|
+
const routeEntry = context.apiRouteByKey.get(apiRouteLookupKey(method, toApiRequestPath(route)));
|
|
46
|
+
if (routeEntry) coveredNodeIds.add(routeEntry.node.id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (selectionType === "dal") {
|
|
53
|
+
for (const nodeId of inferDataCapabilitiesFromTestFile(entry, context)) {
|
|
54
|
+
coveredNodeIds.add(nodeId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [...coveredNodeIds].sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildEvidenceDetails(coveredNodeIds, graph, entry, context) {
|
|
62
|
+
const routes = new Set();
|
|
63
|
+
const requestPaths = new Set();
|
|
64
|
+
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
65
|
+
for (const nodeId of coveredNodeIds) {
|
|
66
|
+
const node = nodeById.get(nodeId);
|
|
67
|
+
if (!node) continue;
|
|
68
|
+
if (node.route) routes.add(node.route);
|
|
69
|
+
if (node.path) requestPaths.add(node.path);
|
|
70
|
+
}
|
|
71
|
+
return buildEvidenceDetailsFromTargets({
|
|
72
|
+
routes: [...routes].sort(),
|
|
73
|
+
requestPaths: [...requestPaths].sort(),
|
|
74
|
+
targets: extractPlaywrightTargets(entry, context),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildEvidenceDetailsFromTargets({ routes = [], requestPaths = [], targets = [] }) {
|
|
79
|
+
const details = {};
|
|
80
|
+
if (routes.length === 1) {
|
|
81
|
+
details.route = routes[0];
|
|
82
|
+
}
|
|
83
|
+
if (requestPaths.length > 0) {
|
|
84
|
+
details.requestPaths = [...requestPaths].sort();
|
|
85
|
+
}
|
|
86
|
+
if (targets.length > 0) {
|
|
87
|
+
details.targets = dedupeTargets(targets);
|
|
88
|
+
}
|
|
89
|
+
return Object.keys(details).length > 0 ? details : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function extractPlaywrightTargets(entry, context) {
|
|
93
|
+
if (!entry || entry.framework !== "playwright" || !context?.serviceRoot) return [];
|
|
94
|
+
const absolutePath = path.join(context.serviceRoot, entry.filePath);
|
|
95
|
+
if (!fs.existsSync(absolutePath)) return [];
|
|
96
|
+
return extractPlaywrightTargetsFromContent(fs.readFileSync(absolutePath, "utf8"));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function extractPlaywrightTargetsFromContent(content) {
|
|
100
|
+
const targets = [];
|
|
101
|
+
|
|
102
|
+
for (const match of content.matchAll(/\bgetByTestId\(\s*["'`]([^"'`]+)["'`]\s*\)/gu)) {
|
|
103
|
+
targets.push({
|
|
104
|
+
kind: "testId",
|
|
105
|
+
value: match[1],
|
|
106
|
+
confidence: "high",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const match of content.matchAll(/\blocator\(\s*["'`]\[data-testid=(?:"|')([^"'`\]]+)(?:"|')\]["'`]\s*\)/gu)) {
|
|
111
|
+
targets.push({
|
|
112
|
+
kind: "testId",
|
|
113
|
+
value: match[1],
|
|
114
|
+
confidence: "medium",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return dedupeTargets(targets);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function extractHttpSuiteRequestsFromTestFile(entry, context) {
|
|
122
|
+
if (!entry || toSelectionType(entry.type, entry.framework) !== "int" || !context?.serviceRoot) return [];
|
|
123
|
+
const absolutePath = path.join(context.serviceRoot, entry.filePath);
|
|
124
|
+
if (!fs.existsSync(absolutePath)) return [];
|
|
125
|
+
return extractHttpSuiteRequestsFromContent(fs.readFileSync(absolutePath, "utf8"), entry.filePath);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function extractHttpSuiteRequestsFromContent(content, filePath = "unknown") {
|
|
129
|
+
return extractHttpSuiteRequests(content, filePath)
|
|
130
|
+
.filter((request) => request.path.startsWith("/api/"))
|
|
131
|
+
.sort((left, right) => `${left.method}:${left.path}`.localeCompare(`${right.method}:${right.path}`));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function inferDataCapabilitiesFromTestFile(entry, context) {
|
|
135
|
+
if (!entry || toSelectionType(entry.type, entry.framework) !== "dal") return [];
|
|
136
|
+
const ownerDirectory = inferOwnerDirectoryFromTestFile(entry.filePath);
|
|
137
|
+
if (!ownerDirectory) return [];
|
|
138
|
+
return inferDataCapabilitiesFromOwner(ownerDirectory, context.dataCapabilities);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function inferDataCapabilitiesFromOwner(ownerDirectory, dataCapabilities = []) {
|
|
142
|
+
return dataCapabilities
|
|
143
|
+
.filter((node) => node.filePath && pathMatchesOwner(node.filePath, ownerDirectory))
|
|
144
|
+
.map((node) => node.id)
|
|
145
|
+
.sort();
|
|
146
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildEvidenceDetailsFromTargets,
|
|
4
|
+
extractHttpSuiteRequestsFromContent,
|
|
5
|
+
extractPlaywrightTargetsFromContent,
|
|
6
|
+
inferDataCapabilitiesFromOwner,
|
|
7
|
+
} from "./evidence.mjs";
|
|
8
|
+
|
|
9
|
+
describe("coverage evidence helpers", () => {
|
|
10
|
+
it("extracts and dedupes Playwright targets from source content", () => {
|
|
11
|
+
const content = `
|
|
12
|
+
await page.getByTestId("save-button").click();
|
|
13
|
+
await page.locator('[data-testid="save-button"]').click();
|
|
14
|
+
await page.getByTestId("drawer").click();
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
expect(extractPlaywrightTargetsFromContent(content)).toEqual([
|
|
18
|
+
{ kind: "testId", value: "save-button", confidence: "high" },
|
|
19
|
+
{ kind: "testId", value: "drawer", confidence: "high" },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("extracts API requests from integration suite source content", () => {
|
|
24
|
+
const content = `
|
|
25
|
+
export default defineHttpSuite(({ rawReq }) => {
|
|
26
|
+
rawReq("GET", "/api/campaigns");
|
|
27
|
+
rawReq("POST", "/api/campaigns");
|
|
28
|
+
rawReq("GET", "/health");
|
|
29
|
+
});
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
expect(extractHttpSuiteRequestsFromContent(content, "campaigns.int.testkit.ts")).toEqual([
|
|
33
|
+
{ method: "GET", path: "/api/campaigns", confidence: "high" },
|
|
34
|
+
{ method: "POST", path: "/api/campaigns", confidence: "high" },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("builds compact evidence details only when meaningful signals exist", () => {
|
|
39
|
+
expect(
|
|
40
|
+
buildEvidenceDetailsFromTargets({
|
|
41
|
+
routes: ["/campaigns"],
|
|
42
|
+
requestPaths: ["/api/campaigns"],
|
|
43
|
+
targets: [{ kind: "testId", value: "save-button", confidence: "high" }],
|
|
44
|
+
})
|
|
45
|
+
).toEqual({
|
|
46
|
+
route: "/campaigns",
|
|
47
|
+
requestPaths: ["/api/campaigns"],
|
|
48
|
+
targets: [{ kind: "testId", value: "save-button", confidence: "high" }],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(buildEvidenceDetailsFromTargets({ routes: [], requestPaths: [], targets: [] })).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("infers DAL coverage from owner directories", () => {
|
|
55
|
+
const dataCapabilities = [
|
|
56
|
+
{ id: "data_capability:src/backend/data/campaigns#saveCampaignRow", filePath: "src/backend/data/campaigns/index.ts" },
|
|
57
|
+
{ id: "data_capability:src/backend/data/users#saveUserRow", filePath: "src/backend/data/users/index.ts" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
expect(inferDataCapabilitiesFromOwner("src/backend/data/campaigns", dataCapabilities)).toEqual([
|
|
61
|
+
"data_capability:src/backend/data/campaigns#saveCampaignRow",
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import {
|
|
4
|
+
normalizeDiscoveryPath,
|
|
5
|
+
shouldExcludeDiscoveryPath,
|
|
6
|
+
} from "../discovery/path-policy.mjs";
|
|
7
|
+
import { normalizePath } from "./shared.mjs";
|
|
8
|
+
import { resolveImportToSourceFile as resolveImportToSourceFileFromAst } from "./next-static-analysis.mjs";
|
|
9
|
+
|
|
10
|
+
export function findNextAppRoot(serviceRoot) {
|
|
11
|
+
const candidates = [path.join(serviceRoot, "app"), path.join(serviceRoot, "src", "app")];
|
|
12
|
+
return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveServiceRoot(productDir, config) {
|
|
16
|
+
const cwd = config?.local?.cwd || ".";
|
|
17
|
+
return path.resolve(productDir, cwd);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function walkFiles(rootDir, options = {}) {
|
|
21
|
+
if (!fs.existsSync(rootDir)) return [];
|
|
22
|
+
const baseDir = options.baseDir || rootDir;
|
|
23
|
+
const exclude = options.exclude || [];
|
|
24
|
+
const results = [];
|
|
25
|
+
const queue = [rootDir];
|
|
26
|
+
while (queue.length > 0) {
|
|
27
|
+
const current = queue.pop();
|
|
28
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
29
|
+
if (entry.isSymbolicLink()) continue;
|
|
30
|
+
const absolutePath = path.join(current, entry.name);
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
const relativeDirPath = normalizeDiscoveryPath(path.relative(baseDir, absolutePath));
|
|
33
|
+
if (shouldExcludeDiscoveryPath(relativeDirPath, exclude)) continue;
|
|
34
|
+
queue.push(absolutePath);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (entry.isFile()) {
|
|
38
|
+
results.push(absolutePath);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return results.sort((left, right) => left.localeCompare(right));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveImportToSourceFile(serviceRoot, fromFilePath, specifier) {
|
|
46
|
+
return resolveImportToSourceFileFromAst(serviceRoot, fromFilePath, specifier);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveSourceCandidate(basePath) {
|
|
50
|
+
const direct = [basePath, `${basePath}.ts`, `${basePath}.tsx`, `${basePath}.js`, `${basePath}.mjs`];
|
|
51
|
+
for (const candidate of direct) {
|
|
52
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
53
|
+
}
|
|
54
|
+
const indexed = [
|
|
55
|
+
path.join(basePath, "index.ts"),
|
|
56
|
+
path.join(basePath, "index.tsx"),
|
|
57
|
+
path.join(basePath, "index.js"),
|
|
58
|
+
path.join(basePath, "index.mjs"),
|
|
59
|
+
];
|
|
60
|
+
for (const candidate of indexed) {
|
|
61
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|