@elench/testkit 0.1.39 → 0.1.41

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 (41) hide show
  1. package/README.md +43 -13
  2. package/lib/cli/args.mjs +5 -3
  3. package/lib/cli/args.test.mjs +5 -5
  4. package/lib/cli/index.mjs +9 -15
  5. package/lib/config/index.mjs +72 -24
  6. package/lib/database/index.mjs +19 -7
  7. package/lib/database/naming.mjs +2 -2
  8. package/lib/database/naming.test.mjs +2 -2
  9. package/lib/runner/default-runtime-runner.mjs +63 -43
  10. package/lib/runner/execution-config.mjs +24 -64
  11. package/lib/runner/execution-config.test.mjs +30 -72
  12. package/lib/runner/formatting.mjs +0 -15
  13. package/lib/runner/formatting.test.mjs +0 -18
  14. package/lib/runner/lifecycle.mjs +7 -7
  15. package/lib/runner/orchestrator.mjs +9 -10
  16. package/lib/runner/planning.mjs +42 -136
  17. package/lib/runner/planning.test.mjs +70 -174
  18. package/lib/runner/playwright-config.mjs +8 -2
  19. package/lib/runner/playwright-config.test.mjs +20 -5
  20. package/lib/runner/playwright-runner.mjs +32 -54
  21. package/lib/runner/readiness.mjs +2 -2
  22. package/lib/runner/reporting.mjs +3 -3
  23. package/lib/runner/reporting.test.mjs +4 -5
  24. package/lib/runner/results.mjs +1 -1
  25. package/lib/runner/results.test.mjs +1 -1
  26. package/lib/runner/runtime-contexts.mjs +20 -24
  27. package/lib/runner/runtime-manager.mjs +181 -0
  28. package/lib/runner/runtime-manager.test.mjs +181 -0
  29. package/lib/runner/services.mjs +4 -4
  30. package/lib/runner/state.mjs +1 -2
  31. package/lib/runner/state.test.mjs +2 -4
  32. package/lib/runner/template.mjs +90 -60
  33. package/lib/runner/template.test.mjs +59 -27
  34. package/lib/runner/worker-loop.mjs +29 -32
  35. package/lib/runtime/index.d.ts +11 -0
  36. package/lib/runtime/index.mjs +34 -0
  37. package/lib/setup/index.d.ts +15 -10
  38. package/lib/shared/file-timeout.mjs +107 -0
  39. package/lib/shared/file-timeout.test.mjs +64 -0
  40. package/package.json +1 -1
  41. package/lib/runner/stack-manager.mjs +0 -146
@@ -1,6 +1,13 @@
1
1
  import rawHttp from "k6/http";
2
2
  import { Rate, Trend } from "k6/metrics";
3
3
  import { check, fail, group, sleep } from "k6";
4
+ import {
5
+ formatWaitForTimeoutError,
6
+ normalizeWaitIntervalSeconds,
7
+ readFileTimeoutBudget,
8
+ remainingFileTimeoutMs,
9
+ remainingFileTimeoutSeconds,
10
+ } from "../shared/file-timeout.mjs";
4
11
 
5
12
  export { check, fail, group, sleep };
6
13
  export { Rate, Trend };
@@ -34,3 +41,30 @@ export {
34
41
  makeRawReq,
35
42
  makeReq,
36
43
  } from "../runtime-src/k6/http.js";
44
+
45
+ export function remainingTimeSeconds() {
46
+ return remainingFileTimeoutSeconds(readFileTimeoutBudget(__ENV), Date.now());
47
+ }
48
+
49
+ export function waitFor(read, isReady, options = {}) {
50
+ const intervalSeconds = normalizeWaitIntervalSeconds(options.intervalSeconds);
51
+ const description = String(options.description || "condition").trim() || "condition";
52
+ const budget = readFileTimeoutBudget(__ENV);
53
+ let lastValue = read();
54
+
55
+ while (true) {
56
+ if (isReady(lastValue)) {
57
+ return lastValue;
58
+ }
59
+
60
+ const remainingMs = remainingFileTimeoutMs(budget, Date.now());
61
+ if (remainingMs <= 0) {
62
+ break;
63
+ }
64
+
65
+ sleep(Math.min(intervalSeconds, remainingMs / 1000));
66
+ lastValue = read();
67
+ }
68
+
69
+ throw new Error(formatWaitForTimeoutError(description, budget.fileTimeoutSeconds));
70
+ }
@@ -2,6 +2,7 @@ import type { AuthAdapter, HeaderBuilder, HttpSuiteConfig } from "../index";
2
2
 
