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:
Eric Wagoner
2026-08-05 22:41:42 -04:00
co-authored by Claude Fable 5
parent ba55863cef
commit 9283a3e980
4 changed files with 71 additions and 25 deletions
+3
View File
@@ -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,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,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"
1 action bgg_id collid name version_id second_copy status timestamp error
7 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
8 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
9 add 273240 The Red Dragon Inn Smorgasbox 446069 added 2026-08-06T02:24:46+00:00
10 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
11 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
12 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
View File
@@ -121,11 +121,16 @@ earlier walkthrough had marked *verified*, and the parts marked
`li:has(.summary-item-thumbnail)`. Plain `listitem` also catches the
paging `<li>`s ("First", "Prev", "1", "…").
- **Paging is an AngularJS `<ul class="pagination">` of anchors**, not
buttons: `a[title="Next Page"]`, `a[title="First Page"]`, with the
parent `<li>` gaining `disabled` at the end. The numbered anchors
render twice (mobile + desktop), so step with Next rather than
clicking a number. Paging is client-side over an already-loaded list —
no request per page.
buttons: `a[title="Next Page"]`, with the parent `<li>` gaining
`disabled` at the end. Paging is client-side over an already-loaded
list — no request per page.
- **Every paging control renders TWICE**: a desktop set and a mobile set
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
name is localized (a Czech edition's row starts "Spící bohové"). Match
the version name inside its parentheses.
+24 -14
View File
@@ -368,8 +368,10 @@ class PlaywrightUploader:
# an AngularJS <ul class="pagination"> of anchors — NOT buttons named
# "next", which is why the old guess quit after page one.
_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"]'
_FIRST_PAGE = 'ul.pagination a[title="First Page"]'
# Rows read "<game name> (<version name>) (<year>)", so the version name
# is matched inside its parentheses — bare substrings would let
# "English edition" match "English edition, second printing".
@@ -381,13 +383,20 @@ class PlaywrightUploader:
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:
nxt = dialog.locator(self._NEXT_PAGE)
if nxt.count() == 0:
nxt = self._visible(dialog, self._NEXT_PAGE)
if nxt is None:
return False # single-page list: the container is ng-show'd off
return "disabled" not in (
nxt.first.evaluate("e => e.closest('li').className") or ""
)
return "disabled" not in (nxt.evaluate("e => e.closest('li').className") or "")
def _select_version(self, dialog, version_name: str) -> tuple[bool, str]:
"""Pick the version whose name matches, paging the whole list first
@@ -422,7 +431,7 @@ class PlaywrightUploader:
loose.append((page_ix, text))
if not self._has_next_page(dialog):
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
else:
# 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:
"""Back to page 1, then forward — the numbered anchors render twice
(mobile + desktop variants), so stepping is the unambiguous route."""
first = dialog.locator(self._FIRST_PAGE)
if first.count():
first.first.click()
self._page.wait_for_timeout(400)
"""Reopen the sub-view (it always starts on page 1) and step
forward. The First/Prev anchors exist only in the mobile variant,
so they can never be clicked on a desktop viewport."""
dialog.get_by_role("button", name="Cancel").first.click()
self._page.wait_for_timeout(300)
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):
dialog.locator(self._NEXT_PAGE).first.click()
self._visible(dialog, self._NEXT_PAGE).click()
self._page.wait_for_timeout(400)
def add_game(self, job: UploadJob) -> tuple[str, str]:
+34 -6
View File
@@ -559,26 +559,35 @@ class _FakePicker:
self.page = 0
self.clicked = None
self.cancelled = False
self.reopened = 0
# -- locator plumbing the uploader uses ---------------------------
def locator(self, selector):
picker = self
class _Loc:
def __init__(self, texts=None):
self.texts = texts
def __init__(self, index=0):
self.index = index
def all_text_contents(self):
return picker.pages[picker.page]
def count(self):
if "pagination" in selector:
return 1
return 2 # BGG renders a mobile AND a desktop control
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
def first(self):
return self
return _Loc(0)
def wait_for(self, **kw):
return None
@@ -588,10 +597,10 @@ class _FakePicker:
return "ng-scope disabled" if last else "ng-scope"
def click(self):
if "pagination" in selector and not self.is_visible():
raise AssertionError("clicked a HIDDEN paging control")
if "Next Page" in selector:
picker.page += 1
elif "First Page" in selector:
picker.page = 0
def filter(self, has_text=None):
picker.clicked = has_text.pattern if has_text else None
@@ -610,6 +619,9 @@ class _FakePicker:
def click(self):
if name == "Cancel":
picker.cancelled = True
elif name == "Set version/edition":
picker.page = 0 # the sub-view reopens on page 1
picker.reopened += 1
return _Btn()
@@ -667,3 +679,19 @@ def test_absent_version_reports_pages_scanned():
picked, why = up._select_version(dialog, "English edition")
assert picked is False
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