From 707b74fac38e518b5fe57a4bc69d50eca7f32bb1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 23 Jul 2026 21:54:41 -0700 Subject: [PATCH] Studio UI test: recover from voice-picker renderer crash, scoped to macOS runners Downgrades a headless-Chromium renderer crash in the voice model-picker step to a warning plus page recovery on macos-14, where CheckMediaAccessPermission can kill the tab. Linux and Windows strict smoke jobs keep hard crash coverage and any live-page failure stays a hard fail. --- tests/studio/playwright_extra_ui.py | 57 +++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/tests/studio/playwright_extra_ui.py b/tests/studio/playwright_extra_ui.py index 0da028a28c..e4de62fd2e 100644 --- a/tests/studio/playwright_extra_ui.py +++ b/tests/studio/playwright_extra_ui.py @@ -36,6 +36,9 @@ ART_DIR = os.environ.get("PW_ART_DIR", "logs/playwright_extra") ART = Path(ART_DIR) ART.mkdir(parents = True, exist_ok = True) STRICT = os.environ.get("STUDIO_UI_STRICT", "0") == "1" +# The Voice-picker media-access crash is specific to headless Chromium on macos-14; only there do we +# downgrade a renderer crash to a warning. Linux/Windows strict smoke jobs keep hard crash coverage. +MACOS_RUNNER = os.environ.get("RUNNER_OS", "").lower() == "macos" or sys.platform == "darwin" # Longer turn timeout: gemma-3-270m CPU inference is 3-5x slower on macos-14 runners. TURN_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_TURN_TIMEOUT_MS", "180000")) WALL_TIMEOUT_S = float(os.environ.get("STUDIO_UI_WALL_TIMEOUT_S", "720")) @@ -71,6 +74,18 @@ def runtime_warn(m: str) -> None: info(f"WARN (runtime): {m}") +def page_crashed(pg, exc: Exception) -> bool: + """True when the browser/page/context died (a macos-14 renderer crash) rather than a live-page + assertion failing -- so the caller can downgrade CI-environment flakiness to a runtime warning.""" + try: + if pg.is_closed(): + return True + except Exception: + return True + msg = str(exc).lower() + return "has been closed" in msg or "target closed" in msg or "crash" in msg + + with sync_playwright() as p: _watchdog = install_wall_clock_watchdog( WALL_TIMEOUT_S, @@ -544,13 +559,17 @@ with sync_playwright() as p: if voice_tab.count() == 0: fail("Voice settings tab not found") else: - voice_tab.click() - page.get_by_label("Dictation engine").click() - page.get_by_role("option", name = "Local transcription").click() - page.get_by_label("Speech recognition model").click() - page.get_by_placeholder("Search model").fill("whisper") - results = page.get_by_test_id("stt-model-results") + # The dictation-engine dropdown touches a media-access path that can crash headless + # Chromium on macos-14 (CheckMediaAccessPermission). A resulting TargetClosedError is CI + # flakiness there, not a product bug, so on macOS a crash is a runtime warning + page + # recovery; on Linux/Windows a crash and any live-page failure stay a hard fail. try: + voice_tab.click() + page.get_by_label("Dictation engine").click() + page.get_by_role("option", name = "Local transcription").click() + page.get_by_label("Speech recognition model").click() + page.get_by_placeholder("Search model").fill("whisper") + results = page.get_by_test_id("stt-model-results") page.wait_for_function( """() => { const node = document.querySelector('[data-testid="stt-model-results"]'); @@ -569,10 +588,23 @@ with sync_playwright() as p: ) info("OK Voice model picker mouse wheel changed scrollTop") except Exception as exc: - fail(f"Voice model picker did not wheel-scroll: {exc!r}") - shoot("10-settings-tabs-visited") - page.keyboard.press("Escape") - page.wait_for_timeout(300) + if page_crashed(page, exc) and MACOS_RUNNER: + runtime_warn(f"Voice model picker aborted (browser/page unstable): {exc!r}") + page = recover_or_replace_page( + page, + ctx, + default_timeout_ms = 60_000, + info = lambda m: info(f"recovery: {m}"), + ) + else: + fail(f"Voice model picker did not wheel-scroll: {exc!r}") + # When the crash closed the context/browser (not just the page), recover_or_replace_page + # cannot mint a replacement and hands back the closed page; skip the cosmetic teardown rather + # than re-raise TargetClosedError on it. is_closed() is a local check and never raises. + if not page.is_closed(): + shoot("10-settings-tabs-visited") + page.keyboard.press("Escape") + page.wait_for_timeout(300) info(f"visited Settings tabs: {seen_tabs}") if not seen_tabs: soft_fail("no Settings tabs were visitable") @@ -591,4 +623,7 @@ with sync_playwright() as p: sys.exit(1) info("PASS extra UI flow") _watchdog.cancel() - browser.close() + try: + browser.close() + except Exception: + pass # a crashed browser may already be gone; never fail teardown after PASS