The picker's paging state outlives a reopen — navigate, don't assume

Sleeping Gods and Gloomhaven "vanished on the second pass" because the
second pass began wherever the first ended: closing and reopening the
version sub-view does NOT reset it to page 1 (Angular keeps the scope),
so the rescan started mid-list and never revisited the earlier pages
holding the row. Verified against the live picker.

The second pass now clicks the visible numbered "1" anchor first — and
so does the initial scan, since paging state can outlive anything. The
reopen is gone entirely. Docs record both this and the has_text
whitespace trap.

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:53:05 -04:00
co-authored by Claude Fable 5
parent 6f2f2dac53
commit 6e72ef22d1
4 changed files with 79 additions and 32 deletions
+5
View File
@@ -15,3 +15,8 @@ add,419687,,Munchkin Big Box,711400,,added,2026-08-06T02:45:16+00:00,
add,252153,,Tang Garden,404702,,added,2026-08-06T02:45:23+00:00, add,252153,,Tang Garden,404702,,added,2026-08-06T02:45:23+00:00,
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:45:58+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""li:has(.summary-item-thumbnail)"").filter(has_text=re.compile(r""Sleeping\ Gods\ \(English\ Gamefound\ edition\)\ \(2023\)"", re.IGNORECASE)).first" add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:45:58+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""li:has(.summary-item-thumbnail)"").filter(has_text=re.compile(r""Sleeping\ Gods\ \(English\ Gamefound\ edition\)\ \(2023\)"", re.IGNORECASE)).first"
add,283766,,Sleeping Gods: Tides of Ruin,463140,,added,2026-08-06T02:46:07+00:00, add,283766,,Sleeping Gods: Tides of Ruin,463140,,added,2026-08-06T02:46:07+00:00,
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:49:44+00:00,RuntimeError: 'Sleeping Gods (English Gamefound edition) (2023)' was listed a moment ago but vanished on the second pass — retryable
add,280794,,Etherfields,458300,,added,2026-08-06T02:49:53+00:00,
add,380837,,Botany,650545,,added,2026-08-06T02:50:00+00:00,
add,393672,,Gloomhaven: Buttons & Bugs,669782,,failed,2026-08-06T02:50:07+00:00,RuntimeError: 'Gloomhaven: Buttons & Bugs (English edition) (2024)' was listed a moment ago but vanished on the second pass — retryable
add,14535,,SPANC: Space Pirate Amazon Ninja Catgirls,29075,,added,2026-08-06T02:50:15+00:00,
1 action bgg_id collid name version_id second_copy status timestamp error
15 add 252153 Tang Garden 404702 added 2026-08-06T02:45:23+00:00
16 add 255984 Sleeping Gods 701034 failed 2026-08-06T02:45:58+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("dialog").locator("li:has(.summary-item-thumbnail)").filter(has_text=re.compile(r"Sleeping\ Gods\ \(English\ Gamefound\ edition\)\ \(2023\)", re.IGNORECASE)).first
17 add 283766 Sleeping Gods: Tides of Ruin 463140 added 2026-08-06T02:46:07+00:00
18 add 255984 Sleeping Gods 701034 failed 2026-08-06T02:49:44+00:00 RuntimeError: 'Sleeping Gods (English Gamefound edition) (2023)' was listed a moment ago but vanished on the second pass — retryable
19 add 280794 Etherfields 458300 added 2026-08-06T02:49:53+00:00
20 add 380837 Botany 650545 added 2026-08-06T02:50:00+00:00
21 add 393672 Gloomhaven: Buttons & Bugs 669782 failed 2026-08-06T02:50:07+00:00 RuntimeError: 'Gloomhaven: Buttons & Bugs (English edition) (2024)' was listed a moment ago but vanished on the second pass — retryable
22 add 14535 SPANC: Space Pirate Amazon Ninja Catgirls 29075 added 2026-08-06T02:50:15+00:00
+9 -3
View File
@@ -128,9 +128,15 @@ earlier walkthrough had marked *verified*, and the parts marked
inside `<li class="visible-xs-*">`. A selector matches both, and inside `<li class="visible-xs-*">`. A selector matches both, and
`.first` may be the hidden one — Playwright then waits for it to become `.first` may be the hidden one — Playwright then waits for it to become
visible until it times out. Always click the first *visible* match. 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 First/Prev may be unclickable on a desktop viewport (mobile-only
desktop viewport: to return to page 1, close and reopen the sub-view variant). To return to page 1, click the visible numbered **"1"**
(it always opens on page 1) and step forward with Next. anchor: the sub-view's paging state SURVIVES closing and reopening it
(Angular keeps the scope), so a reopen lands wherever it was left, not
on page 1.
- **Match row text in Python, not with `has_text`.** Playwright's
`has_text` regex tests raw `textContent`, which carries the markup's
tabs and newlines; a whitespace-normalized capture will never equal it.
Normalize both sides yourself and click the row by index.
- **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 -11
View File
@@ -382,6 +382,7 @@ class PlaywrightUploader:
# visible-xs-* — so every one of these selectors matches hidden nodes # visible-xs-* — so every one of these selectors matches hidden nodes
# too; clicking one waits forever. Always pick the visible match. # 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"]'
_PAGE_LINKS = "ul.pagination a"
# 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".
@@ -428,6 +429,8 @@ class PlaywrightUploader:
# BGG's API name can carry a printing qualifier the picker omits # BGG's API name can carry a printing qualifier the picker omits
# ("English edition 2018-2" vs "(English edition) (2018)") # ("English edition 2018-2" vs "(English edition) (2018)")
relaxed = f"({self._TRAILING_YEAR.sub('', version_name).strip()})".casefold() relaxed = f"({self._TRAILING_YEAR.sub('', version_name).strip()})".casefold()
self._goto_first_page(dialog) # a fresh sub-view opens on page 1,
# but never assume it: paging state outlives a close/reopen
exact: list[tuple[int, str]] = [] exact: list[tuple[int, str]] = []
loose: list[tuple[int, str]] = [] loose: list[tuple[int, str]] = []
pages = 0 pages = 0
@@ -472,18 +475,28 @@ class PlaywrightUploader:
f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess" f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess"
) )
def _click_row(self, dialog, text: str) -> bool: def _goto_first_page(self, dialog) -> None:
"""Reopen the sub-view (it always starts on page 1) and hunt for the """Back to page 1 via the numbered "1" anchor. Closing and
row again by its NORMALIZED text, clicking by index. reopening the sub-view does NOT reset Angular's paging state (it
reopens wherever it was left), and First/Prev render in a
mobile-only variant that a desktop viewport can never click."""
pager = dialog.locator(self._PAGE_LINKS)
for i in range(pager.count()):
anchor = pager.nth(i)
if anchor.is_visible() and (
" ".join((anchor.text_content() or "").split()) == "1"
):
anchor.click()
self._page.wait_for_timeout(400)
return
# no pagination rendered: a single-page list is already page 1
Two traps this avoids: First/Prev exist only in the mobile variant def _click_row(self, dialog, text: str) -> bool:
(unclickable on a desktop viewport), and Playwright's has_text """Find the row again by its NORMALIZED text and click it by index.
regex matches raw textContent — whose tabs and newlines a Playwright's has_text regex matches raw textContent — whose tabs and
whitespace-normalized capture will never equal.""" newlines a normalized capture will never equal — so the comparison
dialog.get_by_role("button", name="Cancel").first.click() happens in Python, and the click addresses a position."""
self._page.wait_for_timeout(300) self._goto_first_page(dialog)
dialog.get_by_role("button", name="Set version/edition").click()
dialog.locator(self._VERSION_ROWS).first.wait_for(timeout=15_000)
for _ in range(MAX_VERSION_PAGES): for _ in range(MAX_VERSION_PAGES):
for i, row_text in enumerate(self._rows_on_page(dialog)): for i, row_text in enumerate(self._rows_on_page(dialog)):
if row_text == text: if row_text == text:
+41 -18
View File
@@ -551,8 +551,9 @@ def test_undeterminable_state_raises_instead_of_guessing(monkeypatch):
class _FakePicker: class _FakePicker:
"""Minimal stand-in for the picker sub-view: pages of row texts, a """Stand-in for the picker sub-view. Models the two traps the real one
Next anchor that disables on the last page.""" sets: paging controls render twice (a hidden mobile duplicate first),
and paging state SURVIVES closing and reopening the sub-view."""
def __init__(self, pages): def __init__(self, pages):
self.pages = pages self.pages = pages
@@ -562,9 +563,12 @@ class _FakePicker:
self.cancelled = False self.cancelled = False
self.reopened = 0 self.reopened = 0
# -- locator plumbing the uploader uses ---------------------------
def locator(self, selector): def locator(self, selector):
picker = self picker = self
is_next = 'title="Next Page"' in selector
is_pager = "pagination" in selector
# anchors: [hidden mobile "1", "1", "2", ...] mirroring the real DOM
anchors = ["1"] + [str(n + 1) for n in range(len(picker.pages))]
class _Loc: class _Loc:
def __init__(self, index=0): def __init__(self, index=0):
@@ -574,22 +578,25 @@ class _FakePicker:
return picker.pages[picker.page] return picker.pages[picker.page]
def count(self): def count(self):
if "pagination" in selector: if is_next:
return 2 # BGG renders a mobile AND a desktop control return 2 # hidden mobile + visible desktop
if is_pager:
return len(anchors)
return len(picker.pages[picker.page]) return len(picker.pages[picker.page])
def nth(self, i): def nth(self, i):
return _Loc(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 _Loc(0) return _Loc(0)
def is_visible(self):
return not (is_pager and self.index == 0)
def text_content(self):
return anchors[self.index] if is_pager and not is_next else ""
def wait_for(self, **kw): def wait_for(self, **kw):
return None return None
@@ -598,16 +605,17 @@ 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(): if is_pager and not self.is_visible():
raise AssertionError("clicked a HIDDEN paging control") raise AssertionError("clicked a HIDDEN paging control")
if "Next Page" in selector: if is_next:
picker.page += 1 picker.page += 1
elif "summary-item-thumbnail" in selector: elif is_pager:
picker.page = int(anchors[self.index]) - 1
else:
picker.clicked_index = self.index picker.clicked_index = self.index
picker.clicked = picker.pages[picker.page][self.index] picker.clicked = picker.pages[picker.page][self.index]
def filter(self, has_text=None): def filter(self, has_text=None):
picker.clicked = has_text.pattern if has_text else None
return self return self
return _Loc() return _Loc()
@@ -624,8 +632,7 @@ class _FakePicker:
if name == "Cancel": if name == "Cancel":
picker.cancelled = True picker.cancelled = True
elif name == "Set version/edition": elif name == "Set version/edition":
picker.page = 0 # the sub-view reopens on page 1 picker.reopened += 1 # note: does NOT reset the page
picker.reopened += 1
return _Btn() return _Btn()
@@ -697,8 +704,7 @@ def test_paging_never_clicks_a_hidden_mobile_control():
) )
picked, _ = up._select_version(dialog, "English edition 2009") picked, _ = up._select_version(dialog, "English edition 2009")
assert picked is True # no AssertionError from the fake = no hidden click 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.clicked_index == 0 # found on the later page, clicked
assert dialog.reopened == 2
def test_outstanding_failures_ignores_retried_jobs(): def test_outstanding_failures_ignores_retried_jobs():
@@ -731,3 +737,20 @@ def test_outstanding_failures_ignores_retried_jobs():
] ]
assert outstanding_failures(rows) == 1 # game 1 was retried and landed assert outstanding_failures(rows) == 1 # game 1 was retried and landed
assert outstanding_failures([]) == 0 assert outstanding_failures([]) == 0
def test_paging_state_survives_reopen_so_page_one_is_clicked():
"""The real sub-view reopens wherever it was left, so the second pass
must navigate back to page 1 explicitly — Sleeping Gods and Gloomhaven
both "vanished" when it didn't."""
up, dialog = _picker_uploader(
[
["Sleeping Gods (English Gamefound edition) (2023)"],
["Sleeping Gods (German edition) (2022)"],
]
)
dialog.page = 1 # left on the last page by a previous scan
picked, why = up._select_version(dialog, "English Gamefound edition")
assert picked is True
assert why == ""
assert dialog.clicked.startswith("Sleeping Gods (English Gamefound")