@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.
- package/lib/playwright/index.d.ts +1 -0
- package/lib/playwright/index.mjs +1 -0
- package/lib/runner/default-runtime-runner.mjs +5 -28
- package/lib/runner/lifecycle.mjs +2 -51
- package/lib/runner/managed-processes.mjs +2 -1
- package/lib/runner/playwright-config.mjs +13 -1
- package/lib/runner/playwright-runner.mjs +85 -15
- package/lib/runner/processes.mjs +59 -3
- package/lib/runner/subprocess.mjs +155 -0
- package/lib/shared/file-timeout.mjs +1 -1
- package/lib/ui/index.d.ts +2 -0
- package/lib/ui/index.mjs +1 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
|
@@ -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> & {
|
package/lib/playwright/index.mjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
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
|
}
|
package/lib/runner/lifecycle.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
?
|
|
69
|
-
:
|
|
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
|
|
74
|
-
status: timedOut ? "failed" : fileResult?.status || (
|
|
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
|
+
}
|
package/lib/runner/processes.mjs
CHANGED
|
@@ -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(-
|
|
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
|
-
|
|
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 `
|
|
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
package/lib/ui/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
25
|
+
"@elench/testkit-protocol": "0.1.114"
|
|
26
26
|
},
|
|
27
27
|
"private": false
|
|
28
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
94
|
-
"@elench/testkit-bridge": "0.1.
|
|
95
|
-
"@elench/testkit-protocol": "0.1.
|
|
96
|
-
"@elench/ts-analysis": "0.1.
|
|
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",
|