@elench/testkit 0.1.62 → 0.1.63

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.
@@ -9,6 +9,7 @@ export const HTTP_WRAPPER_METHODS = {
9
9
  patchJson: "PATCH",
10
10
  deleteJson: "DELETE",
11
11
  };
12
+ export const DYNAMIC_SEGMENT_TOKEN = "__TESTKIT_DYNAMIC_SEGMENT__";
12
13
 
13
14
  export function createEmptyGraph() {
14
15
  return {
@@ -196,3 +197,69 @@ export function dedupeDataImports(entries) {
196
197
  return true;
197
198
  });
198
199
  }
200
+
201
+ export function findMatchingApiRouteEntry(method, candidatePath, routeEntries = []) {
202
+ const normalizedMethod = String(method || "").trim().toUpperCase();
203
+ const normalizedCandidate = normalizeRequestPathCandidate(candidatePath);
204
+ if (!normalizedMethod || !normalizedCandidate) return null;
205
+
206
+ return [...routeEntries]
207
+ .filter((entry) => entry.method === normalizedMethod)
208
+ .sort(compareRouteSpecificity)
209
+ .find((entry) => routePatternMatchesCandidate(entry.requestPath, normalizedCandidate)) || null;
210
+ }
211
+
212
+ export function findMatchingRouteValue(candidateRoute, routeValues = []) {
213
+ const normalizedCandidate = normalizeRequestPathCandidate(candidateRoute);
214
+ if (!normalizedCandidate) return null;
215
+
216
+ return [...routeValues]
217
+ .sort((left, right) => compareRouteSpecificity({ requestPath: left }, { requestPath: right }))
218
+ .find((routeValue) => routePatternMatchesCandidate(routeValue, normalizedCandidate)) || null;
219
+ }
220
+
221
+ export function normalizeRequestPathCandidate(value) {
222
+ if (typeof value !== "string") return null;
223
+ const [withoutHash] = value.split("#");
224
+ const [withoutQuery] = withoutHash.split("?");
225
+ return normalizeRoute(withoutQuery || "/");
226
+ }
227
+
228
+ export function routePatternMatchesCandidate(patternPath, candidatePath) {
229
+ const normalizedPattern = normalizeRequestPathCandidate(patternPath);
230
+ const normalizedCandidate = normalizeRequestPathCandidate(candidatePath);
231
+ if (!normalizedPattern || !normalizedCandidate) return false;
232
+
233
+ const patternSegments = splitRouteSegments(normalizedPattern);
234
+ const candidateSegments = splitRouteSegments(normalizedCandidate);
235
+ if (patternSegments.length !== candidateSegments.length) return false;
236
+
237
+ for (let index = 0; index < patternSegments.length; index += 1) {
238
+ const patternSegment = patternSegments[index];
239
+ const candidateSegment = candidateSegments[index];
240
+ if (patternSegment === candidateSegment) continue;
241
+ if (isDynamicRouteSegment(patternSegment) && candidateSegment.length > 0) continue;
242
+ if (candidateSegment === DYNAMIC_SEGMENT_TOKEN && isDynamicRouteSegment(patternSegment)) continue;
243
+ return false;
244
+ }
245
+
246
+ return true;
247
+ }
248
+
249
+ export function isDynamicRouteSegment(segment) {
250
+ return /^\[[^/]+\]$/u.test(segment) || /^\[\.\.\.[^/]+\]$/u.test(segment) || /^\[\[\.\.\.[^/]+\]\]$/u.test(segment);
251
+ }
252
+
253
+ function splitRouteSegments(value) {
254
+ if (value === "/") return [];
255
+ return value.split("/").filter(Boolean);
256
+ }
257
+
258
+ function compareRouteSpecificity(left, right) {
259
+ const leftSegments = splitRouteSegments(left.requestPath || "");
260
+ const rightSegments = splitRouteSegments(right.requestPath || "");
261
+ const leftStaticSegments = leftSegments.filter((segment) => !isDynamicRouteSegment(segment)).length;
262
+ const rightStaticSegments = rightSegments.filter((segment) => !isDynamicRouteSegment(segment)).length;
263
+ if (leftStaticSegments !== rightStaticSegments) return rightStaticSegments - leftStaticSegments;
264
+ return rightSegments.length - leftSegments.length;
265
+ }
@@ -1,8 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ DYNAMIC_SEGMENT_TOKEN,
3
4
  dedupeTargets,