3
3
  export interface LocalDatabaseConfig {
4
4
  provider: "local";
5
+ binding?: "shared" | "per-runtime";
5
6
  image?: string;
6
7
  password?: string;
7
8
  reset?: boolean;
@@ -33,25 +34,28 @@ export interface SkipConfig {
33
34
  suites?: SkipSuiteRule[];
34
35
  }
35
36
 
36
- export interface SuiteExecutionRule {
37
+ export interface RuntimeConfig {
38
+ instances?: number;
39
+ }
40
+
41
+ export interface SuiteRequirementRule {
37
42
  selector: string;
38
- stackMode: "shared" | "pooled" | "isolated";
43
+ locks?: string[];
39
44
  }
40
45
 
41
- export interface FileExecutionRule {
46
+ export interface FileRequirementRule {
42
47
  path: string;
43
- stackMode: "shared" | "pooled" | "isolated";
48
+ locks?: string[];
44
49
  }
45
50
 
46
- export interface ServiceExecutionConfig {
47
- suites?: SuiteExecutionRule[];
48
- files?: FileExecutionRule[];
51
+ export interface ServiceRequirementConfig {
52
+ suites?: SuiteRequirementRule[];
53
+ files?: FileRequirementRule[];
49
54
  }
50
55
 
51
56
  export interface TestkitExecutionConfig {
52
57
  workers?: number;
53
- stackMode?: "shared" | "pooled" | "isolated";
54
- stackCount?: number;
58
+ fileTimeoutSeconds?: number;
55
59
  }
56
60
 
57
61
  export interface ServiceConfig {
@@ -75,8 +79,9 @@ export interface ServiceConfig {
75
79
  };
76
80
  migrate?: LifecycleConfig;
77
81
  seed?: LifecycleConfig;
82
+ runtime?: RuntimeConfig;
83
+ requirements?: ServiceRequirementConfig;
78
84
  skip?: SkipConfig;
79
- execution?: ServiceExecutionConfig;
80
85
  }
81
86
 
82
87
  export interface TestkitSetup {
@@ -0,0 +1,107 @@
1
+ export const DEFAULT_FILE_TIMEOUT_SECONDS = 60;
2
+ export const FILE_TIMEOUT_INTERVAL_SECONDS = 0.25;
3
+ export const INTERNAL_FILE_TIMEOUT_SECONDS_ENV = "TESTKIT_INTERNAL_FILE_TIMEOUT_SECONDS";
4
+ export const INTERNAL_FILE_DEADLINE_MS_ENV = "TESTKIT_INTERNAL_FILE_DEADLINE_MS";
5
+
6
+ export function parseFileTimeoutOption(value) {
7
+ return parsePositiveInteger(value, "--file-timeout-seconds");
8
+ }
9
+
10
+ export function normalizeFileTimeoutSeconds(
11
+ value,
12
+ label = "execution.fileTimeoutSeconds"
13
+ ) {
14
+ return normalizePositiveInteger(value, label);
15
+ }
16
+
17
+ export function normalizeWaitIntervalSeconds(value) {
18
+ if (value == null) return FILE_TIMEOUT_INTERVAL_SECONDS;
19
+ const normalized = Number(value);
20
+ if (!Number.isFinite(normalized) || normalized <= 0) {
21
+ throw new Error("waitFor intervalSeconds must be a positive number.");
22
+ }
23
+ return normalized;
24
+ }
25
+
26
+ export function buildFileTimeoutEnv(fileTimeoutSeconds, startedAtMs) {
27
+ const normalizedTimeoutSeconds = normalizeFileTimeoutSeconds(fileTimeoutSeconds);
28
+ const normalizedStartedAtMs = normalizeTimestamp(startedAtMs, "startedAtMs");
29
+
30
+ return {
31
+ [INTERNAL_FILE_TIMEOUT_SECONDS_ENV]: String(normalizedTimeoutSeconds),
32
+ [INTERNAL_FILE_DEADLINE_MS_ENV]: String(
33
+ normalizedStartedAtMs + normalizedTimeoutSeconds * 1000
34
+ ),
35
+ };
36
+ }
37
+
38
+ export function readFileTimeoutBudget(env) {
39
+ if (!env || typeof env !== "object") {
40
+ throw new Error("Runtime file timeout budget is missing.");
41
+ }
42
+
43
+ return {
44
+ fileTimeoutSeconds: normalizeFileTimeoutSeconds(
45
+ env[INTERNAL_FILE_TIMEOUT_SECONDS_ENV],
46
+ INTERNAL_FILE_TIMEOUT_SECONDS_ENV
47
+ ),
48
+ deadlineMs: normalizeTimestamp(
49
+ env[INTERNAL_FILE_DEADLINE_MS_ENV],
50
+ INTERNAL_FILE_DEADLINE_MS_ENV
51
+ ),
52
+ };
53
+ }
54
+
55
+ export function remainingFileTimeoutMs(budget, nowMs = Date.now()) {
56
+ const normalizedNowMs = normalizeTimestamp(nowMs, "nowMs");
57
+ const deadlineMs = normalizeTimestamp(budget?.deadlineMs, "deadlineMs");
58
+ return Math.max(0, deadlineMs - normalizedNowMs);
59
+ }
60
+
61
+ export function remainingFileTimeoutSeconds(budget, nowMs = Date.now()) {
62
+ return remainingFileTimeoutMs(budget, nowMs) / 1000;
63
+ }
64
+
65
+ export function formatWaitForTimeoutError(description, fileTimeoutSeconds) {
66
+ const normalizedDescription = String(description || "condition").trim() || "condition";
67
+ const normalizedFileTimeoutSeconds = normalizeFileTimeoutSeconds(
68
+ fileTimeoutSeconds,
69
+ "fileTimeoutSeconds"
70
+ );
71
+ return (
72
+ `Timed out waiting for ${normalizedDescription} before the ` +
73
+ `${normalizedFileTimeoutSeconds}s test file timeout`
74
+ );
75
+ }
76
+
77
+ export function formatFileTimeoutBudgetError(fileTimeoutSeconds) {
78
+ const normalizedFileTimeoutSeconds = normalizeFileTimeoutSeconds(
79
+ fileTimeoutSeconds,
80
+ "fileTimeoutSeconds"
81
+ );
82
+ return `Default runtime exceeded the ${normalizedFileTimeoutSeconds}s test file timeout`;
83
+ }
84
+
85
+ function parsePositiveInteger(value, label) {
86
+ const parsed = Number.parseInt(String(value), 10);
87
+ if (!Number.isInteger(parsed) || parsed <= 0) {
88
+ throw new Error(`Invalid ${label} value "${value}". Expected a positive integer.`);
89
+ }
90
+ return parsed;
91
+ }
92
+
93
+ function normalizePositiveInteger(value, label) {
94
+ const parsed = Number(value);
95
+ if (!Number.isInteger(parsed) || parsed <= 0) {
96
+ throw new Error(`${label} must be a positive integer.`);
97
+ }
98
+ return parsed;
99
+ }
100
+
101
+ function normalizeTimestamp(value, label) {
102
+ const parsed = Number(value);
103
+ if (!Number.isInteger(parsed) || parsed <= 0) {
104
+ throw new Error(`${label} must be a positive integer timestamp.`);
105
+ }
106
+ return parsed;
107
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildFileTimeoutEnv,
4
+ DEFAULT_FILE_TIMEOUT_SECONDS,
5
+ formatFileTimeoutBudgetError,
6
+ formatWaitForTimeoutError,
7
+ normalizeFileTimeoutSeconds,
8
+ normalizeWaitIntervalSeconds,
9
+ parseFileTimeoutOption,
10
+ readFileTimeoutBudget,
11
+ remainingFileTimeoutMs,
12
+ remainingFileTimeoutSeconds,
13
+ } from "./file-timeout.mjs";
14
+
15
+ describe("file-timeout", () => {
16
+ it("normalizes positive file timeout values", () => {
17
+ expect(parseFileTimeoutOption("45")).toBe(45);
18
+ expect(normalizeFileTimeoutSeconds(DEFAULT_FILE_TIMEOUT_SECONDS)).toBe(60);
19
+ expect(() => parseFileTimeoutOption("0")).toThrow(
20
+ 'Invalid --file-timeout-seconds value "0"'
21
+ );
22
+ expect(() => normalizeFileTimeoutSeconds(0)).toThrow(
23
+ "execution.fileTimeoutSeconds must be a positive integer."
24
+ );
25
+ });
26
+
27
+ it("builds and reads runtime timeout budget env", () => {
28
+ const env = buildFileTimeoutEnv(45, 1_000);
29
+ expect(env).toEqual({
30
+ TESTKIT_INTERNAL_FILE_TIMEOUT_SECONDS: "45",
31
+ TESTKIT_INTERNAL_FILE_DEADLINE_MS: "46000",
32
+ });
33
+
34
+ expect(readFileTimeoutBudget(env)).toEqual({
35
+ fileTimeoutSeconds: 45,
36
+ deadlineMs: 46_000,
37
+ });
38
+ });
39
+
40
+ it("computes remaining file timeout budget", () => {
41
+ const budget = {
42
+ fileTimeoutSeconds: 45,
43
+ deadlineMs: 46_000,
44
+ };
45
+
46
+ expect(remainingFileTimeoutMs(budget, 40_000)).toBe(6_000);
47
+ expect(remainingFileTimeoutMs(budget, 47_000)).toBe(0);
48
+ expect(remainingFileTimeoutSeconds(budget, 44_500)).toBe(1.5);
49
+ });
50
+
51
+ it("normalizes wait intervals and timeout errors", () => {
52
+ expect(normalizeWaitIntervalSeconds()).toBe(0.25);
53
+ expect(normalizeWaitIntervalSeconds(0.5)).toBe(0.5);
54
+ expect(() => normalizeWaitIntervalSeconds(0)).toThrow(
55
+ "waitFor intervalSeconds must be a positive number."
56
+ );
57
+ expect(formatWaitForTimeoutError("cron worker pickup", 60)).toBe(
58
+ "Timed out waiting for cron worker pickup before the 60s test file timeout"
59
+ );
60
+ expect(formatFileTimeoutBudgetError(60)).toBe(
61
+ "Default runtime exceeded the 60s test file timeout"
62
+ );
63
+ });
64
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
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",
@@ -1,146 +0,0 @@
1
- import { buildStackIds } from "./execution-config.mjs";
2
- import {
3
- cleanupStackContext,
4
- createStackContext,
5
- ensureStackContextReady,
6
- } from "./runtime-contexts.mjs";
7
-
8
- export function createStackManager({ productDir, graphs, execution, lifecycle }) {
9
- const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
10
- const pools = new Map();
11
-
12
- return {
13
- async acquire(batch) {
14
- const pool = getPool(pools, graphByKey, batch, execution, productDir, lifecycle);
15
- while (true) {
16
- if (lifecycle.isStopRequested()) {
17
- throw lifecycle.signal.reason || new Error("testkit run interrupted");
18
- }
19
-
20
- const slot = claimSlot(pool, batch.accessMode);
21
- if (!slot) {
22
- await sleep(25);
23
- continue;
24
- }
25
-
26
- try {
27
- const context = await getReadyContext(slot, graphByKey, productDir, batch, lifecycle);
28
- return {
29
- slot,
30
- context,
31
- };
32
- } catch (error) {
33
- releaseSlot(slot, batch.accessMode);
34
- await invalidateSlot(slot, lifecycle);
35
- throw error;
36
- }
37
- }
38
- },
39
- async release(lease, options = {}) {
40
- if (!lease?.slot) return;
41
- releaseSlot(lease.slot, options.accessMode || lease.slot.lastAccessMode);
42
- if (options.invalidate) {
43
- await invalidateSlot(lease.slot, lifecycle);
44
- }
45
- },
46
- async cleanupAll() {
47
- for (const pool of pools.values()) {
48
- for (const slot of pool.slots) {
49
- await invalidateSlot(slot, lifecycle);
50
- }
51
- }
52
- },
53
- };
54
- }
55
-
56
- function getPool(pools, graphByKey, batch, execution, productDir, lifecycle) {
57
- const key = `${batch.graphKey}:${batch.stackMode}`;
58
- const existing = pools.get(key);
59
- if (existing) return existing;
60
-
61
- const graph = graphByKey.get(batch.graphKey);
62
- if (!graph) {
63
- throw new Error(`Unknown graph "${batch.graphKey}"`);
64
- }
65
-
66
- const slots = buildPoolStackIds(batch.stackMode, execution).map((stackId) => ({
67
- graph,
68
- stackId,
69
- context: null,
70
- activeSharedCount: 0,
71
- exclusiveActive: false,
72
- lastAccessMode: null,
73
- contextPromise: null,
74
- }));
75
- const pool = {
76
- productDir,
77
- lifecycle,
78
- slots,
79
- };
80
- pools.set(key, pool);
81
- return pool;
82
- }
83
-
84
- function buildPoolStackIds(stackMode, execution) {
85
- if (stackMode === "isolated") {
86
- return Array.from({ length: execution.workers }, (_unused, index) => `isolated-${index + 1}`);
87
- }
88
- if (stackMode === "shared") {
89
- return ["shared"];
90
- }
91
- return buildStackIds({
92
- stackMode: "pooled",
93
- stackCount: execution.stackCount,
94
- });
95
- }
96
-
97
- function claimSlot(pool, accessMode) {
98
- if (accessMode === "shared") {
99
- const candidates = pool.slots.filter((slot) => !slot.exclusiveActive);
100
- if (candidates.length === 0) return null;
101
- const slot = [...candidates].sort((left, right) => left.activeSharedCount - right.activeSharedCount)[0];
102
- slot.activeSharedCount += 1;
103
- slot.lastAccessMode = "shared";
104
- return slot;
105
- }
106
-
107
- const slot = pool.slots.find((candidate) => !candidate.exclusiveActive && candidate.activeSharedCount === 0);
108
- if (!slot) return null;
109
- slot.exclusiveActive = true;
110
- slot.lastAccessMode = "exclusive";
111
- return slot;
112
- }
113
-
114
- async function getReadyContext(slot, graphByKey, productDir, batch, lifecycle) {
115
- if (!slot.context) {
116
- slot.context = createStackContext(slot.stackId, slot.graph, productDir);
117
- lifecycle.trackGraphContext(slot.context);
118
- }
119
- if (!slot.contextPromise) {
120
- slot.contextPromise = Promise.resolve(slot.context);
121
- }
122
- await slot.contextPromise;
123
- await ensureStackContextReady(slot.context, batch, lifecycle);
124
- return slot.context;
125
- }
126
-
127
- function releaseSlot(slot, accessMode) {
128
- if (accessMode === "shared") {
129
- slot.activeSharedCount = Math.max(0, slot.activeSharedCount - 1);
130
- return;
131
- }
132
- slot.exclusiveActive = false;
133
- }
134
-
135
- async function invalidateSlot(slot, lifecycle) {
136
- if (!slot.context) return;
137
- await cleanupStackContext(slot.context, lifecycle);
138
- slot.context = null;
139
- slot.contextPromise = null;
140
- slot.activeSharedCount = 0;
141
- slot.exclusiveActive = false;
142
- }
143
-
144
- function sleep(ms) {
145
- return new Promise((resolve) => setTimeout(resolve, ms));
146
- }