shrinkray 25.12.27.0__tar.gz → 25.12.27.1__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 (66) hide show
  1. {shrinkray-25.12.27.0/src/shrinkray.egg-info → shrinkray-25.12.27.1}/PKG-INFO +3 -2
  2. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/pyproject.toml +5 -2
  3. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/__main__.py +8 -6
  4. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/state.py +48 -6
  5. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/subprocess/client.py +4 -3
  6. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/subprocess/worker.py +1 -1
  7. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/tui.py +5 -4
  8. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1/src/shrinkray.egg-info}/PKG-INFO +3 -2
  9. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray.egg-info/requires.txt +2 -1
  10. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_state.py +82 -0
  11. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_tui.py +1 -1
  12. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_tui_snapshots.py +71 -29
  13. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/LICENSE +0 -0
  14. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/README.md +0 -0
  15. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/setup.cfg +0 -0
  16. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/__init__.py +0 -0
  17. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/cli.py +0 -0
  18. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/display.py +0 -0
  19. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/formatting.py +0 -0
  20. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/__init__.py +0 -0
  21. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/bytes.py +0 -0
  22. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/clangdelta.py +0 -0
  23. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/definitions.py +0 -0
  24. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/genericlanguages.py +0 -0
  25. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/json.py +0 -0
  26. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/patching.py +0 -0
  27. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/python.py +0 -0
  28. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/sat.py +0 -0
  29. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/passes/sequences.py +0 -0
  30. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/problem.py +0 -0
  31. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/process.py +0 -0
  32. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/py.typed +0 -0
  33. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/reducer.py +0 -0
  34. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/subprocess/__init__.py +0 -0
  35. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/subprocess/protocol.py +0 -0
  36. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/ui.py +0 -0
  37. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray/work.py +0 -0
  38. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray.egg-info/SOURCES.txt +0 -0
  39. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray.egg-info/dependency_links.txt +0 -0
  40. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray.egg-info/entry_points.txt +0 -0
  41. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/src/shrinkray.egg-info/top_level.txt +0 -0
  42. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_byte_reduction_passes.py +0 -0
  43. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_clang_delta.py +0 -0
  44. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_cli.py +0 -0
  45. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_definitions.py +0 -0
  46. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_dimacs_cnf.py +0 -0
  47. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_display.py +0 -0
  48. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_formatting.py +0 -0
  49. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_generic_language.py +0 -0
  50. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_generic_shrinking_properties.py +0 -0
  51. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_json_passes.py +0 -0
  52. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_main.py +0 -0
  53. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_misc_reduction_performance.py +0 -0
  54. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_patching.py +0 -0
  55. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_problem.py +0 -0
  56. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_process.py +0 -0
  57. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_python_reducers.py +0 -0
  58. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_reducer.py +0 -0
  59. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_reduction_passes.py +0 -0
  60. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_sat.py +0 -0
  61. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_subprocess_client.py +0 -0
  62. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_subprocess_integration.py +0 -0
  63. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_subprocess_protocol.py +0 -0
  64. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_subprocess_worker.py +0 -0
  65. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_ui.py +0 -0
  66. {shrinkray-25.12.27.0 → shrinkray-25.12.27.1}/tests/test_work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 25.12.27.0
3
+ Version: 25.12.27.1
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -28,7 +28,8 @@ Requires-Dist: hypothesmith>=0.3.1; extra == "dev"
28
28
  Requires-Dist: pytest>=8.0.0; extra == "dev"
29
29
  Requires-Dist: pytest-trio>=0.8.0; extra == "dev"
30
30
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
31
- Requires-Dist: pytest-textual-snapshot>=1.0.0; extra == "dev"
31
+ Requires-Dist: syrupy>=5.0.0; extra == "dev"
32
+ Requires-Dist: jinja2>=3.0.0; extra == "dev"
32
33
  Requires-Dist: coverage[toml]>=7.4.0; extra == "dev"
33
34
  Requires-Dist: pygments>=2.17.0; extra == "dev"
34
35
  Requires-Dist: basedpyright>=1.1.0; extra == "dev"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shrinkray"