5
+ findMatchingApiRouteEntry,
6
+ findMatchingRouteValue,
4
7
  modulePathKey,
5
8
  pageLabelFromRoute,
9
+ routePatternMatchesCandidate,
6
10
  toApiRequestPath,
7
11
  toSelectionType,
8
12
  } from "./shared.mjs";
@@ -36,4 +40,33 @@ describe("coverage shared helpers", () => {
36
40
  expect(toSelectionType("integration", "http")).toBe("int");
37
41
  expect(toSelectionType("ui", "playwright")).toBe("pw");
38
42
  });
43
+
44
+ it("matches dynamic API route patterns against concrete and template-like request paths", () => {
45
+ expect(routePatternMatchesCandidate("/api/projects/[projectId]/overview", "/api/projects/123/overview")).toBe(true);
46
+ expect(
47
+ routePatternMatchesCandidate(
48
+ "/api/projects/[projectId]/events",
49
+ `/api/projects/${DYNAMIC_SEGMENT_TOKEN}/events`
50
+ )
51
+ ).toBe(true);
52
+ expect(routePatternMatchesCandidate("/api/projects/[projectId]/events", "/api/projects/123/stats")).toBe(false);
53
+ });
54
+
55
+ it("prefers the most specific matching API route entry", () => {
56
+ const routeEntry = findMatchingApiRouteEntry("GET", "/api/projects/123/events", [
57
+ { method: "GET", requestPath: "/api/projects/[projectId]" },
58
+ { method: "GET", requestPath: "/api/projects/[projectId]/events" },
59
+ ]);
60
+
61
+ expect(routeEntry).toEqual({
62
+ method: "GET",
63
+ requestPath: "/api/projects/[projectId]/events",
64
+ });
65
+ });
66
+
67
+ it("matches dynamic page routes against discovered route patterns", () => {
68
+ expect(
69
+ findMatchingRouteValue(`/projects/${DYNAMIC_SEGMENT_TOKEN}`, ["/projects/[projectId]", "/projects"])
70
+ ).toBe("/projects/[projectId]");
71
+ });
39
72
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
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.62"
14
+ "@elench/testkit-protocol": "0.1.63"
15
15
  },
16
16
  "private": false
17
17
  }
@@ -269,6 +269,11 @@ function buildGraphProjection(context, page, matchedServiceName) {
269
269
  .filter((node) => node?.kind === "ui_surface");
270
270
  const pageReachableNodeIds = collectReachableNodeIds(pageNode.id, outgoing);
271
271
 
272
+ const surfaceReachableEntries = surfaceNodes.map((surfaceNode) => ({
273
+ surfaceNode,
274
+ reachableNodeIds: collectReachableNodeIds(surfaceNode.id, outgoing),
275
+ }));
276
+
272
277
  if (surfaceNodes.length === 0) {
273
278
  const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], pageReachableNodeIds));
274
279
  return buildPageLevelProjection({
@@ -285,8 +290,9 @@ function buildGraphProjection(context, page, matchedServiceName) {
285
290
  const coverage = [];
286
291
  const failures = [];
287
292
 
288
- for (const surfaceNode of surfaceNodes.sort((left, right) => left.id.localeCompare(right.id))) {
289
- const surfaceReachableNodeIds = collectReachableNodeIds(surfaceNode.id, outgoing);
293
+ for (const { surfaceNode, reachableNodeIds: surfaceReachableNodeIds } of surfaceReachableEntries.sort((left, right) =>
294
+ left.surfaceNode.id.localeCompare(right.surfaceNode.id)
295
+ )) {
290
296
  const relevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], surfaceReachableNodeIds));
291
297
  const supportingTests = relevantEvidence
292
298
  .map((entry) => buildSupportingTestRef(entry, discoveryByFile, context.runArtifact))
@@ -342,6 +348,22 @@ function buildGraphProjection(context, page, matchedServiceName) {
342
348
  });
343
349
  }
