@elench/testkit 0.1.56 → 0.1.58

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,182 @@
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 { discoverTests } from "./index.mjs";
6
+ import { buildHistoryTestId, saveHistory } from "../history/index.mjs";
7
+
8
+ const cleanups = [];
9
+
10
+ afterEach(() => {
11
+ while (cleanups.length > 0) {
12
+ cleanups.pop()();
13
+ }
14
+ });
15
+
16
+ describe("public discovery", () => {
17
+ it("returns resolved suites/files with human labels and persisted history", async () => {
18
+ const productDir = createProduct();
19
+ writeFile(
20
+ productDir,
21
+ "testkit.setup.ts",
22
+ `
23
+ import { defineTestkitSetup } from "@elench/testkit/setup";
24
+
25
+ export default defineTestkitSetup({
26
+ services: {
27
+ api: {
28
+ local: {
29
+ cwd: ".",
30
+ start: "node server.js",
31
+ baseUrl: "http://127.0.0.1:3000",
32
+ readyUrl: "http://127.0.0.1:3000"
33
+ },
34
+ requirements: {
35
+ files: [
36
+ {
37
+ path: "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts",
38
+ locks: ["route-lock"]
39
+ }
40
+ ]
41
+ }
42
+ },
43
+ frontend: {
44
+ local: {
45
+ cwd: "frontend",
46
+ start: "node server.js",
47
+ baseUrl: "http://127.0.0.1:3001",
48
+ readyUrl: "http://127.0.0.1:3001"
49
+ },
50
+ dependsOn: ["api"],
51
+ skip: {
52
+ files: [
53
+ {
54
+ path: "frontend/src/app/login/__testkit__/auth.pw.testkit.ts",
55
+ reason: "Auth is stubbed locally"
56
+ }
57
+ ]
58
+ }
59
+ }
60
+ }
61
+ });
62
+ `
63
+ );
64
+ writeFile(productDir, "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts");
65
+ writeFile(productDir, "frontend/src/app/login/__testkit__/auth.pw.testkit.ts");
66
+
67
+ saveHistory(productDir, {
68
+ version: 1,
69
+ tests: {
70
+ [buildHistoryTestId("api", "int", "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts")]: {
71
+ id: buildHistoryTestId("api", "int", "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts"),
72
+ path: "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts",
73
+ service: "api",
74
+ suiteName: "routes",
75
+ selectionType: "int",
76
+ framework: "k6",
77
+ firstSeenAt: "2026-04-01T00:00:00.000Z",
78
+ lastSeenAt: "2026-04-02T00:00:00.000Z",
79
+ lastRunAt: "2026-04-02T00:00:00.000Z",
80
+ runCount: 4,
81
+ passCount: 3,
82
+ failCount: 1,
83
+ skipCount: 0,
84
+ notRunCount: 0,
85
+ avgDurationMs: 2500,
86
+ durationCount: 4,
87
+ lastStatus: "passed",
88
+ },
89
+ },
90
+ });
91
+
92
+ const result = await discoverTests({ dir: productDir });
93
+ expect(result.summary).toMatchObject({
94
+ services: 2,
95
+ suites: 2,
96
+ files: 2,
97
+ activeFiles: 1,
98
+ skippedFiles: 1,
99
+ byType: {
100
+ int: 1,
101
+ pw: 1,
102
+ },
103
+ });
104
+ expect(result.history.available).toBe(true);
105
+ expect(result.services.map((entry) => entry.name)).toEqual(["api", "frontend"]);
106
+
107
+ const apiFile = result.files.find((entry) => entry.service === "api");
108
+ expect(apiFile).toMatchObject({
109
+ displayName: "Agent Configs Auth Gate",
110
+ suiteName: "routes",
111
+ groupLabel: "Routes",
112
+ selectionType: "int",
113
+ skipped: false,
114
+ locks: ["route-lock"],
115
+ });
116
+ expect(apiFile.history).toMatchObject({
117
+ firstSeenAt: "2026-04-01T00:00:00.000Z",
118
+ runCount: 4,
119
+ passCount: 3,
120
+ failCount: 1,
121
+ avgDurationMs: 2500,
122
+ });
123
+
124
+ const frontendFile = result.files.find((entry) => entry.service === "frontend");
125
+ expect(frontendFile).toMatchObject({
126
+ displayName: "Auth",
127
+ selectionType: "pw",
128
+ skipped: true,
129
+ skipReason: "Auth is stubbed locally",
130
+ dependsOn: ["api"],
131
+ });
132
+ });
133
+
134
+ it("reports legacy discovery diagnostics without failing in report mode", async () => {
135
+ const productDir = createProduct();
136
+ writeFile(
137
+ productDir,
138
+ "testkit.setup.ts",
139
+ `
140
+ import { defineTestkitSetup } from "@elench/testkit/setup";
141
+
142
+ export default defineTestkitSetup({
143
+ services: {
144
+ api: {
145
+ local: {
146
+ cwd: ".",
147
+ start: "node server.js",
148
+ baseUrl: "http://127.0.0.1:3000",
149
+ readyUrl: "http://127.0.0.1:3000"
150
+ }
151
+ }
152
+ }
153
+ });
154
+ `
155
+ );
156
+ writeFile(productDir, "src/api/routes/__testkit__/health/health.int.testkit.ts");
157
+ writeFile(productDir, "tests/api/integration/legacy.int.testkit.ts");
158
+
159
+ const result = await discoverTests({ dir: productDir, diagnostics: "report" });
160
+ expect(result.files).toHaveLength(1);
161
+ expect(result.diagnostics).toEqual(
162
+ expect.arrayContaining([
163
+ expect.objectContaining({
164
+ code: "legacy_path",
165
+ path: "tests/api/integration/legacy.int.testkit.ts",
166
+ }),
167
+ ])
168
+ );
169
+ });
170
+ });
171
+
172
+ function createProduct() {
173
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-public-"));
174
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
175
+ return productDir;
176
+ }
177
+
178
+ function writeFile(productDir, relativePath, contents = "export {};\n") {
179
+ const absolutePath = path.join(productDir, relativePath);
180
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
181
+ fs.writeFileSync(absolutePath, contents);
182
+ }
@@ -0,0 +1,46 @@
1
+ export interface TestHistorySummary {
2
+ firstSeenAt: string | null;
3
+ lastSeenAt: string | null;
4
+ lastRunAt: string | null;
5
+ runCount: number;
6
+ passCount: number;
7
+ failCount: number;
8
+ skipCount: number;
9
+ avgDurationMs: number;
10
+ lastStatus: "passed" | "failed" | "skipped" | "not_run" | null;
11
+ }
12
+
13
+ export interface TestHistoryEntry extends TestHistorySummary {
14
+ id: string;
15
+ path: string;
16
+ service: string;
17
+ suiteName: string;
18
+ selectionType: string;
19
+ framework: string;
20
+ notRunCount: number;
21
+ durationCount: number;
22
+ }
23
+
24
+ export interface TestHistoryDocument {
25
+ version: number;
26
+ tests: Record<string, TestHistoryEntry>;
27
+ }
28
+
29
+ export declare function historyFilePath(productDir: string): string;
30
+ export declare function createEmptyHistory(): TestHistoryDocument;
31
+ export declare function loadHistory(productDir: string): TestHistoryDocument;
32
+ export declare function saveHistory(productDir: string, history: TestHistoryDocument): void;
33
+ export declare function updateHistoryFromRunArtifact(
34
+ history: TestHistoryDocument,
35
+ runArtifact: unknown,
36
+ recordedAt?: string | null
37
+ ): TestHistoryDocument;
38
+ export declare function summarizeHistoryForFiles(
39
+ history: TestHistoryDocument,
40
+ files?: Array<{ id: string; service: string; selectionType: string; path: string }>
41
+ ): Map<string, TestHistorySummary>;
42
+ export declare function buildHistoryTestId(
43
+ serviceName: string,
44
+ selectionType: string,
45
+ filePath: string
46
+ ): string;
@@ -0,0 +1,166 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const HISTORY_SCHEMA_VERSION = 1;
5
+ const HISTORY_DIRNAME = "history";
6
+ const TEST_HISTORY_FILENAME = "tests.json";
7
+
8
+ export function historyFilePath(productDir) {
9
+ return path.join(productDir, ".testkit", HISTORY_DIRNAME, TEST_HISTORY_FILENAME);
10
+ }
11
+
12
+ export function createEmptyHistory() {
13
+ return {
14
+ version: HISTORY_SCHEMA_VERSION,
15
+ tests: {},
16
+ };
17
+ }
18
+
19
+ export function loadHistory(productDir) {
20
+ const filePath = historyFilePath(productDir);
21
+ if (!fs.existsSync(filePath)) {
22
+ return createEmptyHistory();
23
+ }
24
+
25
+ try {
26
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
27
+ return normalizeHistory(parsed);
28
+ } catch {
29
+ return createEmptyHistory();
30
+ }
31
+ }
32
+
33
+ export function saveHistory(productDir, history) {
34
+ const filePath = historyFilePath(productDir);
35
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
36
+ fs.writeFileSync(filePath, `${JSON.stringify(normalizeHistory(history), null, 2)}\n`);
37
+ }
38
+
39
+ export function updateHistoryFromRunArtifact(history, runArtifact, recordedAt = null) {
40
+ const normalized = normalizeHistory(history);
41
+ const seenAt = recordedAt || runArtifact?.generatedAt || new Date().toISOString();
42
+
43
+ for (const service of runArtifact?.services || []) {
44
+ for (const suite of service.suites || []) {
45
+ for (const file of suite.files || []) {
46
+ const id = buildHistoryTestId(service.name, suite.type, file.path);
47
+ const existing = normalized.tests[id];
48
+ const next = existing
49
+ ? { ...existing }
50
+ : {
51
+ id,
52
+ path: file.path,
53
+ service: service.name,
54
+ suiteName: suite.name,
55
+ selectionType: suite.type,
56
+ framework: normalizeArtifactFramework(suite.framework),
57
+ firstSeenAt: seenAt,
58
+ lastSeenAt: seenAt,
59
+ lastRunAt: seenAt,
60
+ runCount: 0,
61
+ passCount: 0,
62
+ failCount: 0,
63
+ skipCount: 0,
64
+ notRunCount: 0,
65
+ avgDurationMs: 0,
66
+ durationCount: 0,
67
+ lastStatus: null,
68
+ };
69
+
70
+ next.path = file.path;
71
+ next.service = service.name;
72
+ next.suiteName = suite.name;
73
+ next.selectionType = suite.type;
74
+ next.framework = normalizeArtifactFramework(suite.framework);
75
+ next.lastSeenAt = seenAt;
76
+ next.lastRunAt = seenAt;
77
+ next.runCount += 1;
78
+ next.lastStatus = file.status;
79
+
80
+ if (file.status === "passed") next.passCount += 1;
81
+ else if (file.status === "failed") next.failCount += 1;
82
+ else if (file.status === "skipped") next.skipCount += 1;
83
+ else next.notRunCount += 1;
84
+
85
+ if (Number(file.durationMs || 0) > 0 && file.status !== "skipped" && file.status !== "not_run") {
86
+ const durationCount = Number(next.durationCount || 0) + 1;
87
+ next.avgDurationMs = Math.max(
88
+ 1,
89
+ Math.round(
90
+ ((Number(next.avgDurationMs || 0) * Number(next.durationCount || 0)) + Number(file.durationMs || 0)) /
91
+ durationCount
92
+ )
93
+ );
94
+ next.durationCount = durationCount;
95
+ }
96
+
97
+ normalized.tests[id] = next;
98
+ }
99
+ }
100
+ }
101
+
102
+ return normalized;
103
+ }
104
+
105
+ export function summarizeHistoryForFiles(history, files = []) {
106
+ const normalized = normalizeHistory(history);
107
+ const byId = new Map();
108
+ for (const file of files) {
109
+ const entry = normalized.tests[file.id] || normalized.tests[buildHistoryTestId(file.service, file.selectionType, file.path)];
110
+ if (!entry) continue;
111
+ byId.set(file.id, {
112
+ firstSeenAt: entry.firstSeenAt || null,
113
+ lastSeenAt: entry.lastSeenAt || null,
114
+ lastRunAt: entry.lastRunAt || null,
115
+ runCount: Number(entry.runCount || 0),
116
+ passCount: Number(entry.passCount || 0),
117
+ failCount: Number(entry.failCount || 0),
118
+ skipCount: Number(entry.skipCount || 0),
119
+ avgDurationMs: Number(entry.avgDurationMs || 0),
120
+ lastStatus: entry.lastStatus || null,
121
+ });
122
+ }
123
+ return byId;
124
+ }
125
+
126
+ export function buildHistoryTestId(serviceName, selectionType, filePath) {
127
+ return [serviceName, selectionType, normalizePath(filePath)].join("|");
128
+ }
129
+
130
+ function normalizeHistory(parsed) {
131
+ const tests = {};
132
+ for (const [id, entry] of Object.entries(parsed?.tests || {})) {
133
+ tests[id] = {
134
+ id,
135
+ path: normalizePath(entry.path || ""),
136
+ service: String(entry.service || ""),
137
+ suiteName: String(entry.suiteName || ""),
138
+ selectionType: String(entry.selectionType || ""),
139
+ framework: normalizeArtifactFramework(entry.framework || "k6"),
140
+ firstSeenAt: entry.firstSeenAt || null,
141
+ lastSeenAt: entry.lastSeenAt || null,
142
+ lastRunAt: entry.lastRunAt || null,
143
+ runCount: Number(entry.runCount || 0),
144
+ passCount: Number(entry.passCount || 0),
145
+ failCount: Number(entry.failCount || 0),
146
+ skipCount: Number(entry.skipCount || 0),
147
+ notRunCount: Number(entry.notRunCount || 0),
148
+ avgDurationMs: Number(entry.avgDurationMs || 0),
149
+ durationCount: Number(entry.durationCount || 0),
150
+ lastStatus: entry.lastStatus || null,
151
+ };
152
+ }
153
+ return {
154
+ version: HISTORY_SCHEMA_VERSION,
155
+ tests,
156
+ };
157
+ }
158
+
159
+ function normalizeArtifactFramework(value) {
160
+ if (value === "default") return "k6";
161
+ return value || "k6";
162
+ }
163
+
164
+ function normalizePath(filePath) {
165
+ return String(filePath).split(path.sep).join("/").replace(/^\.\/+/, "");
166
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildHistoryTestId, createEmptyHistory, summarizeHistoryForFiles, updateHistoryFromRunArtifact } from "./index.mjs";
3
+
4
+ describe("test history", () => {
5
+ it("tracks first seen and pass/fail counters across runs", () => {
6
+ const history = createEmptyHistory();
7
+ const first = updateHistoryFromRunArtifact(
8
+ history,
9
+ {
10
+ generatedAt: "2026-04-30T10:00:00.000Z",
11
+ services: [
12
+ {
13
+ name: "api",
14
+ suites: [
15
+ {
16
+ name: "routes",
17
+ type: "int",
18
+ framework: "default",
19
+ files: [
20
+ {
21
+ path: "src/api/routes/__testkit__/health.int.testkit.ts",
22
+ status: "passed",
23
+ durationMs: 2000,
24
+ },
25
+ ],
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ }
31
+ );
32
+ const second = updateHistoryFromRunArtifact(
33
+ first,
34
+ {
35
+ generatedAt: "2026-05-01T10:00:00.000Z",
36
+ services: [
37
+ {
38
+ name: "api",
39
+ suites: [
40
+ {
41
+ name: "routes",
42
+ type: "int",
43
+ framework: "default",
44
+ files: [
45
+ {
46
+ path: "src/api/routes/__testkit__/health.int.testkit.ts",
47
+ status: "failed",
48
+ durationMs: 4000,
49
+ },
50
+ ],
51
+ },
52
+ ],
53
+ },
54
+ ],
55
+ }
56
+ );
57
+
58
+ const id = buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts");
59
+ expect(second.tests[id]).toMatchObject({
60
+ firstSeenAt: "2026-04-30T10:00:00.000Z",
61
+ lastSeenAt: "2026-05-01T10:00:00.000Z",
62
+ lastRunAt: "2026-05-01T10:00:00.000Z",
63
+ runCount: 2,
64
+ passCount: 1,
65
+ failCount: 1,
66
+ skipCount: 0,
67
+ avgDurationMs: 3000,
68
+ lastStatus: "failed",
69
+ });
70
+ });
71
+
72
+ it("summarizes history for discovery file entries", () => {
73
+ const history = {
74
+ version: 1,
75
+ tests: {
76
+ [buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts")]: {
77
+ id: buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts"),
78
+ path: "src/api/routes/__testkit__/health.int.testkit.ts",
79
+ service: "api",
80
+ suiteName: "routes",
81
+ selectionType: "int",
82
+ framework: "k6",
83
+ firstSeenAt: "2026-04-30T10:00:00.000Z",
84
+ lastSeenAt: "2026-05-01T10:00:00.000Z",
85
+ lastRunAt: "2026-05-01T10:00:00.000Z",
86
+ runCount: 2,
87
+ passCount: 1,
88
+ failCount: 1,
89
+ skipCount: 0,
90
+ notRunCount: 0,
91
+ avgDurationMs: 3000,
92
+ durationCount: 2,
93
+ lastStatus: "failed",
94
+ },
95
+ },
96
+ };
97
+
98
+ const summaries = summarizeHistoryForFiles(history, [
99
+ {
100
+ id: buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts"),
101
+ service: "api",
102
+ selectionType: "int",
103
+ path: "src/api/routes/__testkit__/health.int.testkit.ts",
104
+ },
105
+ ]);
106
+
107
+ expect(summaries.get(buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts"))).toMatchObject({
108
+ runCount: 2,
109
+ passCount: 1,
110
+ failCount: 1,
111
+ avgDurationMs: 3000,
112
+ lastStatus: "failed",
113
+ });
114
+ });
115
+ });
@@ -22,6 +22,10 @@ describe("package metadata", () => {
22
22
  types: "./lib/runtime/index.d.ts",
23
23
  default: "./lib/runtime/index.mjs",
24
24
  });
25
+ expect(packageJson.exports["./discovery"]).toEqual({
26
+ types: "./lib/discovery/index.d.ts",
27
+ default: "./lib/discovery/index.mjs",
28
+ });
25
29
  expect(packageJson.exports["./known-failures"]).toEqual({
26
30
  types: "./lib/known-failures/index.d.ts",
27
31
  default: "./lib/known-failures/index.mjs",
@@ -29,6 +33,7 @@ describe("package metadata", () => {
29
33
  expect(fs.existsSync(path.join(rootDir, "lib", "index.d.ts"))).toBe(true);
30
34
  expect(fs.existsSync(path.join(rootDir, "lib", "setup", "index.d.ts"))).toBe(true);
31
35
  expect(fs.existsSync(path.join(rootDir, "lib", "runtime", "index.d.ts"))).toBe(true);
36
+ expect(fs.existsSync(path.join(rootDir, "lib", "discovery", "index.d.ts"))).toBe(true);
32
37
  expect(fs.existsSync(path.join(rootDir, "lib", "known-failures", "index.d.ts"))).toBe(true);
33
38
  });
34
39
  });
@@ -52,6 +52,7 @@ import { createRuntimeManager } from "./runtime-manager.mjs";
52
52
  import { createWorker, runWorker } from "./worker-loop.mjs";
53
53
  import { findUnmatchedRequestedFiles, isFullRunSelection } from "./selection.mjs";
54
54
  import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
55
+ import { loadHistory, saveHistory, updateHistoryFromRunArtifact } from "../history/index.mjs";
55
56
 
56
57
  export async function runAll(configs, typeValues, suiteSelectors, opts, allConfigs = configs) {
57
58
  const configMap = new Map(allConfigs.map((config) => [config.name, config]));
@@ -302,6 +303,12 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
302
303
  if (opts.writeStatus) {
303
304
  writeStatusArtifact(productDir, enrichedArtifacts.statusArtifact);
304
305
  }
306
+ const nextHistory = updateHistoryFromRunArtifact(
307
+ loadHistory(productDir),
308
+ enrichedArtifacts.runArtifact,
309
+ enrichedArtifacts.runArtifact.generatedAt
310
+ );
311
+ saveHistory(productDir, nextHistory);
305
312
 
306
313
  reporter?.runSummary?.(results, finishedAt - startedAt, knownFailureIssueValidation);
307
314
  await reportTelemetry(telemetry, enrichedArtifacts.runArtifact, reporter);
@@ -106,6 +106,10 @@ 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";
@@ -122,6 +126,7 @@ export interface ServiceConfig {
122
126
  env?: Record<string, string>;
123
127
  envFile?: string;
124
128
  envFiles?: string[];
129
+ browser?: BrowserServiceConfig;
125
130
  local?: false | {
126
131
  baseUrl: string;
127
132
  cwd?: string;
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@elench/testkit-bridge",
3
+ "version": "0.1.58",
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.58"
15
+ },
16
+ "private": false
17
+ }