@elench/testkit 0.1.32 → 0.1.34

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,133 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { execaCommand } from "execa";
4
+ import { resolveServiceCwd } from "../config/index.mjs";
5
+ import { prepareDatabaseRuntime } from "../database/index.mjs";
6
+ import { buildExecutionEnv, resolveWorkerRuntimeConfigs } from "./template.mjs";
7
+ import { writeGraphMetadata } from "./state.mjs";
8
+ import { startLocalServices, stopLocalServices } from "./services.mjs";
9
+
10
+ export function createGraphContext(worker, graph) {
11
+ const graphDir = path.join(worker.productDir, ".testkit", "_graphs", graph.dirName);
12
+ const workerStateDir = path.join(graphDir, "workers", `worker-${worker.workerId}`);
13
+ fs.mkdirSync(workerStateDir, { recursive: true });
14
+ writeGraphMetadata(graphDir, graph);
15
+
16
+ const runtimeConfigs = resolveWorkerRuntimeConfigs(
17
+ graph.rootConfig,
18
+ graph.runtimeConfigs,
19
+ worker.workerId,
20
+ workerStateDir
21
+ );
22
+
23
+ return {
24
+ graphKey: graph.key,
25
+ graphDir,
26
+ workerStateDir,
27
+ runtimeConfigs,
28
+ configByName: new Map(runtimeConfigs.map((config) => [config.name, config])),
29
+ prepared: false,
30
+ started: false,
31
+ startedServices: [],
32
+ };
33
+ }
34
+
35
+ export async function ensureWorkerGraph(worker, batch, graphByKey, lifecycle) {
36
+ const graph = graphByKey.get(batch.graphKey);
37
+ if (!graph) {
38
+ throw new Error(`Unknown graph "${batch.graphKey}"`);
39
+ }
40
+
41
+ if (worker.currentGraphKey && worker.currentGraphKey !== batch.graphKey) {
42
+ await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
43
+ worker.graphSwitches += 1;
44
+ worker.currentGraphKey = null;
45
+ }
46
+
47
+ let context = worker.graphContexts.get(batch.graphKey);
48
+ if (!context) {
49
+ context = createGraphContext(worker, graph);
50
+ worker.graphContexts.set(batch.graphKey, context);
51
+ lifecycle.trackGraphContext(context);
52
+ }
53
+
54
+ if (!context.prepared) {
55
+ await prepareDatabases(context.runtimeConfigs);
56
+ context.prepared = true;
57
+ }
58
+
59
+ if (batchNeedsLocalRuntime(batch) && !context.started) {
60
+ context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
61
+ context.started = true;
62
+ }
63
+
64
+ worker.currentGraphKey = batch.graphKey;
65
+ return context;
66
+ }
67
+
68
+ export async function deactivateGraphContext(context, lifecycle) {
69
+ if (!context?.started) return;
70
+ await stopLocalServices(context.startedServices, lifecycle);
71
+ context.started = false;
72
+ context.startedServices = [];
73
+ }
74
+
75
+ export async function resetCurrentGraph(worker, lifecycle) {
76
+ if (!worker.currentGraphKey) return;
77
+ await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
78
+ worker.currentGraphKey = null;
79
+ }
80
+
81
+ export async function cleanupWorker(worker, lifecycle) {
82
+ for (const context of worker.graphContexts.values()) {
83
+ await deactivateGraphContext(context, lifecycle);
84
+ }
85
+ worker.currentGraphKey = null;
86
+ }
87
+
88
+ export async function prepareDatabases(runtimeConfigs) {
89
+ for (const config of runtimeConfigs) {
90
+ await prepareDatabaseRuntime(config, {
91
+ runMigrate: config.testkit.migrate
92
+ ? (databaseUrl) => runMigrate(config, databaseUrl)
93
+ : null,
94
+ runSeed: config.testkit.seed ? (databaseUrl) => runSeed(config, databaseUrl) : null,
95
+ });
96
+ }
97
+ }
98
+
99
+ async function runMigrate(config, databaseUrl) {
100
+ const migrate = config.testkit.migrate;
101
+ if (!migrate) return;
102
+
103
+ const env = buildExecutionEnv(config, {}, process.env);
104
+ if (databaseUrl) env.DATABASE_URL = databaseUrl;
105
+
106
+ console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
107
+ await execaCommand(migrate.cmd, {
108
+ cwd: resolveServiceCwd(config.productDir, migrate.cwd),
109
+ env,
110
+ stdio: "inherit",
111
+ shell: true,
112
+ });
113
+ }
114
+
115
+ async function runSeed(config, databaseUrl) {
116
+ const seed = config.testkit.seed;
117
+ if (!seed) return;
118
+
119
+ const env = buildExecutionEnv(config, {}, process.env);
120
+ if (databaseUrl) env.DATABASE_URL = databaseUrl;
121
+
122
+ console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
123
+ await execaCommand(seed.cmd, {
124
+ cwd: resolveServiceCwd(config.productDir, seed.cwd),
125
+ env,
126
+ stdio: "inherit",
127
+ shell: true,
128
+ });
129
+ }
130
+
131
+ function batchNeedsLocalRuntime(batch) {
132
+ return batch.type !== "dal";
133
+ }
@@ -0,0 +1,33 @@
1
+ export function findUnmatchedRequestedFiles(
2
+ configs,
3
+ suiteType,
4
+ suiteNames,
5
+ framework,
6
+ fileNames,
7
+ collectSuites,
8
+ normalizePathSeparators
9
+ ) {
10
+ const matchedFiles = new Set();
11
+ for (const config of configs) {
12
+ const suites = collectSuites(config, suiteType, suiteNames, framework, []);
13
+ for (const suite of suites) {
14
+ for (const file of suite.files) {
15
+ matchedFiles.add(normalizePathSeparators(file));
16
+ }
17
+ }
18
+ }
19
+
20
+ return [...new Set(fileNames.map(normalizePathSeparators))].filter(
21
+ (file) => !matchedFiles.has(file)
22
+ );
23
+ }
24
+
25
+ export function isFullRunSelection(suiteNames, fileNames, framework, shard, serviceFilter) {
26
+ return (
27
+ (suiteNames || []).length === 0 &&
28
+ (fileNames || []).length === 0 &&
29
+ (framework || "all") === "all" &&
30
+ (shard || null) === null &&
31
+ (serviceFilter || null) === null
32
+ );
33
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { findUnmatchedRequestedFiles, isFullRunSelection } from "./selection.mjs";
3
+
4
+ describe("runner selection", () => {
5
+ it("finds unmatched requested files", () => {
6
+ const unmatched = findUnmatchedRequestedFiles(
7
+ [{ name: "api" }],
8
+ "int",
9
+ [],
10
+ "all",
11
+ ["tests/a.int.testkit.ts", "tests/missing.int.testkit.ts"],
12
+ () => [{ files: ["tests/a.int.testkit.ts"] }],
13
+ (value) => value
14
+ );
15
+
16
+ expect(unmatched).toEqual(["tests/missing.int.testkit.ts"]);
17
+ });
18
+
19
+ it("detects a full run selection", () => {
20
+ expect(isFullRunSelection([], [], "all", null, null)).toBe(true);
21
+ expect(isFullRunSelection(["auth"], [], "all", null, null)).toBe(false);
22
+ expect(isFullRunSelection([], ["a"], "all", null, null)).toBe(false);
23
+ expect(isFullRunSelection([], [], "playwright", null, null)).toBe(false);
24
+ });
25
+ });
@@ -0,0 +1,73 @@
1
+ import { resolveServiceCwd } from "../config/index.mjs";
2
+ import { buildExecutionEnv, numericPortFromUrl } from "./template.mjs";
3
+ import { DEFAULT_READY_TIMEOUT_MS, assertLocalServicePortsAvailable, isPortInUse, waitForReady } from "./readiness.mjs";
4
+ import { pipeOutput, startDetachedCommand, stopChildProcess, sleep } from "./processes.mjs";
5
+ import { readDatabaseUrl } from "./state-io.mjs";
6
+
7
+ export async function startLocalServices(runtimeConfigs, lifecycle) {
8
+ const started = [];
9
+
10
+ try {
11
+ for (const config of runtimeConfigs) {
12
+ if (!config.testkit.local) continue;
13
+ const proc = await startLocalService(config, lifecycle);
14
+ started.push(proc);
15
+ }
16
+ } catch (error) {
17
+ await stopLocalServices(started, lifecycle);
18
+ throw error;
19
+ }
20
+
21
+ return started;
22
+ }
23
+
24
+ export async function startLocalService(config, lifecycle) {
25
+ const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
26
+ const env = buildExecutionEnv(config, config.testkit.local.env, process.env);
27
+ const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
28
+ if (port) {
29
+ env.PORT = String(port);
30
+ }
31
+
32
+ const dbUrl = readDatabaseUrl(config.stateDir);
33
+ if (dbUrl) {
34
+ env.DATABASE_URL = dbUrl;
35
+ }
36
+
37
+ await assertLocalServicePortsAvailable(config, isPortInUse);
38
+
39
+ console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
40
+ const child = startDetachedCommand(config.testkit.local.start, cwd, env);
41
+
42
+ const outputDrains = [
43
+ pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
44
+ pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
45
+ ];
46
+ lifecycle.registerService(config, child, cwd);
47
+
48
+ const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
49
+
50
+ try {
51
+ await waitForReady({
52
+ name: `${config.workerLabel}:${config.name}`,
53
+ url: config.testkit.local.readyUrl,
54
+ timeoutMs: readyTimeoutMs,
55
+ process: child,
56
+ signal: lifecycle.signal,
57
+ sleep,
58
+ });
59
+ } catch (error) {
60
+ await stopChildProcess(child, outputDrains);
61
+ lifecycle.unregisterService(child.pid);
62
+ throw error;
63
+ }
64
+
65
+ return { name: config.name, child, outputDrains };
66
+ }
67
+
68
+ export async function stopLocalServices(started, lifecycle) {
69
+ for (const service of [...started].reverse()) {
70
+ await stopChildProcess(service.child, service.outputDrains);
71
+ lifecycle?.unregisterService(service.child.pid);
72
+ }
73
+ }
@@ -0,0 +1,25 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ export function readDatabaseUrl(stateDir) {
5
+ return readStateValue(path.join(stateDir, "database_url"));
6
+ }
7
+
8
+ export function readStateValue(filePath) {
9
+ if (!fs.existsSync(filePath)) return null;
10
+ return fs.readFileSync(filePath, "utf8").trim();
11
+ }
12
+
13
+ export function printStateDir(dir, indent) {
14
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
15
+ const filePath = path.join(dir, entry.name);
16
+ if (entry.isDirectory()) {
17
+ console.log(`${indent}${entry.name}/`);
18
+ printStateDir(filePath, `${indent} `);
19
+ continue;
20
+ }
21
+ const value =
22
+ entry.name === "password" ? "********" : fs.readFileSync(filePath, "utf8").trim();
23
+ console.log(`${indent}${entry.name}: ${value}`);
24
+ }
25
+ }
@@ -0,0 +1,95 @@
1
+ import { formatError } from "./formatting.mjs";
2
+ import { runDalBatch, runHttpK6Batch } from "./default-runtime-runner.mjs";
3
+ import { runPlaywrightBatch } from "./playwright-runner.mjs";
4
+ import {
5
+ cleanupWorker,
6
+ ensureWorkerGraph,
7
+ resetCurrentGraph,
8
+ } from "./runtime-contexts.mjs";
9
+
10
+ const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
11
+
12
+ export function createWorker(workerId, productDir) {
13
+ return {
14
+ workerId,
15
+ productDir,
16
+ currentGraphKey: null,
17
+ graphContexts: new Map(),
18
+ graphSwitches: 0,
19
+ taskCount: 0,
20
+ };
21
+ }
22
+
23
+ export async function runWorker(
24
+ worker,
25
+ queue,
26
+ graphByKey,
27
+ trackers,
28
+ timingUpdates,
29
+ lifecycle,
30
+ claimNextBatch,
31
+ recordTaskOutcome,
32
+ recordGraphError
33
+ ) {
34
+ const startedAt = Date.now();
35
+ console.log(`\n══ global worker ${worker.workerId} ══`);
36
+ const errors = [];
37
+
38
+ try {
39
+ while (true) {
40
+ if (lifecycle.isStopRequested()) break;
41
+ const batch = claimNextBatch(queue, worker.currentGraphKey);
42
+ if (!batch) break;
43
+
44
+ try {
45
+ const context = await ensureWorkerGraph(worker, batch, graphByKey, lifecycle);
46
+ const outcomes = await runBatch(context, batch, lifecycle);
47
+ for (const outcome of outcomes) {
48
+ recordTaskOutcome(trackers, outcome.task, outcome);
49
+ timingUpdates.push({
50
+ key: outcome.task.timingKey,
51
+ durationMs: outcome.durationMs,
52
+ });
53
+ worker.taskCount += 1;
54
+ }
55
+ } catch (error) {
56
+ const message = formatError(error);
57
+ errors.push(message);
58
+ recordGraphError(trackers, graphByKey.get(batch.graphKey), message);
59
+ await resetCurrentGraph(worker, lifecycle);
60
+ }
61
+ }
62
+ } finally {
63
+ await cleanupWorker(worker, lifecycle);
64
+ }
65
+
66
+ return {
67
+ workerId: worker.workerId,
68
+ failed: errors.length > 0,
69
+ durationMs: Date.now() - startedAt,
70
+ taskCount: worker.taskCount,
71
+ graphSwitches: worker.graphSwitches,
72
+ errors,
73
+ };
74
+ }
75
+
76
+ async function runBatch(context, batch, lifecycle) {
77
+ const targetConfig = context.configByName.get(batch.targetName);
78
+ if (!targetConfig) {
79
+ throw new Error(`Worker graph missing target config "${batch.targetName}"`);
80
+ }
81
+
82
+ if (batch.framework === "playwright") {
83
+ return runPlaywrightBatch(targetConfig, batch, lifecycle);
84
+ }
85
+ if (batch.type === "dal") {
86
+ return runDalBatch(targetConfig, batch, lifecycle);
87
+ }
88
+ if (batch.framework === "k6" && HTTP_K6_TYPES.has(batch.type)) {
89
+ return runHttpK6Batch(targetConfig, batch, lifecycle);
90
+ }
91
+
92
+ throw new Error(
93
+ `Unsupported task combination for ${batch.targetName}: type=${batch.type} framework=${batch.framework}`
94
+ );
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",