@elench/testkit 0.1.113 → 0.1.115

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.
Files changed (196) hide show
  1. package/lib/cli/assistant/app.mjs +4 -2
  2. package/lib/cli/assistant/session.mjs +5 -1
  3. package/lib/cli/assistant/state.mjs +1 -2
  4. package/lib/cli/components/blocks/run-tree.mjs +7 -2
  5. package/lib/cli/components/hooks/use-element-layout.mjs +63 -0
  6. package/lib/cli/components/hooks/use-spinner-frame.mjs +26 -0
  7. package/lib/playwright/index.d.ts +1 -0
  8. package/lib/playwright/index.mjs +1 -0
  9. package/lib/runner/default-runtime-runner.mjs +5 -28
  10. package/lib/runner/lifecycle.mjs +2 -51
  11. package/lib/runner/managed-processes.mjs +2 -1
  12. package/lib/runner/playwright-config.mjs +13 -1
  13. package/lib/runner/playwright-runner.mjs +85 -15
  14. package/lib/runner/processes.mjs +59 -3
  15. package/lib/runner/subprocess.mjs +155 -0
  16. package/lib/shared/file-timeout.mjs +1 -1
  17. package/lib/ui/index.d.ts +2 -0
  18. package/lib/ui/index.mjs +1 -0
  19. package/node_modules/@alcalzone/ansi-tokenize/README.md +0 -5
  20. package/node_modules/@alcalzone/ansi-tokenize/build/ansiCodes.d.ts +8 -0
  21. package/node_modules/@alcalzone/ansi-tokenize/build/ansiCodes.js +10 -8
  22. package/node_modules/@alcalzone/ansi-tokenize/build/ansiCodes.js.map +1 -1
  23. package/node_modules/@alcalzone/ansi-tokenize/build/tokenize.d.ts +1 -5
  24. package/node_modules/@alcalzone/ansi-tokenize/build/tokenize.js +9 -45
  25. package/node_modules/@alcalzone/ansi-tokenize/build/tokenize.js.map +1 -1
  26. package/node_modules/@alcalzone/ansi-tokenize/package.json +1 -1
  27. package/node_modules/@elench/next-analysis/package.json +1 -1
  28. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  29. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  30. package/node_modules/@elench/ts-analysis/package.json +1 -1
  31. package/node_modules/cli-boxes/index.d.ts +95 -90
  32. package/node_modules/cli-boxes/index.js +5 -2
  33. package/node_modules/cli-boxes/package.json +6 -13
  34. package/node_modules/cli-boxes/readme.md +15 -3
  35. package/node_modules/cli-truncate/index.d.ts +1 -1
  36. package/node_modules/cli-truncate/package.json +4 -4
  37. package/node_modules/cli-truncate/readme.md +1 -0
  38. package/node_modules/ink/build/apply-styles.js +175 -0
  39. package/node_modules/ink/build/build-layout.js +77 -0
  40. package/node_modules/ink/build/calculate-wrapped-text.js +53 -0
  41. package/node_modules/ink/build/components/App.d.ts +1 -4
  42. package/node_modules/ink/build/components/App.js +22 -142
  43. package/node_modules/ink/build/components/App.js.map +1 -1
  44. package/node_modules/ink/build/components/AppContext.d.ts +3 -23
  45. package/node_modules/ink/build/components/AppContext.js +4 -7
  46. package/node_modules/ink/build/components/AppContext.js.map +1 -1
  47. package/node_modules/ink/build/components/Box.d.ts +3 -16
  48. package/node_modules/ink/build/components/Color.js +62 -0
  49. package/node_modules/ink/build/components/Cursor.d.ts +83 -0
  50. package/node_modules/ink/build/components/Cursor.js +53 -0
  51. package/node_modules/ink/build/components/Cursor.js.map +1 -0
  52. package/node_modules/ink/build/components/ErrorBoundary.d.ts +2 -2
  53. package/node_modules/ink/build/components/ErrorOverview.js +6 -6
  54. package/node_modules/ink/build/components/ErrorOverview.js.map +1 -1
  55. package/node_modules/ink/build/components/Static.js.map +1 -1
  56. package/node_modules/ink/build/components/StdinContext.d.ts +1 -7
  57. package/node_modules/ink/build/components/StdinContext.js +0 -1
  58. package/node_modules/ink/build/components/StdinContext.js.map +1 -1
  59. package/node_modules/ink/build/components/Text.d.ts +1 -1
  60. package/node_modules/ink/build/components/Text.js +1 -1
  61. package/node_modules/ink/build/components/Text.js.map +1 -1
  62. package/node_modules/ink/build/components/Transform.d.ts +1 -1
  63. package/node_modules/ink/build/devtools-window-polyfill.js +4 -7
  64. package/node_modules/ink/build/devtools-window-polyfill.js.map +1 -1
  65. package/node_modules/ink/build/devtools.js +6 -31
  66. package/node_modules/ink/build/devtools.js.map +1 -1
  67. package/node_modules/ink/build/dom.d.ts +1 -5
  68. package/node_modules/ink/build/dom.js +1 -20
  69. package/node_modules/ink/build/dom.js.map +1 -1
  70. package/node_modules/ink/build/experimental/apply-style.js +140 -0
  71. package/node_modules/ink/build/experimental/dom.js +123 -0
  72. package/node_modules/ink/build/experimental/output.js +91 -0
  73. package/node_modules/ink/build/experimental/reconciler.js +141 -0
  74. package/node_modules/ink/build/experimental/renderer.js +81 -0
  75. package/node_modules/ink/build/hooks/use-app.d.ts +1 -1
  76. package/node_modules/ink/build/hooks/use-app.js +1 -1
  77. package/node_modules/ink/build/hooks/use-cursor.d.ts +1 -1
  78. package/node_modules/ink/build/hooks/use-cursor.js +1 -1
  79. package/node_modules/ink/build/hooks/use-focus-manager.d.ts +2 -17
  80. package/node_modules/ink/build/hooks/use-focus-manager.js +1 -2
  81. package/node_modules/ink/build/hooks/use-focus-manager.js.map +1 -1
  82. package/node_modules/ink/build/hooks/use-focus.d.ts +1 -2
  83. package/node_modules/ink/build/hooks/use-focus.js +4 -5
  84. package/node_modules/ink/build/hooks/use-focus.js.map +1 -1
  85. package/node_modules/ink/build/hooks/use-input.d.ts +1 -2
  86. package/node_modules/ink/build/hooks/use-input.js +80 -82
  87. package/node_modules/ink/build/hooks/use-input.js.map +1 -1
  88. package/node_modules/ink/build/hooks/use-is-screen-reader-enabled.d.ts +1 -2
  89. package/node_modules/ink/build/hooks/use-is-screen-reader-enabled.js +1 -2
  90. package/node_modules/ink/build/hooks/use-is-screen-reader-enabled.js.map +1 -1
  91. package/node_modules/ink/build/hooks/use-stderr.d.ts +1 -1
  92. package/node_modules/ink/build/hooks/use-stderr.js +1 -1
  93. package/node_modules/ink/build/hooks/use-stdin.d.ts +2 -4
  94. package/node_modules/ink/build/hooks/use-stdin.js +1 -2
  95. package/node_modules/ink/build/hooks/use-stdin.js.map +1 -1
  96. package/node_modules/ink/build/hooks/use-stdout.d.ts +1 -1
  97. package/node_modules/ink/build/hooks/use-stdout.js +1 -1
  98. package/node_modules/ink/build/hooks/useInput.js +38 -0
  99. package/node_modules/ink/build/index.d.ts +1 -8
  100. package/node_modules/ink/build/index.js +0 -4
  101. package/node_modules/ink/build/index.js.map +1 -1
  102. package/node_modules/ink/build/ink.d.ts +3 -48
  103. package/node_modules/ink/build/ink.js +155 -325
  104. package/node_modules/ink/build/ink.js.map +1 -1
  105. package/node_modules/ink/build/input-parser.d.ts +1 -4
  106. package/node_modules/ink/build/input-parser.js +30 -70
  107. package/node_modules/ink/build/input-parser.js.map +1 -1
  108. package/node_modules/ink/build/instance.js +205 -0
  109. package/node_modules/ink/build/layout.d.ts +7 -0
  110. package/node_modules/ink/build/layout.js +33 -0
  111. package/node_modules/ink/build/layout.js.map +1 -0
  112. package/node_modules/ink/build/log-update.d.ts +0 -1
  113. package/node_modules/ink/build/log-update.js +1 -13
  114. package/node_modules/ink/build/log-update.js.map +1 -1
  115. package/node_modules/ink/build/measure-element.d.ts +0 -4
  116. package/node_modules/ink/build/measure-element.js +0 -4
  117. package/node_modules/ink/build/measure-element.js.map +1 -1
  118. package/node_modules/ink/build/options.d.ts +52 -0
  119. package/node_modules/ink/build/options.js +2 -0
  120. package/node_modules/ink/build/options.js.map +1 -0
  121. package/node_modules/ink/build/output.js +0 -25
  122. package/node_modules/ink/build/output.js.map +1 -1
  123. package/node_modules/ink/build/parse-keypress.d.ts +3 -1
  124. package/node_modules/ink/build/parse-keypress.js +17 -19
  125. package/node_modules/ink/build/parse-keypress.js.map +1 -1
  126. package/node_modules/ink/build/reconciler.js +27 -46
  127. package/node_modules/ink/build/reconciler.js.map +1 -1
  128. package/node_modules/ink/build/render-border.js +18 -29
  129. package/node_modules/ink/build/render-border.js.map +1 -1
  130. package/node_modules/ink/build/render-to-string.js +1 -2
  131. package/node_modules/ink/build/render-to-string.js.map +1 -1
  132. package/node_modules/ink/build/render.d.ts +2 -57
  133. package/node_modules/ink/build/render.js +11 -18
  134. package/node_modules/ink/build/render.js.map +1 -1
  135. package/node_modules/ink/build/screen-reader-update.d.ts +13 -0
  136. package/node_modules/ink/build/screen-reader-update.js +38 -0
  137. package/node_modules/ink/build/screen-reader-update.js.map +1 -0
  138. package/node_modules/ink/build/styles.d.ts +16 -78
  139. package/node_modules/ink/build/styles.js +31 -102
  140. package/node_modules/ink/build/styles.js.map +1 -1
  141. package/node_modules/ink/build/utils.d.ts +2 -9
  142. package/node_modules/ink/build/utils.js +3 -18
  143. package/node_modules/ink/build/utils.js.map +1 -1
  144. package/node_modules/ink/build/wrap-text.js +0 -7
  145. package/node_modules/ink/build/wrap-text.js.map +1 -1
  146. package/node_modules/ink/build/write-synchronized.d.ts +1 -1
  147. package/node_modules/ink/build/write-synchronized.js +2 -4
  148. package/node_modules/ink/build/write-synchronized.js.map +1 -1
  149. package/node_modules/ink/node_modules/emoji-regex/LICENSE-MIT.txt +20 -0
  150. package/node_modules/ink/node_modules/emoji-regex/README.md +107 -0
  151. package/node_modules/ink/node_modules/emoji-regex/index.d.ts +3 -0
  152. package/node_modules/ink/node_modules/emoji-regex/index.js +4 -0
  153. package/node_modules/ink/node_modules/emoji-regex/index.mjs +4 -0
  154. package/node_modules/ink/node_modules/emoji-regex/package.json +45 -0
  155. package/node_modules/{wrap-ansi → ink/node_modules/wrap-ansi}/index.d.ts +1 -1
  156. package/node_modules/ink/node_modules/wrap-ansi/index.js +222 -0
  157. package/node_modules/ink/node_modules/wrap-ansi/node_modules/string-width/index.d.ts +39 -0
  158. package/node_modules/ink/node_modules/wrap-ansi/node_modules/string-width/index.js +82 -0
  159. package/node_modules/ink/node_modules/wrap-ansi/node_modules/string-width/license +9 -0
  160. package/node_modules/ink/node_modules/wrap-ansi/node_modules/string-width/package.json +64 -0
  161. package/node_modules/ink/node_modules/wrap-ansi/node_modules/string-width/readme.md +66 -0
  162. package/node_modules/{wrap-ansi → ink/node_modules/wrap-ansi}/package.json +11 -11
  163. package/node_modules/{wrap-ansi → ink/node_modules/wrap-ansi}/readme.md +0 -2
  164. package/node_modules/ink/package.json +98 -34
  165. package/node_modules/ink/readme.md +48 -554
  166. package/node_modules/slice-ansi/index.d.ts +1 -1
  167. package/node_modules/slice-ansi/index.js +89 -146
  168. package/node_modules/slice-ansi/package.json +5 -5
  169. package/node_modules/slice-ansi/readme.md +0 -1
  170. package/node_modules/slice-ansi/tokenize-ansi.js +1 -1
  171. package/package.json +11 -10
  172. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +188 -0
  173. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +1 -0
  174. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +293 -0
  175. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +1 -0
  176. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +25 -0
  177. package/node_modules/@alcalzone/ansi-tokenize/build/consts.d.ts +0 -17
  178. package/node_modules/@alcalzone/ansi-tokenize/build/consts.js +0 -28
  179. package/node_modules/@alcalzone/ansi-tokenize/build/consts.js.map +0 -1
  180. package/node_modules/ink/build/components/AnimationContext.d.ts +0 -9
  181. package/node_modules/ink/build/components/AnimationContext.js +0 -13
  182. package/node_modules/ink/build/components/AnimationContext.js.map +0 -1
  183. package/node_modules/ink/build/hooks/use-animation.d.ts +0 -49
  184. package/node_modules/ink/build/hooks/use-animation.js +0 -87
  185. package/node_modules/ink/build/hooks/use-animation.js.map +0 -1
  186. package/node_modules/ink/build/hooks/use-box-metrics.d.ts +0 -59
  187. package/node_modules/ink/build/hooks/use-box-metrics.js +0 -88
  188. package/node_modules/ink/build/hooks/use-box-metrics.js.map +0 -1
  189. package/node_modules/ink/build/hooks/use-paste.d.ts +0 -35
  190. package/node_modules/ink/build/hooks/use-paste.js +0 -62
  191. package/node_modules/ink/build/hooks/use-paste.js.map +0 -1
  192. package/node_modules/ink/build/hooks/use-window-size.d.ts +0 -18
  193. package/node_modules/ink/build/hooks/use-window-size.js +0 -22
  194. package/node_modules/ink/build/hooks/use-window-size.js.map +0 -1
  195. package/node_modules/wrap-ansi/index.js +0 -468
  196. /package/node_modules/{wrap-ansi → ink/node_modules/wrap-ansi}/license +0 -0
