mirror of
https://github.com/rio-labs/rio.git
synced 2025-12-21 12:59:32 -06:00
refactor testing framework
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -122,4 +122,5 @@ artifacts = ["rio/frontend files/*"]
|
||||
line-length = 80
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
default_async_timeout = 30
|
||||
filterwarnings = ["ignore::rio.warnings.RioPotentialMistakeWarning"]
|
||||
|
||||
@@ -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?"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
3
rio/testing/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .base_client import *
|
||||
from .browser_client import *
|
||||
from .dummy_client import *
|
||||
@@ -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()
|
||||
296
rio/testing/browser_client.py
Normal file
296
rio/testing/browser_client.py
Normal 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")
|
||||
73
rio/testing/dummy_client.py
Normal file
73
rio/testing/dummy_client.py
Normal 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)
|
||||
@@ -1,3 +1,4 @@
|
||||
from .abstract_transport import *
|
||||
from .fastapi_websocket_transport import *
|
||||
from .message_recorder_transport import *
|
||||
from .multi_transport import *
|
||||
|
||||
45
rio/transports/multi_transport.py
Normal file
45
rio/transports/multi_transport.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
91
tests/test_frontend/test_dialogs.py
Normal file
91
tests/test_frontend/test_dialogs.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user