@elench/testkit 0.1.39 → 0.1.40

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.
package/README.md CHANGED
@@ -24,6 +24,9 @@ npx @elench/testkit --type pw
24
24
  # Shared local stack with parallel workers
25
25
  npx @elench/testkit --workers 8 --stack-mode shared
26
26
 
27
+ # One file-level wall clock budget for every suite file
28
+ npx @elench/testkit --file-timeout-seconds 60
29
+
27
30
  # Two reusable local stacks for browser-heavy suites
28
31
  npx @elench/testkit --workers 6 --stack-mode pooled --stack-count 2
29
32
 
@@ -66,6 +69,7 @@ import {
66
69
  export default defineTestkitSetup({
67
70
  execution: {
68
71
  workers: 8,
72
+ fileTimeoutSeconds: 60,
69
73
  stackMode: "shared",
70
74
  stackCount: 1,
71
75
  },
@@ -118,6 +122,7 @@ export default defineTestkitSetup({
118
122
  for:
119
123
 
120
124
  - worker and stack topology
125
+ - per-file wall clock timeout budget
121
126
  - multi-service graphs
122
127
  - local DB configuration
123
128
  - migrate / seed commands
@@ -168,7 +173,20 @@ export default suite;
168
173
  Low-level runtime primitives remain available:
169
174
 
170
175
  ```ts
171
- import { check, group, http } from "@elench/testkit/runtime";
176
+ import { check, group, http, waitFor } from "@elench/testkit/runtime";
177
+ ```
178
+
179
+ `waitFor()` consumes the file budget configured by `execution.fileTimeoutSeconds`.
180
+ Consumers should not set local timeout values in test files.
181
+
182
+ ```ts
183
+ import { waitFor } from "@elench/testkit/runtime";
184
+
185
+ const response = waitFor(
186
+ () => req("GET", "/api/v1/jobs/123", setupData),
187
+ (res) => JSON.parse(res.body).data?.status === "completed",
188
+ { description: "job 123 to complete" }
189
+ );
172
190
  ```
173
191
 
174
192
  ## Discovery
package/lib/cli/args.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "path";
2
2
  import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
3
3
  import {
4
+ parseFileTimeoutOption,
4
5
  parseStackCountOption,
5
6
  parseStackModeOption,
6
7
  parseWorkersOption,
@@ -45,7 +46,12 @@ export function parseSuiteOption(values) {
45
46
  return parseSuiteSelectors(input);
46
47
  }
47
48
 
48
- export { parseWorkersOption, parseStackModeOption, parseStackCountOption };
49
+ export {
50
+ parseFileTimeoutOption,
51
+ parseWorkersOption,
52
+ parseStackModeOption,
53
+ parseStackCountOption,
54
+ };
49
55
 
50
56
  export function parseShardOption(value) {
51
57
  if (!value) return null;
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ parseFileTimeoutOption,
3
4
  parseShardOption,
4
5
  parseStackCountOption,
5
6
  parseStackModeOption,
@@ -59,9 +60,13 @@ describe("cli-args", () => {
59
60
 
60
61
  it("parses and validates execution options", () => {
61
62
  expect(parseWorkersOption("3")).toBe(3);
63
+ expect(parseFileTimeoutOption("45")).toBe(45);
62
64
  expect(parseStackCountOption("2")).toBe(2);
63
65
  expect(parseStackModeOption("pooled")).toBe("pooled");
64
66
  expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
67
+ expect(() => parseFileTimeoutOption("0")).toThrow(
68
+ "Invalid --file-timeout-seconds value"
69
+ );
65
70
  expect(() => parseStackModeOption("legacy")).toThrow("Invalid --stack-mode value");
66
71
  });
67
72
 
package/lib/cli/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { cac } from "cac";
2
2
  import { loadConfigs } from "../config/index.mjs";
3
3
  import {
4
+ parseFileTimeoutOption,
4
5
  parseShardOption,
5
6
  parseStackCountOption,
6
7
  parseStackModeOption,
@@ -24,12 +25,9 @@ export function run() {
24
25
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
25
26
  .option("-f, --file <path>", "Run specific file(s)", { default: [] })
26
27
  .option("--dir <path>", "Explicit product directory")
27
- .option("--workers <n>", "Number of test executors for the whole run", {
28
- default: "1",
29
- })
30
- .option("--stack-mode <mode>", "Stack topology: shared, pooled, or isolated", {
31
- default: "isolated",
32
- })
28
+ .option("--workers <n>", "Number of test executors for the whole run")
29
+ .option("--file-timeout-seconds <n>", "Per-file wall-clock timeout in seconds")
30
+ .option("--stack-mode <mode>", "Stack topology: shared, pooled, or isolated")
33
31
  .option("--stack-count <n>", "Number of prepared stacks when stack-mode=pooled")
34
32
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
35
33
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
@@ -68,8 +66,12 @@ export function run() {
68
66
  return;
69
67
  }
70
68
 
71
- const workers = parseWorkersOption(options.workers);
72
- const stackMode = parseStackModeOption(options.stackMode);
69
+ const workers = options.workers == null ? null : parseWorkersOption(options.workers);
70
+ const fileTimeoutSeconds =
71
+ options.fileTimeoutSeconds == null
72
+ ? null
73
+ : parseFileTimeoutOption(options.fileTimeoutSeconds);
74
+ const stackMode = options.stackMode == null ? null : parseStackModeOption(options.stackMode);
73
75
  const stackCount =
74
76
  options.stackCount == null ? null : parseStackCountOption(options.stackCount);
75
77
  const shard = parseShardOption(options.shard);
@@ -87,6 +89,7 @@ export function run() {
87
89
  typeValues,
88
90
  fileNames,
89
91
  workers,
92
+ fileTimeoutSeconds,
90
93
  stackMode,
91
94
  stackCount,
92
95
  shard,
@@ -8,7 +8,11 @@ import {
8
8
  parseSuiteSelectors,
9
9
  suiteSelectionType,
10
10
  } from "../runner/suite-selection.mjs";
11
- import { normalizeExecutionConfig, normalizeStackModeValue } from "../runner/execution-config.mjs";
11
+ import {
12
+ DEFAULT_FILE_TIMEOUT_SECONDS,
13
+ normalizeExecutionConfig,
14
+ normalizeStackModeValue,
15
+ } from "../runner/execution-config.mjs";
12
16
 
13
17
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
14
18
  const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
@@ -587,6 +591,7 @@ function normalizeRepoExecution(execution) {
587
591
  if (!execution) {
588
592
  return normalizeExecutionConfig({
589
593
  workers: 1,
594
+ fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
590
595
  stackMode: "isolated",
591
596
  stackCount: 1,
592
597
  });
@@ -9,6 +9,10 @@ import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
9
9
  import { formatBatchDescriptor } from "./formatting.mjs";
10
10
  import { buildExecutionEnv } from "./template.mjs";
11
11
  import { readDatabaseUrl } from "./state-io.mjs";
12
+ import {
13
+ buildFileTimeoutEnv,
14
+ formatFileTimeoutBudgetError,
15
+ } from "../shared/file-timeout.mjs";
12
16
 
13
17
  export async function runHttpK6Batch(targetConfig, batch, lifecycle) {
14
18
  const baseUrl = targetConfig.testkit.local?.baseUrl;
@@ -72,20 +76,27 @@ async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
72
76
 
73
77
  export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle, firstLine) {
74
78
  const k6Binary = resolveK6Binary();
79
+ const getFirstLine = firstLine || defaultFirstLine;
75
80
  const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
76
81
  fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
77
82
  const startedAt = Date.now();
78
- const result = await execa(
83
+ const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
84
+ const subprocess = execa(
79
85
  k6Binary,
80
86
  [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)],
81
87
  {
82
88
  cwd: targetConfig.productDir,
83
- env: buildExecutionEnv(targetConfig, {}, process.env),
89
+ env: buildExecutionEnv(
90
+ targetConfig,
91
+ buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
92
+ process.env
93
+ ),
84
94
  reject: false,
85
95
  cancelSignal: lifecycle.signal,
86
96
  forceKillAfterDelay: 5_000,
87
97
  }
88
98
  );
99
+ const { result, timedOut } = await settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds);
89
100
 
90
101
  const stdout = parseDefaultRuntimeOutput(result.stdout || "");
91
102
  const stderr = parseDefaultRuntimeOutput(result.stderr || "");
@@ -98,7 +109,9 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
98
109
  task,
99
110
  [...stdout.artifacts, ...stderr.artifacts]
100
111
  );
101
- const runtimeError = determineDefaultRuntimeFailure(result, summary, firstLine);
112
+ const runtimeError = timedOut
113
+ ? formatFileTimeoutBudgetError(fileTimeoutSeconds)
114
+ : determineDefaultRuntimeFailure(result, summary, getFirstLine);
102
115
  const finishedAt = Date.now();
103
116
 
104
117
  return {
@@ -112,6 +125,35 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
112
125
  };
113
126
  }
114
127
 
128
+ function defaultFirstLine(output) {
129
+ return String(output || "")
130
+ .split(/\r?\n/)
131
+ .map((line) => line.trim())
132
+ .find(Boolean) || null;
133
+ }
134
+
135
+ async function settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds) {
136
+ const timeoutMs = fileTimeoutSeconds * 1000 + 1_000;
137
+ let timeoutHandle = null;
138
+ let timedOut = false;
139
+
140
+ try {
141
+ return await Promise.race([
142
+ subprocess.then((result) => ({ result, timedOut })),
143
+ new Promise((resolve) => {
144
+ timeoutHandle = setTimeout(async () => {
145
+ timedOut = true;
146
+ subprocess.kill("SIGTERM");
147
+ const result = await subprocess.catch((error) => error);
148
+ resolve({ result, timedOut: true });
149
+ }, timeoutMs);
150
+ }),
151
+ ]);
152
+ } finally {
153
+ if (timeoutHandle) clearTimeout(timeoutHandle);
154
+ }
155
+ }
156
+
115
157
  export function buildDefaultRuntimeSummaryPath(targetConfig, task) {
116
158
  return path.join(
117
159
  targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
@@ -1,5 +1,13 @@
1
+ import {
2
+ DEFAULT_FILE_TIMEOUT_SECONDS,
3
+ normalizeFileTimeoutSeconds,
4
+ parseFileTimeoutOption,
5
+ } from "../shared/file-timeout.mjs";
6
+
1
7
  export const STACK_MODES = new Set(["shared", "pooled", "isolated"]);
2
8
 
9
+ export { DEFAULT_FILE_TIMEOUT_SECONDS, parseFileTimeoutOption };
10
+
3
11
  export function parseWorkersOption(value) {
4
12
  return parsePositiveInteger(value, "--workers");
5
13
  }
@@ -27,6 +35,8 @@ export function resolveExecutionConfig({ cli = {}, repo = {} } = {}) {
27
35
  workers: cli.workers ?? repo.workers ?? 1,
28
36
  stackMode: cli.stackMode ?? repo.stackMode ?? "isolated",
29
37
  stackCount: cli.stackCount ?? repo.stackCount ?? null,
38
+ fileTimeoutSeconds:
39
+ cli.fileTimeoutSeconds ?? repo.fileTimeoutSeconds ?? DEFAULT_FILE_TIMEOUT_SECONDS,
30
40
  });
31
41
  }
32
42
 
@@ -35,6 +45,9 @@ export function normalizeExecutionConfig(input = {}) {
35
45
  const stackMode = normalizeStackMode(input.stackMode);
36
46
  const explicitStackCount =
37
47
  input.stackCount == null ? null : normalizePositiveInteger(input.stackCount, "execution.stackCount");
48
+ const fileTimeoutSeconds = normalizeFileTimeoutSeconds(
49
+ input.fileTimeoutSeconds ?? DEFAULT_FILE_TIMEOUT_SECONDS
50
+ );
38
51
 
39
52
  if (stackMode === "shared") {
40
53
  if (explicitStackCount !== null && explicitStackCount !== 1) {
@@ -44,6 +57,7 @@ export function normalizeExecutionConfig(input = {}) {
44
57
  workers,
45
58
  stackMode,
46
59
  stackCount: 1,
60
+ fileTimeoutSeconds,
47
61
  };
48
62
  }
49
63
 
@@ -52,6 +66,7 @@ export function normalizeExecutionConfig(input = {}) {
52
66
  workers,
53
67
  stackMode,
54
68
  stackCount: explicitStackCount ?? 1,
69
+ fileTimeoutSeconds,
55
70
  };
56
71
  }
57
72
 
@@ -64,6 +79,7 @@ export function normalizeExecutionConfig(input = {}) {
64
79
  workers,
65
80
  stackMode,
66
81
  stackCount: workers,
82
+ fileTimeoutSeconds,
67
83
  };
68
84
  }
69
85
 
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  buildStackIds,
4
+ DEFAULT_FILE_TIMEOUT_SECONDS,
4
5
  normalizeExecutionConfig,
6
+ parseFileTimeoutOption,
5
7
  parseStackCountOption,
6
8
  parseStackModeOption,
7
9
  parseWorkersOption,
@@ -13,27 +15,34 @@ import {
13
15
  describe("execution-config", () => {
14
16
  it("parses worker and stack CLI options", () => {
15
17
  expect(parseWorkersOption("8")).toBe(8);
18
+ expect(parseFileTimeoutOption("45")).toBe(45);
16
19
  expect(parseStackCountOption("2")).toBe(2);
17
20
  expect(parseStackModeOption("pooled")).toBe("pooled");
18
21
  expect(() => parseWorkersOption("0")).toThrow('Invalid --workers value "0"');
22
+ expect(() => parseFileTimeoutOption("0")).toThrow(
23
+ 'Invalid --file-timeout-seconds value "0"'
24
+ );
19
25
  expect(() => parseStackModeOption("legacy")).toThrow('Invalid --stack-mode value "legacy"');
20
26
  });
21
27
 
22
28
  it("normalizes shared, pooled, and isolated execution shapes", () => {
23
29
  expect(normalizeExecutionConfig({ workers: 8, stackMode: "shared" })).toEqual({
24
30
  workers: 8,
31
+ fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
25
32
  stackMode: "shared",
26
33
  stackCount: 1,
27
34
  });
28
35
 
29
36
  expect(normalizeExecutionConfig({ workers: 8, stackMode: "pooled", stackCount: 3 })).toEqual({
30
37
  workers: 8,
38
+ fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
31
39
  stackMode: "pooled",
32
40
  stackCount: 3,
33
41
  });
34
42
 
35
43
  expect(normalizeExecutionConfig({ workers: 8, stackMode: "isolated" })).toEqual({
36
44
  workers: 8,
45
+ fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
37
46
  stackMode: "isolated",
38
47
  stackCount: 8,
39
48
  });
@@ -53,10 +62,11 @@ describe("execution-config", () => {
53
62
  expect(
54
63
  resolveExecutionConfig({
55
64
  repo: { workers: 3, stackMode: "shared" },
56
- cli: { workers: 6, stackMode: "pooled", stackCount: 2 },
65
+ cli: { workers: 6, fileTimeoutSeconds: 90, stackMode: "pooled", stackCount: 2 },
57
66
  })
58
67
  ).toEqual({
59
68
  workers: 6,
69
+ fileTimeoutSeconds: 90,
60
70
  stackMode: "pooled",
61
71
  stackCount: 2,
62
72
  });
@@ -135,6 +135,7 @@ export function buildRunArtifact({
135
135
  finishedAt: new Date(finishedAt).toISOString(),
136
136
  durationMs: finishedAt - startedAt,
137
137
  workers: execution.workers,
138
+ fileTimeoutSeconds: execution.fileTimeoutSeconds,
138
139
  workerCount,
139
140
  stackMode: execution.stackMode,
140
141
  stackCount,
@@ -53,6 +53,7 @@ describe("runner reporting", () => {
53
53
  finishedAt: 4000,
54
54
  execution: {
55
55
  workers: 2,
56
+ fileTimeoutSeconds: 60,
56
57
  stackMode: "shared",
57
58
  stackCount: 1,
58
59
  },
@@ -82,6 +83,7 @@ describe("runner reporting", () => {
82
83
  expect(artifact.schemaVersion).toBe(3);
83
84
  expect(artifact.run).toMatchObject({
84
85
  workers: 2,
86
+ fileTimeoutSeconds: 60,
85
87
  workerCount: 1,
86
88
  stackMode: "shared",
87
89
  stackCount: 1,
@@ -29,6 +29,11 @@ export interface RuntimeArtifactOptions {
29
29
  summary?: string;
30
30
  }
31
31
 
32
+ export interface WaitForOptions {
33
+ description?: string;
34
+ intervalSeconds?: number;
35
+ }
36
+
32
37
  export interface RuntimeEnv {
33
38
  BASE: string;
34
39
  MACHINE_ID?: string;
@@ -139,6 +144,12 @@ export declare const http: RuntimeHttpClient;
139
144
 
140
145
  export declare function file(data: unknown, filename?: string, contentType?: string): unknown;
141
146
  export declare function json<T = unknown>(response: Pick<RuntimeResponse, "body">): T;
147
+ export declare function remainingTimeSeconds(): number;
148
+ export declare function waitFor<T>(
149
+ read: () => T,
150
+ isReady: (value: T) => boolean,
151
+ options?: WaitForOptions
152
+ ): T;
142
153
  export declare function emitArtifact(
143
154
  name: string,
144
155
  data: unknown,
@@ -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
+ }
@@ -50,6 +50,7 @@ export interface ServiceExecutionConfig {
50
50
 
51
51
  export interface TestkitExecutionConfig {
52
52
  workers?: number;
53
+ fileTimeoutSeconds?: number;
53
54
  stackMode?: "shared" | "pooled" | "isolated";
54
55
  stackCount?: number;
55
56
  }
@@ -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.40",
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",