refactor testing framework

This commit is contained in:
Aran-Fey
2025-03-29 16:14:22 +01:00
parent 9dc6fb27f8
commit facad9d526
45 changed files with 869 additions and 462 deletions

View File

@@ -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

View File

@@ -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<ListViewState> {
private clickHandlers: Map<string | number, (event: MouseEvent) => void> =
new Map();
private clickHandlers: Map<Key, (event: Event) => void> = new Map();
createElement(): HTMLElement {
let element = document.createElement("div");
@@ -48,8 +52,8 @@ export class ListViewComponent extends ComponentBase<ListViewState> {
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<ListViewState> {
}
}
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<ListViewState> {
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<ListViewState> {
// 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;
}

View File

@@ -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();
}
}

View File

@@ -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<UnittestClientLayou
// Dump recursively, starting with the root component
let rootComponent = getRootComponent();
// Invisible elements produce a size of (0, 0), which is wrong. The usual
// culprit here is the "connection lost" popup, so we'll temporarily make it
// visible.
setConnectionLostPopupVisibleUnlessGoingAway(true);
dumpComponentRecursively(rootComponent, result.componentLayouts);
setConnectionLostPopupVisibleUnlessGoingAway(false);
// Done!
return result;

View File

@@ -122,4 +122,5 @@ artifacts = ["rio/frontend files/*"]
line-length = 80
[tool.pytest.ini_options]
default_async_timeout = 30
filterwarnings = ["ignore::rio.warnings.RioPotentialMistakeWarning"]

View File

@@ -35,7 +35,11 @@ from .. import (
utils,
)
from ..errors import AssetError
from ..transports import FastapiWebsocketTransport, MessageRecorderTransport
from ..transports import (
AbstractTransport,
FastapiWebsocketTransport,
MessageRecorderTransport,
)
from ..utils import URL
from .abstract_app_server import AbstractAppServer
@@ -289,6 +293,13 @@ class FastapiServer(fastapi.FastAPI, AbstractAppServer):
str, asyncio.Future[list[utils.FileInfo]]
] = timer_dict.TimerDict(default_duration=timedelta(minutes=15))
# A function that takes a websocket as input and returns a transport.
# This allows unit tests to override our transport, since they need
# access to the sent/received messages.
self._transport_factory: t.Callable[
[fastapi.WebSocket], AbstractTransport
] = FastapiWebsocketTransport
# FastAPI
self.add_api_route("/robots.txt", self._serve_robots, methods=["GET"])
self.add_api_route(
@@ -990,7 +1001,7 @@ Sitemap: {base_url / "rio/sitemap.xml"}
return
# Replace the session's websocket
transport = FastapiWebsocketTransport(websocket)
transport = self._transport_factory(websocket)
await sess._replace_rio_transport(transport)
# Make sure the client is in sync with the server by refreshing
@@ -998,7 +1009,7 @@ Sitemap: {base_url / "rio/sitemap.xml"}
await sess._send_all_components_on_reconnect()
else:
transport = FastapiWebsocketTransport(websocket)
transport = self._transport_factory(websocket)
try:
sess = await self._create_session_from_websocket(
@@ -1012,10 +1023,6 @@ Sitemap: {base_url / "rio/sitemap.xml"}
self._active_session_tokens[session_token] = sess
self._active_tokens_by_session[sess] = session_token
# Trigger a refresh. This will also send the initial state to
# the frontend.
await sess._refresh()
# Apparently the websocket becomes unusable as soon as this function
# exits, so we must wait until we no longer need the websocket.
#
@@ -1031,7 +1038,7 @@ Sitemap: {base_url / "rio/sitemap.xml"}
session_token: str,
request: fastapi.Request,
websocket: fastapi.WebSocket,
transport: FastapiWebsocketTransport,
transport: AbstractTransport,
) -> rio.Session:
assert request.client is not None, "Why can this happen?"

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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`
```

View File

@@ -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

View File

@@ -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:

3
rio/testing/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .base_client import *
from .browser_client import *
from .dummy_client import *

View File

@@ -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()

View File

@@ -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")

View File

@@ -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)

View File

@@ -1,3 +1,4 @@
from .abstract_transport import *
from .fastapi_websocket_transport import *
from .message_recorder_transport import *
from .multi_transport import *

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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")