@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.
@@ -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
- graphByKey,
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══ global worker ${worker.workerId} ══`);
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
- const context = await ensureWorkerGraph(worker, batch, graphByKey, lifecycle);
46
- const outcomes = await runBatch(context, batch, lifecycle);
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, graphByKey.get(batch.graphKey), message);
59
- await resetCurrentGraph(worker, lifecycle);
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
- await cleanupWorker(worker, lifecycle);
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(`Worker graph missing target config "${batch.targetName}"`);
82
+ throw new Error(`Stack is missing target config "${batch.targetName}"`);
80
83
  }
81
84
 
82
85
  if (batch.framework === "playwright") {
@@ -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
+ }
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.38",
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",