vibefpmgui.window.testing

Test and Run: listing the tests or programs, running them, in parallel too.

Each one runs through the marker wrapper (vibefpmgui.test_runner), so the Tests and Run panels know where each one starts and ends.

  1"""Test and Run: listing the tests or programs, running them, in parallel too.
  2
  3Each one runs through the marker wrapper (`vibefpmgui.test_runner`), so the
  4Tests and Run panels know where each one starts and ends.
  5"""
  6
  7from __future__ import annotations
  8
  9import fnmatch
 10import json
 11import os
 12import time
 13from pathlib import Path
 14
 15from qtpy.QtCore import QProcessEnvironment
 16from qtpy.QtWidgets import QMessageBox
 17
 18from .. import ansi
 19from .. import coverage as cov
 20from ..build_monitor import BuildMonitor
 21from ..compat import join_args, split_args
 22from ..coverage_panel import CoveragePanel
 23from ..fpm_args import runner_command, split_runner
 24from ..parallel_tests import ParallelTests
 25from ..processes import kill_process_tree
 26from ..run_monitor import RunMonitor
 27from ..test_monitor import EXE_RE, MARKER_RE, TestMonitor, parse_test_list
 28from ..test_runner import split_runner as split_inner_runner
 29
 30
 31class TestingMixin:
 32    """Test and Run (a part of `vibefpmgui.main_window.MainWindow`)."""
 33
 34    def _build_run_panels(self):
 35        """The Build, Tests, Run and Coverage panels."""
 36        self.build_monitor = BuildMonitor()
 37        self.test_monitor = TestMonitor()
 38        self.test_monitor.test_selected.connect(self.show_test_output)
 39        self.test_monitor.debug_requested.connect(lambda name: self.debug_named("test", name))
 40        self.test_monitor.build_percent = lambda: self.build_monitor.percent
 41        self.build_monitor.details_requested.connect(self.show_build_details)
 42        self.build_monitor.open_file_requested.connect(self.open_file)
 43        self.build_monitor.compile_command_requested.connect(self.show_compile_command)
 44        self.run_monitor = RunMonitor()
 45        self.run_monitor.debug_requested.connect(lambda name: self.debug_named("program", name))
 46        self.run_monitor.test_selected.connect(
 47            lambda name: self._show_marked_output(self.run_monitor, name))
 48        self.run_monitor.build_percent = lambda: self.build_monitor.percent
 49        self.coverage_panel = CoveragePanel()
 50        self.coverage_panel.open_requested.connect(
 51            lambda path, line: self.navigate_to(path, line, 0, preview=True))
 52        self.coverage_panel.tests_check.setChecked(self.settings.value("coverage_tests", False, type=bool))
 53        self.coverage_panel.tests_check.toggled.connect(lambda on: self.settings.setValue("coverage_tests", on))
 54        self.coverage: dict[Path, cov.FileCoverage] = {}  # the last test run's (see _collect_coverage)
 55        self._coverage_started = 0.0  # when that run started
 56        self._coverage_collector: cov.Collector | None = None
 57
 58    def _marker_monitor(self):
 59        return self.run_monitor if getattr(self, "_marker_kind", "test") == "run" else self.test_monitor
 60
 61    def run_tests(self):
 62        self._start_marked("test")
 63
 64    def run_programs(self):
 65        self._start_marked("run")
 66
 67    def _start_marked(self, kind: str):
 68        """Test or Run in two steps: "fpm <kind> --list" (the names), then
 69        "fpm <kind> --runner <our wrapper>", which builds first (shown in the
 70        Build tab) and then runs each one; the wrapper marks where each starts
 71        and ends, so the Tests / Run tab can follow them."""
 72        if not self.manifest_path or self.busy():
 73            return
 74        try:
 75            list_args = self.build_arguments([kind, "--list"], True, program_args=False)
 76            run_args = self.build_arguments([kind], True)
 77            _, inner_runner = split_runner(split_args(self.extra_edit.text()))
 78        except ValueError as exc:
 79            QMessageBox.warning(self, "Invalid arguments", str(exc))
 80            return
 81        runner = runner_command()
 82        # Our runner goes before the target names and any "-- program args".
 83        cut = run_args.index("--") if "--" in run_args else len(run_args)
 84        collect_args = run_args[:cut] + ["--runner", runner_command(collect=True)] + run_args[cut:]
 85        run_args = run_args[:cut] + ["--runner", runner] + run_args[cut:]
 86        patterns = split_args(self.target_edit.text())
 87        self._marker_kind = kind
 88        self._test_args = (run_args, inner_runner, patterns, collect_args)
 89        description = self._build_description()
 90        if inner_runner:
 91            description += f" · runner: {inner_runner}"
 92        self.build_monitor.reset()  # the build progress shown next is this run's
 93        if kind == "test" and self.coverage_check.isChecked():
 94            cov.clear_counts(self.manifest_path.parent / "build")  # (this run's counts only)
 95            self._coverage_started = time.time()  # (files changed after this aren't what was compiled)
 96        self._marker_monitor().begin_listing(description)
 97        self.show_all_output()
 98        self.run_fpm([kind], True, False, phase="list", args=list_args)
 99
100    def _after_listing(self, code: int, stopped: bool):
101        monitor = self._marker_monitor()
102        output = "".join(self._captured)
103        names = parse_test_list(output)
104        if stopped or code != 0:
105            monitor.finish(code, stopped=stopped)
106            self.show_panel(self.build_monitor)
107            return
108        if names == []:
109            monitor.finish(0, no_tests=True)
110            self.show_panel(monitor)
111            self.statusBar().showMessage(monitor.word("none"))
112            return
113        run_args, inner_runner, patterns, collect_args = self._test_args
114        if names and patterns:
115            # "--list" lists every name regardless of the names given, so apply
116            # the Target names/globs ourselves, as fpm will.
117            names = [n for n in names if any(fnmatch.fnmatchcase(n, p) for p in patterns)]
118        # fpm builds first (the Build tab shows it), then runs them.
119        monitor.start(names or [], self._load_durations(self._marker_kind), building=True)
120        jobs = self.test_jobs_spin.value() if self._marker_kind == "test" else 1
121        monitor.jobs = jobs
122        if jobs > 1:
123            monitor.description += f" · {jobs} at a time"
124            monitor.update_header()  # fpm only builds them and says how to run each; we run them (see _after_collect)
125            self._collected: list[tuple[str, list[str]]] = []
126            self.run_fpm(["test"], True, False, phase="collect", args=collect_args)
127            return
128        env = {"VIBEFPM_INNER_RUNNER": inner_runner} if inner_runner else {}
129        self.run_fpm([self._marker_kind], True, True, phase="marked", args=run_args, env_extra=env)
130
131    def _after_collect(self, code: int, stopped: bool):
132        """fpm has built the tests and said how to run each: run them, a few
133        at a time (Build options → Parallel tests)."""
134        if stopped or code != 0 or not self._collected:
135            self.reload()  # (first, as after "fpm test": then the message is the tests')
136            self._after_marked(code, stopped)  # (stopped, the build failed, or no tests)
137            return
138        _run_args, inner_runner, _patterns, _collect = self._test_args
139        try:
140            inner = split_inner_runner(inner_runner)
141        except ValueError:
142            inner = []
143        expected = self._load_durations("test")
144        # The longest first (the ones never timed first of all): the run ends sooner.
145        commands = sorted(self._collected, key=lambda c: -expected.get(c[0], float("inf")))
146        env = QProcessEnvironment.systemEnvironment()
147        env.insert("NO_COLOR", "1")
148        workers = min(self.test_jobs_spin.value(), len(commands))
149        tests = ParallelTests(commands, workers, str(self.manifest_path.parent), env, inner, kill_process_tree, self)
150        tests.started.connect(self._parallel_started)
151        tests.output.connect(self._parallel_output)
152        tests.finished.connect(self._parallel_finished)
153        tests.done.connect(self._parallel_done)
154        self.parallel_tests = tests
155        self._parallel_raw: dict[str, list[str]] = {}
156        self._parallel_failed = False
157        self._append(f"\n[Running {len(commands)} tests, {workers} at a time. Each test's output is shown "
158                     "here when it finishes; click a test in the Tests panel to watch it as it runs.]\n", bold=True)
159        self.stop_action.setEnabled(True)
160        self._set_project_enabled(True)
161        self.statusBar().showMessage(f"Running tests, {workers} at a time…")
162        tests.start()
163
164    def _parallel_started(self, name: str):
165        self._parallel_raw[name] = []
166        self.test_monitor.test_started(name)
167
168    def _parallel_output(self, name: str, text: str):
169        self._parallel_raw.setdefault(name, []).append(text)
170        plain = ansi.strip(text)
171        self.test_monitor.test_output(name, plain)
172        if self.test_monitor.selected == name:  # shown on its own: follow it
173            self.single_writer.write(plain)
174
175    def _parallel_finished(self, name: str, code: int, seconds: float, stopped: bool):
176        self.test_monitor.test_finished(name, code, seconds, stopped)
177        self._parallel_failed |= code != 0 and not stopped
178        # Its output, all in one piece: the tests' outputs never mix here.
179        status = "stopped" if stopped else "passed" if code == 0 else f"failed (exit code {code})"
180        command = self.parallel_tests.command(dict(self._collected).get(name, [name])) \
181            if self.parallel_tests is not None else [name]
182        rel = [os.path.relpath(a, self.manifest_path.parent) if os.path.isabs(a) else a for a in command]
183        self._append(f"\n── {name} · {status} · {seconds:.2f} s ── $ {join_args(rel)}\n", bold=True)
184        text = "".join(self._parallel_raw.pop(name, []))
185        if text:
186            self._append(text if text.endswith("\n") else text + "\n", colored=True)
187
188    def _parallel_done(self, stopped: bool):
189        if self.parallel_tests is None:
190            return
191        self.parallel_tests.deleteLater()
192        self.parallel_tests = None
193        self.stop_action.setEnabled(False)
194        self._set_project_enabled(self.graph is not None)
195        code = 1 if self._parallel_failed else 0
196        self._append("\n[tests stopped]\n" if stopped else f"\n[tests finished: exit code {code}]\n", bold=True)
197        self.reload()  # (first, as after "fpm test": then the message is the tests')
198        self._after_marked(code, stopped)
199
200    def _after_marked(self, code: int, stopped: bool):
201        monitor = self._marker_monitor()
202        monitor.finish(code, stopped=stopped)
203        self._save_durations(self._marker_kind, monitor.durations())
204        if self._marker_kind == "test" and self.coverage_check.isChecked() and not stopped \
205                and not monitor.build_failed:
206            self._collect_coverage()
207        counts = monitor.counts()
208        total = len(monitor.tests)
209        title = monitor.word("title")
210        if stopped:
211            message = f"{title}: stopped"
212        elif monitor.build_failed:
213            message = f"{title}: the build failed (see the Build tab)"
214        elif counts["Failed"]:
215            failed = [t for t in monitor.tests if t.status == "Failed"]
216            if self._marker_kind == "run" and len(failed) == 1 and failed[0].exit_code is not None:
217                message = f"{title}: {failed[0].name} failed (exit code {failed[0].exit_code})"
218            else:
219                message = f"{title}: {counts['Failed']} of {total} failed"
220        elif counts["Not run"]:
221            message = f"{title}: {counts['Passed']} {monitor.word('passed')}, {counts['Not run']} not run"
222        elif total == 1:
223            message = f"{title}: {monitor.tests[0].name} {monitor.word('passed')}"
224        else:
225            message = f"{title}: all {total} {monitor.word('passed')}"
226        self.statusBar().showMessage(message)
227
228    def _durations_key(self, kind: str = "test") -> str:
229        return self._project_key(f"{kind}_durations")
230
231    def _load_durations(self, kind: str = "test") -> dict[str, float]:
232        try:
233            data = json.loads(self.settings.value(self._durations_key(kind), "{}"))
234            return {str(k): float(v) for k, v in data.items()}
235        except (TypeError, ValueError, AttributeError):
236            return {}
237
238    def _save_durations(self, kind: str, durations: dict[str, float]):
239        if durations and self.manifest_path:
240            merged = {**self._load_durations(kind), **durations}
241            self.settings.setValue(self._durations_key(kind), json.dumps(merged))
242
243    def _test_line(self, raw: str) -> str:
244        """Pass a line of test output through the test monitor; returns the
245        text to show (with its color codes). The runner prints a blank line
246        before each marker, which is dropped along with the marker."""
247        line = ansi.strip(raw)
248        if self._test_building:
249            if MARKER_RE.search(line):
250                self._build_part_done()  # the first test is starting
251            else:
252                self.build_monitor.feed_line(line)  # fpm is still compiling
253        text = ""
254        if self._held_blank:
255            self._held_blank = False
256            if not MARKER_RE.search(line):  # the blank was real output
257                self._marker_monitor().feed_line("")
258                text = "\n"
259        if line == "":
260            self._held_blank = True
261            return text
262        shown = self._marker_monitor().feed_line(line)
263        if shown == line:
264            shown = raw
265        return text + (shown + "\n" if shown is not None else "")
266
267    def _collect_line(self, raw: str) -> str:
268        """A line of "fpm test --runner '… --collect'": the build's output,
269        or a test's command line (taken out, kept for _after_collect)."""
270        line = ansi.strip(raw)
271        match = EXE_RE.search(line)
272        if match is None:
273            if self._test_building:
274                self.build_monitor.feed_line(line)
275            return raw + "\n"
276        if self._test_building:
277            self._build_part_done()
278        try:
279            argv = json.loads(match.group(2))
280        except ValueError:
281            return raw + "\n"
282        self._collected.append((match.group(1), [str(a) for a in argv]))
283        return ""
class TestingMixin:
 32class TestingMixin:
 33    """Test and Run (a part of `vibefpmgui.main_window.MainWindow`)."""
 34
 35    def _build_run_panels(self):
 36        """The Build, Tests, Run and Coverage panels."""
 37        self.build_monitor = BuildMonitor()
 38        self.test_monitor = TestMonitor()
 39        self.test_monitor.test_selected.connect(self.show_test_output)
 40        self.test_monitor.debug_requested.connect(lambda name: self.debug_named("test", name))
 41        self.test_monitor.build_percent = lambda: self.build_monitor.percent
 42        self.build_monitor.details_requested.connect(self.show_build_details)
 43        self.build_monitor.open_file_requested.connect(self.open_file)
 44        self.build_monitor.compile_command_requested.connect(self.show_compile_command)
 45        self.run_monitor = RunMonitor()
 46        self.run_monitor.debug_requested.connect(lambda name: self.debug_named("program", name))
 47        self.run_monitor.test_selected.connect(
 48            lambda name: self._show_marked_output(self.run_monitor, name))
 49        self.run_monitor.build_percent = lambda: self.build_monitor.percent
 50        self.coverage_panel = CoveragePanel()
 51        self.coverage_panel.open_requested.connect(
 52            lambda path, line: self.navigate_to(path, line, 0, preview=True))
 53        self.coverage_panel.tests_check.setChecked(self.settings.value("coverage_tests", False, type=bool))
 54        self.coverage_panel.tests_check.toggled.connect(lambda on: self.settings.setValue("coverage_tests", on))
 55        self.coverage: dict[Path, cov.FileCoverage] = {}  # the last test run's (see _collect_coverage)
 56        self._coverage_started = 0.0  # when that run started
 57        self._coverage_collector: cov.Collector | None = None
 58
 59    def _marker_monitor(self):
 60        return self.run_monitor if getattr(self, "_marker_kind", "test") == "run" else self.test_monitor
 61
 62    def run_tests(self):
 63        self._start_marked("test")
 64
 65    def run_programs(self):
 66        self._start_marked("run")
 67
 68    def _start_marked(self, kind: str):
 69        """Test or Run in two steps: "fpm <kind> --list" (the names), then
 70        "fpm <kind> --runner <our wrapper>", which builds first (shown in the
 71        Build tab) and then runs each one; the wrapper marks where each starts
 72        and ends, so the Tests / Run tab can follow them."""
 73        if not self.manifest_path or self.busy():
 74            return
 75        try:
 76            list_args = self.build_arguments([kind, "--list"], True, program_args=False)
 77            run_args = self.build_arguments([kind], True)
 78            _, inner_runner = split_runner(split_args(self.extra_edit.text()))
 79        except ValueError as exc:
 80            QMessageBox.warning(self, "Invalid arguments", str(exc))
 81            return
 82        runner = runner_command()
 83        # Our runner goes before the target names and any "-- program args".
 84        cut = run_args.index("--") if "--" in run_args else len(run_args)
 85        collect_args = run_args[:cut] + ["--runner", runner_command(collect=True)] + run_args[cut:]
 86        run_args = run_args[:cut] + ["--runner", runner] + run_args[cut:]
 87        patterns = split_args(self.target_edit.text())
 88        self._marker_kind = kind
 89        self._test_args = (run_args, inner_runner, patterns, collect_args)
 90        description = self._build_description()
 91        if inner_runner:
 92            description += f" · runner: {inner_runner}"
 93        self.build_monitor.reset()  # the build progress shown next is this run's
 94        if kind == "test" and self.coverage_check.isChecked():
 95            cov.clear_counts(self.manifest_path.parent / "build")  # (this run's counts only)
 96            self._coverage_started = time.time()  # (files changed after this aren't what was compiled)
 97        self._marker_monitor().begin_listing(description)
 98        self.show_all_output()
 99        self.run_fpm([kind], True, False, phase="list", args=list_args)
