From 90358b18b9067854bd4e535c91b9d3fd473e532d Mon Sep 17 00:00:00 2001 From: Tyson Smith Date: Tue, 9 Jun 2026 17:02:38 -0700 Subject: [PATCH] feat: make stderr and stdout logs accessible via http Add '/grz_stderr' and '/grz_stdout' endpoints to allow the browser access to it's logs via http. --- src/grizzly/replay/replay.py | 11 +++++++++ src/grizzly/replay/test_replay.py | 27 +++++++++++++++-------- src/grizzly/session.py | 11 +++++++++ src/grizzly/target/firefox_target.py | 9 ++++++++ src/grizzly/target/target.py | 11 +++++++++ src/grizzly/target/test_firefox_target.py | 17 ++++++++++++++ src/grizzly/target/test_target.py | 1 + src/grizzly/test_session.py | 3 +++ 8 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/grizzly/replay/replay.py b/src/grizzly/replay/replay.py index 737f56ed..50d8f6ee 100644 --- a/src/grizzly/replay/replay.py +++ b/src/grizzly/replay/replay.py @@ -233,6 +233,17 @@ def _setup_server_map(self, services: WebServices | None = None) -> ServerMap: server_map.set_redirect("grz_start", "grz_harness", required=False) if services: services.map_locations(server_map) + # expose the current browser logs over HTTP + server_map.set_dynamic_response( + "grz_stderr", + lambda _: self.target.read_log("stderr"), + mime_type="text/plain", + ) + server_map.set_dynamic_response( + "grz_stdout", + lambda _: self.target.read_log("stdout"), + mime_type="text/plain", + ) return server_map def _process_reports( diff --git a/src/grizzly/replay/test_replay.py b/src/grizzly/replay/test_replay.py index 6c532a0e..afd6cbbc 100644 --- a/src/grizzly/replay/test_replay.py +++ b/src/grizzly/replay/test_replay.py @@ -1025,6 +1025,13 @@ def test_replay_29(mocker, server): target = mocker.Mock(spec_set=Target, closed=True, launch_timeout=30) sm_cls = mocker.patch("grizzly.replay.replay.ServerMap", autospec=True) + def mapped_responses(): + # map of registered dynamic response url -> (callback, kwargs) + return { + call.args[0]: (call.args[1], call.kwargs) + for call in sm_cls.return_value.set_dynamic_response.call_args_list + } + # without harness - redirects directly to current test with ReplayManager([], server, target, use_harness=False) as replay: result = replay._setup_server_map() @@ -1032,7 +1039,8 @@ def test_replay_29(mocker, server): sm_cls.return_value.set_redirect.assert_called_once_with( "grz_start", "grz_current_test", required=False ) - sm_cls.return_value.set_dynamic_response.assert_not_called() + # only the browser log endpoints are registered (no harness) + assert set(mapped_responses()) == {"grz_stderr", "grz_stdout"} sm_cls.reset_mock() # with harness - serves harness file and redirects to it @@ -1040,17 +1048,18 @@ def test_replay_29(mocker, server): harness_content = replay._harness result = replay._setup_server_map() assert result is sm_cls.return_value - sm_cls.return_value.set_dynamic_response.assert_called_once() - name, fn = sm_cls.return_value.set_dynamic_response.call_args[0] - assert name == "grz_harness" - assert ( - sm_cls.return_value.set_dynamic_response.call_args[1]["mime_type"] - == "text/html" - ) - assert fn(None) == harness_content + responses = mapped_responses() + assert responses["grz_harness"][1]["mime_type"] == "text/html" + assert responses["grz_harness"][0](None) == harness_content sm_cls.return_value.set_redirect.assert_called_once_with( "grz_start", "grz_harness", required=False ) + # browser log endpoints return the current logs as text/plain + for log in ("grz_stderr", "grz_stdout"): + assert responses[log][1]["mime_type"] == "text/plain" + target.read_log.return_value = b"log data" + assert responses["grz_stderr"][0](None) == b"log data" + target.read_log.assert_called_with("stderr") sm_cls.reset_mock() # with services - locations are mapped onto the server map diff --git a/src/grizzly/session.py b/src/grizzly/session.py index 57fe0659..f9c24f88 100644 --- a/src/grizzly/session.py +++ b/src/grizzly/session.py @@ -195,6 +195,17 @@ def run( ) if services: services.map_locations(self.iomanager.server_map) + # expose the current browser logs over HTTP + self.iomanager.server_map.set_dynamic_response( + "grz_stderr", + lambda _: self.target.read_log("stderr"), + mime_type="text/plain", + ) + self.iomanager.server_map.set_dynamic_response( + "grz_stdout", + lambda _: self.target.read_log("stdout"), + mime_type="text/plain", + ) log_limiter = LogOutputLimiter(rate=log_rate) # limit relaunch to max iterations if needed diff --git a/src/grizzly/target/firefox_target.py b/src/grizzly/target/firefox_target.py index 33d257f9..a27ea23c 100644 --- a/src/grizzly/target/firefox_target.py +++ b/src/grizzly/target/firefox_target.py @@ -334,6 +334,15 @@ def log_size(self) -> int: total += length return total + def read_log(self, log_id: str) -> bytes: + cloned = self._puppet.clone_log(log_id) + if cloned is None: + return b"" + try: + return cloned.read_bytes() + finally: + cloned.unlink(missing_ok=True) + def merge_environment(self, extra: Mapping[str, str]) -> None: # use extra as base output = dict(extra) diff --git a/src/grizzly/target/target.py b/src/grizzly/target/target.py index e1ef5cc7..990bc124 100644 --- a/src/grizzly/target/target.py +++ b/src/grizzly/target/target.py @@ -265,6 +265,17 @@ def log_size(self) -> int: Total data size of log files in bytes. """ + def read_log(self, log_id: str) -> bytes: # pylint: disable=unused-argument + """Return the current contents of a browser log (e.g. 'stdout', 'stderr'). + + Args: + log_id: Log identifier. + + Returns: + Log contents, or empty bytes if unavailable. + """ + return b"" + @abstractmethod def merge_environment(self, extra: Mapping[str, str]) -> None: """Add to existing environment. diff --git a/src/grizzly/target/test_firefox_target.py b/src/grizzly/target/test_firefox_target.py index c5de33fe..585441b5 100644 --- a/src/grizzly/target/test_firefox_target.py +++ b/src/grizzly/target/test_firefox_target.py @@ -460,3 +460,20 @@ def test_firefox_target_16(mocker, tmp_path): ) FirefoxTarget._get_certdb(tmp_path / "root.pem", "fake-certutil") assert fake_add.call_count == 1 + + +def test_firefox_target_17(mocker, tmp_path): + """test FirefoxTarget.read_log()""" + fake_ffp = mocker.patch("grizzly.target.firefox_target.FFPuppet", autospec=True) + with FirefoxTarget(tmp_path / "fake", 300, 25, 5000) as target: + # log available + cloned = tmp_path / "cloned.txt" + cloned.write_bytes(b"hello stderr") + fake_ffp.return_value.clone_log.return_value = cloned + assert target.read_log("stderr") == b"hello stderr" + fake_ffp.return_value.clone_log.assert_called_once_with("stderr") + # cloned copy is removed after reading + assert not cloned.is_file() + # log unavailable + fake_ffp.return_value.clone_log.return_value = None + assert target.read_log("stdout") == b"" diff --git a/src/grizzly/target/test_target.py b/src/grizzly/target/test_target.py index 3a3a12f6..ae837b77 100644 --- a/src/grizzly/target/test_target.py +++ b/src/grizzly/target/test_target.py @@ -70,6 +70,7 @@ def test_target_01(tmp_path): assert target.memory_limit == 3 # test stubs target.reverse(1, 2) + assert target.read_log("stderr") == b"" def test_target_02(mocker, tmp_path): diff --git a/src/grizzly/test_session.py b/src/grizzly/test_session.py index a4d38cb8..184d2e72 100644 --- a/src/grizzly/test_session.py +++ b/src/grizzly/test_session.py @@ -106,6 +106,9 @@ def test_session_01(mocker, harness, profiling, coverage, relaunch, iters, runti assert target.close.call_count == max_iters / relaunch assert target.check_result.call_count == max_iters assert target.handle_hang.call_count == 0 + # browser log endpoints are exposed + for log in ("grz_stderr", "grz_stdout"): + assert session.iomanager.server_map.dynamic[log].mime == "text/plain" if profiling: assert any(session.status.profile_entries()) == profiling else: