From 6f2f2dac53d0d9add66253cd6ba54335dcfbc3d5 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 5 Aug 2026 22:49:02 -0400 Subject: [PATCH] Row clicks survive whitespace; the failure badge stops inflating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sleeping Gods failed where three siblings passed: the second-pass row click matched captured text with Playwright's has_text regex, which tests raw textContent — tabs and newlines included — against a capture that was whitespace-normalized. Rows whose markup happened to be tidy matched; that one didn't. The picker now re-finds the row by NORMALIZED text and clicks it by index, which also survives the list re-rendering in a different order between openings. And the UI's failure count only ever grew: upload_log.csv is an append-only audit trail, so a retry that succeeds leaves its old 'failed' line in place. outstanding_failures() counts the LAST status per job key — the same rule _plan_jobs already uses to decide what to skip — so a landed retry clears the badge. On Eric's log: 6 'failed' rows, 1 job actually outstanding. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- data/upload_log.csv | 5 +++++ src/bggpipe/upload.py | 42 ++++++++++++++++++++++++++++++---------- src/bggpipe/webreview.py | 9 +++++++-- tests/test_upload.py | 38 +++++++++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/data/upload_log.csv b/data/upload_log.csv index b642e2e..21a4138 100644 --- a/data/upload_log.csv +++ b/data/upload_log.csv @@ -10,3 +10,8 @@ add,273240,,The Red Dragon Inn Smorgasbox,446069,,added,2026-08-06T02:24:46+00:0 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 ⇆⇆⇆⇆First ⇆⇆⇆ - 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 ⇆⇆⇆⇆First ⇆⇆⇆ - 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 ⇆⇆⇆⇆First ⇆⇆⇆ - 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,589,,Wiz-War,27352,,added,2026-08-06T02:45:10+00:00, +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,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, diff --git a/src/bggpipe/upload.py b/src/bggpipe/upload.py index b61674d..02bdc73 100644 --- a/src/bggpipe/upload.py +++ b/src/bggpipe/upload.py @@ -89,6 +89,16 @@ def _job_key(row: dict) -> tuple[str, str, str]: return _key(row["action"], row["bgg_id"], row["collid"], row["version_id"]) +def outstanding_failures(log_rows: list[dict]) -> int: + """Jobs whose LATEST attempt failed. upload_log.csv is an append-only + audit trail, so counting every 'failed' row ever written would keep + growing after a successful retry.""" + last: dict[tuple[str, str, str], str] = {} + for row in log_rows: + last[_job_key(row)] = row["status"] + return sum(1 for status in last.values() if status == "failed") + + def _read_csv(path: Path) -> list[dict]: if not path.exists(): return [] @@ -443,11 +453,12 @@ class PlaywrightUploader: hits = exact or loose if len(hits) == 1: - page_ix, text = hits[0] - self._goto_picker_page(dialog, page_ix) - dialog.locator(self._VERSION_ROWS).filter( - has_text=re.compile(re.escape(text), re.I) - ).first.click() + _, text = hits[0] + if not self._click_row(dialog, text): + raise RuntimeError( + f"{text!r} was listed a moment ago but vanished on the " + "second pass — retryable" + ) how = "" if exact else f" (matched loosely as {text!r})" return True, how @@ -461,17 +472,28 @@ class PlaywrightUploader: f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess" ) - def _goto_picker_page(self, dialog, page_ix: int) -> None: - """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.""" + def _click_row(self, dialog, text: str) -> bool: + """Reopen the sub-view (it always starts on page 1) and hunt for the + row again by its NORMALIZED text, clicking by index. + + Two traps this avoids: First/Prev exist only in the mobile variant + (unclickable on a desktop viewport), and Playwright's has_text + regex matches raw textContent — whose tabs and newlines a + whitespace-normalized capture will never equal.""" 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): + for _ in range(MAX_VERSION_PAGES): + for i, row_text in enumerate(self._rows_on_page(dialog)): + if row_text == text: + dialog.locator(self._VERSION_ROWS).nth(i).click() + return True + if not self._has_next_page(dialog): + return False self._visible(dialog, self._NEXT_PAGE).click() self._page.wait_for_timeout(400) + return False def add_game(self, job: UploadJob) -> tuple[str, str]: self._ensure_logged_in() diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index b75c303..463bf76 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -785,10 +785,15 @@ def create_app( freshen() match_counts = Counter(row["match_status"] for row in session.rows) log_counts: Counter[str] = Counter() + failed_now = 0 log_path = cfg.upload_log_path if log_path.exists(): + from bggpipe.upload import outstanding_failures + with log_path.open(newline="") as f: - log_counts = Counter(row["status"] for row in csv.DictReader(f)) + log_rows = list(csv.DictReader(f)) + log_counts = Counter(row["status"] for row in log_rows) + failed_now = outstanding_failures(log_rows) games = len(read_games()) return { "boot": boot, @@ -829,7 +834,7 @@ def create_app( "to_add": _csv_count(cfg.to_add_path), "to_update": _csv_count(cfg.to_update_path), "upload_log": dict(log_counts), - "upload_failed": log_counts.get("failed", 0), + "upload_failed": failed_now, "games": games, "job": jobs.snapshot(), } diff --git a/tests/test_upload.py b/tests/test_upload.py index f6a7993..d256ef8 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -558,6 +558,7 @@ class _FakePicker: self.pages = pages self.page = 0 self.clicked = None + self.clicked_index = None self.cancelled = False self.reopened = 0 @@ -601,6 +602,9 @@ class _FakePicker: raise AssertionError("clicked a HIDDEN paging control") if "Next Page" in selector: picker.page += 1 + elif "summary-item-thumbnail" in selector: + picker.clicked_index = self.index + picker.clicked = picker.pages[picker.page][self.index] def filter(self, has_text=None): picker.clicked = has_text.pattern if has_text else None @@ -648,7 +652,7 @@ def test_version_found_on_a_later_page(): ) picked, why = up._select_version(dialog, "English Gamefound edition") assert picked is True # the old code gave up after page one - assert "Gamefound" in dialog.clicked # regex-escaped row text + assert dialog.clicked_index == 0 # clicked by index, not by regex assert why == "" @@ -695,3 +699,35 @@ def test_paging_never_clicks_a_hidden_mobile_control(): 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 + + +def test_outstanding_failures_ignores_retried_jobs(): + """upload_log.csv is append-only: a failure line stays forever, so the + UI badge must count LAST status per job, not every 'failed' ever.""" + from bggpipe.upload import outstanding_failures + + rows = [ + { + "action": "add", + "bgg_id": "1", + "collid": "", + "version_id": "", + "status": "failed", + }, + { + "action": "add", + "bgg_id": "1", + "collid": "", + "version_id": "", + "status": "added", + }, + { + "action": "add", + "bgg_id": "2", + "collid": "", + "version_id": "", + "status": "failed", + }, + ] + assert outstanding_failures(rows) == 1 # game 1 was retried and landed + assert outstanding_failures([]) == 0