Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Catch unhandled errors on both frontend and backend #3572

Merged
merged 12 commits into from
Jul 9, 2024
148 changes: 148 additions & 0 deletions integration/test_exception_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Integration tests for event exception handlers."""

from __future__ import annotations

import time
from typing import Generator

import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

from reflex.testing import AppHarness


def TestApp():
"""A test app for event exception handler integration."""
import reflex as rx

class TestAppConfig(rx.Config):
"""Config for the TestApp app."""

class TestAppState(rx.State):
"""State for the TestApp app."""

def divide_by_number(self, number: int):
"""Divide by number and print the result.

Args:
number: number to divide by

"""
print(1 / number)

app = rx.App(state=rx.State)

@app.add_page
def index():
return rx.vstack(
rx.button(
"induce_frontend_error",
on_click=rx.call_script("induce_frontend_error()"),
id="induce-frontend-error-btn",
),
rx.button(
"induce_backend_error",
on_click=lambda: TestAppState.divide_by_number(0), # type: ignore
id="induce-backend-error-btn",
),
)


@pytest.fixture(scope="module")
def test_app(tmp_path_factory) -> Generator[AppHarness, None, None]:
"""Start TestApp app at tmp_path via AppHarness.

Args:
tmp_path_factory: pytest tmp_path_factory fixture

Yields:
running AppHarness instance

"""
with AppHarness.create(
root=tmp_path_factory.mktemp("test_app"),
app_source=TestApp, # type: ignore
) as harness:
yield harness


@pytest.fixture
def driver(test_app: AppHarness) -> Generator[WebDriver, None, None]:
"""Get an instance of the browser open to the test_app app.

Args:
test_app: harness for TestApp app

Yields:
WebDriver instance.

"""
assert test_app.app_instance is not None, "app is not running"
driver = test_app.frontend()
try:
yield driver
finally:
driver.quit()


def test_frontend_exception_handler_during_runtime(
driver: WebDriver,
capsys,
):
"""Test calling frontend exception handler during runtime.

We send an event containing a call to a non-existent function in the frontend.
This should trigger the default frontend exception handler.

Args:
driver: WebDriver instance.
capsys: pytest fixture for capturing stdout and stderr.

"""
reset_button = WebDriverWait(driver, 20).until(
EC.element_to_be_clickable((By.ID, "induce-frontend-error-btn"))
)

reset_button.click()

# Wait for the error to be logged
time.sleep(2)

captured_default_handler_output = capsys.readouterr()
assert (
"induce_frontend_error" in captured_default_handler_output.out
and "ReferenceError" in captured_default_handler_output.out
)


def test_backend_exception_handler_during_runtime(
driver: WebDriver,
capsys,
):
"""Test calling backend exception handler during runtime.

We invoke TestAppState.divide_by_zero to induce backend error.
This should trigger the default backend exception handler.

Args:
driver: WebDriver instance.
capsys: pytest fixture for capturing stdout and stderr.

"""
reset_button = WebDriverWait(driver, 20).until(
EC.element_to_be_clickable((By.ID, "induce-backend-error-btn"))
)

reset_button.click()

# Wait for the error to be logged
time.sleep(2)

captured_default_handler_output = capsys.readouterr()
assert (
"divide_by_number" in captured_default_handler_output.out
and "ZeroDivisionError" in captured_default_handler_output.out
)
28 changes: 28 additions & 0 deletions reflex/.templates/web/utils/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ export const applyEvent = async (event, socket) => {
}
} catch (e) {
console.log("_call_script", e);
if (window && window?.onerror) {
window.onerror(e.message, null, null, null, e)
}
}
return false;
}
Expand Down Expand Up @@ -687,6 +690,31 @@ export const useEventLoop = (
}
}, [router.isReady]);

// Handle frontend errors and send them to the backend via websocket.
useEffect(() => {

if (typeof window === 'undefined') {
return;
}

window.onerror = function (msg, url, lineNo, columnNo, error) {
addEvents([Event("state.frontend_event_exception_state.handle_frontend_exception", {
stack: error.stack,
})])
return false;
}

//NOTE: Only works in Chrome v49+
//https://github.com/mknichel/javascript-errors?tab=readme-ov-file#promise-rejection-events
window.onunhandledrejection = function (event) {
addEvents([Event("state.frontend_event_exception_state.handle_frontend_exception", {
stack: event.reason.stack,
})])
return false;
}

},[])

// Main event loop.
useEffect(() => {
// Skip if the router is not ready.
Expand Down
Loading
Loading