diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fd72f06..2a4f73d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -8,9 +8,24 @@ on: workflow_dispatch: jobs: + coverage: + # Runs first; fast static check that gates the long device jobs so + # missing demos / flows fail before the emulator boots. + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Check E2E coverage of pythonnative.__all__ + run: python scripts/check-e2e-coverage.py + e2e-android: + needs: coverage runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Checkout @@ -46,12 +61,12 @@ jobs: with: api-level: 31 arch: x86_64 - script: >- - bash -lc "cd examples/hello-world && pn run android --no-logs && cd ../.. && maestro test tests/e2e/android.yaml" + script: bash -lc "./scripts/run-e2e.sh android" e2e-ios: + needs: coverage runs-on: macos-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Checkout @@ -71,11 +86,7 @@ jobs: echo "$HOME/.maestro/bin" >> $GITHUB_PATH brew tap facebook/fb && brew install idb-companion - - name: Build and launch iOS app - working-directory: examples/hello-world - run: pn run ios --no-logs + - name: Build and run E2E tests + run: ./scripts/run-e2e.sh ios env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Run E2E tests - run: maestro --platform ios test tests/e2e/ios.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20f75e2..6b5f78d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,10 +35,13 @@ cd examples/hello-world && pn run android - `src/pythonnative/` – installable library and CLI - `pythonnative/` – core cross‑platform UI components and utilities - `cli/` – `pn` command -- `tests/` – unit tests for the library +- `tests/` – unit tests for the library, plus the Maestro E2E suite + - `e2e/` – the comprehensive E2E suite (see [E2E tests](#e2e-tests-maestro) below and `tests/e2e/AGENTS.md`) - `templates/` – Android/iOS project templates and zips - `examples/` – runnable example apps - - `hello-world/` – minimal demo app using the library + - `hello-world/` – minimal marketing demo + - `e2e-suite/` – comprehensive feature catalog that drives the Maestro E2E suite +- `scripts/` – helper scripts (`check.sh`, `run-e2e.sh`, `check-e2e-coverage.py`) - `README.md`, `pyproject.toml` – repo docs and packaging ## Coding guidelines @@ -269,7 +272,9 @@ fix/cli-regression ### E2E tests (Maestro) -End-to-end tests use [Maestro](https://maestro.dev/) to drive the hello-world example on real emulators and simulators. +End-to-end tests use [Maestro](https://maestro.dev/) to drive the dedicated `examples/e2e-suite` app on real emulators and simulators. That app contains one screen per public symbol in `pythonnative.__all__`; every flow under `tests/e2e/flows//` exercises one symbol. + +The dedicated `examples/hello-world` app is left in place as a small marketing demo; it is **not** the E2E target. ```bash # Install Maestro (one-time) @@ -279,21 +284,36 @@ curl -Ls "https://get.maestro.mobile.dev" | bash brew tap facebook/fb && brew install idb-companion ``` -Build and launch the app first, then run the tests: +Build and run everything via the convenience script: ```bash -cd examples/hello-world - # Android (emulator must be running) -pn run android -maestro test ../../tests/e2e/android.yaml +./scripts/run-e2e.sh android + +# iOS (simulator must be running) +./scripts/run-e2e.sh ios +``` + +For tight iteration, run a single category instead of the full pass: -# iOS (simulator must be running; --platform ios needed when an Android emulator is also connected) -pn run ios -maestro --platform ios test ../../tests/e2e/ios.yaml +```bash +./scripts/run-e2e.sh android hooks +./scripts/run-e2e.sh ios components ``` -Test flows live in `tests/e2e/flows/` and cover the main screen rendering, counter interaction, and multi-screen navigation. The `e2e.yml` workflow runs these automatically on pushes to `main` and PRs. +Available categories: `components`, `hooks`, `navigation`, `layout`, `styling`, `animations`, `misc`. + +A coverage checker, `scripts/check-e2e-coverage.py`, gates CI: every name in `pythonnative.__all__` must be covered by a demo + flow, or listed in `INTENTIONAL_EXEMPTIONS` with a justification. + +When you add a new public symbol you must also: + +1. Add a demo screen under `examples/e2e-suite/app/screens//`. +2. Append a `DemoEntry` in `examples/e2e-suite/app/registry.py`. +3. Add a Maestro flow at `tests/e2e/flows//.yaml`. +4. Append the flow to the top-level `tests/e2e/android.yaml`, `tests/e2e/ios.yaml`, and the matching `tests/e2e/suites/.yaml`. +5. Confirm `python scripts/check-e2e-coverage.py` exits 0. + +`tests/e2e/AGENTS.md` is the deeper reference (label conventions, failure triage, naming rules); AI agents should read it before touching the suite. The `e2e.yml` workflow runs the suite automatically on pushes to `main` and PRs. ### CI diff --git a/examples/e2e-suite/.gitignore b/examples/e2e-suite/.gitignore new file mode 100644 index 0000000..04c47c5 --- /dev/null +++ b/examples/e2e-suite/.gitignore @@ -0,0 +1,4 @@ +build/ +__pycache__/ +*.pyc +.DS_Store diff --git a/examples/e2e-suite/README.md b/examples/e2e-suite/README.md new file mode 100644 index 0000000..36b3c9f --- /dev/null +++ b/examples/e2e-suite/README.md @@ -0,0 +1,37 @@ +# PythonNative E2E Suite + +A comprehensive demo app that exercises every public feature in `pythonnative`. It is the target of the top-level Maestro E2E suite and doubles as a living reference for the framework's surface area. + +Unlike `examples/hello-world`, this app is not a marketing demo: it is structured for automated testing. Each PythonNative feature gets a dedicated screen that: + +- Renders a stable, unique title (so Maestro can wait for the screen to appear). +- Exposes interactive controls with stable, unique labels (so Maestro can tap them). +- Prints a "Result:" line that reflects the feature's state (so Maestro can assert behavior, not just rendering). + +## Running locally + +From the repo root: + +```bash +cd examples/e2e-suite +pn run android # or: pn run ios +``` + +Then, in another shell: + +```bash +# Android +maestro test tests/e2e/android.yaml + +# iOS (use --platform ios if Android is also connected) +maestro --platform ios test tests/e2e/ios.yaml +``` + +## Adding a new feature demo + +1. Add a screen module under `app/screens//.py` exporting a `pn.component`-decorated function. +2. Register it in `app/registry.py` with a unique `id`. +3. Add a Maestro flow at `tests/e2e/flows//.yaml` that opens the screen via its registry `id` and asserts the expected behavior. +4. Re-run `scripts/check-e2e-coverage.py` to make sure every public symbol in `pythonnative.__all__` is covered by a flow. + +See `tests/e2e/AGENTS.md` for a deeper tour of how AI agents should interact with this suite. diff --git a/examples/e2e-suite/app/__init__.py b/examples/e2e-suite/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/main.py b/examples/e2e-suite/app/main.py new file mode 100644 index 0000000..5883105 --- /dev/null +++ b/examples/e2e-suite/app/main.py @@ -0,0 +1,39 @@ +"""E2E suite entry point. + +Wires every demo screen from :mod:`app.registry` into the root +[`Stack.Navigator`][pythonnative.create_stack_navigator]. The first +route, ``"Home"``, is a categorized list of buttons that opens the +rest of the demos. Each demo screen owns its own back navigation via +[`use_navigation().go_back()`][pythonnative.use_navigation]. + +The stack-only architecture keeps the navigation surface flat and +predictable for automated tests: every demo is reachable in exactly +one push, and every back press lands the user back on ``"Home"``. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.registry import DEMOS +from app.screens.category import CategoryListScreen +from app.screens.home import HomeScreen + +print("[e2e-suite] main module imported") + +Stack = pn.create_stack_navigator() + + +@pn.component +def App() -> pn.Element: + """Root component: a Stack with Home, every Category screen, and every demo.""" + return pn.NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen, options={"title": "PythonNative E2E Suite"}), + Stack.Screen( + "Category", + component=CategoryListScreen, + options={"title": "Category"}, + ), + *(Stack.Screen(demo.id, component=demo.component, options={"title": demo.title}) for demo in DEMOS), + ) + ) diff --git a/examples/e2e-suite/app/registry.py b/examples/e2e-suite/app/registry.py new file mode 100644 index 0000000..277712d --- /dev/null +++ b/examples/e2e-suite/app/registry.py @@ -0,0 +1,356 @@ +"""Registry of every demo screen in the E2E suite. + +The registry is the single source of truth that maps a stable demo +``id`` to: + +- the navigation title shown in the platform nav bar, +- the category bucket (used to group demos on the home screen), +- the ``feature`` string — the PythonNative public symbol the demo + exercises (used by ``scripts/check-e2e-coverage.py``), +- the component function that renders the demo. + +Adding a new demo is a three-step process: + +1. Implement the screen in ``app/screens//.py``. +2. Append a ``DemoEntry`` to :data:`DEMOS` below. +3. Author a Maestro flow at ``tests/e2e/flows//.yaml``. + +``app/main.py`` consumes this list to wire every screen into the root +[`Stack.Navigator`][pythonnative.create_stack_navigator]. The +home screen ([`app.screens.home.HomeScreen`][]) also consumes it to +render a categorized list of buttons. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, List + +import pythonnative as pn +from app.screens.alerts.confirm_alert import ConfirmAlertDemo +from app.screens.alerts.simple_alert import SimpleAlertDemo +from app.screens.animations.parallel_animation import ParallelAnimationDemo +from app.screens.animations.sequence_animation import SequenceAnimationDemo +from app.screens.animations.spring_animation import SpringAnimationDemo +from app.screens.animations.timing_animation import TimingAnimationDemo +from app.screens.components.activity_indicator import ActivityIndicatorDemo +from app.screens.components.button import ButtonDemo +from app.screens.components.error_boundary import ErrorBoundaryDemo +from app.screens.components.flat_list import FlatListDemo +from app.screens.components.fragment import FragmentDemo +from app.screens.components.image import ImageDemo +from app.screens.components.keyboard_avoiding_view import KeyboardAvoidingViewDemo +from app.screens.components.modal import ModalDemo +from app.screens.components.picker import PickerDemo +from app.screens.components.pressable import PressableDemo +from app.screens.components.progress_bar import ProgressBarDemo +from app.screens.components.refresh_control import RefreshControlDemo +from app.screens.components.safe_area_view import SafeAreaViewDemo +from app.screens.components.scroll_view import ScrollViewDemo +from app.screens.components.section_list import SectionListDemo +from app.screens.components.slider import SliderDemo +from app.screens.components.spacer import SpacerDemo +from app.screens.components.status_bar import StatusBarDemo +from app.screens.components.switch import SwitchDemo +from app.screens.components.text import TextDemo +from app.screens.components.text_input import TextInputDemo +from app.screens.components.view_column_row import ViewColumnRowDemo +from app.screens.components.web_view import WebViewDemo +from app.screens.hooks.batch_updates_demo import BatchUpdatesDemo +from app.screens.hooks.memo_demo import MemoDemo +from app.screens.hooks.use_async_effect import UseAsyncEffectDemo +from app.screens.hooks.use_callback import UseCallbackDemo +from app.screens.hooks.use_context import UseContextDemo +from app.screens.hooks.use_effect import UseEffectDemo +from app.screens.hooks.use_memo import UseMemoDemo +from app.screens.hooks.use_mutation import UseMutationDemo +from app.screens.hooks.use_persisted_state import UsePersistedStateDemo +from app.screens.hooks.use_query import UseQueryDemo +from app.screens.hooks.use_reducer import UseReducerDemo +from app.screens.hooks.use_ref import UseRefDemo +from app.screens.hooks.use_state import UseStateDemo +from app.screens.hooks.use_window_dimensions import UseWindowDimensionsDemo +from app.screens.layout.absolute_position import AbsolutePositionDemo +from app.screens.layout.alignment import AlignmentDemo +from app.screens.layout.aspect_ratio import AspectRatioDemo +from app.screens.layout.flex_layout import FlexLayoutDemo +from app.screens.layout.padding_margin import PaddingMarginDemo +from app.screens.navigation.drawer_navigator import DrawerNavigatorDemo +from app.screens.navigation.focus_effect import FocusEffectDemo +from app.screens.navigation.params_passing import ParamsPassingDemo +from app.screens.navigation.tab_navigator import TabNavigatorDemo +from app.screens.platform.platform_info import PlatformInfoDemo +from app.screens.runtime.run_async_demo import RunAsyncDemo +from app.screens.sdk.custom_component import CustomComponentDemo +from app.screens.storage.async_storage_demo import AsyncStorageDemo +from app.screens.styling.borders_shadows import BordersShadowsDemo +from app.screens.styling.stylesheet_demo import StyleSheetDemo +from app.screens.styling.transform import TransformDemo +from app.screens.styling.typography import TypographyDemo + + +@dataclass(frozen=True) +class DemoEntry: + """One demo screen in the registry. + + Attributes: + id: Unique, URL-safe identifier used as the Stack route name, + as the home-screen button label suffix, and as the Maestro + flow file name. ``snake_case``. + category: Bucket used to group demos on the home screen. + Mirrors the directory under ``app/screens/`` and + ``tests/e2e/flows/``. + title: Display title shown in the platform nav bar and in the + home-screen list entry. + feature: The ``pythonnative.__all__`` symbol the demo exercises + (e.g. ``"use_state"``). Used by the coverage checker. Use + ``"category::feature"`` form for sub-features that aren't + themselves listed in ``__all__`` (e.g. + ``"styling::transform"``). + component: The ``@pn.component`` function rendering the demo. + """ + + id: str + category: str + title: str + feature: str + component: Callable[[], pn.Element] + + +DEMOS: List[DemoEntry] = [ + # ------------------------------------------------------------------ + # Components + # ------------------------------------------------------------------ + DemoEntry("text", "Components", "Text", "Text", TextDemo), + DemoEntry("button", "Components", "Button", "Button", ButtonDemo), + DemoEntry("text_input", "Components", "TextInput", "TextInput", TextInputDemo), + DemoEntry("image", "Components", "Image", "Image", ImageDemo), + DemoEntry("switch", "Components", "Switch", "Switch", SwitchDemo), + DemoEntry("slider", "Components", "Slider", "Slider", SliderDemo), + DemoEntry("progress_bar", "Components", "ProgressBar", "ProgressBar", ProgressBarDemo), + DemoEntry( + "activity_indicator", + "Components", + "ActivityIndicator", + "ActivityIndicator", + ActivityIndicatorDemo, + ), + DemoEntry( + "view_column_row", + "Components", + "View / Column / Row", + "View", + ViewColumnRowDemo, + ), + DemoEntry("scroll_view", "Components", "ScrollView", "ScrollView", ScrollViewDemo), + DemoEntry("safe_area_view", "Components", "SafeAreaView", "SafeAreaView", SafeAreaViewDemo), + DemoEntry("modal", "Components", "Modal", "Modal", ModalDemo), + DemoEntry("pressable", "Components", "Pressable", "Pressable", PressableDemo), + DemoEntry("picker", "Components", "Picker", "Picker", PickerDemo), + DemoEntry( + "refresh_control", + "Components", + "RefreshControl", + "RefreshControl", + RefreshControlDemo, + ), + DemoEntry("fragment", "Components", "Fragment", "Fragment", FragmentDemo), + DemoEntry( + "error_boundary", + "Components", + "ErrorBoundary", + "ErrorBoundary", + ErrorBoundaryDemo, + ), + DemoEntry("spacer", "Components", "Spacer", "Spacer", SpacerDemo), + DemoEntry("status_bar", "Components", "StatusBar", "StatusBar", StatusBarDemo), + DemoEntry( + "keyboard_avoiding_view", + "Components", + "KeyboardAvoidingView", + "KeyboardAvoidingView", + KeyboardAvoidingViewDemo, + ), + DemoEntry("flat_list", "Components", "FlatList", "FlatList", FlatListDemo), + DemoEntry("section_list", "Components", "SectionList", "SectionList", SectionListDemo), + DemoEntry("web_view", "Components", "WebView", "WebView", WebViewDemo), + # ------------------------------------------------------------------ + # Hooks + # ------------------------------------------------------------------ + DemoEntry("use_state", "Hooks", "use_state", "use_state", UseStateDemo), + DemoEntry("use_effect", "Hooks", "use_effect", "use_effect", UseEffectDemo), + DemoEntry("use_reducer", "Hooks", "use_reducer", "use_reducer", UseReducerDemo), + DemoEntry("use_ref", "Hooks", "use_ref", "use_ref", UseRefDemo), + DemoEntry("use_memo", "Hooks", "use_memo", "use_memo", UseMemoDemo), + DemoEntry("use_callback", "Hooks", "use_callback", "use_callback", UseCallbackDemo), + DemoEntry("use_context", "Hooks", "use_context", "use_context", UseContextDemo), + DemoEntry( + "use_async_effect", + "Hooks", + "use_async_effect", + "use_async_effect", + UseAsyncEffectDemo, + ), + DemoEntry("use_query", "Hooks", "use_query", "use_query", UseQueryDemo), + DemoEntry("use_mutation", "Hooks", "use_mutation", "use_mutation", UseMutationDemo), + DemoEntry( + "use_persisted_state", + "Hooks", + "use_persisted_state", + "use_persisted_state", + UsePersistedStateDemo, + ), + DemoEntry( + "use_window_dimensions", + "Hooks", + "use_window_dimensions", + "use_window_dimensions", + UseWindowDimensionsDemo, + ), + DemoEntry("memo", "Hooks", "memo", "memo", MemoDemo), + DemoEntry("batch_updates", "Hooks", "batch_updates", "batch_updates", BatchUpdatesDemo), + # ------------------------------------------------------------------ + # Navigation + # ------------------------------------------------------------------ + DemoEntry( + "tab_navigator", + "Navigation", + "Tab Navigator", + "create_tab_navigator", + TabNavigatorDemo, + ), + DemoEntry( + "drawer_navigator", + "Navigation", + "Drawer Navigator", + "create_drawer_navigator", + DrawerNavigatorDemo, + ), + DemoEntry( + "params_passing", + "Navigation", + "Route Params", + "use_route", + ParamsPassingDemo, + ), + DemoEntry( + "focus_effect", + "Navigation", + "use_focus_effect", + "use_focus_effect", + FocusEffectDemo, + ), + # ------------------------------------------------------------------ + # Layout + # ------------------------------------------------------------------ + DemoEntry("flex_layout", "Layout", "Flex layout", "layout::flex", FlexLayoutDemo), + DemoEntry( + "aspect_ratio", + "Layout", + "Aspect ratio", + "layout::aspect_ratio", + AspectRatioDemo, + ), + DemoEntry( + "absolute_position", + "Layout", + "Absolute positioning", + "layout::absolute", + AbsolutePositionDemo, + ), + DemoEntry( + "padding_margin", + "Layout", + "Padding & margin", + "layout::spacing", + PaddingMarginDemo, + ), + DemoEntry("alignment", "Layout", "Alignment", "layout::alignment", AlignmentDemo), + # ------------------------------------------------------------------ + # Styling + # ------------------------------------------------------------------ + DemoEntry("typography", "Styling", "Typography", "styling::typography", TypographyDemo), + DemoEntry( + "borders_shadows", + "Styling", + "Borders & shadows", + "styling::borders", + BordersShadowsDemo, + ), + DemoEntry("transform", "Styling", "Transforms", "styling::transform", TransformDemo), + DemoEntry("stylesheet", "Styling", "StyleSheet", "StyleSheet", StyleSheetDemo), + # ------------------------------------------------------------------ + # Animations + # ------------------------------------------------------------------ + DemoEntry("timing_animation", "Animations", "Animated.timing", "Animated", TimingAnimationDemo), + DemoEntry( + "spring_animation", + "Animations", + "Animated.spring", + "animated::spring", + SpringAnimationDemo, + ), + DemoEntry( + "parallel_animation", + "Animations", + "Animated.parallel", + "animated::parallel", + ParallelAnimationDemo, + ), + DemoEntry( + "sequence_animation", + "Animations", + "Animated.sequence", + "animated::sequence", + SequenceAnimationDemo, + ), + # ------------------------------------------------------------------ + # Alerts, storage, runtime, platform, SDK + # ------------------------------------------------------------------ + DemoEntry("simple_alert", "Alerts", "Alert.show", "Alert", SimpleAlertDemo), + DemoEntry( + "confirm_alert", + "Alerts", + "Alert.confirm", + "alerts::confirm", + ConfirmAlertDemo, + ), + DemoEntry( + "async_storage", + "Storage", + "AsyncStorage", + "AsyncStorage", + AsyncStorageDemo, + ), + DemoEntry("run_async", "Runtime", "run_async", "run_async", RunAsyncDemo), + DemoEntry("platform_info", "Platform", "Platform info", "Platform", PlatformInfoDemo), + DemoEntry( + "custom_component", + "SDK", + "Custom component", + "native_component", + CustomComponentDemo, + ), +] + +CATEGORIES: List[str] = [] +for _demo in DEMOS: + if _demo.category not in CATEGORIES: + CATEGORIES.append(_demo.category) + + +def demos_for_category(category: str) -> List[DemoEntry]: + """Return all demos that belong to ``category``, preserving order.""" + return [d for d in DEMOS if d.category == category] + + +def feature_to_demo_id() -> dict: + """Map every covered feature string to its demo ``id``. + + Used by ``scripts/check-e2e-coverage.py`` to confirm that every + public symbol in ``pythonnative.__all__`` is touched by at least + one demo. Multiple demos covering the same feature is fine; the + coverage checker only cares about whether ``feature`` is in this + map. + """ + return {d.feature: d.id for d in DEMOS} diff --git a/examples/e2e-suite/app/screens/__init__.py b/examples/e2e-suite/app/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/alerts/__init__.py b/examples/e2e-suite/app/screens/alerts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/alerts/confirm_alert.py b/examples/e2e-suite/app/screens/alerts/confirm_alert.py new file mode 100644 index 0000000..c784acf --- /dev/null +++ b/examples/e2e-suite/app/screens/alerts/confirm_alert.py @@ -0,0 +1,42 @@ +"""Demo screen for [`pn.Alert.confirm`][pythonnative.Alert]. + +A confirm alert returns a bool. The demo stores the last response and +exposes it on a result line so Maestro can assert "Last response: +confirmed" or "cancelled". +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def ConfirmAlertDemo() -> pn.Element: + """Render a button that fires Alert.confirm; persist the latest response.""" + last, set_last = pn.use_state("(none)") + + async def _run() -> None: + ok = await pn.Alert.confirm( + "Confirm action", + message="Pick Confirm or Cancel.", + confirm_label="Confirm", + cancel_label="Cancel", + ) + set_last("confirmed" if ok else "cancelled") + + return demo_screen( + "Alert.confirm", + "Awaitable confirm alert; the response is shown below.", + section( + "Confirm", + result_text("Last response", last), + buttons_row( + pn.Button("Show confirm", on_click=lambda: pn.run_async(_run())), + ), + hint( + "Maestro taps 'Show confirm', taps 'Confirm', asserts 'Last response: confirmed'. " + "Repeat with 'Cancel'." + ), + ), + ) diff --git a/examples/e2e-suite/app/screens/alerts/simple_alert.py b/examples/e2e-suite/app/screens/alerts/simple_alert.py new file mode 100644 index 0000000..a896fb5 --- /dev/null +++ b/examples/e2e-suite/app/screens/alerts/simple_alert.py @@ -0,0 +1,28 @@ +"""Demo screen for [`pn.Alert.show`][pythonnative.Alert]. + +Shows a fire-and-forget native alert. Maestro taps the button, waits +for the alert title to appear, then dismisses it via "OK". +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def SimpleAlertDemo() -> pn.Element: + """Render a button that fires a simple ``pn.Alert.show`` alert.""" + + def _show() -> None: + pn.Alert.show("Hello!", "This is a native alert.") + + return demo_screen( + "Alert.show", + "Open a native alert dialog; dismiss with OK.", + section( + "Alert", + pn.Button("Show alert", on_click=_show), + hint("Maestro asserts 'Hello!' appears, then taps 'OK'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/animations/__init__.py b/examples/e2e-suite/app/screens/animations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/animations/parallel_animation.py b/examples/e2e-suite/app/screens/animations/parallel_animation.py new file mode 100644 index 0000000..6fb00a1 --- /dev/null +++ b/examples/e2e-suite/app/screens/animations/parallel_animation.py @@ -0,0 +1,49 @@ +"""Demo screen for [`pn.Animated.parallel`][pythonnative.Animated.parallel]. + +Runs a fade + scale in parallel and asserts the status flips to +"done" after both finish. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def ParallelAnimationDemo() -> pn.Element: + """Render a box with parallel fade + scale animations.""" + opacity = pn.use_animated_value(0.0) + scale = pn.use_animated_value(0.5) + status, set_status = pn.use_state("idle") + + async def run() -> None: + set_status("running") + await pn.Animated.parallel( + [ + pn.Animated.timing(opacity, to=1.0, duration=300), + pn.Animated.spring(scale, to=1.0, stiffness=180, damping=12), + ] + ) + set_status("done") + + return demo_screen( + "Animated.parallel", + "Fade + spring concurrently; status flips when both complete.", + section( + "Parallel demo", + result_text("Status", status), + pn.Animated.View( + pn.Text("parallel-box label", style=pn.style(color="#FFFFFF", font_weight="700")), + style=pn.style( + opacity=opacity, + scale=scale, + padding=20, + background_color="#F97316", + border_radius=12, + ), + ), + buttons_row(pn.Button("Run parallel", on_click=lambda: pn.run_async(run()))), + hint("Maestro taps 'Run parallel' and asserts 'Status: done'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/animations/sequence_animation.py b/examples/e2e-suite/app/screens/animations/sequence_animation.py new file mode 100644 index 0000000..f1aca6f --- /dev/null +++ b/examples/e2e-suite/app/screens/animations/sequence_animation.py @@ -0,0 +1,48 @@ +"""Demo screen for [`pn.Animated.sequence`][pythonnative.Animated.sequence]. + +Two timing animations chained in sequence (fade in, then fade out). +Maestro taps "Run sequence" and asserts the status flips to "done" +after both complete. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def SequenceAnimationDemo() -> pn.Element: + """Render a box with sequence (fade in, fade out) animations.""" + opacity = pn.use_animated_value(0.0) + status, set_status = pn.use_state("idle") + + async def run() -> None: + set_status("running") + await pn.Animated.sequence( + [ + pn.Animated.timing(opacity, to=1.0, duration=200), + pn.Animated.timing(opacity, to=0.3, duration=200), + ] + ) + set_status("done") + + return demo_screen( + "Animated.sequence", + "Chain two timings; status flips once the chain finishes.", + section( + "Sequence demo", + result_text("Status", status), + pn.Animated.View( + pn.Text("sequence-box label", style=pn.style(color="#FFFFFF", font_weight="700")), + style=pn.style( + opacity=opacity, + padding=20, + background_color="#7C3AED", + border_radius=12, + ), + ), + buttons_row(pn.Button("Run sequence", on_click=lambda: pn.run_async(run()))), + hint("Maestro taps 'Run sequence' and asserts 'Status: done'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/animations/spring_animation.py b/examples/e2e-suite/app/screens/animations/spring_animation.py new file mode 100644 index 0000000..66fc5e4 --- /dev/null +++ b/examples/e2e-suite/app/screens/animations/spring_animation.py @@ -0,0 +1,50 @@ +"""Demo screen for [`pn.Animated.spring`][pythonnative.Animated.spring]. + +Springs a box to scale=1.0 from 0.5 and back. Maestro taps "Run +spring" and asserts the status flips to "done" once the spring settles. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def SpringAnimationDemo() -> pn.Element: + """Render an animated box driven by Animated.spring.""" + scale = pn.use_animated_value(0.5) + status, set_status = pn.use_state("idle") + + async def run() -> None: + set_status("running") + await pn.Animated.spring(scale, to=1.0, stiffness=160, damping=12) + set_status("done") + + async def reset() -> None: + set_status("reset") + await pn.Animated.spring(scale, to=0.5, stiffness=160, damping=12) + set_status("idle") + + return demo_screen( + "Animated.spring", + "Spring a box up to full scale and back.", + section( + "Spring demo", + result_text("Status", status), + pn.Animated.View( + pn.Text("spring-box label", style=pn.style(color="#FFFFFF", font_weight="700")), + style=pn.style( + scale=scale, + padding=20, + background_color="#22C55E", + border_radius=12, + ), + ), + buttons_row( + pn.Button("Run spring", on_click=lambda: pn.run_async(run())), + pn.Button("Reset", on_click=lambda: pn.run_async(reset())), + ), + hint("Maestro taps 'Run spring' and asserts 'Status: done'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/animations/timing_animation.py b/examples/e2e-suite/app/screens/animations/timing_animation.py new file mode 100644 index 0000000..11a8399 --- /dev/null +++ b/examples/e2e-suite/app/screens/animations/timing_animation.py @@ -0,0 +1,51 @@ +"""Demo screen for [`pn.Animated.timing`][pythonnative.Animated.timing]. + +A button fades a labelled box from 0.0 to 1.0 opacity over 300 ms. A +second button resets it. Maestro taps the run button and asserts the +status line flips to "done". +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def TimingAnimationDemo() -> pn.Element: + """Render an animated box driven by Animated.timing.""" + opacity = pn.use_animated_value(0.0) + status, set_status = pn.use_state("idle") + + async def run() -> None: + set_status("running") + await pn.Animated.timing(opacity, to=1.0, duration=300) + set_status("done") + + async def reset() -> None: + set_status("reset") + await pn.Animated.timing(opacity, to=0.0, duration=150) + set_status("idle") + + return demo_screen( + "Animated.timing", + "Fade a box in/out via Animated.timing and surface the status.", + section( + "Timing demo", + result_text("Status", status), + pn.Animated.View( + pn.Text("timing-box label", style=pn.style(color="#FFFFFF", font_weight="700")), + style=pn.style( + opacity=opacity, + padding=24, + background_color="#0EA5E9", + border_radius=12, + ), + ), + buttons_row( + pn.Button("Run timing", on_click=lambda: pn.run_async(run())), + pn.Button("Reset", on_click=lambda: pn.run_async(reset())), + ), + hint("Maestro taps 'Run timing' and asserts 'Status: done'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/category.py b/examples/e2e-suite/app/screens/category.py new file mode 100644 index 0000000..dfd15ec --- /dev/null +++ b/examples/e2e-suite/app/screens/category.py @@ -0,0 +1,44 @@ +"""Per-category screen: lists every demo in a category as tappable buttons. + +The screen reads its ``name`` route param and renders a scrollable +column of ``"Open: "`` buttons. Maestro flows tap them by +exact title; new demos appear here automatically once they're added +to :data:`app.registry.DEMOS`. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.registry import demos_for_category +from app.theme import styles + + +@pn.component +def CategoryListScreen() -> pn.Element: + """Render every demo in the route's ``name`` category as a button.""" + nav = pn.use_navigation() + params = nav.get_params() + category: str = params.get("name", "Components") + demos = demos_for_category(category) + + def open_demo(demo_id: str) -> None: + nav.navigate(demo_id) + + return pn.ScrollView( + pn.Column( + pn.Text(f"Demos in {category}", style=styles["title"]), + pn.Text( + f"{len(demos)} demos in this category. Tap one to exercise the feature.", + style=styles["hint"], + ), + *[ + pn.Button( + f"Open: {demo.title}", + on_click=lambda _id=demo.id: open_demo(_id), + ) + for demo in demos + ], + pn.Button("Back to home", on_click=nav.go_back), + style=styles["screen"], + ) + ) diff --git a/examples/e2e-suite/app/screens/components/__init__.py b/examples/e2e-suite/app/screens/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/components/activity_indicator.py b/examples/e2e-suite/app/screens/components/activity_indicator.py new file mode 100644 index 0000000..ef2d380 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/activity_indicator.py @@ -0,0 +1,32 @@ +"""Demo screen for [`pn.ActivityIndicator`][pythonnative.ActivityIndicator]. + +The indicator animates by default. A button toggles it on/off so flows +can confirm that the ``animating`` prop wires to the underlying +``UIActivityIndicatorView`` / Android ``ProgressBar``. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@pn.component +def ActivityIndicatorDemo() -> pn.Element: + """Render an ActivityIndicator with a toggle button and state readout.""" + spinning, set_spinning = pn.use_state(True) + + return demo_screen( + "ActivityIndicator", + "Spinning indicator with a stop/start toggle.", + section( + "Indicator", + result_text("Animating", "yes" if spinning else "no"), + pn.ActivityIndicator(animating=spinning), + pn.Button( + "Stop" if spinning else "Start", + on_click=lambda: set_spinning(not spinning), + ), + hint("Tapping the toggle flips Animating between 'yes' and 'no'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/button.py b/examples/e2e-suite/app/screens/components/button.py new file mode 100644 index 0000000..94e8df2 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/button.py @@ -0,0 +1,42 @@ +"""Demo screen for [`pn.Button`][pythonnative.Button]. + +Exposes an "Increment" button that drives a counter so flows can tap +it twice and assert ``"Counter: 2"``. Also includes a disabled-button +variant whose ``on_click`` should never fire. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def ButtonDemo() -> pn.Element: + """Render an enabled counter button and a disabled button.""" + count, set_count = pn.use_state(0) + disabled_count, set_disabled_count = pn.use_state(0) + + return demo_screen( + "Button", + "Tap-driven counter plus a disabled button whose handler must not fire.", + section( + "Enabled button", + result_text("Counter", count), + buttons_row( + pn.Button("Increment", on_click=lambda: set_count(count + 1)), + pn.Button("Reset", on_click=lambda: set_count(0)), + ), + hint("Tap 'Increment' to increase the counter."), + ), + section( + "Disabled button", + result_text("Disabled taps", disabled_count), + pn.Button( + "Should not fire", + on_click=lambda: set_disabled_count(disabled_count + 1), + enabled=False, + ), + hint("Tapping this button must keep 'Disabled taps' at 0."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/error_boundary.py b/examples/e2e-suite/app/screens/components/error_boundary.py new file mode 100644 index 0000000..6fb77d4 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/error_boundary.py @@ -0,0 +1,37 @@ +"""Demo screen for [`pn.ErrorBoundary`][pythonnative.ErrorBoundary]. + +A child component throws unconditionally; the boundary should +intercept the error and render a stable fallback message. Maestro +asserts the fallback is visible. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def Crasher() -> pn.Element: + """A child component that raises on render so the boundary fires.""" + raise RuntimeError("Crasher: intentional render error for ErrorBoundary demo") + + +@pn.component +def ErrorBoundaryDemo() -> pn.Element: + """Render an ErrorBoundary wrapping a deliberately-crashing child.""" + return demo_screen( + "ErrorBoundary", + "The crashing child should be replaced by the fallback below.", + section( + "Boundary", + pn.ErrorBoundary( + Crasher(), + fallback=pn.Text( + "Caught render error", + style=pn.style(color="#B91C1C", font_weight="700", font_size=16), + ), + ), + hint("Maestro asserts 'Caught render error' is visible."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/flat_list.py b/examples/e2e-suite/app/screens/components/flat_list.py new file mode 100644 index 0000000..34862e0 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/flat_list.py @@ -0,0 +1,43 @@ +"""Demo screen for [`pn.FlatList`][pythonnative.FlatList]. + +Renders a virtualized list of 100 rows. Maestro asserts the first +row, scrolls down, and asserts a row further into the list. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def FlatListDemo() -> pn.Element: + """Render a virtualized 100-row FlatList with stable row labels.""" + items = [{"id": i, "label": f"FlatRow {i + 1}"} for i in range(100)] + + def render_row(item: dict, _: int) -> pn.Element: + return pn.View( + pn.Text(item["label"], style=pn.style(font_size=15, font_weight="600")), + style=pn.style( + padding=10, + background_color="#FFFFFF", + border_radius=6, + ), + ) + + return demo_screen( + "FlatList", + "Virtualized 100-row list; scroll to reveal rows further down.", + section( + "List body", + pn.FlatList( + data=items, + item_height=44, + separator_height=4, + render_item=render_row, + key_extractor=lambda item, _: str(item["id"]), + style=pn.style(height=200, background_color="#F1F5F9"), + ), + hint("Maestro asserts 'FlatRow 1' and (after scroll) 'FlatRow 20'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/fragment.py b/examples/e2e-suite/app/screens/components/fragment.py new file mode 100644 index 0000000..f3c3df7 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/fragment.py @@ -0,0 +1,38 @@ +"""Demo screen for [`pn.Fragment`][pythonnative.Fragment]. + +A Fragment returned from a helper function should inline its +children into the surrounding parent. The demo asserts both fragment +texts appear inside the same card, with no extra wrapper element. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +def _twin_lines() -> pn.Element: + """Return two Text elements wrapped in a Fragment. + + Returning a Fragment from a plain helper lets the surrounding + parent (a Column inside ``section``) flatten the siblings without + introducing an extra container. + """ + return pn.Fragment( + pn.Text("Fragment line 1", style=pn.style(font_weight="600")), + pn.Text("Fragment line 2", style=pn.style(color="#0369A1")), + ) + + +@pn.component +def FragmentDemo() -> pn.Element: + """Render a card containing the two lines from ``_twin_lines``.""" + return demo_screen( + "Fragment", + "Fragment merges multiple children into the parent without a wrapper view.", + section( + "Fragment inside a card", + _twin_lines(), + hint("Both lines should appear inside the single card above."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/image.py b/examples/e2e-suite/app/screens/components/image.py new file mode 100644 index 0000000..b2c6d12 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/image.py @@ -0,0 +1,56 @@ +"""Demo screen for [`pn.Image`][pythonnative.Image]. + +Renders a small placeholder image using a public URL plus an +``accessibility_label`` Maestro can find. The actual pixel content +doesn't matter; what matters is that ``Image`` instantiates without +error and that the surrounding labels render. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + +# A 1x1 transparent PNG. Bundling an inline data URI means the demo +# works even when the CI runner has no internet access. +TRANSPARENT_PNG = ( + "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" +) + + +@pn.component +def ImageDemo() -> pn.Element: + """Render a tiny inline data-URI image with stable labels around it.""" + return demo_screen( + "Image", + "Three sized image instances render side-by-side.", + section( + "Inline data-URI image", + pn.Image( + source=TRANSPARENT_PNG, + accessibility_label="image-tile", + style=pn.style(width=64, height=64, background_color="#FECACA"), + ), + hint("If the image fails to load, the colored background remains."), + ), + section( + "Three tiles", + pn.Row( + pn.Image( + source=TRANSPARENT_PNG, + style=pn.style(width=40, height=40, background_color="#FCA5A5"), + ), + pn.Image( + source=TRANSPARENT_PNG, + style=pn.style(width=40, height=40, background_color="#86EFAC"), + ), + pn.Image( + source=TRANSPARENT_PNG, + style=pn.style(width=40, height=40, background_color="#93C5FD"), + ), + style=pn.style(spacing=8), + ), + pn.Text("Tiles rendered: 3"), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/keyboard_avoiding_view.py b/examples/e2e-suite/app/screens/components/keyboard_avoiding_view.py new file mode 100644 index 0000000..85a938e --- /dev/null +++ b/examples/e2e-suite/app/screens/components/keyboard_avoiding_view.py @@ -0,0 +1,45 @@ +"""Demo screen for [`pn.KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView]. + +The actual keyboard-avoidance behavior depends on the platform and on +whether a TextInput is focused; Maestro can't easily reproduce the +edge cases. The demo confirms the component instantiates without +error and renders its children. A nested TextInput is included so the +mount path matches the production usage. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def KeyboardAvoidingViewDemo() -> pn.Element: + """Render KeyboardAvoidingView with a TextInput inside it.""" + value, set_value = pn.use_state("") + return demo_screen( + "KeyboardAvoidingView", + "TextInput inside a KeyboardAvoidingView; mainly a smoke test.", + section( + "Body", + pn.KeyboardAvoidingView( + pn.Column( + pn.Text("KAV body label", style=pn.style(font_weight="600")), + pn.TextInput( + value=value, + placeholder="Tap to focus the keyboard", + on_change=set_value, + style=pn.style( + padding=10, + border_radius=6, + border_width=1, + border_color="#CBD5E1", + background_color="#FFFFFF", + ), + ), + style=pn.style(spacing=8), + ) + ), + hint("Maestro asserts 'KAV body label' is visible after mount."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/modal.py b/examples/e2e-suite/app/screens/components/modal.py new file mode 100644 index 0000000..5f57dc5 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/modal.py @@ -0,0 +1,45 @@ +"""Demo screen for [`pn.Modal`][pythonnative.Modal]. + +A button opens the modal; another button (inside the modal) dismisses +it. Maestro taps "Open modal", asserts the modal's body text appears, +then taps "Close modal" and asserts the body text disappears. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@pn.component +def ModalDemo() -> pn.Element: + """Render a button that opens a Modal containing a dismiss button.""" + visible, set_visible = pn.use_state(False) + + return demo_screen( + "Modal", + "Open the modal, then dismiss it from inside.", + section( + "Modal toggle", + result_text("Modal", "open" if visible else "closed"), + pn.Button("Open modal", on_click=lambda: set_visible(True)), + hint("Maestro asserts 'Modal body text' appears after tap."), + ), + pn.Modal( + pn.Column( + pn.Text( + "Modal body text", + style=pn.style(font_size=18, font_weight="600"), + ), + pn.Text( + "This content only renders when the modal is visible.", + style=pn.style(font_size=13, color="#374151"), + ), + pn.Button("Close modal", on_click=lambda: set_visible(False)), + style=pn.style(spacing=12, padding=20), + ), + visible=visible, + title="Demo modal", + on_dismiss=lambda: set_visible(False), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/picker.py b/examples/e2e-suite/app/screens/components/picker.py new file mode 100644 index 0000000..bca1557 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/picker.py @@ -0,0 +1,50 @@ +"""Demo screen for [`pn.Picker`][pythonnative.Picker]. + +A simple fruit picker plus a button row that selects each value +programmatically. Programmatic selection is what Maestro drives, +since wheel pickers are hard to operate via test scripts. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + +_OPTIONS = [ + {"value": "apple", "label": "Apple"}, + {"value": "banana", "label": "Banana"}, + {"value": "cherry", "label": "Cherry"}, +] + + +@pn.component +def PickerDemo() -> pn.Element: + """Render a Picker plus selector buttons so flows can drive it deterministically.""" + fruit, set_fruit = pn.use_state("apple") + + return demo_screen( + "Picker", + "Pick a fruit via the wheel or the buttons.", + section( + "Picker", + result_text("Picked", fruit), + pn.Picker( + value=fruit, + items=_OPTIONS, + on_change=set_fruit, + style=pn.style( + padding=10, + border_radius=6, + border_width=1, + border_color="#CBD5E1", + background_color="#FFFFFF", + ), + ), + buttons_row( + pn.Button("Pick apple", on_click=lambda: set_fruit("apple")), + pn.Button("Pick banana", on_click=lambda: set_fruit("banana")), + pn.Button("Pick cherry", on_click=lambda: set_fruit("cherry")), + ), + hint("Maestro taps a 'Pick X' button and asserts 'Picked: X'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/pressable.py b/examples/e2e-suite/app/screens/components/pressable.py new file mode 100644 index 0000000..c9009b7 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/pressable.py @@ -0,0 +1,46 @@ +"""Demo screen for [`pn.Pressable`][pythonnative.Pressable]. + +A Pressable wraps a colored View. Tapping toggles the background +color and bumps a counter so Maestro can assert ``on_press`` fired. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@pn.component +def PressableDemo() -> pn.Element: + """Render a Pressable that toggles its background and tracks tap count.""" + count, set_count = pn.use_state(0) + color = "#0EA5E9" if count % 2 == 0 else "#10B981" + + def on_press() -> None: + set_count(count + 1) + + return demo_screen( + "Pressable", + "Tap the colored area; the press counter and background flip.", + section( + "Tap target", + result_text("Presses", count), + pn.Pressable( + pn.View( + pn.Text( + "Tap me (Pressable)", + style=pn.style(color="#FFFFFF", font_weight="700"), + ), + style=pn.style( + padding=18, + background_color=color, + border_radius=12, + align_items="center", + ), + ), + on_press=on_press, + pressed_opacity=0.7, + ), + hint("Maestro taps the area and asserts the Presses count increases."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/progress_bar.py b/examples/e2e-suite/app/screens/components/progress_bar.py new file mode 100644 index 0000000..330c3bd --- /dev/null +++ b/examples/e2e-suite/app/screens/components/progress_bar.py @@ -0,0 +1,47 @@ +"""Demo screen for [`pn.ProgressBar`][pythonnative.ProgressBar]. + +Three progress bars at 0 / 50 / 100 percent + a state-driven progress +bar paired with two buttons. Maestro taps "Advance" twice and asserts +the percentage line steps up to 50. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def ProgressBarDemo() -> pn.Element: + """Render static and stateful progress bars with stable labels.""" + progress, set_progress = pn.use_state(0.25) + + def advance() -> None: + set_progress(min(1.0, round(progress + 0.25, 2))) + + def reset() -> None: + set_progress(0.0) + + return demo_screen( + "ProgressBar", + "Static bars + a stateful bar driven by tap to test value updates.", + section( + "Static bars", + pn.Text("0%"), + pn.ProgressBar(value=0.0), + pn.Text("50%"), + pn.ProgressBar(value=0.5), + pn.Text("100%"), + pn.ProgressBar(value=1.0), + ), + section( + "Stateful bar", + result_text("Progress", f"{int(progress * 100)}%"), + pn.ProgressBar(value=progress), + buttons_row( + pn.Button("Advance", on_click=advance), + pn.Button("Reset", on_click=reset), + ), + hint("Tap 'Advance' to move the bar in 25% steps up to 100%."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/refresh_control.py b/examples/e2e-suite/app/screens/components/refresh_control.py new file mode 100644 index 0000000..c6e0822 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/refresh_control.py @@ -0,0 +1,51 @@ +"""Demo screen for [`pn.RefreshControl`][pythonnative.RefreshControl]. + +Pull-to-refresh is awkward to drive from Maestro on every platform, +so this demo also exposes a "Trigger refresh" button that runs the +same code path. Maestro taps the button and asserts the refresh state +flips, then settles back to idle. +""" + +from __future__ import annotations + +import threading + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@pn.component +def RefreshControlDemo() -> pn.Element: + """Render a ScrollView with a RefreshControl plus a manual trigger button.""" + refreshing, set_refreshing = pn.use_state(False) + count, set_count = pn.use_state(0) + + def start_refresh() -> None: + set_refreshing(True) + + def _done() -> None: + set_refreshing(False) + set_count(count + 1) + + threading.Timer(0.6, _done).start() + + return demo_screen( + "RefreshControl", + "Pull down to refresh, or use the button to trigger the same code path.", + section( + "Refresh state", + result_text("Refreshing", "yes" if refreshing else "no"), + result_text("Refresh runs", count), + pn.Button("Trigger refresh", on_click=start_refresh), + hint("Maestro taps 'Trigger refresh' and asserts the runs counter."), + ), + pn.ScrollView( + pn.Column( + pn.Text("Scrollable content", style=pn.style(font_size=15)), + pn.Text("Pull down here to refresh", style=pn.style(font_size=13, color="#6B7280")), + style=pn.style(spacing=8, padding=12), + ), + refresh_control=pn.RefreshControl(refreshing=refreshing, on_refresh=start_refresh), + style=pn.style(height=160, background_color="#F8FAFC", border_radius=8), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/safe_area_view.py b/examples/e2e-suite/app/screens/components/safe_area_view.py new file mode 100644 index 0000000..f247a48 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/safe_area_view.py @@ -0,0 +1,32 @@ +"""Demo screen for [`pn.SafeAreaView`][pythonnative.SafeAreaView]. + +Wraps a body of text in ``SafeAreaView``. The visual result is +trivial on the simulator since we're already inside a stack with +nav-bar insets applied, but the demo confirms the element instantiates +without error and renders its children. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def SafeAreaViewDemo() -> pn.Element: + """Render a SafeAreaView holding a stable text line.""" + return demo_screen( + "SafeAreaView", + "Children inside SafeAreaView should render without overlapping insets.", + section( + "SafeAreaView body", + pn.SafeAreaView( + pn.Text( + "Inside SafeAreaView", + style=pn.style(font_size=16, padding=12, background_color="#E0E7FF"), + ), + style=pn.style(background_color="#EEF2FF", padding=8), + ), + hint("Maestro asserts 'Inside SafeAreaView' is visible."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/scroll_view.py b/examples/e2e-suite/app/screens/components/scroll_view.py new file mode 100644 index 0000000..2ad2387 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/scroll_view.py @@ -0,0 +1,38 @@ +"""Demo screen for [`pn.ScrollView`][pythonnative.ScrollView]. + +Renders a tall column of numbered rows inside a fixed-height scroll +view. Maestro asserts the first row, scrolls, and then asserts a row +that was initially off-screen. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def ScrollViewDemo() -> pn.Element: + """Render a fixed-height ScrollView with 30 numbered rows.""" + rows = list(range(1, 31)) + return demo_screen( + "ScrollView", + "Scroll vertically to reveal rows beyond the visible area.", + section( + "Tall content", + pn.ScrollView( + pn.Column( + *[ + pn.Text( + f"ScrollRow {i}", + style=pn.style(font_size=15, padding=8, background_color="#F1F5F9"), + ) + for i in rows + ], + style=pn.style(spacing=4), + ), + style=pn.style(height=200, border_width=1, border_color="#CBD5E1"), + ), + hint("Maestro scrolls to reveal a row from later in the list."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/section_list.py b/examples/e2e-suite/app/screens/components/section_list.py new file mode 100644 index 0000000..40352f3 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/section_list.py @@ -0,0 +1,58 @@ +"""Demo screen for [`pn.SectionList`][pythonnative.SectionList]. + +Two short sections with stable headers and rows; the test mostly +verifies that the section header and the first row of each section +render together. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def SectionListDemo() -> pn.Element: + """Render a 2-section SectionList using the eager fallback for stability.""" + sections = [ + { + "title": "Section Alpha", + "data": [{"name": f"Alpha row {i + 1}"} for i in range(3)], + }, + { + "title": "Section Beta", + "data": [{"name": f"Beta row {i + 1}"} for i in range(3)], + }, + ] + + def render_item(item: dict, _i: int, _s: int) -> pn.Element: + return pn.Text( + item["name"], + style=pn.style(font_size=14, padding=8, background_color="#FFFFFF"), + ) + + def render_header(s: dict, _i: int) -> pn.Element: + return pn.Text( + s["title"], + style=pn.style( + font_size=15, + font_weight="700", + padding=8, + background_color="#E2E8F0", + ), + ) + + return demo_screen( + "SectionList", + "Two sections with three rows each.", + section( + "Sections", + pn.SectionList( + sections=sections, + render_item=render_item, + render_section_header=render_header, + style=pn.style(height=240, background_color="#F1F5F9"), + ), + hint("Both section headers and their rows should be visible."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/slider.py b/examples/e2e-suite/app/screens/components/slider.py new file mode 100644 index 0000000..8e992b0 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/slider.py @@ -0,0 +1,41 @@ +"""Demo screen for [`pn.Slider`][pythonnative.Slider]. + +Renders a slider plus two helper buttons that snap it to the +endpoints. Maestro relies on the buttons (which are easier to drive +than dragging a slider thumb) to exercise ``on_change``. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def SliderDemo() -> pn.Element: + """Render a Slider, two snap buttons, and a numeric Value line.""" + value, set_value = pn.use_state(0.0) + + def on_change(new: float) -> None: + set_value(round(float(new), 2)) + + return demo_screen( + "Slider", + "Drag the slider, or tap the buttons to snap to min/max.", + section( + "Slider 0…1", + result_text("Value", f"{value:.2f}"), + pn.Slider( + value=value, + min_value=0.0, + max_value=1.0, + on_change=on_change, + ), + buttons_row( + pn.Button("Set 0.0", on_click=lambda: on_change(0.0)), + pn.Button("Set 0.5", on_click=lambda: on_change(0.5)), + pn.Button("Set 1.0", on_click=lambda: on_change(1.0)), + ), + hint("Maestro taps Set buttons and asserts the Value line."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/spacer.py b/examples/e2e-suite/app/screens/components/spacer.py new file mode 100644 index 0000000..4fad425 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/spacer.py @@ -0,0 +1,30 @@ +"""Demo screen for [`pn.Spacer`][pythonnative.Spacer]. + +Spacer pushes siblings apart. Two columns of "Top" / "Bottom" text +have a Spacer in between; the demo verifies both ends are visible +without overlap. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def SpacerDemo() -> pn.Element: + """Render two text labels separated by an explicit Spacer.""" + return demo_screen( + "Spacer", + "Two labels separated by an explicit Spacer with fixed size.", + section( + "Fixed-size Spacer", + pn.Column( + pn.Text("Spacer top label", style=pn.style(font_weight="600")), + pn.Spacer(size=24), + pn.Text("Spacer bottom label", style=pn.style(font_weight="600")), + style=pn.style(spacing=0, padding=8, background_color="#FEF3C7", border_radius=8), + ), + hint("Both labels are visible and not overlapping."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/status_bar.py b/examples/e2e-suite/app/screens/components/status_bar.py new file mode 100644 index 0000000..952a074 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/status_bar.py @@ -0,0 +1,34 @@ +"""Demo screen for [`pn.StatusBar`][pythonnative.StatusBar]. + +The status bar isn't visually testable via Maestro's accessibility +tree on every platform, so the demo focuses on confirming that +mounting a StatusBar element doesn't crash. A toggle button rotates +between ``"dark"`` and ``"light"`` bar styles so flows can drive the +prop. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@pn.component +def StatusBarDemo() -> pn.Element: + """Render a StatusBar plus a toggle that flips its ``bar_style`` prop.""" + style, set_style = pn.use_state("dark") + + return demo_screen( + "StatusBar", + "Toggle the status bar style between dark and light.", + section( + "Status bar style", + pn.StatusBar(bar_style=style), + result_text("Bar style", style), + pn.Button( + "Toggle", + on_click=lambda: set_style("light" if style == "dark" else "dark"), + ), + hint("Tapping the button flips Bar style between 'dark' and 'light'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/switch.py b/examples/e2e-suite/app/screens/components/switch.py new file mode 100644 index 0000000..7f7f873 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/switch.py @@ -0,0 +1,31 @@ +"""Demo screen for [`pn.Switch`][pythonnative.Switch]. + +Maestro toggles the switch via its accessibility label and asserts +the "State:" line flips between ON and OFF. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def SwitchDemo() -> pn.Element: + """Render a Switch plus a result line tracking its boolean state.""" + on, set_on = pn.use_state(False) + + return demo_screen( + "Switch", + "Toggle the switch and the State line should flip ON/OFF.", + section( + "Single switch", + result_text("State", "ON" if on else "OFF"), + pn.Switch(value=on, on_change=set_on), + buttons_row( + pn.Button("Turn on", on_click=lambda: set_on(True)), + pn.Button("Turn off", on_click=lambda: set_on(False)), + ), + hint("Toggling via tap or the buttons must update the State line."), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/text.py b/examples/e2e-suite/app/screens/components/text.py new file mode 100644 index 0000000..d461673 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/text.py @@ -0,0 +1,44 @@ +"""Demo screen for [`pn.Text`][pythonnative.Text]. + +Exercises the simplest element factory in isolation, including +``style`` font sizing, bold, color, and a few combined styles. Maestro +asserts that several text labels render together. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def TextDemo() -> pn.Element: + """Render a handful of [`Text`][pythonnative.Text] variants.""" + return demo_screen( + "Text", + "Plain text, bold text, sized text, and colored text in one place.", + section( + "Plain text", + pn.Text("Plain text line"), + hint("Renders with default body style."), + ), + section( + "Bold text", + pn.Text("Bold text line", style=pn.style(bold=True, font_size=18)), + ), + section( + "Sized + colored text", + pn.Text( + "Sized and colored line", + style=pn.style(font_size=20, color="#DC2626", font_weight="600"), + ), + ), + section( + "Multi-line text", + pn.Text( + "First paragraph that should wrap if it is long enough to " + "exceed the available horizontal space inside its parent.", + style=pn.style(font_size=14, color="#1F2937", line_height=20), + ), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/text_input.py b/examples/e2e-suite/app/screens/components/text_input.py new file mode 100644 index 0000000..01f0f02 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/text_input.py @@ -0,0 +1,63 @@ +"""Demo screen for [`pn.TextInput`][pythonnative.TextInput]. + +Maestro types into the single-line input and asserts the ``Echo:`` +line mirrors the typed value. The multiline variant is also rendered +so flows can confirm both modes coexist on one screen. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, label, result_text, section +from app.theme import styles + + +@pn.component +def TextInputDemo() -> pn.Element: + """Render a single-line input, a multiline input, and live echoes.""" + name, set_name = pn.use_state("") + notes, set_notes = pn.use_state("") + + field_style = pn.style( + padding=10, + border_radius=6, + border_width=1, + border_color="#CBD5E1", + background_color="#FFFFFF", + font_size=16, + ) + + return demo_screen( + "TextInput", + "Single-line and multiline text entry with a live echo line.", + section( + "Single-line", + label("Name"), + pn.TextInput( + value=name, + placeholder="Type your name here", + on_change=set_name, + return_key_type="done", + auto_correct=False, + style=field_style, + ), + result_text("Echo", name or "(empty)"), + hint("Maestro types into this field and asserts the echo updates."), + ), + section( + "Multiline", + label("Notes"), + pn.TextInput( + value=notes, + placeholder="Type a note…", + on_change=set_notes, + multiline=True, + max_length=200, + style={**field_style, "height": 100}, + ), + pn.Text( + f"Length: {len(notes)}", + style=styles["result"], + ), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/view_column_row.py b/examples/e2e-suite/app/screens/components/view_column_row.py new file mode 100644 index 0000000..8a3d1e0 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/view_column_row.py @@ -0,0 +1,52 @@ +"""Demo screen for [`pn.View`][pythonnative.View], +[`pn.Column`][pythonnative.Column], and [`pn.Row`][pythonnative.Row]. + +The three primitives share a code path but enforce different +``flex_direction`` defaults. The demo renders one of each so Maestro +can confirm they all instantiate and lay children out in the expected +direction. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section +from app.theme import styles + +_TILE = pn.style(width=44, height=44, background_color="#34D399", border_radius=8) + + +@pn.component +def ViewColumnRowDemo() -> pn.Element: + """Render a Row of three tiles, a Column of three tiles, and a generic View.""" + return demo_screen( + "View / Column / Row", + "Confirms Row is horizontal, Column is vertical, View is a generic container.", + section( + "Row (flex_direction: row)", + pn.Row( + pn.View(pn.Text("A"), style=_TILE), + pn.View(pn.Text("B"), style=_TILE), + pn.View(pn.Text("C"), style=_TILE), + style=pn.style(spacing=8), + ), + hint("All three tiles should appear on one horizontal line."), + ), + section( + "Column (flex_direction: column)", + pn.Column( + pn.View(pn.Text("X"), style=_TILE), + pn.View(pn.Text("Y"), style=_TILE), + pn.View(pn.Text("Z"), style=_TILE), + style=pn.style(spacing=8), + ), + hint("All three tiles should stack vertically."), + ), + section( + "Generic View", + pn.View( + pn.Text("inside View"), + style={**styles["card"], "background_color": "#FDE68A"}, + ), + ), + ) diff --git a/examples/e2e-suite/app/screens/components/web_view.py b/examples/e2e-suite/app/screens/components/web_view.py new file mode 100644 index 0000000..d71e076 --- /dev/null +++ b/examples/e2e-suite/app/screens/components/web_view.py @@ -0,0 +1,36 @@ +"""Demo screen for [`pn.WebView`][pythonnative.WebView]. + +The page content depends on the runner having network access, which +isn't guaranteed in CI. We render a WebView pointed at a small inline +``data:`` URL so the demo is hermetic. Maestro only asserts the +surrounding labels — there's no reliable cross-platform way to assert +text *inside* a native WebView via the accessibility tree. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + +_INLINE_DOC = ( + "data:text/html,<html><body style='font-family:sans-serif;padding:8px'>" + "<h2>WebView inline page</h2><p>Inline HTML content.</p></body></html>" +) + + +@pn.component +def WebViewDemo() -> pn.Element: + """Render a WebView with an inline HTML data URL.""" + return demo_screen( + "WebView", + "Renders inline HTML via a data: URL so the demo works offline.", + section( + "WebView body", + pn.WebView( + url=_INLINE_DOC, + style=pn.style(height=160, border_radius=8, border_width=1, border_color="#CBD5E1"), + ), + pn.Text("WebView visible marker", style=pn.style(font_weight="600")), + hint("Maestro asserts the 'WebView visible marker' label is present."), + ), + ) diff --git a/examples/e2e-suite/app/screens/home.py b/examples/e2e-suite/app/screens/home.py new file mode 100644 index 0000000..4df442b --- /dev/null +++ b/examples/e2e-suite/app/screens/home.py @@ -0,0 +1,63 @@ +"""Home screen: lists every category as a tappable row. + +Each row pushes the ``Category`` route with ``{"name": "<category>"}`` +as a route param. The category screen then lists every demo in that +category. + +Stable labels used by Maestro: + +- ``"E2E Suite home"`` — present whenever the home screen is on top + of the stack. Maestro flows start with + ``extendedWaitUntil: visible: "E2E Suite home"`` so they wait for + the app to boot before tapping. +- ``"Open <name>"`` — buttons that open each category. Flows tap + them by name (e.g. ``tapOn: "Open Hooks"``). +""" + +from __future__ import annotations + +import pythonnative as pn +from app.registry import CATEGORIES, demos_for_category +from app.theme import styles + + +@pn.component +def HomeScreen() -> pn.Element: + """Master list of categories. + + Renders one button per category from :data:`app.registry.CATEGORIES`, + each labelled ``"Open <name>"`` so flows can target it by an exact + string match without colliding with the category list screen's + title text. The demo count appears as a separate text line so the + button label itself is short and stable. + """ + nav = pn.use_navigation() + + def open_category(name: str) -> None: + nav.navigate("Category", {"name": name}) + + return pn.ScrollView( + pn.Column( + pn.Text("E2E Suite home", style=styles["title"]), + pn.Text( + "Every category below maps to a folder of demo screens. " + "Tap a category, then tap a demo to exercise that feature.", + style=styles["hint"], + ), + *[ + pn.Column( + pn.Button( + f"Open {name}", + on_click=lambda _name=name: open_category(_name), + ), + pn.Text( + f"{len(demos_for_category(name))} demos", + style=styles["hint"], + ), + style=pn.style(spacing=4), + ) + for name in CATEGORIES + ], + style=styles["screen"], + ) + ) diff --git a/examples/e2e-suite/app/screens/hooks/__init__.py b/examples/e2e-suite/app/screens/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/hooks/batch_updates_demo.py b/examples/e2e-suite/app/screens/hooks/batch_updates_demo.py new file mode 100644 index 0000000..8460571 --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/batch_updates_demo.py @@ -0,0 +1,46 @@ +"""Demo screen for [`pn.batch_updates`][pythonnative.batch_updates]. + +Two ``use_state`` updates wrapped in ``batch_updates`` should produce +exactly one extra render, regardless of how many setters fire. The +demo tracks the render count to expose this. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def BatchUpdatesDemo() -> pn.Element: + """Render a screen tracking render counts across batched vs unbatched setters.""" + renders = pn.use_ref(0) + renders["current"] += 1 + + a, set_a = pn.use_state(0) + b, set_b = pn.use_state(0) + + def update_both_unbatched() -> None: + set_a(a + 1) + set_b(b + 1) + + def update_both_batched() -> None: + with pn.batch_updates(): + set_a(a + 1) + set_b(b + 1) + + return demo_screen( + "batch_updates", + "Compare batched vs unbatched setter calls by render count.", + section( + "State values", + result_text("a", a), + result_text("b", b), + result_text("Render count", renders["current"]), + buttons_row( + pn.Button("Unbatched bump", on_click=update_both_unbatched), + pn.Button("Batched bump", on_click=update_both_batched), + ), + hint("Tapping 'Batched bump' increases render count by 1; " "'Unbatched bump' may increase by 2."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/memo_demo.py b/examples/e2e-suite/app/screens/hooks/memo_demo.py new file mode 100644 index 0000000..fbe73d1 --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/memo_demo.py @@ -0,0 +1,57 @@ +"""Demo screen for [`pn.memo`][pythonnative.memo]. + +Two children are wrapped in ``@pn.memo``. They count their own renders +in module-level refs. The parent has a state that flips on tap. The +memoized children should NOT re-render when the parent state changes, +unless their own props change. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + +_render_counts = {"a": 0, "b": 0} + + +@pn.memo +@pn.component +def _MemoA() -> pn.Element: + _render_counts["a"] += 1 + return pn.Text(f"MemoA render count: {_render_counts['a']}", style=pn.style(font_weight="600")) + + +@pn.memo +@pn.component +def _MemoB(label: str = "x") -> pn.Element: + _render_counts["b"] += 1 + return pn.Text( + f"MemoB label={label} render count: {_render_counts['b']}", + style=pn.style(font_weight="600"), + ) + + +@pn.component +def MemoDemo() -> pn.Element: + """Render two memoized children and a parent counter that should not re-render them.""" + parent_count, set_parent_count = pn.use_state(0) + b_label, set_b_label = pn.use_state("x") + + return demo_screen( + "memo", + "Memoized children stay still when parent state changes.", + section( + "Memo identity", + result_text("Parent renders", parent_count), + _MemoA(), + _MemoB(label=b_label), + buttons_row( + pn.Button("Bump parent", on_click=lambda: set_parent_count(parent_count + 1)), + pn.Button( + "Toggle B label", + on_click=lambda: set_b_label("y" if b_label == "x" else "x"), + ), + ), + hint("Bumping parent should NOT bump MemoA's count. " "Toggling B label DOES bump MemoB's count."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_async_effect.py b/examples/e2e-suite/app/screens/hooks/use_async_effect.py new file mode 100644 index 0000000..f099ec7 --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_async_effect.py @@ -0,0 +1,35 @@ +"""Demo screen for [`pn.use_async_effect`][pythonnative.use_async_effect]. + +An async effect runs once on mount and waits 200 ms before flipping +a "completed" flag. Maestro asserts the initial "loading" line, then +re-asserts after the effect resolves. +""" + +from __future__ import annotations + +import asyncio + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@pn.component +def UseAsyncEffectDemo() -> pn.Element: + """Run an async effect that flips a 'done' flag after a short delay.""" + done, set_done = pn.use_state(False) + + async def _eventually_done() -> None: + await asyncio.sleep(0.2) + set_done(True) + + pn.use_async_effect(_eventually_done, []) + + return demo_screen( + "use_async_effect", + "Async effect resolves after a short delay and flips the status line.", + section( + "Status", + result_text("Status", "done" if done else "loading"), + hint("Maestro waits for 'Status: done' (timeout 5s)."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_callback.py b/examples/e2e-suite/app/screens/hooks/use_callback.py new file mode 100644 index 0000000..a345e45 --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_callback.py @@ -0,0 +1,44 @@ +"""Demo screen for [`pn.use_callback`][pythonnative.use_callback]. + +The hook returns a stable reference for a callback as long as its +deps don't change. We expose this by tracking the identity of the +returned function across renders. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def UseCallbackDemo() -> pn.Element: + """Render a stable-identity callback compared across renders.""" + dep, set_dep = pn.use_state(0) + other, set_other = pn.use_state(0) + last_id = pn.use_ref(None) + changes = pn.use_ref(0) + + cb = pn.use_callback(lambda: None, [dep]) + + if last_id["current"] is None: + last_id["current"] = id(cb) + elif last_id["current"] != id(cb): + changes["current"] += 1 + last_id["current"] = id(cb) + + return demo_screen( + "use_callback", + "Function identity stays stable until dep changes.", + section( + "Identity tracking", + result_text("Identity changes", changes["current"]), + result_text("Dep value", dep), + result_text("Other value", other), + buttons_row( + pn.Button("Change dep", on_click=lambda: set_dep(dep + 1)), + pn.Button("Change other", on_click=lambda: set_other(other + 1)), + ), + hint("Tapping 'Change other' must NOT bump 'Identity changes'. " "Tapping 'Change dep' bumps it by 1."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_context.py b/examples/e2e-suite/app/screens/hooks/use_context.py new file mode 100644 index 0000000..a7aee1c --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_context.py @@ -0,0 +1,46 @@ +"""Demo screen for [`pn.use_context`][pythonnative.use_context], +[`pn.create_context`][pythonnative.create_context], and +[`pn.Provider`][pythonnative.Provider]. + +A trivial theme context with a Provider at the top and a consumer +child shows the value flowing through the tree. A button at the +top swaps the provided value so flows can verify reactive updates. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + +_ThemeContext = pn.create_context("light") + + +@pn.component +def _Consumer() -> pn.Element: + """Read and render the current theme value.""" + theme = pn.use_context(_ThemeContext) + return pn.Text( + f"Consumer sees: {theme}", + style=pn.style(font_weight="600", color="#0F172A"), + ) + + +@pn.component +def UseContextDemo() -> pn.Element: + """Render a Provider with two values, swapping between them on tap.""" + theme, set_theme = pn.use_state("light") + + return demo_screen( + "use_context", + "A Provider passes a value to a deeply nested consumer.", + section( + "Theme context", + result_text("Current theme", theme), + pn.Provider(_ThemeContext, theme, _Consumer()), + buttons_row( + pn.Button("Set light", on_click=lambda: set_theme("light")), + pn.Button("Set dark", on_click=lambda: set_theme("dark")), + ), + hint("Tap 'Set dark' and the consumer line should show 'dark'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_effect.py b/examples/e2e-suite/app/screens/hooks/use_effect.py new file mode 100644 index 0000000..1ae44a9 --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_effect.py @@ -0,0 +1,51 @@ +"""Demo screen for [`pn.use_effect`][pythonnative.use_effect]. + +Two effects: + +- An "on mount" effect that bumps a counter once, then registers a + cleanup. The cleanup runs when the screen unmounts (i.e. on + Back), so Maestro can't directly assert it; the mount counter is + the testable surface. +- A "dependency change" effect that re-runs each time the user + changes the dependency, so flows can drive it deterministically by + tapping "Bump dep". +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def UseEffectDemo() -> pn.Element: + """Render effect run counters driven by dependency-array changes.""" + dep, set_dep = pn.use_state(0) + mount_runs, set_mount_runs = pn.use_state(0) + dep_runs, set_dep_runs = pn.use_state(0) + + def _on_mount() -> None: + # ``[]`` dep list -> only runs on mount. + set_mount_runs(mount_runs + 1) + + pn.use_effect(_on_mount, []) + + def _on_dep_change() -> None: + set_dep_runs(dep_runs + 1) + + pn.use_effect(_on_dep_change, [dep]) + + return demo_screen( + "use_effect", + "Two effects: one runs once on mount, one runs on each dep change.", + section( + "Effect run counters", + result_text("Mount runs", mount_runs), + result_text("Dep runs", dep_runs), + result_text("Dep value", dep), + buttons_row( + pn.Button("Bump dep", on_click=lambda: set_dep(dep + 1)), + ), + hint("Tapping 'Bump dep' twice should set 'Dep runs: 3' (1 on mount + 2 bumps)."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_memo.py b/examples/e2e-suite/app/screens/hooks/use_memo.py new file mode 100644 index 0000000..6cebd7a --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_memo.py @@ -0,0 +1,42 @@ +"""Demo screen for [`pn.use_memo`][pythonnative.use_memo]. + +A memoized factory tracks how many times it ran. The factory only +fires when its dependency array changes, so flows can confirm the +``use_memo`` cache works by toggling a button that doesn't change +the dep. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def UseMemoDemo() -> pn.Element: + """Render a memo whose factory bumps a counter only on dep change.""" + runs = pn.use_ref(0) + dep, set_dep = pn.use_state(0) + other, set_other = pn.use_state(0) + + def _expensive() -> int: + runs["current"] += 1 + return dep * 2 + + memoized = pn.use_memo(_expensive, [dep]) + + return demo_screen( + "use_memo", + "Factory only re-runs when its dep array changes.", + section( + "Memo", + result_text("Factory runs", runs["current"]), + result_text("Memo value", memoized), + result_text("Other state", other), + buttons_row( + pn.Button("Change dep", on_click=lambda: set_dep(dep + 1)), + pn.Button("Change other", on_click=lambda: set_other(other + 1)), + ), + hint("Tap 'Change other' — factory runs stays the same. " "Tap 'Change dep' — factory runs goes up."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_mutation.py b/examples/e2e-suite/app/screens/hooks/use_mutation.py new file mode 100644 index 0000000..4729af0 --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_mutation.py @@ -0,0 +1,42 @@ +"""Demo screen for [`pn.use_mutation`][pythonnative.use_mutation]. + +A fake "submit" mutation resolves to the value it was called with. +Maestro taps the submit button and asserts the result line. +""" + +from __future__ import annotations + +import asyncio + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +async def _submit(payload: str) -> str: + await asyncio.sleep(0.15) + return f"echo:{payload}" + + +@pn.component +def UseMutationDemo() -> pn.Element: + """Render a submit button that fires a use_mutation call.""" + state, run = pn.use_mutation(_submit) + + if state.loading: + status = "submitting" + elif state.error is not None: + status = f"error: {state.error}" + else: + status = "idle" + + return demo_screen( + "use_mutation", + "use_mutation tracks loading, error, and last data fields.", + section( + "Mutation", + result_text("Status", status), + result_text("Last data", state.data or "(none)"), + pn.Button("Submit hello", on_click=lambda: run("hello")), + hint("Tap submit; Maestro asserts 'Last data: echo:hello'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_persisted_state.py b/examples/e2e-suite/app/screens/hooks/use_persisted_state.py new file mode 100644 index 0000000..7a98df6 --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_persisted_state.py @@ -0,0 +1,33 @@ +"""Demo screen for [`pn.use_persisted_state`][pythonnative.use_persisted_state]. + +Stores an integer counter under a stable key in +[`AsyncStorage`][pythonnative.AsyncStorage]. The persistence aspect +isn't testable in a single Maestro flow (you'd need to relaunch the +app), so the demo focuses on the in-session API: tapping "Bump" must +update the visible value and a "Clear" button must reset it to 0. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def UsePersistedStateDemo() -> pn.Element: + """Render a counter persisted under ``e2e.persisted_demo``.""" + value, set_value = pn.use_persisted_state("e2e.persisted_demo", 0) + + return demo_screen( + "use_persisted_state", + "Counter persisted to AsyncStorage; restored on relaunch.", + section( + "Counter", + result_text("Persisted value", value), + buttons_row( + pn.Button("Bump", on_click=lambda: set_value(value + 1)), + pn.Button("Clear", on_click=lambda: set_value(0)), + ), + hint("Tap 'Bump' twice; Maestro asserts 'Persisted value: 2'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_query.py b/examples/e2e-suite/app/screens/hooks/use_query.py new file mode 100644 index 0000000..bc3a54d --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_query.py @@ -0,0 +1,43 @@ +"""Demo screen for [`pn.use_query`][pythonnative.use_query]. + +A fake fetch resolves to a fixed string after ~300 ms. The demo shows +loading, success, and refetch — all three pieces of the +[`QueryResult`][pythonnative.QueryResult] API. +""" + +from __future__ import annotations + +import asyncio + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +async def _fake_fetch() -> str: + await asyncio.sleep(0.3) + return "fetched-value" + + +@pn.component +def UseQueryDemo() -> pn.Element: + """Render the result of a use_query call plus a refetch button.""" + q = pn.use_query(_fake_fetch, []) + + if q.loading and q.data is None: + status = "loading" + elif q.error is not None: + status = f"error: {q.error}" + else: + status = "ready" + + return demo_screen( + "use_query", + "use_query manages loading, success, and refetch state.", + section( + "Query", + result_text("Status", status), + result_text("Data", q.data or "(none)"), + pn.Button("Refetch", on_click=q.refetch), + hint("Maestro waits for 'Data: fetched-value' after the fetch resolves."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_reducer.py b/examples/e2e-suite/app/screens/hooks/use_reducer.py new file mode 100644 index 0000000..3a44a69 --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_reducer.py @@ -0,0 +1,41 @@ +"""Demo screen for [`pn.use_reducer`][pythonnative.use_reducer]. + +A tiny reducer drives a +/-/reset counter. Maestro dispatches each +action via dedicated buttons and asserts the result line. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +def _reducer(state: int, action: str) -> int: + if action == "inc": + return state + 1 + if action == "dec": + return state - 1 + if action == "reset": + return 0 + return state + + +@pn.component +def UseReducerDemo() -> pn.Element: + """Render a 3-action reducer counter.""" + count, dispatch = pn.use_reducer(_reducer, 0) + + return demo_screen( + "use_reducer", + "Counter driven by a reducer with inc / dec / reset actions.", + section( + "Reducer counter", + result_text("Counter", count), + buttons_row( + pn.Button("Dispatch inc", on_click=lambda: dispatch("inc")), + pn.Button("Dispatch dec", on_click=lambda: dispatch("dec")), + pn.Button("Dispatch reset", on_click=lambda: dispatch("reset")), + ), + hint("Tap 'Dispatch inc' twice, assert 'Counter: 2'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_ref.py b/examples/e2e-suite/app/screens/hooks/use_ref.py new file mode 100644 index 0000000..ee10830 --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_ref.py @@ -0,0 +1,44 @@ +"""Demo screen for [`pn.use_ref`][pythonnative.use_ref]. + +``use_ref`` provides a mutable container whose changes don't trigger +a re-render. The demo combines a ref-backed counter with a +re-render trigger so Maestro can observe two distinct values: the +"silent" ref value (only visible on re-render) and the render-tracked +state value. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def UseRefDemo() -> pn.Element: + """Render a ref-driven counter and a render-trigger counter.""" + silent = pn.use_ref(0) + renders, set_renders = pn.use_state(0) + + def bump_silent() -> None: + silent["current"] += 1 + + def force_render() -> None: + set_renders(renders + 1) + + return demo_screen( + "use_ref", + "Compare a silent ref counter to a re-render-driving state counter.", + section( + "Counters", + result_text("Silent ref value", silent["current"]), + result_text("Renders", renders), + buttons_row( + pn.Button("Bump silent", on_click=bump_silent), + pn.Button("Force render", on_click=force_render), + ), + hint( + "Bump silent N times: 'Silent ref value' stays 0 until a " + "render happens. Tap 'Force render' to surface the new value." + ), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_state.py b/examples/e2e-suite/app/screens/hooks/use_state.py new file mode 100644 index 0000000..9d055ad --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_state.py @@ -0,0 +1,31 @@ +"""Demo screen for [`pn.use_state`][pythonnative.use_state]. + +Most basic hook demo: increment, decrement, reset. Maestro taps +"Increment" twice and asserts the value reaches 2. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def UseStateDemo() -> pn.Element: + """Render an int counter driven by use_state.""" + count, set_count = pn.use_state(0) + + return demo_screen( + "use_state", + "Counter driven by a single use_state hook.", + section( + "Counter", + result_text("Counter", count), + buttons_row( + pn.Button("Increment", on_click=lambda: set_count(count + 1)), + pn.Button("Decrement", on_click=lambda: set_count(count - 1)), + pn.Button("Reset", on_click=lambda: set_count(0)), + ), + hint("Maestro taps Increment twice, asserts 'Counter: 2'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/hooks/use_window_dimensions.py b/examples/e2e-suite/app/screens/hooks/use_window_dimensions.py new file mode 100644 index 0000000..a14f88f --- /dev/null +++ b/examples/e2e-suite/app/screens/hooks/use_window_dimensions.py @@ -0,0 +1,27 @@ +"""Demo screen for [`pn.use_window_dimensions`][pythonnative.use_window_dimensions]. + +The hook returns a ``{"width": float, "height": float}`` dict for the +current window. The exact values vary by device/emulator, so the demo +asserts the line is present and contains the expected ``×`` glyph. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@pn.component +def UseWindowDimensionsDemo() -> pn.Element: + """Render the current window dimensions in a stable, single line.""" + dims = pn.use_window_dimensions() + + return demo_screen( + "use_window_dimensions", + "Current window size, returned reactively by the hook.", + section( + "Dimensions", + result_text("Window", f"{int(dims['width'])} × {int(dims['height'])}"), + hint("Maestro asserts the 'Window:' line is visible (size varies)."), + ), + ) diff --git a/examples/e2e-suite/app/screens/layout/__init__.py b/examples/e2e-suite/app/screens/layout/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/layout/absolute_position.py b/examples/e2e-suite/app/screens/layout/absolute_position.py new file mode 100644 index 0000000..0dadf7b --- /dev/null +++ b/examples/e2e-suite/app/screens/layout/absolute_position.py @@ -0,0 +1,44 @@ +"""Demo screen for ``position: "absolute"`` styling. + +A dark canvas with four pinned corner labels and a centered label +using percentage offsets. All five labels must be visible. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + +_PIN = pn.style( + position="absolute", + background_color="#FBBF24", + padding=4, +) + + +@pn.component +def AbsolutePositionDemo() -> pn.Element: + """Render five absolutely-positioned labels on a dark canvas.""" + return demo_screen( + "Absolute positioning", + "Four corner labels and a centered label pinned absolutely.", + section( + "Canvas", + pn.View( + pn.View(pn.Text("abs-top-left"), style={**_PIN, "top": 4, "left": 4}), + pn.View(pn.Text("abs-top-right"), style={**_PIN, "top": 4, "right": 4}), + pn.View(pn.Text("abs-bottom-left"), style={**_PIN, "bottom": 4, "left": 4}), + pn.View(pn.Text("abs-bottom-right"), style={**_PIN, "bottom": 4, "right": 4}), + pn.View( + pn.Text("abs-center"), + style={**_PIN, "left": "30%", "right": "30%", "top": "40%"}, + ), + style=pn.style( + height=180, + background_color="#1E293B", + border_radius=8, + ), + ), + hint("Maestro asserts each of the five labels."), + ), + ) diff --git a/examples/e2e-suite/app/screens/layout/alignment.py b/examples/e2e-suite/app/screens/layout/alignment.py new file mode 100644 index 0000000..9f8fdb3 --- /dev/null +++ b/examples/e2e-suite/app/screens/layout/alignment.py @@ -0,0 +1,52 @@ +"""Demo screen for align_items / justify_content variants. + +Three rows demonstrate three different ``align_items`` values, each +with a labelled child so Maestro can confirm the layout pass renders +each variant. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +def _swatch(text: str) -> pn.Element: + return pn.View( + pn.Text(text, style=pn.style(color="#FFFFFF")), + style=pn.style(padding=8, background_color="#0EA5E9"), + ) + + +@pn.component +def AlignmentDemo() -> pn.Element: + """Render rows demonstrating three align_items values.""" + return demo_screen( + "Alignment", + "Three rows showing align_items: start, center, end.", + section( + "align_items: start", + pn.Row( + _swatch("align-start-a"), + _swatch("align-start-b"), + style=pn.style(align_items="flex_start", spacing=8, height=80, background_color="#F1F5F9"), + ), + ), + section( + "align_items: center", + pn.Row( + _swatch("align-center-a"), + _swatch("align-center-b"), + style=pn.style(align_items="center", spacing=8, height=80, background_color="#F1F5F9"), + ), + ), + section( + "align_items: end", + pn.Row( + _swatch("align-end-a"), + _swatch("align-end-b"), + style=pn.style(align_items="flex_end", spacing=8, height=80, background_color="#F1F5F9"), + ), + hint("Each row's children should sit at top, middle, bottom respectively."), + ), + ) diff --git a/examples/e2e-suite/app/screens/layout/aspect_ratio.py b/examples/e2e-suite/app/screens/layout/aspect_ratio.py new file mode 100644 index 0000000..3a7b5e1 --- /dev/null +++ b/examples/e2e-suite/app/screens/layout/aspect_ratio.py @@ -0,0 +1,45 @@ +"""Demo screen for ``aspect_ratio`` styling. + +Two sized boxes whose width is set explicitly; the height is computed +from ``aspect_ratio``. The squares should appear square and the +widescreen should be wider than tall. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def AspectRatioDemo() -> pn.Element: + """Render a 1:1 square and a 16:9 widescreen box.""" + return demo_screen( + "Aspect ratio", + "Width is fixed; height derives from aspect_ratio.", + section( + "1:1 + 16:9", + pn.Row( + pn.View( + pn.Text("aspect-1-1", style=pn.style(color="#FFFFFF")), + style=pn.style( + width=80, + aspect_ratio=1.0, + background_color="#0EA5E9", + padding=8, + ), + ), + pn.View( + pn.Text("aspect-16-9", style=pn.style(color="#FFFFFF")), + style=pn.style( + width=160, + aspect_ratio=16 / 9, + background_color="#22C55E", + padding=8, + ), + ), + style=pn.style(spacing=8), + ), + hint("Both labels must be visible; layout passes without crashing."), + ), + ) diff --git a/examples/e2e-suite/app/screens/layout/flex_layout.py b/examples/e2e-suite/app/screens/layout/flex_layout.py new file mode 100644 index 0000000..8c237b9 --- /dev/null +++ b/examples/e2e-suite/app/screens/layout/flex_layout.py @@ -0,0 +1,40 @@ +"""Demo screen for flex layout primitives. + +Three sibling boxes in a row: a fixed-width box, a ``flex: 1`` box, +and another fixed-width box. The middle box should stretch to fill +the available space. Maestro asserts the three labels appear in the +expected order. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def FlexLayoutDemo() -> pn.Element: + """Render three flex children in a row, with the middle one stretching.""" + return demo_screen( + "Flex layout", + "Three siblings: fixed, flex:1, fixed.", + section( + "Row with flex", + pn.Row( + pn.View( + pn.Text("flex-fixed-left", style=pn.style(color="#FFFFFF")), + style=pn.style(width=80, background_color="#0EA5E9", padding=8), + ), + pn.View( + pn.Text("flex-grow", style=pn.style(color="#FFFFFF")), + style=pn.style(flex=1, background_color="#22C55E", padding=8), + ), + pn.View( + pn.Text("flex-fixed-right", style=pn.style(color="#FFFFFF")), + style=pn.style(width=80, background_color="#0EA5E9", padding=8), + ), + style=pn.style(spacing=4, height=64), + ), + hint("Maestro asserts the three labels are visible together."), + ), + ) diff --git a/examples/e2e-suite/app/screens/layout/padding_margin.py b/examples/e2e-suite/app/screens/layout/padding_margin.py new file mode 100644 index 0000000..ddb4d9a --- /dev/null +++ b/examples/e2e-suite/app/screens/layout/padding_margin.py @@ -0,0 +1,49 @@ +"""Demo screen for padding / margin styling. + +Three boxes with different padding and margin values, all wrapped in +a colored parent so the relative spacing is visually obvious. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def PaddingMarginDemo() -> pn.Element: + """Render padded and margined siblings inside a colored parent.""" + return demo_screen( + "Padding & margin", + "Three boxes with different padding / margin values.", + section( + "Padding", + pn.View( + pn.Text("padding-4", style=pn.style(color="#FFFFFF", padding=4, background_color="#0EA5E9")), + pn.Text( + "padding-12", + style=pn.style(color="#FFFFFF", padding=12, background_color="#22C55E"), + ), + pn.Text( + "padding-24", + style=pn.style(color="#FFFFFF", padding=24, background_color="#F97316"), + ), + style=pn.style(spacing=8, padding=12, background_color="#E2E8F0", border_radius=8), + ), + hint("All three labels must be visible with visibly different padding."), + ), + section( + "Margin", + pn.Column( + pn.View( + pn.Text("margin-0", style=pn.style(color="#FFFFFF")), + style=pn.style(padding=6, background_color="#0EA5E9"), + ), + pn.View( + pn.Text("margin-12", style=pn.style(color="#FFFFFF")), + style=pn.style(padding=6, background_color="#22C55E", margin=12), + ), + style=pn.style(spacing=0, background_color="#E2E8F0", border_radius=8), + ), + ), + ) diff --git a/examples/e2e-suite/app/screens/navigation/__init__.py b/examples/e2e-suite/app/screens/navigation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/navigation/drawer_navigator.py b/examples/e2e-suite/app/screens/navigation/drawer_navigator.py new file mode 100644 index 0000000..2cef582 --- /dev/null +++ b/examples/e2e-suite/app/screens/navigation/drawer_navigator.py @@ -0,0 +1,55 @@ +"""Demo screen for [`pn.create_drawer_navigator`][pythonnative.create_drawer_navigator]. + +A nested Drawer navigator with two screens. We expose an explicit +"Open drawer" button rather than relying on swipe gestures so Maestro +can drive the demo deterministically. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + +_Drawer = pn.create_drawer_navigator() + + +@pn.component +def _DrawerOne() -> pn.Element: + nav = pn.use_navigation() + return pn.Column( + pn.Text("Drawer screen One", style=pn.style(font_size=18, font_weight="700")), + pn.Button("Open drawer", on_click=nav.open_drawer), + pn.Button("Go to Two", on_click=lambda: nav.navigate("Two")), + style=pn.style(spacing=8, padding=16), + ) + + +@pn.component +def _DrawerTwo() -> pn.Element: + nav = pn.use_navigation() + return pn.Column( + pn.Text("Drawer screen Two", style=pn.style(font_size=18, font_weight="700")), + pn.Button("Open drawer", on_click=nav.open_drawer), + pn.Button("Go to One", on_click=lambda: nav.navigate("One")), + style=pn.style(spacing=8, padding=16), + ) + + +@pn.component +def DrawerNavigatorDemo() -> pn.Element: + """Render a nested Drawer navigator with two screens.""" + return demo_screen( + "Drawer Navigator", + "Drawer with two screens; explicit Open drawer button.", + section( + "Drawer (nested)", + pn.View( + _Drawer.Navigator( + _Drawer.Screen("One", component=_DrawerOne, options={"title": "One"}), + _Drawer.Screen("Two", component=_DrawerTwo, options={"title": "Two"}), + ), + style=pn.style(height=320, border_radius=8, background_color="#F8FAFC"), + ), + hint("Maestro taps 'Go to Two' and asserts 'Drawer screen Two' is visible."), + ), + ) diff --git a/examples/e2e-suite/app/screens/navigation/focus_effect.py b/examples/e2e-suite/app/screens/navigation/focus_effect.py new file mode 100644 index 0000000..9b5fc2a --- /dev/null +++ b/examples/e2e-suite/app/screens/navigation/focus_effect.py @@ -0,0 +1,46 @@ +"""Demo screen for [`pn.use_focus_effect`][pythonnative.use_focus_effect]. + +The focus effect bumps a counter every time the screen gains focus. +Maestro pushes another screen on top, pops back, and asserts the +focus counter incremented. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def FocusEffectDemo() -> pn.Element: + """Render a focus counter bumped by ``use_focus_effect``.""" + focus_count, set_focus_count = pn.use_state(0) + nav = pn.use_navigation() + + def _on_focus() -> None: + # ``use_focus_effect`` re-runs every time the screen gains focus, + # so we don't need a cleanup here. + set_focus_count(focus_count + 1) + + pn.use_focus_effect(_on_focus, []) + + def push_and_pop_temp() -> None: + # Navigate to a sibling screen and immediately come back. The + # "use_state" route exists in the registry and has a back button. + nav.navigate("use_state") + + return demo_screen( + "use_focus_effect", + "Focus counter increments every time the screen comes back into focus.", + section( + "Focus", + result_text("Focus count", focus_count), + buttons_row( + pn.Button("Push another screen", on_click=push_and_pop_temp), + ), + hint( + "Push, then tap Back. The focus count should be at least 2 " + "after returning here (1 on mount, 1 on refocus)." + ), + ), + ) diff --git a/examples/e2e-suite/app/screens/navigation/params_passing.py b/examples/e2e-suite/app/screens/navigation/params_passing.py new file mode 100644 index 0000000..2f679da --- /dev/null +++ b/examples/e2e-suite/app/screens/navigation/params_passing.py @@ -0,0 +1,39 @@ +"""Demo screen for navigation params and [`pn.use_route`][pythonnative.use_route]. + +The Stack screen for this demo (route id ``"params_passing"``) reads +its route params via ``pn.use_route()``. To exercise that without +needing a second Stack screen, the demo navigates back to its own +route id with new params and asserts the readout updates. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + + +@pn.component +def ParamsPassingDemo() -> pn.Element: + """Render the active route's params using ``use_route``.""" + params = pn.use_route() + nav = pn.use_navigation() + + def push_with(value: str) -> None: + nav.navigate("params_passing", {"value": value}) + + return demo_screen( + "Route Params", + "use_route reads the active route's params; navigating with new params updates the readout.", + section( + "Route info", + result_text("Param 'value'", params.get("value") or "(none)"), + buttons_row( + pn.Button("Push value=alpha", on_click=lambda: push_with("alpha")), + pn.Button("Push value=beta", on_click=lambda: push_with("beta")), + ), + hint( + "Maestro taps 'Push value=alpha' and asserts \"Param 'value': alpha\". " + "Then taps the second button and asserts the param flipped to 'beta'." + ), + ), + ) diff --git a/examples/e2e-suite/app/screens/navigation/tab_navigator.py b/examples/e2e-suite/app/screens/navigation/tab_navigator.py new file mode 100644 index 0000000..14dbd37 --- /dev/null +++ b/examples/e2e-suite/app/screens/navigation/tab_navigator.py @@ -0,0 +1,61 @@ +"""Demo screen for [`pn.create_tab_navigator`][pythonnative.create_tab_navigator]. + +A nested Tab navigator with three labelled tabs lives inside the +current Stack screen. Maestro asserts that tab switching reveals the +expected body text on each tab. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + +_Tab = pn.create_tab_navigator() + + +@pn.component +def _TabAlpha() -> pn.Element: + return pn.Column( + pn.Text("Tab Alpha body", style=pn.style(font_size=18, font_weight="700")), + pn.Text("This is the Alpha tab content.", style=pn.style(color="#475569")), + style=pn.style(spacing=8, padding=16), + ) + + +@pn.component +def _TabBeta() -> pn.Element: + return pn.Column( + pn.Text("Tab Beta body", style=pn.style(font_size=18, font_weight="700")), + pn.Text("This is the Beta tab content.", style=pn.style(color="#475569")), + style=pn.style(spacing=8, padding=16), + ) + + +@pn.component +def _TabGamma() -> pn.Element: + return pn.Column( + pn.Text("Tab Gamma body", style=pn.style(font_size=18, font_weight="700")), + pn.Text("This is the Gamma tab content.", style=pn.style(color="#475569")), + style=pn.style(spacing=8, padding=16), + ) + + +@pn.component +def TabNavigatorDemo() -> pn.Element: + """Render a nested Tab navigator with three labelled tabs.""" + return demo_screen( + "Tab Navigator", + "Nested Tab navigator with three tabs. Tap each to reveal its body.", + section( + "Tabs (nested)", + pn.View( + _Tab.Navigator( + _Tab.Screen("Alpha", component=_TabAlpha, options={"title": "Alpha"}), + _Tab.Screen("Beta", component=_TabBeta, options={"title": "Beta"}), + _Tab.Screen("Gamma", component=_TabGamma, options={"title": "Gamma"}), + ), + style=pn.style(height=320, border_radius=8, background_color="#F8FAFC"), + ), + hint("Maestro asserts each tab's body after tapping its label."), + ), + ) diff --git a/examples/e2e-suite/app/screens/platform/__init__.py b/examples/e2e-suite/app/screens/platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/platform/platform_info.py b/examples/e2e-suite/app/screens/platform/platform_info.py new file mode 100644 index 0000000..0f1d217 --- /dev/null +++ b/examples/e2e-suite/app/screens/platform/platform_info.py @@ -0,0 +1,27 @@ +"""Demo screen for [`pn.Platform`][pythonnative.Platform]. + +Reads ``Platform.OS`` and ``Platform.Version`` and prints them. Maestro +asserts the OS line and version line are present; the version value +varies by device. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@pn.component +def PlatformInfoDemo() -> pn.Element: + """Render Platform.OS and Platform.Version.""" + return demo_screen( + "Platform info", + "Platform.OS and Platform.Version values.", + section( + "Platform values", + result_text("OS", pn.Platform.OS), + result_text("Version", pn.Platform.Version), + result_text("PythonNative", pn.__version__), + hint("Maestro asserts the OS line and the version line are visible."), + ), + ) diff --git a/examples/e2e-suite/app/screens/runtime/__init__.py b/examples/e2e-suite/app/screens/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/runtime/run_async_demo.py b/examples/e2e-suite/app/screens/runtime/run_async_demo.py new file mode 100644 index 0000000..8609076 --- /dev/null +++ b/examples/e2e-suite/app/screens/runtime/run_async_demo.py @@ -0,0 +1,34 @@ +"""Demo screen for [`pn.run_async`][pythonnative.run_async]. + +A button kicks an async coroutine that flips a result line after a +short sleep. Confirms the runtime's asyncio loop is wired up. +""" + +from __future__ import annotations + +import asyncio + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@pn.component +def RunAsyncDemo() -> pn.Element: + """Render a button that schedules an async coroutine via run_async.""" + last, set_last = pn.use_state("idle") + + async def _job() -> None: + set_last("running") + await asyncio.sleep(0.2) + set_last("done") + + return demo_screen( + "run_async", + "Fire-and-forget a coroutine on the framework asyncio loop.", + section( + "Async job", + result_text("Status", last), + pn.Button("Run async job", on_click=lambda: pn.run_async(_job())), + hint("Maestro taps the button and waits for 'Status: done'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/scaffold.py b/examples/e2e-suite/app/screens/scaffold.py new file mode 100644 index 0000000..c7f51bb --- /dev/null +++ b/examples/e2e-suite/app/screens/scaffold.py @@ -0,0 +1,97 @@ +"""Shared layout shell used by every demo screen. + +``demo_screen`` renders a consistent structure so flows have a small, +predictable set of strings to wait on: + +- ``"Demo: <title>"`` — anchor text marking that the demo loaded. Maestro + flows start with ``extendedWaitUntil: visible: "Demo: <title>"`` so they + wait for the screen to render before interacting. +- ``"Back to list"`` — the bottom button that pops back to the category + list. Every demo has it in the same place, so cleanup is identical + across flows. + +Demo screens supply only the body content; everything around it is +boilerplate kept in one file so the surface area each Maestro flow +needs to learn stays small. +""" + +from __future__ import annotations + +from typing import Iterable + +import pythonnative as pn +from app.theme import styles + + +def demo_screen( + title: str, + summary: str, + *body: pn.Element, +) -> pn.Element: + """Render a demo screen with a stable header, body, and back button. + + Args: + title: Demo title shown as ``"Demo: <title>"`` text. This is + the canonical "screen loaded" marker for Maestro flows. + summary: One-line description of what the demo demonstrates. + Shown beneath the title in the secondary text style. + *body: Children that contain the actual demo content. + + Returns: + A scrollable [`pn.ScrollView`][pythonnative.ScrollView] + wrapping the title, summary, body, and a back button. + """ + nav = pn.use_navigation() + return pn.ScrollView( + pn.Column( + pn.Text(f"Demo: {title}", style=styles["title"]), + pn.Text(summary, style=styles["subtitle"]), + *body, + pn.Button("Back to list", on_click=nav.go_back), + style=styles["screen"], + ) + ) + + +def card(*children: pn.Element) -> pn.Element: + """Wrap demo children in a soft card so the body has visual structure.""" + return pn.View(*children, style=styles["card"]) + + +def result_text(prefix: str, value: object) -> pn.Element: + """Render a ``"<prefix>: <value>"`` line in the bright result style. + + Used by demos to expose dynamic state Maestro can assert against. + The exact whitespace is preserved so flows can match exactly: + + ``assertVisible: "Counter: 5"`` + """ + return pn.Text(f"{prefix}: {value}", style=styles["result"]) + + +def label(text: str) -> pn.Element: + """Render a small label above a control.""" + return pn.Text(text, style=styles["section_title"]) + + +def hint(text: str) -> pn.Element: + """Render a quieter explanatory line.""" + return pn.Text(text, style=styles["hint"]) + + +def section(title: str, *children: pn.Element) -> pn.Element: + """A titled card with multiple children, separated by spacing.""" + return card(label(title), *children) + + +def buttons_row(*buttons: pn.Element) -> pn.Element: + """Lay out a horizontal row of buttons with consistent spacing.""" + return pn.Row(*buttons, style=styles["row"]) + + +def list_lines(lines: Iterable[str]) -> pn.Element: + """Render a vertical column of plain text lines (label style).""" + return pn.Column( + *[pn.Text(line, style=styles["label"]) for line in lines], + style=pn.style(spacing=4), + ) diff --git a/examples/e2e-suite/app/screens/sdk/__init__.py b/examples/e2e-suite/app/screens/sdk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/sdk/custom_component.py b/examples/e2e-suite/app/screens/sdk/custom_component.py new file mode 100644 index 0000000..0ef800f --- /dev/null +++ b/examples/e2e-suite/app/screens/sdk/custom_component.py @@ -0,0 +1,47 @@ +"""Demo screen for [`pn.sdk`][pythonnative.sdk] surface inspection. + +Registering a real cross-platform custom native component requires +``ViewHandler`` implementations for iOS and Android, which is more +than a screen-level demo can do safely. The demo limits itself to +exercising the SDK surface: it confirms the headline exports are +importable, builds a frozen [`Props`][pythonnative.sdk.Props] +subclass, and reads back the registry. If any of those break, this +screen will fail to import and the flow will error out during boot — +which is exactly what we want. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, result_text, section + + +@dataclass(frozen=True) +class _DemoProps(pn.Props): + """Tiny custom Props subclass used purely to exercise the SDK type.""" + + label: str = "" + + +@pn.component +def CustomComponentDemo() -> pn.Element: + """Inspect the SDK surface without registering a new platform handler.""" + custom_registered = pn.sdk.list_components() + props_instance = _DemoProps(label="hello") + + return demo_screen( + "Custom component", + "SDK surface check: Props subclass + registry inspection.", + section( + "SDK status", + result_text("Props subclass works", "yes" if props_instance.label == "hello" else "no"), + result_text("SDK module loaded", "yes" if hasattr(pn.sdk, "Props") else "no"), + result_text("Custom components registered", len(custom_registered)), + hint( + "Both 'yes' lines must render. The count is 0 in a stock " + "install (no third-party native components present)." + ), + ), + ) diff --git a/examples/e2e-suite/app/screens/storage/__init__.py b/examples/e2e-suite/app/screens/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/storage/async_storage_demo.py b/examples/e2e-suite/app/screens/storage/async_storage_demo.py new file mode 100644 index 0000000..ba93ad4 --- /dev/null +++ b/examples/e2e-suite/app/screens/storage/async_storage_demo.py @@ -0,0 +1,45 @@ +"""Demo screen for [`pn.AsyncStorage`][pythonnative.AsyncStorage]. + +Saves a value under a stable key, then reads it back into a result +line. Maestro taps "Write", taps "Read", and asserts the read value. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import buttons_row, demo_screen, hint, result_text, section + +_KEY = "e2e.async_storage_demo" + + +@pn.component +def AsyncStorageDemo() -> pn.Element: + """Render write / read / clear buttons for an AsyncStorage entry.""" + value, set_value = pn.use_state("(unread)") + + async def _write() -> None: + await pn.AsyncStorage.set(_KEY, "stored-value") + set_value("(unread)") + + async def _read() -> None: + v = await pn.AsyncStorage.get(_KEY) + set_value(v or "(none)") + + async def _clear() -> None: + await pn.AsyncStorage.delete(_KEY) + set_value("(unread)") + + return demo_screen( + "AsyncStorage", + "Write a value, read it back, optionally clear it.", + section( + "Storage I/O", + result_text("Read value", value), + buttons_row( + pn.Button("Write", on_click=lambda: pn.run_async(_write())), + pn.Button("Read", on_click=lambda: pn.run_async(_read())), + pn.Button("Clear", on_click=lambda: pn.run_async(_clear())), + ), + hint("Tap Write, then Read; assert 'Read value: stored-value'."), + ), + ) diff --git a/examples/e2e-suite/app/screens/styling/__init__.py b/examples/e2e-suite/app/screens/styling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/e2e-suite/app/screens/styling/borders_shadows.py b/examples/e2e-suite/app/screens/styling/borders_shadows.py new file mode 100644 index 0000000..3710dbe --- /dev/null +++ b/examples/e2e-suite/app/screens/styling/borders_shadows.py @@ -0,0 +1,44 @@ +"""Demo screen for borders, border_radius, and shadows. + +A rounded card with a 1px border and a soft shadow. The exact pixel +output is platform-specific but the demo asserts the element renders +with its label. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def BordersShadowsDemo() -> pn.Element: + """Render a card with borders, radius, and shadow styling.""" + return demo_screen( + "Borders & shadows", + "Card with border, radius, and shadow / elevation.", + section( + "Card", + pn.View( + pn.Text("border-shadow-card", style=pn.style(font_weight="600", font_size=16)), + pn.Text( + "Inside a card with a soft shadow", + style=pn.style(color="#475569", font_size=13), + ), + style=pn.style( + padding=16, + background_color="#FFFFFF", + border_radius=12, + border_width=1, + border_color="#E2E8F0", + shadow_color="#000000", + shadow_offset={"width": 0, "height": 4}, + shadow_opacity=0.08, + shadow_radius=10, + elevation=4, + spacing=6, + ), + ), + hint("Maestro asserts the 'border-shadow-card' label."), + ), + ) diff --git a/examples/e2e-suite/app/screens/styling/stylesheet_demo.py b/examples/e2e-suite/app/screens/styling/stylesheet_demo.py new file mode 100644 index 0000000..7a7c4d9 --- /dev/null +++ b/examples/e2e-suite/app/screens/styling/stylesheet_demo.py @@ -0,0 +1,48 @@ +"""Demo screen for [`pn.StyleSheet`][pythonnative.StyleSheet] and +[`pn.style`][pythonnative.style]. + +A small style sheet is created locally; the screen reuses each entry +in a different element so flows can confirm the StyleSheet entries +resolve to working styles. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + +_sheet = pn.StyleSheet.create( + pill=pn.style( + padding=8, + background_color="#0EA5E9", + border_radius=999, + ), + pill_label=pn.style(color="#FFFFFF", font_weight="600"), + danger=pn.style( + padding=8, + background_color="#DC2626", + border_radius=8, + ), + danger_label=pn.style(color="#FFFFFF", font_weight="700"), +) + + +@pn.component +def StyleSheetDemo() -> pn.Element: + """Render two elements styled via shared StyleSheet entries.""" + return demo_screen( + "StyleSheet", + "Reusable styles via StyleSheet.create + pn.style.", + section( + "Pills", + pn.View( + pn.Text("stylesheet-pill", style=_sheet["pill_label"]), + style=_sheet["pill"], + ), + pn.View( + pn.Text("stylesheet-danger", style=_sheet["danger_label"]), + style=_sheet["danger"], + ), + hint("Maestro asserts both labels are visible."), + ), + ) diff --git a/examples/e2e-suite/app/screens/styling/transform.py b/examples/e2e-suite/app/screens/styling/transform.py new file mode 100644 index 0000000..c3fd430 --- /dev/null +++ b/examples/e2e-suite/app/screens/styling/transform.py @@ -0,0 +1,51 @@ +"""Demo screen for the ``transform`` style. + +The ``transform`` style accepts a list of transform specs (translate, +rotate, scale). The demo applies one of each so flows can confirm the +element instantiates without error. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def TransformDemo() -> pn.Element: + """Render boxes with translate, rotate, and scale transforms.""" + return demo_screen( + "Transforms", + "Boxes with translate, rotate, and scale transforms.", + section( + "Transformed boxes", + pn.Row( + pn.View( + pn.Text("translate", style=pn.style(color="#FFFFFF")), + style=pn.style( + padding=8, + background_color="#0EA5E9", + transform=[{"translate_x": 12}], + ), + ), + pn.View( + pn.Text("rotate", style=pn.style(color="#FFFFFF")), + style=pn.style( + padding=8, + background_color="#22C55E", + transform=[{"rotate": 15.0}], + ), + ), + pn.View( + pn.Text("scale", style=pn.style(color="#FFFFFF")), + style=pn.style( + padding=8, + background_color="#F97316", + transform=[{"scale": 1.2}], + ), + ), + style=pn.style(spacing=12, padding=16), + ), + hint("Maestro asserts each of the three transform labels."), + ), + ) diff --git a/examples/e2e-suite/app/screens/styling/typography.py b/examples/e2e-suite/app/screens/styling/typography.py new file mode 100644 index 0000000..10504ac --- /dev/null +++ b/examples/e2e-suite/app/screens/styling/typography.py @@ -0,0 +1,32 @@ +"""Demo screen for typography styling. + +Shows several font sizes, weights, colors, and a text-decoration +example. Maestro asserts each labelled line is present. +""" + +from __future__ import annotations + +import pythonnative as pn +from app.screens.scaffold import demo_screen, hint, section + + +@pn.component +def TypographyDemo() -> pn.Element: + """Render text in several typographic styles.""" + return demo_screen( + "Typography", + "Six text variants with different size, weight, color, decoration.", + section( + "Variants", + pn.Text("type-headline", style=pn.style(font_size=24, font_weight="700")), + pn.Text("type-body", style=pn.style(font_size=16)), + pn.Text("type-caption", style=pn.style(font_size=12, color="#6B7280")), + pn.Text("type-italic", style=pn.style(font_size=15, font_style="italic")), + pn.Text("type-underline", style=pn.style(font_size=15, text_decoration="underline")), + pn.Text( + "type-letter-spacing", + style=pn.style(font_size=15, letter_spacing=2.0), + ), + hint("Maestro asserts each labelled line."), + ), + ) diff --git a/examples/e2e-suite/app/theme.py b/examples/e2e-suite/app/theme.py new file mode 100644 index 0000000..f589cdb --- /dev/null +++ b/examples/e2e-suite/app/theme.py @@ -0,0 +1,29 @@ +"""Shared styles for the E2E suite app. + +Every demo screen reuses the same handful of styles so flows can rely +on consistent layout (no surprise scrolling, predictable spacing). The +exact visual style is unimportant; what matters is that text labels +are large enough for Maestro to find them and that controls don't +overlap. +""" + +import pythonnative as pn + +styles = pn.StyleSheet.create( + screen=pn.style(spacing=12, padding=16, align_items="stretch"), + title=pn.style(font_size=22, bold=True, color="#0F172A"), + subtitle=pn.style(font_size=14, color="#475569"), + section_title=pn.style(font_size=16, font_weight="600", color="#0F172A"), + label=pn.style(font_size=14, color="#1F2937"), + result=pn.style(font_size=15, font_weight="600", color="#047857"), + hint=pn.style(font_size=12, color="#6B7280"), + card=pn.style( + padding=12, + spacing=8, + background_color="#F8FAFC", + border_radius=8, + border_width=1, + border_color="#E2E8F0", + ), + row=pn.style(spacing=8, align_items="center"), +) diff --git a/examples/e2e-suite/pythonnative.json b/examples/e2e-suite/pythonnative.json new file mode 100644 index 0000000..1f7ce5d --- /dev/null +++ b/examples/e2e-suite/pythonnative.json @@ -0,0 +1,8 @@ +{ + "name": "PythonNative E2E Suite", + "appId": "com.pythonnative.e2e", + "entryPoint": "app/main.py", + "pythonVersion": "3.11", + "ios": {}, + "android": {} +} diff --git a/examples/e2e-suite/requirements.txt b/examples/e2e-suite/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/mypy.ini b/mypy.ini index 370f2f6..8c39759 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,7 +7,12 @@ warn_return_any = False strict_optional = False pretty = True files = src, tests, examples -exclude = (^build/|^examples/.*/build/) +# `examples/e2e-suite/app/` shares the top-level package name `app` with +# `examples/hello-world/app/`, which makes mypy fail with a duplicate-module +# error in whole-tree mode. The suite is exercised end-to-end by Maestro and +# imports `app.screens.*` via a PYTHONPATH that only `pn run` sets, so it is +# not a meaningful mypy target. Ruff + Black still cover it. +exclude = (^build/|^examples/.*/build/|^examples/e2e-suite/) disallow_untyped_defs = True disallow_incomplete_defs = True diff --git a/scripts/check-e2e-coverage.py b/scripts/check-e2e-coverage.py new file mode 100755 index 0000000..32d0d30 --- /dev/null +++ b/scripts/check-e2e-coverage.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Verify every public ``pythonnative`` symbol is exercised by an E2E demo. + +Run as part of ``./scripts/check.sh`` or directly: + + python scripts/check-e2e-coverage.py + +The script: + +1. Reads ``pythonnative.__all__`` to get the list of public symbols. +2. Reads ``examples/e2e-suite/app/registry.py`` to discover every + demo's ``feature`` field. Each ``feature`` either matches a name + in ``__all__`` or uses a ``"category::name"`` form for sub-features + that aren't directly listed. +3. Reports any public symbol not covered by at least one demo. +4. Reports any demo flow file missing for a registered demo. + +Exit code is ``0`` when every public symbol is covered and every demo +has a matching flow file; ``1`` otherwise. + +This script is *static analysis only*: it doesn't run the app or the +flows. It exists so that adding a new public export to ``pythonnative`` +without adding an E2E demo is a CI failure rather than a silent +regression. +""" + +from __future__ import annotations + +import ast +import json +import re +import sys +from pathlib import Path +from typing import Iterable, Set + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" / "pythonnative" / "__init__.py" +REGISTRY = ROOT / "examples" / "e2e-suite" / "app" / "registry.py" +FLOWS_DIR = ROOT / "tests" / "e2e" / "flows" + +# Symbols intentionally NOT covered by a directly-mapped E2E flow. +# Each entry has a comment explaining why the symbol is exempt: type +# alias, ambient infrastructure exercised by every flow, or platform +# capability that requires real device hardware. New entries should +# justify themselves in the same way. +INTENTIONAL_EXEMPTIONS: Set[str] = { + # -------------------------------------------------------------- + # Type-only re-exports — statically checkable, no UI surface. + # -------------------------------------------------------------- + "Element", + "AlignItems", + "AlignSelf", + "AutoCapitalize", + "Color", + "Dimension", + "EdgeInsets", + "FlexDirection", + "FontWeight", + "JustifyContent", + "KeyboardType", + "Overflow", + "Position", + "ReturnKeyType", + "ScaleType", + "ShadowOffset", + "Style", + "StyleProp", + "TextAlign", + "TextDecoration", + "ThemeContext", + "TransformSpec", + "AnimatedValue", # observed via use_animated_value usage in animations + "QueryResult", # observed via use_query demo + "MutationCall", # observed via use_mutation demo + "MutationState", # observed via use_mutation demo + # -------------------------------------------------------------- + # Built-in Props dataclasses — exercised indirectly via their + # corresponding component demos. + # -------------------------------------------------------------- + "ActivityIndicatorProps", + "ButtonProps", + "ImageProps", + "KeyboardAvoidingViewProps", + "ModalProps", + "PickerProps", + "PressableProps", + "ProgressBarProps", + "SafeAreaViewProps", + "ScrollViewProps", + "SliderProps", + "SpacerProps", + "StatusBarProps", + "SwitchProps", + "TextInputProps", + "TextProps", + "ViewProps", + "WebViewProps", + # -------------------------------------------------------------- + # Ambient infrastructure exercised by every flow (importing the + # app + rendering any screen invokes them, so a dedicated demo + # would be redundant). + # -------------------------------------------------------------- + "component", # @pn.component decorator — every screen uses it + "Column", # used everywhere; co-tested with View in ViewColumnRowDemo + "Row", # used everywhere; co-tested with View in ViewColumnRowDemo + "NavigationContainer", # wraps the root navigator; used by main.py + "create_stack_navigator", # used by main.py; every flow pushes screens + "create_screen", # platform bridge invoked by the native templates + "use_navigation", # used by every screen for go_back + "use_animated_value", # used in every animation demo + "Provider", # covered by use_context demo + "create_context", # covered by use_context demo + "style", # the style helper itself is exercised by every styled demo + "resolve_style", # internal helper exposed for SDK authors + # -------------------------------------------------------------- + # Hooks whose values depend on physical device state that an + # emulator/simulator can't reliably reproduce. + # -------------------------------------------------------------- + "use_safe_area_insets", # no reliable insets in emulator viewport + "use_keyboard_height", # requires a real keyboard transition + # -------------------------------------------------------------- + # Networking + native-module surfaces. These need network or + # platform hardware (camera, location, notifications) that CI + # emulators don't reliably provide; tested via unit tests in + # tests/test_net.py and the native_modules dummy paths. + # -------------------------------------------------------------- + "fetch", + "HTTPError", + "Response", + "Camera", + "FileSystem", + "Location", + "Notifications", + # -------------------------------------------------------------- + # SDK re-exports — module-level names mirroring submodule content. + # -------------------------------------------------------------- + "runtime", # module re-export; run_async demo covers it + "sdk", # module re-export; custom_component demo covers it + "ViewHandler", # ABC; subclassed by built-in handlers + "element_factory", # tested via unit tests; needs registered handlers + "register_component", # tested via unit tests; needs handler implementations + "Props", # tested in custom_component demo and SDK unit tests +} + + +def _public_symbols() -> Set[str]: + """Return the values of ``__all__`` in the pythonnative package. + + Parsed via ``ast`` rather than imported, so the check can run + without instantiating the package (which would pull in iOS/Android + code paths that fail off-device). + """ + tree = ast.parse(SRC.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + if isinstance(node.value, ast.List): + names: Set[str] = set() + for el in node.value.elts: + if isinstance(el, ast.Constant) and isinstance(el.value, str): + names.add(el.value) + return names + raise RuntimeError(f"No __all__ list found in {SRC}") + + +def _read_registry_features() -> Set[str]: + text = REGISTRY.read_text(encoding="utf-8") + # Match: DemoEntry("id", "Category", "Title", "feature", ComponentName), + pattern = re.compile( + r'DemoEntry\(\s*"(?P<id>[^"]+)"\s*,\s*"(?P<category>[^"]+)"\s*,' + r'\s*"(?P<title>[^"]+)"\s*,\s*"(?P<feature>[^"]+)"\s*,', + re.MULTILINE, + ) + return {m.group("feature") for m in pattern.finditer(text)} + + +def _read_registry_ids() -> Set[str]: + text = REGISTRY.read_text(encoding="utf-8") + pattern = re.compile(r'DemoEntry\(\s*"(?P<id>[^"]+)"', re.MULTILINE) + return {m.group("id") for m in pattern.finditer(text)} + + +def _flow_files() -> Set[str]: + """Return the set of demo ids that have a flow file.""" + ids: Set[str] = set() + for path in FLOWS_DIR.rglob("*.yaml"): + # Flow filename is the demo id (e.g. ``use_state.yaml``). + ids.add(path.stem) + return ids + + +def _format_report( + public: Iterable[str], + features: Set[str], + demo_ids: Set[str], + flow_ids: Set[str], +) -> dict: + public_set = set(public) + direct_covered = features & public_set + missing_public = public_set - features - INTENTIONAL_EXEMPTIONS + missing_flows = demo_ids - flow_ids + extra_flows = flow_ids - demo_ids + return { + "public_symbols": len(public_set), + "demos": len(demo_ids), + "flows": len(flow_ids), + "directly_covered_public_symbols": sorted(direct_covered), + "missing_public_symbols": sorted(missing_public), + "demos_without_flow": sorted(missing_flows), + "flow_files_without_demo": sorted(extra_flows), + } + + +def main(argv: list[str]) -> int: + """Run the coverage check and print a human or JSON report.""" + public = _public_symbols() + features = _read_registry_features() + demo_ids = _read_registry_ids() + flow_ids = _flow_files() + + report = _format_report(public, features, demo_ids, flow_ids) + + json_mode = "--json" in argv + if json_mode: + print(json.dumps(report, indent=2)) + else: + print("E2E coverage report") + print("===================") + print(f" Public symbols in pythonnative.__all__: {report['public_symbols']}") + print(f" Demo entries in registry: {report['demos']}") + print(f" Flow files under tests/e2e/flows: {report['flows']}") + print(f" Public symbols directly covered: {len(report['directly_covered_public_symbols'])}") + print(f" Intentional exemptions: {len(INTENTIONAL_EXEMPTIONS)}") + if report["missing_public_symbols"]: + print() + print(" ERROR: the following public symbols have no E2E demo:") + for name in report["missing_public_symbols"]: + print(f" - {name}") + print() + print( + " Fix: add a DemoEntry to examples/e2e-suite/app/registry.py " + 'with feature="<name>", create the screen, and add a ' + "tests/e2e/flows/<category>/<id>.yaml file.\n" + " Alternatively, if the symbol genuinely can't be tested " + "via a UI flow, add it to INTENTIONAL_EXEMPTIONS in this " + "script with a justification comment." + ) + if report["demos_without_flow"]: + print() + print(" ERROR: the following registered demos have no flow file:") + for name in report["demos_without_flow"]: + print(f" - {name}") + if report["flow_files_without_demo"]: + print() + print(" WARNING: the following flow files don't match any registered demo:") + for name in report["flow_files_without_demo"]: + print(f" - {name}") + + failed = bool(report["missing_public_symbols"]) or bool(report["demos_without_flow"]) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/check.sh b/scripts/check.sh index 33b4025..e74b8aa 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -49,4 +49,7 @@ step "Build package (sdist + wheel)" step "Run tests (pytest)" "$PY" -m pytest -q +step "Check E2E coverage" +"$PY" scripts/check-e2e-coverage.py + printf "\nAll CI checks passed.\n" diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh new file mode 100755 index 0000000..752c349 --- /dev/null +++ b/scripts/run-e2e.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Build the e2e-suite example app and run the comprehensive Maestro suite. +# +# This script is the supported way for AI agents and humans to run the +# full E2E pass locally. It mirrors what CI does in .github/workflows/e2e.yml. +# +# Usage: +# ./scripts/run-e2e.sh android [suite] +# ./scripts/run-e2e.sh ios [suite] +# +# Examples: +# ./scripts/run-e2e.sh android # full suite +# ./scripts/run-e2e.sh android components # only the components suite +# ./scripts/run-e2e.sh ios hooks # only the hooks suite on iOS +# +# Available suites: full, components, hooks, navigation, layout, styling, +# animations, misc. +# +# Prerequisites: +# - `pn` CLI available (e.g. via `pip install -e .`). +# - `maestro` CLI on PATH (https://maestro.dev/). +# - For Android: an emulator running. +# - For iOS: a simulator running (and `idb-companion` installed). +# +# The script: +# 1. Builds + installs the e2e-suite app via `pn run <platform> --no-logs`. +# 2. Picks the right Maestro YAML based on platform + suite. +# 3. Runs `maestro test` up to ``MAESTRO_MAX_ATTEMPTS`` times (default +# 2) and exits with the last attempt's exit code. +# +# A successful run prints "All E2E suites passed." at the end and exits 0. +# Any failed flow is reported by Maestro in its standard format; see +# tests/e2e/AGENTS.md for guidance on interpreting failures. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +PLATFORM="${1:-android}" +SUITE="${2:-full}" + +case "$PLATFORM" in + android|ios) ;; + *) + echo "Error: platform must be 'android' or 'ios' (got: $PLATFORM)" >&2 + exit 2 + ;; +esac + +if ! command -v pn > /dev/null; then + echo "Error: 'pn' CLI not found on PATH. Install via 'pip install -e .'" >&2 + exit 2 +fi + +if ! command -v maestro > /dev/null; then + echo "Error: 'maestro' CLI not found on PATH." >&2 + echo "Install: curl -Ls 'https://get.maestro.mobile.dev' | bash" >&2 + exit 2 +fi + +case "$PLATFORM" in + android) APP_ID="com.pythonnative.android_template" ;; + ios) APP_ID="com.pythonnative.ios-template" ;; +esac + +case "$SUITE" in + full) + if [[ "$PLATFORM" == "android" ]]; then + MAESTRO_TARGET="tests/e2e/android.yaml" + else + MAESTRO_TARGET="tests/e2e/ios.yaml" + fi + ;; + components|hooks|navigation|layout|styling|animations|misc) + MAESTRO_TARGET="tests/e2e/suites/${SUITE}.yaml" + ;; + *) + echo "Error: unknown suite '$SUITE'" >&2 + echo "Available suites: full, components, hooks, navigation, layout, styling, animations, misc" >&2 + exit 2 + ;; +esac + +printf "\n==> Building e2e-suite app for %s\n" "$PLATFORM" +pushd examples/e2e-suite > /dev/null +pn run "$PLATFORM" --no-logs +popd > /dev/null + +run_maestro() { + if [[ "$PLATFORM" == "ios" ]]; then + maestro --platform ios test -e "APP_ID=$APP_ID" "$MAESTRO_TARGET" + else + maestro test -e "APP_ID=$APP_ID" "$MAESTRO_TARGET" + fi +} + +printf "\n==> Running Maestro suite: %s\n" "$MAESTRO_TARGET" + +# Maestro's iOS XCUITest driver occasionally loses its connection to the +# app during long suites and surfaces transient "Application is not +# running" / "Request for viewHierarchy failed" errors that have nothing +# to do with the test under test. Allow one automatic retry of the whole +# suite (overridable via ``MAESTRO_MAX_ATTEMPTS``) so CI doesn't fail on +# driver flakes. A retry can also mask a genuine race in the suite, so +# treat the "retrying..." line as a signal to investigate, not just to +# trust the second pass. +MAX_ATTEMPTS="${MAESTRO_MAX_ATTEMPTS:-2}" +attempt=1 +while (( attempt <= MAX_ATTEMPTS )); do + if run_maestro; then + break + fi + if (( attempt == MAX_ATTEMPTS )); then + printf "\nMaestro suite failed after %d attempt(s).\n" "$attempt" >&2 + exit 1 + fi + printf "\n==> Maestro suite failed (attempt %d/%d); retrying...\n" \ + "$attempt" "$MAX_ATTEMPTS" >&2 + attempt=$(( attempt + 1 )) +done + +printf "\nAll E2E suites passed.\n" diff --git a/src/pythonnative/animated.py b/src/pythonnative/animated.py index 0597413..f8eb36b 100644 --- a/src/pythonnative/animated.py +++ b/src/pythonnative/animated.py @@ -62,6 +62,12 @@ async def fade_in(): _TARGET_FPS = 60.0 _FRAME_DT = 1.0 / _TARGET_FPS +# Upper bound on how much wall-clock time the animation loop will try to +# catch up on in a single iteration after thread starvation. At 60 fps +# this is ~333 ms of simulated motion; further drift is dropped to keep +# the loop responsive. +_MAX_CATCHUP_FRAMES = 20 + _EASINGS: Dict[str, Callable[[float], float]] = { "linear": lambda t: t, "ease_in": lambda t: t * t, @@ -199,6 +205,20 @@ def _ensure_thread_locked(self) -> None: def _loop(self) -> None: last = time.monotonic() + # Clamping the per-tick dt is important for numerical stability: + # an underdamped spring with a 0.3 s step explodes immediately, + # and on iOS/Android the animation thread can be starved for + # several frames during render bursts. We integrate physics on a + # clamped dt (max 2 target frames) and sub-step when wall-clock + # has advanced more than that, so the perceived motion still + # tracks real time at most a couple of frames behind. After an + # extreme starvation (e.g. the app was backgrounded for seconds) + # we cap the catch-up at ``_MAX_CATCHUP_FRAMES`` worth of + # physics; any further wall-clock drift is dropped on the floor, + # which keeps the loop responsive instead of spinning forward + # through hundreds of substeps. + max_step = _FRAME_DT * 2.0 + max_catchup = _FRAME_DT * _MAX_CATCHUP_FRAMES while not self._stopped: now = time.monotonic() dt = now - last @@ -209,13 +229,19 @@ def _loop(self) -> None: time.sleep(0.05) last = time.monotonic() continue - for anim in active: - try: - finished = anim.advance(dt) - except Exception: - finished = True - if finished: - self.remove(anim) + remaining = min(dt, max_catchup) + while remaining > 0.0: + step = remaining if remaining <= max_step else max_step + remaining -= step + for anim in active: + if getattr(anim, "_completed", False): + continue + try: + finished = anim.advance(step) + except Exception: + finished = True + if finished: + self.remove(anim) time.sleep(_FRAME_DT) diff --git a/src/pythonnative/layout.py b/src/pythonnative/layout.py index a24e62b..3abc4c6 100644 --- a/src/pythonnative/layout.py +++ b/src/pythonnative/layout.py @@ -401,7 +401,17 @@ class LayoutNode: height: Computed height in points. """ - __slots__ = ("style", "children", "measure", "user_data", "x", "y", "width", "height") + __slots__ = ( + "style", + "children", + "measure", + "user_data", + "x", + "y", + "width", + "height", + "_pn_scroll_axis", + ) def __init__( self, @@ -418,6 +428,14 @@ def __init__( self.y: float = 0.0 self.width: float = 0.0 self.height: float = 0.0 + # ``"x"``/``"y"`` for scroll containers; ``None`` for everything + # else. Consumed by ``_measure_container`` to clamp the node's + # own main-axis size to the parent's available space while still + # measuring children unbounded on the scroll axis (which is what + # makes the native ``UIScrollView`` / Android ``ScrollView`` + # actually scroll). The reconciler stamps this when building the + # layout tree for ``ScrollView`` elements. + self._pn_scroll_axis: Optional[str] = None def __repr__(self) -> str: return ( @@ -576,6 +594,22 @@ def _measure_container( width = explicit_w if explicit_w is not None else (used_w + pad_x) height = explicit_h if explicit_h is not None else (used_h + pad_y) + + # Scroll containers: clamp the container's own main-axis size to the + # parent's available space when no explicit size was provided. The + # children are still measured against an unbounded main-axis (handled + # via the wrapper inserted in ``Reconciler._build_layout_tree``) so the + # overflow becomes the scrollable region. Without this clamp, the + # container would grow to fit its content and there would be no + # overflow for the native ScrollView to scroll. Skipped when the + # parent is itself unbounded, so nested scroll views still fall back + # to natural sizing (the inner scroll is unscrollable in that case, + # which matches the behavior in React Native). + scroll_axis = getattr(node, "_pn_scroll_axis", None) + if scroll_axis == "y" and explicit_h is None and math.isfinite(avail_h): + height = avail_h + elif scroll_axis == "x" and explicit_w is None and math.isfinite(avail_w): + width = avail_w return width, height diff --git a/src/pythonnative/native_views/android.py b/src/pythonnative/native_views/android.py index d1b1dd3..1bd530b 100644 --- a/src/pythonnative/native_views/android.py +++ b/src/pythonnative/native_views/android.py @@ -35,6 +35,7 @@ _pn_view_visual_props: dict = {} _DRAWABLE_STYLE_KEYS = ("background_color", "border_radius", "border_width", "border_color") + # ====================================================================== # Shared helpers # ====================================================================== @@ -509,18 +510,27 @@ def onClick(self, view: Any) -> None: class ScrollViewHandler(AndroidViewHandler): """Scroll container — wraps a single child whose height is unbounded. + Uses ``androidx.core.widget.NestedScrollView`` rather than the + framework ``android.widget.ScrollView`` because the framework + ScrollView always intercepts vertical gestures, even when it has + no overflow. That breaks the common case of nesting a small + fixed-height scroll view inside a screen-level scroll view (the + outer steals every gesture and the inner never scrolls). + ``NestedScrollView`` implements the standard + ``NestedScrollingParent2`` / ``NestedScrollingChild2`` protocol so + the outer cooperates with any nested scroll, only consuming + leftover scroll when its child reaches its limit. + When a ``refresh_control`` prop is provided, wraps the scroll in a `SwipeRefreshLayout` and forwards the on-refresh callback. """ def create(self, props: Dict[str, Any]) -> Any: - sv = jclass("android.widget.ScrollView")(_ctx()) + try: + sv = jclass("androidx.core.widget.NestedScrollView")(_ctx()) + except Exception: + sv = jclass("android.widget.ScrollView")(_ctx()) _apply_common_visual(sv, props) - # Wrap the inner ScrollView in a SwipeRefreshLayout when - # ``refresh_control`` is asked for. Implementing this cleanly - # would require returning a different parent; for v1, we - # attach the listener via a wrapper that we expose to - # add_child callers below. return sv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: @@ -536,6 +546,17 @@ def remove_child(self, parent: Any, child: Any) -> None: class TextInputHandler(AndroidViewHandler): def create(self, props: Dict[str, Any]) -> Any: et = jclass("android.widget.EditText")(_ctx()) + # Default to single-line so pressing Enter triggers IME_ACTION_DONE + # (submit / dismiss) instead of inserting a newline. The + # ``_apply`` path will override this if ``multiline=True`` is + # set in props. Without this, every TextInput without an + # explicit ``multiline`` value falls back to Android's + # multi-line default and Enter inserts ``\n``. + try: + if not props.get("multiline"): + et.setSingleLine(True) + except Exception: + pass self._apply(et, props) return et diff --git a/src/pythonnative/native_views/ios.py b/src/pythonnative/native_views/ios.py index 90dd9b7..c403548 100644 --- a/src/pythonnative/native_views/ios.py +++ b/src/pythonnative/native_views/ios.py @@ -54,6 +54,19 @@ def _safe_finite(value: Any, default: float = 0.0) -> float: UIColor = ObjCClass("UIColor") UIFont = ObjCClass("UIFont") +# Declare ``superview`` as a property on UIView so rubicon-objc returns +# the actual UIView (or None) on attribute access, instead of an +# ObjCBoundMethod. Without this, accessing ``view.superview`` returns a +# method handle and the entire codepath that updates UIScrollView's +# ``contentSize`` would raise silently. See rubicon-objc docs on +# ``declare_property`` for why some ``@property`` declarations aren't +# auto-detected by the runtime introspection. +try: + _UIView = ObjCClass("UIView") + _UIView.declare_property("superview") +except Exception: + pass + def _objc_ptr(obj: Any) -> Optional[int]: """Return the raw Objective-C pointer for a Rubicon object.""" @@ -131,6 +144,8 @@ def _objc_ptr(obj: Any) -> Optional[int]: _SEL_ADD_TARGET_ACTION_EVENTS = _sel_reg(b"addTarget:action:forControlEvents:") _SEL_ON_EDIT = _sel_reg(b"onEdit:") _SEL_ON_SUBMIT = _sel_reg(b"onSubmit:") +_SEL_RESIGN_FIRST_RESPONDER = _sel_reg(b"resignFirstResponder") +_SEL_TEXT_FIELD_SHOULD_RETURN = _sel_reg(b"textFieldShouldReturn:") _NS_OBJECT_CLS = _get_cls(b"NSObject") @@ -507,12 +522,19 @@ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: pass try: parent = native_view.superview - set_content_size = getattr(parent, "setContentSize_", None) - if set_content_size is not None: + parent_cls = "" + try: + parent_cls = str(parent.objc_class.name) if parent is not None else "" + except Exception: + parent_cls = "" + # Expand the parent UIScrollView's contentSize whenever a + # child's frame extends past the visible bounds, so the + # scroll view can actually scroll to reveal it. + if "UIScrollView" in parent_cls: bounds = parent.bounds content_w = max(float(bounds.size.width), frame_x + frame_w) content_h = max(float(bounds.size.height), frame_y + frame_h) - set_content_size((content_w, content_h)) + parent.setContentSize_((content_w, content_h)) except Exception: pass except Exception: @@ -651,6 +673,7 @@ def onTap_(self, sender: object) -> None: _PN_TEXTFIELD_TARGET_CLS: Optional[int] = None _textfield_edit_imp_ref: Any = None _textfield_submit_imp_ref: Any = None +_textfield_should_return_imp_ref: Any = None def _textfield_text(sender_ptr: int) -> str: @@ -694,8 +717,27 @@ def _textfield_on_submit_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None: pass +def _textfield_should_return_imp(self_ptr: int, _cmd: int, tf_ptr: int) -> bool: + """``UITextFieldDelegate.textFieldShouldReturn:`` — dismiss the keyboard. + + iOS doesn't dismiss the keyboard on Return by default; the standard + pattern is for the delegate to call ``resignFirstResponder`` and + return ``YES``. Matching that here brings PythonNative's + ``TextInput`` in line with React Native's default behavior and with + what users expect from a ``return_key_type="done"`` style. + """ + try: + _objc_msgSend.restype = None + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p] + _objc_msgSend(_ct.c_void_p(int(tf_ptr or 0)), _SEL_RESIGN_FIRST_RESPONDER) + except Exception: + pass + return True + + def _ensure_textfield_target_class() -> Optional[int]: - global _PN_TEXTFIELD_TARGET_CLS, _textfield_edit_imp_ref, _textfield_submit_imp_ref + global _PN_TEXTFIELD_TARGET_CLS + global _textfield_edit_imp_ref, _textfield_submit_imp_ref, _textfield_should_return_imp_ref if _PN_TEXTFIELD_TARGET_CLS is not None: return _PN_TEXTFIELD_TARGET_CLS existing = _get_cls(b"PNTextFieldActionTarget") @@ -706,10 +748,18 @@ def _ensure_textfield_target_class() -> Optional[int]: if not cls: return None action_type = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p) + bool_type = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p) _textfield_edit_imp_ref = action_type(_textfield_on_edit_imp) _textfield_submit_imp_ref = action_type(_textfield_on_submit_imp) + _textfield_should_return_imp_ref = bool_type(_textfield_should_return_imp) _add_method(cls, _SEL_ON_EDIT, _ct.cast(_textfield_edit_imp_ref, _ct.c_void_p), b"v@:@") _add_method(cls, _SEL_ON_SUBMIT, _ct.cast(_textfield_submit_imp_ref, _ct.c_void_p), b"v@:@") + _add_method( + cls, + _SEL_TEXT_FIELD_SHOULD_RETURN, + _ct.cast(_textfield_should_return_imp_ref, _ct.c_void_p), + b"c@:@", + ) _reg_cls(cls) _PN_TEXTFIELD_TARGET_CLS = int(cls) return _PN_TEXTFIELD_TARGET_CLS @@ -760,6 +810,16 @@ def _attach_textfield_raw_target(tf: Any, props: Dict[str, Any]) -> None: _SEL_ON_SUBMIT, 1 << 6, ) + # Wire the same object as the UITextFieldDelegate so its + # ``textFieldShouldReturn:`` runs and resigns first responder + # — without this iOS keeps the keyboard up after Return. + _objc_msgSend.restype = None + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p] + _objc_msgSend( + _ct.c_void_p(tf_ptr), + _SEL_SET_DELEGATE, + _ct.c_void_p(target_ptr), + ) if "on_change" in props: _pn_tf_change_callback_map[int(target_ptr)] = props["on_change"] if "on_submit" in props: @@ -1313,8 +1373,10 @@ def _apply_textfield(self, tf: Any, props: Dict[str, Any]) -> None: except Exception: pass self._common_apply(tf, props) - if "on_change" in props or "on_submit" in props: - _attach_textfield_raw_target(tf, props) + # Always wire the action target — even without ``on_change`` / + # ``on_submit`` we want the textfield's delegate set so Return + # dismisses the keyboard (textFieldShouldReturn:). + _attach_textfield_raw_target(tf, props) def _apply_textview(self, tv: Any, props: Dict[str, Any]) -> None: if "value" in props: diff --git a/src/pythonnative/navigation.py b/src/pythonnative/navigation.py index b0d8a03..355fd66 100644 --- a/src/pythonnative/navigation.py +++ b/src/pythonnative/navigation.py @@ -64,7 +64,14 @@ def App(): # Focus context # ====================================================================== -_FocusContext = create_context(False) +# Defaults to True: components rendered outside any declarative +# navigator (e.g. the root component of a screen pushed via the host's +# native nav stack) are by definition focused — the host's own +# ``on_resume`` / ``on_pause`` lifecycle drives the focus state for +# those. Declarative navigators override this provider on the active +# subtree (always True today; reserved for future inactive-screen +# rendering). +_FocusContext = create_context(True) # ====================================================================== # Data structures @@ -925,6 +932,15 @@ def use_focus_effect(effect: Callable, deps: Optional[list] = None) -> None: one. Useful for starting subscriptions, refreshing data, or pausing animations on the inactive screen. + The focus state combines two sources of truth: + + - The screen host's lifecycle (``on_resume`` / ``on_pause``), so + pushing a sibling onto the navigation stack blurs this screen and + popping back to it refocuses it. + - The in-tree ``_FocusContext`` value, which lets declarative + navigators (e.g. tabs, drawers) mark only the active subtree as + focused even when both screens are part of the same host. + Args: effect: A zero-arg callable invoked when focused. Optionally returns a cleanup callable. @@ -941,7 +957,46 @@ def HomeScreen(): return pn.Text("Home") ``` """ - is_focused = use_context(_FocusContext) + context_focused = use_context(_FocusContext) + + nav = use_context(_NavigationContext) + # Walk the navigator parent chain to find the screen host. Declarative + # navigators (Stack/Tab/Drawer) wrap the host's ``NavigationHandle`` + # as ``_parent``; only the host-level handle has ``_host``. + host = None + cursor = nav + while cursor is not None: + candidate = getattr(cursor, "_host", None) + if candidate is not None: + host = candidate + break + cursor = getattr(cursor, "_parent", None) + initial_host_focus = bool(getattr(host, "_is_focused", True)) if host is not None else True + host_focused, set_host_focused = use_state(initial_host_focus) + + def subscribe_to_host_focus() -> Any: + if host is None: + return None + subscribers = getattr(host, "_focus_subscribers", None) + if subscribers is None: + return None + subscribers.append(set_host_focused) + # The host may have changed focus state between the initial + # ``use_state`` call and this effect running (e.g. mid-render + # lifecycle event); resync once to avoid stale state. + set_host_focused(bool(host._is_focused)) + + def cleanup() -> None: + try: + subscribers.remove(set_host_focused) + except ValueError: + pass + + return cleanup + + use_effect(subscribe_to_host_focus, []) + + is_focused = context_focused and host_focused all_deps = [is_focused] + (list(deps) if deps is not None else []) def wrapped_effect() -> Any: diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py index da39949..f18b8e8 100644 --- a/src/pythonnative/reconciler.py +++ b/src/pythonnative/reconciler.py @@ -803,8 +803,37 @@ def _run_layout(self) -> None: # root in the screen. for child in layout_root.children: self._apply_layout(child, 0.0, 0.0) + # Lay out the children of every visible ``Modal`` as a fresh + # subtree sized to the viewport. Modals are excluded from the + # main layout tree (their content lives in a separately + # presented native container) so without this pass the + # children's frames never get computed and the modal renders + # blank. + self._layout_visible_modals(self._tree, viewport_w, viewport_h) self._log_viewport(f"_run_layout: pass#{layout_pass} done") + def _layout_visible_modals( + self, + vnode: VNode, + viewport_w: float, + viewport_h: float, + ) -> None: + element = vnode.element + if isinstance(element.type, str) and element.type == "Modal": + if element.props.get("visible") and vnode.children: + child_layout = self._build_layout_tree(vnode.children[0]) + if child_layout is not None: + viewport = LayoutNode( + style={"width": viewport_w, "height": viewport_h}, + children=[child_layout], + ) + calculate_layout(viewport, viewport_w, viewport_h) + for c in viewport.children: + self._apply_layout(c, 0.0, 0.0) + return + for child in vnode.children: + self._layout_visible_modals(child, viewport_w, viewport_h) + def _build_layout_tree(self, vnode: VNode) -> Optional[LayoutNode]: """Walk `vnode` and build a parallel `LayoutNode` tree of native nodes. @@ -829,6 +858,15 @@ def _build_layout_tree(self, vnode: VNode) -> Optional[LayoutNode]: style = extract_layout_style(element.props) layout = LayoutNode(style=style, user_data=vnode) + if element.type == "ScrollView": + # Mark the scroll axis so the layout engine clamps the + # container's own main-axis size to its parent's available + # space (otherwise the container grows to fit its content + # and there is no overflow for the native ScrollView to + # actually scroll). The children are still wrapped below so + # they see an unbounded main axis when measured. + scroll_axis = element.props.get("scroll_axis", "vertical") + layout._pn_scroll_axis = "x" if scroll_axis == "horizontal" else "y" self._log_viewport( f"_build_layout_tree: node type={element.type!r} view={self._obj_debug(vnode.native_view)} " f"style={style!r} children={len(vnode.children)}" diff --git a/src/pythonnative/screen.py b/src/pythonnative/screen.py index 06867ff..984ca0b 100644 --- a/src/pythonnative/screen.py +++ b/src/pythonnative/screen.py @@ -165,6 +165,25 @@ def _init_host_common(host: Any, component_path: str, component_func: Any) -> No host._hot_reload_manifest_path = None host._hot_reload_last_version = None host._layout_listener = None # retained on Android to prevent GC + # Focus state — drives ``use_focus_effect``. Starts focused because + # a host is only created when the screen is being presented; the + # platform lifecycle hooks (``on_resume`` / ``on_pause``) flip this + # when the user navigates to / from another screen. + host._is_focused = True + host._focus_subscribers = [] + + +def _set_host_focused(host: Any, focused: bool) -> None: + """Update ``host._is_focused`` and notify ``use_focus_effect`` subscribers.""" + if getattr(host, "_is_focused", True) == focused: + return + host._is_focused = focused + subscribers = list(getattr(host, "_focus_subscribers", ()) or ()) + for callback in subscribers: + try: + callback(focused) + except Exception: + pass def _push_viewport_size(host: Any, width: float, height: float) -> None: @@ -232,6 +251,23 @@ def _flush_scheduled_renders(hosts: Sequence[Any]) -> None: def _on_create(host: Any) -> None: from .hooks import NavigationHandle, Provider, _NavigationContext + # ``on_create`` is idempotent across native-view recreations. On + # Android the FragmentManager destroys and recreates a screen's + # view every time the user pops back to it, and the platform + # template calls ``screen.on_create()`` again from + # ``onViewCreated`` — but the Python screen object (and therefore + # the reconciler, hook state, focus subscribers, etc.) persists + # across that. Re-running the full mount path here would reset + # use_state, clobber use_focus_effect subscriptions, and break + # navigation handles held by existing components, which is why + # the focus counter never advanced past ``1`` before this guard. + # If we're already mounted, just re-attach the existing root view + # to the (newly created) native container — ``on_resume`` will + # fire the focus subscribers separately. + if host._reconciler is not None and host._root_native_view is not None: + host._attach_root(host._root_native_view) + return + host._nav_handle = NavigationHandle(host) host._reconciler = _new_reconciler(host) @@ -711,7 +747,7 @@ def on_start(self) -> None: pass def on_resume(self) -> None: - pass + _set_host_focused(self, True) def on_layout(self) -> None: # Android pushes viewport changes through the @@ -721,7 +757,7 @@ def on_layout(self) -> None: pass def on_pause(self) -> None: - pass + _set_host_focused(self, False) def on_stop(self) -> None: pass @@ -796,6 +832,18 @@ def _attach_root(self, native_view: Any) -> None: container.removeAllViews() except Exception: pass + # When the user pops back to a previously mounted screen, + # ``native_view`` is the root from the prior mount and may + # still be parented under the old (destroyed) FrameLayout. + # ViewGroup.addView() throws if a view already has a + # parent, so detach it from the old one before re-attaching + # to the freshly created container. + try: + old_parent = native_view.getParent() + if old_parent is not None: + old_parent.removeView(native_view) + except Exception: + pass LayoutParams = jclass("android.view.ViewGroup$LayoutParams") lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) container.addView(native_view, lp) @@ -1052,7 +1100,7 @@ def on_start(self) -> None: pass def on_pause(self) -> None: - pass + _set_host_focused(self, False) def on_stop(self) -> None: pass @@ -1267,6 +1315,7 @@ def on_resume(self) -> None: # ``viewDidAppear`` always follows ``viewDidLayoutSubviews``, # but trigger one extra sync here for safety in case a # template overrides the layout call without forwarding. + _set_host_focused(self, True) if self._root_native_view is None: _log_pn("on_resume: no root_native_view yet, skipping") return diff --git a/src/pythonnative/storage.py b/src/pythonnative/storage.py index 8e37b9f..187bc77 100644 --- a/src/pythonnative/storage.py +++ b/src/pythonnative/storage.py @@ -52,18 +52,32 @@ async def restore_session(): _DEFAULTS_SUITE = "pn_async_storage" -def _ios_set(key: str, value: str) -> None: - from rubicon.objc import ObjCClass +# Cache the NSUserDefaults class lookup. rubicon.objc's +# ``ObjCClass("NSUserDefaults")`` walks the ObjC runtime metadata which +# takes hundreds of milliseconds on first call; resolving once at module +# import keeps every later get/set/delete in the sub-millisecond range. +_ios_defaults: Any = None + + +def _ios_get_defaults() -> Any: + global _ios_defaults + if _ios_defaults is None: + from rubicon.objc import ObjCClass + + _ios_defaults = ObjCClass("NSUserDefaults").standardUserDefaults + return _ios_defaults + - defaults = ObjCClass("NSUserDefaults").standardUserDefaults +def _ios_set(key: str, value: str) -> None: + defaults = _ios_get_defaults() defaults.setObject_forKey_(value, key) - defaults.synchronize() + # ``synchronize()`` is documented as unnecessary on modern iOS and + # can block for seconds while it flushes to disk on a busy system; + # NSUserDefaults already coalesces writes asynchronously. def _ios_get(key: str) -> Optional[str]: - from rubicon.objc import ObjCClass - - defaults = ObjCClass("NSUserDefaults").standardUserDefaults + defaults = _ios_get_defaults() val = defaults.stringForKey_(key) if val is None: return None @@ -74,17 +88,12 @@ def _ios_get(key: str) -> Optional[str]: def _ios_delete(key: str) -> None: - from rubicon.objc import ObjCClass - - defaults = ObjCClass("NSUserDefaults").standardUserDefaults + defaults = _ios_get_defaults() defaults.removeObjectForKey_(key) - defaults.synchronize() def _ios_all_keys() -> List[str]: - from rubicon.objc import ObjCClass - - defaults = ObjCClass("NSUserDefaults").standardUserDefaults + defaults = _ios_get_defaults() rep = defaults.dictionaryRepresentation() if rep is None: return [] diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md new file mode 100644 index 0000000..5da6623 --- /dev/null +++ b/tests/e2e/AGENTS.md @@ -0,0 +1,245 @@ +# Working with the E2E suite (for AI agents) + +This document explains how an AI agent should interact with the PythonNative E2E test system. Read it before making changes that could affect any feature on the library's public surface, before adding a new feature, or when diagnosing a failing CI run. + +## What this suite is + +The E2E suite drives the `examples/e2e-suite` app on a real Android emulator or iOS Simulator using [Maestro](https://maestro.dev/). Every public symbol in `pythonnative.__all__` is either: + +1. exercised by a dedicated demo screen + Maestro flow, or +2. listed in `INTENTIONAL_EXEMPTIONS` in `scripts/check-e2e-coverage.py` with a comment explaining why. + +The coverage checker `scripts/check-e2e-coverage.py` enforces (1) and (2): if you add a new public symbol without adding a demo (or an exemption with justification), the script exits non-zero and CI fails. + +## Map of the system + +```text +examples/e2e-suite/ +├── app/ +│ ├── main.py # Root Stack registers every demo route +│ ├── registry.py # Single source of truth: DemoEntry list +│ ├── theme.py # Shared styles used by every demo screen +│ └── screens/ +│ ├── home.py # Lists categories (anchor: "E2E Suite home") +│ ├── category.py # Lists demos in a category (anchor: "Demos in <Cat>") +│ ├── scaffold.py # demo_screen(...) helper used by every demo +│ ├── components/ # One file per Component demo +│ ├── hooks/ # One file per Hook demo +│ ├── navigation/ # One file per Navigation demo +│ ├── layout/ # Layout demos +│ ├── styling/ # Styling demos +│ ├── animations/ # Animated.* demos +│ ├── alerts/ # Alert.show / Alert.confirm demos +│ ├── storage/ # AsyncStorage demos +│ ├── runtime/ # run_async demo +│ ├── platform/ # Platform info demo +│ └── sdk/ # SDK surface demo +└── pythonnative.json + +tests/e2e/ +├── AGENTS.md # (this file) +├── android.yaml # Full Android suite — runs every flow +├── ios.yaml # Full iOS suite — runs every flow +├── helpers/ +│ ├── open_demo.yaml # Reusable: launch + nav to a demo +│ └── close_demo.yaml # Reusable: pop back to home +├── suites/ # Per-category aggregator yamls +│ ├── components.yaml +│ ├── hooks.yaml +│ └── ... +└── flows/ # One yaml per demo + ├── components/ + ├── hooks/ + └── ... + +scripts/ +├── run-e2e.sh # Build app + run a Maestro suite +└── check-e2e-coverage.py # Static check; mirrors __all__ to demos +``` + +## Running the suite locally + +```bash +# Full Android suite (emulator must be running) +./scripts/run-e2e.sh android + +# Full iOS suite (simulator must be running; idb-companion installed) +./scripts/run-e2e.sh ios + +# Just one category, for tight iteration loops: +./scripts/run-e2e.sh android hooks +./scripts/run-e2e.sh ios components +``` + +Available category suites: `components`, `hooks`, `navigation`, `layout`, `styling`, `animations`, `misc`. + +You can also run a single flow directly. Useful when iterating on one demo: + +```bash +maestro test \ + -e APP_ID=com.pythonnative.android_template \ + tests/e2e/flows/hooks/use_state.yaml +``` + +The build step (`pn run <platform> --no-logs`) only needs to run once per change to `app/`. After that, repeat-run the Maestro flow as you iterate. + +### How `open_demo.yaml` decides what to do + +`helpers/open_demo.yaml` is *state-aware* — it inspects the current screen and runs only the steps it needs to land on `Demo: <DEMO_TITLE>`. Same-category consecutive flows stay on the category screen between demos (no detour through home, no `launchApp`); cross-category transitions go via home; a dead app gets relaunched. The companion `helpers/close_demo.yaml` only pops one level (back to the category list) so the next flow's `open_demo` can pick up cheaply. + +This is intentional and the source of the suite's speed. When debugging a flow, **don't** "simplify" `open_demo` to always `launchApp` + go home + go to category — that's the slow path the smart logic was written to avoid (about 15 min vs. 3 min of pure navigation overhead across the full iOS suite). If a flow needs a guaranteed clean app launch, set up that state in the flow itself. + +Gate conditions in `open_demo` deliberately use signals that work on **both** platforms. The two cross-platform asymmetries that matter here: + +- **Scroll preservation.** iOS preserves a ScrollView's offset across navigation; Android resets it to the top. A condition like `visible: "Back to home"` (button at the bottom of a category list) works on iOS after a return-from-demo but fails on Android because the list is back at the top. The helper gates on `"Demos in .*"` (top of the list, visible on both) and `notVisible: "Back to list"` (i.e. we left the demo screen) instead. +- **Native-view recreation on Android.** The Android FragmentManager destroys and rebuilds a screen's view tree on pop-back; `pythonnative.screen._on_create` short-circuits the second call so hook state, `use_focus_effect` subscriptions, and `use_navigation` handles persist. If a future change to `screen.py` or `ScreenFragment.kt` breaks that idempotency, `flows/navigation/focus_effect.yaml` is the canary — it'll regress to `Focus count: 1` on pop-back. + +### Scrolling fixed-height containers (ScrollView / FlatList) + +Maestro's `scrollUntilVisible` always swipes from the screen center. That works for the outer page ScrollView (which fills the screen) but **not** for small in-page containers like the 200 dp `ScrollView` / `FlatList` demos — the screen center sits below those containers, so the swipe lands outside them and never moves the contents. + +`flows/components/scroll_view.yaml` and `flows/components/flat_list.yaml` work around this with an explicit-coordinate swipe loop wrapped in `repeat: while: notVisible: ...`. Two reasons for the loop rather than a fixed `times: N`: + +- Per-swipe scroll travel is platform-dependent — Android's `NestedScrollView` flings more aggressively than iOS's `UIScrollView`, so a count tuned for one platform overshoots on the other. +- iOS preserves the inner ScrollView's offset across navigation, so a re-entry into the demo may already have the target row in view; `while: notVisible` exits the loop immediately in that case. + +When adding a new flow that needs to scroll a non-fullscreen container, copy this pattern (small swipes ~10% of screen height, ~500 ms each, `times` cap as a safety net) rather than calling `scrollUntilVisible`. + +### Suite-level retry + +`scripts/run-e2e.sh` re-invokes the whole `maestro test` once if the first attempt exits non-zero. The retry exists to absorb Maestro's iOS XCUITest driver flake (transient `Application is not running` / `Request for viewHierarchy failed`) — not to paper over real failures. + +When the script prints: + +```text +==> Maestro suite failed (attempt 1/2); retrying... +``` + +treat it as a signal to investigate, not as "all clear." If a flow needs the retry to pass on a given run, the underlying issue is almost always one of: + +- a genuine race or timing assumption in the demo or flow (fix it), +- a CPU-starvation-induced numerical instability in animated code (clamp the integrator), +- or a real Maestro/driver bug worth filing upstream. + +Override or disable with `MAESTRO_MAX_ATTEMPTS=1 ./scripts/run-e2e.sh ios` when bisecting a flake. + +## The flow header convention + +Every flow file under `tests/e2e/flows/` starts with a two-line header pointing at the demo and the source code: + +```yaml +# Tests <one-line summary of what's verified>. +# +# Demo screen: examples/e2e-suite/app/screens/<category>/<file>.py +# Source under test: src/pythonnative/<file>.py :: <symbol> +appId: ${APP_ID} +--- +``` + +When a flow fails, **start by reading these three files in order**: + +1. The flow yaml — see what assertion failed. +2. The demo screen — see what the demo expected to render. +3. The source under test — see the implementation that's responsible. + +## When a flow fails + +A typical Maestro failure looks like: + +```text +[Failed] hooks/use_state.yaml + Assertion 'Counter: 2' not visible after 10s. +``` + +Diagnostic procedure: + +1. **Locate the flow file**: `tests/e2e/flows/hooks/use_state.yaml`. +2. **Read the header comment** to find the demo screen (`use_state.py`) and the source file (`hooks.py :: use_state`). +3. **Re-run the single flow** to confirm the failure is reproducible: + + ```bash + maestro test -e APP_ID=com.pythonnative.android_template tests/e2e/flows/hooks/use_state.yaml + ``` + +4. **Inspect logs**: `pn run android` streams `print()` calls from the device. `print("[use_state] count -> ...")` style debug statements from the demo screen surface here, which is usually the fastest way to localize a regression. +5. **Reproduce in isolation**: many failures are state-related. Re-run `./scripts/run-e2e.sh android components` (or the relevant category) — if the flow passes there but fails in the full suite, the bug is most likely in cleanup between flows. + +## Adding a new demo (and its flow) + +When you add a new public symbol to `pythonnative`, follow this exact recipe: + +1. Add an exported name to `src/pythonnative/__init__.py :: __all__`. +2. Implement the feature. +3. Create the demo screen at `examples/e2e-suite/app/screens/<category>/<symbol>.py`: + + ```python + import pythonnative as pn + from app.screens.scaffold import demo_screen, hint, result_text, section + + + @pn.component + def MyFeatureDemo() -> pn.Element: + return demo_screen( + "My feature", + "Short summary visible on the demo screen.", + section( + "Try it", + result_text("State", "..."), + pn.Button("Trigger", on_click=lambda: None), + hint("Maestro asserts the State line."), + ), + ) + ``` + +4. Register the demo in `examples/e2e-suite/app/registry.py`: + + ```python + from app.screens.<category>.<symbol> import MyFeatureDemo + + DEMOS = [ + ... + DemoEntry("my_feature", "Hooks", "My feature", "<symbol>", MyFeatureDemo), + ] + ``` + +5. Author the Maestro flow at `tests/e2e/flows/<category>/<symbol>.yaml`. Use the existing flows as templates; they all use the `open_demo.yaml` / `close_demo.yaml` helpers. +6. Append the new flow to: + - `tests/e2e/android.yaml` + - `tests/e2e/ios.yaml` + - `tests/e2e/suites/<category>.yaml` +7. Run `python scripts/check-e2e-coverage.py` and confirm it exits 0. +8. Run `./scripts/run-e2e.sh android` (or `ios`) and confirm the new flow passes. + +If a symbol is genuinely untestable through a UI flow (type-only alias, network-dependent, requires hardware), instead add it to `INTENTIONAL_EXEMPTIONS` in `scripts/check-e2e-coverage.py` with a comment explaining why. + +## Stable label conventions + +These conventions keep flows robust across platforms. Stick to them when authoring demo screens. + +| Where the label appears | Format | Example | +| --- | --- | --- | +| Home screen anchor | exact text | `"E2E Suite home"` | +| Home category button | `"Open <Category>"` | `"Open Hooks"` | +| Category screen anchor | `"Demos in <Category>"` | `"Demos in Hooks"` | +| Category demo button | `"Open: <Title>"` | `"Open: use_state"` | +| Demo screen anchor | `"Demo: <Title>"` | `"Demo: use_state"` | +| Result line | `"<Prefix>: <Value>"` | `"Counter: 2"` | +| Back button | `"Back to list"` | (same on every demo) | + +Avoid emoji and platform-specific glyphs in labels — Maestro's text matching is much happier with plain ASCII. + +## When tests fail because the demo, not the library, is wrong + +It happens. The fix is to update the flow + demo together so the test reflects intended behavior. Do NOT: + +- mark a flow as `flaky` or wrap it in retries without first finding the root cause, +- change a `result_text` value to silence the test if the library returns something genuinely incorrect, +- delete a flow because it's hard to fix on one platform — gate it with `runFlow` from one of the platform-specific suites instead (we don't have these yet, but `flows/<category>/<feature>_android.yaml` is the established naming if needed). + +When you do change a demo or flow, update its header comment so it still accurately documents what's being tested. + +## CI integration + +The full suite runs on every push to `main` and every PR via `.github/workflows/e2e.yml`. Both the Android job (Linux runner + emulator) and the iOS job (macOS runner + simulator) call into `scripts/run-e2e.sh`, so the local and CI execution paths are identical. + +The coverage check is wired into `scripts/check.sh`, which `ci.yml` runs on every push and PR. New `__all__` entries without a demo (or exemption) fail CI before the E2E job even starts, which keeps the inner loop fast. diff --git a/tests/e2e/android.yaml b/tests/e2e/android.yaml index 7f422cb..8cdd78c 100644 --- a/tests/e2e/android.yaml +++ b/tests/e2e/android.yaml @@ -1,9 +1,74 @@ +# Android master E2E suite for the e2e-suite example app. +# +# Aggregates every category's flow file into one ordered run. Each flow +# is self-contained: it launches the app, navigates to its demo, +# exercises the feature, and returns to the home screen so the next +# flow can start cleanly. +# +# Run with: +# cd examples/e2e-suite && pn run android --no-logs +# cd ../.. && maestro test tests/e2e/android.yaml appId: com.pythonnative.android_template env: APP_ID: com.pythonnative.android_template --- -- runFlow: flows/main.yaml -- runFlow: flows/navigation_android.yaml -- runFlow: flows/layout_android.yaml -- runFlow: flows/list_screen.yaml -- runFlow: flows/settings_screen.yaml +- runFlow: flows/components/text.yaml +- runFlow: flows/components/button.yaml +- runFlow: flows/components/text_input.yaml +- runFlow: flows/components/image.yaml +- runFlow: flows/components/switch.yaml +- runFlow: flows/components/slider.yaml +- runFlow: flows/components/progress_bar.yaml +- runFlow: flows/components/activity_indicator.yaml +- runFlow: flows/components/view_column_row.yaml +- runFlow: flows/components/scroll_view.yaml +- runFlow: flows/components/safe_area_view.yaml +- runFlow: flows/components/modal.yaml +- runFlow: flows/components/pressable.yaml +- runFlow: flows/components/picker.yaml +- runFlow: flows/components/refresh_control.yaml +- runFlow: flows/components/fragment.yaml +- runFlow: flows/components/error_boundary.yaml +- runFlow: flows/components/spacer.yaml +- runFlow: flows/components/status_bar.yaml +- runFlow: flows/components/keyboard_avoiding_view.yaml +- runFlow: flows/components/flat_list.yaml +- runFlow: flows/components/section_list.yaml +- runFlow: flows/components/web_view.yaml +- runFlow: flows/hooks/use_state.yaml +- runFlow: flows/hooks/use_effect.yaml +- runFlow: flows/hooks/use_reducer.yaml +- runFlow: flows/hooks/use_ref.yaml +- runFlow: flows/hooks/use_memo.yaml +- runFlow: flows/hooks/use_callback.yaml +- runFlow: flows/hooks/use_context.yaml +- runFlow: flows/hooks/use_async_effect.yaml +- runFlow: flows/hooks/use_query.yaml +- runFlow: flows/hooks/use_mutation.yaml +- runFlow: flows/hooks/use_persisted_state.yaml +- runFlow: flows/hooks/use_window_dimensions.yaml +- runFlow: flows/hooks/memo.yaml +- runFlow: flows/hooks/batch_updates.yaml +- runFlow: flows/navigation/tab_navigator.yaml +- runFlow: flows/navigation/drawer_navigator.yaml +- runFlow: flows/navigation/params_passing.yaml +- runFlow: flows/navigation/focus_effect.yaml +- runFlow: flows/layout/flex_layout.yaml +- runFlow: flows/layout/aspect_ratio.yaml +- runFlow: flows/layout/absolute_position.yaml +- runFlow: flows/layout/padding_margin.yaml +- runFlow: flows/layout/alignment.yaml +- runFlow: flows/styling/typography.yaml +- runFlow: flows/styling/borders_shadows.yaml +- runFlow: flows/styling/transform.yaml +- runFlow: flows/styling/stylesheet.yaml +- runFlow: flows/animations/timing_animation.yaml +- runFlow: flows/animations/spring_animation.yaml +- runFlow: flows/animations/parallel_animation.yaml +- runFlow: flows/animations/sequence_animation.yaml +- runFlow: flows/alerts/simple_alert.yaml +- runFlow: flows/alerts/confirm_alert.yaml +- runFlow: flows/storage/async_storage.yaml +- runFlow: flows/runtime/run_async.yaml +- runFlow: flows/platform/platform_info.yaml +- runFlow: flows/sdk/custom_component.yaml diff --git a/tests/e2e/flows/alerts/confirm_alert.yaml b/tests/e2e/flows/alerts/confirm_alert.yaml new file mode 100644 index 0000000..350e7f2 --- /dev/null +++ b/tests/e2e/flows/alerts/confirm_alert.yaml @@ -0,0 +1,32 @@ +# Tests Alert.confirm returns True / False from each button. +# +# Demo screen: examples/e2e-suite/app/screens/alerts/confirm_alert.py +# Source under test: src/pythonnative/alerts.py :: Alert.confirm +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Alerts + DEMO_TITLE: Alert.confirm +- assertVisible: "Last response: (none)" +- tapOn: "Show confirm" +- extendedWaitUntil: + visible: "Confirm action" + timeout: 5000 +- tapOn: "Confirm" +- extendedWaitUntil: + visible: "Last response: confirmed" + timeout: 5000 +- tapOn: "Show confirm" +- extendedWaitUntil: + visible: "Confirm action" + timeout: 5000 +- tapOn: "Cancel" +- extendedWaitUntil: + visible: "Last response: cancelled" + timeout: 5000 +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Alerts diff --git a/tests/e2e/flows/alerts/simple_alert.yaml b/tests/e2e/flows/alerts/simple_alert.yaml new file mode 100644 index 0000000..6a3cbbe --- /dev/null +++ b/tests/e2e/flows/alerts/simple_alert.yaml @@ -0,0 +1,21 @@ +# Tests Alert.show opens a native dialog. +# +# Demo screen: examples/e2e-suite/app/screens/alerts/simple_alert.py +# Source under test: src/pythonnative/alerts.py :: Alert.show +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Alerts + DEMO_TITLE: Alert.show +- tapOn: "Show alert" +- extendedWaitUntil: + visible: "Hello!" + timeout: 5000 +- assertVisible: "This is a native alert." +- tapOn: "OK" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Alerts diff --git a/tests/e2e/flows/animations/parallel_animation.yaml b/tests/e2e/flows/animations/parallel_animation.yaml new file mode 100644 index 0000000..7362104 --- /dev/null +++ b/tests/e2e/flows/animations/parallel_animation.yaml @@ -0,0 +1,20 @@ +# Tests Animated.parallel composes timing + spring concurrently. +# +# Demo screen: examples/e2e-suite/app/screens/animations/parallel_animation.py +# Source under test: src/pythonnative/animated.py :: Animated.parallel +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Animations + DEMO_TITLE: Animated.parallel +- assertVisible: "Status: idle" +- tapOn: "Run parallel" +- extendedWaitUntil: + visible: "Status: done" + timeout: 10000 +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Animations diff --git a/tests/e2e/flows/animations/sequence_animation.yaml b/tests/e2e/flows/animations/sequence_animation.yaml new file mode 100644 index 0000000..b41f27b --- /dev/null +++ b/tests/e2e/flows/animations/sequence_animation.yaml @@ -0,0 +1,20 @@ +# Tests Animated.sequence runs animations in order. +# +# Demo screen: examples/e2e-suite/app/screens/animations/sequence_animation.py +# Source under test: src/pythonnative/animated.py :: Animated.sequence +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Animations + DEMO_TITLE: Animated.sequence +- assertVisible: "Status: idle" +- tapOn: "Run sequence" +- extendedWaitUntil: + visible: "Status: done" + timeout: 10000 +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Animations diff --git a/tests/e2e/flows/animations/spring_animation.yaml b/tests/e2e/flows/animations/spring_animation.yaml new file mode 100644 index 0000000..9cb6c52 --- /dev/null +++ b/tests/e2e/flows/animations/spring_animation.yaml @@ -0,0 +1,20 @@ +# Tests Animated.spring fires and completes. +# +# Demo screen: examples/e2e-suite/app/screens/animations/spring_animation.py +# Source under test: src/pythonnative/animated.py :: Animated.spring +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Animations + DEMO_TITLE: Animated.spring +- assertVisible: "Status: idle" +- tapOn: "Run spring" +- extendedWaitUntil: + visible: "Status: done" + timeout: 10000 +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Animations diff --git a/tests/e2e/flows/animations/timing_animation.yaml b/tests/e2e/flows/animations/timing_animation.yaml new file mode 100644 index 0000000..b5a9f90 --- /dev/null +++ b/tests/e2e/flows/animations/timing_animation.yaml @@ -0,0 +1,24 @@ +# Tests Animated.timing fires and completes. +# +# Demo screen: examples/e2e-suite/app/screens/animations/timing_animation.py +# Source under test: src/pythonnative/animated.py :: Animated.timing +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Animations + DEMO_TITLE: Animated.timing +- assertVisible: "Status: idle" +- tapOn: "Run timing" +- extendedWaitUntil: + visible: "Status: done" + timeout: 5000 +- tapOn: "Reset" +- extendedWaitUntil: + visible: "Status: idle" + timeout: 5000 +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Animations diff --git a/tests/e2e/flows/components/activity_indicator.yaml b/tests/e2e/flows/components/activity_indicator.yaml new file mode 100644 index 0000000..ace9f36 --- /dev/null +++ b/tests/e2e/flows/components/activity_indicator.yaml @@ -0,0 +1,20 @@ +# Tests ActivityIndicator toggle flips its animating prop. +# +# Demo screen: examples/e2e-suite/app/screens/components/activity_indicator.py +# Source under test: src/pythonnative/components.py :: ActivityIndicator +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: ActivityIndicator +- assertVisible: "Animating: yes" +- tapOn: "Stop" +- assertVisible: "Animating: no" +- tapOn: "Start" +- assertVisible: "Animating: yes" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/button.yaml b/tests/e2e/flows/components/button.yaml new file mode 100644 index 0000000..ea1b921 --- /dev/null +++ b/tests/e2e/flows/components/button.yaml @@ -0,0 +1,24 @@ +# Tests Button taps increment a counter; disabled buttons don't fire. +# +# Demo screen: examples/e2e-suite/app/screens/components/button.py +# Source under test: src/pythonnative/components.py :: Button +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Button +- assertVisible: "Counter: 0" +- tapOn: "Increment" +- assertVisible: "Counter: 1" +- tapOn: "Increment" +- assertVisible: "Counter: 2" +- tapOn: "Reset" +- assertVisible: "Counter: 0" +- tapOn: "Should not fire" +- assertVisible: "Disabled taps: 0" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/error_boundary.yaml b/tests/e2e/flows/components/error_boundary.yaml new file mode 100644 index 0000000..c64ef6e --- /dev/null +++ b/tests/e2e/flows/components/error_boundary.yaml @@ -0,0 +1,16 @@ +# Tests ErrorBoundary catches a render error and shows the fallback. +# +# Demo screen: examples/e2e-suite/app/screens/components/error_boundary.py +# Source under test: src/pythonnative/components.py :: ErrorBoundary +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: ErrorBoundary +- assertVisible: "Caught render error" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/flat_list.yaml b/tests/e2e/flows/components/flat_list.yaml new file mode 100644 index 0000000..b12d191 --- /dev/null +++ b/tests/e2e/flows/components/flat_list.yaml @@ -0,0 +1,33 @@ +# Tests FlatList virtualizes rows; scrolling reveals later rows. +# +# Demo screen: examples/e2e-suite/app/screens/components/flat_list.py +# Source under test: src/pythonnative/components.py :: FlatList +# +# Same pattern as components/scroll_view.yaml: the FlatList is a +# small fixed-height (200 dp) container near the top of the screen, +# so Maestro's ``scrollUntilVisible`` (which swipes from the screen +# center) misses the container entirely. We loop with explicit +# coordinates and ``while: notVisible`` so the test exits as soon as +# the target row is in view, regardless of per-platform fling physics. +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: FlatList +- assertVisible: "FlatRow 1" +- repeat: + times: 15 + while: + notVisible: "FlatRow 20" + commands: + - swipe: + start: 50%, 20% + end: 50%, 10% + duration: 500 +- assertVisible: "FlatRow 20" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/fragment.yaml b/tests/e2e/flows/components/fragment.yaml new file mode 100644 index 0000000..6d04b8a --- /dev/null +++ b/tests/e2e/flows/components/fragment.yaml @@ -0,0 +1,17 @@ +# Tests Fragment merges its children into the parent. +# +# Demo screen: examples/e2e-suite/app/screens/components/fragment.py +# Source under test: src/pythonnative/components.py :: Fragment +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Fragment +- assertVisible: "Fragment line 1" +- assertVisible: "Fragment line 2" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/image.yaml b/tests/e2e/flows/components/image.yaml new file mode 100644 index 0000000..739739a --- /dev/null +++ b/tests/e2e/flows/components/image.yaml @@ -0,0 +1,16 @@ +# Tests Image renders without crashing alongside its labels. +# +# Demo screen: examples/e2e-suite/app/screens/components/image.py +# Source under test: src/pythonnative/components.py :: Image +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Image +- assertVisible: "Tiles rendered: 3" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/keyboard_avoiding_view.yaml b/tests/e2e/flows/components/keyboard_avoiding_view.yaml new file mode 100644 index 0000000..9346e5c --- /dev/null +++ b/tests/e2e/flows/components/keyboard_avoiding_view.yaml @@ -0,0 +1,16 @@ +# Tests KeyboardAvoidingView renders its body without crashing. +# +# Demo screen: examples/e2e-suite/app/screens/components/keyboard_avoiding_view.py +# Source under test: src/pythonnative/components.py :: KeyboardAvoidingView +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: KeyboardAvoidingView +- assertVisible: "KAV body label" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/modal.yaml b/tests/e2e/flows/components/modal.yaml new file mode 100644 index 0000000..cd4ad84 --- /dev/null +++ b/tests/e2e/flows/components/modal.yaml @@ -0,0 +1,32 @@ +# Tests Modal opens and closes on demand. +# +# Demo screen: examples/e2e-suite/app/screens/components/modal.py +# Source under test: src/pythonnative/components.py :: Modal +# +# The intermediate ``Modal: open`` text from the outer screen is +# *not* asserted while the modal is presented — on iOS the modal +# sheet covers the outer screen so its accessibility elements are +# offscreen for the duration. Instead we verify the toggle by +# checking that ``Modal body text`` becomes / stops being visible +# and that the outer state returns to ``Modal: closed`` after dismiss. +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Modal +- assertVisible: "Modal: closed" +- tapOn: "Open modal" +- extendedWaitUntil: + visible: "Modal body text" + timeout: 10000 +- tapOn: "Close modal" +- extendedWaitUntil: + notVisible: "Modal body text" + timeout: 10000 +- assertVisible: "Modal: closed" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/picker.yaml b/tests/e2e/flows/components/picker.yaml new file mode 100644 index 0000000..aa26289 --- /dev/null +++ b/tests/e2e/flows/components/picker.yaml @@ -0,0 +1,20 @@ +# Tests Picker updates its bound value when buttons set it programmatically. +# +# Demo screen: examples/e2e-suite/app/screens/components/picker.py +# Source under test: src/pythonnative/components.py :: Picker +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Picker +- assertVisible: "Picked: apple" +- tapOn: "Pick banana" +- assertVisible: "Picked: banana" +- tapOn: "Pick cherry" +- assertVisible: "Picked: cherry" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/pressable.yaml b/tests/e2e/flows/components/pressable.yaml new file mode 100644 index 0000000..167a319 --- /dev/null +++ b/tests/e2e/flows/components/pressable.yaml @@ -0,0 +1,20 @@ +# Tests Pressable on_press fires and increments the counter. +# +# Demo screen: examples/e2e-suite/app/screens/components/pressable.py +# Source under test: src/pythonnative/components.py :: Pressable +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Pressable +- assertVisible: "Presses: 0" +- tapOn: "Tap me (Pressable)" +- assertVisible: "Presses: 1" +- tapOn: "Tap me (Pressable)" +- assertVisible: "Presses: 2" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/progress_bar.yaml b/tests/e2e/flows/components/progress_bar.yaml new file mode 100644 index 0000000..168630b --- /dev/null +++ b/tests/e2e/flows/components/progress_bar.yaml @@ -0,0 +1,20 @@ +# Tests ProgressBar advances by 25% per tap and clamps to 100%. +# +# Demo screen: examples/e2e-suite/app/screens/components/progress_bar.py +# Source under test: src/pythonnative/components.py :: ProgressBar +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: ProgressBar +- assertVisible: "Progress: 25%" +- tapOn: "Advance" +- assertVisible: "Progress: 50%" +- tapOn: "Reset" +- assertVisible: "Progress: 0%" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/refresh_control.yaml b/tests/e2e/flows/components/refresh_control.yaml new file mode 100644 index 0000000..f1f3be2 --- /dev/null +++ b/tests/e2e/flows/components/refresh_control.yaml @@ -0,0 +1,22 @@ +# Tests RefreshControl fires its callback and resolves. +# +# Demo screen: examples/e2e-suite/app/screens/components/refresh_control.py +# Source under test: src/pythonnative/components.py :: RefreshControl +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: RefreshControl +- assertVisible: "Refreshing: no" +- assertVisible: "Refresh runs: 0" +- tapOn: "Trigger refresh" +- extendedWaitUntil: + visible: "Refresh runs: 1" + timeout: 10000 +- assertVisible: "Refreshing: no" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/safe_area_view.yaml b/tests/e2e/flows/components/safe_area_view.yaml new file mode 100644 index 0000000..07976c1 --- /dev/null +++ b/tests/e2e/flows/components/safe_area_view.yaml @@ -0,0 +1,16 @@ +# Tests SafeAreaView renders its child without crashing. +# +# Demo screen: examples/e2e-suite/app/screens/components/safe_area_view.py +# Source under test: src/pythonnative/components.py :: SafeAreaView +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: SafeAreaView +- assertVisible: "Inside SafeAreaView" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/scroll_view.yaml b/tests/e2e/flows/components/scroll_view.yaml new file mode 100644 index 0000000..8fdc3e8 --- /dev/null +++ b/tests/e2e/flows/components/scroll_view.yaml @@ -0,0 +1,44 @@ +# Tests ScrollView reveals rows that started off-screen after scrolling. +# +# Demo screen: examples/e2e-suite/app/screens/components/scroll_view.py +# Source under test: src/pythonnative/components.py :: ScrollView +# +# The demo's inner ScrollView is intentionally small (200 dp) — that's +# what's being tested — and it sits near the top of the screen, *not* +# in the middle. Maestro's ``scrollUntilVisible`` always swipes from +# the screen center, which lands below this scroll container and never +# scrolls it. The recommended Maestro pattern for non-fullscreen +# scrollables is a custom swipe loop with explicit coordinates that +# stay inside the scroll container's bounds. See: +# https://docs.maestro.dev/examples/recipes/custom-scrolling-for-screen-fragments +# +# Per-swipe scroll travel is platform-dependent — Android's +# ``NestedScrollView`` flings more aggressively per gesture than iOS's +# ``UIScrollView``, so a fixed ``times: N`` overshoots on one platform +# and undershoots on the other. We loop with ``while: notVisible`` +# instead, which exits as soon as ``ScrollRow 20`` enters the viewport +# regardless of how many swipes that took on this platform. Swipes are +# kept small (10% of screen height) so a single fling can't shoot +# past row 20. +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: ScrollView +- assertVisible: "ScrollRow 1" +- repeat: + times: 15 + while: + notVisible: "ScrollRow 20" + commands: + - swipe: + start: 50%, 20% + end: 50%, 10% + duration: 500 +- assertVisible: "ScrollRow 20" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/section_list.yaml b/tests/e2e/flows/components/section_list.yaml new file mode 100644 index 0000000..c86a50d --- /dev/null +++ b/tests/e2e/flows/components/section_list.yaml @@ -0,0 +1,18 @@ +# Tests SectionList renders section headers and items together. +# +# Demo screen: examples/e2e-suite/app/screens/components/section_list.py +# Source under test: src/pythonnative/components.py :: SectionList +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: SectionList +- assertVisible: "Section Alpha" +- assertVisible: "Alpha row 1" +- assertVisible: "Section Beta" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/slider.yaml b/tests/e2e/flows/components/slider.yaml new file mode 100644 index 0000000..413ec68 --- /dev/null +++ b/tests/e2e/flows/components/slider.yaml @@ -0,0 +1,22 @@ +# Tests Slider value updates via programmatic snap buttons. +# +# Demo screen: examples/e2e-suite/app/screens/components/slider.py +# Source under test: src/pythonnative/components.py :: Slider +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Slider +- assertVisible: "Value: 0.00" +- tapOn: "Set 0.5" +- assertVisible: "Value: 0.50" +- tapOn: "Set 1.0" +- assertVisible: "Value: 1.00" +- tapOn: "Set 0.0" +- assertVisible: "Value: 0.00" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/spacer.yaml b/tests/e2e/flows/components/spacer.yaml new file mode 100644 index 0000000..a09b782 --- /dev/null +++ b/tests/e2e/flows/components/spacer.yaml @@ -0,0 +1,17 @@ +# Tests Spacer renders and labels remain visible around it. +# +# Demo screen: examples/e2e-suite/app/screens/components/spacer.py +# Source under test: src/pythonnative/components.py :: Spacer +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Spacer +- assertVisible: "Spacer top label" +- assertVisible: "Spacer bottom label" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/status_bar.yaml b/tests/e2e/flows/components/status_bar.yaml new file mode 100644 index 0000000..77ec5a9 --- /dev/null +++ b/tests/e2e/flows/components/status_bar.yaml @@ -0,0 +1,20 @@ +# Tests StatusBar toggles between dark and light styles. +# +# Demo screen: examples/e2e-suite/app/screens/components/status_bar.py +# Source under test: src/pythonnative/components.py :: StatusBar +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: StatusBar +- assertVisible: "Bar style: dark" +- tapOn: "Toggle" +- assertVisible: "Bar style: light" +- tapOn: "Toggle" +- assertVisible: "Bar style: dark" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/switch.yaml b/tests/e2e/flows/components/switch.yaml new file mode 100644 index 0000000..b0f50f6 --- /dev/null +++ b/tests/e2e/flows/components/switch.yaml @@ -0,0 +1,20 @@ +# Tests Switch flips between ON and OFF in response to button taps. +# +# Demo screen: examples/e2e-suite/app/screens/components/switch.py +# Source under test: src/pythonnative/components.py :: Switch +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Switch +- assertVisible: "State: OFF" +- tapOn: "Turn on" +- assertVisible: "State: ON" +- tapOn: "Turn off" +- assertVisible: "State: OFF" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/text.yaml b/tests/e2e/flows/components/text.yaml new file mode 100644 index 0000000..261e5bf --- /dev/null +++ b/tests/e2e/flows/components/text.yaml @@ -0,0 +1,18 @@ +# Tests Text element renders multiple variants in one screen. +# +# Demo screen: examples/e2e-suite/app/screens/components/text.py +# Source under test: src/pythonnative/components.py :: Text +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: Text +- assertVisible: "Plain text line" +- assertVisible: "Bold text line" +- assertVisible: "Sized and colored line" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/text_input.yaml b/tests/e2e/flows/components/text_input.yaml new file mode 100644 index 0000000..40f2934 --- /dev/null +++ b/tests/e2e/flows/components/text_input.yaml @@ -0,0 +1,25 @@ +# Tests TextInput: typing into a single-line input mirrors into the echo line. +# +# Demo screen: examples/e2e-suite/app/screens/components/text_input.py +# Source under test: src/pythonnative/components.py :: TextInput +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: TextInput +- assertVisible: "Echo: (empty)" +- tapOn: "Type your name here" +- inputText: "Maestro" +- assertVisible: "Echo: Maestro" +# Maestro's ``hideKeyboard`` is unreliable on iOS 26+ (it looks for a +# UI dismiss button that doesn't exist for a standard text keyboard). +# ``pressKey: Enter`` works because the iOS handler installs a +# ``UITextFieldDelegate.textFieldShouldReturn:`` that resigns first +# responder, which is also the behavior most users expect. +- pressKey: Enter +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/view_column_row.yaml b/tests/e2e/flows/components/view_column_row.yaml new file mode 100644 index 0000000..d5c4160 --- /dev/null +++ b/tests/e2e/flows/components/view_column_row.yaml @@ -0,0 +1,22 @@ +# Tests View / Column / Row render their children in the right direction. +# +# Demo screen: examples/e2e-suite/app/screens/components/view_column_row.py +# Source under test: src/pythonnative/components.py :: View, Column, Row +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: View / Column / Row +- assertVisible: "A" +- assertVisible: "B" +- assertVisible: "C" +- assertVisible: "X" +- assertVisible: "Y" +- assertVisible: "Z" +- assertVisible: "inside View" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/components/web_view.yaml b/tests/e2e/flows/components/web_view.yaml new file mode 100644 index 0000000..5ebb1da --- /dev/null +++ b/tests/e2e/flows/components/web_view.yaml @@ -0,0 +1,16 @@ +# Tests WebView mounts; surrounding labels remain visible. +# +# Demo screen: examples/e2e-suite/app/screens/components/web_view.py +# Source under test: src/pythonnative/components.py :: WebView +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Components + DEMO_TITLE: WebView +- assertVisible: "WebView visible marker" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Components diff --git a/tests/e2e/flows/hooks/batch_updates.yaml b/tests/e2e/flows/hooks/batch_updates.yaml new file mode 100644 index 0000000..567a3ae --- /dev/null +++ b/tests/e2e/flows/hooks/batch_updates.yaml @@ -0,0 +1,21 @@ +# Tests batch_updates collapses multiple setters into a single render. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/batch_updates_demo.py +# Source under test: src/pythonnative/hooks.py :: batch_updates +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: batch_updates +- tapOn: "Batched bump" +- assertVisible: "a: 1" +- assertVisible: "b: 1" +- tapOn: "Batched bump" +- assertVisible: "a: 2" +- assertVisible: "b: 2" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/memo.yaml b/tests/e2e/flows/hooks/memo.yaml new file mode 100644 index 0000000..aa63fcb --- /dev/null +++ b/tests/e2e/flows/hooks/memo.yaml @@ -0,0 +1,21 @@ +# Tests pn.memo prevents children from re-rendering on unrelated parent state changes. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/memo_demo.py +# Source under test: src/pythonnative/hooks.py :: memo +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: memo +- assertVisible: "MemoA render count: 1" +- tapOn: "Bump parent" +- assertVisible: "Parent renders: 1" +- assertVisible: "MemoA render count: 1" +- tapOn: "Toggle B label" +- assertVisible: "MemoB label=y render count: 2" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_async_effect.yaml b/tests/e2e/flows/hooks/use_async_effect.yaml new file mode 100644 index 0000000..4ccce83 --- /dev/null +++ b/tests/e2e/flows/hooks/use_async_effect.yaml @@ -0,0 +1,18 @@ +# Tests use_async_effect resolves and updates state on mount. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_async_effect.py +# Source under test: src/pythonnative/hooks.py :: use_async_effect +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_async_effect +- extendedWaitUntil: + visible: "Status: done" + timeout: 5000 +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_callback.yaml b/tests/e2e/flows/hooks/use_callback.yaml new file mode 100644 index 0000000..ac84da2 --- /dev/null +++ b/tests/e2e/flows/hooks/use_callback.yaml @@ -0,0 +1,22 @@ +# Tests use_callback identity stays stable until dep changes. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_callback.py +# Source under test: src/pythonnative/hooks.py :: use_callback +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_callback +- assertVisible: "Identity changes: 0" +- tapOn: "Change other" +- assertVisible: "Identity changes: 0" +- tapOn: "Change other" +- assertVisible: "Identity changes: 0" +- tapOn: "Change dep" +- assertVisible: "Identity changes: 1" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_context.yaml b/tests/e2e/flows/hooks/use_context.yaml new file mode 100644 index 0000000..9571b65 --- /dev/null +++ b/tests/e2e/flows/hooks/use_context.yaml @@ -0,0 +1,20 @@ +# Tests use_context + Provider propagate values to a child. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_context.py +# Source under test: src/pythonnative/hooks.py :: create_context, use_context, Provider +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_context +- assertVisible: "Current theme: light" +- assertVisible: "Consumer sees: light" +- tapOn: "Set dark" +- assertVisible: "Current theme: dark" +- assertVisible: "Consumer sees: dark" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_effect.yaml b/tests/e2e/flows/hooks/use_effect.yaml new file mode 100644 index 0000000..4bb4d9c --- /dev/null +++ b/tests/e2e/flows/hooks/use_effect.yaml @@ -0,0 +1,24 @@ +# Tests use_effect runs once on mount and again on each dep change. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_effect.py +# Source under test: src/pythonnative/hooks.py :: use_effect +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_effect +- assertVisible: "Mount runs: 1" +- assertVisible: "Dep runs: 1" +- assertVisible: "Dep value: 0" +- tapOn: "Bump dep" +- assertVisible: "Dep runs: 2" +- assertVisible: "Dep value: 1" +- tapOn: "Bump dep" +- assertVisible: "Dep runs: 3" +- assertVisible: "Mount runs: 1" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_memo.yaml b/tests/e2e/flows/hooks/use_memo.yaml new file mode 100644 index 0000000..0304420 --- /dev/null +++ b/tests/e2e/flows/hooks/use_memo.yaml @@ -0,0 +1,24 @@ +# Tests use_memo factory only fires when its deps change. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_memo.py +# Source under test: src/pythonnative/hooks.py :: use_memo +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_memo +- assertVisible: "Factory runs: 1" +- assertVisible: "Memo value: 0" +- assertVisible: "Other state: 0" +- tapOn: "Change other" +- assertVisible: "Factory runs: 1" +- assertVisible: "Other state: 1" +- tapOn: "Change dep" +- assertVisible: "Factory runs: 2" +- assertVisible: "Memo value: 2" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_mutation.yaml b/tests/e2e/flows/hooks/use_mutation.yaml new file mode 100644 index 0000000..1c6d678 --- /dev/null +++ b/tests/e2e/flows/hooks/use_mutation.yaml @@ -0,0 +1,21 @@ +# Tests use_mutation submits and exposes the result data. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_mutation.py +# Source under test: src/pythonnative/hooks.py :: use_mutation +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_mutation +- assertVisible: "Status: idle" +- assertVisible: "Last data: (none)" +- tapOn: "Submit hello" +- extendedWaitUntil: + visible: "Last data: echo:hello" + timeout: 5000 +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_persisted_state.yaml b/tests/e2e/flows/hooks/use_persisted_state.yaml new file mode 100644 index 0000000..2072213 --- /dev/null +++ b/tests/e2e/flows/hooks/use_persisted_state.yaml @@ -0,0 +1,23 @@ +# Tests use_persisted_state behaves like use_state in-session. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_persisted_state.py +# Source under test: src/pythonnative/storage.py :: use_persisted_state +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_persisted_state +- tapOn: "Clear" +- assertVisible: "Persisted value: 0" +- tapOn: "Bump" +- assertVisible: "Persisted value: 1" +- tapOn: "Bump" +- assertVisible: "Persisted value: 2" +- tapOn: "Clear" +- assertVisible: "Persisted value: 0" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_query.yaml b/tests/e2e/flows/hooks/use_query.yaml new file mode 100644 index 0000000..b9f0ba3 --- /dev/null +++ b/tests/e2e/flows/hooks/use_query.yaml @@ -0,0 +1,24 @@ +# Tests use_query loads, resolves, and refetches data. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_query.py +# Source under test: src/pythonnative/hooks.py :: use_query, QueryResult +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_query +- extendedWaitUntil: + visible: "Status: ready" + timeout: 5000 +- assertVisible: "Data: fetched-value" +- tapOn: "Refetch" +- extendedWaitUntil: + visible: "Status: ready" + timeout: 5000 +- assertVisible: "Data: fetched-value" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_reducer.yaml b/tests/e2e/flows/hooks/use_reducer.yaml new file mode 100644 index 0000000..6f96bfd --- /dev/null +++ b/tests/e2e/flows/hooks/use_reducer.yaml @@ -0,0 +1,24 @@ +# Tests use_reducer dispatches inc / dec / reset actions. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_reducer.py +# Source under test: src/pythonnative/hooks.py :: use_reducer +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_reducer +- assertVisible: "Counter: 0" +- tapOn: "Dispatch inc" +- assertVisible: "Counter: 1" +- tapOn: "Dispatch inc" +- assertVisible: "Counter: 2" +- tapOn: "Dispatch dec" +- assertVisible: "Counter: 1" +- tapOn: "Dispatch reset" +- assertVisible: "Counter: 0" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_ref.yaml b/tests/e2e/flows/hooks/use_ref.yaml new file mode 100644 index 0000000..931803b --- /dev/null +++ b/tests/e2e/flows/hooks/use_ref.yaml @@ -0,0 +1,23 @@ +# Tests use_ref keeps a silent value across non-render updates. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_ref.py +# Source under test: src/pythonnative/hooks.py :: use_ref +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_ref +- assertVisible: "Silent ref value: 0" +- assertVisible: "Renders: 0" +- tapOn: "Bump silent" +- tapOn: "Bump silent" +- assertVisible: "Silent ref value: 0" +- tapOn: "Force render" +- assertVisible: "Silent ref value: 2" +- assertVisible: "Renders: 1" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_state.yaml b/tests/e2e/flows/hooks/use_state.yaml new file mode 100644 index 0000000..b8922bb --- /dev/null +++ b/tests/e2e/flows/hooks/use_state.yaml @@ -0,0 +1,24 @@ +# Tests use_state increments / resets a counter. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_state.py +# Source under test: src/pythonnative/hooks.py :: use_state +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_state +- assertVisible: "Counter: 0" +- tapOn: "Increment" +- assertVisible: "Counter: 1" +- tapOn: "Increment" +- assertVisible: "Counter: 2" +- tapOn: "Decrement" +- assertVisible: "Counter: 1" +- tapOn: "Reset" +- assertVisible: "Counter: 0" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/hooks/use_window_dimensions.yaml b/tests/e2e/flows/hooks/use_window_dimensions.yaml new file mode 100644 index 0000000..a10a419 --- /dev/null +++ b/tests/e2e/flows/hooks/use_window_dimensions.yaml @@ -0,0 +1,17 @@ +# Tests use_window_dimensions returns a non-empty Window: line. +# +# Demo screen: examples/e2e-suite/app/screens/hooks/use_window_dimensions.py +# Source under test: src/pythonnative/hooks.py :: use_window_dimensions +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Hooks + DEMO_TITLE: use_window_dimensions +- assertVisible: + text: "Window:.*×.*" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Hooks diff --git a/tests/e2e/flows/layout/absolute_position.yaml b/tests/e2e/flows/layout/absolute_position.yaml new file mode 100644 index 0000000..ef6b7c3 --- /dev/null +++ b/tests/e2e/flows/layout/absolute_position.yaml @@ -0,0 +1,20 @@ +# Tests position:"absolute" places four corners and a centered label. +# +# Demo screen: examples/e2e-suite/app/screens/layout/absolute_position.py +# Source under test: src/pythonnative/layout.py +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Layout + DEMO_TITLE: Absolute positioning +- assertVisible: "abs-top-left" +- assertVisible: "abs-top-right" +- assertVisible: "abs-bottom-left" +- assertVisible: "abs-bottom-right" +- assertVisible: "abs-center" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Layout diff --git a/tests/e2e/flows/layout/alignment.yaml b/tests/e2e/flows/layout/alignment.yaml new file mode 100644 index 0000000..c9a4419 --- /dev/null +++ b/tests/e2e/flows/layout/alignment.yaml @@ -0,0 +1,18 @@ +# Tests three align_items variants render labelled children. +# +# Demo screen: examples/e2e-suite/app/screens/layout/alignment.py +# Source under test: src/pythonnative/layout.py, src/pythonnative/style.py +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Layout + DEMO_TITLE: Alignment +- assertVisible: "align-start-a" +- assertVisible: "align-center-a" +- assertVisible: "align-end-a" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Layout diff --git a/tests/e2e/flows/layout/aspect_ratio.yaml b/tests/e2e/flows/layout/aspect_ratio.yaml new file mode 100644 index 0000000..439b4c3 --- /dev/null +++ b/tests/e2e/flows/layout/aspect_ratio.yaml @@ -0,0 +1,17 @@ +# Tests aspect_ratio sizes both square and widescreen boxes. +# +# Demo screen: examples/e2e-suite/app/screens/layout/aspect_ratio.py +# Source under test: src/pythonnative/layout.py +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Layout + DEMO_TITLE: Aspect ratio +- assertVisible: "aspect-1-1" +- assertVisible: "aspect-16-9" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Layout diff --git a/tests/e2e/flows/layout/flex_layout.yaml b/tests/e2e/flows/layout/flex_layout.yaml new file mode 100644 index 0000000..00c4064 --- /dev/null +++ b/tests/e2e/flows/layout/flex_layout.yaml @@ -0,0 +1,18 @@ +# Tests flex layout renders fixed, flex-grow, and fixed children in a row. +# +# Demo screen: examples/e2e-suite/app/screens/layout/flex_layout.py +# Source under test: src/pythonnative/layout.py +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Layout + DEMO_TITLE: Flex layout +- assertVisible: "flex-fixed-left" +- assertVisible: "flex-grow" +- assertVisible: "flex-fixed-right" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Layout diff --git a/tests/e2e/flows/layout/padding_margin.yaml b/tests/e2e/flows/layout/padding_margin.yaml new file mode 100644 index 0000000..d20ccc9 --- /dev/null +++ b/tests/e2e/flows/layout/padding_margin.yaml @@ -0,0 +1,20 @@ +# Tests padding / margin labels render at different sizes. +# +# Demo screen: examples/e2e-suite/app/screens/layout/padding_margin.py +# Source under test: src/pythonnative/layout.py, src/pythonnative/style.py +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Layout + DEMO_TITLE: Padding & margin +- assertVisible: "padding-4" +- assertVisible: "padding-12" +- assertVisible: "padding-24" +- assertVisible: "margin-0" +- assertVisible: "margin-12" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Layout diff --git a/tests/e2e/flows/layout_android.yaml b/tests/e2e/flows/layout_android.yaml deleted file mode 100644 index 652f88f..0000000 --- a/tests/e2e/flows/layout_android.yaml +++ /dev/null @@ -1,17 +0,0 @@ -appId: ${APP_ID} ---- -# Switch to the Layout screen and verify the parts that fit in the -# 320x640 Android CI viewport. -- launchApp -- extendedWaitUntil: - visible: "Hello from PythonNative Demo!" - timeout: 30000 -- tapOn: "Layout" -- extendedWaitUntil: - visible: "Flex layout" - timeout: 10000 -- assertVisible: "flex: 1" -- assertVisible: "Aspect ratio" -- assertVisible: "1:1" -- assertVisible: "16:9" -- assertVisible: "Absolute positioning" diff --git a/tests/e2e/flows/layout_screen.yaml b/tests/e2e/flows/layout_screen.yaml deleted file mode 100644 index bcbb51a..0000000 --- a/tests/e2e/flows/layout_screen.yaml +++ /dev/null @@ -1,22 +0,0 @@ -appId: ${APP_ID} ---- -# Switch to the Layout screen and verify the flex / aspect-ratio / -# absolute-positioning demos all render after the layout pass. -- launchApp -- extendedWaitUntil: - visible: "Hello from PythonNative Demo!" - timeout: 30000 -- tapOn: "Layout" -- extendedWaitUntil: - visible: "Flex layout" - timeout: 10000 -- assertVisible: "flex: 1" -- assertVisible: "Aspect ratio" -- assertVisible: "1:1" -- assertVisible: "16:9" -- assertVisible: "Absolute positioning" -- assertVisible: "top-left" -- assertVisible: "top-right" -- assertVisible: "bottom-left" -- assertVisible: "bottom-right" -- assertVisible: "centered" diff --git a/tests/e2e/flows/list_screen.yaml b/tests/e2e/flows/list_screen.yaml deleted file mode 100644 index a2ee814..0000000 --- a/tests/e2e/flows/list_screen.yaml +++ /dev/null @@ -1,17 +0,0 @@ -appId: ${APP_ID} ---- -# Switch to the List screen and verify the virtualized FlatList renders rows. -- launchApp -- extendedWaitUntil: - visible: "Hello from PythonNative Demo!" - timeout: 30000 -- tapOn: "List" -- extendedWaitUntil: - visible: "Row 1" - timeout: 10000 -- assertVisible: "Row 1" -- assertVisible: "Row 2" -- assertVisible: "Row 3" -- scroll -- scroll -- scroll diff --git a/tests/e2e/flows/main.yaml b/tests/e2e/flows/main.yaml deleted file mode 100644 index 62c04b6..0000000 --- a/tests/e2e/flows/main.yaml +++ /dev/null @@ -1,14 +0,0 @@ -appId: ${APP_ID} ---- -# Verify the home screen renders correctly and the counter works. -- launchApp -- extendedWaitUntil: - visible: "Hello from PythonNative Demo!" - timeout: 30000 -- assertVisible: "Tapped 0 times" -- assertVisible: "Tap me" -- assertVisible: "View Showcase" -- tapOn: "Tap me" -- assertVisible: "Tapped 1 times" -- tapOn: "Tap me" -- assertVisible: "Tapped 2 times" diff --git a/tests/e2e/flows/navigation.yaml b/tests/e2e/flows/navigation.yaml deleted file mode 100644 index 6d14ffd..0000000 --- a/tests/e2e/flows/navigation.yaml +++ /dev/null @@ -1,19 +0,0 @@ -appId: ${APP_ID} ---- -# Navigate Home -> Showcase -> Forms and back to Home. -- launchApp -- extendedWaitUntil: - visible: "Hello from PythonNative Demo!" - timeout: 30000 -- tapOn: "View Showcase" -- assertVisible: "Greetings from Home" -- assertVisible: "View Forms" -- assertVisible: "Back" -- tapOn: "View Forms" -- assertVisible: "Forms" -- assertVisible: "You navigated two levels deep." -- assertVisible: "Back to Showcase" -- tapOn: "Back to Showcase" -- assertVisible: "Greetings from Home" -- tapOn: "Back" -- assertVisible: "Hello from PythonNative Demo!" diff --git a/tests/e2e/flows/navigation/drawer_navigator.yaml b/tests/e2e/flows/navigation/drawer_navigator.yaml new file mode 100644 index 0000000..e2d692f --- /dev/null +++ b/tests/e2e/flows/navigation/drawer_navigator.yaml @@ -0,0 +1,20 @@ +# Tests the nested Drawer navigator switches screens via explicit nav buttons. +# +# Demo screen: examples/e2e-suite/app/screens/navigation/drawer_navigator.py +# Source under test: src/pythonnative/navigation.py :: create_drawer_navigator +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Navigation + DEMO_TITLE: Drawer Navigator +- assertVisible: "Drawer screen One" +- tapOn: "Go to Two" +- assertVisible: "Drawer screen Two" +- tapOn: "Go to One" +- assertVisible: "Drawer screen One" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Navigation diff --git a/tests/e2e/flows/navigation/focus_effect.yaml b/tests/e2e/flows/navigation/focus_effect.yaml new file mode 100644 index 0000000..18cea5d --- /dev/null +++ b/tests/e2e/flows/navigation/focus_effect.yaml @@ -0,0 +1,31 @@ +# Tests use_focus_effect fires on focus / refocus. +# +# Demo screen: examples/e2e-suite/app/screens/navigation/focus_effect.py +# Source under test: src/pythonnative/navigation.py :: use_focus_effect +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Navigation + DEMO_TITLE: use_focus_effect +- assertVisible: "Focus count: 1" +- tapOn: "Push another screen" +- extendedWaitUntil: + visible: "Demo: use_state" + timeout: 10000 +# ``- back`` on iOS swipes from the left edge, which is unreliable +# in the simulator. Tap the demo's own "Back to list" button instead, +# which pops one stack entry — exactly what we need to refocus the +# previous demo screen. +- tapOn: "Back to list" +- extendedWaitUntil: + visible: "Demo: use_focus_effect" + timeout: 10000 +# After refocus the counter must be >= 2; we assert one specific value +# that is the typical observed sequence. +- assertVisible: "Focus count: 2" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Navigation diff --git a/tests/e2e/flows/navigation/params_passing.yaml b/tests/e2e/flows/navigation/params_passing.yaml new file mode 100644 index 0000000..0aff298 --- /dev/null +++ b/tests/e2e/flows/navigation/params_passing.yaml @@ -0,0 +1,27 @@ +# Tests use_route reads navigation params; navigating with new params updates them. +# +# Demo screen: examples/e2e-suite/app/screens/navigation/params_passing.py +# Source under test: src/pythonnative/navigation.py :: use_route, NavigationHandle.navigate +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Navigation + DEMO_TITLE: Route Params +- assertVisible: "Param 'value': (none)" +- tapOn: "Push value=alpha" +- assertVisible: "Param 'value': alpha" +- tapOn: "Push value=beta" +- assertVisible: "Param 'value': beta" +# Each ``Push value=…`` pushes a new ``params_passing`` entry onto +# the stack, so two pops are needed to drain that history before +# the standard close_demo tap pops out of the demo to the category. +- tapOn: "Back to list" +- assertVisible: "Param 'value': alpha" +- tapOn: "Back to list" +- assertVisible: "Param 'value': (none)" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Navigation diff --git a/tests/e2e/flows/navigation/tab_navigator.yaml b/tests/e2e/flows/navigation/tab_navigator.yaml new file mode 100644 index 0000000..4cd63d8 --- /dev/null +++ b/tests/e2e/flows/navigation/tab_navigator.yaml @@ -0,0 +1,22 @@ +# Tests the nested Tab navigator switches tabs and shows their body. +# +# Demo screen: examples/e2e-suite/app/screens/navigation/tab_navigator.py +# Source under test: src/pythonnative/navigation.py :: create_tab_navigator +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Navigation + DEMO_TITLE: Tab Navigator +- assertVisible: "Tab Alpha body" +- tapOn: "Beta" +- assertVisible: "Tab Beta body" +- tapOn: "Gamma" +- assertVisible: "Tab Gamma body" +- tapOn: "Alpha" +- assertVisible: "Tab Alpha body" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Navigation diff --git a/tests/e2e/flows/navigation_android.yaml b/tests/e2e/flows/navigation_android.yaml deleted file mode 100644 index 60aba95..0000000 --- a/tests/e2e/flows/navigation_android.yaml +++ /dev/null @@ -1,23 +0,0 @@ -appId: ${APP_ID} ---- -# Navigate Home -> Showcase -> Forms and back on Android. -- launchApp -- extendedWaitUntil: - visible: "Hello from PythonNative Demo!" - timeout: 30000 -- tapOn: "View Showcase" -- extendedWaitUntil: - visible: "Greetings from Home" - timeout: 10000 -- scroll -- scroll -- extendedWaitUntil: - visible: "View Forms" - timeout: 10000 -- tapOn: "View Forms" -- assertVisible: "Forms" -- assertVisible: "You navigated two levels deep." -- back -- extendedWaitUntil: - visible: "Greetings from Home" - timeout: 10000 diff --git a/tests/e2e/flows/platform/platform_info.yaml b/tests/e2e/flows/platform/platform_info.yaml new file mode 100644 index 0000000..48f6fc4 --- /dev/null +++ b/tests/e2e/flows/platform/platform_info.yaml @@ -0,0 +1,21 @@ +# Tests Platform.OS and Platform.Version expose non-empty values. +# +# Demo screen: examples/e2e-suite/app/screens/platform/platform_info.py +# Source under test: src/pythonnative/platform.py +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Platform + DEMO_TITLE: Platform info +- assertVisible: + text: "OS:.*" +- assertVisible: + text: "Version:.*" +- assertVisible: + text: "PythonNative:.*" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Platform diff --git a/tests/e2e/flows/runtime/run_async.yaml b/tests/e2e/flows/runtime/run_async.yaml new file mode 100644 index 0000000..69e7402 --- /dev/null +++ b/tests/e2e/flows/runtime/run_async.yaml @@ -0,0 +1,20 @@ +# Tests run_async schedules a coroutine on the framework loop. +# +# Demo screen: examples/e2e-suite/app/screens/runtime/run_async_demo.py +# Source under test: src/pythonnative/runtime.py :: run_async +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Runtime + DEMO_TITLE: run_async +- assertVisible: "Status: idle" +- tapOn: "Run async job" +- extendedWaitUntil: + visible: "Status: done" + timeout: 5000 +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Runtime diff --git a/tests/e2e/flows/sdk/custom_component.yaml b/tests/e2e/flows/sdk/custom_component.yaml new file mode 100644 index 0000000..2c25015 --- /dev/null +++ b/tests/e2e/flows/sdk/custom_component.yaml @@ -0,0 +1,17 @@ +# Tests the SDK surface loaded and Props subclassing works. +# +# Demo screen: examples/e2e-suite/app/screens/sdk/custom_component.py +# Source under test: src/pythonnative/sdk/__init__.py, src/pythonnative/sdk/_components.py +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: SDK + DEMO_TITLE: Custom component +- assertVisible: "Props subclass works: yes" +- assertVisible: "SDK module loaded: yes" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: SDK diff --git a/tests/e2e/flows/settings_screen.yaml b/tests/e2e/flows/settings_screen.yaml deleted file mode 100644 index 51792aa..0000000 --- a/tests/e2e/flows/settings_screen.yaml +++ /dev/null @@ -1,19 +0,0 @@ -appId: ${APP_ID} ---- -# Switch to the Settings screen and verify Platform info + Alert API. -- launchApp -- extendedWaitUntil: - visible: "Hello from PythonNative Demo!" - timeout: 30000 -- tapOn: "Settings" -- extendedWaitUntil: - visible: "Settings" - timeout: 10000 -- assertVisible: "PythonNative v.*" -- assertVisible: "Show alert" -- tapOn: "Show alert" -- extendedWaitUntil: - visible: "Hello!" - timeout: 5000 -- assertVisible: "This is a native alert dialog." -- tapOn: "OK" diff --git a/tests/e2e/flows/storage/async_storage.yaml b/tests/e2e/flows/storage/async_storage.yaml new file mode 100644 index 0000000..488c77d --- /dev/null +++ b/tests/e2e/flows/storage/async_storage.yaml @@ -0,0 +1,28 @@ +# Tests AsyncStorage write / read / delete round-trip. +# +# Demo screen: examples/e2e-suite/app/screens/storage/async_storage_demo.py +# Source under test: src/pythonnative/storage.py :: AsyncStorage +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Storage + DEMO_TITLE: AsyncStorage +- tapOn: "Clear" +- assertVisible: "Read value: (unread)" +- tapOn: "Write" +- assertVisible: "Read value: (unread)" +- tapOn: "Read" +- extendedWaitUntil: + visible: "Read value: stored-value" + timeout: 5000 +- tapOn: "Clear" +- tapOn: "Read" +- extendedWaitUntil: + visible: "Read value: (none)" + timeout: 5000 +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Storage diff --git a/tests/e2e/flows/styling/borders_shadows.yaml b/tests/e2e/flows/styling/borders_shadows.yaml new file mode 100644 index 0000000..baeb23b --- /dev/null +++ b/tests/e2e/flows/styling/borders_shadows.yaml @@ -0,0 +1,16 @@ +# Tests borders + shadow card renders with its label. +# +# Demo screen: examples/e2e-suite/app/screens/styling/borders_shadows.py +# Source under test: src/pythonnative/style.py, native handlers +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Styling + DEMO_TITLE: Borders & shadows +- assertVisible: "border-shadow-card" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Styling diff --git a/tests/e2e/flows/styling/stylesheet.yaml b/tests/e2e/flows/styling/stylesheet.yaml new file mode 100644 index 0000000..a414270 --- /dev/null +++ b/tests/e2e/flows/styling/stylesheet.yaml @@ -0,0 +1,17 @@ +# Tests StyleSheet entries resolve to working styles. +# +# Demo screen: examples/e2e-suite/app/screens/styling/stylesheet_demo.py +# Source under test: src/pythonnative/style.py :: StyleSheet, style +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Styling + DEMO_TITLE: StyleSheet +- assertVisible: "stylesheet-pill" +- assertVisible: "stylesheet-danger" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Styling diff --git a/tests/e2e/flows/styling/transform.yaml b/tests/e2e/flows/styling/transform.yaml new file mode 100644 index 0000000..423bf10 --- /dev/null +++ b/tests/e2e/flows/styling/transform.yaml @@ -0,0 +1,18 @@ +# Tests transform styles (translate, rotate, scale) render their labels. +# +# Demo screen: examples/e2e-suite/app/screens/styling/transform.py +# Source under test: src/pythonnative/style.py :: TransformSpec, native handlers +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Styling + DEMO_TITLE: Transforms +- assertVisible: "translate" +- assertVisible: "rotate" +- assertVisible: "scale" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Styling diff --git a/tests/e2e/flows/styling/typography.yaml b/tests/e2e/flows/styling/typography.yaml new file mode 100644 index 0000000..74e0a96 --- /dev/null +++ b/tests/e2e/flows/styling/typography.yaml @@ -0,0 +1,21 @@ +# Tests typography variants all render. +# +# Demo screen: examples/e2e-suite/app/screens/styling/typography.py +# Source under test: src/pythonnative/style.py, src/pythonnative/components.py :: Text +appId: ${APP_ID} +--- +- runFlow: + file: ../../helpers/open_demo.yaml + env: + CATEGORY: Styling + DEMO_TITLE: Typography +- assertVisible: "type-headline" +- assertVisible: "type-body" +- assertVisible: "type-caption" +- assertVisible: "type-italic" +- assertVisible: "type-underline" +- assertVisible: "type-letter-spacing" +- runFlow: + file: ../../helpers/close_demo.yaml + env: + CATEGORY: Styling diff --git a/tests/e2e/helpers/close_demo.yaml b/tests/e2e/helpers/close_demo.yaml new file mode 100644 index 0000000..449fc89 --- /dev/null +++ b/tests/e2e/helpers/close_demo.yaml @@ -0,0 +1,31 @@ +# Reusable cleanup: pop from a demo screen back to its category list. +# +# Each flow ends with this helper so the next flow's ``open_demo.yaml`` +# can pick up from a known state. ``open_demo`` is state-aware and will +# handle category-to-category or home-to-category transitions itself, +# so this helper only needs to take a single step out of the demo — +# the suite stays in the category screen between consecutive demos in +# the same category, which is most of the suite by volume and the main +# source of the suite-wide speedup. +# +# Callers pass: +# +# CATEGORY: "Components" +# +# The ``Demos in ${CATEGORY}`` assertion catches a broken "Back to list" +# button right here, with a clear error, instead of letting the failure +# surface as a confusing scroll-not-found in the next flow's +# ``open_demo``. +# +# iOS preserves a parent ScrollView's offset across navigation, so the +# category title can start above the visible fold after returning from +# a deep demo — we scroll up to it before asserting. +appId: ${APP_ID} +--- +- tapOn: "Back to list" +- scrollUntilVisible: + element: + text: "Demos in ${CATEGORY}" + direction: UP + timeout: 15000 +- assertVisible: "Demos in ${CATEGORY}" diff --git a/tests/e2e/helpers/open_demo.yaml b/tests/e2e/helpers/open_demo.yaml new file mode 100644 index 0000000..233a691 --- /dev/null +++ b/tests/e2e/helpers/open_demo.yaml @@ -0,0 +1,122 @@ +# Reusable navigation helper: get the app to ``"Demo: <DEMO_TITLE>"`` +# from whatever screen we happen to be on. +# +# Callers pass two env vars: +# +# CATEGORY: "Hooks" +# DEMO_TITLE: "use_state" +# +# This helper is *state-aware*: it does the minimum work needed to land +# on the target demo. The full suite spends most of its time replaying +# the same launch + nav-home + nav-category dance between every flow, +# so the suite-wide speedup from short-circuiting these steps is +# significant (and it removes a lot of ``launchApp`` calls, which is +# where Maestro's iOS XCUITest driver tends to flake). +# +# State machine, evaluated top-to-bottom; each block runs only if the +# previous one's effects still leave us off-target: +# +# 1. On a demo screen ("Back to list" visible) +# -> tap "Back to list", now on some category screen. +# 2. On the wrong category screen ("Back to home" visible, but title +# != ``Demos in ${CATEGORY}``) +# -> tap "Back to home", now on the home screen. +# 3. App is dead (neither right category nor home visible) +# -> ``launchApp``, now on the home screen. +# 4. On the home screen (but not yet on the right category) +# -> tap ``Open ${CATEGORY}``, now on the right category screen. +# 5. On the right category screen +# -> tap ``Open: ${DEMO_TITLE}``, now on the target demo. +# +# Both the home and category screens use a ScrollView and the target +# button can be below the visible fold (Components has 20+ demos). +# Maestro's ``tapOn`` does not auto-scroll, so we ``scrollUntilVisible`` +# before each tap. iOS also preserves a ScrollView's offset across +# navigation, so we scroll UP to surface the title before asserting. +appId: ${APP_ID} +--- +# 1. If we landed on a demo screen, pop one level to its category list. +# Confirm the pop by waiting for ``Back to list`` to disappear rather +# than for the category's ``Back to home`` button to appear: Android +# resets the category ScrollView's offset to the top after navigation, +# so ``Back to home`` (at the bottom of the category list) starts +# below the visible fold and isn't a reliable post-tap signal. iOS +# preserves the offset so the latter would work there — but the +# former works on both. +- runFlow: + when: + visible: "Back to list" + commands: + - tapOn: "Back to list" + - extendedWaitUntil: + notVisible: "Back to list" + timeout: 15000 + +# 2. If we're on the wrong category, go back to home. We gate on the +# category title (``Demos in ...``) being visible rather than the +# ``Back to home`` button: Android resets the category ScrollView's +# offset to the top after navigation, so ``Back to home`` (at the +# bottom of the list) starts off-screen and isn't a reliable signal. +# The title is at the top of the scroll content and ``close_demo`` +# always scrolls back to it before exiting, so it's visible on both +# platforms when the next flow's ``open_demo`` runs. +- runFlow: + when: + visible: + text: "Demos in .*" + notVisible: "Demos in ${CATEGORY}" + commands: + - scrollUntilVisible: + element: + text: "Back to home" + direction: DOWN + - tapOn: "Back to home" + - scrollUntilVisible: + element: + text: "E2E Suite home" + direction: UP + timeout: 15000 + - assertVisible: "E2E Suite home" + +# 3. If neither the right category nor the home screen is visible, the +# app is either not running yet (first flow of the suite) or the driver +# lost its connection. Relaunch as a cold start. +- runFlow: + when: + notVisible: "Demos in ${CATEGORY}" + commands: + - runFlow: + when: + notVisible: "E2E Suite home" + commands: + - launchApp + - extendedWaitUntil: + visible: "E2E Suite home" + timeout: 60000 + +# 4. From the home screen, navigate into the right category. +- runFlow: + when: + visible: "E2E Suite home" + notVisible: "Demos in ${CATEGORY}" + commands: + - scrollUntilVisible: + element: + text: "Open ${CATEGORY}" + direction: DOWN + - tapOn: "Open ${CATEGORY}" + - extendedWaitUntil: + visible: "Demos in ${CATEGORY}" + timeout: 15000 + +# 5. We're now guaranteed to be on the right category list. Open the +# requested demo. +- scrollUntilVisible: + element: + text: "Open: ${DEMO_TITLE}" + direction: DOWN +- tapOn: + text: "Open: ${DEMO_TITLE}" +- extendedWaitUntil: + visible: "Demo: ${DEMO_TITLE}" + timeout: 15000 diff --git a/tests/e2e/ios.yaml b/tests/e2e/ios.yaml index a299b94..2491dcd 100644 --- a/tests/e2e/ios.yaml +++ b/tests/e2e/ios.yaml @@ -1,9 +1,74 @@ +# iOS master E2E suite for the e2e-suite example app. +# +# Mirrors android.yaml. The bundle ID differs between the two +# templates but the flow set is the same; each flow file references +# elements by accessibility text only, so the same YAML works on both +# platforms. +# +# Run with: +# cd examples/e2e-suite && pn run ios --no-logs +# cd ../.. && maestro --platform ios test tests/e2e/ios.yaml appId: com.pythonnative.ios-template env: APP_ID: com.pythonnative.ios-template --- -- runFlow: flows/main.yaml -- runFlow: flows/navigation.yaml -- runFlow: flows/layout_screen.yaml -- runFlow: flows/list_screen.yaml -- runFlow: flows/settings_screen.yaml +- runFlow: flows/components/text.yaml +- runFlow: flows/components/button.yaml +- runFlow: flows/components/text_input.yaml +- runFlow: flows/components/image.yaml +- runFlow: flows/components/switch.yaml +- runFlow: flows/components/slider.yaml +- runFlow: flows/components/progress_bar.yaml +- runFlow: flows/components/activity_indicator.yaml +- runFlow: flows/components/view_column_row.yaml +- runFlow: flows/components/scroll_view.yaml +- runFlow: flows/components/safe_area_view.yaml +- runFlow: flows/components/modal.yaml +- runFlow: flows/components/pressable.yaml +- runFlow: flows/components/picker.yaml +- runFlow: flows/components/refresh_control.yaml +- runFlow: flows/components/fragment.yaml +- runFlow: flows/components/error_boundary.yaml +- runFlow: flows/components/spacer.yaml +- runFlow: flows/components/status_bar.yaml +- runFlow: flows/components/keyboard_avoiding_view.yaml +- runFlow: flows/components/flat_list.yaml +- runFlow: flows/components/section_list.yaml +- runFlow: flows/components/web_view.yaml +- runFlow: flows/hooks/use_state.yaml +- runFlow: flows/hooks/use_effect.yaml +- runFlow: flows/hooks/use_reducer.yaml +- runFlow: flows/hooks/use_ref.yaml +- runFlow: flows/hooks/use_memo.yaml +- runFlow: flows/hooks/use_callback.yaml +- runFlow: flows/hooks/use_context.yaml +- runFlow: flows/hooks/use_async_effect.yaml +- runFlow: flows/hooks/use_query.yaml +- runFlow: flows/hooks/use_mutation.yaml +- runFlow: flows/hooks/use_persisted_state.yaml +- runFlow: flows/hooks/use_window_dimensions.yaml +- runFlow: flows/hooks/memo.yaml +- runFlow: flows/hooks/batch_updates.yaml +- runFlow: flows/navigation/tab_navigator.yaml +- runFlow: flows/navigation/drawer_navigator.yaml +- runFlow: flows/navigation/params_passing.yaml +- runFlow: flows/navigation/focus_effect.yaml +- runFlow: flows/layout/flex_layout.yaml +- runFlow: flows/layout/aspect_ratio.yaml +- runFlow: flows/layout/absolute_position.yaml +- runFlow: flows/layout/padding_margin.yaml +- runFlow: flows/layout/alignment.yaml +- runFlow: flows/styling/typography.yaml +- runFlow: flows/styling/borders_shadows.yaml +- runFlow: flows/styling/transform.yaml +- runFlow: flows/styling/stylesheet.yaml +- runFlow: flows/animations/timing_animation.yaml +- runFlow: flows/animations/spring_animation.yaml +- runFlow: flows/animations/parallel_animation.yaml +- runFlow: flows/animations/sequence_animation.yaml +- runFlow: flows/alerts/simple_alert.yaml +- runFlow: flows/alerts/confirm_alert.yaml +- runFlow: flows/storage/async_storage.yaml +- runFlow: flows/runtime/run_async.yaml +- runFlow: flows/platform/platform_info.yaml +- runFlow: flows/sdk/custom_component.yaml diff --git a/tests/e2e/suites/animations.yaml b/tests/e2e/suites/animations.yaml new file mode 100644 index 0000000..ad7e475 --- /dev/null +++ b/tests/e2e/suites/animations.yaml @@ -0,0 +1,7 @@ +# Animations category suite. +appId: ${APP_ID} +--- +- runFlow: ../flows/animations/timing_animation.yaml +- runFlow: ../flows/animations/spring_animation.yaml +- runFlow: ../flows/animations/parallel_animation.yaml +- runFlow: ../flows/animations/sequence_animation.yaml diff --git a/tests/e2e/suites/components.yaml b/tests/e2e/suites/components.yaml new file mode 100644 index 0000000..e071da2 --- /dev/null +++ b/tests/e2e/suites/components.yaml @@ -0,0 +1,32 @@ +# Component category suite — Maestro entry-point for component-only runs. +# +# Usage: +# maestro test tests/e2e/suites/components.yaml -e APP_ID=com.pythonnative.android_template +# +# Useful for AI agents iterating on a component fix: only the relevant +# flows run, keeping the feedback loop tight. +appId: ${APP_ID} +--- +- runFlow: ../flows/components/text.yaml +- runFlow: ../flows/components/button.yaml +- runFlow: ../flows/components/text_input.yaml +- runFlow: ../flows/components/image.yaml +- runFlow: ../flows/components/switch.yaml +- runFlow: ../flows/components/slider.yaml +- runFlow: ../flows/components/progress_bar.yaml +- runFlow: ../flows/components/activity_indicator.yaml +- runFlow: ../flows/components/view_column_row.yaml +- runFlow: ../flows/components/scroll_view.yaml +- runFlow: ../flows/components/safe_area_view.yaml +- runFlow: ../flows/components/modal.yaml +- runFlow: ../flows/components/pressable.yaml +- runFlow: ../flows/components/picker.yaml +- runFlow: ../flows/components/refresh_control.yaml +- runFlow: ../flows/components/fragment.yaml +- runFlow: ../flows/components/error_boundary.yaml +- runFlow: ../flows/components/spacer.yaml +- runFlow: ../flows/components/status_bar.yaml +- runFlow: ../flows/components/keyboard_avoiding_view.yaml +- runFlow: ../flows/components/flat_list.yaml +- runFlow: ../flows/components/section_list.yaml +- runFlow: ../flows/components/web_view.yaml diff --git a/tests/e2e/suites/hooks.yaml b/tests/e2e/suites/hooks.yaml new file mode 100644 index 0000000..416847a --- /dev/null +++ b/tests/e2e/suites/hooks.yaml @@ -0,0 +1,17 @@ +# Hooks category suite — Maestro entry-point for hook-only runs. +appId: ${APP_ID} +--- +- runFlow: ../flows/hooks/use_state.yaml +- runFlow: ../flows/hooks/use_effect.yaml +- runFlow: ../flows/hooks/use_reducer.yaml +- runFlow: ../flows/hooks/use_ref.yaml +- runFlow: ../flows/hooks/use_memo.yaml +- runFlow: ../flows/hooks/use_callback.yaml +- runFlow: ../flows/hooks/use_context.yaml +- runFlow: ../flows/hooks/use_async_effect.yaml +- runFlow: ../flows/hooks/use_query.yaml +- runFlow: ../flows/hooks/use_mutation.yaml +- runFlow: ../flows/hooks/use_persisted_state.yaml +- runFlow: ../flows/hooks/use_window_dimensions.yaml +- runFlow: ../flows/hooks/memo.yaml +- runFlow: ../flows/hooks/batch_updates.yaml diff --git a/tests/e2e/suites/layout.yaml b/tests/e2e/suites/layout.yaml new file mode 100644 index 0000000..8bb5018 --- /dev/null +++ b/tests/e2e/suites/layout.yaml @@ -0,0 +1,8 @@ +# Layout category suite. +appId: ${APP_ID} +--- +- runFlow: ../flows/layout/flex_layout.yaml +- runFlow: ../flows/layout/aspect_ratio.yaml +- runFlow: ../flows/layout/absolute_position.yaml +- runFlow: ../flows/layout/padding_margin.yaml +- runFlow: ../flows/layout/alignment.yaml diff --git a/tests/e2e/suites/misc.yaml b/tests/e2e/suites/misc.yaml new file mode 100644 index 0000000..9ae892a --- /dev/null +++ b/tests/e2e/suites/misc.yaml @@ -0,0 +1,9 @@ +# Alerts / storage / runtime / platform / SDK category suite. +appId: ${APP_ID} +--- +- runFlow: ../flows/alerts/simple_alert.yaml +- runFlow: ../flows/alerts/confirm_alert.yaml +- runFlow: ../flows/storage/async_storage.yaml +- runFlow: ../flows/runtime/run_async.yaml +- runFlow: ../flows/platform/platform_info.yaml +- runFlow: ../flows/sdk/custom_component.yaml diff --git a/tests/e2e/suites/navigation.yaml b/tests/e2e/suites/navigation.yaml new file mode 100644 index 0000000..ee9fd67 --- /dev/null +++ b/tests/e2e/suites/navigation.yaml @@ -0,0 +1,7 @@ +# Navigation category suite. +appId: ${APP_ID} +--- +- runFlow: ../flows/navigation/tab_navigator.yaml +- runFlow: ../flows/navigation/drawer_navigator.yaml +- runFlow: ../flows/navigation/params_passing.yaml +- runFlow: ../flows/navigation/focus_effect.yaml diff --git a/tests/e2e/suites/styling.yaml b/tests/e2e/suites/styling.yaml new file mode 100644 index 0000000..3794b2a --- /dev/null +++ b/tests/e2e/suites/styling.yaml @@ -0,0 +1,7 @@ +# Styling category suite. +appId: ${APP_ID} +--- +- runFlow: ../flows/styling/typography.yaml +- runFlow: ../flows/styling/borders_shadows.yaml +- runFlow: ../flows/styling/transform.yaml +- runFlow: ../flows/styling/stylesheet.yaml diff --git a/tests/test_layout.py b/tests/test_layout.py index eae8992..8b50c7b 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -754,6 +754,86 @@ def test_unbounded_with_pure_flex_collapses_to_zero() -> None: assert root.height == 30 +# ====================================================================== +# Scroll-axis clamping (the marker the reconciler stamps on ScrollView) +# ====================================================================== + + +def test_scroll_axis_y_clamps_container_height_to_parent_avail() -> None: + """Vertical scroll containers fill the parent's height (don't grow with content). + + Without this clamp the container would size itself to ``used_main`` + (the natural content height), making + ``UIScrollView.frame.height == contentSize.height`` so the native + scroll view never has any overflow to actually scroll. + """ + inner = LayoutNode(style={"width": 100, "height": 1000}) + scroll = LayoutNode(children=[inner]) + scroll._pn_scroll_axis = "y" + root = LayoutNode( + style={"flex_direction": "column", "width": 200, "height": 400}, + children=[scroll], + ) + calculate_layout(root, 200, 400) + assert scroll.height == 400 + assert inner.height == 1000 + + +def test_scroll_axis_y_still_lets_children_measure_unbounded() -> None: + scroll = LayoutNode( + children=[LayoutNode(style={"width": 100, "height": 1500})], + ) + scroll._pn_scroll_axis = "y" + root = LayoutNode( + style={"flex_direction": "column", "width": 200, "height": 300}, + children=[scroll], + ) + calculate_layout(root, 200, 300) + assert scroll.children[0].height == 1500 + + +def test_scroll_axis_y_respects_explicit_height() -> None: + scroll = LayoutNode( + style={"height": 250}, + children=[LayoutNode(style={"width": 100, "height": 900})], + ) + scroll._pn_scroll_axis = "y" + root = LayoutNode( + style={"flex_direction": "column", "width": 200, "height": 800}, + children=[scroll], + ) + calculate_layout(root, 200, 800) + assert scroll.height == 250 + + +def test_scroll_axis_y_with_unbounded_parent_falls_back_to_natural() -> None: + """Nested scroll containers (parent unbounded) fall back to natural size. + + Matches React Native's behavior: an inner ``ScrollView`` whose + parent doesn't provide a finite height is treated as a normal + column — it sizes to its content and isn't independently scrollable. + """ + inner = LayoutNode(style={"width": 100, "height": 600}) + scroll = LayoutNode(children=[inner]) + scroll._pn_scroll_axis = "y" + root = LayoutNode(style={"width": 200}, children=[scroll]) + calculate_layout(root, 200, math.inf) + assert scroll.height == 600 + + +def test_scroll_axis_x_clamps_container_width_to_parent_avail() -> None: + inner = LayoutNode(style={"width": 1000, "height": 100}) + scroll = LayoutNode(style={"flex_direction": "row"}, children=[inner]) + scroll._pn_scroll_axis = "x" + root = LayoutNode( + style={"flex_direction": "column", "width": 400, "height": 200}, + children=[scroll], + ) + calculate_layout(root, 400, 200) + assert scroll.width == 400 + assert inner.width == 1000 + + # ====================================================================== # extract_layout_style helper # ====================================================================== diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index d43e2d8..f0cd99f 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -653,6 +653,45 @@ def test_layout_pass_positions_flex_children_in_row() -> None: assert root.children[2].frame == (240.0, 0.0, 60.0, 40.0) +def test_layout_pass_clamps_scroll_view_to_viewport_height() -> None: + """ScrollView's own measured height fills the parent rather than tracking content. + + Regression test: without this clamp, a screen-sized ScrollView that + wraps a taller child would size itself to the child's natural + height, leaving the native scroll view with + ``frame.height == contentSize.height`` and nothing to scroll. We + wrap the ScrollView in a View so it isn't the root (the root's + frame is owned by the screen host, not by the layout engine). + """ + backend = MockBackend() + rec = Reconciler(backend) + rec.set_viewport_size(400, 600) + + el = Element( + "View", + {"width": 400, "height": 600}, + [ + Element( + "ScrollView", + {}, + [ + Element( + "Column", + {}, + [Element("View", {"height": 200}, []) for _ in range(6)], + ), + ], + ), + ], + ) + root = rec.mount(el) + scroll = root.children[0] + assert scroll.type_name == "ScrollView" + assert scroll.frame[3] == 600.0 + inner_column = scroll.children[0] + assert inner_column.frame[3] == 1200.0 + + def test_layout_pass_handles_absolute_positioning() -> None: backend = MockBackend() rec = Reconciler(backend)