Files
mirror/tests/e2e/test_share_layout.py
T
ducoterra bf027672b5
Build and Push Containers / test (push) Successful in 47s
Build and Push Containers / build-and-push-app (push) Successful in 10s
phase: 04_reachable_share_cta
All verification is complete. The pending working-tree changes are exactly the phase 04 work (CSS, tests, the README narrow-screen line, the AGENTS.md "Two→Three pages" fix) — all accurate.

## Final report

**Verified (final pass — all 4 task files present in `complete/`):**
- Gate 1: `uv run ruff check . && uv run ruff format --check .` → all checks passed, 29 files formatted
- Gate 2: `uv run pyright` → 0 errors
- Gate 3: `uv run pytest` → **269 passed, 4 skipped** (realdisplay-gated), coverage 95%
- Gate 4: `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **3 passed, 1 skipped** (host delivers no audio source — documented in module docstring)

**Completion criteria:**
1. `#start` elementFromPoint at the 11 required sizes — ✅ `TestIdleShareButtonReachable` sweeps 12 viewports (superset); 12/12 passed
2. `#stop` clickable while sharing — ✅ `TestLiveStopButtonReachable` (320×568, 390×844, 1280×450) passed, incl. placeholder-hidden + livetag assertions
3. Single-cell stage guard — ✅ `test_the_share_stage_is_one_cell` + `test_the_stage_has_a_floor_and_a_ceiling` passed; CSS has `grid-template: 1fr / 1fr`, `.stage > * { grid-area: 1 / 1 }`, `min-height: 264px`, `max-height: min(70svh, 640px)` + vh fallback, `.placeholder { max-height: 100%; overflow: auto }`, `align-content: safe center`
4. Live video fills stage, rounded corners, `#livetag` top-left — ✅ verified by live-browser probe: video fills stage (decoded), stage radius 14px, livetag top-left, placeholder hidden
5. README accurate, AGENTS.md current, suite 100% green — ✅
6. Phases 01–03 unchanged — ✅ `test_keyboard_wall` + `test_audio_share` + `test_sender_fidelity`: 48 passed

**Documentation:** README.md reviewed in full (page table, quickstart, all 12 config defaults vs `config.py`, CLI flags, secure-context/wayland/audio sections, kiosk key table vs `watch.js`/`share.js`, dev commands) — **consistent, no fixes needed**. AGENTS.md verified statement-by-statement (commands, routes, test names, browser/realdisplay flags) — **no stale statements; no changes needed** (the pending "Two→Three pages" fix is correct).

**Decisions:** criterion 4 had no dedicated test, so verified directly with a throwaway probe reusing the e2e harness (script in /tmp, not committed). No live infrastructure touched. No defects found in prior work.

**Next pending phase:** none — `todo/` is empty of other phases.
2026-11-01 23:26:36 -05:00

175 lines
7.7 KiB
Python

"""End-to-end: the Share CTA is a *hit*, at every realistic viewport.
The bug this file pins: ``#preview-stage`` was a two-row grid (the idle
``<video>`` reserved a ~150 px row, the placeholder stacked below it and ran
into ``overflow: hidden``), and its height was an unbounded function of its
width (180 px tall at 320 px wide; 543 px in a 480 px window, pushing the
button below the fold). On the reference host,
``document.elementFromPoint`` at the button centre named ``H2`` at 320x568,
``#name`` at 360x640, ``LABEL`` at 390x844, ``#share-link`` at 480x800, and
``null`` at 900x480 / 1280x450 — a finger or cursor would never have hit the
button.
The assertion is a **hit test, not a size check**:
``document.elementFromPoint(cx, cy).closest("#start")`` must be the button.
That is the only in-browser statement that means *a finger or a cursor would
hit it*, and it is exactly the check that failed. (A ``page.click`` would start
a real capture and need teardown; the hit test is the same claim with no side
effects. The live-state sweep below still exercises a real ``start_sharing()``
once per shape, so the interaction path is not untested.)
320 px is the floor of "realistic": below it the page is expected to scroll,
not to fit. The containment comparisons carry a +/-1 px tolerance for
sub-pixel layout at fractional device pixel ratios.
"""
from __future__ import annotations
from typing import ClassVar
import pytest
from playwright.sync_api import expect
from .conftest import start_sharing
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
# The sweep: the clip cases (button clipped out of the stage), the
# below-the-fold cases (stage taller than the viewport), and the control
# shapes in between — exactly the list in 03_reachable_cta_e2e.md.
VIEWPORTS = [
(320, 568),
(360, 640),
(390, 844),
(430, 932),
(480, 800), # the clip cases
(700, 900),
(900, 700),
(1024, 600),
(1280, 800),
(1920, 1080),
(900, 480),
(1280, 450), # the below-the-fold cases
]
# The probe that reproduced the bug — kept verbatim so the next reader can
# re-run the diagnosis. ``hit`` named the culprit during diagnosis: ``H2`` at
# 320x568, ``#name`` at 360x640, ``LABEL`` at 390x844, ``#share-link`` at
# 480x800, ``null`` at 900x480 / 1280x450.
HIT_PROBE = """() => {
const b = document.querySelector("#start");
const s = document.querySelector("#preview-stage").getBoundingClientRect();
const r = b.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return {
disabled: b.disabled,
in_stage: r.bottom <= s.bottom + 1 && r.top >= s.top - 1,
in_viewport: r.bottom <= innerHeight && r.right <= innerWidth && r.top >= 0,
hit_is_button: !!hit && !!hit.closest("#start"),
hit: hit ? hit.id || hit.tagName : null,
no_h_scroll: document.documentElement.scrollWidth <= innerWidth,
};
}"""
# The same hit test, parameterised on the button. Used for #stop while a share
# is live: #stop lives in the Session panel, not the stage, so "inside the
# stage" does not apply to it — clickability is the hit test plus the viewport.
STOP_PROBE = """(selector) => {
const b = document.querySelector(selector);
const r = b.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return {
disabled: b.disabled,
in_viewport: r.bottom <= innerHeight && r.right <= innerWidth && r.top >= 0,
hit_is_button: !!hit && !!hit.closest(selector),
hit: hit ? hit.id || hit.tagName : null,
no_h_scroll: document.documentElement.scrollWidth <= innerWidth,
};
}"""
# The live-state invariants: the placeholder is hidden, the LIVE badge is
# visible and sits top-left inside the stage.
LIVE_STATE_PROBE = """() => {
const s = document.querySelector("#preview-stage").getBoundingClientRect();
const l = document.querySelector("#livetag").getBoundingClientRect();
return {
placeholder_hidden: document.querySelector("#placeholder").hidden,
livetag_visible: !document.querySelector("#livetag").hidden,
livetag_in_stage:
l.left >= s.left - 1 && l.top >= s.top - 1 &&
l.right <= s.right + 1 && l.bottom <= s.bottom + 1,
};
}"""
def _open_page(base_url, browser, request, width: int, height: int):
context = browser.new_context(viewport={"width": width, "height": height})
request.addfinalizer(context.close)
page = context.new_page()
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
return page
class TestIdleShareButtonReachable:
"""``#start`` must be hit-testable at every realistic viewport, idle.
A layout change that stacks the stage's children again (or un-bounds the
box's height) fails at least one of these: the clipped shapes fail
``in_stage``/``hit_is_button`` (the old bug), the short shapes fail
``in_viewport`` (the old bug), and ``no_h_scroll`` catches a layout that
hides the button behind horizontal scroll instead.
"""
@pytest.mark.parametrize("viewport", VIEWPORTS)
def test_the_share_button_is_hit_testable(self, base_url, browser, request, viewport):
page = _open_page(base_url, browser, request, viewport[0], viewport[1])
expect(page.locator("#start")).to_be_visible(timeout=10_000)
result = page.evaluate(HIT_PROBE)
label = f"{viewport[0]}x{viewport[1]}: {result}"
# A disabled button would make the hit test vacuous.
assert result["disabled"] is False, f"#start is disabled: {label}"
# The point that a finger or cursor would land on must be the button.
assert result["hit_is_button"], (
f"#start is not hit-testable, elementFromPoint named {result['hit']!r}: {label}"
)
# Inside the stage or `overflow: hidden` clips it; inside the viewport
# or a finger can never reach it; no horizontal scroll to reach it.
assert result["in_stage"], f"#start is clipped by the stage: {label}"
assert result["in_viewport"], f"#start is outside the viewport: {label}"
assert result["no_h_scroll"], f"the button is behind a horizontal scroll: {label}"
class TestLiveStopButtonReachable:
"""The live state must not regress: while sharing, ``#stop`` stays
hit-testable, ``#placeholder`` is hidden, and ``#livetag`` sits top-left
inside the stage. The three shapes are the ones measured live in
``00_phase.md`` (``hit_stop: true`` after the fix).
"""
LIVE_VIEWPORTS: ClassVar[list[tuple[int, int]]] = [
(320, 568),
(390, 844),
(1280, 450),
]
@pytest.mark.parametrize("viewport", LIVE_VIEWPORTS)
def test_stop_is_hit_testable_while_sharing(self, base_url, browser, request, viewport):
page = _open_page(base_url, browser, request, viewport[0], viewport[1])
start_sharing(page)
stop = page.evaluate(STOP_PROBE, "#stop")
label = f"{viewport[0]}x{viewport[1]}: {stop}"
assert stop["disabled"] is False, f"#stop is disabled: {label}"
assert stop["hit_is_button"], (
f"#stop is not hit-testable, elementFromPoint named {stop['hit']!r}: {label}"
)
assert stop["in_viewport"], f"#stop is outside the viewport: {label}"
assert stop["no_h_scroll"], f"#stop is behind a horizontal scroll: {label}"
state = page.evaluate(LIVE_STATE_PROBE)
label = f"{viewport[0]}x{viewport[1]}: {state}"
assert state["placeholder_hidden"], f"#placeholder is still shown while sharing: {label}"
assert state["livetag_visible"], f"#livetag is not visible while sharing: {label}"
assert state["livetag_in_stage"], f"#livetag is not inside the stage: {label}"