Paging clicks only visible controls: First/Prev are mobile-only
Munchkin Big Box and Tang Garden hung 30s each on a "First Page" anchor that exists but is invisible: BGG renders every paging control twice, and the First/Prev pair lives only in the mobile set (<li class="visible-xs-inline">). A desktop viewport can never click it. Paging now selects the first VISIBLE match, and returning to page 1 closes and reopens the sub-view (which always opens on page 1) instead of reaching for a control that isn't there. A test proves no hidden control is ever clicked — the fake picker raises if one is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
ba55863cef
commit
9283a3e980
@@ -7,3 +7,6 @@ add,31260,,Agricola,297589,,added,2026-08-06T02:24:28+00:00,
|
|||||||
add,181304,,Mysterium,536289,,added_no_version,2026-08-06T02:24:34+00:00,version 'English edition 2018-2' not in picker; added without version
|
add,181304,,Mysterium,536289,,added_no_version,2026-08-06T02:24:34+00:00,version 'English edition 2018-2' not in picker; added without version
|
||||||
add,312786,,Poetry for Neanderthals,514050,,added_no_version,2026-08-06T02:24:40+00:00,version 'English edition 2020' not in picker; added without version
|
add,312786,,Poetry for Neanderthals,514050,,added_no_version,2026-08-06T02:24:40+00:00,version 'English edition 2020' not in picker; added without version
|
||||||
add,273240,,The Red Dragon Inn Smorgasbox,446069,,added,2026-08-06T02:24:46+00:00,
|
add,273240,,The Red Dragon Inn Smorgasbox,446069,,added,2026-08-06T02:24:46+00:00,
|
||||||
|
add,419687,,Munchkin Big Box,711400,,failed,2026-08-06T02:38:12+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""ul.pagination a[title=\""First Page\""]"").first - locator resolved to <a href="""" role=""menuitem"" title=""First Page"" class=""pagination-pager"" ng-click=""selectPage(1)""> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 55 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms"
|
||||||
|
add,252153,,Tang Garden,404702,,failed,2026-08-06T02:38:48+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""ul.pagination a[title=\""First Page\""]"").first - locator resolved to <a href="""" role=""menuitem"" title=""First Page"" class=""pagination-pager"" ng-click=""selectPage(1)""> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 56 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms"
|
||||||
|
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:39:23+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""ul.pagination a[title=\""First Page\""]"").first - locator resolved to <a href="""" role=""menuitem"" title=""First Page"" class=""pagination-pager"" ng-click=""selectPage(1)""> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 56 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms"
|
||||||
|
|||||||
|
+10
-5
@@ -121,11 +121,16 @@ earlier walkthrough had marked *verified*, and the parts marked
|
|||||||
`li:has(.summary-item-thumbnail)`. Plain `listitem` also catches the
|
`li:has(.summary-item-thumbnail)`. Plain `listitem` also catches the
|
||||||
paging `<li>`s ("First", "Prev", "1", "…").
|
paging `<li>`s ("First", "Prev", "1", "…").
|
||||||
- **Paging is an AngularJS `<ul class="pagination">` of anchors**, not
|
- **Paging is an AngularJS `<ul class="pagination">` of anchors**, not
|
||||||
buttons: `a[title="Next Page"]`, `a[title="First Page"]`, with the
|
buttons: `a[title="Next Page"]`, with the parent `<li>` gaining
|
||||||
parent `<li>` gaining `disabled` at the end. The numbered anchors
|
`disabled` at the end. Paging is client-side over an already-loaded
|
||||||
render twice (mobile + desktop), so step with Next rather than
|
list — no request per page.
|
||||||
clicking a number. Paging is client-side over an already-loaded list —
|
- **Every paging control renders TWICE**: a desktop set and a mobile set
|
||||||
no request per page.
|
inside `<li class="visible-xs-*">`. A selector matches both, and
|
||||||
|
`.first` may be the hidden one — Playwright then waits for it to become
|
||||||
|
visible until it times out. Always click the first *visible* match.
|
||||||
|
First/Prev exist ONLY in the mobile set, so they are unclickable on a
|
||||||
|
desktop viewport: to return to page 1, close and reopen the sub-view
|
||||||
|
(it always opens on page 1) and step forward with Next.
|
||||||
- **Row text is `<game name> (<version name>) (<year>)`**, and the game
|
- **Row text is `<game name> (<version name>) (<year>)`**, and the game
|
||||||
name is localized (a Czech edition's row starts "Spící bohové"). Match
|
name is localized (a Czech edition's row starts "Spící bohové"). Match
|
||||||
the version name inside its parentheses.
|
the version name inside its parentheses.
|
||||||
|
|||||||
+24
-14
@@ -368,8 +368,10 @@ class PlaywrightUploader:
|
|||||||
# an AngularJS <ul class="pagination"> of anchors — NOT buttons named
|
# an AngularJS <ul class="pagination"> of anchors — NOT buttons named
|
||||||
# "next", which is why the old guess quit after page one.
|
# "next", which is why the old guess quit after page one.
|
||||||
_VERSION_ROWS = "li:has(.summary-item-thumbnail)"
|
_VERSION_ROWS = "li:has(.summary-item-thumbnail)"
|
||||||
|
# Paging controls render TWICE — a desktop set and a mobile set marked
|
||||||
|
# visible-xs-* — so every one of these selectors matches hidden nodes
|
||||||
|
# too; clicking one waits forever. Always pick the visible match.
|
||||||
_NEXT_PAGE = 'ul.pagination a[title="Next Page"]'
|
_NEXT_PAGE = 'ul.pagination a[title="Next Page"]'
|
||||||
_FIRST_PAGE = 'ul.pagination a[title="First Page"]'
|
|
||||||
# Rows read "<game name> (<version name>) (<year>)", so the version name
|
# Rows read "<game name> (<version name>) (<year>)", so the version name
|
||||||
# is matched inside its parentheses — bare substrings would let
|
# is matched inside its parentheses — bare substrings would let
|
||||||
# "English edition" match "English edition, second printing".
|
# "English edition" match "English edition, second printing".
|
||||||
@@ -381,13 +383,20 @@ class PlaywrightUploader:
|
|||||||
for t in dialog.locator(self._VERSION_ROWS).all_text_contents()
|
for t in dialog.locator(self._VERSION_ROWS).all_text_contents()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _visible(self, dialog, selector):
|
||||||
|
"""The first VISIBLE match, or None — see _NEXT_PAGE."""
|
||||||
|
loc = dialog.locator(selector)
|
||||||
|
for i in range(loc.count()):
|
||||||
|
candidate = loc.nth(i)
|
||||||
|
if candidate.is_visible():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
def _has_next_page(self, dialog) -> bool:
|
def _has_next_page(self, dialog) -> bool:
|
||||||
nxt = dialog.locator(self._NEXT_PAGE)
|
nxt = self._visible(dialog, self._NEXT_PAGE)
|
||||||
if nxt.count() == 0:
|
if nxt is None:
|
||||||
return False # single-page list: the container is ng-show'd off
|
return False # single-page list: the container is ng-show'd off
|
||||||
return "disabled" not in (
|
return "disabled" not in (nxt.evaluate("e => e.closest('li').className") or "")
|
||||||
nxt.first.evaluate("e => e.closest('li').className") or ""
|
|
||||||
)
|
|
||||||
|
|
||||||
def _select_version(self, dialog, version_name: str) -> tuple[bool, str]:
|
def _select_version(self, dialog, version_name: str) -> tuple[bool, str]:
|
||||||
"""Pick the version whose name matches, paging the whole list first
|
"""Pick the version whose name matches, paging the whole list first
|
||||||
@@ -422,7 +431,7 @@ class PlaywrightUploader:
|
|||||||
loose.append((page_ix, text))
|
loose.append((page_ix, text))
|
||||||
if not self._has_next_page(dialog):
|
if not self._has_next_page(dialog):
|
||||||
break
|
break
|
||||||
dialog.locator(self._NEXT_PAGE).first.click()
|
self._visible(dialog, self._NEXT_PAGE).click()
|
||||||
self._page.wait_for_timeout(400) # client-side paging: no request
|
self._page.wait_for_timeout(400) # client-side paging: no request
|
||||||
else:
|
else:
|
||||||
# never saw the end of the list: "not in picker" would be a false
|
# never saw the end of the list: "not in picker" would be a false
|
||||||
@@ -453,14 +462,15 @@ class PlaywrightUploader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _goto_picker_page(self, dialog, page_ix: int) -> None:
|
def _goto_picker_page(self, dialog, page_ix: int) -> None:
|
||||||
"""Back to page 1, then forward — the numbered anchors render twice
|
"""Reopen the sub-view (it always starts on page 1) and step
|
||||||
(mobile + desktop variants), so stepping is the unambiguous route."""
|
forward. The First/Prev anchors exist only in the mobile variant,
|
||||||
first = dialog.locator(self._FIRST_PAGE)
|
so they can never be clicked on a desktop viewport."""
|
||||||
if first.count():
|
dialog.get_by_role("button", name="Cancel").first.click()
|
||||||
first.first.click()
|
self._page.wait_for_timeout(300)
|
||||||
self._page.wait_for_timeout(400)
|
dialog.get_by_role("button", name="Set version/edition").click()
|
||||||
|
dialog.locator(self._VERSION_ROWS).first.wait_for(timeout=15_000)
|
||||||
for _ in range(page_ix):
|
for _ in range(page_ix):
|
||||||
dialog.locator(self._NEXT_PAGE).first.click()
|
self._visible(dialog, self._NEXT_PAGE).click()
|
||||||
self._page.wait_for_timeout(400)
|
self._page.wait_for_timeout(400)
|
||||||
|
|
||||||
def add_game(self, job: UploadJob) -> tuple[str, str]:
|
def add_game(self, job: UploadJob) -> tuple[str, str]:
|
||||||
|
|||||||
+34
-6
@@ -559,26 +559,35 @@ class _FakePicker:
|
|||||||
self.page = 0
|
self.page = 0
|
||||||
self.clicked = None
|
self.clicked = None
|
||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
|
self.reopened = 0
|
||||||
|
|
||||||
# -- locator plumbing the uploader uses ---------------------------
|
# -- locator plumbing the uploader uses ---------------------------
|
||||||
def locator(self, selector):
|
def locator(self, selector):
|
||||||
picker = self
|
picker = self
|
||||||
|
|
||||||
class _Loc:
|
class _Loc:
|
||||||
def __init__(self, texts=None):
|
def __init__(self, index=0):
|
||||||
self.texts = texts
|
self.index = index
|
||||||
|
|
||||||
def all_text_contents(self):
|
def all_text_contents(self):
|
||||||
return picker.pages[picker.page]
|
return picker.pages[picker.page]
|
||||||
|
|
||||||
def count(self):
|
def count(self):
|
||||||
if "pagination" in selector:
|
if "pagination" in selector:
|
||||||
return 1
|
return 2 # BGG renders a mobile AND a desktop control
|
||||||
return len(picker.pages[picker.page])
|
return len(picker.pages[picker.page])
|
||||||
|
|
||||||
|
def nth(self, i):
|
||||||
|
return _Loc(i)
|
||||||
|
|
||||||
|
def is_visible(self):
|
||||||
|
# among PAGING controls, index 0 is the mobile
|
||||||
|
# (visible-xs-*) duplicate — present but not clickable
|
||||||
|
return "pagination" not in selector or self.index != 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def first(self):
|
def first(self):
|
||||||
return self
|
return _Loc(0)
|
||||||
|
|
||||||
def wait_for(self, **kw):
|
def wait_for(self, **kw):
|
||||||
return None
|
return None
|
||||||
@@ -588,10 +597,10 @@ class _FakePicker:
|
|||||||
return "ng-scope disabled" if last else "ng-scope"
|
return "ng-scope disabled" if last else "ng-scope"
|
||||||
|
|
||||||
def click(self):
|
def click(self):
|
||||||
|
if "pagination" in selector and not self.is_visible():
|
||||||
|
raise AssertionError("clicked a HIDDEN paging control")
|
||||||
if "Next Page" in selector:
|
if "Next Page" in selector:
|
||||||
picker.page += 1
|
picker.page += 1
|
||||||
elif "First Page" in selector:
|
|
||||||
picker.page = 0
|
|
||||||
|
|
||||||
def filter(self, has_text=None):
|
def filter(self, has_text=None):
|
||||||
picker.clicked = has_text.pattern if has_text else None
|
picker.clicked = has_text.pattern if has_text else None
|
||||||
@@ -610,6 +619,9 @@ class _FakePicker:
|
|||||||
def click(self):
|
def click(self):
|
||||||
if name == "Cancel":
|
if name == "Cancel":
|
||||||
picker.cancelled = True
|
picker.cancelled = True
|
||||||
|
elif name == "Set version/edition":
|
||||||
|
picker.page = 0 # the sub-view reopens on page 1
|
||||||
|
picker.reopened += 1
|
||||||
|
|
||||||
return _Btn()
|
return _Btn()
|
||||||
|
|
||||||
@@ -667,3 +679,19 @@ def test_absent_version_reports_pages_scanned():
|
|||||||
picked, why = up._select_version(dialog, "English edition")
|
picked, why = up._select_version(dialog, "English edition")
|
||||||
assert picked is False
|
assert picked is False
|
||||||
assert "not offered" in why and "1 page(s)" in why
|
assert "not offered" in why and "1 page(s)" in why
|
||||||
|
|
||||||
|
|
||||||
|
def test_paging_never_clicks_a_hidden_mobile_control():
|
||||||
|
"""BGG renders First/Prev/Next twice — a desktop set and a mobile set
|
||||||
|
that is invisible on a desktop viewport. Clicking the hidden one hangs
|
||||||
|
until timeout (it did, on Munchkin Big Box and Tang Garden)."""
|
||||||
|
up, dialog = _picker_uploader(
|
||||||
|
[
|
||||||
|
["Munchkin (German edition) (2010)"],
|
||||||
|
["Munchkin (English edition) (2009)"],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
picked, _ = up._select_version(dialog, "English edition 2009")
|
||||||
|
assert picked is True # no AssertionError from the fake = no hidden click
|
||||||
|
# the initial open plus ONE reopen — never a "First Page" click
|
||||||
|
assert dialog.reopened == 2
|
||||||
|
|||||||
Reference in New Issue
Block a user