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
144 lines
7.6 KiB
Markdown
144 lines
7.6 KiB
Markdown
# BGG "Add to Collection" flow — recon notes for the upload stage
|
||
|
||
Recorded 2026-08-01 by walking the flow manually on boardgamegeek.com (Wingspan,
|
||
id 266192) while logged in. Dialog opened, inspected, and **cancelled without
|
||
saving**. These notes are the selector documentation the spec requires before
|
||
automating Stage 5.
|
||
|
||
## Entry point
|
||
|
||
- Game page has **two** "Add To" buttons (one in the game header module, one
|
||
lower on the page) — target the first, but match by accessible name, not
|
||
position. Button's accessible name is "Add To" with adjacent text
|
||
"Collection".
|
||
- Clicking opens a `role="dialog"` containing a `<form>`. Dialog heading shows
|
||
a "Loading..." span before content settles — **wait for the game-name
|
||
heading** (e.g. `getByRole('heading', {name: gameName})`) before
|
||
interacting.
|
||
|
||
## Main dialog structure
|
||
|
||
- **Status checkboxes**, each wrapped in a `<label>`: Own, Prev. Owned,
|
||
For Trade, Want to Play, Want in Trade, Want to Buy, Pre-ordered, Wishlist.
|
||
→ `dialog.getByLabel('Own')` and check it. Nothing is pre-checked.
|
||
- Rating: a 1–10 slider plus a "Rating" text input. We do not set ratings.
|
||
- Comment (public) textbox — unused by us.
|
||
- "Advanced (private info, parts exchange)" expander: Price Paid, Current
|
||
Price, Quantity, Acquisition Date, Acquired From, Inventory Date/Location,
|
||
Private Comment, Want/Has Parts. All unused (we don't track provenance).
|
||
- "Customize Item Info (title, image)" expander: Custom Title, Custom Image
|
||
Id, plus **manual version-override fields** (Publisher Id, Language select,
|
||
Year, Other, Barcode). These are for defining a custom version — do NOT use
|
||
them; always pick a cataloged version instead (or none).
|
||
- Footer: `Save` (`type="submit"`) and `Cancel` buttons.
|
||
|
||
## Version picker ("Set version/edition")
|
||
|
||
- Button labeled **"Set version/edition"** near the top of the dialog swaps
|
||
the dialog content to a "Versions" sub-view (same `role="dialog"`).
|
||
- The sub-view is a **paginated list with NO search/filter box**. Each
|
||
listitem's text is the full canonical version name + year, e.g.
|
||
"Flügelschlag (German fifth edition) (2024)" — these names match the
|
||
version names returned by `/thing?id=X&versions=1`.
|
||
- Selection strategy: resolve the target version NAME from the XML API,
|
||
then page through the list matching listitem text
|
||
(`getByRole('listitem').filter({hasText: versionName})`). Newest years
|
||
appear first.
|
||
- The sub-view has its own **Cancel** that returns to the main dialog — it
|
||
is a different button from the main dialog's Cancel. Two-level dismissal.
|
||
|
||
## Automation gotchas observed
|
||
|
||
1. **Element references go stale constantly.** The page re-renders after
|
||
load and after every dialog transition; clicks on cached handles silently
|
||
miss. Playwright's auto-waiting role/label locators handle this — never
|
||
cache element handles across a dialog state change.
|
||
2. **Dialog persists in the DOM after cancel**, just hidden
|
||
(`offsetParent === null`). "Is the dialog gone" checks must test
|
||
visibility, not existence. Same applies when verifying a save completed.
|
||
3. First click on "Add To" right after page load can no-op (hydration race).
|
||
Wait for network-idle or the button's stable state before clicking.
|
||
4. Verify saves via the collection API (`--verify`), not by UI state.
|
||
|
||
## Playwright locator sketch
|
||
|
||
```python
|
||
page.get_by_role("button", name="Add To").first.click()
|
||
dialog = page.get_by_role("dialog")
|
||
dialog.get_by_role("heading", name=game_name).wait_for()
|
||
dialog.get_by_label("Own").check()
|
||
if version_name:
|
||
dialog.get_by_role("button", name="Set version/edition").click()
|
||
# page through listitems until version_name matches, then click it
|
||
dialog.get_by_role("button", name="Save").click()
|
||
```
|
||
|
||
Unverified so far (needs a real, sacrificial save on one game before batch
|
||
runs): pagination controls in the version sub-view, exact post-save behavior
|
||
(toast? dialog close? redirect?), and how the dialog differs when the game is
|
||
ALREADY in the collection (second-copy flow must create a new entry, not edit
|
||
the existing one).
|
||
|
||
## Login page (recon 2026-08-01, anonymous probe via Playwright)
|
||
|
||
- **Cloudflare Turnstile blocks headless browsers outright**: the headless
|
||
shell never gets past "Just a moment..." (`cf-turnstile-response` hidden
|
||
input, no form). A normal **headed** Chromium passed the check without
|
||
interaction. Hence `bggpipe upload` runs headed by default; `--headless`
|
||
exists but expect login to fail there. A first login in headed mode may
|
||
still need one human click on the challenge widget; the session then
|
||
persists via `storage_state.json` (gitignored).
|
||
- **BGG pages never reach Playwright's `networkidle`** — ad/analytics
|
||
requests poll forever. Navigate with `wait_until="domcontentloaded"` and
|
||
rely on element-level auto-waiting.
|
||
- Verified form selectors at `/login`: `#inputUsername` (name=`username`,
|
||
formcontrolname=`username`), `#inputPassword`, and a button with
|
||
accessible name **"Sign In"** (`type="button"` — Angular handles submit,
|
||
so click the button rather than pressing Enter and hoping for a form
|
||
submit). Labels "Username"/"Password" point at those ids. Cookie-consent
|
||
checkboxes (Essential, Performance Analytics, ...) render on the same
|
||
page but did not overlay the form in the probe.
|
||
- Logged-in detection heuristic (unverified): the header shows a "Sign In"
|
||
link only when logged out.
|
||
|
||
|
||
## Verified against the live site (2026-08-06, first real uploads)
|
||
|
||
The add flow works end to end; every failure on the way was in code the
|
||
earlier walkthrough had marked *verified*, and the parts marked
|
||
*unverified* were mostly right. Corrections:
|
||
|
||
- **Sign In is an `<a class="btn">` with no `href`.** It therefore has no
|
||
implicit `link` role: `get_by_role("link", name="Sign In")` matches
|
||
nothing in any state. The header also hydrates after
|
||
`domcontentloaded`, so for a moment neither Sign In nor Sign Out
|
||
exists — a check resting on one absence silently concludes "signed in"
|
||
and browses anonymously. Poll until one control or the other proves the
|
||
state; treat "neither, after 30s" as an error.
|
||
- **`get_by_label("Own")` also matches "Prev. Owned."** Use
|
||
`get_by_role("checkbox", name="Own", exact=True)`.
|
||
- **Version rows** are the `<li>`s carrying a thumbnail:
|
||
`li:has(.summary-item-thumbnail)`. Plain `listitem` also catches the
|
||
paging `<li>`s ("First", "Prev", "1", "…").
|
||
- **Paging is an AngularJS `<ul class="pagination">` of anchors**, not
|
||
buttons: `a[title="Next Page"]`, with the parent `<li>` gaining
|
||
`disabled` at the end. Paging is client-side over an already-loaded
|
||
list — no request per page.
|
||
- **Every paging control renders TWICE**: a desktop set and a mobile set
|
||
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
|
||
name is localized (a Czech edition's row starts "Spící bohové"). Match
|
||
the version name inside its parentheses.
|
||
- **The API's version name is not always the picker's string.** BGG's
|
||
XML gives e.g. `English edition 2018-2` where the picker shows
|
||
`(English edition) (2018)`. Match the full name first, then retry with
|
||
a trailing year/printing qualifier stripped — and if that relaxed match
|
||
hits more than one row, refuse and add version-less (never guess).
|
||
- **An owned game's page has no "Add To" button**; it reads
|
||
"In Collections (Own…)". That is the update flow's entry point.
|