Files
bggpipe/docs/bgg-upload-flow.md
T
Eric WagonerandClaude Fable 5 6e72ef22d1 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
2026-08-05 22:53:05 -04:00

8.0 KiB
Raw Blame History

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 110 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

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 may be unclickable on a desktop viewport (mobile-only variant). To return to page 1, click the visible numbered "1" 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 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.