ouroboros/tests/test_ui_settings_overflow_browser.py
Ouroboros 8bb1e4bea9 Clip the native select's value and size the webkit scrollbar on both axes
WebKit computes `overflow: visible` on a native select, so a long selected
option (the Telegram Command-mode label) painted past the control and became
scrollable overflow of `.settings-scroll`, whose `overflow-y: auto` makes
`overflow-x` compute to `auto`: Settings > Advanced scrolled sideways in the
desktop shell (+147 / +103 / +53 px at 900 / 1000 / 1100 px windows; Blink
clips). `select.ui-control` now clips its value. The global
`::-webkit-scrollbar` recipe sized only `width` (the vertical bar) since the
initial commit, so every horizontal bar kept the 16-17 px UA thickness with
the accent thumb; it now sizes both axes and paints a transparent corner.

DESIGN.md states that a control never widens its column; the Design System
section of DEVELOPMENT.md names the containment rules and the two open gaps
(the wizard document does not load style.css; standard scrollbar-* properties
opt an element out of the webkit recipe on Blink). Two static pins in
tests/test_web_typography_static.py and one WebKit browser test with a
synthetic extension form keep the class closed.
2026-09-14 17:07:37 +03:00

214 lines
9.1 KiB
Python

"""WebKit guard: Settings never grows a horizontal scrollbar.
The owner's report was a thick red horizontal scrollbar across the bottom of
Settings -> Advanced in the packaged desktop shell (WKWebView). Blink's UA
stylesheet clips a native ``<select>`` (``overflow: clip``); WebKit computes
``visible``, so a long selected option paints past the control's own box, the
``.settings-scroll`` page scroller (``overflow-x`` resolves to ``auto``) grows,
and the scrollbar the app paints for it was UA-thick because the global
``::-webkit-scrollbar`` recipe sized only ``width``.
Chromium is deliberately NOT exercised here: the class cannot appear there, so
a second engine would only pin the engine that never had the bug.
"""
from __future__ import annotations
import os
import pathlib
import textwrap
import pytest
pytest_plugins = ("tests.test_ui_smoke_playwright",)
# The real trigger from the owner's screenshot: a 65-character sentence as the
# selected option of an extension settings select.
LONG_OPTION_LABEL = "Full access (default) — raw owner commands incl. /panic, /restart"
SECTION_TITLE = "Owner command access"
def _write_long_option_settings_extension(data_dir: pathlib.Path) -> str:
"""Install an exact-hash reviewed extension whose settings form carries the
long-label select, rendered by the production settings renderer.
The field ORDER mirrors the real Telegram section and is load-bearing: the
long select is the SECOND field, so it lands in the right-hand column of
``.form-grid.two``. A leak out of a left-column control is absorbed by the
empty right half of the grid and never reaches the page scroller, so a
left-column reproduction would be green on the unfixed stylesheet too.
"""
from ouroboros.skill_loader import (
SkillReviewState,
compute_content_hash,
save_review_state,
)
name = "settings_overflow_smoke"
skill_dir = data_dir / "skills" / "external" / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
textwrap.dedent(
f"""\
---
name: {name}
description: Settings select whose label is long enough to overflow.
version: 0.1.0
type: extension
entry: plugin.py
permissions: ["route", "widget"]
---
# Settings overflow smoke
"""
),
encoding="utf-8",
)
plugin = textwrap.dedent(
'''\
async def save(request):
body = await request.json()
return {"message": f"Saved {body.get('access') or 'default'} access."}
def register(api):
api.register_route("save", save, methods=("POST",))
api.register_settings_section(
"config",
"__SECTION_TITLE__",
schema={
"components": [
{
"type": "form",
"id": "access-form",
"route": "save",
"method": "POST",
"submit_label": "Save access",
"fields": [
{
"name": "language",
"label": "Language",
"type": "select",
"default": "en",
"options": [
{"label": "English", "value": "en"},
{"label": "Russian", "value": "ru"},
],
},
{
"name": "command_mode",
"label": "Command mode",
"type": "select",
"default": "full",
"options": [
{"label": "__LONG_LABEL__", "value": "full"},
{"label": "Strict", "value": "strict"},
],
},
],
}
]
},
)
'''
)
plugin = plugin.replace("__LONG_LABEL__", LONG_OPTION_LABEL)
plugin = plugin.replace("__SECTION_TITLE__", SECTION_TITLE)
(skill_dir / "plugin.py").write_text(plugin, encoding="utf-8")
content_hash = compute_content_hash(skill_dir, manifest_entry="plugin.py")
save_review_state(
data_dir,
name,
SkillReviewState(status="pass", content_hash=content_hash),
)
return name
@pytest.mark.serial
@pytest.mark.ui_browser
def test_settings_advanced_never_scrolls_horizontally_in_webkit(direct_server_with_data):
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
skill = _write_long_option_settings_extension(data_dir)
evidence_dir = pathlib.Path(
os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(data_dir.parent))
)
evidence_dir.mkdir(parents=True, exist_ok=True)
screenshot = evidence_dir / "settings-advanced-webkit-no-horizontal-overflow.png"
try:
with sync_playwright() as pw:
browser = pw.webkit.launch(headless=True)
# The narrow desktop shell window the owner reported from; the leak
# grows as the window narrows, so this is the honest width to pin.
page = browser.new_page(viewport={"width": 1000, "height": 680})
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
toggled = page.evaluate(
"""async (skill) => {
const response = await fetch(`/api/skills/${encodeURIComponent(skill)}/toggle`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: true}),
});
return {status: response.status, body: await response.json()};
}""",
skill,
)
assert toggled["status"] == 200, toggled
assert toggled["body"].get("enabled") is True, toggled
page.wait_for_selector('[data-nav-page="settings"]', timeout=30_000)
page.click('[data-nav-page="settings"]')
page.locator('[data-settings-tab="advanced"]').click()
section = page.locator('.settings-extension-section').filter(
has_text=SECTION_TITLE
)
section.wait_for(state="visible", timeout=30_000)
select = section.locator('select[name="command_mode"]')
select.wait_for(state="visible", timeout=30_000)
# The long option must actually be the selected one, or the
# measurement below would prove nothing.
assert select.input_value() == "full"
assert LONG_OPTION_LABEL in select.inner_text()
overflow = page.evaluate(
"""() => {
const s = document.querySelector('.settings-scroll');
return s.scrollWidth - s.clientWidth;
}"""
)
widths = select.evaluate(
"""(element) => [
element.getBoundingClientRect().width,
element.parentElement.getBoundingClientRect().width,
]"""
)
# `.settings-scroll` is an inner scroller, so a full-page shot
# would frame the top of Advanced and not the offending control.
select.scroll_into_view_if_needed()
page.wait_for_timeout(150)
# Captured before the assertions so a red run leaves evidence too.
page.screenshot(path=str(screenshot), full_page=True)
assert overflow <= 1, (
"Settings scrolls horizontally: .settings-scroll is "
f"{overflow}px wider than its own box. A native select's "
"value is not clipped by WebKit, so a long option leaks "
"into the page scroller and the app paints the horizontal "
"bar the owner reported."
)
assert widths[0] <= widths[1] + 1, (
"the select is wider than its grid cell: "
f"{widths[0]}px control in a {widths[1]}px cell"
)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise