Files
91df482dd8be361426d88291ca527a12c3961384
DeckVariant fields: has_card_images (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), family (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), is_polarized (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). TarotCard.SUIT_CHOICES collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since sig_deck_cards + levity/gravity_sig_cards already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (batons for Italian, wands for English, clubs for Playing) lives in Sprint A.2's display_suit_name property, not in the enum. Audit 2026-05-25 revealed the existing fiorentine-minchiate DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → tarot-rider-waite-smith, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: sig_deck_cards + _sig_unique_cards_for_deck queries shrink from suit__in=[3 values] and [4 values] to [2 values] each (one per segment); TarotCard.suit_icon mapping shrinks from 8 entries to 4; gameboard.views.tarot_fan._suit_order shrinks from 8 keys to 4. Existing test files updated: test_game_room_tray.py (largest update — self.fiorentine → self.rws, id_kit_fiorentine_deck → id_kit_tarot_deck (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); test_game_room_deck_contrib.py (same pattern, smaller); lyric/test_models.py + gameboard/test_views.py (slug literal swaps only); epic/test_models.py _make_sig_card test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in DeckSchemaA0Test cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the image_filename + display_suit_name properties that consume the new family field; A.3+ wires the render branches across 6 surfaces
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FT flake mitigations triggered by pipelines #302/#303 — three independent fixes consolidated in one commit; test_admin_tarot._login_to_admin now waits on
"Site administration" body substring before returning, same shape as 054b0aa's test_admin.py fix — the helper used to click submit + return immediately, letting the three TarotAdminTest tests race their subsequent browser.get(/admin/epic/tarotcard/) against the in-flight POST → 302 → admin home navigation so on a slow CI runner the new GET cancelled the unfinished POST, the session cookie was never set, the browser landed back on /admin/login/?next=…, and the downstream assertion saw the login-page body ('Earthman Deck' not found in 'Django administration\nEmail:\nPassword:' in #303, plus a NoSuchElementException for "The Schiz" on test_admin_earthman_card_detail's link-text click) — wait_for w. assertIn retries til the post-login page actually renders so the rest of the helper's callers start from a settled state ; test_jasmine swaps wait_for(check_results) → wait_for_slow(check_results, timeout=60) — the spec suite has grown well past the 10s MAX_WAIT the default wait_for decorator affords + under CI contention (parallel Selenium workers competing for CPU on the same droplet) Jasmine's .jasmine-overall-result was still reporting "Running..." at 10s when the assertion fired w. (no detail) failure list (#303); check_results body is unchanged — "Running..." doesn't match the 0 failures regex so it falls into the failure branch + raises AssertionError, which wait_for_slow naturally retries til the result settles or 60s elapses ; base._make_browser wraps webdriver.Firefox(options=options) in try/except WebDriverException w. one sleep+retry when "geckodriver" appears in the error message — covers the spawn race under --parallel where multiple workers hit the same binary mid-permission-set + one of them gets 'geckodriver' executable may have wrong permissions (1 test in #302 + 1 test in #303, different test classes each time, confirming infra not test-logic); narrow filter on "geckodriver" so a genuine install fault still fails fast — both attempts would surface the same error in <1s ; deferred option filed to memory (project_ci_remove_pip_install_deferred.md) — dropping the pip install -r requirements.dev.txt line from each FT step would save ~5 min/pipeline (CI image drifted from requirements.dev.txt since a21e6aa so the install actually downloads 30+ packages every step instead of the intended "already satisfied" no-op verify) but loses the dep-drift safety net; declined for now, revisit when wall-clock pain > safety value — TDD
FT flake mitigations triggered by pipelines #302/#303 — three independent fixes consolidated in one commit; test_admin_tarot._login_to_admin now waits on
"Site administration" body substring before returning, same shape as 054b0aa's test_admin.py fix — the helper used to click submit + return immediately, letting the three TarotAdminTest tests race their subsequent browser.get(/admin/epic/tarotcard/) against the in-flight POST → 302 → admin home navigation so on a slow CI runner the new GET cancelled the unfinished POST, the session cookie was never set, the browser landed back on /admin/login/?next=…, and the downstream assertion saw the login-page body ('Earthman Deck' not found in 'Django administration\nEmail:\nPassword:' in #303, plus a NoSuchElementException for "The Schiz" on test_admin_earthman_card_detail's link-text click) — wait_for w. assertIn retries til the post-login page actually renders so the rest of the helper's callers start from a settled state ; test_jasmine swaps wait_for(check_results) → wait_for_slow(check_results, timeout=60) — the spec suite has grown well past the 10s MAX_WAIT the default wait_for decorator affords + under CI contention (parallel Selenium workers competing for CPU on the same droplet) Jasmine's .jasmine-overall-result was still reporting "Running..." at 10s when the assertion fired w. (no detail) failure list (#303); check_results body is unchanged — "Running..." doesn't match the 0 failures regex so it falls into the failure branch + raises AssertionError, which wait_for_slow naturally retries til the result settles or 60s elapses ; base._make_browser wraps webdriver.Firefox(options=options) in try/except WebDriverException w. one sleep+retry when "geckodriver" appears in the error message — covers the spawn race under --parallel where multiple workers hit the same binary mid-permission-set + one of them gets 'geckodriver' executable may have wrong permissions (1 test in #302 + 1 test in #303, different test classes each time, confirming infra not test-logic); narrow filter on "geckodriver" so a genuine install fault still fails fast — both attempts would surface the same error in <1s ; deferred option filed to memory (project_ci_remove_pip_install_deferred.md) — dropping the pip install -r requirements.dev.txt line from each FT step would save ~5 min/pipeline (CI image drifted from requirements.dev.txt since a21e6aa so the install actually downloads 30+ packages every step instead of the intended "already satisfied" no-op verify) but loses the dep-drift safety net; declined for now, revisit when wall-clock pain > safety value — TDD
test_admin FT: wait on post-login content, not on
<body> itself — pipeline #300 caught the flake (AssertionError: 'Site administration' not found in 'Django administration\nToggle theme...\nEmail:\nPassword:'); the pre-fix body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) resolved on the FIRST <body> Selenium found — which exists on the login page too — so on a slow CI runner the form submit hadn't navigated yet by the time wait_for completed, the assertion ran against the still-stale login-page body, and the test failed; locally the submit always completes inside the wait window (10s MAX_WAIT) so this never reproduces; fix: wait_for the "Site administration" substring directly via assertIn (the wait decorator retries on AssertionError til MAX_WAIT, so the loop keeps polling the body's text content until the admin home page actually renders), THEN read body.text once + run the remaining Users / Tokens assertions inline — same shape as the working test_admin_tarot / test_admin_post_readonly login flows; 1 FT green locally w. no other changes — TDD
Baltimorean "Ard!" → "Baltimorean" rename across inline surfaces (banner / scroll line / my-notes Title row); navbar DON greeting keeps "Ayo, Ard!" as the sole Ard! flair — and a companion PronounsAppletFlowTest sync-point fix for the 200/Brief response path introduced by the Baltimorean unlock loop in
435a192 ; FT fix (functional_tests/test_game_kit.py) — PronounsAppletFlowTest.test_pronoun_flip_propagates_to_billscroll_and_most_recent was written pre-Baltimorean when set_pronouns always returned 204 → reload; the Baltimorean loop split the response into 200-w-brief (first-bawlmorese, no reload — Brief banner would be lost) vs 204-reload (other pronouns), so the test's step-4 wait_for(.gk-pronoun-card.active[data-pronoun='bawlmorese']) hung indefinitely on the Brief banner because the active class only updates after the reload that no longer happens; sync point swapped to wait_for(.note-banner) — the Brief banner's appearance is the natural post-commit signal that the server saved the pronoun (after which step-5/6 navigate to billboard + scroll to verify "yos" prose) ; rename core (drama/models.py) — Note.grant_if_new else branch's attr_combo inline attribution now uses note.display_name instead of note.display_title, so the scroll line reads "Look!—new Note unlocked. Baltimorean recognizes @disco the Baltimorean." instead of "…the Ard!." (for stargazer/schizo/nomad display_name == display_title so the line is unchanged; only baltimorean's two values diverge — display_title="Ard!" for navbar flair, display_name="Baltimorean" for everywhere else); Brief.title field also swapped from display_title to display_name so the banner title slot reads "Baltimorean" not "Ard!" — admin-grant branch (_ADMIN_NOTE_SLUGS: super-schizo, super-nomad) still uses display_title for the "honorary title of {X}" phrase because that branch DOES want the don-able title there ("Schizoid Man" / "Stranger") ; my-notes card "Title:" row rename — added card_title key to _NOTE_DISPLAY["baltimorean"] ({"greeting": "Ayo,", "title": "Ard!", "card_title": "Baltimorean"}) + new Note.card_title property that falls through _NOTE_DISPLAY[slug]["card_title"] then defaults to display_title; billboard/views.py _my_notes_context swaps "recognition_title": n.display_title → n.card_title so the my-notes Baltimorean card shows "Title: Baltimorean" instead of "Title: Ard!" — super-schizo's card still reads "Title: Schizoid Man" because card_title falls back to display_title for slugs w.o an override ; ONLY Ard! surface remaining: navbar DON greeting via User.active_title.display_title (lyric/models.py:158) — display_title itself was deliberately untouched so the navbar still flips Welcome, Earthman → Ayo, Ard! after DON, which is the entire point of the Baltimorean flair ; existing DB rows w. stored "the Ard!" Line.text + Brief.title="Ard!" from prior dev-DB grants will NOT update — those columns are persisted at grant_if_new time, not computed live; user has accepted dev-DB wipe-and-re-acquire as the path forward, so no data migration ; tests — drama/tests/unit/test_models.py +2 UTs (test_baltimorean_card_title_is_baltimorean pins the override, test_stargazer_card_title_falls_back_to_display_title pins the fallback for non-overridden slugs); drama/tests/integrated/test_note_brief.py test_first_grant_creates_post_line_and_brief updated assertion brief.title == note.display_name (was display_title) to reflect the new contract; dashboard/tests/integrated/test_views.py test_brief_payload_carries_baltimorean_title expects "Baltimorean" not "Ard!"; functional_tests/test_bill_baltimorean.py T1 banner title check swapped "Ard!" → "Baltimorean" + module docstring line 10 updated — T4 (navbar DON greeting flip to "Ayo, Ard!") preserved verbatim, the one surface where Ard! still lives ; full FT suite for baltimorean 6/6 green in 53s; drama + dashboard + billboard ITs/UTs 290 green in 16.5s — TDD
CI: FT stages run in parallel, --parallel dropped intra-stage — bud-btn click sites bypass scroll-into-view ; .woodpecker/main.yaml restructures the FT split — test-FTs-non-room + test-FTs-room now both
depends_on: test-two-browser-FTs (instead of room serially depending on non-room) so they fan out + run concurrently, each w. its own DATABASE_URL: sqlite:////tmp/test_db_{non_room,room}.sqlite3 outside the shared workspace mount so the two stages can't see each other's SQLite file + the #296 EOFError-on-half-created-test-db blocker is gone; --parallel flag dropped from both manage.py test invocations because the empirical wall-clock from #302-304 (151 tests in ~42 min, 83 tests in ~16 min — ~16-17s/test avg either way) shows ~1-1.5x speedup at best — Firefox spawn cost (~3-5s cold-start × 38 tests/worker) + RAM pressure (4 headless Firefoxes ≈ 1.5-2GB working set on a quadcore DO droplet) + SQLite file-lock contention eat most of the gain the cores would otherwise give, while the contention amplifies every transient-DOM flake we've spent the last 2 days chasing (login-race in #300/303 → 054b0aa + ad0041d, gecko-perms in #302/303 → ad0041d, ElementNotInteractable in #304 → this commit, Jasmine-timeout in #303 → ad0041d); stage-level parallelism gets back the wall-clock reduction (~16min savings vs serial stages) w.o. amplifying within-stage contention — net wall clock should be ~60-65min for both FT stages running concurrently vs ~58min today, but flake exposure drops dramatically; downstream screendumps + build-and-push already list both FT steps in their depends_on so they naturally gate on the slower of the two ; test_core_bud_btn.py wraps 4 unwrapped find_element(...).click() sites in wait_for(execute_script("arguments[0].click()", btn)) — the _open_panel_and_invite helper feeding 6 GatekeeperBudBtnAsyncInviteTest tests + the standalone GatekeeperBudBtnDuplicateInviteErrorTest test_duplicate_invite_shows_error_brief_and_fyi_flashes_slot click + both .note-banner--duplicate .note-banner__fyi clicks (one on the share-flow at line 433, one on the gatekeeper-invite-flow at line 622) — pipeline #304 errored on the OK button click w. ElementNotInteractableException: Element <button id="id_bud_ok"> could not be scrolled into view because the post-send_keys autocomplete dropdown on _bud_invite_panel.html briefly overlapped the OK button under CI contention so Firefox refused the scroll-into-view; same pattern + same fix shape as confirm_guard in base.py — execute_script bypasses Selenium's scroll-into-view gate entirely + wait_for absorbs any leftover transient state via WebDriverException retry ; commit only ships the test-side fix + CI restructure; if the CI changes work as intended (green pipeline #305) the docker-rebuild option for python-tdd-ci:latest still stands as the next ~5min/pipeline win, separately filed in project_ci_remove_pip_install_deferred.md — TDD
feat: wallet Shop polish — microtooltip extraction, Shop-first ordering, DRY tooltip styling, writs rebalance, "no expiry" on all items. Visual-pass tweaks landing atop the 5-chunk Shop rollout (commits
8e476f5 → d28cf7b). **Microtooltip extraction**: .tt-microbutton-portal (Chunk 4's wrap-inside-.tt) replaced w. a sibling .tt-micro div on each .shop-tile. wallet.js's initWalletTooltips clones BOTH into separate portals on hover — .tt → #id_tooltip_portal (main card), .tt-micro → #id_mini_tooltip_portal (small italic pill at bottom-right of main, mirroring Game Kit's Equipped/Unequipped/In-Use mini portal). Hover persistence covers both portals + the source tile w. a 200ms grace timer cancelled by mouseenter on any of the 3 zones. Capped items (BAND-owned) render NO btn at all — just "Already owned" microtext (mirrors Game Kit's status-only "Equipped" pill rather than the disabled-× pattern that lived in Chunk 4). **Tooltip-pin on guard open**: WalletTooltips.pin() / .unpin() exposed on window; wallet-shop.js's BUY click calls pin() before showGuard() + both onConfirm / onDismiss callbacks call unpin() → the item tooltip stays visible behind the guard's "Buy {name} for ${price}?" prompt instead of orphaning. **Shop-first applet ordering**: new Applet.display_order field (default 100, lower = earlier; PK tie-break preserves legacy insertion-order for the existing 3 applets); seed migration sets wallet-shop.display_order=10 so Shop renders atop Balances/Tokens/Payment. applet_context() updated to .order_by("display_order", "pk"). New WalletAppletOrderTest (2 ITs) pins Shop-first DOM order + view-context list. **DRY tooltip styling**: shop tooltip now uses the same 4-slot .tt-title / .tt-description / .tt-shoptalk / .tt-expiry classes as the Tokens row. New ShopItem.shoptalk field for the italic flavor line (band-1 = "Unlimited free entry (BYOB)" split out of description; tithes blank). New ShopItem.tooltip_expiry() method returns "no expiry" — eternal-stock convention (all current items; seasonal listings could override later). **Writs rebalance**: locked 2026-05-22 — tithe-1 144→12 writs, tithe-5 750→60 writs. Description text updated in lockstep ("1 Tithe Token + 12 Writs" / "5 Tithe Tokens + 60 Writs"). **Badge tweak**: ×N badge shrunk 2rem → 1.5rem + nudged further off-tile (top: -0.7rem, right: -1rem) so most of the underlying icon stays visible. **SCSS**: .tt-micro hidden in source DOM (portal-only); #id_mini_tooltip_portal mostly mirrors gameboard's mini at _gameboard.scss:140 but allows BUY-btn label to wrap onto multiple lines (white-space: normal on .tt-buy-btn); .tt-already-owned styled w. --secUser italic at 0.85rem to match Game Kit pills. **Migrations** — 5 new: lyric/0010_repricing_tithe_writs (writs + description), lyric/0011_shopitem_shoptalk (schema), lyric/0012_seed_shop_shoptalk (band split), applets/0012_applet_display_order (schema), applets/0013_wallet_shop_display_order (Shop atop). All idempotent. **TDD** — 5 new ITs across test_shop_models.py (shoptalk default + per-item assertions, tooltip_expiry method, updated tithe writs values, WalletAppletOrderTest), 1 new FT (test_shop_buy_guard_portal_pins_item_tooltip — programmatically dispatches mouseenter/mouseleave to exercise the pin/unpin race), 3 new Jasmine specs (T6 pin-on-click, T7 unpin-on-confirm, T8 unpin-on-dismiss). Existing FT band-owned assertion switched to .tt-micro (no .tt-buy-btn present), Jasmine T2 rewritten to assert no btn renders. **3 traps caught** mid-build: (a) multi-line {# #} comment leaked into DOM again (cf [[feedback-django-comments-single-line-only]]) — pinned the trap; (b) spyOn(window, 'fetch') Jasmine double-spy collision (cf trapped previously); (c) async pollution where afterEach restores window.Stripe=undefined before _doBuy's continuation hits it — fixed by per-test never-resolving fetch mock. 1211 IT/UT + 9 wallet FTs green; Jasmine SpecRunner verified visually (FT hangs Selenium-side on spec count). Pipeline will sweep all FTs
Baltimorean "Ard!" → "Baltimorean" rename across inline surfaces (banner / scroll line / my-notes Title row); navbar DON greeting keeps "Ayo, Ard!" as the sole Ard! flair — and a companion PronounsAppletFlowTest sync-point fix for the 200/Brief response path introduced by the Baltimorean unlock loop in
435a192 ; FT fix (functional_tests/test_game_kit.py) — PronounsAppletFlowTest.test_pronoun_flip_propagates_to_billscroll_and_most_recent was written pre-Baltimorean when set_pronouns always returned 204 → reload; the Baltimorean loop split the response into 200-w-brief (first-bawlmorese, no reload — Brief banner would be lost) vs 204-reload (other pronouns), so the test's step-4 wait_for(.gk-pronoun-card.active[data-pronoun='bawlmorese']) hung indefinitely on the Brief banner because the active class only updates after the reload that no longer happens; sync point swapped to wait_for(.note-banner) — the Brief banner's appearance is the natural post-commit signal that the server saved the pronoun (after which step-5/6 navigate to billboard + scroll to verify "yos" prose) ; rename core (drama/models.py) — Note.grant_if_new else branch's attr_combo inline attribution now uses note.display_name instead of note.display_title, so the scroll line reads "Look!—new Note unlocked. Baltimorean recognizes @disco the Baltimorean." instead of "…the Ard!." (for stargazer/schizo/nomad display_name == display_title so the line is unchanged; only baltimorean's two values diverge — display_title="Ard!" for navbar flair, display_name="Baltimorean" for everywhere else); Brief.title field also swapped from display_title to display_name so the banner title slot reads "Baltimorean" not "Ard!" — admin-grant branch (_ADMIN_NOTE_SLUGS: super-schizo, super-nomad) still uses display_title for the "honorary title of {X}" phrase because that branch DOES want the don-able title there ("Schizoid Man" / "Stranger") ; my-notes card "Title:" row rename — added card_title key to _NOTE_DISPLAY["baltimorean"] ({"greeting": "Ayo,", "title": "Ard!", "card_title": "Baltimorean"}) + new Note.card_title property that falls through _NOTE_DISPLAY[slug]["card_title"] then defaults to display_title; billboard/views.py _my_notes_context swaps "recognition_title": n.display_title → n.card_title so the my-notes Baltimorean card shows "Title: Baltimorean" instead of "Title: Ard!" — super-schizo's card still reads "Title: Schizoid Man" because card_title falls back to display_title for slugs w.o an override ; ONLY Ard! surface remaining: navbar DON greeting via User.active_title.display_title (lyric/models.py:158) — display_title itself was deliberately untouched so the navbar still flips Welcome, Earthman → Ayo, Ard! after DON, which is the entire point of the Baltimorean flair ; existing DB rows w. stored "the Ard!" Line.text + Brief.title="Ard!" from prior dev-DB grants will NOT update — those columns are persisted at grant_if_new time, not computed live; user has accepted dev-DB wipe-and-re-acquire as the path forward, so no data migration ; tests — drama/tests/unit/test_models.py +2 UTs (test_baltimorean_card_title_is_baltimorean pins the override, test_stargazer_card_title_falls_back_to_display_title pins the fallback for non-overridden slugs); drama/tests/integrated/test_note_brief.py test_first_grant_creates_post_line_and_brief updated assertion brief.title == note.display_name (was display_title) to reflect the new contract; dashboard/tests/integrated/test_views.py test_brief_payload_carries_baltimorean_title expects "Baltimorean" not "Ard!"; functional_tests/test_bill_baltimorean.py T1 banner title check swapped "Ard!" → "Baltimorean" + module docstring line 10 updated — T4 (navbar DON greeting flip to "Ayo, Ard!") preserved verbatim, the one surface where Ard! still lives ; full FT suite for baltimorean 6/6 green in 53s; drama + dashboard + billboard ITs/UTs 290 green in 16.5s — TDD
FT flake mitigations triggered by pipelines #302/#303 — three independent fixes consolidated in one commit; test_admin_tarot._login_to_admin now waits on
"Site administration" body substring before returning, same shape as 054b0aa's test_admin.py fix — the helper used to click submit + return immediately, letting the three TarotAdminTest tests race their subsequent browser.get(/admin/epic/tarotcard/) against the in-flight POST → 302 → admin home navigation so on a slow CI runner the new GET cancelled the unfinished POST, the session cookie was never set, the browser landed back on /admin/login/?next=…, and the downstream assertion saw the login-page body ('Earthman Deck' not found in 'Django administration\nEmail:\nPassword:' in #303, plus a NoSuchElementException for "The Schiz" on test_admin_earthman_card_detail's link-text click) — wait_for w. assertIn retries til the post-login page actually renders so the rest of the helper's callers start from a settled state ; test_jasmine swaps wait_for(check_results) → wait_for_slow(check_results, timeout=60) — the spec suite has grown well past the 10s MAX_WAIT the default wait_for decorator affords + under CI contention (parallel Selenium workers competing for CPU on the same droplet) Jasmine's .jasmine-overall-result was still reporting "Running..." at 10s when the assertion fired w. (no detail) failure list (#303); check_results body is unchanged — "Running..." doesn't match the 0 failures regex so it falls into the failure branch + raises AssertionError, which wait_for_slow naturally retries til the result settles or 60s elapses ; base._make_browser wraps webdriver.Firefox(options=options) in try/except WebDriverException w. one sleep+retry when "geckodriver" appears in the error message — covers the spawn race under --parallel where multiple workers hit the same binary mid-permission-set + one of them gets 'geckodriver' executable may have wrong permissions (1 test in #302 + 1 test in #303, different test classes each time, confirming infra not test-logic); narrow filter on "geckodriver" so a genuine install fault still fails fast — both attempts would surface the same error in <1s ; deferred option filed to memory (project_ci_remove_pip_install_deferred.md) — dropping the pip install -r requirements.dev.txt line from each FT step would save ~5 min/pipeline (CI image drifted from requirements.dev.txt since a21e6aa so the install actually downloads 30+ packages every step instead of the intended "already satisfied" no-op verify) but loses the dep-drift safety net; declined for now, revisit when wall-clock pain > safety value — TDD