@elench/testkit 0.1.57 → 0.1.59

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.
@@ -0,0 +1,241 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { buildCoverageGraph, buildServiceCoverageContext } from "./index.mjs";
6
+
7
+ const cleanups = [];
8
+
9
+ afterEach(() => {
10
+ while (cleanups.length > 0) {
11
+ cleanups.pop()();
12
+ }
13
+ });
14
+
15
+ describe("coverage graph builder", () => {
16
+ it("builds a Next page -> request -> API route -> server capability graph", () => {
17
+ const productDir = createProduct();
18
+ writeFile(
19
+ productDir,
20
+ "testkit.setup.ts",
21
+ `
22
+ import { defineTestkitSetup, nextService } from "@elench/testkit/setup";
23
+
24
+ export default defineTestkitSetup({
25
+ services: {
26
+ web: nextService({
27
+ cwd: ".",
28
+ start: "node server.js",
29
+ baseUrl: "http://127.0.0.1:3000",
30
+ readyUrl: "http://127.0.0.1:3000"
31
+ })
32
+ }
33
+ });
34
+ `
35
+ );
36
+ writeFile(
37
+ productDir,
38
+ "src/app/campaigns/page.tsx",
39
+ `
40
+ "use client";
41
+ import { getJson } from "@/client/http/http";
42
+
43
+ export default async function CampaignsPage() {
44
+ await getJson("/api/campaigns");
45
+ return null;
46
+ }
47
+ `
48
+ );
49
+ writeFile(productDir, "src/client/http/http.ts", `export function getJson() { return null; }`);
50
+ writeFile(
51
+ productDir,
52
+ "src/app/api/campaigns/route.ts",
53
+ `
54
+ import { listCampaigns, createCampaign } from "@/backend/server/campaigns";
55
+
56
+ export async function GET() {
57
+ return listCampaigns();
58
+ }
59
+
60
+ export async function POST() {
61
+ return createCampaign();
62
+ }
63
+ `
64
+ );
65
+ writeFile(productDir, "src/backend/server/campaigns/index.ts", `export async function listCampaigns() {}\nexport async function createCampaign() {}`);
66
+
67
+ const context = buildServiceCoverageContext(productDir, "web", {
68
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
69
+ });
70
+
71
+ expect(context.pageByRoute.get("/campaigns").node).toMatchObject({
72
+ kind: "page_view",
73
+ route: "/campaigns",
74
+ filePath: "src/app/campaigns/page.tsx",
75
+ });
76
+ expect(context.apiRouteByKey.get("GET:/api/campaigns").node).toMatchObject({
77
+ kind: "api_route",
78
+ route: "/campaigns",
79
+ method: "GET",
80
+ path: "/api/campaigns",
81
+ filePath: "src/app/api/campaigns/route.ts",
82
+ });
83
+ expect(context.graph.edges).toEqual(
84
+ expect.arrayContaining([
85
+ expect.objectContaining({
86
+ kind: "requests",
87
+ from: "page_view:web:/campaigns",
88
+ }),
89
+ expect.objectContaining({
90
+ kind: "handles",
91
+ to: "api_route:web:GET:/api/campaigns",
92
+ }),
93
+ expect.objectContaining({
94
+ kind: "delegates_to",
95
+ from: "api_route:web:GET:/api/campaigns",
96
+ }),
97
+ ])
98
+ );
99
+ });
100
+
101
+ it("discovers server action links from pages", () => {
102
+ const productDir = createProduct();
103
+ writeFile(
104
+ productDir,
105
+ "src/app/settings/page.tsx",
106
+ `
107
+ import { saveSettings } from "./actions";
108
+
109
+ export default function SettingsPage() {
110
+ void saveSettings;
111
+ return null;
112
+ }
113
+ `
114
+ );
115
+ writeFile(
116
+ productDir,
117
+ "src/app/settings/actions.ts",
118
+ `
119
+ "use server";
120
+ import { updateSettings } from "@/backend/server/settings";
121
+
122
+ export async function saveSettings() {
123
+ return updateSettings();
124
+ }
125
+ `
126
+ );
127
+ writeFile(productDir, "src/backend/server/settings.ts", `export async function updateSettings() {}`);
128
+
129
+ const context = buildServiceCoverageContext(productDir, "web", {
130
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
131
+ });
132
+
133
+ expect(context.serverActionByExportKey.get("src/app/settings/actions.ts#saveSettings").node).toMatchObject({
134
+ kind: "server_action",
135
+ label: "saveSettings",
136
+ });
137
+ expect(context.graph.edges).toEqual(
138
+ expect.arrayContaining([
139
+ expect.objectContaining({
140
+ kind: "triggers",
141
+ from: "page_view:web:/settings",
142
+ to: "server_action:web:src/app/settings/actions.ts#saveSettings",
143
+ }),
144
+ expect.objectContaining({
145
+ kind: "delegates_to",
146
+ from: "server_action:web:src/app/settings/actions.ts#saveSettings",
147
+ }),
148
+ ])
149
+ );
150
+ });
151
+
152
+ it("attaches convention-based test evidence to page and route nodes", () => {
153
+ 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 }); }`);
156
+
157
+ const graph = buildCoverageGraph({
158
+ productDir,
159
+ services: {
160
+ web: {
161
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
162
+ },
163
+ },
164
+ discoveryFiles: [
165
+ {
166
+ serviceName: "web",
167
+ type: "e2e",
168
+ framework: "playwright",
169
+ suiteName: "campaigns",
170
+ filePath: "src/app/campaigns/__testkit__/campaigns.pw.testkit.ts",
171
+ },
172
+ {
173
+ serviceName: "web",
174
+ type: "integration",
175
+ framework: "k6",
176
+ suiteName: "campaigns",
177
+ filePath: "src/app/api/campaigns/__testkit__/campaigns.int.testkit.ts",
178
+ },
179
+ ],
180
+ });
181
+
182
+ expect(graph.evidence).toEqual(
183
+ expect.arrayContaining([
184
+ expect.objectContaining({
185
+ testFilePath: "src/app/campaigns/__testkit__/campaigns.pw.testkit.ts",
186
+ coveredNodeIds: ["page_view:web:/campaigns"],
187
+ }),
188
+ expect.objectContaining({
189
+ testFilePath: "src/app/api/campaigns/__testkit__/campaigns.int.testkit.ts",
190
+ coveredNodeIds: ["api_route:web:GET:/api/campaigns"],
191
+ }),
192
+ ])
193
+ );
194
+ });
195
+
196
+ it("normalizes dynamic routes", () => {
197
+ const productDir = createProduct();
198
+ writeFile(productDir, "src/app/projects/[projectId]/page.tsx", `export default function ProjectPage() { return null; }`);
199
+ writeFile(productDir, "src/app/api/projects/[projectId]/route.ts", `export async function GET() { return Response.json({ ok: true }); }`);
200
+
201
+ const context = buildServiceCoverageContext(productDir, "web", {
202
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
203
+ });
204
+
205
+ expect(context.pageByRoute.get("/projects/[projectId]").node).toBeTruthy();
206
+ expect(context.apiRouteByKey.get("GET:/api/projects/[projectId]").node).toBeTruthy();
207
+ });
208
+
209
+ it("does not exclude app/coverage source routes while still allowing top-level coverage output to be ignored", () => {
210
+ const productDir = createProduct();
211
+ writeFile(productDir, "app/coverage/page.tsx", `export default function CoveragePage() { return null; }`);
212
+ writeFile(productDir, "coverage/page.tsx", `export default function OutputPage() { return null; }`);
213
+
214
+ const context = buildServiceCoverageContext(
215
+ productDir,
216
+ "web",
217
+ {
218
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
219
+ discovery: { roots: ["app"] },
220
+ },
221
+ { exclude: ["coverage"] }
222
+ );
223
+
224
+ expect(context.pageByRoute.get("/coverage").node).toMatchObject({
225
+ filePath: "app/coverage/page.tsx",
226
+ });
227
+ expect(context.graph.nodes.some((node) => node.filePath === "coverage/page.tsx")).toBe(false);
228
+ });
229
+ });
230
+
231
+ function createProduct() {
232
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-coverage-"));
233
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
234
+ return productDir;
235
+ }
236
+
237
+ function writeFile(productDir, relativePath, content = "export {};\n") {
238
+ const absolutePath = path.join(productDir, relativePath);
239
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
240
+ fs.writeFileSync(absolutePath, content);
241
+ }
@@ -1,3 +1,5 @@
1
+ import type { CoverageGraph } from "@elench/testkit-protocol";
2
+
1
3
  export type DiscoverySelectionType = "int" | "e2e" | "scenario" | "dal" | "load" | "pw";
2
4
  export type DiscoveryInternalType = "integration" | "e2e" | "scenario" | "dal" | "load";
3
5
  export type DiscoveryFramework = "k6" | "playwright";
@@ -86,6 +88,7 @@ export interface DiscoveryResult {
86
88
  services: DiscoveryService[];
87
89
  suites: DiscoverySuite[];
88
90
  files: DiscoveryFile[];
91
+ coverageGraph: CoverageGraph | null;
89
92
  diagnostics: DiscoveryDiagnostic[];
90
93
  summary: {
91
94
  services: number;
@@ -2,6 +2,7 @@ import path from "path";
2
2
  import { loadConfigContext, resolveProductDir } from "../config/index.mjs";
3
3
  import { discoverProject } from "../config/discovery.mjs";
4
4
  import { loadTestkitSetup } from "../config/setup-loader.mjs";
5
+ import { buildCoverageGraph } from "../coverage/index.mjs";
5
6
  import { historyFilePath, loadHistory, summarizeHistoryForFiles } from "../history/index.mjs";
6
7
  import {
7
8
  matchesSelectedTypes,
@@ -11,7 +12,7 @@ import {
11
12
  suiteSelectionType,
12
13
  } from "../runner/suite-selection.mjs";
13
14
 
14
- const DISCOVERY_SCHEMA_VERSION = 1;
15
+ const DISCOVERY_SCHEMA_VERSION = 3;
15
16
 
16
17
  export async function discoverTests(options = {}) {
17
18
  const productDir = resolveProductDir(process.cwd(), options.dir);
@@ -36,6 +37,7 @@ export async function discoverTests(options = {}) {
36
37
  services: [],
37
38
  suites: [],
38
39
  files: [],
40
+ coverageGraph: null,
39
41
  diagnostics: [...setupContext.diagnostics],
40
42
  summary: emptySummary(),
41
43
  history: {
@@ -45,11 +47,12 @@ export async function discoverTests(options = {}) {
45
47
  };
46
48
 
47
49
  if (!setupContext.setup) {
48
- return finalizeDiscoveryResult(baseResult);
50
+ return finalizeDiscoveryResult(baseResult, productDir);
49
51
  }
50
52
 
51
53
  const rawDiscovery = discoverProject(productDir, setupContext.setup.services || {}, {
52
54
  strict: filters.diagnosticsMode === "error",
55
+ discovery: setupContext.setup.discovery || {},
53
56
  });
54
57
  baseResult.diagnostics.push(...rawDiscovery.diagnostics);
55
58
  validateRequestedService(filters.serviceFilter, setupContext.setup.services || {}, rawDiscovery);
@@ -86,6 +89,12 @@ export async function discoverTests(options = {}) {
86
89
  services: resolved.services,
87
90
  suites: resolved.suites,
88
91
  files: resolved.files,
92
+ coverageGraph: buildCoverageGraph({
93
+ productDir,
94
+ repoDiscovery: setupContext.setup.discovery || {},
95
+ services: setupContext.setup.services || {},
96
+ discoveryFiles: rawDiscovery.files || [],
97
+ }),
89
98
  },
90
99
  productDir
91
100
  );
@@ -102,6 +111,12 @@ export async function discoverTests(options = {}) {
102
111
  services: rawOnly.services,
103
112
  suites: rawOnly.suites,
104
113
  files: rawOnly.files,
114
+ coverageGraph: buildCoverageGraph({
115
+ productDir,
116
+ repoDiscovery: setupContext.setup.discovery || {},
117
+ services: setupContext.setup.services || {},
118
+ discoveryFiles: rawDiscovery.files || [],
119
+ }),
105
120
  },
106
121
  productDir
107
122
  );
@@ -0,0 +1,106 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ export const DEFAULT_DISCOVERY_EXCLUDES = Object.freeze([
5
+ ".cache",
6
+ ".git",
7
+ ".hg",
8
+ ".next",
9
+ ".nuxt",
10
+ ".playwright-browsers",
11
+ ".svn",
12
+ ".testkit",
13
+ ".turbo",
14
+ "build",
15
+ "coverage",
16
+ "dist",
17
+ "node_modules",
18
+ "playwright-report",
19
+ "test-results",
20
+ ]);
21
+
22
+ export function normalizeDiscoveryConfig(value, { allowRoots = true } = {}) {
23
+ if (value == null) return {};
24
+ if (typeof value !== "object" || Array.isArray(value)) {
25
+ throw new Error("testkit discovery config must be an object");
26
+ }
27
+
28
+ const normalized = {};
29
+ if (allowRoots && Object.prototype.hasOwnProperty.call(value, "roots")) {
30
+ normalized.roots = normalizeDiscoveryPathList(value.roots, "testkit discovery roots");
31
+ }
32
+ if (Object.prototype.hasOwnProperty.call(value, "exclude")) {
33
+ normalized.exclude = normalizeDiscoveryPathList(value.exclude, "testkit discovery exclude");
34
+ }
35
+ return normalized;
36
+ }
37
+
38
+ export function mergeDiscoveryConfigs(...configs) {
39
+ const merged = {};
40
+ for (const config of configs.filter(Boolean)) {
41
+ if (Array.isArray(config.roots)) {
42
+ merged.roots = [...config.roots];
43
+ }
44
+ if (Array.isArray(config.exclude) && config.exclude.length > 0) {
45
+ merged.exclude = [...new Set([...(merged.exclude || []), ...config.exclude])];
46
+ }
47
+ }
48
+ return merged;
49
+ }
50
+
51
+ export function resolveDiscoveryRoots(baseDir, roots, defaultRoot = ".") {
52
+ const normalizedRoots = Array.isArray(roots) && roots.length > 0 ? roots : [defaultRoot];
53
+ const seen = new Set();
54
+ const resolved = [];
55
+
56
+ for (const root of normalizedRoots) {
57
+ const normalizedRoot = normalizeDiscoveryPath(root);
58
+ if (!normalizedRoot) continue;
59
+ const absolutePath = path.resolve(baseDir, normalizedRoot === "." ? "." : normalizedRoot);
60
+ if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isDirectory()) continue;
61
+ if (seen.has(absolutePath)) continue;
62
+ seen.add(absolutePath);
63
+ resolved.push(absolutePath);
64
+ }
65
+
66
+ return resolved.sort((left, right) => left.localeCompare(right));
67
+ }
68
+
69
+ export function shouldExcludeDiscoveryPath(relativePath, exclude = []) {
70
+ const normalizedPath = normalizeDiscoveryPath(relativePath);
71
+ if (!normalizedPath || normalizedPath === ".") return false;
72
+ return exclude.some((pattern) => matchesDiscoveryPathPattern(normalizedPath, pattern));
73
+ }
74
+
75
+ export function normalizeDiscoveryPath(value) {
76
+ const normalized = String(value || "")
77
+ .trim()
78
+ .split(path.sep)
79
+ .join("/")
80
+ .replace(/^\.\/+/u, "")
81
+ .replace(/\/+/gu, "/")
82
+ .replace(/\/+$/u, "");
83
+ return normalized || ".";
84
+ }
85
+
86
+ function normalizeDiscoveryPathList(value, label) {
87
+ if (!Array.isArray(value)) {
88
+ throw new Error(`${label} must be an array of relative paths`);
89
+ }
90
+ const normalized = value
91
+ .map((entry) => {
92
+ if (typeof entry !== "string" || entry.trim().length === 0) {
93
+ throw new Error(`${label} entries must be non-empty strings`);
94
+ }
95
+ if (path.isAbsolute(entry)) {
96
+ throw new Error(`${label} entries must be relative paths`);
97
+ }
98
+ return normalizeDiscoveryPath(entry);
99
+ })
100
+ .filter(Boolean);
101
+ return [...new Set(normalized)];
102
+ }
103
+
104
+ function matchesDiscoveryPathPattern(relativePath, pattern) {
105
+ return relativePath === pattern || relativePath.startsWith(`${pattern}/`);
106
+ }
@@ -0,0 +1,65 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ mergeDiscoveryConfigs,
7
+ normalizeDiscoveryConfig,
8
+ resolveDiscoveryRoots,
9
+ shouldExcludeDiscoveryPath,
10
+ } from "./path-policy.mjs";
11
+
12
+ const cleanups = [];
13
+
14
+ afterEach(() => {
15
+ while (cleanups.length > 0) {
16
+ cleanups.pop()();
17
+ }
18
+ });
19
+
20
+ describe("discovery path policy", () => {
21
+ it("normalizes roots and excludes as relative paths", () => {
22
+ expect(
23
+ normalizeDiscoveryConfig({
24
+ roots: ["./app/", "src/app"],
25
+ exclude: ["./coverage/", "dist"],
26
+ })
27
+ ).toEqual({
28
+ roots: ["app", "src/app"],
29
+ exclude: ["coverage", "dist"],
30
+ });
31
+ });
32
+
33
+ it("merges excludes without losing later roots", () => {
34
+ expect(
35
+ mergeDiscoveryConfigs(
36
+ { roots: ["src"], exclude: ["coverage"] },
37
+ { exclude: ["dist"] },
38
+ { roots: ["app"], exclude: ["test-results"] }
39
+ )
40
+ ).toEqual({
41
+ roots: ["app"],
42
+ exclude: ["coverage", "dist", "test-results"],
43
+ });
44
+ });
45
+
46
+ it("treats exclude paths as path prefixes rather than bare directory names", () => {
47
+ expect(shouldExcludeDiscoveryPath("coverage", ["coverage"])).toBe(true);
48
+ expect(shouldExcludeDiscoveryPath("coverage/report/index.html", ["coverage"])).toBe(true);
49
+ expect(shouldExcludeDiscoveryPath("app/coverage", ["coverage"])).toBe(false);
50
+ expect(shouldExcludeDiscoveryPath("src/app/coverage", ["coverage"])).toBe(false);
51
+ });
52
+
53
+ it("resolves only existing discovery roots", () => {
54
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-roots-"));
55
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
56
+ fs.mkdirSync(path.join(productDir, "app"), { recursive: true });
57
+ fs.mkdirSync(path.join(productDir, "src", "app"), { recursive: true });
58
+
59
+ const roots = resolveDiscoveryRoots(productDir, ["app", "missing", "src/app"]);
60
+ expect(roots).toEqual([
61
+ path.join(productDir, "app"),
62
+ path.join(productDir, "src", "app"),
63
+ ]);
64
+ });
65
+ });
@@ -106,22 +106,30 @@ export interface TestkitExecutionConfig {
106
106
  fileTimeoutSeconds?: number;
107
107
  }
108
108
 
109
+ export interface BrowserServiceConfig {
110
+ origins?: string[];
111
+ }
112
+
109
113
  export interface KnownFailureIssueValidationConfig {
110
114
  provider?: "github";
111
115
  mode?: "off" | "warn" | "error";
112
116
  cacheTtlSeconds?: number;
113
117
  }
114
118
 
119
+ export interface DiscoveryConfig {
120
+ roots?: string[];
121
+ exclude?: string[];
122
+ }
123
+
115
124
  export interface ServiceConfig {
116
125
  database?: LocalDatabaseConfig;
117
126
  databaseFrom?: string;
118
127
  dependsOn?: string[];
119
- discovery?: {
120
- roots?: string[];
121
- };
128
+ discovery?: DiscoveryConfig;
122
129
  env?: Record<string, string>;
123
130
  envFile?: string;
124
131
  envFiles?: string[];
132
+ browser?: BrowserServiceConfig;
125
133
  local?: false | {
126
134
  baseUrl: string;
127
135
  cwd?: string;
@@ -137,6 +145,7 @@ export interface ServiceConfig {
137
145
  }
138
146
 
139
147
  export interface TestkitSetup {
148
+ discovery?: DiscoveryConfig;
140
149
  execution?: TestkitExecutionConfig;
141
150
  profiles?: {
142
151
  http?: Record<string, HttpSuiteConfig>;
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@elench/testkit-bridge",
3
+ "version": "0.1.59",
4
+ "description": "Browser bridge helpers for testkit",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs"
9
+ },
10
+ "files": [
11
+ "src/"
12
+ ],
13
+ "dependencies": {
14
+ "@elench/testkit-protocol": "0.1.59"
15
+ },
16
+ "private": false
17
+ }