recon_gen.common.browser.helpers

Helpers for driving QuickSight dashboards in a Playwright browser.

Used by both the e2e test suite (tests/e2e/test_*.py) and production CLI code (the screenshot pipeline that renders handbook images against a deployed dashboard). Promoted out of tests/e2e/ in M.1.10 so production no longer has to import from tests/.

The QuickSight identity region (us-east-1) is where embed URL generation and user operations live, even when the dashboard itself is deployed in another region.

Functions

bump_table_page_size_to_10000(page, ...)

Click the visual's page-size dropdown → 10000, no scrolling or counting.

clear_dropdown(page, control_title, timeout_ms)

Reset a FilterControl to its "all values" default.

click_context_menu_item(page, item_text, ...)

Click an entry in QuickSight's data-point context menu by visible text.

click_first_row_of_visual(page, ...)

Click the first data cell (row 0, col 0) of the named visual.

click_sheet_tab(page, name, timeout_ms)

Activate a sheet tab by its visible name and wait for the switch.

count_chart_categories(page, visual_title)

Count distinct categorical entries (bars / slices) in a chart.

count_table_rows(page, visual_title)

Count distinct table rows in the visual whose title matches.

count_table_total_rows(page, visual_title, ...)

Return the full (post-filter) row count of a QS table visual via the scroll-accumulate dance.

find_row_in_table_via_scroll(page, ...)

AA.A.l2ft-rails-inverse.2.e — return the first row whose visible cells subset-match predicate (header → value), walking the scroll-accumulate dance so virtualized rows below the fold get mounted before they're checked.

generate_dashboard_embed_url(*, ...[, ...])

Generate a pre-authenticated embed URL for a dashboard.

get_sheet_tab_names(page)

Return the visible sheet tab labels in order.

get_user_arn()

Return the QuickSight user ARN to embed dashboards for.

get_visual_titles(page)

Return the title text of every visual currently on the page.

read_chart_categories(page, visual_title)

Return the ordered category labels (bar names / slice names) of a chart visual, parsed from QS's screen-reader aria-label.

read_dropdown_options(page, control_title, ...)

Return the data-value option labels in a FilterControl dropdown.

read_kpi_value(page, visual_title, *[, ...])

Return the displayed big-number text of a KPI visual.

read_table_rows_dom(page, visual_title)

Read the DOM-visible rows of a QuickSight Table visual as a list of dicts keyed by column-header text, in display order.

read_table_rows_via_scroll(page, visual_title)

De-virtualized table read — scroll through the table and accumulate every row that mounts.

record_sql_trace(label, sql, summary)

Append one SQL-query entry to <capture_dir>/sql_trace.txt.

right_click_first_row_of_visual(page, ...)

Right-click the first data cell of the named visual.

screenshot(page, name[, subdir])

Save a screenshot under tests/e2e/screenshots/[subdir/].

scroll_visual_into_view(page, visual_title, ...)

Scroll the visual with the given title to the viewport center.

set_date_range(page, start, end, timeout_ms)

Fill the two date-range pickers and commit each with Enter.

set_dropdown_value(page, control_title, ...)

Pick a single value from a SINGLE_SELECT FilterControl by title.

set_multi_select_values(page, control_title, ...)

Pick one or more values from a MULTI_SELECT FilterControl by title.

set_parameter_datetime_value(page, ...)

Fill a single ParameterDateTimePicker control by its title.

set_parameter_slider_value(page, ...)

Set a single-value ParameterSliderControl by its title.

sheet_control_titles(page)

Return the visible titles of filter controls on the active sheet.

table_is_paginated(page, visual_title)

Cheap DOM probe — does this visual have a QS pagination control?

trigger_failure_capture(page, *[, test_id, cfg])

Public-API capture trigger for the pytest-fixture path.

visual_error_text(page, visual_title)

AA.A.8 — cheap DOM probe — does this visual show a QS error overlay?

visual_is_empty(page, visual_title)

Cheap DOM probe — does this visual show QS's empty-state overlay?

wait_for_dashboard_loaded(page, timeout_ms)

Wait for the QuickSight dashboard chrome (sheet tabs) to appear.

wait_for_dropdown_options_present(page, ...)

Poll read_dropdown_options until it returns at least one option (or timeout).

wait_for_table_rows_to_change(page, ...)

Poll a table visual's row count until it differs from before.

wait_for_visual_titles_present(page, ...)

Block until every title in expected_titles is rendered as an analysis_visual_title_label.

wait_for_visuals_present(page, min_count, ...)

Wait until at least min_count visual containers are rendered.

webkit_page([headless, viewport])

Yield a Playwright WebKit page; tears down browser on exit.

recon_gen.common.browser.helpers.bump_table_page_size_to_10000(page, visual_title, timeout_ms)[source]

Click the visual’s page-size dropdown → 10000, no scrolling or counting. Returns True if the click sequence completed; False if the pagination dropdown never appeared (table isn’t actually paginated — use table_is_paginated to pre-check and skip the call entirely).

Does not wait for the post-bump re-fetch. QS fires a fresh data query when page size changes; caller must settle that re-fetch separately (via QsEmbedDriver._settle_after_param_change, which keys off the WebSocket START/STOP frames — not a fixed wait_for_timeout). The original implementation’s wait_for_timeout(500) here was the AA.H.11 race trigger: 500 ms wasn’t enough for the re-fetch to land on cold visuals, and the follow-up scroll-accumulate dance read the empty container.

The focus-click on the visual title (required to reveal the pagination control) is the same JS-dispatched .click() pattern used elsewhere — bypasses Playwright’s actionability checks that race against QS’s re-render churn.

Return type:

bool

