diff --git a/frontend/code/components/componentBase.ts b/frontend/code/components/componentBase.ts index f87d3eae..cd0ee77f 100644 --- a/frontend/code/components/componentBase.ts +++ b/frontend/code/components/componentBase.ts @@ -15,6 +15,8 @@ import { ComponentId } from "../dataModels"; import { insertWrapperElement, replaceElement } from "../utils"; import { devToolsConnector } from "../app"; +export type Key = string | number; + /// Base for all component states. Updates received from the backend are /// partial, hence most properties may be undefined. export type ComponentState = { @@ -25,7 +27,7 @@ export type ComponentState = { // displayed to developers in Rio's dev tools _python_type_: string; // Debugging information - _key_: string | number | null; + _key_: Key | null; // How much space to leave on the left, top, right, bottom _margin_: [number, number, number, number]; // Explicit size request, if any diff --git a/frontend/code/components/listView.ts b/frontend/code/components/listView.ts index 27b53c9b..c4493772 100644 --- a/frontend/code/components/listView.ts +++ b/frontend/code/components/listView.ts @@ -1,6 +1,11 @@ import { componentsByElement, componentsById } from "../componentManagement"; import { ComponentId } from "../dataModels"; -import { ComponentBase, ComponentState, DeltaState } from "./componentBase"; +import { + ComponentBase, + ComponentState, + DeltaState, + Key, +} from "./componentBase"; import { CustomListItemComponent } from "./customListItem"; import { HeadingListItemComponent } from "./headingListItem"; import { SeparatorListItemComponent } from "./separatorListItem"; @@ -8,13 +13,12 @@ import { SeparatorListItemComponent } from "./separatorListItem"; export type ListViewState = ComponentState & { _type_: "ListView-builtin"; children: ComponentId[]; - selection_mode: "none" | "single" | "multiple"; // Selection mode - selected_keys: (string | number)[]; // Indices of selected items + selection_mode: "none" | "single" | "multiple"; + selected_items: Key[]; }; export class ListViewComponent extends ComponentBase { - private clickHandlers: Map void> = - new Map(); + private clickHandlers: Map void> = new Map(); createElement(): HTMLElement { let element = document.createElement("div"); @@ -48,8 +52,8 @@ export class ListViewComponent extends ComponentBase { this.state.selection_mode = deltaState.selection_mode; this._updateSelectionInteractivity(); } - if (deltaState.selected_keys !== undefined) { - this.state.selected_keys = deltaState.selected_keys; + if (deltaState.selected_items !== undefined) { + this.state.selected_items = deltaState.selected_items; this._updateSelectionStyles(); } } @@ -169,68 +173,64 @@ export class ListViewComponent extends ComponentBase { } } - private _itemKey(item: HTMLElement): string | number | null { - const component = componentsByElement.get( - item.firstElementChild as HTMLElement - ); - const key = component?.state._key_ ?? null; - if (key === null || key === "") { - console.warn("No key found for item", item); + /// Returns all child elements that have a key, along with the key + private _childrenWithKeys(): [HTMLElement, Key][] { + let result = [] as [HTMLElement, Key][]; + + for (let child of this.element.querySelectorAll( + ".rio-listview-grouped" + )) { + let itemKey = keyFromChildElement(child); + if (itemKey === null) continue; + + result.push([child as HTMLElement, itemKey]); } - return key; + + return result; } _updateSelectionInteractivity(): void { // Remove all existing listeners from current DOM elements if (this.clickHandlers.size > 0) { - this.element - .querySelectorAll(".rio-listview-grouped") - .forEach((item) => { - const itemKey = this._itemKey(item); - if (!itemKey) return; + for (let [item, itemKey] of this._childrenWithKeys()) { + const oldHandler = this.clickHandlers.get(itemKey); + if (oldHandler) { + item.removeEventListener("click", oldHandler); + } + } - const oldHandler = this.clickHandlers.get(itemKey); - if (oldHandler) { - item.removeEventListener("click", oldHandler); - } - }); - this.clickHandlers.clear(); // Clear all handlers when selection is disabled + // Clear all handlers when selection is disabled + this.clickHandlers.clear(); } if (this.state.selection_mode !== "none") { this.element.classList.add("selectable"); - this.element - .querySelectorAll(".rio-listview-grouped") - .forEach((item) => { - const itemKey = this._itemKey(item); - if (!itemKey) return; - // Create and store a new handler for this key - const handler = (event: MouseEvent) => - this._handleItemClick(itemKey); - item.addEventListener("click", handler); - this.clickHandlers.set(itemKey, handler); - }); + for (let [item, itemKey] of this._childrenWithKeys()) { + const handler = () => this._handleItemClick(itemKey); + item.addEventListener("click", handler); + this.clickHandlers.set(itemKey, handler); + } } else { this.element.classList.remove("selectable"); } } - _handleItemClick(itemKey: string | number): void { + _handleItemClick(itemKey: Key): void { if (this.state.selection_mode === "none") return; - const currentSelection = [...this.state.selected_keys]; + const currentSelection = [...this.state.selected_items]; const isSelected = currentSelection.includes(itemKey); if (this.state.selection_mode === "single") { - this.state.selected_keys = isSelected ? [] : [itemKey]; + this.state.selected_items = isSelected ? [] : [itemKey]; } else if (this.state.selection_mode === "multiple") { if (isSelected) { - this.state.selected_keys = currentSelection.filter( + this.state.selected_items = currentSelection.filter( (key) => key !== itemKey ); } else { - this.state.selected_keys = [...currentSelection, itemKey]; + this.state.selected_items = [...currentSelection, itemKey]; } } @@ -242,10 +242,11 @@ export class ListViewComponent extends ComponentBase { this.element .querySelectorAll(".rio-listview-grouped") .forEach((item) => { - const itemKey = this._itemKey(item); + const itemKey = keyFromChildElement(item); const listItem = item.querySelector(".rio-custom-list-item"); + if (listItem !== null && itemKey !== null) { - if (this.state.selected_keys.includes(itemKey)) { + if (this.state.selected_items.includes(itemKey)) { listItem.classList.add("selected"); } else { listItem.classList.remove("selected"); @@ -258,7 +259,18 @@ export class ListViewComponent extends ComponentBase { // Send selection change to the backend this.sendMessageToBackend({ type: "selectionChange", - selected_keys: this.state.selected_keys, + selected_items: this.state.selected_items, }); } } + +function keyFromChildElement(item: Element): Key | null { + const component = componentsByElement.get( + item.firstElementChild as HTMLElement + ); + const key = component?.state._key_ ?? null; + if (key === null || key === "") { + console.warn("No key found for item", item); + } + return key; +} diff --git a/frontend/code/popupManager.ts b/frontend/code/popupManager.ts index 1782561a..857813ea 100644 --- a/frontend/code/popupManager.ts +++ b/frontend/code/popupManager.ts @@ -30,6 +30,7 @@ import { getRootComponent, } from "./componentManagement"; import { DialogContainerComponent } from "./components/dialogContainer"; +import { markEventAsHandled } from "./eventHandling"; import { camelToKebab, commitCss, @@ -39,6 +40,8 @@ import { reprElement, } from "./utils"; +import ally from "ally.js"; + const enableSafariScrollingWorkaround = /^((?!chrome|android).)*safari/i.test( navigator.userAgent ); @@ -810,9 +813,12 @@ export class PopupManager { private currentAnimationPlayback: RioAnimationPlayback | null = null; + private focusTrap: ally.FocusTrap | null = null; + /// Listen for interactions with the outside world, so they can close the /// popup if user-closable. private clickHandler: ((event: MouseEvent) => void) | null = null; + private keydownHandler: ((event: KeyboardEvent) => void) | null = null; // Event handlers for repositioning the popup private scrollHandler: ((event: Event) => void) | null = null; @@ -937,6 +943,13 @@ export class PopupManager { } private removeEventHandlers(): void { + if (this.keydownHandler !== null) { + this.shadeElement.removeEventListener( + "keydown", + this.keydownHandler + ); + } + if (this.clickHandler !== null) { window.removeEventListener("click", this.clickHandler, true); } @@ -1033,6 +1046,29 @@ export class PopupManager { // but the modal shade already takes care of that. } + private _onKeydown(event: KeyboardEvent): void { + // If the popup isn't user-closable or not even open, there's nothing + // to do + if (!this.userClosable || !this.isOpen) { + return; + } + + // We only care about the "Escape" key + if (event.key !== "Escape") { + return; + } + + markEventAsHandled(event); + + // Close the popup + this.isOpen = false; + + // Tell the outside world + if (this.onUserClose !== undefined) { + this.onUserClose(); + } + } + private _repositionContentIfPositionChanged(): void { let anchorRect = this._anchor.getBoundingClientRect(); @@ -1080,9 +1116,18 @@ export class PopupManager { this.clickHandler = clickHandler; // Shuts up the type checker window.addEventListener("pointerdown", clickHandler, true); + let keydownHandler = this._onKeydown.bind(this); + this.keydownHandler = keydownHandler; // Shuts up the type checker + this.shadeElement.addEventListener("keydown", keydownHandler); + // Position the popup let animation = this._positionContent(); + // Fullscreen popups get special treatment in terms of keyboard focus + // if (this.positioner instanceof FullscreenPositioner) { + // ensureFocusIsInside(this.content); + // } + // Cancel the close animation, if it's still playing if (this.currentAnimationPlayback !== null) { this.currentAnimationPlayback.cancel(); @@ -1247,3 +1292,20 @@ function getAnchorRectInContainer( // the relation between "CSS pixels" and "visible pixels") return anchor.getBoundingClientRect(); } + +function ensureFocusIsInside(element: HTMLElement): void { + // If the focus is already inside, do nothing + let focusedElement = document.activeElement; + if (focusedElement !== null && element.contains(focusedElement)) { + return; + } + + // Find something to focus + const focusableElement = element.querySelector( + 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElement instanceof HTMLElement) { + focusableElement.focus(); + } +} diff --git a/frontend/code/rpcFunctions.ts b/frontend/code/rpcFunctions.ts index a54e8d92..ee574296 100644 --- a/frontend/code/rpcFunctions.ts +++ b/frontend/code/rpcFunctions.ts @@ -19,6 +19,7 @@ import { createBrowseButton, } from "./components/filePickerArea"; import { FullscreenPositioner, PopupManager } from "./popupManager"; +import { setConnectionLostPopupVisibleUnlessGoingAway } from "./rpc"; export async function registerFont( name: string, @@ -311,7 +312,13 @@ export async function getUnittestClientLayoutInfo(): Promise rio.Session: assert request.client is not None, "Why can this happen?" diff --git a/rio/components/key_event_listener.py b/rio/components/key_event_listener.py index 3300df34..3addfd77 100644 --- a/rio/components/key_event_listener.py +++ b/rio/components/key_event_listener.py @@ -610,13 +610,20 @@ class _KeyUpDownEvent: @classmethod def _from_json(cls, json_data: dict[str, t.Any]): - return cls( + result = cls( hardware_key=json_data["hardwareKey"], software_key=json_data["softwareKey"], text=json_data["text"], modifiers=frozenset(json_data["modifiers"]), ) + assert isinstance(result.hardware_key, str) + assert isinstance(result.software_key, str) + assert isinstance(result.text, str) + assert all(isinstance(modifier, str) for modifier in result.modifiers) + + return result + def __str__(self) -> str: keys = [ key diff --git a/rio/components/list_view.py b/rio/components/list_view.py index 3cfb6260..a45dbb03 100644 --- a/rio/components/list_view.py +++ b/rio/components/list_view.py @@ -3,9 +3,11 @@ from __future__ import annotations import typing as t import typing_extensions as te +from uniserde import JsonDoc import rio +from .component import Key from .fundamental_component import FundamentalComponent __all__ = ["ListView"] @@ -16,11 +18,11 @@ class ListViewSelectionChangeEvent: Event triggered when the selection in a ListView changes. ## Attributes: - `selected_keys`: A list of keys of the currently selected items. + `selected_items`: A list of keys of the currently selected items. """ - def __init__(self, selected_keys: list[str | int]): - self.selected_keys = selected_keys + def __init__(self, selected_items: list[str | int]): + self.selected_items = selected_items @t.final @@ -56,7 +58,7 @@ class ListView(FundamentalComponent): "single" (one item selectable), or "multiple" (multiple items selectable). Defaults to "none". - `selected_keys`: A list of keys of currently selected items. Defaults to + `selected_items`: A list of keys of currently selected items. Defaults to an empty list. `on_selection_change`: Event handler triggered when the selection changes. @@ -74,7 +76,7 @@ class ListView(FundamentalComponent): rio.SimpleListItem("Item 1", key="item1"), rio.SimpleListItem("Item 2", key="item2"), selection_mode="single", - selected_keys=['item1'], # Preselect the first item + selected_items=["item1"], # Preselect the first item ) ``` @@ -92,8 +94,8 @@ class ListView(FundamentalComponent): def on_press_heading_list_item(self, product: str) -> None: print(f"Pressed {product}") - def on_selection_change(self, selected_keys) -> None: - print(f"Selected keys: {selected_keys}") + def on_selection_change(self, selected_items) -> None: + print(f"Selected keys: {selected_items}") def build(self) -> rio.Component: # First create the ListView @@ -124,7 +126,7 @@ class ListView(FundamentalComponent): children: list[rio.Component] selection_mode: t.Literal["none", "single", "multiple"] - selected_keys: list[int] + selected_items: list[Key] on_selection_change: rio.EventHandler[ListViewSelectionChangeEvent] def __init__( @@ -149,7 +151,7 @@ class ListView(FundamentalComponent): # SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never", # SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never", selection_mode: t.Literal["none", "single", "multiple"] = "none", - selected_keys: list[str | int] | None = None, + selected_items: list[Key] | None = None, on_selection_change: rio.EventHandler[ ListViewSelectionChangeEvent ] = None, @@ -177,7 +179,7 @@ class ListView(FundamentalComponent): self.children = list(children) self.selection_mode = selection_mode - self.selected_keys = selected_keys or [] + self.selected_items = selected_items or [] self.on_selection_change = on_selection_change def add(self, child: rio.Component) -> te.Self: @@ -198,6 +200,11 @@ class ListView(FundamentalComponent): self.children.append(child) return self + def _custom_serialize_(self) -> JsonDoc: + return { + "selected_items": self.selected_items, # type: ignore (variance) + } + async def _on_message_(self, msg: t.Any) -> None: """ Handle messages from the frontend, such as selection changes. @@ -209,10 +216,10 @@ class ListView(FundamentalComponent): msg_type: str = msg["type"] assert isinstance(msg_type, str), msg_type - selected_keys: list[str | int] = msg["selected_keys"] + selected_items: list[str | int] = msg["selected_items"] selection_mode: str = self.selection_mode if selection_mode == "single": - assert len(selected_keys) < 2, ( + assert len(selected_items) < 2, ( "Only zero or one keys may be selected in single selection mode" ) else: @@ -223,11 +230,13 @@ class ListView(FundamentalComponent): # Trigger the event await self.call_event_handler( self.on_selection_change, - ListViewSelectionChangeEvent(selected_keys), + ListViewSelectionChangeEvent(selected_items), ) # Update the state - self._apply_delta_state_from_frontend({"selected_keys": selected_keys}) + self._apply_delta_state_from_frontend( + {"selected_items": selected_items} + ) # Refresh the session await self.session._refresh() diff --git a/rio/components/multi_line_text_input.py b/rio/components/multi_line_text_input.py index 199a45da..45016873 100644 --- a/rio/components/multi_line_text_input.py +++ b/rio/components/multi_line_text_input.py @@ -187,16 +187,18 @@ class MultiLineTextInput(KeyboardFocusableFundamentalComponent): # date value. assert isinstance(msg, dict), msg - self._apply_delta_state_from_frontend({"text": msg["text"]}) + text = msg["text"] + assert isinstance(text, str) + self._apply_delta_state_from_frontend({"text": text}) await self.call_event_handler( self.on_change, - MultiLineTextInputChangeEvent(self.text), + MultiLineTextInputChangeEvent(text), ) await self.call_event_handler( self.on_confirm, - MultiLineTextInputConfirmEvent(self.text), + MultiLineTextInputConfirmEvent(text), ) # Refresh the session diff --git a/rio/components/number_input.py b/rio/components/number_input.py index 2f149845..4eacbe9b 100644 --- a/rio/components/number_input.py +++ b/rio/components/number_input.py @@ -245,12 +245,19 @@ class NumberInput(KeyboardFocusableFundamentalComponent): assert isinstance(msg, dict), msg # Update the local state - old_value = self.value if self.is_sensitive: - self._apply_delta_state_from_frontend({"value": msg["value"]}) + old_value = self.value - value_has_changed = old_value != self.value + new_value = msg["value"] + assert isinstance(new_value, (int, float)) + + self._apply_delta_state_from_frontend({"value": new_value}) + + value_has_changed = old_value != self.value + else: + new_value = self.value + value_has_changed = False # What sort of event is this? event_type = msg.get("type") @@ -259,7 +266,7 @@ class NumberInput(KeyboardFocusableFundamentalComponent): if event_type == "gainFocus": await self.call_event_handler( self.on_gain_focus, - NumberInputFocusEvent(self.value), + NumberInputFocusEvent(new_value), ) # Lose focus @@ -267,12 +274,12 @@ class NumberInput(KeyboardFocusableFundamentalComponent): if self.is_sensitive and value_has_changed: await self.call_event_handler( self.on_change, - NumberInputChangeEvent(self.value), + NumberInputChangeEvent(new_value), ) await self.call_event_handler( self.on_lose_focus, - NumberInputFocusEvent(self.value), + NumberInputFocusEvent(new_value), ) # Change @@ -280,7 +287,7 @@ class NumberInput(KeyboardFocusableFundamentalComponent): if self.is_sensitive and value_has_changed: await self.call_event_handler( self.on_change, - NumberInputChangeEvent(self.value), + NumberInputChangeEvent(new_value), ) # Confirm @@ -289,12 +296,12 @@ class NumberInput(KeyboardFocusableFundamentalComponent): if value_has_changed: await self.call_event_handler( self.on_change, - NumberInputChangeEvent(self.value), + NumberInputChangeEvent(new_value), ) await self.call_event_handler( self.on_confirm, - NumberInputConfirmEvent(self.value), + NumberInputConfirmEvent(new_value), ) # Invalid diff --git a/rio/components/pointer_event_listener.py b/rio/components/pointer_event_listener.py index 3ab367f0..3770f319 100644 --- a/rio/components/pointer_event_listener.py +++ b/rio/components/pointer_event_listener.py @@ -70,7 +70,7 @@ class PointerEvent: @staticmethod def _from_message(msg: dict[str, t.Any]) -> PointerEvent: - return PointerEvent( + result = PointerEvent( pointer_type=msg["pointerType"], button=msg.get("button"), window_x=msg["windowX"], @@ -79,6 +79,15 @@ class PointerEvent: component_y=msg["componentY"], ) + assert result.pointer_type in ("mouse", "touch") + assert result.button is None or result.button in MOUSE_BUTTONS + assert isinstance(result.window_x, float) + assert isinstance(result.window_y, float) + assert isinstance(result.component_x, float) + assert isinstance(result.component_y, float) + + return result + @t.final @imy.docstrings.mark_constructor_as_private diff --git a/rio/components/switcher_bar.py b/rio/components/switcher_bar.py index 917787d7..8d2db8c5 100644 --- a/rio/components/switcher_bar.py +++ b/rio/components/switcher_bar.py @@ -283,13 +283,13 @@ class SwitcherBar(FundamentalComponent, t.Generic[T]): # backend. Ignore them. return - # TEMP, for debugging the switcher bar JS code - # self._apply_delta_state_from_frontend({'selected_value': selected_value}) - self.selected_value = selected_value + self._apply_delta_state_from_frontend( + {"selected_value": selected_value} + ) # Trigger the event await self.call_event_handler( - self.on_change, SwitcherBarChangeEvent(self.selected_value) + self.on_change, SwitcherBarChangeEvent(selected_value) ) # Refresh the session diff --git a/rio/components/table.py b/rio/components/table.py index 1fa4318a..42cc4dd5 100644 --- a/rio/components/table.py +++ b/rio/components/table.py @@ -158,13 +158,13 @@ class Table(FundamentalComponent): "Age": [25, 30, 35], "City": ["New York", "San Francisco", "Los Angeles"], }, - on_press=self.on_cell_press + on_press=self.on_cell_press, ) def on_cell_press(self, event): print(f"Cell clicked at row {event.row}, column {event.column}") - # For header cells, event.row will be "header" - # The actual value can be accessed with event.value + # For header cells, `event.row` will be "header" + # The actual value can be accessed with `event.value` ``` diff --git a/rio/components/text_input.py b/rio/components/text_input.py index 436716f3..592a70dc 100644 --- a/rio/components/text_input.py +++ b/rio/components/text_input.py @@ -203,12 +203,19 @@ class TextInput(KeyboardFocusableFundamentalComponent): assert isinstance(msg, dict), msg # Update the local state - old_value = self.text if self.is_sensitive: - self._apply_delta_state_from_frontend({"text": msg["text"]}) + old_value = self.text - value_has_changed = old_value != self.text + new_value = msg["text"] + assert isinstance(new_value, str) + + self._apply_delta_state_from_frontend({"text": new_value}) + + value_has_changed = old_value != new_value + else: + new_value = self.text + value_has_changed = False # What sort of event is this? event_type = msg.get("type") @@ -217,7 +224,7 @@ class TextInput(KeyboardFocusableFundamentalComponent): if event_type == "gainFocus": await self.call_event_handler( self.on_gain_focus, - TextInputFocusEvent(self.text), + TextInputFocusEvent(new_value), ) # Lose focus @@ -225,12 +232,12 @@ class TextInput(KeyboardFocusableFundamentalComponent): if self.is_sensitive and value_has_changed: await self.call_event_handler( self.on_change, - TextInputChangeEvent(self.text), + TextInputChangeEvent(new_value), ) await self.call_event_handler( self.on_lose_focus, - TextInputFocusEvent(self.text), + TextInputFocusEvent(new_value), ) # Change @@ -238,7 +245,7 @@ class TextInput(KeyboardFocusableFundamentalComponent): if self.is_sensitive and value_has_changed: await self.call_event_handler( self.on_change, - TextInputChangeEvent(self.text), + TextInputChangeEvent(new_value), ) # Confirm @@ -247,12 +254,12 @@ class TextInput(KeyboardFocusableFundamentalComponent): if value_has_changed: await self.call_event_handler( self.on_change, - TextInputChangeEvent(self.text), + TextInputChangeEvent(new_value), ) await self.call_event_handler( self.on_confirm, - TextInputConfirmEvent(self.text), + TextInputConfirmEvent(new_value), ) # Invalid diff --git a/rio/debug/layouter.py b/rio/debug/layouter.py index 7a783039..819835f8 100644 --- a/rio/debug/layouter.py +++ b/rio/debug/layouter.py @@ -213,7 +213,10 @@ def iter_direct_tree_children( component, rio.components.fundamental_component.FundamentalComponent, ): - yield from component._iter_referenced_components_() + yield from component._iter_direct_and_indirect_child_containing_attributes_( + include_self=False, + recurse_into_high_level_components=True, + ) # High level components have a single child: their build result else: diff --git a/rio/testing/__init__.py b/rio/testing/__init__.py new file mode 100644 index 00000000..67f3559c --- /dev/null +++ b/rio/testing/__init__.py @@ -0,0 +1,3 @@ +from .base_client import * +from .browser_client import * +from .dummy_client import * diff --git a/rio/testing.py b/rio/testing/base_client.py similarity index 66% rename from rio/testing.py rename to rio/testing/base_client.py index ac836ac4..ea594dbd 100644 --- a/rio/testing.py +++ b/rio/testing/base_client.py @@ -1,25 +1,24 @@ +import abc import asyncio import typing as t import ordered_set -import starlette.datastructures import typing_extensions as te from uniserde import JsonDoc import rio +import rio.app_server -from . import data_models -from .app_server import TestingServer -from .transports import MessageRecorderTransport, TransportInterrupted +from ..transports import MessageRecorderTransport -__all__ = ["TestClient"] +__all__ = ["BaseClient"] T = t.TypeVar("T") C = t.TypeVar("C", bound=rio.Component) -class TestClient: +class BaseClient(abc.ABC): @t.overload def __init__( self, @@ -59,6 +58,7 @@ class TestClient: running_in_window: bool = False, user_settings: JsonDoc = {}, active_url: str = "/", + debug_mode: bool = False, use_ordered_dirty_set: bool = False, ): if app is None: @@ -77,48 +77,46 @@ class TestClient: default_attachments=tuple(default_attachments), ) - self._app_server = TestingServer( - app, - debug_mode=False, - running_in_window=running_in_window, - ) - + self._app = app self._user_settings = user_settings self._active_url = active_url + self._running_in_window = running_in_window + self._debug_mode = debug_mode self._use_ordered_dirty_set = use_ordered_dirty_set - self._session: rio.Session | None = None - self._transport = MessageRecorderTransport( + self._recorder_transport = MessageRecorderTransport( process_sent_message=self._process_sent_message ) self._first_refresh_completed = asyncio.Event() - def _process_sent_message(self, message: JsonDoc) -> None: - if "id" in message: - self._transport.queue_response( - { - "jsonrpc": "2.0", - "id": message["id"], - "result": None, - } - ) + self._app_server: rio.app_server.AbstractAppServer | None = None + self._session: rio.Session | None = None + # Overriding this function is miserable because of the overloads and + # myriad of parameters, so we'll provide a __post_init__ for convenience + self.__post_init__() + + def __post_init__(self) -> None: + pass + + @abc.abstractmethod + async def _get_app_server(self) -> rio.app_server.AbstractAppServer: + raise NotImplementedError + + @abc.abstractmethod + async def _create_session(self) -> rio.Session: + raise NotImplementedError + + def _process_sent_message(self, message: JsonDoc) -> None: if message["method"] == "updateComponentStates": self._first_refresh_completed.set() async def __aenter__(self) -> te.Self: - url = str(rio.URL("http://unit.test") / self._active_url.lstrip("/")) + self._app_server = await self._get_app_server() + self._app_server.app = self._app + self._app_server.debug_mode = self._debug_mode - self._session = await self._app_server.create_session( - initial_message=data_models.InitialClientMessage.from_defaults( - url=url, - user_settings=self._user_settings, - ), - transport=self._transport, - client_ip="localhost", - client_port=12345, - http_headers=starlette.datastructures.Headers(), - ) + self._session = await self._create_session() if self._use_ordered_dirty_set: self._session._dirty_components = ordered_set.OrderedSet( @@ -133,29 +131,11 @@ class TestClient: if self._session is not None: await self._session._close(close_remote_session=False) - async def _simulate_interrupted_connection(self) -> None: - assert self._session is not None - - self._transport.queue_response(TransportInterrupted) - - while self._session._is_connected_event.is_set(): - await asyncio.sleep(0.05) - - async def _simulate_reconnect(self) -> None: - assert self._session is not None - - # If currently connected, disconnect first - if self._session._is_connected_event.is_set(): - await self._simulate_interrupted_connection() - - self._transport = MessageRecorderTransport( - process_sent_message=self._process_sent_message - ) - await self._session._replace_rio_transport(self._transport) - @property - def _outgoing_messages(self) -> list[JsonDoc]: - return self._transport.sent_messages + def _received_messages(self) -> list[JsonDoc]: + # From a "client" perspective they are "received" messages, but from a + # "transport" perspective they are "sent" messages + return self._recorder_transport.sent_messages @property def _dirty_components(self) -> set[rio.Component]: @@ -169,7 +149,7 @@ class TestClient: def _last_component_state_changes( self, ) -> t.Mapping[rio.Component, t.Mapping[str, object]]: - for message in reversed(self._transport.sent_messages): + for message in reversed(self._received_messages): if message["method"] == "updateComponentStates": delta_states: dict = message["params"]["delta_states"] # type: ignore return { @@ -212,17 +192,23 @@ class TestClient: return self.session._get_user_root_component() def get_components(self, component_type: type[C]) -> t.Iterator[C]: - root_component = self.root_component + roots = [self.root_component] - for component in root_component._iter_component_tree_(): - if type(component) is component_type: - yield component # type: ignore + for root_component in roots: + for component in root_component._iter_component_tree_(): + if type(component) is component_type: + yield component # type: ignore + + roots.extend( + dialog._root_component + for dialog in component._owned_dialogs_.values() + ) def get_component(self, component_type: type[C]) -> C: try: return next(self.get_components(component_type)) except StopIteration: - raise AssertionError(f"No component of type {component_type} found") + raise ValueError(f"No component of type {component_type} found") async def refresh(self) -> None: await self.session._refresh() diff --git a/rio/testing/browser_client.py b/rio/testing/browser_client.py new file mode 100644 index 00000000..7a9c9d26 --- /dev/null +++ b/rio/testing/browser_client.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import asyncio +import contextlib +import typing as t + +import asyncio_atexit +import playwright.async_api +import uvicorn + +import rio.app_server +import rio.data_models +from rio.app_server import FastapiServer +from rio.components.text import Text +from rio.transports import FastapiWebsocketTransport, MultiTransport +from rio.utils import choose_free_port + +from .base_client import BaseClient + +__all__ = ["BrowserClient", "prepare_browser_client"] + + +# For debugging. Set this to a number > 0 if you want to look at the browser. +# +# Note: Chrome's console doesn't show `console.debug` messages per default. To +# see them, click on "All levels" and check "Verbose". +DEBUG_SHOW_BROWSER_DURATION = 10 + + +server_manager: ServerManager | None = None + + +async def _get_server_manager() -> ServerManager: + global server_manager + + if server_manager is None: + server_manager = ServerManager() + await server_manager.start() + + return server_manager + + +@contextlib.asynccontextmanager +async def prepare_browser_client(): + """ + Starting a `BrowserClient` can take a while, and can cause unit tests to + exceed their timeout. This context manager prepares all of the stuff that + `BrowserClient`s require to function, eliminating most of the overhead. + + To ensure that the expensive preparation happens before the unit tests + start, use this context manager in a fixture: + + ```python + @pytest.fixture(scope="session", autouse=True) + async def prepare(): + async with prepare_browser_client(): + yield + ``` + + Note: There have been issues where pytest would hang when shutting down the + browser used by `BrowserClient`s. This context manager should also help with + that. + """ + await _get_server_manager() + + try: + yield + finally: + if server_manager is not None: + await server_manager.stop() + + +class BrowserClient(BaseClient): + def __post_init__(self) -> None: + self._page: playwright.async_api.Page | None = None + + @property + def playwright_page(self) -> playwright.async_api.Page: + assert self._page is not None + return self._page + + @property + def _window_width_in_pixels(self) -> int: + assert self._page is not None + assert self._page.viewport_size is not None + + return self._page.viewport_size["width"] + + @property + def _window_height_in_pixels(self) -> int: + assert self._page is not None + assert self._page.viewport_size is not None + + return self._page.viewport_size["height"] + + async def click(self, x: float, y: float, *, sleep: float = 0.1) -> None: + assert self._page is not None + + if isinstance(x, float) and x <= 1: + x = round(x * self._window_width_in_pixels) + + if isinstance(y, float) and y <= 1: + y = round(y * self._window_height_in_pixels) + + if DEBUG_SHOW_BROWSER_DURATION > 0: + await self.execute_js(f""" + const marker = document.createElement('div'); + marker.style.background = 'red'; + marker.style.borderRadius = '50%'; + marker.style.position = 'absolute'; + marker.style.zIndex = '9999'; + marker.style.width = '10px'; + marker.style.height = '10px'; + marker.style.left = `{x}px`; + marker.style.top = `{y}px`; + marker.style.transform = 'translate(-50%, -50%)'; + document.body.appendChild(marker); + + setTimeout(() => {{ + marker.remove(); + }}, {sleep} * 1000); + """) + + await self._page.mouse.click(x, y) + await asyncio.sleep(sleep) + + async def execute_js(self, js: str) -> t.Any: + assert self._page is not None + return await self._page.evaluate(js) + + async def _get_app_server(self) -> rio.app_server.AbstractAppServer: + manager = await _get_server_manager() + return manager.app_server + + async def _create_session(self) -> rio.Session: + manager = await _get_server_manager() + + assert not manager.app_server.sessions, ( + f"App server still has sessions?! {manager.app_server.sessions}" + ) + + manager.app = self._app + manager.app_server._transport_factory = ( + lambda websocket: MultiTransport( + FastapiWebsocketTransport(websocket), + self._recorder_transport, + ) + ) + + self._page = await manager.new_page() + await self._page.goto(f"http://localhost:{manager.port}") + + while True: + try: + return manager.app_server.sessions[0] + except IndexError: + await asyncio.sleep(0.1) + + async def __aexit__(self, *args: t.Any) -> None: + # Sleep to keep the browser open for debugging + await asyncio.sleep(DEBUG_SHOW_BROWSER_DURATION) + + if self._page is not None: + await self._page.close() + + await super().__aexit__(*args) + + manager = await _get_server_manager() + while manager.app_server.sessions: + await asyncio.sleep(0.1) + + +class ServerManager: + """ + This class is designed to efficiently create many `BrowserClient` objects + for different GUIs. Starting a web server and a browser every time you need + a `BrowserClient` has very high overhead, so this class re-uses the existing + ones when possible. + """ + + def __init__(self) -> None: + self.port = choose_free_port("localhost") + + self.app = rio.App( + build=rio.Spacer, + # JS reports incorrect sizes and positions for hidden elements, and + # so the tests end up failing because of the icon in the connection + # lost popup. I think it's because icons have a fixed size, but JS + # reports the size as 0x0. So we'll get rid of the icon. + build_connection_lost_message=build_connection_lost_message, + ) + self._app_server: FastapiServer | None = None + self._uvicorn_server: uvicorn.Server | None = None + self._uvicorn_serve_task: asyncio.Task[None] | None = None + self._playwright: playwright.async_api.Playwright | None = None + self._browser: playwright.async_api.Browser | None = None + self._browser_context: playwright.async_api.BrowserContext | None = None + + @property + def app_server(self) -> FastapiServer: + assert self._app_server is not None + return self._app_server + + async def new_page(self) -> playwright.async_api.Page: + assert self._browser_context is not None + return await self._browser_context.new_page() + + async def start(self) -> None: + asyncio_atexit.register(self.stop) + + await self._start_browser() + await self._start_uvicorn_server() + + async def stop(self) -> None: + if self._browser_context is not None: + await self._browser_context.close() + + if self._browser is not None: + await self._browser.close() + + if self._playwright is not None: + await self._playwright.stop() + + if self._uvicorn_server is not None: + self._uvicorn_server.should_exit = True + + assert self._uvicorn_serve_task is not None + await self._uvicorn_serve_task + + async def _start_uvicorn_server(self) -> None: + server_is_ready_event = asyncio.Event() + loop = asyncio.get_running_loop() + + def set_server_ready_event() -> None: + loop.call_soon_threadsafe(server_is_ready_event.set) + + self._app_server = FastapiServer( + self.app, + debug_mode=False, + running_in_window=False, + internal_on_app_start=set_server_ready_event, + base_url=None, + ) + + config = uvicorn.Config( + self._app_server, + port=self.port, + log_level="critical", + ) + self._uvicorn_server = uvicorn.Server(config) + + current_task: asyncio.Task = asyncio.current_task() # type: ignore + self._uvicorn_serve_task = asyncio.create_task( + self._run_uvicorn(current_task) + ) + + await server_is_ready_event.wait() + + # Just because uvicorn says it's ready doesn't mean it's actually ready. + # Give it a bit more time. + await asyncio.sleep(1) + + async def _run_uvicorn(self, test_task: asyncio.Task) -> None: + assert self._uvicorn_server is not None + + try: + await self._uvicorn_server.serve() + except BaseException as error: + test_task.cancel(f"Uvicorn server crashed: {error}") + + async def _start_browser(self) -> None: + self._playwright = await playwright.async_api.async_playwright().start() + + try: + self._browser = await self._playwright.chromium.launch( + headless=DEBUG_SHOW_BROWSER_DURATION == 0 + ) + except Exception: + raise Exception( + "Playwright cannot launch chromium. Please execute the" + " following command:\n" + "playwright install --with-deps chromium\n" + "(If you're using a virtual environment, activate it first.)" + ) from None + + # With default settings, playwright gets detected as a crawler. So we + # need to emulate a real device. + kwargs = dict(self._playwright.devices["Desktop Chrome"]) + # The default window size is too large to fit on my screen, which sucks + # when debugging. Make it smaller. + kwargs["viewport"] = {"width": 800, "height": 600} + self._browser_context = await self._browser.new_context(**kwargs) + + +def build_connection_lost_message() -> Text: + return rio.Text("Connection Lost") diff --git a/rio/testing/dummy_client.py b/rio/testing/dummy_client.py new file mode 100644 index 00000000..faa30ad4 --- /dev/null +++ b/rio/testing/dummy_client.py @@ -0,0 +1,73 @@ +import asyncio + +import starlette.datastructures +from uniserde import JsonDoc + +import rio +from rio.app_server.abstract_app_server import AbstractAppServer + +from .. import data_models +from ..app_server import TestingServer +from ..transports import MessageRecorderTransport, TransportInterrupted +from .base_client import BaseClient + +__all__ = ["DummyClient"] + + +class DummyClient(BaseClient): + def __post_init__(self) -> None: + self.__app_server = TestingServer( + self._app, + debug_mode=self._debug_mode, + running_in_window=self._running_in_window, + ) + + def _process_sent_message(self, message: JsonDoc) -> None: + if "id" in message: + self._recorder_transport.queue_response( + { + "jsonrpc": "2.0", + "id": message["id"], + "result": None, + } + ) + + if message["method"] == "updateComponentStates": + self._first_refresh_completed.set() + + async def _get_app_server(self) -> AbstractAppServer: + return self.__app_server + + async def _create_session(self) -> rio.Session: + url = str(rio.URL("http://unit.test") / self._active_url.lstrip("/")) + + return await self.__app_server.create_session( + initial_message=data_models.InitialClientMessage.from_defaults( + url=url, + user_settings=self._user_settings, + ), + transport=self._recorder_transport, + client_ip="localhost", + client_port=12345, + http_headers=starlette.datastructures.Headers(), + ) + + async def _simulate_interrupted_connection(self) -> None: + assert self._session is not None + + self._recorder_transport.queue_response(TransportInterrupted) + + while self._session._is_connected_event.is_set(): + await asyncio.sleep(0.05) + + async def _simulate_reconnect(self) -> None: + assert self._session is not None + + # If currently connected, disconnect first + if self._session._is_connected_event.is_set(): + await self._simulate_interrupted_connection() + + self._recorder_transport = MessageRecorderTransport( + process_sent_message=self._process_sent_message + ) + await self._session._replace_rio_transport(self._recorder_transport) diff --git a/rio/transports/__init__.py b/rio/transports/__init__.py index c5858993..faa8a02c 100644 --- a/rio/transports/__init__.py +++ b/rio/transports/__init__.py @@ -1,3 +1,4 @@ from .abstract_transport import * from .fastapi_websocket_transport import * from .message_recorder_transport import * +from .multi_transport import * diff --git a/rio/transports/multi_transport.py b/rio/transports/multi_transport.py new file mode 100644 index 00000000..86d3827e --- /dev/null +++ b/rio/transports/multi_transport.py @@ -0,0 +1,45 @@ +from .abstract_transport import AbstractTransport, TransportClosed + +__all__ = ["MultiTransport"] + + +class MultiTransport(AbstractTransport): + """ + Sends outgoing messages to multiple transports. + """ + + def __init__( + self, + main_transport: AbstractTransport, + *extra_transports: AbstractTransport, + ) -> None: + super().__init__() + + assert not main_transport.is_closed + + self._main_transport = main_transport + self._extra_transports = extra_transports + + async def send_if_possible(self, message: str, /) -> None: + await self._main_transport.send_if_possible(message) + + for transport in self._extra_transports: + await transport.send_if_possible(message) + + if self._main_transport.is_closed: + await self.close() + + async def receive(self) -> str: + try: + return await self._main_transport.receive() + except TransportClosed: + await self.close() + raise + + async def close(self) -> None: + await self._main_transport.close() + + for transport in self._extra_transports: + await transport.close() + + self.closed_event.set() diff --git a/rio/webview_shim.py b/rio/webview_shim.py index b01c99d5..359d5e9a 100644 --- a/rio/webview_shim.py +++ b/rio/webview_shim.py @@ -24,16 +24,16 @@ sys.argv = argv[:1] try: - # `pywebview` supports a number of backends, each with its own pros and cons. QT - # seems to be the best option for us: It runs on all platforms, supports most if - # not all features (like setting the window icon) and is likely to remain - # actively maintained. + # `pywebview` supports a number of backends, each with its own pros and + # cons. QT seems to be the best option for us: It runs on all platforms, + # supports most if not all features (like setting the window icon) and is + # likely to remain actively maintained. # - # Just importing `webview` doesn't do the trick though. It uses `qtpy`, which in - # turn supports multiple backends. We specifically want it to use `PySide6` as - # that one comes with the most powerful, chromium-powered web engine. By - # importing `PySide6` before importing `webview`, we ensure that `qtpy` will - # pick `PySide6` as the backend. + # Just importing `webview` doesn't do the trick though. It uses `qtpy`, + # which in turn supports multiple backends. We specifically want it to use + # `PySide6` as that one comes with the most powerful, chromium-powered web + # engine. By importing `PySide6` before importing `webview`, we ensure that + # `qtpy` will pick `PySide6` as the backend. # # There's some related discussion on GitHub: # https://github.com/rio-labs/rio/issues/164 diff --git a/tests/test_app_build.py b/tests/test_app_build.py index 108e8a98..d0741bdc 100644 --- a/tests/test_app_build.py +++ b/tests/test_app_build.py @@ -5,7 +5,7 @@ async def test_fundamental_container_as_root() -> None: def build() -> rio.Component: return rio.Row(rio.Text("Hello")) - async with rio.testing.TestClient(build) as test_client: + async with rio.testing.DummyClient(build) as test_client: row_component = test_client.get_component(rio.Row) text_component = test_client.get_component(rio.Text) diff --git a/tests/test_attribute_bindings.py b/tests/test_attribute_bindings.py index 62b90bd4..27ee71ce 100644 --- a/tests/test_attribute_bindings.py +++ b/tests/test_attribute_bindings.py @@ -41,7 +41,7 @@ async def test_bindings_arent_created_too_early() -> None: def build(self) -> rio.Component: return IHaveACustomInit(text=self.bind().text) - async with rio.testing.TestClient(Container) as test_client: + async with rio.testing.DummyClient(Container) as test_client: root_component = test_client.get_component(Container) child_component = test_client.get_component(IHaveACustomInit) @@ -78,12 +78,12 @@ async def test_init_receives_attribute_bindings_as_input() -> None: def build(self) -> rio.Component: return Square(self.bind().size) - async with rio.testing.TestClient(lambda: Container(7)): + async with rio.testing.DummyClient(lambda: Container(7)): assert isinstance(size_value, PendingAttributeBinding) async def test_binding_assignment_on_child() -> None: - async with rio.testing.TestClient(Parent) as test_client: + async with rio.testing.DummyClient(Parent) as test_client: root_component = test_client.get_component(Parent) text_component = test_client._get_build_output(root_component, rio.Text) @@ -100,7 +100,7 @@ async def test_binding_assignment_on_child() -> None: async def test_binding_assignment_on_parent() -> None: - async with rio.testing.TestClient(Parent) as test_client: + async with rio.testing.DummyClient(Parent) as test_client: root_component = test_client.get_component(Parent) text_component = test_client._get_build_output(root_component) @@ -126,7 +126,7 @@ async def test_binding_assignment_on_sibling() -> None: rio.Text(self.bind().text), ) - async with rio.testing.TestClient(Root) as test_client: + async with rio.testing.DummyClient(Root) as test_client: root_component = test_client.get_component(Root) text1, text2 = t.cast( list[rio.Text], @@ -148,7 +148,7 @@ async def test_binding_assignment_on_sibling() -> None: async def test_binding_assignment_on_grandchild() -> None: - async with rio.testing.TestClient(Grandparent) as test_client: + async with rio.testing.DummyClient(Grandparent) as test_client: root_component = test_client.get_component(Grandparent) parent = t.cast(Parent, test_client._get_build_output(root_component)) text_component: rio.Text = test_client._get_build_output(parent) @@ -168,7 +168,7 @@ async def test_binding_assignment_on_grandchild() -> None: async def test_binding_assignment_on_middle() -> None: - async with rio.testing.TestClient(Grandparent) as test_client: + async with rio.testing.DummyClient(Grandparent) as test_client: root_component = test_client.get_component(Grandparent) parent: Parent = test_client._get_build_output(root_component) text_component: rio.Text = test_client._get_build_output(parent) @@ -188,7 +188,7 @@ async def test_binding_assignment_on_middle() -> None: async def test_binding_assignment_on_child_after_reconciliation() -> None: - async with rio.testing.TestClient(Parent) as test_client: + async with rio.testing.DummyClient(Parent) as test_client: root_component = test_client.get_component(Parent) text_component: rio.Text = test_client._get_build_output(root_component) @@ -208,7 +208,7 @@ async def test_binding_assignment_on_child_after_reconciliation() -> None: async def test_binding_assignment_on_parent_after_reconciliation() -> None: - async with rio.testing.TestClient(Parent) as test_client: + async with rio.testing.DummyClient(Parent) as test_client: root_component = test_client.get_component(Parent) text_component: rio.Text = test_client._get_build_output(root_component) @@ -237,7 +237,7 @@ async def test_binding_assignment_on_sibling_after_reconciliation() -> None: rio.Text(self.bind().text), ) - async with rio.testing.TestClient(Root) as test_client: + async with rio.testing.DummyClient(Root) as test_client: root_component = test_client.get_component(Root) text1, text2 = test_client._get_build_output(root_component).children @@ -259,7 +259,7 @@ async def test_binding_assignment_on_sibling_after_reconciliation() -> None: async def test_binding_assignment_on_grandchild_after_reconciliation() -> None: - async with rio.testing.TestClient(Grandparent) as test_client: + async with rio.testing.DummyClient(Grandparent) as test_client: root_component = test_client.get_component(Grandparent) parent: Parent = test_client._get_build_output(root_component) text_component: rio.Text = test_client._get_build_output(parent) @@ -282,7 +282,7 @@ async def test_binding_assignment_on_grandchild_after_reconciliation() -> None: async def test_binding_assignment_on_middle_after_reconciliation() -> None: - async with rio.testing.TestClient(Grandparent) as test_client: + async with rio.testing.DummyClient(Grandparent) as test_client: root_component = test_client.get_component(Grandparent) parent: Parent = test_client._get_build_output(root_component) text_component: rio.Text = test_client._get_build_output(parent) diff --git a/tests/test_custom_components.py b/tests/test_custom_components.py index c9580ff5..c20d4123 100644 --- a/tests/test_custom_components.py +++ b/tests/test_custom_components.py @@ -11,7 +11,7 @@ async def test_fields_with_defaults(): def build(self) -> rio.Component: raise NotImplementedError() - async with rio.testing.TestClient(TestComponent) as test_client: + async with rio.testing.DummyClient(TestComponent) as test_client: component = test_client.get_component(TestComponent) assert component.foo == [] assert component.bar == 5 @@ -27,6 +27,6 @@ async def test_post_init(): def build(self) -> rio.Component: return rio.Text("hi") - async with rio.testing.TestClient(TestComponent) as test_client: + async with rio.testing.DummyClient(TestComponent) as test_client: root_component = test_client.get_component(TestComponent) assert root_component.post_init_called diff --git a/tests/test_events.py b/tests/test_events.py index 8d21110b..60ca65f2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -52,7 +52,7 @@ async def test_mounted(): def build(): return ChildMounter(EventCounter(NestedComponent())) - async with rio.testing.TestClient(build) as test_client: + async with rio.testing.DummyClient(build) as test_client: mounter = test_client.get_component(ChildMounter) event_counter = t.cast(EventCounter, mounter.child) assert event_counter.mount_count == 0 @@ -82,7 +82,7 @@ async def test_double_mount(): def build(): return ChildMounter(EventCounter(rio.Text("hello!"))) - async with rio.testing.TestClient(build) as test_client: + async with rio.testing.DummyClient(build) as test_client: mounter = test_client.get_component(ChildMounter) event_counter = t.cast(EventCounter, mounter.child) @@ -108,7 +108,7 @@ async def test_refresh_after_synchronous_mount_handler(): def build(self) -> rio.Component: return rio.Switch(self.mounted) - async with rio.testing.TestClient(DemoComponent) as test_client: + async with rio.testing.DummyClient(DemoComponent) as test_client: demo_component = test_client.get_component(DemoComponent) switch = test_client.get_component(rio.Switch) @@ -131,7 +131,7 @@ async def test_periodic(): def build(self) -> rio.Component: return rio.Spacer() - async with rio.testing.TestClient(DemoComponent) as test_client: + async with rio.testing.DummyClient(DemoComponent) as test_client: ticks_before = ticks await asyncio.sleep(0.1) ticks_after = ticks @@ -167,7 +167,7 @@ async def test_populate_dead_child(): def build(): return ChildMounter(DemoComponent()) - async with rio.testing.TestClient(build) as test_client: + async with rio.testing.DummyClient(build) as test_client: mounter = test_client.get_component(ChildMounter) # Unmount the child before its `on_populate` handler makes it dirty @@ -175,7 +175,7 @@ async def test_populate_dead_child(): await test_client.refresh() # Wait for the `on_populate` handler and the subsequent refresh - test_client._outgoing_messages.clear() + test_client._received_messages.clear() await asyncio.sleep(1.5) # Make sure the dead component wasn't sent to the frontend diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b69ee42b..f8560d3f 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -104,7 +104,7 @@ async def test_extension_events() -> None: assert len(extension_instance.function_call_log) == 0 - async with rio.testing.TestClient(app) as test_client: + async with rio.testing.DummyClient(app) as test_client: # The test client doesn't support Rio's full feature set and so doesn't # actually call the app start/close events right now. Skip them diff --git a/tests/test_layouting/conftest.py b/tests/test_frontend/conftest.py similarity index 60% rename from tests/test_layouting/conftest.py rename to tests/test_frontend/conftest.py index 44eeabfc..356132ba 100644 --- a/tests/test_layouting/conftest.py +++ b/tests/test_frontend/conftest.py @@ -1,17 +1,13 @@ import pytest -from tests.utils.layouting import cleanup, setup +from rio.testing import prepare_browser_client # Somewhat counter-intuitively, `scope='module'` would be incorrect here. That # would execute the fixture once for each submodule. Scoping it to the session # instead does exactly what we want: It runs only once, and only if it's needed. @pytest.fixture(scope="session", autouse=True) -@pytest.mark.async_timeout(30) # Yes, fixtures can time out +@pytest.mark.async_timeout(60) # Yes, fixtures can time out async def manage_server(): - await setup() - yield - await cleanup() - - -pytestmark = pytest.mark.async_timeout(10) + async with prepare_browser_client(): + yield diff --git a/tests/test_frontend/test_dialogs.py b/tests/test_frontend/test_dialogs.py new file mode 100644 index 00000000..91a5ba88 --- /dev/null +++ b/tests/test_frontend/test_dialogs.py @@ -0,0 +1,91 @@ +import asyncio +import typing as t + +import pytest + +import rio +from tests.utils.layouting import BrowserClient + + +class DialogOpener(rio.Component): + """ + Helper class that opens a dialog on mount. + """ + + build_dialog_content: t.Callable[[], rio.Component] + + modal: bool = True + + dialog_closed: bool = False + + @rio.event.on_mount + async def on_mount(self): + dialog = await self.session.show_custom_dialog( + self.build_dialog_content, + user_closable=True, + modal=self.modal, + ) + await dialog.wait_for_close() + + self.dialog_closed = True + + def build(self) -> rio.Component: + return rio.Spacer() + + +@pytest.mark.parametrize("modal", [True, False]) +async def test_click_closes_dialog(modal: bool): + def build(): + return DialogOpener( + lambda: rio.Text("foo", align_x=0.5, align_y=0.5), + modal=modal, + ) + + async with BrowserClient(build) as test_client: + await asyncio.sleep(0.1) + + await test_client.click(100, 100) + + opener = test_client.get_component(DialogOpener) + assert opener.dialog_closed + + +async def test_click_in_popup_doesnt_close_dialog(): + """ + Tests whether clicking a popup - like a dropdown - in the dialog closes the + dialog. + """ + + def build(): + return DialogOpener( + lambda: rio.Dropdown("123456789"), + ) + + async with BrowserClient(build) as test_client: + await asyncio.sleep(1) + + # Click to open the dropdown. Dialogs are vertically aligned at 40%. + center_y = test_client._window_height_in_pixels * 0.4 + 5 + await test_client.click(0.5, center_y, sleep=1) + + # Click a bit below to select an option from the dropdown + await test_client.click(0.5, center_y + 50) + + opener = test_client.get_component(DialogOpener) + assert not opener.dialog_closed + + +async def test_esc_closes_dialog(): + def build(): + return DialogOpener( + lambda: rio.Text("foo", align_x=0.5, align_y=0.5), + ) + + async with BrowserClient(build) as test_client: + await asyncio.sleep(0.1) + + await test_client.playwright_page.keyboard.press("Escape") + await asyncio.sleep(0.1) + + opener = test_client.get_component(DialogOpener) + assert opener.dialog_closed diff --git a/tests/test_layouting/__init__.py b/tests/test_frontend/test_layouting/__init__.py similarity index 100% rename from tests/test_layouting/__init__.py rename to tests/test_frontend/test_layouting/__init__.py diff --git a/tests/test_layouting/test_dev_tools.py b/tests/test_frontend/test_layouting/test_dev_tools.py similarity index 100% rename from tests/test_layouting/test_dev_tools.py rename to tests/test_frontend/test_layouting/test_dev_tools.py diff --git a/tests/test_layouting/test_flow_container.py b/tests/test_frontend/test_layouting/test_flow_container.py similarity index 100% rename from tests/test_layouting/test_flow_container.py rename to tests/test_frontend/test_layouting/test_flow_container.py diff --git a/tests/test_layouting/test_linear_containers.py b/tests/test_frontend/test_layouting/test_linear_containers.py similarity index 100% rename from tests/test_layouting/test_linear_containers.py rename to tests/test_frontend/test_layouting/test_linear_containers.py diff --git a/tests/test_layouting/test_popup.py b/tests/test_frontend/test_layouting/test_popup.py similarity index 100% rename from tests/test_layouting/test_popup.py rename to tests/test_frontend/test_layouting/test_popup.py diff --git a/tests/test_layouting/test_scroll_container.py b/tests/test_frontend/test_layouting/test_scroll_container.py similarity index 100% rename from tests/test_layouting/test_scroll_container.py rename to tests/test_frontend/test_layouting/test_scroll_container.py diff --git a/tests/test_layouting/test_stack.py b/tests/test_frontend/test_layouting/test_stack.py similarity index 100% rename from tests/test_layouting/test_stack.py rename to tests/test_frontend/test_layouting/test_stack.py diff --git a/tests/test_layouting/test_text.py b/tests/test_frontend/test_layouting/test_text.py similarity index 100% rename from tests/test_layouting/test_text.py rename to tests/test_frontend/test_layouting/test_text.py diff --git a/tests/test_page_views.py b/tests/test_page_views.py index 9f2bdb33..0066b0de 100644 --- a/tests/test_page_views.py +++ b/tests/test_page_views.py @@ -19,7 +19,7 @@ async def test_one_page_view() -> None: ], ) - async with rio.testing.TestClient(app) as test_client: + async with rio.testing.DummyClient(app) as test_client: # Make sure the Spacer (which is located on the home page) exists test_client.get_component(rio.Spacer) @@ -49,6 +49,6 @@ async def test_nested_page_views() -> None: ], ) - async with rio.testing.TestClient(app) as test_client: + async with rio.testing.DummyClient(app) as test_client: # Make sure the Spacer (which is located on the innermost page) exists test_client.get_component(rio.Spacer) diff --git a/tests/test_reconciliation.py b/tests/test_reconciliation.py index d86fdc98..aabd8911 100644 --- a/tests/test_reconciliation.py +++ b/tests/test_reconciliation.py @@ -11,7 +11,7 @@ async def test_reconciliation(): else: return rio.TextInput(min_height=15, is_secret=True) - async with rio.testing.TestClient(Toggler) as test_client: + async with rio.testing.DummyClient(Toggler) as test_client: toggler = test_client.get_component(Toggler) text_input = test_client.get_component(rio.TextInput) @@ -57,7 +57,7 @@ async def test_reconcile_instance_with_itself() -> None: def build() -> rio.Component: return Container(rio.Text("foo")) - async with rio.testing.TestClient( + async with rio.testing.DummyClient( build, use_ordered_dirty_set=True ) as test_client: container = test_client.get_component(Container) @@ -85,8 +85,8 @@ async def test_reconcile_same_component_instance(): def build(): return rio.Container(rio.Text("Hello")) - async with rio.testing.TestClient(build) as test_client: - test_client._outgoing_messages.clear() + async with rio.testing.DummyClient(build) as test_client: + test_client._received_messages.clear() root_component = test_client.get_component(rio.Container) await root_component._force_refresh() @@ -97,7 +97,7 @@ async def test_reconcile_same_component_instance(): # root_component to refresh, it's reasonable to send that component's # data to JS. assert ( - not test_client._outgoing_messages + not test_client._received_messages or test_client._last_updated_components == {root_component} ) @@ -121,7 +121,7 @@ async def test_reconcile_unusual_types(): def build(self): return rio.Text(self.text) - async with rio.testing.TestClient(Container) as test_client: + async with rio.testing.DummyClient(Container) as test_client: root_component = test_client.get_component(Container) # As long as this doesn't crash, it's fine @@ -138,7 +138,7 @@ async def test_reconcile_by_key(): else: return rio.Container(rio.Text("World", key="foo")) - async with rio.testing.TestClient(Toggler) as test_client: + async with rio.testing.DummyClient(Toggler) as test_client: root_component = test_client.get_component(Toggler) text = test_client.get_component(rio.Text) @@ -158,7 +158,7 @@ async def test_key_prevents_structural_match(): else: return rio.Text("World", key="foo") - async with rio.testing.TestClient(Toggler) as test_client: + async with rio.testing.DummyClient(Toggler) as test_client: root_component = test_client.get_component(Toggler) text = test_client.get_component(rio.Text) @@ -175,7 +175,7 @@ async def test_key_interrupts_structure(): def build(self): return rio.Container(rio.Text(self.key_), key=self.key_) - async with rio.testing.TestClient(Toggler) as test_client: + async with rio.testing.DummyClient(Toggler) as test_client: root_component = test_client.get_component(Toggler) text = test_client.get_component(rio.Text) @@ -200,7 +200,7 @@ async def test_structural_matching_inside_keyed_component(): rio.Container(rio.Text("C"), key="foo"), ) - async with rio.testing.TestClient(Toggler) as test_client: + async with rio.testing.DummyClient(Toggler) as test_client: root_component = test_client.get_component(Toggler) text = test_client.get_component(rio.Text) @@ -231,7 +231,7 @@ async def test_key_matching_inside_keyed_component(): key="row", ) - async with rio.testing.TestClient(Toggler) as test_client: + async with rio.testing.DummyClient(Toggler) as test_client: root_component = test_client.get_component(Toggler) text = test_client.get_component(rio.Text) @@ -259,7 +259,7 @@ async def test_same_key_on_different_component_type(): else: return ComponentWithText("World", key="foo") - async with rio.testing.TestClient(Toggler) as test_client: + async with rio.testing.DummyClient(Toggler) as test_client: root_component = test_client.get_component(Toggler) text = test_client.get_component(rio.Text) @@ -276,7 +276,7 @@ async def test_text_reconciliation(): def build(self) -> rio.Component: return rio.Text(self.text) - async with rio.testing.TestClient(RootComponent) as test_client: + async with rio.testing.DummyClient(RootComponent) as test_client: root = test_client.get_component(RootComponent) text = test_client.get_component(rio.Text) @@ -294,7 +294,7 @@ async def test_grid_reconciliation(): rows = [[rio.Text(f"Row {n}")] for n in range(self.num_rows)] return rio.Grid(*rows) - async with rio.testing.TestClient(RootComponent) as test_client: + async with rio.testing.DummyClient(RootComponent) as test_client: root = test_client.get_component(RootComponent) grid = test_client.get_component(rio.Grid) @@ -323,7 +323,7 @@ async def test_margin_reconciliation(): rio.Text("hi", margin=1), ) - async with rio.testing.TestClient(RootComponent) as test_client: + async with rio.testing.DummyClient(RootComponent) as test_client: root = test_client.get_component(RootComponent) texts = list(test_client.get_components(rio.Text)) diff --git a/tests/test_refresh.py b/tests/test_refresh.py index f3e88e56..187c0c82 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -5,8 +5,8 @@ async def test_refresh_with_nothing_to_do() -> None: def build() -> rio.Component: return rio.Text("Hello") - async with rio.testing.TestClient(build) as test_client: - test_client._outgoing_messages.clear() + async with rio.testing.DummyClient(build) as test_client: + test_client._received_messages.clear() await test_client.refresh() assert not test_client._dirty_components @@ -18,7 +18,7 @@ async def test_refresh_with_clean_root_component() -> None: text_component = rio.Text("Hello") return rio.Container(text_component) - async with rio.testing.TestClient(build) as test_client: + async with rio.testing.DummyClient(build) as test_client: text_component = test_client.get_component(rio.Text) text_component.text = "World" @@ -47,7 +47,7 @@ async def test_rebuild_component_with_dead_parent() -> None: def build() -> rio.Component: return ChildUnmounter(ComponentWithState("Hello")) - async with rio.testing.TestClient( + async with rio.testing.DummyClient( build, use_ordered_dirty_set=True ) as test_client: # Change the component's state, but also remove it from the component @@ -80,7 +80,7 @@ async def test_unmount_and_remount() -> None: show_child=True, ) - async with rio.testing.TestClient(build) as test_client: + async with rio.testing.DummyClient(build) as test_client: root_component = test_client.get_component(DemoComponent) child_component = root_component.content row_component = test_client.get_component(rio.Row) @@ -125,7 +125,7 @@ async def test_rebuild_component_with_dead_builder(): def build(self) -> rio.Component: return rio.Text(self.state) - async with rio.testing.TestClient(ChildToggler) as test_client: + async with rio.testing.DummyClient(ChildToggler) as test_client: toggler = test_client.get_component(ChildToggler) stateful_component = test_client.get_component(StatefulComponent) @@ -137,9 +137,9 @@ async def test_rebuild_component_with_dead_builder(): stateful_component.state = "bye" - test_client._outgoing_messages.clear() + test_client._received_messages.clear() await test_client.refresh() - assert not test_client._outgoing_messages + assert not test_client._received_messages async def test_changing_children_of_not_dirty_high_level_component(): @@ -174,7 +174,7 @@ async def test_changing_children_of_not_dirty_high_level_component(): def build(self) -> rio.Component: return self.content - async with rio.testing.TestClient(HighLevelComponent1) as test_client: + async with rio.testing.DummyClient(HighLevelComponent1) as test_client: root_component = test_client.get_component(HighLevelComponent1) text_component = test_client.get_component(rio.Text) @@ -204,13 +204,13 @@ async def test_binding_doesnt_update_children() -> None: rio.Text(self.text), ) - async with rio.testing.TestClient(ComponentWithBinding) as test_client: + async with rio.testing.DummyClient(ComponentWithBinding) as test_client: root_component = test_client.get_component(ComponentWithBinding) text_input = test_client.get_component(rio.TextInput) text = test_client.get_component(rio.Text) # Note: `text_input._on_message_` automatically triggers a refresh - test_client._outgoing_messages.clear() + test_client._received_messages.clear() await text_input._on_message_({"type": "confirm", "text": "hello"}) # Only the Text component has changed in this rebuild diff --git a/tests/test_session.py b/tests/test_session.py index 7e521b35..6f5838d6 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,7 +4,7 @@ import rio.testing async def test_client_attachments(): - async with rio.testing.TestClient() as test_client: + async with rio.testing.DummyClient() as test_client: session = test_client.session list1 = ["foo", "bar"] @@ -18,7 +18,7 @@ async def test_client_attachments(): async def test_access_nonexistent_session_attachment(): - async with rio.testing.TestClient() as test_client: + async with rio.testing.DummyClient() as test_client: with pytest.raises(KeyError): test_client.session[list] @@ -30,7 +30,7 @@ async def test_default_attachments(): dict_attachment = {"foo": "bar"} settings_attachment = Settings(3) - async with rio.testing.TestClient( + async with rio.testing.DummyClient( default_attachments=[dict_attachment, settings_attachment] ) as test_client: session = test_client.session diff --git a/tests/test_testing_tools.py b/tests/test_testing_tools.py index f3cad6af..f4434139 100644 --- a/tests/test_testing_tools.py +++ b/tests/test_testing_tools.py @@ -4,7 +4,7 @@ import rio.testing async def test_active_page_url(): url = "foo/bar" - async with rio.testing.TestClient(active_url=url) as test_client: + async with rio.testing.DummyClient(active_url=url) as test_client: assert test_client.session.active_page_url.path == "/" + url @@ -12,7 +12,7 @@ async def test_crashed_build_functions_are_tracked(): def build() -> rio.Component: return 3 # type: ignore - async with rio.testing.TestClient(build) as test_client: + async with rio.testing.DummyClient(build) as test_client: assert len(test_client.crashed_build_functions) == 1 @@ -26,7 +26,7 @@ async def test_rebuild_resets_crashed_build_functions(): else: return rio.Text("hi") - async with rio.testing.TestClient(CrashingComponent) as test_client: + async with rio.testing.DummyClient(CrashingComponent) as test_client: assert len(test_client.crashed_build_functions) == 1 crashing_component = test_client.get_component(CrashingComponent) diff --git a/tests/test_user_settings.py b/tests/test_user_settings.py index 98591456..62affdbc 100644 --- a/tests/test_user_settings.py +++ b/tests/test_user_settings.py @@ -37,7 +37,7 @@ async def test_load_settings() -> None: "foo:bar": "baz", } - async with rio.testing.TestClient( + async with rio.testing.DummyClient( running_in_window=False, default_attachments=(RootSettings(), FooSettings()), user_settings=user_settings, @@ -63,7 +63,7 @@ async def test_load_settings_file(monkeypatch: MonkeyPatch) -> None: lambda _: FakeFile('{"foo": "bar", "section:foo": {"bar": "baz"} }'), ) - async with rio.testing.TestClient( + async with rio.testing.DummyClient( running_in_window=True, default_attachments=(RootSettings(), FooSettings()), ) as test_client: diff --git a/tests/test_zzz_guardrails.py b/tests/test_zzz_guardrails.py index 16138d41..10be63aa 100644 --- a/tests/test_zzz_guardrails.py +++ b/tests/test_zzz_guardrails.py @@ -34,7 +34,7 @@ async def test_type_checking(): return rio.Spacer() - async with rio.testing.TestClient(build): + async with rio.testing.DummyClient(build): pass @@ -51,7 +51,7 @@ async def test_type_checking_error(func_): return rio.Spacer() - async with rio.testing.TestClient(build): + async with rio.testing.DummyClient(build): pass @@ -69,7 +69,7 @@ async def test_component_class_can_be_used_as_build_function( def build(): return rio.PageView(fallback_build=component_cls) - async with rio.testing.TestClient(build): + async with rio.testing.DummyClient(build): pass @@ -112,7 +112,7 @@ async def test_init_cannot_read_state_properties(): def build(self) -> rio.Component: return IllegalComponent(17) - async with rio.testing.TestClient(Container): + async with rio.testing.DummyClient(Container): assert init_executed assert accessing_foo_raised_exception assert accessing_margin_top_raised_exception diff --git a/tests/utils/layouting.py b/tests/utils/layouting.py index 1a23af48..4f9d83b8 100644 --- a/tests/utils/layouting.py +++ b/tests/utils/layouting.py @@ -1,108 +1,15 @@ from __future__ import annotations -import asyncio import typing as t -import asyncio_atexit -import playwright.async_api -import uvicorn - -import rio.app_server import rio.data_models -from rio.app_server import FastapiServer -from rio.components.text import Text from rio.debug.layouter import Layouter -from rio.utils import choose_free_port +from rio.testing import BrowserClient -__all__ = ["BrowserClient", "verify_layout", "setup", "cleanup"] +__all__ = ["verify_layout"] -# For debugging. Set this to a number > 0 if you want to look at the browser. -# -# Note: Chrome's console doesn't show `console.debug` messages per default. To -# see them, click on "All levels" and check "Verbose". -DEBUG_SHOW_BROWSER_DURATION = 0 - - -server_manager: ServerManager | None = None - - -async def _get_server_manager() -> ServerManager: - global server_manager - - if server_manager is None: - server_manager = ServerManager() - await server_manager.start() - - return server_manager - - -async def setup() -> None: - """ - The functions in this module require a fairly expensive one-time setup, - which often causes tests to fail because they exceed their timeout. Calling - this function in a fixture solves that problem. - """ - await _get_server_manager() - - -async def cleanup() -> None: - if server_manager is not None: - await server_manager.stop() - - -class BrowserClient: - def __init__( - self, build: t.Callable[[], rio.Component], *, debug_mode: bool = False - ) -> None: - self._build = build - self._debug_mode = debug_mode - self._session: rio.Session | None = None - self._page: playwright.async_api.Page | None = None - - @property - def session(self) -> rio.Session: - assert self._session is not None - return self._session - - async def execute_js(self, js: str) -> t.Any: - assert self._page is not None - return await self._page.evaluate(js) - - async def __aenter__(self) -> BrowserClient: - manager = await _get_server_manager() - - assert not manager.app_server.sessions, ( - f"App server still has sessions?! {manager.app_server.sessions}" - ) - - manager.app._build = self._build - manager.app_server.debug_mode = self._debug_mode - - self._page = await manager.new_page() - await self._page.goto(f"http://localhost:{manager.port}") - - while not manager.app_server.sessions: - await asyncio.sleep(0.1) - - self._session = manager.app_server.sessions[0] - - return self - - async def __aexit__(self, *args: t.Any) -> None: - # Sleep to keep the browser open for debugging - await asyncio.sleep(DEBUG_SHOW_BROWSER_DURATION) - - if self._page is not None: - await self._page.close() - - if self._session is not None: - await self._session._close(close_remote_session=False) - - -async def verify_layout( - build: t.Callable[[], rio.Component], -) -> Layouter: +async def verify_layout(build: t.Callable[[], rio.Component]) -> Layouter: """ Rio contains two layout implementations: One on the client side, which determines the real layout of components, and a second one on the server @@ -137,129 +44,3 @@ async def verify_layout( ) return layouter - - -class ServerManager: - """ - This class is designed to efficiently create many `BrowserClient` objects - for different GUIs. Starting a web server and a browser every time you need - a `BrowserClient` has very high overhead, so this class re-uses the existing - ones when possible. - """ - - def __init__(self) -> None: - self.port = choose_free_port("localhost") - - self.app = rio.App( - build=rio.Spacer, - # JS reports incorrect sizes and positions for hidden elements, and - # so the tests end up failing because of the icon in the connection - # lost popup. I think it's because icons have a fixed size, but JS - # reports the size as 0x0. So we'll get rid of the icon. - build_connection_lost_message=build_connection_lost_message, - ) - self._app_server: FastapiServer | None = None - self._uvicorn_server: uvicorn.Server | None = None - self._uvicorn_serve_task: asyncio.Task[None] | None = None - self._playwright: playwright.async_api.Playwright | None = None - self._browser: playwright.async_api.Browser | None = None - self._browser_context: playwright.async_api.BrowserContext | None = None - - @property - def app_server(self) -> FastapiServer: - assert self._app_server is not None - return self._app_server - - async def new_page(self) -> playwright.async_api.Page: - assert self._browser_context is not None - return await self._browser_context.new_page() - - async def start(self) -> None: - asyncio_atexit.register(self.stop) - - await self._start_browser() - await self._start_uvicorn_server() - - async def stop(self) -> None: - if self._browser_context is not None: - await self._browser_context.close() - - if self._browser is not None: - await self._browser.close() - - if self._playwright is not None: - await self._playwright.stop() - - if self._uvicorn_server is not None: - self._uvicorn_server.should_exit = True - - assert self._uvicorn_serve_task is not None - await self._uvicorn_serve_task - - async def _start_uvicorn_server(self) -> None: - server_is_ready_event = asyncio.Event() - loop = asyncio.get_running_loop() - - def set_server_ready_event() -> None: - loop.call_soon_threadsafe(server_is_ready_event.set) - - self._app_server = FastapiServer( - self.app, - debug_mode=False, - running_in_window=False, - internal_on_app_start=set_server_ready_event, - base_url=None, - ) - - config = uvicorn.Config( - self._app_server, - port=self.port, - log_level="critical", - ) - self._uvicorn_server = uvicorn.Server(config) - - current_task: asyncio.Task = asyncio.current_task() # type: ignore - self._uvicorn_serve_task = asyncio.create_task( - self._run_uvicorn(current_task) - ) - - await server_is_ready_event.wait() - - # Just because uvicorn says it's ready doesn't mean it's actually ready. - # Give it a bit more time. - await asyncio.sleep(1) - - async def _run_uvicorn(self, test_task: asyncio.Task) -> None: - assert self._uvicorn_server is not None - - try: - await self._uvicorn_server.serve() - except BaseException as error: - test_task.cancel(f"Uvicorn server crashed: {error}") - - async def _start_browser(self) -> None: - self._playwright = await playwright.async_api.async_playwright().start() - - try: - self._browser = await self._playwright.chromium.launch( - headless=DEBUG_SHOW_BROWSER_DURATION == 0 - ) - except Exception: - raise Exception( - "Playwright cannot launch chromium. Please execute the" - " following command:\n" - "playwright install --with-deps chromium\n" - "(If you're using a virtual environment, activate it first.)" - ) from None - - # With default settings, playwright gets detected as a crawler. So we - # need to emulate a real device. - kwargs = dict(self._playwright.devices["Desktop Chrome"]) - # The default window size is too large to fit on my screen, which sucks - # when debugging. Make it smaller. - kwargs["viewport"] = {"width": 800, "height": 600} - self._browser_context = await self._browser.new_context(**kwargs) - - -def build_connection_lost_message() -> Text: - return rio.Text("Connection Lost")