@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.
Files changed (36) hide show
  1. package/lib/config/database.mjs +53 -0
  2. package/lib/config/database.test.mjs +29 -0
  3. package/lib/config/discovery-config.mjs +13 -0
  4. package/lib/config/env.mjs +55 -0
  5. package/lib/config/env.test.mjs +40 -0
  6. package/lib/config/index.mjs +21 -807
  7. package/lib/config/paths.mjs +28 -0
  8. package/lib/config/paths.test.mjs +27 -0
  9. package/lib/config/runtime.mjs +241 -0
  10. package/lib/config/runtime.test.mjs +56 -0
  11. package/lib/config/skip-config.mjs +189 -0
  12. package/lib/config/skip-config.test.mjs +63 -0
  13. package/lib/config/telemetry.mjs +28 -0
  14. package/lib/config/validation.mjs +124 -0
  15. package/lib/coverage/backend-discovery.mjs +183 -0
  16. package/lib/coverage/backend-discovery.test.mjs +52 -0
  17. package/lib/coverage/evidence.mjs +146 -0
  18. package/lib/coverage/evidence.test.mjs +64 -0
  19. package/lib/coverage/fs-walk.mjs +64 -0
  20. package/lib/coverage/graph-builder.mjs +167 -0
  21. package/lib/coverage/index.mjs +1 -776
  22. package/lib/coverage/index.test.mjs +183 -14
  23. package/lib/coverage/next-discovery.mjs +174 -0
  24. package/lib/coverage/next-static-analysis.mjs +728 -0
  25. package/lib/coverage/routing.mjs +86 -0
  26. package/lib/coverage/routing.test.mjs +52 -0
  27. package/lib/coverage/shared.mjs +197 -0
  28. package/lib/coverage/shared.test.mjs +39 -0
  29. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  30. package/node_modules/@elench/testkit-bridge/src/index.mjs +101 -15
  31. package/node_modules/@elench/testkit-bridge/src/index.test.mjs +36 -6
  32. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  33. package/node_modules/@elench/testkit-protocol/src/index.d.ts +1 -0
  34. package/node_modules/@elench/testkit-protocol/src/index.mjs +3 -1
  35. package/node_modules/@elench/testkit-protocol/src/index.test.mjs +14 -0
  36. package/package.json +5 -4
@@ -13,7 +13,7 @@ afterEach(() => {
13
13
  });
14
14
 
15
15
  describe("coverage graph builder", () => {
16
- it("builds a Next page -> request -> API route -> server capability graph", () => {
16
+ it("builds a Next page -> surface -> action -> request -> API route -> backend/data capability graph", () => {
17
17
  const productDir = createProduct();
18
18
  writeFile(
19
19
  productDir,
@@ -38,15 +38,35 @@ describe("coverage graph builder", () => {
38
38
  "src/app/campaigns/page.tsx",
39
39
  `
40
40
  "use client";
41
- import { getJson } from "@/client/http/http";
41
+ import { getJson, postJson } from "@/client/http/http";
42
42
 
43
- export default async function CampaignsPage() {
43
+ async function refreshCampaigns() {
44
44
  await getJson("/api/campaigns");
45
- return null;
45
+ }
46
+
47
+ async function createCampaign() {
48
+ await postJson("/api/campaigns");
49
+ }
50
+
51
+ export default function CampaignsPage() {
52
+ return (
53
+ <main>
54
+ <button data-testid="campaign-refresh-button" onClick={() => refreshCampaigns()}>
55
+ Refresh campaigns
56
+ </button>
57
+ <button data-testid="campaign-create-button" onClick={createCampaign}>
58
+ Create campaign
59
+ </button>
60
+ </main>
61
+ );
46
62
  }
47
63
  `
48
64
  );
49
- writeFile(productDir, "src/client/http/http.ts", `export function getJson() { return null; }`);
65
+ writeFile(
66
+ productDir,
67
+ "src/client/http/http.ts",
68
+ `export function getJson() { return null; }\nexport function postJson() { return null; }`
69
+ );
50
70
  writeFile(
51
71
  productDir,
52
72
  "src/app/api/campaigns/route.ts",
@@ -62,7 +82,26 @@ describe("coverage graph builder", () => {
62
82
  }
63
83
  `
64
84
  );
65
- writeFile(productDir, "src/backend/server/campaigns/index.ts", `export async function listCampaigns() {}\nexport async function createCampaign() {}`);
85
+ writeFile(
86
+ productDir,
87
+ "src/backend/server/campaigns/index.ts",
88
+ `
89
+ import { insertCampaignRow, selectCampaignRows } from "@/backend/data/campaigns";
90
+
91
+ export async function listCampaigns() {
92
+ return selectCampaignRows();
93
+ }
94
+
95
+ export async function createCampaign() {
96
+ return insertCampaignRow();
97
+ }
98
+ `
99
+ );
100
+ writeFile(
101
+ productDir,
102
+ "src/backend/data/campaigns/index.ts",
103
+ `export async function selectCampaignRows() {}\nexport async function insertCampaignRow() {}`
104
+ );
66
105
 
67
106
  const context = buildServiceCoverageContext(productDir, "web", {
68
107
  local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
@@ -73,6 +112,10 @@ describe("coverage graph builder", () => {
73
112
  route: "/campaigns",
74
113
  filePath: "src/app/campaigns/page.tsx",
75
114
  });
115
+ expect(context.pageByRoute.get("/campaigns").surfacesByTargetValue.get("campaign-refresh-button")).toMatchObject({
116
+ kind: "ui_surface",
117
+ label: "Refresh campaigns",
118
+ });
76
119
  expect(context.apiRouteByKey.get("GET:/api/campaigns").node).toMatchObject({
77
120
  kind: "api_route",
78
121
  route: "/campaigns",
@@ -83,8 +126,18 @@ describe("coverage graph builder", () => {
83
126
  expect(context.graph.edges).toEqual(
84
127
  expect.arrayContaining([
85
128
  expect.objectContaining({
86
- kind: "requests",
129
+ kind: "contains",
87
130
  from: "page_view:web:/campaigns",
131
+ to: "ui_surface:web:/campaigns:campaign-refresh-button",
132
+ }),
133
+ expect.objectContaining({
134
+ kind: "triggers",
135
+ from: "ui_surface:web:/campaigns:campaign-refresh-button",
136
+ to: expect.stringContaining("ui_action:web:/campaigns"),
137
+ }),
138
+ expect.objectContaining({
139
+ kind: "requests",
140
+ from: expect.stringContaining("ui_action:web:/campaigns"),
88
141
  }),
89
142
  expect.objectContaining({
90
143
  kind: "handles",
@@ -94,6 +147,11 @@ describe("coverage graph builder", () => {
94
147
  kind: "delegates_to",
95
148
  from: "api_route:web:GET:/api/campaigns",
96
149
  }),
150
+ expect.objectContaining({
151
+ kind: "delegates_to",
152
+ from: "server_capability:src/backend/server/campaigns#createCampaign",
153
+ to: "data_capability:src/backend/data/campaigns#insertCampaignRow",
154
+ }),
97
155
  ])
98
156
  );
99
157
  });
@@ -107,8 +165,13 @@ describe("coverage graph builder", () => {
107
165
  import { saveSettings } from "./actions";
108
166
 
109
167
  export default function SettingsPage() {
110
- void saveSettings;
111
- return null;
168
+ return (
169
+ <form action={saveSettings}>
170
+ <button data-testid="settings-save-button" type="submit">
171
+ Save settings
172
+ </button>
173
+ </form>
174
+ );
112
175
  }
113
176
  `
114
177
  );
@@ -138,7 +201,12 @@ describe("coverage graph builder", () => {
138
201
  expect.arrayContaining([
139
202
  expect.objectContaining({
140
203
  kind: "triggers",
141
- from: "page_view:web:/settings",
204
+ from: expect.stringContaining("ui_surface:web:/settings"),
205
+ to: "ui_action:web:/settings:saveSettings",
206
+ }),
207
+ expect.objectContaining({
208
+ kind: "triggers",
209
+ from: "ui_action:web:/settings:saveSettings",
142
210
  to: "server_action:web:src/app/settings/actions.ts#saveSettings",
143
211
  }),
144
212
  expect.objectContaining({
@@ -149,10 +217,86 @@ describe("coverage graph builder", () => {
149
217
  );
150
218
  });
151
219
 
152
- it("attaches convention-based test evidence to page and route nodes", () => {
220
+ it("attaches convention-based test evidence to page, surface, route, and data nodes", () => {
153
221
  const productDir = createProduct();
154
- writeFile(productDir, "src/app/campaigns/page.tsx", `export default function CampaignsPage() { return null; }`);
155
- writeFile(productDir, "src/app/api/campaigns/route.ts", `export async function GET() { return Response.json({ ok: true }); }`);
222
+ writeFile(
223
+ productDir,
224
+ "src/app/campaigns/page.tsx",
225
+ `
226
+ "use client";
227
+ import { getJson } from "@/client/http/http";
228
+
229
+ async function loadCampaigns() {
230
+ await getJson("/api/campaigns");
231
+ }
232
+
233
+ export default function CampaignsPage() {
234
+ return <button data-testid="campaign-save-button" onClick={loadCampaigns}>Save campaign</button>;
235
+ }
236
+ `
237
+ );
238
+ writeFile(productDir, "src/client/http/http.ts", `export async function getJson() { return null; }`);
239
+ writeFile(
240
+ productDir,
241
+ "src/app/api/campaigns/route.ts",
242
+ `
243
+ import { listCampaigns } from "@/backend/server/campaigns";
244
+
245
+ export async function GET() {
246
+ return listCampaigns();
247
+ }
248
+ `
249
+ );
250
+ writeFile(
251
+ productDir,
252
+ "src/backend/server/campaigns/index.ts",
253
+ `
254
+ import { saveCampaignRow } from "@/backend/data/campaigns";
255
+
256
+ export async function listCampaigns() {
257
+ return saveCampaignRow();
258
+ }
259
+ `
260
+ );
261
+ writeFile(productDir, "src/backend/data/campaigns/index.ts", `export async function saveCampaignRow() {}`);
262
+ writeFile(
263
+ productDir,
264
+ "src/app/campaigns/__testkit__/campaigns.pw.testkit.ts",
265
+ `
266
+ import { expect, test } from "@playwright/test";
267
+
268
+ test("campaigns route", async ({ page }) => {
269
+ await page.goto("/campaigns");
270
+ await expect(page.getByTestId("campaign-save-button")).toBeVisible();
271
+ });
272
+ `
273
+ );
274
+ writeFile(
275
+ productDir,
276
+ "src/app/api/campaigns/__testkit__/campaigns.int.testkit.ts",
277
+ `
278
+ import { defineHttpSuite } from "@elench/testkit";
279
+
280
+ const suite = defineHttpSuite(({ rawReq }) => {
281
+ rawReq("GET", "/api/campaigns");
282
+ });
283
+
284
+ export default suite;
285
+ `
286
+ );
287
+ writeFile(
288
+ productDir,
289
+ "src/backend/data/campaigns/__testkit__/campaigns.dal.testkit.ts",
290
+ `
291
+ import { defineDalSuite } from "@elench/testkit";
292
+
293
+ const suite = defineDalSuite(({ db }) => {
294
+ db.query("SELECT 1 AS value");
295
+ });
296
+
297
+ export default suite;
298
+ `
299
+ );
156
300
 
157
301
  const graph = buildCoverageGraph({
158
302
  productDir,
@@ -176,6 +320,13 @@ describe("coverage graph builder", () => {
176
320
  suiteName: "campaigns",
177
321
  filePath: "src/app/api/campaigns/__testkit__/campaigns.int.testkit.ts",
178
322
  },
323
+ {
324
+ serviceName: "web",
325
+ type: "dal",
326
+ framework: "k6",
327
+ suiteName: "campaigns",
328
+ filePath: "src/backend/data/campaigns/__testkit__/campaigns.dal.testkit.ts",
329
+ },
179
330
  ],
180
331
  });
181
332
 
@@ -183,12 +334,30 @@ describe("coverage graph builder", () => {
183
334
  expect.arrayContaining([
184
335
  expect.objectContaining({
185
336
  testFilePath: "src/app/campaigns/__testkit__/campaigns.pw.testkit.ts",
186
- coveredNodeIds: ["page_view:web:/campaigns"],
337
+ coveredNodeIds: expect.arrayContaining([
338
+ "page_view:web:/campaigns",
339
+ "ui_surface:web:/campaigns:campaign-save-button",
340
+ ]),
341
+ details: expect.objectContaining({
342
+ route: "/campaigns",
343
+ targets: [
344
+ expect.objectContaining({
345
+ kind: "testId",
346
+ value: "campaign-save-button",
347
+ }),
348
+ ],
349
+ }),
187
350
  }),
188
351
  expect.objectContaining({
189
352
  testFilePath: "src/app/api/campaigns/__testkit__/campaigns.int.testkit.ts",
190
353
  coveredNodeIds: ["api_route:web:GET:/api/campaigns"],
191
354
  }),
355
+ expect.objectContaining({
356
+ testFilePath: "src/backend/data/campaigns/__testkit__/campaigns.dal.testkit.ts",
357
+ coveredNodeIds: expect.arrayContaining([
358
+ expect.stringContaining("data_capability:src/backend/data/campaigns"),
359
+ ]),
360
+ }),
192
361
  ])
193
362
  );
194
363
  });
@@ -0,0 +1,174 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { analyzeNextPageFile } from "./next-static-analysis.mjs";
4
+ import { extractBackendImports, extractExportedFunctions, extractExportedMethodBodies } from "./backend-discovery.mjs";
5
+ import { resolveImportToSourceFile, walkFiles } from "./fs-walk.mjs";
6
+ import { routeFromApiFile, routeFromAppFile } from "./routing.mjs";
7
+ import {
8
+ dedupeNodes,
9
+ hasWord,
10
+ HTTP_METHODS,
11
+ isServerActionFile,
12
+ normalizePath,
13
+ normalizeRoute,
14
+ pageLabelFromRoute,
15
+ toApiRequestPath,
16
+ } from "./shared.mjs";
17
+
18
+ export function discoverPageViews({ serviceName, serviceRoot, nextAppRoot, exclude = [], resolveImportToSourceFile }) {
19
+ const pageFiles = walkFiles(nextAppRoot, { baseDir: serviceRoot, exclude }).filter(
20
+ (filePath) => filePath.endsWith("/page.tsx") || filePath.endsWith("/page.ts")
21
+ );
22
+ const nodes = [];
23
+ const edges = [];
24
+ const pageEntries = [];
25
+
26
+ for (const absolutePath of pageFiles) {
27
+ const relativePath = normalizePath(path.relative(serviceRoot, absolutePath));
28
+ const route = routeFromAppFile(nextAppRoot, absolutePath);
29
+ const content = fs.readFileSync(absolutePath, "utf8");
30
+ const node = {
31
+ id: `page_view:${serviceName}:${route}`,
32
+ kind: "page_view",
33
+ service: serviceName,
34
+ label: pageLabelFromRoute(route),
35
+ route,
36
+ filePath: relativePath,
37
+ };
38
+ nodes.push(node);
39
+
40
+ const pageAnalysis = analyzeNextPageFile({
41
+ serviceName,
42
+ serviceRoot,
43
+ filePath: relativePath,
44
+ route,
45
+ content,
46
+ readSourceFile: (resolvedPath) => {
47
+ const absoluteResolved = path.join(serviceRoot, resolvedPath);
48
+ return fs.existsSync(absoluteResolved) ? fs.readFileSync(absoluteResolved, "utf8") : null;
49
+ },
50
+ resolveImportPath: (specifier) => resolveImportToSourceFile(serviceRoot, relativePath, specifier),
51
+ isServerActionFile,
52
+ normalizeRoute,
53
+ });
54
+
55
+ for (const analysisNode of pageAnalysis.nodes) nodes.push(analysisNode);
56
+ for (const analysisEdge of pageAnalysis.edges) edges.push(analysisEdge);
57
+
58
+ pageEntries.push({
59
+ node,
60
+ route,
61
+ filePath: relativePath,
62
+ requests: pageAnalysis.requests,
63
+ serverActionRefs: pageAnalysis.serverActionRefs,
64
+ surfacesByTargetValue: pageAnalysis.surfacesByTargetValue,
65
+ });
66
+ }
67
+
68
+ return { nodes, edges, pageEntries };
69
+ }
70
+
71
+ export function discoverApiRoutes({ serviceName, serviceRoot, nextAppRoot, exclude = [], resolveImportToSourceFile }) {
72
+ const routeFiles = walkFiles(path.join(nextAppRoot, "api"), { baseDir: serviceRoot, exclude }).filter(
73
+ (filePath) => filePath.endsWith("/route.ts") || filePath.endsWith("/route.tsx") || filePath.endsWith("/route.js")
74
+ );
75
+ const nodes = [];
76
+ const edges = [];
77
+ const routeEntries = [];
78
+
79
+ for (const absolutePath of routeFiles) {
80
+ const filePath = normalizePath(path.relative(serviceRoot, absolutePath));
81
+ const route = routeFromApiFile(nextAppRoot, absolutePath);
82
+ const requestPath = toApiRequestPath(route);
83
+ const content = fs.readFileSync(absolutePath, "utf8");
84
+ const methodBodies = extractExportedMethodBodies(content, HTTP_METHODS);
85
+ const backendImports = extractBackendImports({ serviceName, serviceRoot, filePath, content, resolveImportToSourceFile });
86
+
87
+ for (const [method, body] of methodBodies) {
88
+ const node = {
89
+ id: `api_route:${serviceName}:${method}:${requestPath}`,
90
+ kind: "api_route",
91
+ service: serviceName,
92
+ label: `${method} ${requestPath}`,
93
+ route,
94
+ method,
95
+ path: requestPath,
96
+ filePath,
97
+ };
98
+ nodes.push(node);
99
+
100
+ const matchedCapabilities = backendImports.filter((entry) => hasWord(body, entry.importName));
101
+ for (const capability of matchedCapabilities) {
102
+ nodes.push(capability.node);
103
+ edges.push({
104
+ id: `delegates_to:${node.id}:${capability.node.id}`,
105
+ kind: "delegates_to",
106
+ from: node.id,
107
+ to: capability.node.id,
108
+ confidence: "high",
109
+ });
110
+ }
111
+
112
+ routeEntries.push({ node, method, route, requestPath, filePath });
113
+ }
114
+ }
115
+
116
+ return { nodes: dedupeNodes(nodes), edges, routeEntries };
117
+ }
118
+
119
+ export function discoverServerActions({ serviceName, serviceRoot, exclude = [], resolveImportToSourceFile }) {
120
+ const appRoots = [path.join(serviceRoot, "app"), path.join(serviceRoot, "src", "app")].filter((candidate) =>
121
+ fs.existsSync(candidate)
122
+ );
123
+ const nodes = [];
124
+ const edges = [];
125
+ const actionEntries = [];
126
+
127
+ for (const appRoot of appRoots) {
128
+ for (const absolutePath of walkFiles(appRoot, { baseDir: serviceRoot, exclude })) {
129
+ if (!absolutePath.endsWith(".ts") && !absolutePath.endsWith(".tsx")) continue;
130
+ const content = fs.readFileSync(absolutePath, "utf8");
131
+ if (!isServerActionFile(content)) continue;
132
+
133
+ const relativePath = normalizePath(path.relative(serviceRoot, absolutePath));
134
+ const backendImports = extractBackendImports({
135
+ serviceName,
136
+ serviceRoot,
137
+ filePath: relativePath,
138
+ content,
139
+ resolveImportToSourceFile,
140
+ });
141
+ const exportedFunctions = extractExportedFunctions(content);
142
+
143
+ for (const exported of exportedFunctions) {
144
+ const node = {
145
+ id: `server_action:${serviceName}:${relativePath}#${exported.name}`,
146
+ kind: "server_action",
147
+ service: serviceName,
148
+ label: exported.name,
149
+ filePath: relativePath,
150
+ };
151
+ nodes.push(node);
152
+ actionEntries.push({
153
+ node,
154
+ exportName: exported.name,
155
+ sourceFile: relativePath,
156
+ });
157
+
158
+ const matchedCapabilities = backendImports.filter((entry) => hasWord(exported.body, entry.importName));
159
+ for (const capability of matchedCapabilities) {
160
+ nodes.push(capability.node);
161
+ edges.push({
162
+ id: `delegates_to:${node.id}:${capability.node.id}`,
163
+ kind: "delegates_to",
164
+ from: node.id,
165
+ to: capability.node.id,
166
+ confidence: "high",
167
+ });
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ return { nodes: dedupeNodes(nodes), edges, actionEntries };
174
+ }