Files
omnigent-ai--omnigent/tests/e2e_ui/mobile/test_android_shell.py
T
2026-07-13 13:12:00 +08:00

135 lines
5.9 KiB
Python

"""Android WebView shell: web-layer feature detection and the safe-area fold.
The native Android shell (``web/android``) loads the SPA and injects
``window.omnigentNative = {kind: "android", ...}``. The web layer feature-detects
it (``isAndroidShell()`` in ``web/src/lib/nativeBridge.ts``) and, when true, the
``AppShell`` tags its root with ``data-android-native="true"``
(``web/src/shell/AppShell.tsx``). That attribute gates the Android-specific
chrome in ``index.css`` — most importantly the safe-area fold: unlike iOS,
Android WebView reports ``env(safe-area-inset-*)`` as 0, so the shell injects the
OS-measured inset as ``--omnigent-android-safe-area-*`` and ``index.css`` folds
it into the shared ``--omnigent-safe-top/bottom`` with ``max()``.
The e2e_ui harness runs the SPA in a plain Chromium browser, not the Android
WebView, so ``isAndroidShell()`` is false by default. To exercise the shell path
end-to-end we inject a minimal ``window.omnigentNative`` stub via
``add_init_script`` *before any app script runs* — the same feature-detection
stubbing the desktop shell tests use (``sessions/test_pinned_session_hotkeys.py``
injects ``window.omnigentDesktop``).
These cover the chain the ``nativeBridge`` unit tests can't reach end to end:
the injected bridge -> ``isAndroidShell()`` -> the ``AppShell``
``data-android-native`` attribute -> the ``index.css`` ``max()`` fold that lets
the injected OS inset reach the layout's shared vars.
"""
from __future__ import annotations
from playwright.sync_api import Page, ViewportSize, expect
# A phone-sized viewport: the Android shell is a mobile surface, and the narrow
# width is where the sidebar behaves as an overlay drawer (the
# ``[data-android-native]`` drawer rules this change adds). The
# ``data-android-native`` tag itself is viewport-independent.
_MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
# Minimal stand-in for the Android WebView bridge (``web/android``'s
# ``NativeBridgeScript``). Runs before any app script on every navigation
# (``add_init_script``), so ``nativeApi()`` in ``nativeBridge.ts`` — which now
# accepts ``kind === "android"`` — sees a native shell. Every method is a guarded
# no-op: ``kind`` is what ``isAndroidShell()`` keys off, and the rest keep
# unrelated native calls (badge / notify / inset subscription) from throwing
# under the stub.
_ANDROID_SHELL_INIT_SCRIPT = """
window.omnigentNative = {
kind: "android",
setBadgeCount: function () {},
notify: function () { return Promise.resolve(false); },
onNotificationActivated: function () { return function () {}; },
onNativeInsets: function () { return function () {}; },
};
"""
# Read the *resolved* ``--omnigent-safe-top`` in pixels. ``getComputedStyle`` on a
# custom property returns its declared text (the ``max()`` expression), so instead
# size a throwaway probe by ``var(--omnigent-safe-top)`` and read its computed
# height, which resolves the fold.
_READ_SAFE_TOP_PX = """
() => {
const probe = document.createElement('div');
probe.style.cssText =
'position:absolute;visibility:hidden;pointer-events:none;height:var(--omnigent-safe-top)';
document.body.appendChild(probe);
const px = getComputedStyle(probe).height;
probe.remove();
return px;
}
"""
def test_android_shell_tags_root_and_folds_os_inset(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Under the injected Android bridge, the SPA tags its root and folds the inset.
Asserts (1) the app-shell carries ``data-android-native="true"`` — i.e.
``isAndroidShell()`` -> ``AppShell`` wiring fires — and (2) the OS inset the
shell injects as ``--omnigent-android-safe-area-top`` reaches the shared
``--omnigent-safe-top`` through the ``index.css`` ``max()`` fold (which is 0
in a plain browser, where ``env(safe-area-inset-top)`` is also 0).
:param page: Playwright page fixture (fresh context per test).
:param seeded_session: ``(base_url, session_id)`` of a runner-bound session.
"""
base_url, session_id = seeded_session
page.set_viewport_size(_MOBILE_VIEWPORT)
page.add_init_script(_ANDROID_SHELL_INIT_SCRIPT)
page.goto(f"{base_url}/c/{session_id}")
shell = page.locator(".app-shell")
expect(shell).to_have_attribute("data-android-native", "true")
# No native inset injected yet: env(safe-area-inset-top) is 0 in a plain
# browser and the Android var is unset, so the fold resolves to 0.
assert page.evaluate(_READ_SAFE_TOP_PX) == "0px"
# The native layer pushes the measured OS inset as
# --omnigent-android-safe-area-top; index.css folds it into
# --omnigent-safe-top via max(), so the layout reads the real inset.
page.evaluate(
"() => document.documentElement.style"
".setProperty('--omnigent-android-safe-area-top', '40px')"
)
assert page.evaluate(_READ_SAFE_TOP_PX) == "40px"
def test_no_android_tag_or_fold_in_plain_browser(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""A plain browser tab (no bridge) gets neither the tag nor the fold.
Without the ``window.omnigentNative`` stub, ``isAndroidShell()`` is false, so
the app-shell must NOT carry ``data-android-native`` and the
``--omnigent-android-safe-area-*`` fold must contribute nothing — the gate
that keeps the Android chrome off the plain web app. This is the half of the
contract only an end-to-end browser run can prove.
:param page: Playwright page fixture (fresh context per test).
:param seeded_session: ``(base_url, session_id)`` of a runner-bound session.
"""
base_url, session_id = seeded_session
page.set_viewport_size(_MOBILE_VIEWPORT)
page.goto(f"{base_url}/c/{session_id}")
shell = page.locator(".app-shell")
expect(shell).to_be_visible()
assert shell.get_attribute("data-android-native") is None
# The web app never injects --omnigent-android-safe-area-*, so with
# env(safe-area-inset-top) also 0 here the shared inset stays 0.
assert page.evaluate(_READ_SAFE_TOP_PX) == "0px"