tenzir-test 0.11.0__tar.gz → 0.12.0__tar.gz

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 (83) hide show
  1. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/PKG-INFO +1 -1
  2. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/pyproject.toml +1 -1
  3. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/cli.py +4 -0
  4. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/run.py +152 -36
  5. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/runners/custom_python_fixture_runner.py +5 -2
  6. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/runners/shell_runner.py +4 -2
  7. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/tests/test_run.py +103 -20
  8. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/.gitignore +0 -0
  9. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/LICENSE +0 -0
  10. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/README.md +0 -0
  11. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/README.md +0 -0
  12. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/fixtures/README.md +0 -0
  13. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/fixtures/__init__.py +0 -0
  14. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/fixtures/http.py +0 -0
  15. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/fixtures/server.py +0 -0
  16. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/inputs/events.ndjson +0 -0
  17. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/runners/__init__.py +0 -0
  18. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/runners/xxd.py +0 -0
  19. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/context/01-context-create.tql +0 -0
  20. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/context/01-context-create.txt +0 -0
  21. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/context/02-context-update.tql +0 -0
  22. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/context/02-context-update.txt +0 -0
  23. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/context/03-context-inspect.tql +0 -0
  24. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/context/03-context-inspect.txt +0 -0
  25. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/context/test.yaml +0 -0
  26. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/fail.tql +0 -0
  27. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/fail.txt +0 -0
  28. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/hex/hello.txt +0 -0
  29. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/hex/hello.xxd +0 -0
  30. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/http-fixture.tql +0 -0
  31. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/http-fixture.txt +0 -0
  32. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/lazy.tql +0 -0
  33. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/lazy.txt +0 -0
  34. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/node-fixture.tql +0 -0
  35. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/node-fixture.txt +0 -0
  36. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/executor-only/sum.py +0 -0
  37. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/executor-only/sum.txt +0 -0
  38. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/executor-with-http-fixture/request.py +0 -0
  39. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/executor-with-http-fixture/request.txt +0 -0
  40. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/executor-with-http-fixture/test.yaml +0 -0
  41. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/executor-with-node-fixture/context-manager.py +0 -0
  42. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/executor-with-node-fixture/context-manager.txt +0 -0
  43. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/fixture-driving/manual_control.py +0 -0
  44. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/fixture-driving/manual_control.txt +0 -0
  45. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/pure-python/flaky_coin.py +0 -0
  46. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/pure-python/flaky_coin.txt +0 -0
  47. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/pure-python/hello_world.py +0 -0
  48. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/python/pure-python/hello_world.txt +0 -0
  49. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/read-inputs.tql +0 -0
  50. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/read-inputs.txt +0 -0
  51. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/shell/exit-code-test.sh +0 -0
  52. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/shell/exit-code-test.txt +0 -0
  53. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/shell/http-fixture-check.sh +0 -0
  54. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/shell/http-fixture-check.txt +0 -0
  55. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/shell/tmp-dir.sh +0 -0
  56. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/example-project/tests/shell/tmp-dir.txt +0 -0
  57. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/__init__.py +0 -0
  58. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/_python_runner.py +0 -0
  59. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/checks.py +0 -0
  60. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/config.py +0 -0
  61. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/engine/__init__.py +0 -0
  62. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/engine/operations.py +0 -0
  63. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/engine/registry.py +0 -0
  64. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/engine/state.py +0 -0
  65. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/engine/worker.py +0 -0
  66. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/fixtures/__init__.py +0 -0
  67. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/fixtures/node.py +0 -0
  68. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/packages.py +0 -0
  69. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/py.typed +0 -0
  70. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/runners/__init__.py +0 -0
  71. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/runners/_utils.py +0 -0
  72. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/runners/diff_runner.py +0 -0
  73. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/runners/ext_runner.py +0 -0
  74. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/runners/runner.py +0 -0
  75. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/runners/tenzir_runner.py +0 -0
  76. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/src/tenzir_test/runners/tql_runner.py +0 -0
  77. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/tests/test_cli.py +0 -0
  78. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/tests/test_config.py +0 -0
  79. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/tests/test_engine_operations.py +0 -0
  80. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/tests/test_python_runner.py +0 -0
  81. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/tests/test_run_config.py +0 -0
  82. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/tests/test_runner_registry.py +0 -0
  83. {tenzir_test-0.11.0 → tenzir_test-0.12.0}/tests/test_shell_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tenzir-test
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Reusable test execution framework extracted from the Tenzir repository.
5
5
  Project-URL: Homepage, https://github.com/tenzir/test