@@ -1,7 +1,8 @@
1
1
  import React, { createElement, useEffect, useMemo, useRef, useState } from "react";
2
- import { Box, Text, useApp, useBoxMetrics, useCursor, useInput, useStdout } from "ink";
2
+ import { Box, Text, useApp, useCursor, useInput, useStdout } from "ink";
3
3
  import { bold, cyan, dim, green, red, yellow } from "../terminal/colors.mjs";
4
4
  import { RunTreeView } from "../components/blocks/run-tree.mjs";
5
+ import { useElementLayout } from "../components/hooks/use-element-layout.mjs";
5
6
  import { colorCodeLine } from "./code-block.mjs";
6
7
  import { getComposerDisplayModel } from "./composer.mjs";
7
8
  import { MarkdownBlock } from "./markdown-block.mjs";
@@ -370,7 +371,8 @@ function colorCommandStatus(block, status) {
370
371
 
371
372
  function ComposerBar({ view, busy }) {
372
373
  const ref = useRef(null);
373
- const metrics = useBoxMetrics(ref);
374
+ const { stdout } = useStdout();
375
+ const metrics = useElementLayout(ref, { stdout });
374
376
  const { setCursorPosition } = useCursor();
375
377
  const display = getComposerDisplayModel(
376
378
  {
@@ -81,7 +81,11 @@ export async function runAssistantConversationTurn({
81
81
  onEvent(event) {
82
82
  appendProviderTrace(tracePath, event);
83
83
  onProviderEvent?.(event);
84
- if (event.type === "status" || event.type === "tool-start" || event.type === "tool-update" || event.type === "tool-end") {
84
+ if (
85
+ event.display !== false &&
86
+ !event.transient &&
87
+ (event.type === "status" || event.type === "tool-start" || event.type === "tool-update" || event.type === "tool-end")
88
+ ) {
85
89
  onStatus?.(formatProviderEvent(event));
86
90
  }
87
91
  },
@@ -757,14 +757,13 @@ function createProviderTurnState() {
757
757
  assistantMessageId: null,
758
758
  assistantMessageIdsByProviderItem: new Map(),
759
759
  providerToolMessageIdsByProviderItem: new Map(),
760
- lastActivityText: null,
760
+ lastActivityText: null,
761
761
  };
762
762
  }
763
763
 
764
764
  function handleProviderEvent(turn, event, { appendMessage, updateMessage, setStatus } = {}) {
765
765
  if (!event) return;
766
766
  if (event.transient || event.display === false) {
767
- if (event.type === "status" && event.text) setStatus?.(event.text);
768
767
  return;
769
768
  }
770
769
  if (event.type === "assistant-delta") {
@@ -1,5 +1,5 @@
1
1
  import React, { createElement, useEffect, useMemo, useState } from "react";
2
- import { Box, Text, useAnimation, useApp, useInput } from "ink";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
3
  import figures from "figures";
4
4
  import { formatDuration } from "../../../runner/formatting.mjs";
5
5
  import {
@@ -14,6 +14,7 @@ import {
14
14
  import { renderSummaryBox } from "../primitives/summary-box.mjs";
15
15
  import { getTerminalWidth } from "../../terminal/layout.mjs";
16
16
  import { renderFailureDetail, renderPassedDetail } from "../../renderers/run/inline-detail.mjs";
17
+ import { useSpinnerFrame } from "../hooks/use-spinner-frame.mjs";
17
18
 
18
19
  const SPINNER_FRAMES = ["|", "/", "-", "\\"];
19
20
 
@@ -27,7 +28,11 @@ export function RunTreeView({
27
28
  const { exit } = useApp();
28
29
  const controlledSnapshot = Boolean(snapshotOverride);
29
30
  const [snapshot, setSnapshot] = useState(() => snapshotOverride || runState.getSnapshot());
30
- const { frame } = useAnimation({ interval: 80, isActive: !controlledSnapshot && !snapshot.finished });
31
+ const frame = useSpinnerFrame({
32
+ frameCount: SPINNER_FRAMES.length,
33
+ intervalMs: 80,
34
+ isActive: !controlledSnapshot && !snapshot.finished,
35
+ });
31
36
  const spinnerFrame = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
32
37
 
33
38
  useEffect(() => {
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+
3
+ const EMPTY_LAYOUT = Object.freeze({
4
+ width: 0,
5
+ height: 0,
6
+ left: 0,
7
+ top: 0,
8
+ hasMeasured: false,
9
+ });
10
+
11
+ export function readElementLayout(ref) {
12
+ const node = ref?.current || null;
13
+ const rawLayout = node?.yogaNode?.getComputedLayout?.() || null;
14
+ if (!node || !rawLayout) return EMPTY_LAYOUT;
15
+
16
+ return {
17
+ width: normalizeLayoutNumber(rawLayout.width),
18
+ height: normalizeLayoutNumber(rawLayout.height),
19
+ left: normalizeLayoutNumber(rawLayout.left),
20
+ top: normalizeLayoutNumber(rawLayout.top),
21
+ hasMeasured: true,
22
+ };
23
+ }
24
+
25
+ export function layoutsEqual(left, right) {
26
+ return (
27
+ left.width === right.width &&
28
+ left.height === right.height &&
29
+ left.left === right.left &&
30
+ left.top === right.top &&
31
+ left.hasMeasured === right.hasMeasured
32
+ );
33
+ }
34
+
35
+ export function useElementLayout(ref, { stdout } = {}) {
36
+ const [layout, setLayout] = useState(EMPTY_LAYOUT);
37
+
38
+ const updateLayout = useCallback(() => {
39
+ const nextLayout = readElementLayout(ref);
40
+ setLayout((currentLayout) => layoutsEqual(currentLayout, nextLayout) ? currentLayout : nextLayout);
41
+ }, [ref]);
42
+
43
+ useEffect(updateLayout);
44
+
45
+ useEffect(() => {
46
+ if (!stdout?.on) return undefined;
47
+
48
+ stdout.on("resize", updateLayout);
49
+ return () => {
50
+ if (stdout.off) {
51
+ stdout.off("resize", updateLayout);
52
+ return;
53
+ }
54
+ stdout.removeListener?.("resize", updateLayout);
55
+ };
56
+ }, [stdout, updateLayout]);
57
+
58
+ return useMemo(() => layout, [layout]);
59
+ }
60
+
61
+ function normalizeLayoutNumber(value) {
62
+ return Number.isFinite(value) ? value : 0;
63
+ }
@@ -0,0 +1,26 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function nextSpinnerFrame(frame, frameCount) {
4
+ const normalizedFrameCount = Number.isInteger(frameCount) && frameCount > 0 ? frameCount : 1;
5
+ return (frame + 1) % normalizedFrameCount;
6
+ }
7
+
8
+ export function useSpinnerFrame({
9
+ frameCount,
10
+ intervalMs = 80,
11
+ isActive = true,
12
+ } = {}) {
13
+ const [frame, setFrame] = useState(0);
14
+
15
+ useEffect(() => {
16
+ if (!isActive) return undefined;
17
+
18
+ const timer = setInterval(() => {
19
+ setFrame((currentFrame) => nextSpinnerFrame(currentFrame, frameCount));
20
+ }, intervalMs);
21
+
22
+ return () => clearInterval(timer);
23
+ }, [frameCount, intervalMs, isActive]);
24
+
25
+ return frame;
26
+ }
@@ -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
+ }