@elench/testkit 0.1.38 → 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 +31 -4
- package/lib/cli/args.mjs +12 -7
- package/lib/cli/args.test.mjs +14 -4
- package/lib/cli/index.mjs +20 -6
- package/lib/config/index.mjs +123 -0
- package/lib/runner/default-runtime-runner.mjs +49 -7
- package/lib/runner/execution-config.mjs +124 -0
- package/lib/runner/execution-config.test.mjs +111 -0
- package/lib/runner/lifecycle.mjs +7 -7
- package/lib/runner/orchestrator.mjs +51 -24
- package/lib/runner/planning.mjs +42 -2
- package/lib/runner/planning.test.mjs +175 -3
- package/lib/runner/playwright-config.test.mjs +1 -1
- package/lib/runner/playwright-runner.mjs +2 -2
- package/lib/runner/readiness.mjs +2 -2
- package/lib/runner/reporting.mjs +6 -2
- package/lib/runner/reporting.test.mjs +14 -1
- package/lib/runner/runtime-contexts.mjs +38 -47
- package/lib/runner/services.mjs +4 -4
- package/lib/runner/stack-manager.mjs +146 -0
- package/lib/runner/template.mjs +40 -32
- package/lib/runner/template.test.mjs +42 -35
- package/lib/runner/worker-loop.mjs +17 -14
- package/lib/runtime/index.d.ts +11 -0
- package/lib/runtime/index.mjs +34 -0
- package/lib/setup/index.d.ts +24 -0
- package/lib/shared/file-timeout.mjs +107 -0
- package/lib/shared/file-timeout.test.mjs +64 -0
- package/package.json +1 -1
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { formatError } from "./formatting.mjs";
|
|
2
2
|
import { runDalBatch, runHttpK6Batch } from "./default-runtime-runner.mjs";
|
|
3
3
|
import { runPlaywrightBatch } from "./playwright-runner.mjs";
|
|
4
|
-
import {
|
|
5
|
-
cleanupWorker,
|
|
6
|
-
ensureWorkerGraph,
|
|
7
|
-
resetCurrentGraph,
|
|
8
|
-
} from "./runtime-contexts.mjs";
|
|
9
4
|
|
|
10
5
|
const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
|
|
11
6
|
|
|
@@ -14,7 +9,6 @@ export function createWorker(workerId, productDir) {
|
|
|
14
9
|
workerId,
|
|
15
10
|
productDir,
|
|
16
11
|
currentGraphKey: null,
|
|
17
|
-
graphContexts: new Map(),
|
|
18
12
|
graphSwitches: 0,
|
|
19
13
|
taskCount: 0,
|
|
20
14
|
};
|
|
@@ -23,7 +17,7 @@ export function createWorker(workerId, productDir) {
|
|
|
23
17
|
export async function runWorker(
|
|
24
18
|
worker,
|
|
25
19
|
queue,
|
|
26
|
-
|
|
20
|
+
stackManager,
|
|
27
21
|
trackers,
|
|
28
22
|
timingUpdates,
|
|
29
23
|
lifecycle,
|
|
@@ -32,7 +26,7 @@ export async function runWorker(
|
|
|
32
26
|
recordGraphError
|
|
33
27
|
) {
|
|
34
28
|
const startedAt = Date.now();
|
|
35
|
-
console.log(`\n══
|
|
29
|
+
console.log(`\n══ worker ${worker.workerId} ══`);
|
|
36
30
|
const errors = [];
|
|
37
31
|
|
|
38
32
|
try {
|
|
@@ -41,9 +35,14 @@ export async function runWorker(
|
|
|
41
35
|
const batch = claimNextBatch(queue, worker.currentGraphKey);
|
|
42
36
|
if (!batch) break;
|
|
43
37
|
|
|
38
|
+
let lease = null;
|
|
44
39
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
if (worker.currentGraphKey && worker.currentGraphKey !== batch.graphKey) {
|
|
41
|
+
worker.graphSwitches += 1;
|
|
42
|
+
}
|
|
43
|
+
worker.currentGraphKey = batch.graphKey;
|
|
44
|
+
lease = await stackManager.acquire(batch);
|
|
45
|
+
const outcomes = await runBatch(lease.context, batch, lifecycle);
|
|
47
46
|
for (const outcome of outcomes) {
|
|
48
47
|
recordTaskOutcome(trackers, outcome.task, outcome);
|
|
49
48
|
timingUpdates.push({
|
|
@@ -52,15 +51,19 @@ export async function runWorker(
|
|
|
52
51
|
});
|
|
53
52
|
worker.taskCount += 1;
|
|
54
53
|
}
|
|
54
|
+
await stackManager.release(lease, { accessMode: batch.accessMode });
|
|
55
55
|
} catch (error) {
|
|
56
56
|
const message = formatError(error);
|
|
57
57
|
errors.push(message);
|
|
58
|
-
recordGraphError(trackers,
|
|
59
|
-
await
|
|
58
|
+
recordGraphError(trackers, { assignedTargets: lease?.context?.assignedTargets || [batch.targetName] }, message);
|
|
59
|
+
await stackManager.release(lease, {
|
|
60
|
+
accessMode: batch.accessMode,
|
|
61
|
+
invalidate: lease !== null,
|
|
62
|
+
});
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
} finally {
|
|
63
|
-
|
|
66
|
+
worker.currentGraphKey = null;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
return {
|
|
@@ -76,7 +79,7 @@ export async function runWorker(
|
|
|
76
79
|
async function runBatch(context, batch, lifecycle) {
|
|
77
80
|
const targetConfig = context.configByName.get(batch.targetName);
|
|
78
81
|
if (!targetConfig) {
|
|
79
|
-
throw new Error(`
|
|
82
|
+
throw new Error(`Stack is missing target config "${batch.targetName}"`);
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
if (batch.framework === "playwright") {
|
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
|
@@ -33,6 +33,28 @@ export interface SkipConfig {
|
|
|
33
33
|
suites?: SkipSuiteRule[];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export interface SuiteExecutionRule {
|
|
37
|
+
selector: string;
|
|
38
|
+
stackMode: "shared" | "pooled" | "isolated";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FileExecutionRule {
|
|
42
|
+
path: string;
|
|
43
|
+
stackMode: "shared" | "pooled" | "isolated";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ServiceExecutionConfig {
|
|
47
|
+
suites?: SuiteExecutionRule[];
|
|
48
|
+
files?: FileExecutionRule[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TestkitExecutionConfig {
|
|
52
|
+
workers?: number;
|
|
53
|
+
fileTimeoutSeconds?: number;
|
|
54
|
+
stackMode?: "shared" | "pooled" | "isolated";
|
|
55
|
+
stackCount?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
export interface ServiceConfig {
|
|
37
59
|
database?: LocalDatabaseConfig;
|
|
38
60
|
databaseFrom?: string;
|
|
@@ -55,9 +77,11 @@ export interface ServiceConfig {
|
|
|
55
77
|
migrate?: LifecycleConfig;
|
|
56
78
|
seed?: LifecycleConfig;
|
|
57
79
|
skip?: SkipConfig;
|
|
80
|
+
execution?: ServiceExecutionConfig;
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
export interface TestkitSetup {
|
|
84
|
+
execution?: TestkitExecutionConfig;
|
|
61
85
|
profiles?: {
|
|
62
86
|
http?: Record<string, HttpSuiteConfig>;
|
|
63
87
|
};
|
|
@@ -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
|
+
});
|