344
350
 
351
+ const routeLevelNodeIds = collectRouteLevelNodeIds(pageReachableNodeIds, surfaceReachableEntries);
352
+ if (routeLevelNodeIds.size > 0) {
353
+ const routeRelevantEvidence = evidence.filter((entry) => intersects(entry.coveredNodeIds || [], routeLevelNodeIds));
354
+ const pageProjection = buildPageLevelProjection({
355
+ pageNode,
356
+ nodeById,
357
+ relevantEvidence: routeRelevantEvidence,
358
+ pageReachableNodeIds: routeLevelNodeIds,
359
+ discoveryByFile,
360
+ failureByFile,
361
+ runArtifact: context.runArtifact,
362
+ });
363
+ coverage.unshift(...pageProjection.coverage);
364
+ failures.unshift(...pageProjection.failures);
365
+ }
366
+
345
367
  return { coverage, failures };
346
368
  }
347
369
 
@@ -417,6 +439,16 @@ function collectReachableNodeIds(startNodeId, outgoing) {
417
439
  return visited;
418
440
  }
419
441
 
442
+ function collectRouteLevelNodeIds(pageReachableNodeIds, surfaceReachableEntries) {
443
+ const routeLevelNodeIds = new Set(pageReachableNodeIds);
444
+ for (const { reachableNodeIds } of surfaceReachableEntries) {
445
+ for (const nodeId of reachableNodeIds) {
446
+ routeLevelNodeIds.delete(nodeId);
447
+ }
448
+ }
449
+ return routeLevelNodeIds;
450
+ }
451
+
420
452
  function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, excludedNodeIds = new Set()) {
421
453
  const nodes = new Map();
422
454
  for (const entry of evidenceEntries) {
@@ -161,6 +161,163 @@ const context = {
161
161
  },
162
162
  };
163
163
 
164
+ const routeLevelContext = {
165
+ product: {
166
+ name: "dashboard",
167
+ directory: "/tmp/dashboard",
168
+ },
169
+ services: [
170
+ {
171
+ name: "dashboard",
172
+ baseUrl: "http://localhost:3000",
173
+ origin: "http://localhost:3000",
174
+ },
175
+ ],
176
+ discovery: {
177
+ files: [
178
+ {
179
+ path: "__testkit__/projects/projects.int.testkit.ts",
180
+ displayName: "Projects",
181
+ service: "dashboard",
182
+ suiteName: "projects",
183
+ selectionType: "int",
184
+ framework: "k6",
185
+ skipped: false,
186
+ },
187
+ ],
188
+ coverageGraph: {
189
+ schemaVersion: 1,
190
+ nodes: [
191
+ {
192
+ id: "page_view:dashboard:/projects",
193
+ kind: "page_view",
194
+ service: "dashboard",
195
+ label: "Projects",
196
+ route: "/projects",
197
+ filePath: "src/app/projects/page.tsx",
198
+ },
199
+ {
200
+ id: "ui_surface:dashboard:/projects:project-create-button",
201
+ kind: "ui_surface",
202
+ service: "dashboard",
203
+ label: "Create Project",
204
+ route: "/projects",
205
+ filePath: "src/app/projects/page.tsx",
206
+ target: { kind: "testId", value: "project-create-button", confidence: "high" },
207
+ },
208
+ {
209
+ id: "ui_action:dashboard:/projects:handleCreate",
210
+ kind: "ui_action",
211
+ service: "dashboard",
212
+ label: "handleCreate",
213
+ route: "/projects",
214
+ filePath: "src/app/projects/page.tsx",
215
+ },
216
+ {
217
+ id: "client_request:dashboard:provider:GET:/api/projects",
218
+ kind: "client_request",
219
+ service: "dashboard",
220
+ label: "GET /api/projects",
221
+ route: "/projects",
222
+ method: "GET",
223
+ path: "/api/projects",
224
+ filePath: "src/components/project-context.tsx",
225
+ },
226
+ {
227
+ id: "client_request:dashboard:projects:POST:/api/projects",
228
+ kind: "client_request",
229
+ service: "dashboard",
230
+ label: "POST /api/projects",
231
+ route: "/projects",
232
+ method: "POST",
233
+ path: "/api/projects",
234
+ filePath: "src/app/projects/page.tsx",
235
+ },
236
+ {
237
+ id: "api_route:dashboard:GET:/api/projects",
238
+ kind: "api_route",
239
+ service: "dashboard",
240
+ label: "GET /api/projects",
241
+ route: "/projects",
242
+ method: "GET",
243
+ path: "/api/projects",
244
+ filePath: "src/app/api/projects/route.ts",
245
+ },
246
+ {
247
+ id: "api_route:dashboard:POST:/api/projects",
248
+ kind: "api_route",
249
+ service: "dashboard",
250
+ label: "POST /api/projects",
251
+ route: "/projects",
252
+ method: "POST",
253
+ path: "/api/projects",
254
+ filePath: "src/app/api/projects/route.ts",
255
+ },
256
+ ],
257
+ edges: [
258
+ {
259
+ id: "contains:page_view:dashboard:/projects:ui_surface:dashboard:/projects:project-create-button",
260
+ kind: "contains",
261
+ from: "page_view:dashboard:/projects",
262
+ to: "ui_surface:dashboard:/projects:project-create-button",
263
+ },
264
+ {
265
+ id: "triggers:ui_surface:dashboard:/projects:project-create-button:ui_action:dashboard:/projects:handleCreate",
266
+ kind: "triggers",
267
+ from: "ui_surface:dashboard:/projects:project-create-button",
268
+ to: "ui_action:dashboard:/projects:handleCreate",
269
+ },
270
+ {
271
+ id: "requests:page_view:dashboard:/projects:client_request:dashboard:provider:GET:/api/projects",
272
+ kind: "requests",
273
+ from: "page_view:dashboard:/projects",
274
+ to: "client_request:dashboard:provider:GET:/api/projects",
275
+ },
276
+ {
277
+ id: "requests:ui_action:dashboard:/projects:handleCreate:client_request:dashboard:projects:POST:/api/projects",
278
+ kind: "requests",
279
+ from: "ui_action:dashboard:/projects:handleCreate",
280
+ to: "client_request:dashboard:projects:POST:/api/projects",
281
+ },
282
+ {
283
+ id: "handles:client_request:dashboard:provider:GET:/api/projects:api_route:dashboard:GET:/api/projects",
284
+ kind: "handles",
285
+ from: "client_request:dashboard:provider:GET:/api/projects",
286
+ to: "api_route:dashboard:GET:/api/projects",
287
+ },
288
+ {
289
+ id: "handles:client_request:dashboard:projects:POST:/api/projects:api_route:dashboard:POST:/api/projects",
290
+ kind: "handles",
291
+ from: "client_request:dashboard:projects:POST:/api/projects",
292
+ to: "api_route:dashboard:POST:/api/projects",
293
+ },
294
+ ],
295
+ evidence: [
296
+ {
297
+ id: "evidence:dashboard:__testkit__/projects/projects.int.testkit.ts",
298
+ source: "convention",
299
+ confidence: "high",
300
+ service: "dashboard",
301
+ suiteName: "projects",
302
+ selectionType: "int",
303
+ framework: "k6",
304
+ testFilePath: "__testkit__/projects/projects.int.testkit.ts",
305
+ coveredNodeIds: [
306
+ "api_route:dashboard:GET:/api/projects",
307
+ "api_route:dashboard:POST:/api/projects",
308
+ ],
309
+ details: {
310
+ route: "/projects",
311
+ requestPaths: ["/api/projects"],
312
+ },
313
+ },
314
+ ],
315
+ diagnostics: [],
316
+ },
317
+ },
318
+ runArtifact: null,
319
+ };
320
+
164
321
  describe("testkit bridge", () => {
165
322
  it("matches pages against service base URLs", () => {
166
323
  expect(buildMatchResponse(context, "http://localhost:3000/coverage")).toMatchObject({
@@ -177,46 +334,76 @@ describe("testkit bridge", () => {
177
334
  summary: {
178
335
  failureState: "failing",
179
336
  coverageState: "covered",
180
- relatedFailureCount: 1,
181
- relatedCoverageCount: 1,
337
+ relatedFailureCount: 2,
338
+ relatedCoverageCount: 2,
182
339
  coverageBreakdown: {
183
- direct: 0,
340
+ direct: 1,
184
341
  indirect: 0,
185
342
  mixed: 1,
186
343
  },
187
344
  },
188
- failures: [
189
- {
345
+ failures: expect.arrayContaining([
346
+ expect.objectContaining({
347
+ kind: "page_view",
348
+ label: "Coverage",
349
+ }),
350
+ expect.objectContaining({
190
351
  kind: "ui_surface",
191
352
  label: "Refresh overlay",
192
353
  importance: "high",
193
354
  reason: "Refresh overlay is implicated by 1 failing test.",
194
355
  targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
195
356
  failedTests: [
196
- {
357
+ expect.objectContaining({
197
358
  filePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
198
359
  type: "pw",
199
- },
360
+ }),
200
361
  ],
201
- },
202
- ],
203
- coverage: [
204
- {
362
+ }),
363
+ ]),
364
+ coverage: expect.arrayContaining([
365
+ expect.objectContaining({
366
+ kind: "page_view",
367
+ label: "Coverage",
368
+ supportKind: "direct",
369
+ }),
370
+ expect.objectContaining({
205
371
  kind: "ui_surface",
206
372
  label: "Refresh overlay",
207
373
  importance: "high",
208
374
  supportKind: "mixed",
209
375
  targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
210
376
  supportingTests: [
211
- {
377
+ expect.objectContaining({
212
378
  filePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
213
- },
214
- {
379
+ }),
380
+ expect.objectContaining({
215
381
  filePath: "app/api/coverage/__testkit__/coverage.int.testkit.ts",
216
- },
382
+ }),
217
383
  ],
218
- },
219
- ],
384
+ }),
385
+ ]),
386
+ });
387
+ });
388
+
389
+ it("retains route-level coverage when support comes from page-owned requests outside any surfaced control", () => {
390
+ expect(buildPageOverlayResponse(routeLevelContext, "http://localhost:3000/projects")).toMatchObject({
391
+ summary: {
392
+ coverageState: "covered",
393
+ relatedCoverageCount: 2,
394
+ },
395
+ coverage: expect.arrayContaining([
396
+ expect.objectContaining({
397
+ kind: "page_view",
398
+ label: "Projects",
399
+ supportKind: "indirect",
400
+ }),
401
+ expect.objectContaining({
402
+ kind: "ui_surface",
403
+ label: "Create Project",
404
+ supportKind: "indirect",
405
+ }),
406
+ ]),
220
407
  });
221
408
  });
222
409
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -59,8 +59,8 @@
59
59
  "vitest": "^3.2.4"
60
60
  },
61
61
  "dependencies": {
62
- "@elench/testkit-bridge": "0.1.62",
63
- "@elench/testkit-protocol": "0.1.62",
62
+ "@elench/testkit-bridge": "0.1.63",
63
+ "@elench/testkit-protocol": "0.1.63",
64
64
  "@babel/code-frame": "^7.29.0",
65
65
  "@oclif/core": "^4.10.6",
66
66
  "esbuild": "^0.25.11",