Row clicks survive whitespace; the failure badge stops inflating
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
e49d1234d6
commit
6f2f2dac53
@@ -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 <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,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,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"
|
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"
|
||||||
|
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,
|
||||||
|
|||||||
|
+32
-10
@@ -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"])
|
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]:
|
def _read_csv(path: Path) -> list[dict]:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
@@ -443,11 +453,12 @@ class PlaywrightUploader:
|
|||||||
|
|
||||||
hits = exact or loose
|
hits = exact or loose
|
||||||
if len(hits) == 1:
|
if len(hits) == 1:
|
||||||
page_ix, text = hits[0]
|
_, text = hits[0]
|
||||||
self._goto_picker_page(dialog, page_ix)
|
if not self._click_row(dialog, text):
|
||||||
dialog.locator(self._VERSION_ROWS).filter(
|
raise RuntimeError(
|
||||||
has_text=re.compile(re.escape(text), re.I)
|
f"{text!r} was listed a moment ago but vanished on the "
|
||||||
).first.click()
|
"second pass — retryable"
|
||||||
|
)
|
||||||
how = "" if exact else f" (matched loosely as {text!r})"
|
how = "" if exact else f" (matched loosely as {text!r})"
|
||||||
return True, how
|
return True, how
|
||||||
|
|
||||||
@@ -461,17 +472,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 _goto_picker_page(self, dialog, page_ix: int) -> None:
|
def _click_row(self, dialog, text: str) -> bool:
|
||||||
"""Reopen the sub-view (it always starts on page 1) and step
|
"""Reopen the sub-view (it always starts on page 1) and hunt for the
|
||||||
forward. The First/Prev anchors exist only in the mobile variant,
|
row again by its NORMALIZED text, clicking by index.
|
||||||
so they can never be clicked on a desktop viewport."""
|
|
||||||
|
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()
|
dialog.get_by_role("button", name="Cancel").first.click()
|
||||||
self._page.wait_for_timeout(300)
|
self._page.wait_for_timeout(300)
|
||||||
dialog.get_by_role("button", name="Set version/edition").click()
|
dialog.get_by_role("button", name="Set version/edition").click()
|
||||||
dialog.locator(self._VERSION_ROWS).first.wait_for(timeout=15_000)
|
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._visible(dialog, self._NEXT_PAGE).click()
|
||||||
self._page.wait_for_timeout(400)
|
self._page.wait_for_timeout(400)
|
||||||
|
return False
|
||||||
|
|
||||||
def add_game(self, job: UploadJob) -> tuple[str, str]:
|
def add_game(self, job: UploadJob) -> tuple[str, str]:
|
||||||
self._ensure_logged_in()
|
self._ensure_logged_in()
|
||||||
|
|||||||
@@ -785,10 +785,15 @@ def create_app(
|
|||||||
freshen()
|
freshen()
|
||||||
match_counts = Counter(row["match_status"] for row in session.rows)
|
match_counts = Counter(row["match_status"] for row in session.rows)
|
||||||
log_counts: Counter[str] = Counter()
|
log_counts: Counter[str] = Counter()
|
||||||
|
failed_now = 0
|
||||||
log_path = cfg.upload_log_path
|
log_path = cfg.upload_log_path
|
||||||
if log_path.exists():
|
if log_path.exists():
|
||||||
|
from bggpipe.upload import outstanding_failures
|
||||||
|
|
||||||
with log_path.open(newline="") as f:
|
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())
|
games = len(read_games())
|
||||||
return {
|
return {
|
||||||
"boot": boot,
|
"boot": boot,
|
||||||
@@ -829,7 +834,7 @@ def create_app(
|
|||||||
"to_add": _csv_count(cfg.to_add_path),
|
"to_add": _csv_count(cfg.to_add_path),
|
||||||
"to_update": _csv_count(cfg.to_update_path),
|
"to_update": _csv_count(cfg.to_update_path),
|
||||||
"upload_log": dict(log_counts),
|
"upload_log": dict(log_counts),
|
||||||
"upload_failed": log_counts.get("failed", 0),
|
"upload_failed": failed_now,
|
||||||
"games": games,
|
"games": games,
|
||||||
"job": jobs.snapshot(),
|
"job": jobs.snapshot(),
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -558,6 +558,7 @@ class _FakePicker:
|
|||||||
self.pages = pages
|
self.pages = pages
|
||||||
self.page = 0
|
self.page = 0
|
||||||
self.clicked = None
|
self.clicked = None
|
||||||
|
self.clicked_index = None
|
||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
self.reopened = 0
|
self.reopened = 0
|
||||||
|
|
||||||
@@ -601,6 +602,9 @@ class _FakePicker:
|
|||||||
raise AssertionError("clicked a HIDDEN paging control")
|
raise AssertionError("clicked a HIDDEN paging control")
|
||||||
if "Next Page" in selector:
|
if "Next Page" in selector:
|
||||||
picker.page += 1
|
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):
|
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
|
||||||
@@ -648,7 +652,7 @@ def test_version_found_on_a_later_page():
|
|||||||
)
|
)
|
||||||
picked, why = up._select_version(dialog, "English Gamefound edition")
|
picked, why = up._select_version(dialog, "English Gamefound edition")
|
||||||
assert picked is True # the old code gave up after page one
|
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 == ""
|
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
|
assert picked is True # no AssertionError from the fake = no hidden click
|
||||||
# the initial open plus ONE reopen — never a "First Page" click
|
# the initial open plus ONE reopen — never a "First Page" click
|
||||||
assert dialog.reopened == 2
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user