mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
In-container Playwright runs were flaky: the chromium renderer crashed
mid-build ("Target crashed") on the 64M /dev/shm, and login intermittently
hung. Added `--disable-dev-shm-usage` + `--no-sandbox` launch args, a
4-attempt login retry loop (domcontentloaded + explicit selector wait),
and an `ELM_GUID` env override so a per-UPRN assessment can be targeted
without editing the module. Tooling only — no calculator impact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
10 KiB
Python
246 lines
10 KiB
Python
"""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_<panelId>` 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
|