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,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"
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 `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
View File
@@ -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
View File
@@ -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