100
101    def _after_listing(self, code: int, stopped: bool):
102        monitor = self._marker_monitor()
103        output = "".join(self._captured)
104        names = parse_test_list(output)
105        if stopped or code != 0:
106            monitor.finish(code, stopped=stopped)
107            self.show_panel(self.build_monitor)
108            return
109        if names == []:
110            monitor.finish(0, no_tests=True)
111            self.show_panel(monitor)
112            self.statusBar().showMessage(monitor.word("none"))
113            return
114        run_args, inner_runner, patterns, collect_args = self._test_args
115        if names and patterns:
116            # "--list" lists every name regardless of the names given, so apply
117            # the Target names/globs ourselves, as fpm will.
118            names = [n for n in names if any(fnmatch.fnmatchcase(n, p) for p in patterns)]
119        # fpm builds first (the Build tab shows it), then runs them.
120        monitor.start(names or [], self._load_durations(self._marker_kind), building=True)
121        jobs = self.test_jobs_spin.value() if self._marker_kind == "test" else 1
122        monitor.jobs = jobs
123        if jobs > 1:
124            monitor.description += f" · {jobs} at a time"
125            monitor.update_header()  # fpm only builds them and says how to run each; we run them (see _after_collect)
126            self._collected: list[tuple[str, list[str]]] = []
127            self.run_fpm(["test"], True, False, phase="collect", args=collect_args)
128            return
129        env = {"VIBEFPM_INNER_RUNNER": inner_runner} if inner_runner else {}
130        self.run_fpm([self._marker_kind], True, True, phase="marked", args=run_args, env_extra=env)
131
132    def _after_collect(self, code: int, stopped: bool):
133        """fpm has built the tests and said how to run each: run them, a few
134        at a time (Build options → Parallel tests)."""
135        if stopped or code != 0 or not self._collected:
136            self.reload()  # (first, as after "fpm test": then the message is the tests')
137            self._after_marked(code, stopped)  # (stopped, the build failed, or no tests)
138            return
139        _run_args, inner_runner, _patterns, _collect = self._test_args
140        try:
141            inner = split_inner_runner(inner_runner)
142        except ValueError:
143            inner = []
144        expected = self._load_durations("test")
145        # The longest first (the ones never timed first of all): the run ends sooner.
146        commands = sorted(self._collected, key=lambda c: -expected.get(c[0], float("inf")))
147        env = QProcessEnvironment.systemEnvironment()
148        env.insert("NO_COLOR", "1")
149        workers = min(self.test_jobs_spin.value(), len(commands))
150        tests = ParallelTests(commands, workers, str(self.manifest_path.parent), env, inner, kill_process_tree, self)
151        tests.started.connect(self._parallel_started)
152        tests.output.connect(self._parallel_output)
153        tests.finished.connect(self._parallel_finished)
154        tests.done.connect(self._parallel_done)
155        self.parallel_tests = tests
156        self._parallel_raw: dict[str, list[str]] = {}
157        self._parallel_failed = False
158        self._append(f"\n[Running {len(commands)} tests, {workers} at a time. Each test's output is shown "
159                     "here when it finishes; click a test in the Tests panel to watch it as it runs.]\n", bold=True)
160        self.stop_action.setEnabled(True)
161        self._set_project_enabled(True)
162        self.statusBar().showMessage(f"Running tests, {workers} at a time…")
163        tests.start()
164
165    def _parallel_started(self, name: str):
166        self._parallel_raw[name] = []
167        self.test_monitor.test_started(name)
168
169    def _parallel_output(self, name: str, text: str):
170        self._parallel_raw.setdefault(name, []).append(text)
171        plain = ansi.strip(text)
172        self.test_monitor.test_output(name, plain)
173        if self.test_monitor.selected == name:  # shown on its own: follow it
174            self.single_writer.write(plain)
175
176    def _parallel_finished(self, name: str, code: int, seconds: float, stopped: bool):
177        self.test_monitor.test_finished(name, code, seconds, stopped)
178        self._parallel_failed |= code != 0 and not stopped
179        # Its output, all in one piece: the tests' outputs never mix here.
180        status = "stopped" if stopped else "passed" if code == 0 else f"failed (exit code {code})"
181        command = self.parallel_tests.command(dict(self._collected).get(name, [name])) \
182            if self.parallel_tests is not None else [name]
183        rel = [os.path.relpath(a, self.manifest_path.parent) if os.path.isabs(a) else a for a in command]
184        self._append(f"\n── {name} · {status} · {seconds:.2f} s ── $ {join_args(rel)}\n", bold=True)
185        text = "".join(self._parallel_raw.pop(name, []))
186        if text:
187            self._append(text if text.endswith("\n") else text + "\n", colored=True)
188
189    def _parallel_done(self, stopped: bool):
190        if self.parallel_tests is None:
191            return
192        self.parallel_tests.deleteLater()
193        self.parallel_tests = None
194        self.stop_action.setEnabled(False)
195        self._set_project_enabled(self.graph is not None)
196        code = 1 if self._parallel_failed else 0
197        self._append("\n[tests stopped]\n" if stopped else f"\n[tests finished: exit code {code}]\n", bold=True)
198        self.reload()  # (first, as after "fpm test": then the message is the tests')
199        self._after_marked(code, stopped)
200
201    def _after_marked(self, code: int, stopped: bool):
202        monitor = self._marker_monitor()
203        monitor.finish(code, stopped=stopped)
204        self._save_durations(self._marker_kind, monitor.durations())
205        if self._marker_kind == "test" and self.coverage_check.isChecked() and not stopped \
206                and not monitor.build_failed:
207            self._collect_coverage()
208        counts = monitor.counts()
209        total = len(monitor.tests)
210        title = monitor.word("title")
211        if stopped:
212            message = f"{title}: stopped"
213        elif monitor.build_failed:
214            message = f"{title}: the build failed (see the Build tab)"
215        elif counts["Failed"]:
216            failed = [t for t in monitor.tests if t.status == "Failed"]
217            if self._marker_kind == "run" and len(failed) == 1 and failed[0].exit_code is not None:
218                message = f"{title}: {failed[0].name} failed (exit code {failed[0].exit_code})"
219            else:
220                message = f"{title}: {counts['Failed']} of {total} failed"
221        elif counts["Not run"]:
222            message = f"{title}: {counts['Passed']} {monitor.word('passed')}, {counts['Not run']} not run"
223        elif total == 1:
224            message = f"{title}: {monitor.tests[0].name} {monitor.word('passed')}"
225        else:
226            message = f"{title}: all {total} {monitor.word('passed')}"
227        self.statusBar().showMessage(message)
228
229    def _durations_key(self, kind: str = "test") -> str:
230        return self._project_key(f"{kind}_durations")
231
232    def _load_durations(self, kind: str = "test") -> dict[str, float]:
233        try:
234            data = json.loads(self.settings.value(self._durations_key(kind), "{}"))
235            return {str(k): float(v) for k, v in data.items()}
236        except (TypeError, ValueError, AttributeError):
237            return {}
238
239    def _save_durations(self, kind: str, durations: dict[str, float]):
240        if durations and self.manifest_path:
241            merged = {**self._load_durations(kind), **durations}
242            self.settings.setValue(self._durations_key(kind), json.dumps(merged))
243
244    def _test_line(self, raw: str) -> str:
245        """Pass a line of test output through the test monitor; returns the
246        text to show (with its color codes). The runner prints a blank line
247        before each marker, which is dropped along with the marker."""
248        line = ansi.strip(raw)
249        if self._test_building:
250            if MARKER_RE.search(line):
251                self._build_part_done()  # the first test is starting
252            else:
253                self.build_monitor.feed_line(line)  # fpm is still compiling
254        text = ""
255        if self._held_blank:
256            self._held_blank = False
257            if not MARKER_RE.search(line):  # the blank was real output
258                self._marker_monitor().feed_line("")
259                text = "\n"
260        if line == "":
261            self._held_blank = True
262            return text
263        shown = self._marker_monitor().feed_line(line)
264        if shown == line:
265            shown = raw
266        return text + (shown + "\n" if shown is not None else "")
267
268    def _collect_line(self, raw: str) -> str:
269        """A line of "fpm test --runner '… --collect'": the build's output,
270        or a test's command line (taken out, kept for _after_collect)."""
271        line = ansi.strip(raw)
272        match = EXE_RE.search(line)
273        if match is None:
274            if self._test_building:
275                self.build_monitor.feed_line(line)
276            return raw + "\n"
277        if self._test_building:
278            self._build_part_done()
279        try:
280            argv = json.loads(match.group(2))
281        except ValueError:
282            return raw + "\n"
283        self._collected.append((match.group(1), [str(a) for a in argv]))
284        return ""

Test and Run (a part of vibefpmgui.main_window.MainWindow).

def run_tests(self):
62    def run_tests(self):
63        self._start_marked("test")
def run_programs(self):
65    def run_programs(self):
66        self._start_marked("run")