@elench/testkit 0.1.113 → 0.1.114

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.
@@ -11,6 +11,7 @@ export declare function defineConfig<T extends Record<string, unknown>>(
11
11
  config: T,
12
12
  options?: PlaywrightConfigOptions
13
13
  ): T & {
14
+ globalTimeout?: number;
14
15
  timeout: number;
15
16
  expect: Record<string, unknown> & { timeout: number };
16
17
  use: Record<string, unknown> & {
@@ -27,6 +27,7 @@ export function defineConfig(config = {}, options = {}) {
27
27
 
28
28
  return {
29
29
  ...config,
30
+ ...(managed ? { globalTimeout: timeoutMs } : {}),
30
31
  timeout: timeoutMs,
31
32
  expect: {
32
33
  ...(config.expect || {}),
@@ -1,6 +1,5 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { execa } from "execa";
4
3
  import { bundleK6File } from "../bundler/index.mjs";
5
4
  import { resolveK6Binary } from "../config/binaries.mjs";
6
5
  import {
@@ -17,7 +16,7 @@ import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
17
16
  import { readDatabaseUrl } from "./state-io.mjs";
18
17
  import { buildTaskExecutionEnv } from "./template.mjs";
19
18
  import { registerManagedProcess, unregisterManagedProcess } from "./managed-processes.mjs";
20
- import { killChildProcess } from "./processes.mjs";
19
+ import { settleManagedSubprocess, startManagedSubprocess } from "./subprocess.mjs";
21
20
 
22
21
  export async function runHttpK6Task(targetConfig, task, lifecycle, lease, reporter = null) {
23
22
  const baseUrl = targetConfig.testkit.local?.baseUrl;
@@ -88,7 +87,7 @@ export async function runDefaultRuntimeTask(
88
87
  fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
89
88
  const startedAt = Date.now();
90
89
  const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
91
- const subprocess = execa(
90
+ const subprocess = startManagedSubprocess(
92
91
  k6Binary,
93
92
  [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)],
94
93
  {
@@ -102,8 +101,6 @@ export async function runDefaultRuntimeTask(
102
101
  },
103
102
  process.env
104
103
  ),
105
- reject: false,
106
- forceKillAfterDelay: 5_000,
107
104
  }
108
105
  );
109
106
  registerManagedProcess(lifecycle, subprocess, "SIGINT");
@@ -111,7 +108,9 @@ export async function runDefaultRuntimeTask(
111
108
  let result;
112
109
  let timedOut;
113
110
  try {
114
- ({ result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds));
111
+ ({ result, timedOut } = await settleManagedSubprocess(subprocess, {
112
+ timeoutMs: fileTimeoutSeconds * 1000 + 1_000,
113
+ }));
115
114
  } finally {
116
115
  unregisterManagedProcess(lifecycle, subprocess);
117
116
  }
@@ -170,28 +169,6 @@ export async function runDefaultRuntimeTask(
170
169
  };
171
170
  }
172
171
 
173
- export async function settleSubprocess(subprocess, fileTimeoutSeconds) {
174
- const timeoutMs = fileTimeoutSeconds * 1000 + 1_000;
175
- let timeoutHandle = null;
176
- let timedOut = false;
177
-
178
- try {
179
- return await Promise.race([
180
- subprocess.then((result) => ({ result, timedOut })),
181
- new Promise((resolve) => {
182
- timeoutHandle = setTimeout(async () => {
183
- timedOut = true;
184
- killChildProcess(subprocess, "SIGTERM");
185
- const result = await subprocess.catch((error) => error);
186
- resolve({ result, timedOut: true });
187
- }, timeoutMs);
188
- }),
189
- ]);
190
- } finally {
191
- if (timeoutHandle) clearTimeout(timeoutHandle);
192
- }
193
- }
194
-
195
172
  export function buildDefaultRuntimeSummaryPath(lease, task) {
196
173
  return path.join(lease.leaseDir, "default-runtime", `task-${task.id}.summary.json`);
197
174
  }
@@ -1,8 +1,8 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
- import { execFileSync } from "child_process";
5
4
  import { destroyRuntimeDatabase, cleanupOrphanedLocalInfrastructure } from "../database/index.mjs";
5
+ import { killProcessTree, listDescendantPids } from "./processes.mjs";
6
6
 
7
7
  const RUN_SCHEMA_VERSION = 1;
8
8
  const RUNS_DIRNAME = path.join(".testkit", "_runs");
@@ -297,22 +297,7 @@ async function terminateOwnedProcess(service) {
297
297
  }
298
298
 
299
299
  function killProcessGroup(pid, signal) {
300
- try {
301
- process.kill(-pid, signal);
302
- return;
303
- } catch (error) {
304
- if (error?.code !== "ESRCH") {
305
- // Fall through and try the direct pid.
306
- } else {
307
- return;
308
- }
309
- }
310
-
311
- try {
312
- process.kill(pid, signal);
313
- } catch (error) {
314
- if (error?.code !== "ESRCH") throw error;
315
- }
300
+ killProcessTree(pid, signal);
316
301
  }
317
302
 
318
303
  function terminateDescendantProcesses(rootPid, signal) {
@@ -328,40 +313,6 @@ function terminateDescendantProcesses(rootPid, signal) {
328
313
  }
329
314
  }
330
315
 
331
- function listDescendantPids(rootPid) {
332
- try {
333
- const output = execFileSync("ps", ["-eo", "pid=,ppid="], {
334
- encoding: "utf8",
335
- stdio: ["ignore", "pipe", "ignore"],
336
- });
337
- const childrenByParent = new Map();
338
- for (const line of output.split(/\r?\n/)) {
339
- const trimmed = line.trim();
340
- if (!trimmed) continue;
341
- const [pidRaw, parentRaw] = trimmed.split(/\s+/);
342
- const pid = Number(pidRaw);
343
- const parentPid = Number(parentRaw);
344
- if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
345
- const siblings = childrenByParent.get(parentPid) || [];
346
- siblings.push(pid);
347
- childrenByParent.set(parentPid, siblings);
348
- }
349
-
350
- const descendants = [];
351
- const stack = [...(childrenByParent.get(rootPid) || [])];
352
- while (stack.length > 0) {
353
- const pid = stack.pop();
354
- descendants.push(pid);
355
- for (const childPid of childrenByParent.get(pid) || []) {
356
- stack.push(childPid);
357
- }
358
- }
359
- return descendants;
360
- } catch {
361
- return [];
362
- }
363
- }
364
-
365
316
  async function waitForPidExit(pid, timeoutMs) {
366
317
  const startedAt = Date.now();
367
318
  while (Date.now() - startedAt < timeoutMs) {
@@ -1,8 +1,9 @@
1
1
  import { killChildProcess } from "./processes.mjs";
2
+ import { terminateSubprocess } from "./subprocess.mjs";
2
3
 
3
4
  export function registerManagedProcess(lifecycle, subprocess, signal = "SIGINT") {
4
5
  lifecycle.registerProcess(subprocess, () => {
5
- killChildProcess(subprocess, signal);
6
+ void terminateSubprocess(subprocess, { gracefulSignal: signal });
6
7
  });
7
8
  if (lifecycle.isStopRequested()) {
8
9
  interruptManagedProcess(subprocess, signal);
@@ -3,7 +3,7 @@ import path from "path";
3
3
  import { pathToFileURL } from "url";
4
4
  import { normalizePathSeparators } from "./state.mjs";
5
5
 
6
- export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, lease) {
6
+ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, lease, options = {}) {
7
7
  if (!lease?.leaseDir) {
8
8
  throw new Error(
9
9
  `Playwright task for service "${targetConfig.name}" requires a lease-scoped directory`
@@ -17,6 +17,7 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
17
17
  const configPath = path.join(stateDir, "playwright.testkit.config.mjs");
18
18
  const baseConfigPath = findPlaywrightConfig(cwd);
19
19
  const normalizedFiles = requestedFiles.map(normalizePathSeparators);
20
+ const globalTimeoutMs = normalizeOptionalPositiveInteger(options.globalTimeoutMs, "globalTimeoutMs");
20
21
 
21
22
  let source = "";
22
23
  if (baseConfigPath) {
@@ -28,6 +29,7 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
28
29
  ` testDir: ${JSON.stringify(cwd)},\n` +
29
30
  ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
30
31
  ` outputDir: ${JSON.stringify(outputDir)},\n` +
32
+ (globalTimeoutMs ? ` globalTimeout: ${globalTimeoutMs},\n` : "") +
31
33
  ` workers: 1,\n` +
32
34
  ` fullyParallel: false,\n` +
33
35
  ` webServer: undefined,\n` +
@@ -38,6 +40,7 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
38
40
  ` testDir: ${JSON.stringify(cwd)},\n` +
39
41
  ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
40
42
  ` outputDir: ${JSON.stringify(outputDir)},\n` +
43
+ (globalTimeoutMs ? ` globalTimeout: ${globalTimeoutMs},\n` : "") +
41
44
  ` workers: 1,\n` +
42
45
  ` fullyParallel: false,\n` +
43
46
  `};\n`;
@@ -47,6 +50,15 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
47
50
  return configPath;
48
51
  }
49
52
 
53
+ function normalizeOptionalPositiveInteger(value, label) {
54
+ if (value == null) return null;
55
+ const normalized = Number(value);
56
+ if (!Number.isInteger(normalized) || normalized <= 0) {
57
+ throw new Error(`${label} must be a positive integer.`);
58
+ }
59
+ return normalized;
60
+ }
61
+
50
62
  export function resolvePlaywrightOutputDir(stateDir) {
51
63
  return path.join(stateDir, "playwright-output");
52
64
  }
@@ -1,14 +1,14 @@
1
1
  import path from "path";
2
- import { execa } from "execa";
2
+ import fs from "fs";
3
3
  import { parsePlaywrightJsonResults } from "../reporters/playwright.mjs";
4
4
  import { resolveServiceCwd } from "../config/paths.mjs";
5
- import { formatFileTimeoutBudgetError } from "../shared/file-timeout.mjs";
5
+ import { buildFileTimeoutEnv, formatFileTimeoutBudgetError } from "../shared/file-timeout.mjs";
6
6
  import { persistTaskOutputArtifacts } from "./artifacts.mjs";
7
- import { settleSubprocess } from "./default-runtime-runner.mjs";
8
7
  import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
9
8
  import { registerManagedProcess, unregisterManagedProcess } from "./managed-processes.mjs";
10
9
  import { normalizePathSeparators } from "./state.mjs";
11
10
  import { buildPlaywrightEnv } from "./template.mjs";
11
+ import { settleManagedSubprocess, startManagedSubprocess } from "./subprocess.mjs";
12
12
 
13
13
  export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, reporter = null) {
14
14
  const local = targetConfig.testkit.local;
@@ -20,20 +20,36 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, re
20
20
 
21
21
  const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
22
22
  const requestedFile = path.relative(cwd, path.join(targetConfig.productDir, task.file));
23
- const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, [requestedFile], lease);
24
23
  if (lifecycle.isStopRequested()) {
25
24
  throw new Error(`testkit run interrupted before starting ${task.file}`);
26
25
  }
27
26
  const startedAt = Date.now();
28
27
  const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
29
- const subprocess = execa(
28
+ const playwrightConfigPath = ensurePlaywrightTestConfig(
29
+ targetConfig,
30
+ cwd,
31
+ [requestedFile],
32
+ lease,
33
+ { globalTimeoutMs: fileTimeoutSeconds * 1000 }
34
+ );
35
+ const jsonReportPath = buildPlaywrightJsonReportPath(lease, task);
36
+ const subprocess = startManagedSubprocess(
30
37
  "npx",
31
38
  ["playwright", "test", "--config", playwrightConfigPath, "--reporter=json"],
32
39
  {
33
40
  cwd,
34
- env: buildPlaywrightEnv(targetConfig, local.baseUrl, lease, process.env),
35
- reject: false,
36
- forceKillAfterDelay: 5_000,
41
+ env: {
42
+ ...buildPlaywrightEnv(
43
+ targetConfig,
44
+ local.baseUrl,
45
+ lease,
46
+ {
47
+ ...process.env,
48
+ ...buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
49
+ }
50
+ ),
51
+ PLAYWRIGHT_JSON_OUTPUT_FILE: jsonReportPath,
52
+ },
37
53
  }
38
54
  );
39
55
  registerManagedProcess(lifecycle, subprocess, "SIGINT");
@@ -41,17 +57,30 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, re
41
57
  let result;
42
58
  let timedOut;
43
59
  try {
44
- ({ result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds));
60
+ ({ result, timedOut } = await settleManagedSubprocess(subprocess, {
61
+ timeoutMs: fileTimeoutSeconds * 1000 + 1_000,
62
+ }));
45
63
  } finally {
46
64
  unregisterManagedProcess(lifecycle, subprocess);
47
65
  }
48
66
 
49
- const parsed = parsePlaywrightJsonResults(result.stdout || "", cwd);
67
+ const jsonReport = readOptionalText(jsonReportPath);
68
+ const stdoutReport = jsonReport ? "" : extractJsonReportFromStdout(result.stdout || "");
69
+ const parsed = parsePlaywrightJsonResults(jsonReport || stdoutReport, cwd);
50
70
  const finishedAt = Date.now();
51
71
  const durationMs = finishedAt - startedAt;
52
72
  const relativeFile = normalizePathSeparators(requestedFile);
53
73
  const fileResult = parsed.fileResults.get(relativeFile);
54
74
  const outputArtifacts = persistTaskOutputArtifacts(targetConfig.productDir, task, [
75
+ result.stdout
76
+ ? {
77
+ name: "playwright-stdout",
78
+ kind: "runtime.output",
79
+ summary: result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "captured stdout",
80
+ stream: "stdout",
81
+ text: result.stdout,
82
+ }
83
+ : null,
55
84
  result.stderr
56
85
  ? {
57
86
  name: "playwright-stderr",
@@ -62,16 +91,19 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, re
62
91
  }
63
92
  : null,
64
93
  ]);
65
- const genericError = timedOut
94
+ const parsedError = stripAnsi(parsed.errors[0] || "");
95
+ const globalTimeoutError = isPlaywrightGlobalTimeoutError(parsedError);
96
+ const genericError = timedOut || globalTimeoutError
66
97
  ? formatFileTimeoutBudgetError(fileTimeoutSeconds)
67
98
  : result.exitCode === 0
68
- ? parsed.errors[0] || null
69
- : parsed.errors[0] || result.stderr.trim() || `Playwright exited with code ${result.exitCode}`;
99
+ ? parsedError || null
100
+ : parsedError || firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout) || `Playwright exited with code ${result.exitCode}`;
101
+ const failed = timedOut || globalTimeoutError || (fileResult ? fileResult.status === "failed" : genericError !== null);
70
102
 
71
103
  return {
72
104
  task,
73
- failed: timedOut ? true : fileResult ? fileResult.status === "failed" : result.exitCode !== 0,
74
- status: timedOut ? "failed" : fileResult?.status || (result.exitCode === 0 ? "passed" : "failed"),
105
+ failed,
106
+ status: timedOut ? "failed" : fileResult?.status || (failed ? "failed" : "passed"),
75
107
  error: timedOut ? genericError : fileResult?.error || genericError,
76
108
  durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
77
109
  startedAt,
@@ -80,3 +112,41 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, re
80
112
  failureDetails: timedOut ? [] : fileResult?.failureDetails || [],
81
113
  };
82
114
  }
115
+
116
+ export function buildPlaywrightJsonReportPath(lease, task) {
117
+ if (!lease?.leaseDir) {
118
+ throw new Error(`Playwright task ${task?.file || ""} requires a lease-scoped directory`);
119
+ }
120
+ const reportDir = path.join(lease.leaseDir, "playwright-reports");
121
+ fs.mkdirSync(reportDir, { recursive: true });
122
+ return path.join(reportDir, `task-${task.id}.json`);
123
+ }
124
+
125
+ function readOptionalText(filePath) {
126
+ try {
127
+ return fs.readFileSync(filePath, "utf8");
128
+ } catch {
129
+ return "";
130
+ }
131
+ }
132
+
133
+ function extractJsonReportFromStdout(stdout) {
134
+ const trimmed = String(stdout || "").trim();
135
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return "";
136
+ return trimmed;
137
+ }
138
+
139
+ function firstNonEmptyLine(value) {
140
+ return stripAnsi(value)
141
+ .split(/\r?\n/)
142
+ .map((line) => line.trim())
143
+ .find(Boolean) || null;
144
+ }
145
+
146
+ function isPlaywrightGlobalTimeoutError(value) {
147
+ return /Timed out waiting \d+(?:\.\d+)?s for the test suite to run/i.test(value || "");
148
+ }
149
+
150
+ function stripAnsi(value) {
151
+ return String(value || "").replace(/\u001b\[[0-9;]*m/g, "");
152
+ }
@@ -1,4 +1,4 @@
1
- import { spawn } from "child_process";
1
+ import { execFileSync, spawn } from "child_process";
2
2
 
3
3
  export function normalizeServiceStartCommand(command) {
4
4
  if (typeof command !== "string") {
@@ -36,9 +36,16 @@ export function startDetachedCommand(command, cwd, env) {
36
36
 
37
37
  export function killChildProcess(child, signal) {
38
38
  if (!child?.pid) return;
39
+ killProcessTree(child.pid, signal);
40
+ }
41
+
42
+ export function killProcessTree(pid, signal) {
43
+ const normalizedPid = Number(pid);
44
+ if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) return;
45
+ const descendants = listDescendantPids(normalizedPid);
39
46
 
40
47
  try {
41
- process.kill(-child.pid, signal);
48
+ process.kill(-normalizedPid, signal);
42
49
  return;
43
50
  } catch (error) {
44
51
  if (error?.code !== "ESRCH") {
@@ -49,10 +56,18 @@ export function killChildProcess(child, signal) {
49
56
  }
50
57
 
51
58
  try {
52
- child.kill(signal);
59
+ process.kill(normalizedPid, signal);
53
60
  } catch (error) {
54
61
  if (error?.code !== "ESRCH") throw error;
55
62
  }
63
+
64
+ for (const childPid of descendants) {
65
+ try {
66
+ process.kill(childPid, signal);
67
+ } catch (error) {
68
+ if (error?.code !== "ESRCH") throw error;
69
+ }
70
+ }
56
71
  }
57
72
 
58
73
  export function captureOutput(stream, options = {}) {
@@ -116,3 +131,44 @@ export async function stopChildProcess(child, outputDrains = []) {
116
131
  export function sleep(ms) {
117
132
  return new Promise((resolve) => setTimeout(resolve, ms));
118
133
  }
134
+
135
+ export function listDescendantPids(rootPid) {
136
+ const normalizedRootPid = Number(rootPid);
137
+ if (!Number.isInteger(normalizedRootPid) || normalizedRootPid <= 0) return [];
138
+
139
+ if (process.platform === "win32") {
140
+ return [];
141
+ }
142
+
143
+ try {
144
+ const output = execFileSync("ps", ["-eo", "pid=,ppid="], {
145
+ encoding: "utf8",
146
+ stdio: ["ignore", "pipe", "ignore"],
147
+ });
148
+ const childrenByParent = new Map();
149
+ for (const line of output.split(/\r?\n/)) {
150
+ const trimmed = line.trim();
151
+ if (!trimmed) continue;
152
+ const [pidRaw, parentRaw] = trimmed.split(/\s+/);
153
+ const pid = Number(pidRaw);
154
+ const parentPid = Number(parentRaw);
155
+ if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
156
+ const siblings = childrenByParent.get(parentPid) || [];
157
+ siblings.push(pid);
158
+ childrenByParent.set(parentPid, siblings);
159
+ }
160
+
161
+ const descendants = [];
162
+ const stack = [...(childrenByParent.get(normalizedRootPid) || [])];
163
+ while (stack.length > 0) {
164
+ const pid = stack.pop();
165
+ descendants.push(pid);
166
+ for (const childPid of childrenByParent.get(pid) || []) {
167
+ stack.push(childPid);
168
+ }
169
+ }
170
+ return descendants;
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
@@ -0,0 +1,155 @@
1
+ import { execa } from "execa";
2
+ import { killProcessTree, sleep } from "./processes.mjs";
3
+
4
+ const DEFAULT_TERMINATION_GRACE_MS = 5_000;
5
+
6
+ export function startManagedSubprocess(command, args = [], options = {}) {
7
+ const detached = options.detached ?? process.platform !== "win32";
8
+ return execa(command, args, {
9
+ ...options,
10
+ detached,
11
+ reject: false,
12
+ forceKillAfterDelay: options.forceKillAfterDelay ?? DEFAULT_TERMINATION_GRACE_MS,
13
+ });
14
+ }
15
+
16
+ export async function settleManagedSubprocess(
17
+ subprocess,
18
+ {
19
+ timeoutMs,
20
+ gracefulSignal = "SIGTERM",
21
+ forceSignal = "SIGKILL",
22
+ terminationGraceMs = DEFAULT_TERMINATION_GRACE_MS,
23
+ } = {}
24
+ ) {
25
+ const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, "timeoutMs");
26
+ const normalizedGraceMs = normalizePositiveInteger(terminationGraceMs, "terminationGraceMs");
27
+ const startedAt = Date.now();
28
+ let timeoutHandle = null;
29
+ let timeoutStarted = false;
30
+ let terminationPromise = null;
31
+
32
+ try {
33
+ const completed = await Promise.race([
34
+ subprocess.then(
35
+ async (result) => {
36
+ if (!timeoutStarted) return { result, timedOut: false, termination: null };
37
+ const termination = terminationPromise ? await terminationPromise : null;
38
+ return { result, timedOut: true, termination };
39
+ },
40
+ async (error) => {
41
+ const result = normalizeSubprocessError(error);
42
+ if (!timeoutStarted) return { result, timedOut: false, termination: null };
43
+ const termination = terminationPromise ? await terminationPromise : null;
44
+ return { result, timedOut: true, termination };
45
+ }
46
+ ),
47
+ new Promise((resolve) => {
48
+ timeoutHandle = setTimeout(async () => {
49
+ timeoutStarted = true;
50
+ terminationPromise = terminateSubprocess(subprocess, {
51
+ gracefulSignal,
52
+ forceSignal,
53
+ terminationGraceMs: normalizedGraceMs,
54
+ });
55
+ const termination = await terminationPromise;
56
+ resolve({
57
+ result: termination.result,
58
+ timedOut: true,
59
+ termination,
60
+ });
61
+ }, normalizedTimeoutMs);
62
+ }),
63
+ ]);
64
+
65
+ return {
66
+ ...completed,
67
+ durationMs: Date.now() - startedAt,
68
+ };
69
+ } finally {
70
+ if (timeoutHandle) clearTimeout(timeoutHandle);
71
+ }
72
+ }
73
+
74
+ export async function terminateSubprocess(
75
+ subprocess,
76
+ {
77
+ gracefulSignal = "SIGTERM",
78
+ forceSignal = "SIGKILL",
79
+ terminationGraceMs = DEFAULT_TERMINATION_GRACE_MS,
80
+ } = {}
81
+ ) {
82
+ const normalizedGraceMs = normalizePositiveInteger(terminationGraceMs, "terminationGraceMs");
83
+ const gracefulAt = Date.now();
84
+ killProcessTree(subprocess?.pid, gracefulSignal);
85
+
86
+ const gracefulResult = await waitForSubprocess(subprocess, normalizedGraceMs);
87
+ if (gracefulResult.settled) {
88
+ return {
89
+ gracefulSignal,
90
+ forceSignal: null,
91
+ forced: false,
92
+ durationMs: Date.now() - gracefulAt,
93
+ result: gracefulResult.result,
94
+ };
95
+ }
96
+
97
+ killProcessTree(subprocess?.pid, forceSignal);
98
+ const forcedResult = await waitForSubprocess(subprocess, normalizedGraceMs);
99
+ return {
100
+ gracefulSignal,
101
+ forceSignal,
102
+ forced: true,
103
+ durationMs: Date.now() - gracefulAt,
104
+ result: forcedResult.settled
105
+ ? forcedResult.result
106
+ : syntheticKilledResult(subprocess, forceSignal),
107
+ };
108
+ }
109
+
110
+ async function waitForSubprocess(subprocess, timeoutMs) {
111
+ if (!subprocess) {
112
+ return { settled: true, result: syntheticKilledResult(null, "SIGKILL") };
113
+ }
114
+
115
+ const result = await Promise.race([
116
+ subprocess.then(
117
+ (value) => ({ settled: true, result: value }),
118
+ (error) => ({ settled: true, result: normalizeSubprocessError(error) })
119
+ ),
120
+ sleep(timeoutMs).then(() => ({ settled: false, result: null })),
121
+ ]);
122
+ return result;
123
+ }
124
+
125
+ function syntheticKilledResult(subprocess, signal) {
126
+ return {
127
+ command: subprocess?.command || null,
128
+ escapedCommand: subprocess?.escapedCommand || null,
129
+ exitCode: null,
130
+ signal,
131
+ stdout: "",
132
+ stderr: "",
133
+ timedOut: true,
134
+ failed: true,
135
+ killed: true,
136
+ };
137
+ }
138
+
139
+ function normalizeSubprocessError(error) {
140
+ return {
141
+ ...error,
142
+ exitCode: error?.exitCode ?? null,
143
+ signal: error?.signal ?? null,
144
+ stdout: error?.stdout || "",
145
+ stderr: error?.stderr || error?.message || "",
146
+ };
147
+ }
148
+
149
+ function normalizePositiveInteger(value, label) {
150
+ const normalized = Number(value);
151
+ if (!Number.isInteger(normalized) || normalized <= 0) {
152
+ throw new Error(`${label} must be a positive integer.`);
153
+ }
154
+ return normalized;
155
+ }
@@ -79,7 +79,7 @@ export function formatFileTimeoutBudgetError(fileTimeoutSeconds) {
79
79
  fileTimeoutSeconds,
80
80
  "fileTimeoutSeconds"
81
81
  );
82
- return `Default runtime exceeded the ${normalizedFileTimeoutSeconds}s test file timeout`;
82
+ return `Test file exceeded the ${normalizedFileTimeoutSeconds}s wall-clock timeout`;
83
83
  }
84
84
 
85
85
  function parsePositiveInteger(value, label) {
package/lib/ui/index.d.ts CHANGED
@@ -1 +1,3 @@
1
1
  export * from "@playwright/test";
2
+ export { defineConfig } from "../playwright/index";
3
+ export type { PlaywrightConfigOptions } from "../playwright/index";
package/lib/ui/index.mjs CHANGED
@@ -1 +1,2 @@
1
1
  export * from "@playwright/test";
2
+ export { defineConfig } from "../playwright/index.mjs";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "description": "Browser bridge helpers for testkit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc -p tsconfig.json --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@elench/testkit-protocol": "0.1.113"
25
+ "@elench/testkit-protocol": "0.1.114"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/ts-analysis",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -90,10 +90,10 @@
90
90
  },
91
91
  "dependencies": {
92
92
  "@babel/code-frame": "^7.29.0",
93
- "@elench/next-analysis": "0.1.113",
94
- "@elench/testkit-bridge": "0.1.113",
95
- "@elench/testkit-protocol": "0.1.113",
96
- "@elench/ts-analysis": "0.1.113",
93
+ "@elench/next-analysis": "0.1.114",
94
+ "@elench/testkit-bridge": "0.1.114",
95
+ "@elench/testkit-protocol": "0.1.114",
96
+ "@elench/ts-analysis": "0.1.114",
97
97
  "@oclif/core": "^4.10.6",
98
98
  "@playwright/test": "^1.52.0",
99
99
  "esbuild": "^0.25.11",