6
6
  Project-URL: Repository, https://github.com/tenzir/test
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tenzir-test"
3
- version = "0.11.0"
3
+ version = "0.12.0"
4
4
  description = "Reusable test execution framework extracted from the Tenzir repository."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -191,6 +191,8 @@ def main(argv: Sequence[str] | None = None) -> int:
191
191
  """Run the Click command and translate Click exits to integer codes."""
192
192
 
193
193
  command_main = getattr(cli, "main")
194
+ previous_color_mode = runtime.get_color_mode()
195
+ runtime.set_color_mode(runtime.ColorMode.AUTO)
194
196
  try:
195
197
  result = command_main(
196
198
  args=list(argv) if argv is not None else None,
@@ -206,6 +208,8 @@ def main(argv: Sequence[str] | None = None) -> int:
206
208
  return _normalize_exit_code(exc.code)
207
209
  else:
208
210
  return _normalize_exit_code(result)
211
+ finally:
212
+ runtime.set_color_mode(previous_color_mode)
209
213
 
210
214
 
211
215
  if __name__ == "__main__":
@@ -138,6 +138,14 @@ class HarnessMode(enum.Enum):
138
138
  PASSTHROUGH = "passthrough"
139
139
 
140
140
 
141
+ class ColorMode(enum.Enum):
142
+ """Supported color output policies."""
143
+
144
+ AUTO = "auto"
145
+ ALWAYS = "always"
146
+ NEVER = "never"
147
+
148
+
141
149
  def detect_execution_mode(root: Path) -> tuple[ExecutionMode, Path | None]:
142
150
  """Return the execution mode and detected package root for `root`."""
143
151
 
@@ -159,19 +167,23 @@ INPUTS_DIR: Path = ROOT / "inputs"
159
167
  EXECUTION_MODE: ExecutionMode = ExecutionMode.PROJECT
160
168
  _DETECTED_PACKAGE_ROOT: Path | None = None
161
169
  HARNESS_MODE = HarnessMode.COMPARE
162
- CHECKMARK = "\033[92;1m✔\033[0m"
163
- CROSS = "\033[31m✘\033[0m"
164
- INFO = "\033[94;1mi\033[0m"
165
- SKIP = "\033[90;1m●\033[0m"
166
- DEBUG_PREFIX = "\033[95m◆\033[0m"
167
- BOLD = "\033[1m"
168
- CHECK_COLOR = "\033[92;1m"
169
- PASS_MAX_COLOR = "\033[92m"
170
- FAIL_COLOR = "\033[31m"
171
- SKIP_COLOR = "\033[90;1m"
172
- RESET_COLOR = "\033[0m"
173
- DETAIL_COLOR = "\033[2;37m"
174
- PASS_SPECTRUM = [
170
+ _COLOR_MODE = ColorMode.NEVER
171
+ COLORS_ENABLED = False
172
+ CHECKMARK = ""
173
+ CROSS = ""
174
+ INFO = ""
175
+ SKIP = ""
176
+ DEBUG_PREFIX = ""
177
+ BOLD = ""
178
+ CHECK_COLOR = ""
179
+ PASS_MAX_COLOR = ""
180
+ FAIL_COLOR = ""
181
+ SKIP_COLOR = ""
182
+ RESET_COLOR = ""
183
+ DETAIL_COLOR = ""
184
+ DIFF_ADD_COLOR = ""
185
+ PASS_SPECTRUM: list[str] = []
186
+ _COLORED_PASS_SPECTRUM = [
175
187
  "\033[38;5;52m", # 0-9% deep red
176
188
  "\033[38;5;88m", # 10-19% red
177
189
  "\033[38;5;124m", # 20-29% dark orange
@@ -182,8 +194,107 @@ PASS_SPECTRUM = [
182
194
  "\033[38;5;148m", # 70-79% spring green
183
195
  "\033[38;5;112m", # 80-89% medium green
184
196
  "\033[38;5;28m", # 90-99% deep forest green
185
- PASS_MAX_COLOR, # 100% bright green
197
+ "\033[92m", # 100% bright green
186
198
  ]
199
+ _INTERRUPTED_NOTICE = "└─▶ test interrupted by user"
200
+
201
+
202
+ def _colors_available() -> bool:
203
+ if _COLOR_MODE is ColorMode.NEVER:
204
+ return False
205
+ if "NO_COLOR" in os.environ:
206
+ return False
207
+ if _COLOR_MODE is ColorMode.ALWAYS:
208
+ return True
209
+ return True
210
+
211
+
212
+ def _apply_color_palette() -> None:
213
+ global COLORS_ENABLED
214
+ global CHECKMARK
215
+ global CROSS
216
+ global INFO
217
+ global SKIP
218
+ global DEBUG_PREFIX
219
+ global BOLD
220
+ global CHECK_COLOR
221
+ global PASS_MAX_COLOR
222
+ global FAIL_COLOR
223
+ global SKIP_COLOR
224
+ global RESET_COLOR
225
+ global DETAIL_COLOR
226
+ global DIFF_ADD_COLOR
227
+ global PASS_SPECTRUM
228
+ global _INTERRUPTED_NOTICE
229
+
230
+ COLORS_ENABLED = _colors_available()
231
+ RESET_COLOR = "\033[0m" if COLORS_ENABLED else ""
232
+
233
+ def _wrap(code: str, text: str) -> str:
234
+ if not code:
235
+ return text
236
+ return f"{code}{text}{RESET_COLOR}"
237
+
238
+ CHECK_COLOR = "\033[92;1m" if COLORS_ENABLED else ""
239
+ PASS_MAX_COLOR = "\033[92m" if COLORS_ENABLED else ""
240
+ FAIL_COLOR = "\033[31m" if COLORS_ENABLED else ""
241
+ SKIP_COLOR = "\033[90;1m" if COLORS_ENABLED else ""
242
+ DETAIL_COLOR = "\033[2;37m" if COLORS_ENABLED else ""
243
+ DIFF_ADD_COLOR = "\033[32m" if COLORS_ENABLED else ""
244
+ BOLD = "\033[1m" if COLORS_ENABLED else ""
245
+
246
+ CHECKMARK = _wrap(CHECK_COLOR, "✔")
247
+ CROSS = _wrap(FAIL_COLOR, "✘")
248
+ INFO = _wrap("\033[94;1m" if COLORS_ENABLED else "", "i")
249
+ SKIP = _wrap(SKIP_COLOR, "●")
250
+ DEBUG_PREFIX = _wrap("\033[95m" if COLORS_ENABLED else "", "◆")
251
+ PASS_SPECTRUM = (
252
+ list(_COLORED_PASS_SPECTRUM) if COLORS_ENABLED else ["" for _ in _COLORED_PASS_SPECTRUM]
253
+ )
254
+ _INTERRUPTED_NOTICE = (
255
+ f"└─▶ {_wrap('\033[33m' if COLORS_ENABLED else '', 'test interrupted by user')}"
256
+ )
257
+
258
+
259
+ def refresh_color_palette() -> None:
260
+ """Re-evaluate ANSI color availability based on environment variables."""
261
+
262
+ _apply_color_palette()
263
+
264
+
265
+ def colors_enabled() -> bool:
266
+ return COLORS_ENABLED
267
+
268
+
269
+ def get_color_mode() -> ColorMode:
270
+ return _COLOR_MODE
271
+
272
+
273
+ def set_color_mode(mode: ColorMode) -> None:
274
+ global _COLOR_MODE
275
+ if not isinstance(mode, ColorMode):
276
+ raise TypeError("mode must be an instance of ColorMode")
277
+ if _COLOR_MODE is mode:
278
+ return
279
+ _COLOR_MODE = mode
280
+ _apply_color_palette()
281
+
282
+
283
+ def colorize(text: str, color: str) -> str:
284
+ """Wrap `text` with the given ANSI `color` code if colors are enabled."""
285
+
286
+ if not color:
287
+ return text
288
+ return f"{color}{text}{RESET_COLOR}"
289
+
290
+
291
+ def format_failure_message(message: str) -> str:
292
+ """Render a standardized failure line with optional ANSI coloring."""
293
+
294
+ return f"└─▶ {colorize(message, FAIL_COLOR)}"
295
+
296
+
297
+ _apply_color_palette()
187
298
  ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
188
299
 
189
300
  stdout_lock = threading.RLock()
@@ -244,8 +355,6 @@ def _is_interrupt_exit(returncode: int) -> bool:
244
355
  return returncode in {128 + sig for sig in _INTERRUPT_SIGNALS}
245
356
 
246
357
 
247
- _INTERRUPTED_NOTICE = "└─▶ \033[33mtest interrupted by user\033[0m"
248
-
249
358
  _CURRENT_RETRY_CONTEXT = threading.local()
250
359
  _CURRENT_SUITE_CONTEXT = threading.local()
251
360
 
@@ -332,7 +441,8 @@ def _format_attempt_suffix() -> str:
332
441
  attempt, max_attempts = progress
333
442
  if attempt <= 1 or max_attempts <= 1:
334
443
  return ""
335
- return f" \033[2;37mattempts={attempt}/{max_attempts}\033[0m"
444
+ detail = f"attempts={attempt}/{max_attempts}"
445
+ return f" {colorize(detail, DETAIL_COLOR)}"
336
446
 
337
447
 
338
448
  def _format_suite_suffix() -> str:
@@ -342,7 +452,8 @@ def _format_suite_suffix() -> str:
342
452
  name, index, total = progress
343
453
  if not name or total <= 0:
344
454
  return ""
345
- return f" \033[2;37msuite={name} ({index}/{total})\033[0m"
455
+ detail = f"suite={name} ({index}/{total})"
456
+ return f" {colorize(detail, DETAIL_COLOR)}"
346
457
 
347
458
 
348
459
  TEST_TMP_ENV_VAR = "TENZIR_TMP_DIR"
@@ -2175,8 +2286,8 @@ def _color_tree_glyphs(line: str, color: str) -> str:
2175
2286
  glyphs = {"│", "├", "└", "─"}
2176
2287
  parts = []
2177
2288
  for char in line:
2178
- if char in glyphs:
2179
- parts.append(f"{color}{char}\033[0m")
2289
+ if char in glyphs and color:
2290
+ parts.append(f"{color}{char}{RESET_COLOR}")
2180
2291
  else:
2181
2292
  parts.append(char)
2182
2293
  return "".join(parts)
@@ -2424,7 +2535,7 @@ def _print_detailed_summary(summary: Summary) -> None:
2424
2535
  if summary.failed_paths:
2425
2536
  print(f"{CROSS} Failed tests:")
2426
2537
  for line in _render_tree(_build_path_tree(summary.failed_paths)):
2427
- print(f" {_color_tree_glyphs(line, '\033[31m')}")
2538
+ print(f" {_color_tree_glyphs(line, FAIL_COLOR)}")
2428
2539
 
2429
2540
 
2430
2541
  def get_version() -> str:
@@ -2489,16 +2600,16 @@ def _format_unary_symbols(count: int, symbols: dict[int, str]) -> str:
2489
2600
  def _format_diff_counter(added: int, removed: int) -> str:
2490
2601
  plus_segment = _format_unary_symbols(added, _PLUS_SYMBOLS)
2491
2602
  minus_segment = _format_unary_symbols(removed, _MINUS_SYMBOLS)
2492
- colored_plus = f"\033[32m{plus_segment}\033[0m" if plus_segment else ""
2493
- colored_minus = f"\033[31m{minus_segment}\033[0m" if minus_segment else ""
2603
+ colored_plus = colorize(plus_segment, DIFF_ADD_COLOR) if plus_segment else ""
2604
+ colored_minus = colorize(minus_segment, FAIL_COLOR) if minus_segment else ""
2494
2605
  return f"{colored_plus}{colored_minus}"
2495
2606
 
2496
2607
 
2497
2608
  def _format_stat_header(path: os.PathLike[str] | str, added: int, removed: int) -> str:
2498
2609
  path_str = os.fspath(path)
2499
2610
  counter = _format_diff_counter(added, removed)
2500
- plus_count = f"\033[32m{added}(+)\033[0m"
2501
- minus_count = f"\033[31m{removed}(-)\033[0m"
2611
+ plus_count = colorize(f"{added}(+)", DIFF_ADD_COLOR)
2612
+ minus_count = colorize(f"{removed}(-)", FAIL_COLOR)
2502
2613
  if counter:
2503
2614
  counter_segment = f" {counter}"
2504
2615
  else:
@@ -2543,9 +2654,9 @@ def print_diff(expected: bytes, actual: bytes, path: Path) -> None:
2543
2654
  continue
2544
2655
  text = raw_line.decode("utf-8", "replace").rstrip("\r\n")
2545
2656
  if raw_line.startswith(b"+") and not raw_line.startswith(b"+++"):
2546
- text = f"\033[32m{text}\033[0m"
2657
+ text = colorize(text, DIFF_ADD_COLOR)
2547
2658
  elif raw_line.startswith(b"-") and not raw_line.startswith(b"---"):
2548
- text = f"\033[31m{text}\033[0m"
2659
+ text = colorize(text, FAIL_COLOR)
2549
2660
  diff_lines.append(text)
2550
2661
  rel_path = _relativize_path(path)
2551
2662
  rel_path_str = os.fspath(rel_path)
@@ -2589,7 +2700,7 @@ def run_simple_test(
2589
2700
  # Parse test configuration
2590
2701
  test_config = parse_test_config(test, coverage=coverage)
2591
2702
  except ValueError as e:
2592
- report_failure(test, f"└─▶ \033[31m{e}\033[0m")
2703
+ report_failure(test, format_failure_message(str(e)))
2593
2704
  return False
2594
2705
 
2595
2706
  inputs_override = typing.cast(str | None, test_config.get("inputs"))
@@ -2675,13 +2786,16 @@ def run_simple_test(
2675
2786
  captured_stderr = completed.stderr or b""
2676
2787
  stderr_output = captured_stderr.replace(root_bytes, b"")
2677
2788
  except subprocess.TimeoutExpired:
2678
- report_failure(test, f"└─▶ \033[31msubprocess hit {timeout}s timeout\033[0m")
2789
+ report_failure(
2790
+ test,
2791
+ format_failure_message(f"subprocess hit {timeout}s timeout"),
2792
+ )
2679
2793
  return False
2680
2794
  except subprocess.CalledProcessError as e:
2681
- report_failure(test, f"└─▶ \033[31msubprocess error: {e}\033[0m")
2795
+ report_failure(test, format_failure_message(f"subprocess error: {e}"))
2682
2796
  return False
2683
2797
  except Exception as e:
2684
- report_failure(test, f"└─▶ \033[31munexpected exception: {e}\033[0m")
2798
+ report_failure(test, format_failure_message(f"unexpected exception: {e}"))
2685
2799
  return False
2686
2800
  finally:
2687
2801
  fixtures_impl.pop_context(context_token)
@@ -2696,7 +2810,7 @@ def run_simple_test(
2696
2810
  summary_line = (
2697
2811
  _INTERRUPTED_NOTICE
2698
2812
  if interrupted
2699
- else f"└─▶ \033[31mgot unexpected exit code {completed.returncode}\033[0m"
2813
+ else format_failure_message(f"got unexpected exit code {completed.returncode}")
2700
2814
  )
2701
2815
  if passthrough_mode:
2702
2816
  report_failure(test, summary_line)
@@ -2729,7 +2843,7 @@ def run_simple_test(
2729
2843
  f.write(output)
2730
2844
  else:
2731
2845
  if not ref_path.exists():
2732
- report_failure(test, f'└─▶ \033[31mFailed to find ref file: "{ref_path}"\033[0m')
2846
+ report_failure(test, format_failure_message(f'Failed to find ref file: "{ref_path}"'))
2733
2847
  return False
2734
2848
  log_comparison(test, ref_path, mode="comparing")
2735
2849
  expected = ref_path.read_bytes()
@@ -2758,7 +2872,9 @@ def handle_skip(reason: str, test: Path, update: bool, output_ext: str) -> bool
2758
2872
  if expected != b"":
2759
2873
  report_failure(
2760
2874
  test,
2761
- f'└─▶ \033[31mReference file for skipped test must be empty: "{ref_path}"\033[0m',
2875
+ format_failure_message(
2876
+ f'Reference file for skipped test must be empty: "{ref_path}"'
2877
+ ),
2762
2878
  )
2763
2879
  return False
2764
2880
  return "skipped"
@@ -2935,7 +3051,7 @@ class Worker:
2935
3051
  if suite_fixtures is None:
2936
3052
  configured_fixtures = config_fixtures
2937
3053
  if parse_error is not None:
2938
- message = f"└─▶ \033[31m{parse_error}\033[0m"
3054
+ message = format_failure_message(parse_error)
2939
3055
  report_failure(test_path, message)
2940
3056
  summary.total += 1
2941
3057
  summary.record_runner_outcome(runner.name, False)
@@ -2981,7 +3097,7 @@ class Worker:
2981
3097
  interrupted = True
2982
3098
  outcome = False
2983
3099
  except Exception as exc:
2984
- error_message = f"└─▶ \033[31m{exc}\033[0m"
3100
+ error_message = format_failure_message(str(exc))
2985
3101
  report_failure(test_path, error_message)
2986
3102
  outcome = False
2987
3103
  final_interrupted = False
@@ -127,7 +127,9 @@ class CustomPythonFixture(ExtRunner):
127
127
  if not ref_path.exists():
128
128
  run_mod.report_failure(
129
129
  test,
130
- f'└─▶ \033[31mFailed to find ref file: "{ref_path}"\033[0m',
130
+ run_mod.format_failure_message(
131
+ f'Failed to find ref file: "{ref_path}"'
132
+ ),
131
133
  )
132
134
  return False
133
135
  run_mod.log_comparison(test, ref_path, mode="comparing")
@@ -144,7 +146,8 @@ class CustomPythonFixture(ExtRunner):
144
146
  run_mod.cleanup_test_tmp_dir(env.get(run_mod.TEST_TMP_ENV_VAR))
145
147
  except subprocess.TimeoutExpired:
146
148
  run_mod.report_failure(
147
- test, f"└─▶ \033[31mpython fixture hit {timeout}s timeout\033[0m"
149
+ test,
150
+ run_mod.format_failure_message(f"python fixture hit {timeout}s timeout"),
148
151
  )
149
152
  return False
150
153
  except subprocess.CalledProcessError as e:
@@ -79,7 +79,9 @@ class ShellRunner(ExtRunner):
79
79
  good = completed.returncode == 0
80
80
  if expect_error == good:
81
81
  suppressed = run_mod.should_suppress_failure_output()
82
- summary_line = f"└─▶ \033[31mgot unexpected exit code {completed.returncode}\033[0m"
82
+ summary_line = run_mod.format_failure_message(
83
+ f"got unexpected exit code {completed.returncode}"
84
+ )
83
85
  if passthrough:
84
86
  if not suppressed:
85
87
  run_mod.report_failure(test, summary_line)
@@ -127,7 +129,7 @@ class ShellRunner(ExtRunner):
127
129
  if not stdout_path.exists():
128
130
  run_mod.report_failure(
129
131
  test,
130
- f'└─▶ \033[31mFailed to find ref file: "{stdout_path}"\033[0m',
132
+ run_mod.format_failure_message(f'Failed to find ref file: "{stdout_path}"'),
131
133
  )
132
134
  return False
133
135
  run_mod.log_comparison(test, stdout_path, mode="comparing")
@@ -16,6 +16,7 @@ from tenzir_test.runners.runner import Runner
16
16
 
17
17
  def test_main_warns_when_outside_project_root(tmp_path, monkeypatch, capsys):
18
18
  original_settings = run._settings
19
+ original_root = run.ROOT
19
20
  monkeypatch.setenv("TENZIR_TEST_ROOT", str(tmp_path))
20
21
  monkeypatch.delenv("TENZIR_BINARY", raising=False)
21
22
  monkeypatch.delenv("TENZIR_NODE_BINARY", raising=False)
@@ -24,7 +25,13 @@ def test_main_warns_when_outside_project_root(tmp_path, monkeypatch, capsys):
24
25
  try:
25
26
  run.main([])
26
27
  finally:
27
- run.apply_settings(original_settings)
28
+ if original_settings is not None:
29
+ run.apply_settings(original_settings)
30
+ else:
31
+ run._settings = None
32
+ run.TENZIR_BINARY = None
33
+ run.TENZIR_NODE_BINARY = None
34
+ run._set_project_root(original_root)
28
35
 
29
36
  assert exc.value.code == 1
30
37
  captured = capsys.readouterr()
@@ -35,6 +42,7 @@ def test_main_warns_when_outside_project_root(tmp_path, monkeypatch, capsys):
35
42
 
36
43
  def test_main_warns_outside_project_root_with_selection(tmp_path, monkeypatch, capsys):
37
44
  original_settings = run._settings
45
+ original_root = run.ROOT
38
46
  monkeypatch.setenv("TENZIR_TEST_ROOT", str(tmp_path))
39
47
  monkeypatch.delenv("TENZIR_BINARY", raising=False)
40
48
  monkeypatch.delenv("TENZIR_NODE_BINARY", raising=False)
@@ -43,7 +51,13 @@ def test_main_warns_outside_project_root_with_selection(tmp_path, monkeypatch, c
43
51
  try:
44
52
  run.main(["tests/sample.tql"])
45
53
  finally:
46
- run.apply_settings(original_settings)
54
+ if original_settings is not None:
55
+ run.apply_settings(original_settings)
56
+ else:
57
+ run._settings = None
58
+ run.TENZIR_BINARY = None
59
+ run.TENZIR_NODE_BINARY = None
60
+ run._set_project_root(original_root)
47
61
 
48
62
  assert str(exc.value).startswith("error: test path `tests/sample.tql` does not exist")
49
63
  captured = capsys.readouterr()
@@ -52,6 +66,7 @@ def test_main_warns_outside_project_root_with_selection(tmp_path, monkeypatch, c
52
66
 
53
67
  def test_main_accepts_satellite_selection_without_project_root(tmp_path, monkeypatch, capsys):
54
68
  original_settings = run._settings
69
+ original_root = run.ROOT
55
70
  library_root = tmp_path / "library"
56
71
  library_root.mkdir()
57
72
  package_dir = library_root / "pkg"
@@ -67,7 +82,13 @@ def test_main_accepts_satellite_selection_without_project_root(tmp_path, monkeyp
67
82
  try:
68
83
  run.main(["pkg"])
69
84
  finally:
70
- run.apply_settings(original_settings)
85
+ if original_settings is not None:
86
+ run.apply_settings(original_settings)
87
+ else:
88
+ run._settings = None
89
+ run.TENZIR_BINARY = None
90
+ run.TENZIR_NODE_BINARY = None
91
+ run._set_project_root(original_root)
71
92
 
72
93
  captured = capsys.readouterr()
73
94
  lines = [line for line in captured.out.splitlines() if line]
@@ -78,6 +99,7 @@ def test_main_accepts_current_directory_selection_without_project_root(
78
99
  tmp_path, monkeypatch, capsys
79
100
  ):
80
101
  original_settings = run._settings
102
+ original_root = run.ROOT
81
103
  library_root = tmp_path / "library"
82
104
  library_root.mkdir()
83
105
  package_dir = library_root / "pkg"
@@ -93,7 +115,13 @@ def test_main_accepts_current_directory_selection_without_project_root(
93
115
  try:
94
116
  run.main(["."])
95
117
  finally:
96
- run.apply_settings(original_settings)
118
+ if original_settings is not None:
119
+ run.apply_settings(original_settings)
120
+ else:
121
+ run._settings = None
122
+ run.TENZIR_BINARY = None
123
+ run.TENZIR_NODE_BINARY = None
124
+ run._set_project_root(original_root)
97
125
 
98
126
  captured = capsys.readouterr()
99
127
  lines = [line for line in captured.out.splitlines() if line]
@@ -241,16 +269,29 @@ def test_print_detailed_summary_outputs_tree(capsys):
241
269
  )
242
270
  summary.runner_stats["tenzir"] = run.RunnerStats(total=2, failed=1, skipped=1)
243
271
 
244
- run._print_detailed_summary(summary)
272
+ original_color_mode = run.get_color_mode()
273
+ fail_color = ""
274
+ reset_color = ""
275
+ skip_symbol = run.SKIP
276
+ cross_symbol = run.CROSS
277
+ try:
278
+ run.set_color_mode(run.ColorMode.ALWAYS)
279
+ run._print_detailed_summary(summary)
280
+ output = capsys.readouterr().out.splitlines()
281
+ fail_color = run.FAIL_COLOR
282
+ reset_color = run.RESET_COLOR
283
+ skip_symbol = run.SKIP
284
+ cross_symbol = run.CROSS
285
+ finally:
286
+ run.set_color_mode(original_color_mode)
245
287
 
246
- output = capsys.readouterr().out.splitlines()
247
288
  assert output[0] == ""
248
- assert output[1] == f"{run.SKIP} Skipped tests:"
289
+ assert output[1] == f"{skip_symbol} Skipped tests:"
249
290
  assert output[2] == " └── pkg"
250
291
  assert output[3] == " └── tests"
251
292
  assert output[4] == " └── slow.tql"
252
293
  assert output[5] == ""
253
- assert output[6] == f"{run.CROSS} Failed tests:"
294
+ assert output[6] == f"{cross_symbol} Failed tests:"
254
295
  failed_lines = output[7:10]
255
296
  for line, suffix in zip(
256
297
  failed_lines,
@@ -259,8 +300,10 @@ def test_print_detailed_summary_outputs_tree(capsys):
259
300
  ):
260
301
  assert line.startswith(" ")
261
302
  assert line.endswith(suffix)
262
- assert line.count("\x1b[31m") >= 1
263
- assert f"\x1b[31m{suffix.strip()}" not in line
303
+ assert fail_color in line
304
+ if fail_color and reset_color:
305
+ assert f"{fail_color}{suffix.strip()}" not in line
306
+ assert line.endswith(f"{suffix}")
264
307
 
265
308
 
266
309
  def test_handle_skip_uses_skip_glyph(tmp_path, capsys):
@@ -860,42 +903,55 @@ def test_detailed_summary_order(capsys):
860
903
  def test_print_diff_default_layout(capsys):
861
904
  original_show_diff = run.should_show_diff_output()
862
905
  original_show_stat = run.should_show_diff_stat()
906
+ original_color_mode = run.get_color_mode()
863
907
  try:
908
+ run.set_color_mode(run.ColorMode.ALWAYS)
864
909
  run.set_show_diff_output(True)
865
910
  run.set_show_diff_stat(True)
866
911
  run.print_diff(b"line\nbeta\n", b"line\ngamma\n", Path("tests/example.txt"))
912
+ output = capsys.readouterr().out.splitlines()
913
+ expected_counter = run._format_diff_counter(1, 1)
914
+ expected_counts = (
915
+ f"{run.colorize('1(+)', run.DIFF_ADD_COLOR)}/{run.colorize('1(-)', run.FAIL_COLOR)}"
916
+ )
917
+ colored_minus = run.colorize("-beta", run.FAIL_COLOR)
918
+ colored_plus = run.colorize("+gamma", run.DIFF_ADD_COLOR)
867
919
  finally:
920
+ run.set_color_mode(original_color_mode)
868
921
  run.set_show_diff_output(original_show_diff)
869
922
  run.set_show_diff_stat(original_show_stat)
870
923
 
871
- output = capsys.readouterr().out.splitlines()
872
924
  assert output[0].startswith(f"{run._BLOCK_INDENT}┌ tests/example.txt")
873
- expected_counter = run._format_diff_counter(1, 1)
874
- assert "\033[32m1(+)\033[0m/\033[31m1(-)\033[0m" in output[0]
925
+ assert expected_counts in output[0]
875
926
  assert output[0].endswith(expected_counter)
876
927
  assert output[1] == f"{run._BLOCK_INDENT}│ @@ -1,2 +1,2 @@"
877
928
  assert output[2] == f"{run._BLOCK_INDENT}│ line"
878
- assert output[3].startswith(f"{run._BLOCK_INDENT}│ \033[31m-beta")
879
- assert output[4].startswith(f"{run._BLOCK_INDENT}│ \033[32m+gamma")
929
+ assert output[3].startswith(f"{run._BLOCK_INDENT}│ {colored_minus}")
930
+ assert output[4].startswith(f"{run._BLOCK_INDENT}│ {colored_plus}")
880
931
  assert output[5] == f"{run._BLOCK_INDENT}└ 2 lines changed"
881
932
 
882
933
 
883
934
  def test_print_diff_no_diff_outputs_stat_only(capsys):
884
935
  original_show_diff = run.should_show_diff_output()
885
936
  original_show_stat = run.should_show_diff_stat()
937
+ original_color_mode = run.get_color_mode()
886
938
  try:
939
+ run.set_color_mode(run.ColorMode.ALWAYS)
887
940
  run.set_show_diff_output(False)
888
941
  run.set_show_diff_stat(True)
889
942
  run.print_diff(b"line\nbeta\n", b"line\ngamma\n", Path("tests/example.txt"))
943
+ output = capsys.readouterr().out.splitlines()
944
+ expected_header = (
945
+ f"{run._BLOCK_INDENT}┌ tests/example.txt "
946
+ f"{run.colorize('1(+)', run.DIFF_ADD_COLOR)}"
947
+ f"/{run.colorize('1(-)', run.FAIL_COLOR)} "
948
+ f"{run._format_diff_counter(1, 1)}"
949
+ )
890
950
  finally:
951
+ run.set_color_mode(original_color_mode)
891
952
  run.set_show_diff_output(original_show_diff)
892
953
  run.set_show_diff_stat(original_show_stat)
893
954
 
894
- output = capsys.readouterr().out.splitlines()
895
- expected_header = (
896
- f"{run._BLOCK_INDENT}┌ tests/example.txt "
897
- f"\033[32m1(+)\033[0m/\033[31m1(-)\033[0m {run._format_diff_counter(1, 1)}"
898
- )
899
955
  assert output == [
900
956
  expected_header,
901
957
  f"{run._BLOCK_INDENT}└ 2 lines changed",
@@ -933,6 +989,33 @@ def test_print_diff_disabled_outputs_nothing(capsys):
933
989
  assert capsys.readouterr().out.strip() == ""
934
990
 
935
991
 
992
+ def test_no_color_env_disables_colors(monkeypatch, capsys):
993
+ original_show_diff = run.should_show_diff_output()
994
+ original_show_stat = run.should_show_diff_stat()
995
+ original_color_mode = run.get_color_mode()
996
+ capsys.readouterr()
997
+ try:
998
+ run.set_color_mode(run.ColorMode.ALWAYS)
999
+ monkeypatch.setenv("NO_COLOR", "1")
1000
+ run.refresh_color_palette()
1001
+ run.set_show_diff_output(True)
1002
+ run.set_show_diff_stat(True)
1003
+ run.print_diff(b"line\nbeta\n", b"line\ngamma\n", Path("tests/example.txt"))
1004
+ captured = capsys.readouterr().out
1005
+ palette_state = run.colors_enabled()
1006
+ checkmark_symbol = run.CHECKMARK
1007
+ finally:
1008
+ monkeypatch.delenv("NO_COLOR", raising=False)
1009
+ run.set_color_mode(original_color_mode)
1010
+ run.refresh_color_palette()
1011
+ run.set_show_diff_output(original_show_diff)
1012
+ run.set_show_diff_stat(original_show_stat)
1013
+
1014
+ assert "\033[" not in captured
1015
+ assert palette_state is False
1016
+ assert checkmark_symbol == "✔"
1017
+
1018
+
936
1019
  def test_describe_project_root_detects_standard_project(tmp_path: Path) -> None:
937
1020
  project_root = tmp_path / "proj"
938
1021
  tests_dir = project_root / "tests"
File without changes
File without changes
File without changes