@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 +19 -1
- package/lib/cli/args.mjs +7 -1
- package/lib/cli/args.test.mjs +5 -0
- package/lib/cli/index.mjs +11 -8
- package/lib/config/index.mjs +6 -1
- package/lib/runner/default-runtime-runner.mjs +45 -3
- package/lib/runner/execution-config.mjs +16 -0
- package/lib/runner/execution-config.test.mjs +11 -1
- package/lib/runner/reporting.mjs +1 -0
- package/lib/runner/reporting.test.mjs +2 -0
- package/lib/runtime/index.d.ts +11 -0
- package/lib/runtime/index.mjs +34 -0
- package/lib/setup/index.d.ts +1 -0
- package/lib/shared/file-timeout.mjs +107 -0
- package/lib/shared/file-timeout.test.mjs +64 -0
- package/package.json +1 -1
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 {
|
|
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;
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
package/lib/config/index.mjs
CHANGED
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
parseSuiteSelectors,
|
|
9
9
|
suiteSelectionType,
|
|
10
10
|
} from "../runner/suite-selection.mjs";
|
|
11
|
-
import {
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
});
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -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,
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -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,
|
package/lib/runtime/index.mjs
CHANGED
|
@@ -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
|
+
}
|
package/lib/setup/index.d.ts
CHANGED
|
@@ -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
|
+
});
|