3
- version = "25.12.27.0"
3
+ version = "25.12.27.1"
4
4
  description = "Shrink Ray"
5
5
  authors = [
6
6
  {name = "David R. MacIver", email = "david@drmaciver.com"}
@@ -41,7 +41,10 @@ dev = [
41
41
  "pytest>=8.0.0",
42
42
  "pytest-trio>=0.8.0",
43
43
  "pytest-asyncio>=0.21.0",
44
- "pytest-textual-snapshot>=1.0.0",
44
+ # pytest-textual-snapshot is vendored in tests/pytest_textual_snapshot.py
45
+ # to fix syrupy 5.0 compatibility. These are its dependencies:
46
+ "syrupy>=5.0.0",
47
+ "jinja2>=3.0.0",
45
48
  "coverage[toml]>=7.4.0",
46
49
  "pygments>=2.17.0",
47
50
  "basedpyright>=1.1.0",
@@ -75,12 +75,14 @@ async def run_shrink_ray(
75
75
  )
76
76
  @click.option(
77
77
  "--timeout",
78
- default=1,
78
+ default=None,
79
79
  type=click.FLOAT,
80
80
  help=(
81
- "Time out subprocesses after this many seconds. If set to <= 0 then "
82
- "no timeout will be used. Any commands that time out will be treated "
83
- "as failing the test"
81
+ "Time out subprocesses after this many seconds. If not specified, "
82
+ "runs the interestingness test once and sets timeout to 10x the "
83
+ "measured time (capped at 5 minutes). If set to <= 0 then no timeout "
84
+ "will be used. Any commands that time out will be treated as failing "
85
+ "the test"
84
86
  ),
85
87
  )
86
88
  @click.option(
@@ -212,7 +214,7 @@ def main(
212
214
  backup: str,
213
215
  filename: str,
214
216
  test: list[str],
215
- timeout: float,
217
+ timeout: float | None,
216
218
  in_place: bool,
217
219
  parallelism: int,
218
220
  seed: int,
@@ -225,7 +227,7 @@ def main(
225
227
  ui_type: UIType,
226
228
  theme: str,
227
229
  ) -> None:
228
- if timeout <= 0:
230
+ if timeout is not None and timeout <= 0:
229
231
  timeout = float("inf")
230
232
 
231
233
  if not os.access(test[0], os.X_OK):
@@ -36,13 +36,32 @@ class TimeoutExceededOnInitial(InvalidInitialExample):
36
36
  )
37
37
 
38
38
 
39
+ # Constants for dynamic timeout
40
+ DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT = 300.0 # 5 minutes for first call
41
+ DYNAMIC_TIMEOUT_MULTIPLIER = 10
42
+ DYNAMIC_TIMEOUT_MAX = 300.0 # 5 minutes maximum
43
+ DYNAMIC_TIMEOUT_MIN = 1.0 # 1 second minimum to prevent edge cases
44
+
45
+
46
+ def compute_dynamic_timeout(runtime: float) -> float:
47
+ """Compute dynamic timeout based on measured runtime.
48
+
49
+ The timeout is set to 10x the measured runtime, clamped between
50
+ DYNAMIC_TIMEOUT_MIN and DYNAMIC_TIMEOUT_MAX.
51
+ """
52
+ return max(
53
+ DYNAMIC_TIMEOUT_MIN,
54
+ min(runtime * DYNAMIC_TIMEOUT_MULTIPLIER, DYNAMIC_TIMEOUT_MAX),
55
+ )
56
+
57
+
39
58
  @define(slots=False)
40
59
  class ShrinkRayState[TestCase](ABC):
41
60
  input_type: Any # InputType from __main__
42
61
  in_place: bool
43
62
  test: list[str]
44
63
  filename: str
45
- timeout: float
64
+ timeout: float | None
46
65
  base: str
47
66
  parallelism: int
48
67
  initial: TestCase
@@ -127,7 +146,8 @@ class ShrinkRayState[TestCase](ABC):
127
146
  completed = await trio.run_process(command, **kwargs)
128
147
  runtime = time.time() - start_time
129
148
 
130
- if runtime >= self.timeout and self.first_call:
149
+ # Check for timeout violation (only when timeout is explicitly set)
150
+ if self.timeout is not None and runtime >= self.timeout and self.first_call:
131
151
  self.initial_exit_code = completed.returncode
132
152
  self.first_call = False
133
153
  raise TimeoutExceededOnInitial(
@@ -137,6 +157,9 @@ class ShrinkRayState[TestCase](ABC):
137
157
 
138
158
  if self.first_call:
139
159
  self.initial_exit_code = completed.returncode
160
+ # Set dynamic timeout if not explicitly specified
161
+ if self.timeout is None:
162
+ self.timeout = compute_dynamic_timeout(runtime)
140
163
  self.first_call = False
141
164
 
142
165
  # Store captured output
@@ -168,9 +191,19 @@ class ShrinkRayState[TestCase](ABC):
168
191
  sp = await nursery.start(call_with_kwargs)
169
192
 
170
193
  try:
171
- with trio.move_on_after(
172
- self.timeout * 10 if self.first_call else self.timeout
173
- ):
194
+ # Determine effective timeout for this call
195
+ if self.first_call:
196
+ # For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
197
+ if self.timeout is None:
198
+ effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
199
+ else:
200
+ effective_timeout = self.timeout * 10
201
+ else:
202
+ # For subsequent calls, timeout must be set (either explicit or computed)
203
+ assert self.timeout is not None
204
+ effective_timeout = self.timeout
205
+
206
+ with trio.move_on_after(effective_timeout):
174
207
  await sp.wait()
175
208
 
176
209
  runtime = time.time() - start_time
@@ -179,7 +212,12 @@ class ShrinkRayState[TestCase](ABC):
179
212
  # Process didn't terminate before timeout - kill it
180
213
  await self._interrupt_wait_and_kill(sp)
181
214
 
182
- if runtime >= self.timeout and self.first_call:
215
+ # Check for timeout violation (only when timeout is explicitly set)
216
+ if (
217
+ self.timeout is not None
218
+ and runtime >= self.timeout
219
+ and self.first_call
220
+ ):
183
221
  raise TimeoutExceededOnInitial(
184
222
  timeout=self.timeout,
185
223
  runtime=runtime,
@@ -187,6 +225,10 @@ class ShrinkRayState[TestCase](ABC):
187
225
  finally:
188
226
  if self.first_call:
189
227
  self.initial_exit_code = sp.returncode
228
+ # Set dynamic timeout if not explicitly specified
229
+ if self.timeout is None:
230
+ runtime = time.time() - start_time
231
+ self.timeout = compute_dynamic_timeout(runtime)
190
232
  self.first_call = False
191
233
 
192
234
  result: int | None = sp.returncode
@@ -127,7 +127,7 @@ class SubprocessClient:
127
127
  file_path: str,
128
128
  test: list[str],
129
129
  parallelism: int | None = None,
130
- timeout: float = 1.0,
130
+ timeout: float | None = None,
131
131
  seed: int = 0,
132
132
  input_type: str = "all",
133
133
  in_place: bool = False,
@@ -138,10 +138,9 @@ class SubprocessClient:
138
138
  trivial_is_error: bool = True,
139
139
  ) -> Response:
140
140
  """Start the reduction process."""
141
- params = {
141
+ params: dict[str, Any] = {
142
142
  "file_path": file_path,
143
143
  "test": test,
144
- "timeout": timeout,
145
144
  "seed": seed,
146
145
  "input_type": input_type,
147
146
  "in_place": in_place,
@@ -153,6 +152,8 @@ class SubprocessClient:
153
152
  }
154
153
  if parallelism is not None:
155
154
  params["parallelism"] = parallelism
155
+ if timeout is not None:
156
+ params["timeout"] = timeout
156
157
  return await self.send_command("start", params)
157
158
 
158
159
  async def get_status(self) -> Response:
@@ -169,7 +169,7 @@ class ReducerWorker:
169
169
  filename = params["file_path"]
170
170
  test = params["test"]
171
171
  parallelism = params.get("parallelism", os.cpu_count() or 1)
172
- timeout = params.get("timeout", 1.0)
172
+ timeout = params.get("timeout") # None means dynamic timeout
173
173
  seed = params.get("seed", 0)
174
174
  input_type = InputType[params.get("input_type", "all")]
175
175
  in_place = params.get("in_place", False)
@@ -98,7 +98,7 @@ class ReductionClientProtocol(Protocol):
98
98
  file_path: str,
99
99
  test: list[str],
100
100
  parallelism: int | None = None,
101
- timeout: float = 1.0,
101
+ timeout: float | None = None,
102
102
  seed: int = 0,
103
103
  input_type: str = "all",
104
104
  in_place: bool = False,
@@ -616,7 +616,7 @@ class ShrinkRayApp(App[None]):
616
616
  file_path: str,
617
617
  test: list[str],
618
618
  parallelism: int | None = None,
619
- timeout: float = 1.0,
619
+ timeout: float | None = None,
620
620
  seed: int = 0,
621
621
  input_type: str = "all",
622
622
  in_place: bool = False,
@@ -657,6 +657,7 @@ class ShrinkRayApp(App[None]):
657
657
  yield Label(
658
658
  "Shrink Ray - [h] help, [p] passes, [c] skip pass, [q] quit",
659
659
  id="status-label",
660
+ markup=False,
660
661
  )
661
662
  with Vertical(id="stats-container"):
662
663
  yield StatsDisplay(id="stats-display")
@@ -807,7 +808,7 @@ async def _validate_initial_example(
807
808
  file_path: str,
808
809
  test: list[str],
809
810
  parallelism: int | None,
810
- timeout: float,
811
+ timeout: float | None,
811
812
  seed: int,
812
813
  input_type: str,
813
814
  in_place: bool,
@@ -855,7 +856,7 @@ def run_textual_ui(
855
856
  file_path: str,
856
857
  test: list[str],
857
858
  parallelism: int | None = None,
858
- timeout: float = 1.0,
859
+ timeout: float | None = None,
859
860
  seed: int = 0,
860
861
  input_type: str = "all",
861
862
  in_place: bool = False,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 25.12.27.0
3
+ Version: 25.12.27.1
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -28,7 +28,8 @@ Requires-Dist: hypothesmith>=0.3.1; extra == "dev"
28
28
  Requires-Dist: pytest>=8.0.0; extra == "dev"
29
29
  Requires-Dist: pytest-trio>=0.8.0; extra == "dev"
30
30
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
31
- Requires-Dist: pytest-textual-snapshot>=1.0.0; extra == "dev"
31
+ Requires-Dist: syrupy>=5.0.0; extra == "dev"
32
+ Requires-Dist: jinja2>=3.0.0; extra == "dev"
32
33
  Requires-Dist: coverage[toml]>=7.4.0; extra == "dev"
33
34
  Requires-Dist: pygments>=2.17.0; extra == "dev"
34
35
  Requires-Dist: basedpyright>=1.1.0; extra == "dev"
@@ -15,7 +15,8 @@ hypothesmith>=0.3.1
15
15
  pytest>=8.0.0
16
16
  pytest-trio>=0.8.0
17
17
  pytest-asyncio>=0.21.0
18
- pytest-textual-snapshot>=1.0.0
18
+ syrupy>=5.0.0
19
+ jinja2>=3.0.0
19
20
  coverage[toml]>=7.4.0
20
21
  pygments>=2.17.0
21
22
  basedpyright>=1.1.0
@@ -9,6 +9,7 @@ import trio
9
9
  from shrinkray.cli import InputType
10
10
  from shrinkray.problem import InvalidInitialExample
11
11
  from shrinkray.state import (
12
+ DYNAMIC_TIMEOUT_MIN,
12
13
  ShrinkRayDirectoryState,
13
14
  ShrinkRayStateSingleFile,
14
15
  TimeoutExceededOnInitial,
@@ -1640,6 +1641,87 @@ async def test_run_for_exit_code_debug_mode_timeout_on_first_call(tmp_path):
1640
1641
  assert state.first_call is False
1641
1642
 
1642
1643
 
1644
+ async def test_run_for_exit_code_debug_mode_dynamic_timeout(tmp_path):
1645
+ """Test dynamic timeout computation in debug mode on first call.
1646
+
1647
+ Exercises the dynamic timeout computation path in debug mode.
1648
+ """
1649
+ script = tmp_path / "test.sh"
1650
+ script.write_text("#!/bin/bash\nexit 0")
1651
+ script.chmod(0o755)
1652
+
1653
+ target = tmp_path / "test.txt"
1654
+ target.write_text("hello")
1655
+
1656
+ state = ShrinkRayStateSingleFile(
1657
+ input_type=InputType.arg,
1658
+ in_place=False,
1659
+ test=[str(script)],
1660
+ filename=str(target),
1661
+ timeout=None, # Dynamic timeout
1662
+ base="test.txt",
1663
+ parallelism=1,
1664
+ initial=b"hello",
1665
+ formatter="none",
1666
+ trivial_is_error=True,
1667
+ seed=0,
1668
+ volume=Volume.quiet,
1669
+ clang_delta_executable=None,
1670
+ )
1671
+
1672
+ # First call in debug mode with None timeout should compute dynamic timeout
1673
+ assert state.timeout is None
1674
+ exit_code = await state.run_for_exit_code(b"hello", debug=True)
1675
+ assert exit_code == 0
1676
+ # After first call, timeout should be computed
1677
+ assert state.timeout is not None
1678
+ assert state.timeout > 0
1679
+ # first_call should be False after this
1680
+ assert state.first_call is False
1681
+
1682
+
1683
+ async def test_run_for_exit_code_dynamic_timeout_non_debug(tmp_path):
1684
+ """Test dynamic timeout computation in non-debug mode on first call.
1685
+
1686
+ Exercises the dynamic timeout computation path in non-debug mode,
1687
+ which uses run_script_on_file instead of the debug path.
1688
+ """
1689
+ script = tmp_path / "test.sh"
1690
+ script.write_text("#!/bin/bash\nexit 0")
1691
+ script.chmod(0o755)
1692
+
1693
+ target = tmp_path / "test.txt"
1694
+ target.write_text("hello")
1695
+
1696
+ state = ShrinkRayStateSingleFile(
1697
+ input_type=InputType.arg,
1698
+ in_place=False,
1699
+ test=[str(script)],
1700
+ filename=str(target),
1701
+ timeout=None, # Dynamic timeout
1702
+ base="test.txt",
1703
+ parallelism=1,
1704
+ initial=b"hello",
1705
+ formatter="none",
1706
+ trivial_is_error=True,
1707
+ seed=0,
1708
+ volume=Volume.quiet,
1709
+ clang_delta_executable=None,
1710
+ )
1711
+
1712
+ # First call in non-debug mode with None timeout should compute dynamic timeout
1713
+ assert state.timeout is None
1714
+ exit_code = await state.run_for_exit_code(b"hello", debug=False)
1715
+ assert exit_code == 0
1716
+ # After first call, timeout should be computed
1717
+ assert state.timeout is not None
1718
+ assert state.timeout > 0
1719
+ # Verify minimum timeout is respected
1720
+ assert state.timeout >= DYNAMIC_TIMEOUT_MIN
1721
+ # first_call should be False after this
1722
+ assert state.first_call is False
1723
+
1724
+
1643
1725
  async def test_run_for_exit_code_debug_mode_captures_stdout(tmp_path):
1644
1726
  """Test that debug mode captures stdout output.
1645
1727
 
@@ -55,7 +55,7 @@ class FakeReductionClient:
55
55
  file_path: str,
56
56
  test: list[str],
57
57
  parallelism: int | None = None,
58
- timeout: float = 1.0,
58
+ timeout: float | None = None,
59
59
  seed: int = 0,
60
60
  input_type: str = "all",
61
61
  in_place: bool = False,
@@ -1,9 +1,33 @@
1
1
  """Snapshot tests for the textual TUI."""
2
2
 
3
+ import asyncio
4
+ from pathlib import Path
5
+
3
6
  import pytest
4
7
 
5
- from shrinkray.subprocess.protocol import PassStatsData, ProgressUpdate
6
- from shrinkray.tui import ShrinkRayApp
8
+ from shrinkray.subprocess.protocol import PassStatsData, ProgressUpdate, Response
9
+ from shrinkray.tui import ShrinkRayApp, StatsDisplay
10
+
11
+
12
+ # Directory where snapshots are stored
13
+ SNAPSHOTS_DIR = Path(__file__).parent / "__snapshots__" / "test_tui_snapshots"
14
+
15
+
16
+ def pytest_sessionfinish(session, exitstatus):
17
+ """Verify all snapshots contain expected content after tests complete."""
18
+ if not SNAPSHOTS_DIR.exists():
19
+ return
20
+
21
+ # Using vendored pytest_textual_snapshot with syrupy 5.0 compatibility
22
+ for snapshot_file in SNAPSHOTS_DIR.glob("*.svg"):
23
+ content = snapshot_file.read_text()
24
+ # Each snapshot should contain "Reducer" which appears in StatsDisplay
25
+ # Note: SVG uses &#160; for non-breaking spaces, so we just check for "Reducer"
26
+ if "Reducer" not in content:
27
+ raise AssertionError(
28
+ f"Snapshot {snapshot_file.name} does not contain expected text 'Reducer'. "
29
+ "The snapshot may have been captured before the app fully rendered."
30
+ )
7
31
 
8
32
 
9
33
  class FakeReductionClientForSnapshots:
@@ -12,7 +36,8 @@ class FakeReductionClientForSnapshots:
12
36
  def __init__(self, updates: list[ProgressUpdate]):
13
37
  self._updates = updates
14
38
  self._update_index = 0
15
- self._completed = False
39
+ self._cancelled = False
40
+ self._updates_consumed = False
16
41
 
17
42
  async def start(self) -> None:
18
43
  pass
@@ -22,7 +47,7 @@ class FakeReductionClientForSnapshots:
22
47
  file_path: str,
23
48
  test: list[str],
24
49
  parallelism: int | None = None,
25
- timeout: float = 1.0,
50
+ timeout: float | None = None,
26
51
  seed: int = 0,
27
52
  input_type: str = "all",
28
53
  in_place: bool = False,
@@ -31,34 +56,24 @@ class FakeReductionClientForSnapshots:
31
56
  no_clang_delta: bool = False,
32
57
  clang_delta: str = "",
33
58
  trivial_is_error: bool = True,
34
- ):
35
- from shrinkray.subprocess.protocol import Response
36
-
59
+ ) -> Response:
37
60
  return Response(id="start", result={"status": "started"})
38
61
 
39
- async def cancel(self):
40
- from shrinkray.subprocess.protocol import Response
41
-
42
- self._completed = True
62
+ async def cancel(self) -> Response:
63
+ self._cancelled = True
43
64
  return Response(id="cancel", result={"status": "cancelled"})
44
65
 
45
- async def disable_pass(self, pass_name: str):
46
- from shrinkray.subprocess.protocol import Response
47
-
66
+ async def disable_pass(self, pass_name: str) -> Response:
48
67
  return Response(
49
68
  id="disable", result={"status": "disabled", "pass_name": pass_name}
50
69
  )
51
70
 
52
- async def enable_pass(self, pass_name: str):
53
- from shrinkray.subprocess.protocol import Response
54
-
71
+ async def enable_pass(self, pass_name: str) -> Response:
55
72
  return Response(
56
73
  id="enable", result={"status": "enabled", "pass_name": pass_name}
57
74
  )
58
75
 
59
- async def skip_current_pass(self):
60
- from shrinkray.subprocess.protocol import Response
61
-
76
+ async def skip_current_pass(self) -> Response:
62
77
  return Response(id="skip", result={"status": "skipped"})
63
78
 
64
79
  async def close(self) -> None:
@@ -66,14 +81,18 @@ class FakeReductionClientForSnapshots:
66
81
 
67
82
  async def get_progress_updates(self):
68
83
  for update in self._updates:
69
- if self._completed:
84
+ if self._cancelled:
70
85
  break
71
86
  yield update
72
- self._completed = True
87
+ self._updates_consumed = True
88
+ # Keep the app running by waiting indefinitely (until cancelled)
89
+ while not self._cancelled:
90
+ await asyncio.sleep(1)
73
91
 
74
92
  @property
75
93
  def is_completed(self) -> bool:
76
- return self._completed
94
+ # Only complete when cancelled, not when updates are consumed
95
+ return self._cancelled
77
96
 
78
97
  @property
79
98
  def error_message(self) -> str | None:
@@ -180,40 +199,63 @@ def large_file_update() -> ProgressUpdate:
180
199
  # === TUI snapshot tests ===
181
200
 
182
201
 
202
+ async def wait_for_render(pilot):
203
+ """Wait for the app to fully render with actual content.
204
+
205
+ Checks that the StatsDisplay has received and rendered the update
206
+ by verifying original_size is non-zero (all test fixtures have non-zero size).
207
+ """
208
+ max_attempts = 50 # Generous limit to avoid flakiness
209
+ for _ in range(max_attempts):
210
+ await pilot.pause()
211
+ stats = pilot.app.query_one("#stats-display", StatsDisplay)
212
+ if stats.original_size > 0:
213
+ # Content has been rendered, give one more pause for layout
214
+ await pilot.pause()
215
+ return
216
+
217
+ # Get final state for error message
218
+ final_stats = pilot.app.query_one("#stats-display", StatsDisplay)
219
+ raise AssertionError(
220
+ "App did not render within expected time. "
221
+ f"StatsDisplay.original_size is still {final_stats.original_size}"
222
+ )
223
+
224
+
183
225
  def test_initial_state(snap_compare, initial_state_update):
184
226
  """Snapshot test for initial app state."""
185
227
  app = make_app_with_updates([initial_state_update])
186
- assert snap_compare(app, terminal_size=(120, 40))
228
+ assert snap_compare(app, terminal_size=(120, 40), run_before=wait_for_render)
187
229
 
188
230
 
189
231
  def test_mid_reduction(snap_compare, mid_reduction_update):
190
232
  """Snapshot test for mid-reduction state."""
191
233
  app = make_app_with_updates([mid_reduction_update])
192
- assert snap_compare(app, terminal_size=(120, 40))
234
+ assert snap_compare(app, terminal_size=(120, 40), run_before=wait_for_render)
193
235
 
194
236
 
195
237
  def test_hex_mode(snap_compare, hex_mode_update):
196
238
  """Snapshot test for hex mode display."""
197
239
  app = make_app_with_updates([hex_mode_update])
198
- assert snap_compare(app, terminal_size=(120, 40))
240
+ assert snap_compare(app, terminal_size=(120, 40), run_before=wait_for_render)
199
241
 
200
242
 
201
243
  def test_large_file(snap_compare, large_file_update):
202
244
  """Snapshot test for large file with potential diff."""
203
245
  app = make_app_with_updates([large_file_update])
204
- assert snap_compare(app, terminal_size=(120, 40))
246
+ assert snap_compare(app, terminal_size=(120, 40), run_before=wait_for_render)
205
247
 
206
248
 
207
249
  def test_small_terminal(snap_compare, mid_reduction_update):
208
250
  """Snapshot test with smaller terminal size."""
209
251
  app = make_app_with_updates([mid_reduction_update])
210
- assert snap_compare(app, terminal_size=(80, 24))
252
+ assert snap_compare(app, terminal_size=(80, 24), run_before=wait_for_render)
211
253
 
212
254
 
213
255
  def test_wide_terminal(snap_compare, mid_reduction_update):
214
256
  """Snapshot test with wider terminal."""
215
257
  app = make_app_with_updates([mid_reduction_update])
216
- assert snap_compare(app, terminal_size=(160, 50))
258
+ assert snap_compare(app, terminal_size=(160, 50), run_before=wait_for_render)
217
259
 
218
260
 
219
261
  @pytest.fixture
File without changes
File without changes
File without changes