Parameters:
  • page (Page)

  • visual_title (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.clear_dropdown(page, control_title, timeout_ms)[source]

Reset a FilterControl to its “all values” default.

Opens the dropdown and clicks the “Select all” / “All” entry. Works for both SINGLE_SELECT and MULTI_SELECT controls — QuickSight uses the same listbox markup for both.

Return type:

None

Parameters:
  • page (Page)

  • control_title (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.click_context_menu_item(page, item_text, timeout_ms)[source]

Click an entry in QuickSight’s data-point context menu by visible text.

QS’s right-click menu mounts as a portal with each entry as a [role="menuitem"]. The drill action’s Name parameter from the Python builder appears verbatim as the menu item’s text.

Return type:

None

Parameters:
  • page (Page)

  • item_text (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.click_first_row_of_visual(page, visual_title, timeout_ms)[source]

Click the first data cell (row 0, col 0) of the named visual.

Tags the cell with a unique data-e2e-target attribute first so the click selector is unambiguous even when multiple tables share the same global sn-table-cell-0-0. Clears the marker after so subsequent calls don’t pick up a stale target.

Return type:

None

Parameters:
  • page (Page)

  • visual_title (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.click_sheet_tab(page, name, timeout_ms)[source]

Activate a sheet tab by its visible name and wait for the switch.

QuickSight tears down the prior sheet’s visuals on switch. We snapshot the current visual titles before the click, then wait for them to be replaced — otherwise a wait that just checks “≥ N visuals present” can be satisfied by the prior sheet.

Return type:

None

Parameters:
  • page (Page)

  • name (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.count_chart_categories(page, visual_title)[source]

Count distinct categorical entries (bars / slices) in a chart.

QS renders charts to <canvas>, so there are no DOM bars/slices to count directly. Two signals we can read:

  1. Chart aria-label: QS publishes a screen-reader description like “This is a chart with type Bar chart … the data for X is Y, the data for Z is W, …”. Counting the data for occurrences yields the category count reliably (works for bar + line + pie).

  2. Legend rows (data-automation-id="visual_legend_item_value"): present on pie/donut charts and any chart with a legend.

Returns the max of the two signals, or -1 if the visual isn’t found. Use to assert change, not exact value (chart may hide low-freq series).

Return type:

int

Parameters:
  • page (Page)

  • visual_title (str)

recon_gen.common.browser.helpers.count_table_rows(page, visual_title)[source]

Count distinct table rows in the visual whose title matches.

Returns -1 if no visual with that title is on the page. Returns 0 if the visual is present but empty. Caller is responsible for ensuring the visual is hydrated (use scroll_visual_into_view for below-the-fold tables).

Return type:

int

Parameters:
  • page (Page)

  • visual_title (str)

recon_gen.common.browser.helpers.count_table_total_rows(page, visual_title, timeout_ms)[source]

Return the full (post-filter) row count of a QS table visual via the scroll-accumulate dance.

QS tables virtualize — count_table_rows only sees the ~10 rows currently mounted in the DOM. For filter-narrowing assertions where both pre and post totals exceed the viewport, DOM counts stay flat and the assertion silently passes. This helper:

  1. Scrolls the inner .grid-container to the bottom, tracking the highest sn-table-cell-N-* index seen.

Return type:

int

Returns:

-1 if the visual isn’t on the page. -2 if the visual is present but has no .grid-container (e.g. a one-row table that QS renders without a scroll container). The post-bump count otherwise (max + 1, or 0 if no cells).

Parameters:
  • page (Page)

  • visual_title (str)

  • timeout_ms (int)

Caller must page-size-bump first for tables exceeding the QS default page size (~10–15 rows) — this helper only scrolls, no longer bumps (the bump was extracted to bump_table_page_size_to_10000). For small tables (no pagination control), count_table_rows is the exact total — pre-check via table_is_paginated and skip the bump + scroll entirely.

Pre-AA.H.11 this helper bundled the focus-click + page-size-bump + scroll into one call with a fixed wait_for_timeout(500) after the bump. The 500 ms wasn’t enough for the post-bump re-fetch to land, so the scroll read an empty container and returned 0 — causing the audit-agreement test to report qs_count=0 on cold sheets where the table actually had 2+ rows (verified via screenshot + DOM capture). The fix split the orchestration so callers can:

  1. Cheap-path-skip via table_is_paginated (no clicks, no risk).

  2. Bump + WS-settle deterministically when overflow is real.

  3. Read via the scroll-accumulate dance below.

recon_gen.common.browser.helpers.find_row_in_table_via_scroll(page, visual_title, predicate)[source]

AA.A.l2ft-rails-inverse.2.e — return the first row whose visible cells subset-match predicate (header → value), walking the scroll-accumulate dance so virtualized rows below the fold get mounted before they’re checked. Returns None if no row matches after scrolling to the bottom (or 500 stable steps).

Use case: the inverse-picker test asks “is there ANY row in the post-pick result that still has the anchor’s value for the toggled column?”. Early-exits the scroll loop on first match — typical “yes, broken filter” returns in <500 ms; “no, anchor excluded” walks to the bottom (~1-3s on a 100-row table at 120ms/step).

The membership check IS the contract — count-based assertions can’t tell apart “filter narrowed” from “filter returned different rows for unrelated reasons”, but “is the anchor’s row excluded?” is the actual question the picker test wants to answer.

Returns None if the visual isn’t on the page or has no .grid-container. The predicate’s keys must match column- header display labels (use visual_column_label to resolve SQL column names → display labels).

Return type:

dict[str, str] | None

Parameters:
  • page (Page)

  • visual_title (str)

  • predicate (dict[str, str])

recon_gen.common.browser.helpers.generate_dashboard_embed_url(*, aws_account_id, aws_region, dashboard_id, user_arn=None, session_lifetime_minutes=60)[source]

Generate a pre-authenticated embed URL for a dashboard.

Builds a boto3 QuickSight client in aws_region (the dashboard’s region) and signs the URL with it. Embed URLs MUST be signed by a client whose region matches the dashboard’s region — using the identity region (us-east-1) for a dashboard deployed elsewhere returns a URL QuickSight rejects with “We can’t open that dashboard, another Quick account or it was deleted” — a confusing error that suggests permission/account/deletion when the actual cause is region mismatch. The M.4.1.i first AWS-side dry-run burned an hour on this when the harness called this helper with the identity-region client.

Earlier the signature took a pre-built client which made it possible to pass the wrong region’s client. This version requires callers to pass aws_region and constructs the client itself, making the bug class unrepresentable.

All args keyword-only — protects against positional-arg drift if the parameter list ever changes again.

Return type:

str

Parameters:
  • aws_account_id (str)

  • aws_region (str)

  • dashboard_id (str)

  • user_arn (str | None)

  • session_lifetime_minutes (int)

recon_gen.common.browser.helpers.get_sheet_tab_names(page)[source]

Return the visible sheet tab labels in order.

Return type:

list[str]

Parameters:

page (Page)

recon_gen.common.browser.helpers.get_user_arn()[source]

Return the QuickSight user ARN to embed dashboards for.

Reads RECON_E2E_USER_ARN. Raises RuntimeError when unset — the previous silent fallback to a hardcoded account-specific ARN string masked CI misconfiguration (Phase W’s ci-bot user has a different ARN than the local-dev default; the fallback produced an embed URL the bot couldn’t view) and burned a project AWS account ID into the source. Fail-loud is the contract.

Return type:

str

recon_gen.common.browser.helpers.get_visual_titles(page)[source]

Return the title text of every visual currently on the page.

Return type:

list[str]

Parameters:

page (Page)

recon_gen.common.browser.helpers.read_chart_categories(page, visual_title)[source]

Return the ordered category labels (bar names / slice names) of a chart visual, parsed from QS’s screen-reader aria-label.

QS aria-labels a chart container with “…the data for <CAT> is <N>, the data for <CAT> is <N>, …”. Parse that into an ordered list. Returns [] if the visual isn’t found or has no aria description.

Return type:

list[str]

Parameters:
  • page (Page)

  • visual_title (str)

recon_gen.common.browser.helpers.read_dropdown_options(page, control_title, timeout_ms)[source]

Return the data-value option labels in a FilterControl dropdown.

Opens the dropdown for control_title, reads every [role="option"] label, dismisses the popover, and returns the list with sentinel entries filtered out ("Select all", "All", blanks). Used by data-agnostic e2e tests that need to pick a valid value from the dropdown without hardcoding what the values are — e.g., “pick the first selectable value to narrow the table.”

Return type:

list[str]

Parameters:
  • page (Page)

  • control_title (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.read_kpi_value(page, visual_title, *, timeout_ms=8000)[source]

Return the displayed big-number text of a KPI visual.

QS renders the value inside .visual-x-center (the actual text node). The kpi-display-value automation-id wraps the container but its innerText sometimes includes the comparison label — prefer the center node and fall back to the automation-id if unavailable.

Stability poll: returns only when 2 consecutive reads (200ms apart) match. Handles both:

  • First-paint race (BO.1 test_bg3_drift_sheet_kpis_match_matview_counts fix): caller’s wait_loaded(…) may settle one visual while sibling KPIs on the same sheet are still rendering; poll past the None window until the value appears stable.

  • Post-param-change stale-read (BO.1 test_bg2_daily_statement_kpis_match_summary_matview fix): after a date pick, QS’s WS settle may return before the KPI cell repaints — DOM transiently still shows the previous day’s value. Stability poll waits for the new value to land.

Raises AssertionError if the value never reaches a stable state within the timeout.

Return type:

str

Parameters:
  • page (Page)

  • visual_title (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.read_table_rows_dom(page, visual_title)[source]

Read the DOM-visible rows of a QuickSight Table visual as a list of dicts keyed by column-header text, in display order.

QS virtualizes — only ~10 rows are in the DOM at once — so this returns that window, not necessarily the whole table. Caller is responsible for getting the table on screen (use scroll_visual_into_view first; bump the page size if the full table is needed). Returns [] if the visual isn’t found or has no body cells (empty table / still loading).

Column headers come from the [data-automation-id="sn-table-column-N"] divs (their .title span — the visible header text); body cells from sn-table-cell-{row}-{col}. Headers and cells are zipped by position — the Nth header (left-to-right in the DOM) pairs with the Nth cell (smallest col first) in each row — so it’s robust to QS’s internal column-index numbering (the header’s sn-table-column-N and the body’s sn-table-cell-r-c use different N/c origins).

Return type:

list[dict[str, str]]

Parameters:
  • page (Page)

  • visual_title (str)

recon_gen.common.browser.helpers.read_table_rows_via_scroll(page, visual_title)[source]

De-virtualized table read — scroll through the table and accumulate every row that mounts. Returns a list of dicts keyed by column-header text, in row-index order across the full dataset.

QS virtualizes ~10-37 rows in the DOM at a time. read_table_rows_dom captures only what’s currently mounted; for tables with N > window, callers need this scroll-collect shape to get every row. Used by l1_invariant_rows_seen post-BO.1 fix: row-identity checks against a 119-row overdraft table were comparing a 37-row DOM window to the page-size-bumped count of 119 and failing the assertion.

Walks the same scroll-accumulate dance as find_row_in_table_via_scroll but returns the ACCUMULATED row map instead of early-exiting on the first predicate match. Dedupes by the absolute row index in sn-table-cell-{row}-{col} (QS uses an absolute index across the whole dataset, not the window-relative index, so newly-mounted rows have row indices we haven’t seen yet).

Returns [] if the visual isn’t found or has no body cells. Bounded by 500 scroll steps + the table’s natural scroll-bottom.

Return type:

list[dict[str, str]]

Parameters:
  • page (Page)

  • visual_title (str)

recon_gen.common.browser.helpers.record_sql_trace(label, sql, summary)[source]

Append one SQL-query entry to <capture_dir>/sql_trace.txt.

Browser-test helpers (fetch_anchor_row, future picker-universe probes) call this after executing a query so the failure-capture bundle records what the test asked the DB for alongside the DOM + db_counts artifacts. When a dropdown later shows MuiAutocomplete-noOptions for a value the test typed, the triage answers “did the DB return that value” by reading sql_trace.txt instead of re-running the queries by hand.

Format per entry (append-mode):

## <label> – ran at <iso-timestamp> <sql> — <summary>

Test ID resolves from PYTEST_CURRENT_TEST — runs outside pytest no-op the write. Sidecar contract: swallow exceptions; capture failures must never mask the original test failure.

Return type:

None

Parameters:
  • label (str)

  • sql (str)

  • summary (str)

recon_gen.common.browser.helpers.right_click_first_row_of_visual(page, visual_title, timeout_ms)[source]

Right-click the first data cell of the named visual.

Mirror of click_first_row_of_visual but dispatches a contextmenu event so QuickSight opens the visual’s DATA_POINT_MENU drill list. Tags the cell with data-e2e-target first so the click target is unambiguous when multiple tables share the same global cell selectors.

Return type:

None

Parameters:
  • page (Page)

  • visual_title (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.screenshot(page, name, subdir=None)[source]

Save a screenshot under tests/e2e/screenshots/[subdir/].

Pass subdir to namespace outputs per-app (e.g. "payment_recon" or "account_recon") so the two apps’ screenshots don’t overwrite each other when they happen to share a test name.

Return type:

Path

Parameters:
  • page (Page)

  • name (str)

  • subdir (str | None)

recon_gen.common.browser.helpers.scroll_visual_into_view(page, visual_title, timeout_ms, *, wait_for_cells=True)[source]

Scroll the visual with the given title to the viewport center.

QuickSight virtualizes below-the-fold visuals — table cells are absent from the DOM until the visual is on screen. Browser tests that click into such a table must call this first, or the click-target selector will return nothing.

Pass wait_for_cells=False for chart visuals (bar / pie / line), which don’t render sn-table-cell-* markers and would otherwise time out.

Return type:

None

Parameters:
  • page (Page)

  • visual_title (str)

  • timeout_ms (int)

  • wait_for_cells (bool)

recon_gen.common.browser.helpers.set_date_range(page, start, end, timeout_ms, picker_indices=(0, 1))[source]

Fill the two date-range pickers and commit each with Enter.

start / end use QuickSight’s accepted text format (YYYY/MM/DD). picker_indices defaults to (0, 1) — the first date-range control on the active sheet. Override when a sheet has multiple ranges.

For parameter-driven date pickers (ParameterDateTimePicker, not FilterDateTimePicker.type=DATE_RANGE), use set_parameter_datetime_value() instead — those render as separate sheet controls each with their own scoped DOM, not a shared 0-and-1-indexed range widget.

Return type:

None

Parameters:
  • page (Page)

  • start (str)

  • end (str)

  • timeout_ms (int)

  • picker_indices (tuple[int, int])

recon_gen.common.browser.helpers.set_dropdown_value(page, control_title, value, timeout_ms)[source]

Pick a single value from a SINGLE_SELECT FilterControl by title.

Opens the dropdown for control_title and clicks the option whose text equals value. Use clear_dropdown to reset to “All”. Dismisses the popover with Escape so subsequent visual interactions aren’t blocked by the listbox overlay.

Handles both _open_control_dropdown variants transparently:

  • Simple variant — listbox is fully rendered on open; has_text finds the option directly.

  • Search-enabled variant (MUI Autocomplete) — options are virtualized and value may live outside the rendered window. Type value into the autocomplete search input first so the Autocomplete narrows to the matching subset, then click. This is the operator’s actual flow for an 8000-option dropdown (you can’t scroll to your target — you type to find it). Per AA.H.8 the driver carries this dance so every pick_filter / set_dropdown_value caller works against both variants without renderer-specific code in the test.

Return type:

None

Parameters:
  • page (Page)

  • control_title (str)

  • value (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.set_multi_select_values(page, control_title, values, timeout_ms)[source]

Pick one or more values from a MULTI_SELECT FilterControl by title.

Deselects any currently-checked options first (via the option’s aria-selected state), then ticks only the requested values. Commits by pressing Escape to dismiss the popover.

Return type:

None

Parameters:
  • page (Page)

  • control_title (str)

  • values (list[str])

  • timeout_ms (int)

recon_gen.common.browser.helpers.set_parameter_datetime_value(page, control_title, value, timeout_ms)[source]

Fill a single ParameterDateTimePicker control by its title.

Each ParameterDateTimePicker on a sheet renders as its own sheet_control card scoped by data-automation-context to the control’s title. The date input lives at data-automation-id="date_picker_0" within that card. Targeting by title avoids the cross-control collision (each card has its own locally-indexed picker).

value uses QuickSight’s accepted text format (YYYY/MM/DD).

Return type:

None

Parameters:
  • page (Page)

  • control_title (str)

  • value (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.set_parameter_slider_value(page, control_title, value, timeout_ms)[source]

Set a single-value ParameterSliderControl by its title.

QS renders each ParameterSliderControl as its own sheet_control card scoped by data-automation-context to the control title. The card carries an MUI slider (a draggable thumb — fragile to drive pixel-accurately in Playwright) AND a typable text box that commits the value when it loses focus (typing alone doesn’t take effect). So the reliable path: find the one non-hidden <input> in the card (the MUI slider’s own <input type="hidden"> value carrier is the other one), fill it, blur it.

value is the numeric slider position — an int for the typical step-1 control; rendered without a trailing .0.

Return type:

None

Parameters:
  • page (Page)

  • control_title (str)

  • value (float)

  • timeout_ms (int)

recon_gen.common.browser.helpers.sheet_control_titles(page)[source]

Return the visible titles of filter controls on the active sheet.

Return type:

list[str]

Parameters:

page (Page)

recon_gen.common.browser.helpers.table_is_paginated(page, visual_title)[source]

Cheap DOM probe — does this visual have a QS pagination control?

Returns True iff a simplePagedDisplayNav_dropdown_pageSize element exists in the DOM, scoped to the visual whose title matches. Small tables (≤ QS default page size, typically ~10–15 rows) render without pagination — the DOM holds every row, so count_table_rows is the exact total and no bump is needed.

This is a pure read — no clicks, no timeouts, no re-render risk. Critical for callers that previously triggered a spurious re-fetch by clicking the page-size dropdown on small tables that didn’t need it (the AA.H.11 root cause: the click → re-fetch → getMaxRow read the empty container mid-refetch → returned 0 even though cells were about to mount).

Return type:

bool

Parameters:
  • page (Page)

  • visual_title (str)

recon_gen.common.browser.helpers.trigger_failure_capture(page, *, test_id=None, cfg=None)[source]

Public-API capture trigger for the pytest-fixture path.

Pytest’s yield-fixture semantics don’t re-throw test-body exceptions back into the fixture’s generator — so webkit_page’s except BaseException: handler never fires for a typical e2e test failure. The fixture’s teardown (post-yield code, after consulting request.node.rep_call.failed via the standard pytest_runtest_makereport hook) calls this function instead to drop the same artifacts.

Reads the sinks webkit_page attached to the page (_qs_gen_console_sink, _qs_gen_network_sink, _qs_gen_test_id). Falls back to _test_id_from_pytest_env() when no test_id is passed and no page-attached default exists.

cfg is the Config dataclass; when present, also dumps db_counts.txt (per-table row counts for every relation matching cfg.db_table_prefix_*). Pass None to skip — the QS-side artifacts still land. Conftest’s maybe_capture_on_failure resolves cfg from the cfg fixture and forwards it.

Sets page._qs_gen_capture_triggered = True so webkit_page’s finally block knows to save the trace.zip (otherwise the trace would only land when the exception bubbled through the except).

Idempotent — calling twice with the same test_id just overwrites the artifacts (last call wins).

Return type:

None

Parameters:
  • page (Page)

  • test_id (str | None)

  • cfg (object | None)

recon_gen.common.browser.helpers.visual_error_text(page, visual_title)[source]

AA.A.8 — cheap DOM probe — does this visual show a QS error overlay?

QuickSight surfaces per-visual rendering failures via an error overlay scoped inside the [data-automation-id="analysis_visual"] container — e.g. the “your tabular report contains duplicate columns. To proceed, remove all duplicates.” message that surfaces on Pending Aging’s Stuck Pending Detail today (the AA.A.8.bug case). Returns the overlay’s text content (concatenated across matched nodes) when an error is present; None when the visual is mounted without errors OR when the visual isn’t found.

Use this in DashboardDriver.wait_loaded to HARD-FAIL on error overlays — pre-AA.A.8 a broken visual would silently time out the cell/empty-overlay predicate and the caller had to wait the full visual timeout before getting a generic “didn’t render” failure (with no error text). After AA.A.8: the operator gets the actual QS error string in the test failure message, fast.

Selector union mirrors _capture_failure_qs_errors (the post- failure forensics path) but scopes to the named visual container — that’s the meaningful axis for “did THIS visual fail to render”.

Return type:

str | None

Parameters:
  • page (Page)

  • visual_title (str)

recon_gen.common.browser.helpers.visual_is_empty(page, visual_title)[source]

Cheap DOM probe — does this visual show QS’s empty-state overlay?

QS mounts a [data-automation-id="visual-overlay-title"] element with data-automation-context="No data" inside any visual whose backing dataset returned zero rows (table / Sankey / chart / KPI). The overlay mounts at the same time the visual’s frame renders — typically within a few hundred ms of the sheet load — so a positive return from this probe is high-signal: the visual is mounted AND confirmed empty, no row-fetch race to wait out.

Use this BEFORE scroll_visual_into_view to short-circuit empty cases. scroll_visual_into_view’s wait_for_cells=True mode waits for sn-table-cell-0-0 to appear, which empty tables never mount — without this probe the helper times out at timeout_ms on every empty visual. Returns False if the visual isn’t found (let downstream selectors raise the “no visual” error with their own context).

Return type:

bool

Parameters:
  • page (Page)

  • visual_title (str)

recon_gen.common.browser.helpers.wait_for_dashboard_loaded(page, timeout_ms)[source]

Wait for the QuickSight dashboard chrome (sheet tabs) to appear.

Polls for [role="tab"] directly. Don’t wait for networkidle first — QS holds open WebSocket / long-polling connections that keep the network busy indefinitely, so networkidle never fires and a 120s timeout burns waiting for an event that won’t happen. The sheet-tab strip attaching to the DOM is the authoritative “chrome is up” signal; in practice it appears within ~1s of the embed URL load completing.

Return type:

None

Parameters:
  • page (Page)

  • timeout_ms (int)

recon_gen.common.browser.helpers.wait_for_dropdown_options_present(page, dropdown_title, timeout_ms)[source]

Poll read_dropdown_options until it returns at least one option (or timeout).

Replaces fixed-sleep waits in cascade-dropdown tests where one parameter pick (e.g., a Metadata Key) repopulates a downstream dropdown’s options (e.g., Metadata Value). The dropdown is closed between reads, so this is a Python-side retry loop rather than a page.wait_for_function JS poll. Fail-fasts on a real “options never populate” regression; tight on the happy path.

Return type:

list[str]

Parameters:
  • page (Page)

  • dropdown_title (str)

  • timeout_ms (int)

recon_gen.common.browser.helpers.wait_for_table_rows_to_change(page, visual_title, before, timeout_ms)[source]

Poll a table visual’s row count until it differs from before.

Returns the new row count. Raises a Playwright timeout if the count never changes. Use this after triggering a filter / drill action so the test doesn’t sleep blindly.

Return type:

int

Parameters:
  • page (Page)

  • visual_title (str)

  • before (int)

  • timeout_ms (int)

recon_gen.common.browser.helpers.wait_for_visual_titles_present(page, expected_titles, timeout_ms)[source]

Block until every title in expected_titles is rendered as an analysis_visual_title_label. Visual containers attach before their title labels hydrate, so a simple container count isn’t enough when the test asserts on specific titles.

Return type:

None

Parameters:
  • page (Page)

  • expected_titles (list[str])

  • timeout_ms (int)

recon_gen.common.browser.helpers.wait_for_visuals_present(page, min_count, timeout_ms)[source]

Wait until at least min_count visual containers are rendered.

Returns the actual count observed.

Return type:

int

Parameters:
  • page (Page)

  • min_count (int)

  • timeout_ms (int)

recon_gen.common.browser.helpers.webkit_page(headless=True, viewport=(1600, 1000))[source]

Yield a Playwright WebKit page; tears down browser on exit.

On exception inside the with body, captures six diagnostics per failing test:

  • screenshot.png (or <test_id>.png in legacy mode) — full-page screenshot of the failure state

  • dom.html — serialized DOM of the top-level frame at failure moment (page.content()). Pairs with the screenshot: the pixels show what’s visually there, the DOM shows what the test’s selectors were actually looking at. Critical for “click target not found” / “control didn’t mount” failures.

  • console.txt — every JS console message + uncaught pageerror accumulated since page creation (M.4.4.11 pattern, lifted from _harness_browser._attach_console_capture)

  • qs_errors.txt — text content of any QS error overlays visible on the page (the “Failed to load visual” / SQL error tooltips that classic-QS shows for failed dataset queries)

  • network.txt — HTTP status + URL for every non-2xx response the page made. The X.1.b investigation revealed multiple 404 Not Found responses paired with the Sample values not found JS errors — the URL pattern should disambiguate which QS-side resource is missing.

  • ws_frames.txt (AA.A.qs-triage.1) — QS-only: every text WebSocket frame the page sent (QS’s data-layer protocol — START_VIS carries the actual parameter substitution QS made for the visual’s CustomSql). Sink lives on QsEmbedDriver._ws_frames and is attached to the page as page._qs_gen_ws_frames_sink; App2-only tests leave the sink empty and land an empty file (signal that the test didn’t open a QS embed).

  • trace.zip (Y.2.gate.c.11) — Playwright trace bundle: full action timeline, DOM snapshots per action, screenshots, network, and console. Open with playwright show-trace trace.zip. Plain-text artifacts (dom.html) cover the grep-able path; trace.zip is for full-UI replay.

Output destination depends on RECON_GEN_RUN_DIR:

  • Set (running under the test layer chain runner): $RECON_GEN_RUN_DIR/browser/<test_id>/{screenshot.png,dom.html, console.txt,qs_errors.txt,network.txt,ws_frames.txt,trace.zip} — per-test directory so artifacts cluster cleanly.

  • Unset (legacy ./run_e2e.sh / direct pytest invocation): tests/e2e/screenshots/_failures/<test_id>.png etc., flat directory with per-file <test_id>_ prefix to disambiguate. Trace.zip is NOT written in legacy mode (no run-dir to put it in).

The test_id is snapshotted at webkit_page entry (when pytest’s PYTEST_CURRENT_TEST env var is reliably set inside the test body) rather than re-read inside the except handler — that handler can run during fixture teardown after pytest has cleared the var, which would silently demote captures to unknown/.

Trace capture policy: - On exception → trace always written (under the run-dir mode). - On clean exit → trace written iff RECON_GEN_TRACE_ALL=1 is set

(operator opt-in for “I want the full trace even on green tests”; flag plumbed by Y.2.gate.c.7).

Capture is best-effort: each capture function catches its own exceptions and emits a [CAPTURE FAILURE] <artifact>: <type>: <msg> line to stderr (loud-fail). The original assertion still bubbles up unmasked — but a regression in the capture path is visible in the layer’s stderr.log instead of being invisible until the next forensic session.

Return type:

Generator[Page, None, None]

Parameters:
  • headless (bool)

  • viewport (tuple[int, int])