Spaces:
Paused
Paused
| """Subprocess runner: check a model-built web app in a REAL headless browser. | |
| Invoked as `python engine/browser_runner.py <app.html>` by | |
| engine/browsercheck.py — never imported (keeps it free of the engine package / | |
| liteforge, and isolates a browser crash from the Gradio process). It loads the | |
| app wrapped in the EXACT same `srcdoc` + `sandbox` as the live preview | |
| (engine/preview.py), so the verdict matches what the user sees, then clicks every | |
| button and exercises the keyboard, and reports any uncaught JavaScript errors. | |
| Browser: headless Firefox via Selenium + geckodriver. (Playwright's browser CDN | |
| is firewalled in this environment; conda-forge Firefox is the reachable, rootless | |
| real browser. The choice is invisible to callers — same JSON contract.) | |
| We capture errors by injecting a tiny `window.onerror`/`unhandledrejection` | |
| collector at the top of the framed document (so it catches errors during initial | |
| script execution — the "script ran before its element / undefined function" | |
| class), then read it back. That is the HARD failure signal. | |
| Output: one JSON line {ok, errors, buttons, clicked}. Exit 3 only when the | |
| browser itself can't run, so the caller can fall back to the jsdom checker. | |
| """ | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import tempfile | |
| PREVIEW_SANDBOX = "allow-scripts allow-same-origin allow-modals allow-popups allow-forms" | |
| # Installed by the rootless conda-forge setup (see DEVELOPING.md). Overridable. | |
| _BROWSER_PREFIX = os.environ.get( | |
| "SMOLBUILDER_BROWSER_PREFIX", | |
| os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".browser")) | |
| _FIREFOX_BIN = os.path.join(_BROWSER_PREFIX, "bin", "FirefoxApp", "firefox") | |
| _GECKODRIVER = os.path.join(_BROWSER_PREFIX, "bin", "geckodriver") | |
| # Injected first inside the frame so it catches errors thrown during load. | |
| _CAPTURE = ("<script>(function(){window.__errs=[];" | |
| "window.addEventListener('error',function(e){try{__errs.push('uncaught: '+" | |
| "((e.error&&e.error.message)||e.message||String(e)))}catch(_){}} ,true);" | |
| "window.addEventListener('unhandledrejection',function(e){try{__errs.push(" | |
| "'rejection: '+((e.reason&&e.reason.message)||e.reason))}catch(_){}});})();</script>") | |
| def _escape_srcdoc(doc: str) -> str: | |
| return doc.replace("&", "&").replace('"', """) | |
| def _inject_capture(app_html: str) -> str: | |
| """Put the error collector before the app's own scripts.""" | |
| m = re.search(r"<head[^>]*>", app_html, re.I) | |
| if m: | |
| return app_html[:m.end()] + _CAPTURE + app_html[m.end():] | |
| m = re.search(r"<html[^>]*>", app_html, re.I) | |
| if m: | |
| return app_html[:m.end()] + _CAPTURE + app_html[m.end():] | |
| return _CAPTURE + app_html | |
| def _emit(obj: dict) -> None: | |
| sys.stdout.write(json.dumps(obj) + "\n") | |
| def main(path: str) -> int: | |
| try: | |
| from selenium import webdriver | |
| from selenium.webdriver.firefox.options import Options | |
| from selenium.webdriver.firefox.service import Service | |
| from selenium.webdriver.common.by import By | |
| except Exception as e: | |
| _emit({"ok": None, "infra": f"selenium import failed: {e}"}) | |
| return 3 | |
| if not (os.path.exists(_FIREFOX_BIN) and os.path.exists(_GECKODRIVER)): | |
| _emit({"ok": None, "infra": "firefox/geckodriver not installed"}) | |
| return 3 | |
| with open(path, encoding="utf-8") as f: | |
| app_html = f.read() | |
| host = ('<!doctype html><meta charset="utf-8"><body style="margin:0">' | |
| f'<iframe id="app" style="width:100%;height:600px;border:0" ' | |
| f'sandbox="{PREVIEW_SANDBOX}" ' | |
| f'srcdoc="{_escape_srcdoc(_inject_capture(app_html))}"></iframe>') | |
| host_path = os.path.join(tempfile.mkdtemp(prefix="brhost-"), "host.html") | |
| with open(host_path, "w", encoding="utf-8") as f: | |
| f.write(host) | |
| opts = Options() | |
| opts.add_argument("-headless") | |
| opts.binary_location = _FIREFOX_BIN | |
| opts.set_preference("security.sandbox.content.level", 0) # no userns in container | |
| svc = Service(executable_path=_GECKODRIVER, log_output=os.path.join(tempfile.gettempdir(), "gecko.log")) | |
| try: | |
| driver = webdriver.Firefox(options=opts, service=svc) | |
| except Exception as e: | |
| _emit({"ok": None, "infra": f"firefox launch failed: {str(e)[:200]}"}) | |
| return 3 | |
| errors: list[str] = [] | |
| buttons = clicked = 0 | |
| try: | |
| driver.set_page_load_timeout(20) | |
| driver.get("file://" + host_path) | |
| driver.switch_to.frame(driver.find_element(By.ID, "app")) | |
| import time | |
| time.sleep(0.3) # let scripts settle | |
| els = driver.find_elements( | |
| By.CSS_SELECTOR, "button, [onclick], input[type=button], input[type=submit]") | |
| buttons = len(els) | |
| for el in els[:25]: | |
| try: | |
| driver.execute_script("arguments[0].disabled=false;", el) | |
| el.click() | |
| clicked += 1 | |
| except Exception: | |
| pass # handler errors show up in __errs | |
| # Exercise keyboard handlers (canvas games etc.). | |
| try: | |
| driver.execute_script( | |
| "['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].forEach(function(k){" | |
| "var c={key:k,keyCode:k===' '?32:({ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39}[k]),bubbles:true};" | |
| "document.dispatchEvent(new KeyboardEvent('keydown',c));" | |
| "window.dispatchEvent(new KeyboardEvent('keydown',c));});") | |
| except Exception: | |
| pass | |
| time.sleep(0.3) # surface late/timer errors | |
| try: | |
| errors = driver.execute_script("return window.__errs || [];") or [] | |
| except Exception: | |
| errors = [] | |
| finally: | |
| try: | |
| driver.quit() | |
| except Exception: | |
| pass | |
| errors = [str(e)[:400] for e in errors][:20] | |
| _emit({"ok": len(errors) == 0, "errors": errors, "buttons": buttons, "clicked": clicked}) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main(sys.argv[1])) | |