"""Reusable Elmhurst RdSAP-10-Online automation helpers (campaign edition). Why this exists: the per-cert build in the `expand-sap-accuracy-corpus` loop is the slow, flaky part. `elmhurst_fill.py` hard-codes one cert's values and relies on a *persisted* login session — but Elmhurst's auth is a **session cookie** that Playwright's persistent context does NOT reliably restore across process launches. This module sidesteps both problems: * **Log in fresh each run** (creds from env `ELM_ACCESS`/`ELM_PWD`, else a gitignored `scripts/hyde/.elmhurst-creds.json` {"access","pwd"}). Assessment DATA persists server-side via *Save & Close*, so re-login per run is fine and you can build/verify one page at a time across separate processes. * **Headed on the noVNC display** (`DISPLAY=:99`; run `start_viewer.sh` first). Hard-won Elmhurst-DOM gotchas baked into the helpers below (see SKILL.md too): * Most controls AutoPostBack — set one field, wait for the postback, re-locate. * Sub-tabs (Doors, Air Pressure Test, Lighting, Water-Heating FGHRS…) are AjaxControlToolkit tabs: their fields are in the DOM but NOT visible until the tab span `#__tab_` is clicked. Use `click_tab()`. * Delete is a Yes/No MODAL (not a native dialog): click the row delete, then `#..._LinkButtonYes`. Its ModalBackground intercepts clicks — use JS .click(). * The window grid will NOT delete the LAST row, and **each Add wipes the previously-added row's editable cells** — so multi-row window entry via the add-form does not stick. Enter ONE combined window row (total glazed area, one orientation); per worked-ref guidance that's an accepted approximation. * Window glazing: "...known data" enables per-window U/g but then DEMANDS Frame Factor + Data-Source + Location + Building Part per row (Recommendations validation fails otherwise). Prefer a standard band, e.g. "Double post or during 2022" (auto-derives U≈1.4/g≈0.72), and still set Orientation + Location="External wall" + Building Part="Main". * The "U-value known" override checkboxes (walls/roof/floor) don't reliably toggle via automation — accept the natural RdSAP age-band U-value. * The **Recommendations** page is the validation gate; it MUST show zero errors before the Energy Report Summary / worksheet PDFs are downloadable. Common required-but-blank fields: insulated-door U-value, air-pressure-test certificate number (when method=Blower Door). * Truth only after Save & Close + reload: the live (unsaved) grid mis-displays older rows as 0.00 — never delete a row by its *live* width; reload first. Usage: import elmhurst_lib as E with E.session() as (ctx, page): E.goto(page, "Dimensions", "WebFormDimensions.aspx") E.set_text(page, E.FP + "...TextBoxFloorAreaLowestFloor", "44.93") E.save_close(page) """ from __future__ import annotations import json import os from contextlib import contextmanager from pathlib import Path from typing import Callable, Generator, Optional from playwright.sync_api import ( BrowserContext, Page, TimeoutError as PlaywrightTimeoutError, sync_playwright, ) HERE = Path(__file__).parent SESSION_DIR = HERE / ".elmhurst-session" CREDS_FILE = HERE / ".elmhurst-creds.json" # gitignored; {"access":..,"pwd":..} # The reusable campaign assessment ("Khalim-test"), overwritten per UPRN. Can be # overridden per run via the `ELM_GUID` env var (e.g. a per-UPRN assessment under # a different account) — see Limitations "parameterize the GUID". ASSESSMENT_GUID = os.environ.get( "ELM_GUID", "B44A0DB4-4C08-4241-B818-86F060172105" ) ENTRY_URL = ( "https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx" f"?Guid={ASSESSMENT_GUID}" ) FP = "ContentBody_ContentPlaceHolder1_" # control-id prefix NAV = "ContentBody_buttonAction" # left-rail section nav id prefix SETTLE_MS = 700 def _creds() -> tuple[str, str]: if CREDS_FILE.exists(): d = json.loads(CREDS_FILE.read_text()) return d["access"], d["pwd"] access = os.environ.get("ELM_ACCESS") pwd = os.environ.get("ELM_PWD") if not access or not pwd: raise SystemExit( "No Elmhurst creds: set ELM_ACCESS/ELM_PWD or write " f"{CREDS_FILE} = {{'access':..,'pwd':..}}" ) return access, pwd @contextmanager def session() -> Generator[tuple[BrowserContext, Page], None, None]: """Launch headed on :99, log in fresh, land on the assessment. Closes on exit.""" with sync_playwright() as p: ctx = p.chromium.launch_persistent_context( user_data_dir=str(SESSION_DIR), headless=False, accept_downloads=True, viewport={"width": 1400, "height": 1000}, # Container /dev/shm is tiny (64M) → chromium renderer "Target # crashed" mid-build without this. --no-sandbox for rootless Xvfb. args=["--disable-dev-shm-usage", "--no-sandbox"], ) page = ctx.pages[0] if ctx.pages else ctx.new_page() page.on("dialog", lambda d: d.accept()) # Login can be slow/flaky; retry the whole goto+fill a few times. for attempt in range(4): try: page.goto(ENTRY_URL, wait_until="domcontentloaded", timeout=60_000) if "WebFormLogin" not in page.url: break # already authed (server session still valid) access, pwd = _creds() ac = "#ctl00_ctl00_ContentBody_ContentBody_TextBoxAccessCode" page.wait_for_selector(ac, state="visible", timeout=30_000) page.fill(ac, access) page.fill("#ctl00_ctl00_ContentBody_ContentBody_TextBoxPassword", pwd) with page.expect_navigation(wait_until="networkidle", timeout=60_000): page.click("#ctl00_ctl00_ContentBody_ContentBody_ImageButtonEnter") page.wait_for_timeout(1000) if "WebFormLogin" not in page.url: break except PlaywrightTimeoutError: print(f" login attempt {attempt+1} timed out, retrying...", flush=True) page.wait_for_timeout(2000) try: yield ctx, page finally: ctx.close() def commit(page: Page, action: Callable[[], object]) -> None: """Run a control change that triggers an ASP.NET postback; wait for it.""" try: with page.expect_navigation(wait_until="load", timeout=8_000): action() except PlaywrightTimeoutError: page.wait_for_load_state("networkidle", timeout=8_000) page.wait_for_timeout(SETTLE_MS) def goto(page: Page, nav: str, url_fragment: str) -> None: """Navigate to a left-rail section (no-op if already on it — re-clicking double-postbacks). Navigating saves the current page into the server session.""" if url_fragment in page.url: return with page.expect_navigation(wait_until="networkidle", timeout=60_000): page.click(f"#{NAV}{nav}_Link", timeout=15_000) page.wait_for_timeout(SETTLE_MS) def click_tab(page: Page, panel_id: str) -> None: """Activate an AjaxControlToolkit sub-tab by its panel id, e.g. 'TabContainer_TabPanelDoorsPanel'. Client-side; no postback.""" page.evaluate( "(id)=>{const e=document.getElementById('__tab_'+id); if(e)e.click();}", f"{FP}{panel_id}", ) page.wait_for_timeout(500) def set_text(page: Page, suffix: str, value: str, autopostback: bool = True) -> None: loc = page.locator(f"#{FP}{suffix}") loc.fill(value) if autopostback: commit(page, lambda: loc.press("Tab")) else: page.wait_for_timeout(150) def set_select(page: Page, suffix: str, value: str, autopostback: bool = True) -> None: loc = page.locator(f"#{FP}{suffix}") if autopostback: commit(page, lambda: loc.select_option(value)) else: loc.select_option(value) def save_close(page: Page) -> None: """Save & Close — commits the whole server session to the DB (so the data survives the next fresh-login run). The body button id-suffix avoids the top-bar LinkButton copy. NEVER touches Submit.""" with page.expect_navigation(wait_until="networkidle", timeout=60_000): page.click("#ContentBody_ButtonSaveClose", timeout=15_000) page.wait_for_timeout(SETTLE_MS) # --- Window grid helpers (Openings page) --------------------------------- _WP = "TabContainer_TabPanelWindowsPanel_" _GRID = "GridViewExtendedWidows" def window_row_count(page: Page) -> int: return page.evaluate( f"""()=>document.querySelectorAll("[id*='{_GRID}_DeleteButton_']").length""" ) def _jsclick(page: Page, element_id: str) -> None: page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", element_id) def add_combined_window( page: Page, total_area_m2: float, orientation: str, glazing: str = "Double post or during 2022", ) -> None: """Add ONE window row of `total_area_m2` (width=area, height=1.0). Sets the required per-row fields (orientation/location/building-part) so the Recommendations validation passes. Caller then deletes the prior rows.""" set_select(page, f"{_WP}DropDownListExtGlazing", glazing) page.locator(f"#{FP}{_WP}TextBoxExtWidth").fill(str(total_area_m2)) h = page.locator(f"#{FP}{_WP}TextBoxExtHeight") h.fill("1.00") commit(page, lambda: h.press("Tab")) page.locator(f"#{FP}{_WP}DropDownListExtOrientation").select_option(orientation) page.locator(f"#{FP}{_WP}DropDownListExtBuildingPartId").select_option("Main") page.locator(f"#{FP}{_WP}DropDownListExtLocation").select_option("External wall") page.wait_for_timeout(200) before = window_row_count(page) _jsclick(page, f"{FP}{_WP}ButtonAddWindow") for _ in range(20): page.wait_for_timeout(200) if window_row_count(page) > before: break def delete_first_window(page: Page) -> None: """Delete the first window row via the Yes/No modal. Will refuse to drop the LAST row (Elmhurst blocks it) — so add the replacement BEFORE deleting.""" before = window_row_count(page) first = page.evaluate( f"""()=>{{const e=document.querySelector("[id*='{_GRID}_DeleteButton_']"); return e?e.id:null;}}""" ) if not first: return _jsclick(page, first) page.wait_for_selector(f"#{FP}DeleteWindowDialog_LinkButtonYes", state="visible", timeout=5_000) _jsclick(page, f"{FP}DeleteWindowDialog_LinkButtonYes") for _ in range(20): page.wait_for_timeout(200) if window_row_count(page) < before: break