First real upload lands: three browser-flow bugs, found by doing it
A Gentle Rain is on BGG (collid 148198034, version 701315 — the
English Bloom edition matched from a shelf photo), verified by
re-fetching the collection. The failures on the way, all in the
login gate the docs called "verified" and none in the version-picker
code they called "unverified":
1. BGG's Sign In is an <a class="btn"> with NO href, so it has no
implicit link role: get_by_role("link", name="Sign In") matched
zero elements in EVERY state, and "no Sign In link" was read as
"already signed in". Every run browsed anonymously.
2. The header hydrates after domcontentloaded, so for a moment
neither control exists — a check resting on one absence guesses.
_signed_out() now polls until the page proves one state or the
other (Sign In vs Sign Out) and raises after 30s rather than
assume; login is verified by the transition, and the session file
saves only after that proof.
3. get_by_label("Own") also matched "Prev. Owned" — strict-mode
violation; the Own checkbox is now matched exactly.
Also: Playwright's multi-line call logs no longer break upload_log.csv
into ragged rows (errors flatten to one line). Three unit tests cover
the hydration window, positive detection, and the undeterminable case.
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
481fa63488
commit
6ae2667c58
+13
-13
File diff suppressed because one or more lines are too long
+61
-4
@@ -1,7 +1,64 @@
|
|||||||
bgg_id,bgg_name,year,type,version_id,version_name,title_raw,source_photos,second_copy
|
bgg_id,bgg_name,year,type,version_id,version_name,title_raw,source_photos,second_copy
|
||||||
235096,Cat Crimes,2017,boardgame,360982,English edition,CAT CRIMES,IMG_4499.jpeg;IMG_4505.jpeg,
|
334011,A Gentle Rain,2021,boardgame,701315,English Bloom edition,a Gentle Rain,IMG_4501.jpeg;IMG_4507.jpeg,
|
||||||
268163,A Gentle Rain,2019,boardgame,268164,Bloom Edition,a Gentle Rain,IMG_4501.jpeg;IMG_4507.jpeg,
|
589,Wiz-War,1983,boardgame,27352,Chessex sixth edition,Wiz-War,IMG_4502.jpeg,
|
||||||
|
125921,Catan: Junior,2012,boardgame,476263,English third edition,CATAN JUNIOR,IMG_4516.jpeg,
|
||||||
|
31260,Agricola,2007,boardgame,297589,Z-Man Games English fifth edition,AGRICOLA,IMG_4518.jpeg,
|
||||||
|
181304,Mysterium,2015,boardgame,536289,English edition 2018-2,MYSTERIUM,IMG_4520.jpeg,
|
||||||
|
312786,Poetry for Neanderthals,2020,boardgame,514050,English edition 2020,Poetry for Neanderthals,IMG_4526.jpeg,
|
||||||
|
273240,The Red Dragon Inn Smorgasbox,2019,boardgame,446069,Kickstarter edition,The Red Dragon Inn: Smorgasbox,IMG_4527.jpeg,
|
||||||
|
419687,Munchkin Big Box,2025,boardgame,711400,English edition,Munchkin Big Box,IMG_4530.jpeg,
|
||||||
|
252153,Tang Garden,2019,boardgame,404702,English edition,Tang Garden,IMG_4533.jpeg,
|
||||||
|
255984,Sleeping Gods,2021,boardgame,701034,English Gamefound edition,Sleeping Gods,IMG_4534.jpeg,
|
||||||
|
283766,Sleeping Gods: Tides of Ruin,2021,boardgame,463140,English edition,Sleeping Gods: Tides of Ruin,IMG_4534.jpeg,
|
||||||
|
280794,Etherfields,2020,boardgame,458300,English edition,ETHERFIELDS,IMG_4535.jpeg,
|
||||||
|
380837,Botany,2024,boardgame,650545,English edition,BOTANY,IMG_4536.jpeg,
|
||||||
|
393672,Gloomhaven: Buttons & Bugs,2024,boardgame,669782,English edition,GLOOMHAVEN Buttons & Bugs,IMG_4537.jpeg,
|
||||||
|
14535,SPANC: Space Pirate Amazon Ninja Catgirls,2005,boardgame,29075,English first edition,SPANC Space Pirate Amazon Ninja Catgirls,IMG_4537.jpeg,
|
||||||
|
9666,Greed Quest,2004,boardgame,215137,English edition,Greed Quest,IMG_4537.jpeg,
|
||||||
|
40508,Scrabble Slam!,2008,boardgame,644326,English edition with Hasbro logo,SCRABBLE SLAM!,IMG_4543.jpeg,
|
||||||
|
3181,Farkle,1930,boardgame,87931,Legendary Games cannister edition,POCKET FARKEL,IMG_4545.jpeg,
|
||||||
|
193485,Dastardly Dirigibles,2016,boardgame,300446,English edition,Dastardly Dirigibles,IMG_4548.jpeg,
|
||||||
|
226065,Sheriff of Nottingham: Merry Men,2017,boardgame,353389,English edition,SHERIFF OF NOTTINGHAM MERRY MEN,IMG_4550.jpeg,
|
||||||
|
143884,Machi Koro,2012,boardgame,453051,"English fifth anniversary edition, first printing",MACHI KORO,IMG_4551.jpeg,
|
||||||
|
358880,Etherfields: Kittenburg Expansion,2022,boardgame,603694,English edition,ETHERFIELDS KITTENBURG EXPANSION,IMG_4570.jpeg,
|
||||||
|
432858,Artistry,2025,boardgame,734890,English edition,ARTISTRY,IMG_4515.jpeg,
|
||||||
|
417060,Botany: Tantalizing Trees,2024,boardgameexpansion,706506,English edition,BOTANY: TANTALIZING TREES,IMG_4571.jpeg,
|
||||||
|
417064,Botany: Perilous Perfumes,2024,boardgameexpansion,706510,English edition,BOTANY: PERILOUS PERFUMES,IMG_4571.jpeg,
|
||||||
|
161928,Utter Nonsense,2014,boardgame,,,Utter Nonsense!,IMG_4501.jpeg;IMG_4507.jpeg;IMG_4556.jpeg,
|
||||||
50381,Cards Against Humanity,2009,boardgame,,,Cards Against Humanity,IMG_4501.jpeg;IMG_4556.jpeg,
|
50381,Cards Against Humanity,2009,boardgame,,,Cards Against Humanity,IMG_4501.jpeg;IMG_4556.jpeg,
|
||||||
1078,Skip-Bo,1967,boardgame,,,SKIP-BO,IMG_4501.jpeg;IMG_4542.jpeg,
|
1269,Skip-Bo,1967,boardgame,,,SKIP-BO,IMG_4501.jpeg;IMG_4542.jpeg,
|
||||||
218866,Scrawl,2017,boardgame,,,SCRAWL,IMG_4502.jpeg,
|
202982,Scrawl,2016,boardgame,,,SCRAWL,IMG_4502.jpeg,
|
||||||
195314,Herbaceous,2017,boardgame,,,Herbaceous,IMG_4507.jpeg,
|
195314,Herbaceous,2017,boardgame,,,Herbaceous,IMG_4507.jpeg,
|
||||||
|
320202,Dragon Land,2020,boardgame,,,Dragon Land,IMG_4508.jpeg;IMG_4542.jpeg,
|
||||||
|
2452,Jenga,1983,boardgame,,,Jenga,IMG_4509.jpeg,
|
||||||
|
42063,Code Master,1997,boardgame,,,CODE MASTER,IMG_4510.jpeg,
|
||||||
|
340420,Throw Throw Avocado,2021,boardgame,,,THROW THROW AVOCADO,IMG_4511.jpeg,
|
||||||
|
1260,Rook,1906,boardgame,,,ROOK,IMG_4511.jpeg,
|
||||||
|
116,Guillotine,1998,boardgame,,,Guillotine,IMG_4512.jpeg,
|
||||||
|
169124,Flick 'em Up!,2015,boardgame,,,Flick'em UP!,IMG_4513.jpeg,
|
||||||
|
20866,The Rocky Horror Trivia Game,2005,boardgame,,,The Rocky Horror Trivia Game,IMG_4514.jpeg,
|
||||||
|
269564,Captain Marvel: Secret Skrulls,2019,boardgame,,,CAPTAIN MARVEL SECRET SKRULLS,IMG_4516.jpeg,
|
||||||
|
18755,Pie Face!,1964,boardgame,,,Pie Face!,IMG_4517.jpeg,
|
||||||
|
140509,Dungeons & Dragons,1985,boardgame,,,DUNGEONS & DRAGONS,IMG_4519.jpeg,
|
||||||
|
201248,Evolution: The Beginning,2016,boardgame,,,evolution The Beginning,IMG_4520.jpeg,
|
||||||
|
362205,Sleeping Gods: Primeval Peril,2023,boardgame,,,Sleeping Gods PRIMEVAL PERIL,IMG_4521.jpeg,
|
||||||
|
453845,Murder at the Manor,2021,boardgame,,,Murder at the Manor,IMG_4522.jpeg,
|
||||||
|
329230,Bluffaneer,2021,boardgame,,,Bluffaneer,IMG_4522.jpeg,
|
||||||
|
358320,Sleeping Gods: Distant Skies,2023,boardgame,,,Sleeping Gods DISTANT SKIES,IMG_4523.jpeg,
|
||||||
|
389113,Rivals,2025,boardgame,,,THE RIVALS FOR CATAN CARD GAME,IMG_4523.jpeg,
|
||||||
|
324937,Wiz-War (9th Edition),2023,boardgame,,,WIZ-WAR,IMG_4528.jpeg,
|
||||||
|
295770,Frosthaven,2022,boardgame,,,FROSTHAVEN,IMG_4531.jpeg,
|
||||||
|
174430,Gloomhaven,2017,boardgame,,,GLOOMHAVEN,IMG_4532.jpeg,
|
||||||
|
442312,Deadly Doodles,2025,boardgame,,,DEADLY DOODLES,IMG_4533.jpeg,
|
||||||
|
332398,Everdell: The Complete Collection,2022,boardgame,,,Everdell The Complete Collection,IMG_4533.jpeg,
|
||||||
|
334065,Verdant,2022,boardgame,,,Verdant,IMG_4534.jpeg,
|
||||||
|
283155,Calico,2020,boardgame,,,Calico,IMG_4534.jpeg,
|
||||||
|
291457,Gloomhaven: Jaws of the Lion,2020,boardgame,,,GLOOMHAVEN - Jaws of the Lion,IMG_4538.jpeg,
|
||||||
|
150925,Hold Your Breath!,2014,boardgame,,,Hold your Breath!,IMG_4547.jpeg,
|
||||||
|
2980,The Simpsons Trivia Game,2000,boardgame,,,The Simpsons Trivia Game,shelf-20260803-213735.jpg,
|
||||||
|
1197,Blink,1995,boardgame,,,BLINK,shelf-20260803-215650.jpg,
|
||||||
|
352819,The Office Trivia Game,2019,boardgame,,,The Office Trivia Game,shelf-20260803-215703.jpg,
|
||||||
|
163412,Patchwork,2014,boardgame,,,Patchwork,IMG_4501.jpeg,
|
||||||
|
291847,Mantis Falls,2021,boardgame,,,MANTIS FALLS,IMG_4505.jpeg,
|
||||||
|
2058,Civilization: West Extension Map,1986,boardgameexpansion,,,Civilization: West Extension Map,,
|
||||||
|
452833,Artistry: Delightful Doorways Mini Expansion,2025,boardgameexpansion,,,Artistry: Delightful Doorways Mini Expansion,,
|
||||||
|
|||||||
|
@@ -1 +1,35 @@
|
|||||||
collid,bgg_id,bgg_name,version_id,version_name
|
collid,bgg_id,bgg_name,version_id,version_name
|
||||||
|
53429559,240,Britannia,24621,Avalon Hill second edition
|
||||||
|
53429530,71,Civilization,24006,Avalon Hill first edition 1981
|
||||||
|
53429542,177,Advanced Civilization,24972,English edition
|
||||||
|
53429504,3090,Doctor Who: The Game of Time & Space,25782,English edition
|
||||||
|
53429482,3585,Sorcerer: The Game of Magical Conflict,211154,SPI Designer's Edition
|
||||||
|
53429367,2524,StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century,219137,SPI boxed designer's edition
|
||||||
|
53429816,224,History of the World,20727,Avalon Hill/Hasbro edition
|
||||||
|
53429636,9209,Ticket to Ride,294188,English edition 2012 with Spiel des Jahres logo
|
||||||
|
53430595,13,Catan,347121,English edition 2016
|
||||||
|
53430485,1219,Labyrinth,488621,English edition 2017
|
||||||
|
53429649,137909,Bugs in the Kitchen,216474,English first edition
|
||||||
|
53430142,27710,Catan Dice Game,606657,English edition 2018
|
||||||
|
53429953,169784,Castle Panic: The Dark Titan,260454,English first edition
|
||||||
|
53429666,147370,Robot Turtles,231664,English second edition
|
||||||
|
53430559,43443,Castle Panic,103068,"English edition, fourth printing"
|
||||||
|
53429632,24310,The Red Dragon Inn,374258,English fifth edition
|
||||||
|
53429748,39856,Dixit,296529,Asmodee/Libellud English edition 2015
|
||||||
|
53430257,35505,Walk the Plank!,781669,English MDG-4311 edition
|
||||||
|
53430020,164,"Before I Kill You, Mister Bond",28465,"Cheapass black and white ""Better Edition"" edition"
|
||||||
|
53429887,65244,Forbidden Island,31208,English edition 051210
|
||||||
|
53429908,95386,Tempurra,290572,English edition
|
||||||
|
53430200,258,Fluxx,20840,Looney Labs Version v2.1
|
||||||
|
53430332,188614,Simon's Cat Card Game,779792,"English edition, second printing"
|
||||||
|
53429850,4324,Risk: The Lord of the Rings,26859,English edition 2002
|
||||||
|
53429933,1927,Munchkin,28144,"English edition 2009, 15th printing"
|
||||||
|
53430007,1784,Dark Cults,110926,First edition
|
||||||
|
53429986,176,Give Me the Brain!,27673,Czech edition
|
||||||
|
53430040,257,Kill Doctor Lucky,520364,"Cheapass envelope edition, second printing"
|
||||||
|
53612880,153999,"...and then, we held hands.",280845,English edition
|
||||||
|
53612866,166976,Consentacle,410382,English Kickstarter edition
|
||||||
|
53430652,231302,The Cat Game,361880,English/French edition
|
||||||
|
53430517,40692,Small World,294178,English third edition
|
||||||
|
53429718,172503,Mage Wars Academy,265107,English edition
|
||||||
|
53429642,104710,Wiz-War (Eighth Edition),117685,English first edition
|
||||||
|
|||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
action,bgg_id,collid,name,version_id,second_copy,status,timestamp,error
|
||||||
|
add,334011,,A Gentle Rain,701315,,failed,2026-08-06T02:15:34+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""A\ Gentle\ Rain"", re.IGNORECASE)) to be visible"
|
||||||
|
add,589,,Wiz-War,27352,,failed,2026-08-06T02:20:33+00:00,"Error: Locator.check: Error: strict mode violation: get_by_role(""dialog"").get_by_label(""Own"") resolved to 2 elements: 1) <input type=""checkbox"" ng-model=""item.status.own"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Own"", exact=True) 2) <input type=""checkbox"" ng-model=""item.status.prevowned"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Prev. Owned"") Call log: - waiting for get_by_role(""dialog"").get_by_label(""Own"")"
|
||||||
|
add,334011,,A Gentle Rain,701315,,added,2026-08-06T02:21:32+00:00,
|
||||||
|
+40
-7
@@ -213,12 +213,13 @@ def build_queue(
|
|||||||
|
|
||||||
def _scrub(text: str) -> str:
|
def _scrub(text: str) -> str:
|
||||||
"""Credentials must never reach the log, even via a selector error that
|
"""Credentials must never reach the log, even via a selector error that
|
||||||
echoes filled form values."""
|
echoes filled form values. Playwright errors carry multi-line call
|
||||||
|
logs; a CSV cell keeps one line."""
|
||||||
for key in ("BGG_PASSWORD", "BGG_USERNAME"):
|
for key in ("BGG_PASSWORD", "BGG_USERNAME"):
|
||||||
value = os.environ.get(key)
|
value = os.environ.get(key)
|
||||||
if value:
|
if value:
|
||||||
text = text.replace(value, "***")
|
text = text.replace(value, "***")
|
||||||
return text
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
|
||||||
class LoginError(RuntimeError):
|
class LoginError(RuntimeError):
|
||||||
@@ -275,11 +276,32 @@ class PlaywrightUploader:
|
|||||||
# plus per-element auto-waiting is the reliable pattern.
|
# plus per-element auto-waiting is the reliable pattern.
|
||||||
self._page.goto(url, wait_until="domcontentloaded")
|
self._page.goto(url, wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
# BGG's Sign In control is an <a class="btn"> with NO href, so it has
|
||||||
|
# no implicit link role — a role="link" query never matched it in any
|
||||||
|
# state, and "no Sign In link" silently meant "already logged in".
|
||||||
|
_SIGN_IN = 'a:has-text("Sign In"), button:has-text("Sign In")'
|
||||||
|
_SIGNED_IN = ':text-is("Sign Out"), :text-is("Log Out")'
|
||||||
|
|
||||||
def _signed_out(self) -> bool:
|
def _signed_out(self) -> bool:
|
||||||
# Heuristic: the header shows a "Sign In" link only when logged out.
|
"""True = signed out. Waits for the header to prove ONE state or
|
||||||
return (
|
the other: BGG hydrates it after domcontentloaded, so an early read
|
||||||
self._page.get_by_role("link", name=re.compile(r"^sign in$", re.I)).count()
|
finds NEITHER control — and any check resting on a single absence
|
||||||
> 0
|
(the original bug) then guesses, silently, in the unsafe direction.
|
||||||
|
Undeterminable state raises rather than assumes."""
|
||||||
|
page = self._page
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
out = page.locator(self._SIGN_IN).count()
|
||||||
|
inside = page.locator(self._SIGNED_IN).count()
|
||||||
|
if out and not inside:
|
||||||
|
return True
|
||||||
|
if inside and not out:
|
||||||
|
return False
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
raise LoginError(
|
||||||
|
"could not tell whether this browser is signed in (neither a "
|
||||||
|
"Sign In nor a Sign Out control appeared in 30s) — BGG's header "
|
||||||
|
"may have changed; see docs/bgg-upload-flow.md"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_logged_in(self) -> None:
|
def _ensure_logged_in(self) -> None:
|
||||||
@@ -313,6 +335,15 @@ class PlaywrightUploader:
|
|||||||
"login did not complete (wrong credentials, or the login "
|
"login did not complete (wrong credentials, or the login "
|
||||||
"page changed — see docs/bgg-upload-flow.md)"
|
"page changed — see docs/bgg-upload-flow.md)"
|
||||||
) from err
|
) from err
|
||||||
|
# Verify the TRANSITION: the Sign In control was visible a
|
||||||
|
# moment ago, so its disappearance is evidence, not inference.
|
||||||
|
self._goto(f"{BGG}/")
|
||||||
|
if self._signed_out():
|
||||||
|
raise LoginError(
|
||||||
|
"the login form was submitted but the site still offers "
|
||||||
|
"Sign In — credentials rejected, or a challenge is "
|
||||||
|
"pending in the browser window"
|
||||||
|
)
|
||||||
self._context.storage_state(path=str(self._storage_state))
|
self._context.storage_state(path=str(self._storage_state))
|
||||||
self._storage_state.chmod(0o600) # session cookies: owner-only
|
self._storage_state.chmod(0o600) # session cookies: owner-only
|
||||||
self._authed = True
|
self._authed = True
|
||||||
@@ -382,7 +413,9 @@ class PlaywrightUploader:
|
|||||||
dialog.get_by_role(
|
dialog.get_by_role(
|
||||||
"heading", name=re.compile(re.escape(job.name), re.I)
|
"heading", name=re.compile(re.escape(job.name), re.I)
|
||||||
).wait_for(timeout=15_000)
|
).wait_for(timeout=15_000)
|
||||||
dialog.get_by_label("Own").check()
|
# exact: "Own" is a substring of "Prev. Owned", and a loose label
|
||||||
|
# match resolves to both checkboxes (strict-mode violation)
|
||||||
|
dialog.get_by_role("checkbox", name="Own", exact=True).check()
|
||||||
status, note = "added", ""
|
status, note = "added", ""
|
||||||
if job.version_name and not self._select_version(dialog, job.version_name):
|
if job.version_name and not self._select_version(dialog, job.version_name):
|
||||||
status = "added_no_version"
|
status = "added_no_version"
|
||||||
|
|||||||
@@ -491,3 +491,57 @@ def test_drift_warning_suppressed_for_multi_edition_partial_run(tmp_path, capsys
|
|||||||
)
|
)
|
||||||
run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=_now)
|
run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=_now)
|
||||||
assert "manual correction" not in capsys.readouterr().out
|
assert "manual correction" not in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
# -- sign-in detection: never guess from a single absence ----------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePage:
|
||||||
|
"""Counts per selector, replayed one poll at a time — the header
|
||||||
|
hydrates late, so the first reads legitimately see nothing."""
|
||||||
|
|
||||||
|
def __init__(self, script):
|
||||||
|
self.script = list(script)
|
||||||
|
self.polls = 0
|
||||||
|
|
||||||
|
def locator(self, selector):
|
||||||
|
counts = self.script[min(self.polls, len(self.script) - 1)]
|
||||||
|
|
||||||
|
class _Loc:
|
||||||
|
def count(self):
|
||||||
|
return counts["out" if "Sign In" in selector else "in"]
|
||||||
|
|
||||||
|
return _Loc()
|
||||||
|
|
||||||
|
def wait_for_timeout(self, ms):
|
||||||
|
self.polls += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _uploader_with(script):
|
||||||
|
from bggpipe.upload import PlaywrightUploader
|
||||||
|
|
||||||
|
up = PlaywrightUploader("someone")
|
||||||
|
up._page = _FakePage(script)
|
||||||
|
return up
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_out_waits_through_hydration():
|
||||||
|
# neither control for two polls (the window the old check misread as
|
||||||
|
# "already signed in"), then the Sign In control appears
|
||||||
|
up = _uploader_with([{"out": 0, "in": 0}, {"out": 0, "in": 0}, {"out": 1, "in": 0}])
|
||||||
|
assert up._signed_out() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_in_is_detected_positively():
|
||||||
|
up = _uploader_with([{"out": 0, "in": 0}, {"out": 0, "in": 1}])
|
||||||
|
assert up._signed_out() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_undeterminable_state_raises_instead_of_guessing(monkeypatch):
|
||||||
|
from bggpipe.upload import LoginError
|
||||||
|
|
||||||
|
ticks = iter([0, 1, 2, 31]) # force the deadline to pass
|
||||||
|
monkeypatch.setattr("time.monotonic", lambda: next(ticks))
|
||||||
|
up = _uploader_with([{"out": 0, "in": 0}])
|
||||||
|
with pytest.raises(LoginError, match="could not tell"):
|
||||||
|
up._signed_out()
|
||||||
|
|||||||
Reference in New Issue
Block a user