diff --git a/packages/flow/docs/MULTI_SURFACE.md b/packages/flow/docs/MULTI_SURFACE.md index b31198831..d3176d82c 100644 --- a/packages/flow/docs/MULTI_SURFACE.md +++ b/packages/flow/docs/MULTI_SURFACE.md @@ -278,6 +278,17 @@ clear reason: a popup whose *first* action closes it before Flow captured any frame of it. Nothing would anchor that action. Act in the popup once more first, or record that step with the scripted recorder. +The browser can report the popup closed while the closing click is still +being delivered ("Target page, context or browser has been closed"). The +recorder marks a click that left its popup closed as `closes_surface`, and +that step is the only one for which the report counts as a delivered click. +At replay the browser backend accepts it when three things hold: the step is +marked `closes_surface`, the click ran on a page surface that was open when +the click started, and the page reads as closed within a second. Any other +"page closed" report, including one on the primary page or on a step not +marked, leaves delivery uncertain, and the run goes on only as far as its +postconditions and effect checks confirm. + A click in a new page within the first poll tick after it loads can arrive before the listener is installed and is not recorded. Replay then halts at the next step rather than skipping it silently. diff --git a/packages/flow/openadapt_flow/backend.py b/packages/flow/openadapt_flow/backend.py index 4ee64fd1c..5d60a75c0 100644 --- a/packages/flow/openadapt_flow/backend.py +++ b/packages/flow/openadapt_flow/backend.py @@ -604,6 +604,33 @@ def reset_fresh_actuation_state(self) -> None: ... +@runtime_checkable +class PageCloseExpectationBackend(Protocol): + """Optional seam for a click that closes its own popup. + + A popup or tab that a step opened can close itself from the click that + finishes it (an approval or sign-in window). The browser may then report + the page closed while the click is still being delivered. Without an + expectation, that error leaves delivery uncertain. The runtime arms an + expectation only for a step whose recording shows it closed its page + (``Step.closes_surface``); the backend then counts the click as delivered + only when the page was open before the click, the browser reports the + page closed, and the page is confirmed closed afterwards. + """ + + def expect_page_close(self) -> None: + """Expect the next click on this page to close it. One-shot.""" + ... + + def cancel_page_close(self) -> None: + """Drop an unconsumed expectation.""" + ... + + def page_closed(self) -> bool: + """Whether this page surface has closed.""" + ... + + @runtime_checkable class PreparedPointerActuationBackend(Protocol): """Optional pointer pre-positioning for opaque remote-display clients. diff --git a/packages/flow/openadapt_flow/backends/playwright_backend.py b/packages/flow/openadapt_flow/backends/playwright_backend.py index 774131039..7e6b82243 100644 --- a/packages/flow/openadapt_flow/backends/playwright_backend.py +++ b/packages/flow/openadapt_flow/backends/playwright_backend.py @@ -61,6 +61,16 @@ "Execution context was destroyed", ) +# How long a click that reported its page closed waits for the page to read +# as closed before the close counts as confirmed. +_PAGE_CLOSE_CONFIRM_S = 1.0 + + +def _is_target_closed_error(exc: BaseException) -> bool: + """Whether Playwright reported the page, context or browser closed.""" + return type(exc).__name__ == "TargetClosedError" + + _MODIFIER_ALIASES = { "meta": "Meta", "cmd": "Meta", @@ -1081,6 +1091,9 @@ def __init__( self._fill_store_key = f"__oaflow_filled_{uuid.uuid4().hex}" self._structural_tokens: dict[str, _StructuralGuard] = {} self._guarded_coordinate: Optional[_CoordinateGuard] = None + # One-shot expectation that the next click closes this page surface + # (Step.closes_surface; see expect_page_close). + self._page_close_expected = False self._guarded_keyboard: Optional[_KeyboardGuard] = None self._qualification_environment: Optional[tuple[str, str, str, str]] = None self._qualification_input_guard: Optional[Callable[[], None]] = None @@ -2142,6 +2155,8 @@ def act_structural( # cannot declare (for example a browser inside a managed remote # session), so the DOM guard alone is not sufficient. self._assert_qualification_environment_current() + close_expected = self._take_page_close_expectation() + open_before = not self._page_is_closed() try: if double: token_locator.dblclick(timeout=1000) @@ -2152,13 +2167,18 @@ def act_structural( # this call. Once Playwright begins its action, an exception # (notably frame detach/navigation) cannot prove that the # browser emitted no input. Surface a typed uncertainty so the - # runtime verifies the effect exactly once and never retries. - raise ActionDeliveryUncertain( - operation="dom_double_click" if double else "dom_click", - native=False, - target_fingerprint=fingerprint, - cause_type=type(exc).__name__, - ) from exc + # runtime verifies the effect exactly once and never retries, + # unless this page was expected to close itself on this click. + if not ( + _is_target_closed_error(exc) + and self._closed_itself_as_expected(close_expected, open_before) + ): + raise ActionDeliveryUncertain( + operation="dom_double_click" if double else "dom_click", + native=False, + target_fingerprint=fingerprint, + cause_type=type(exc).__name__, + ) from exc except ActionDeliveryUncertain: raise except StructuralResolutionRefused: @@ -2548,6 +2568,8 @@ def act_guarded_coordinate( "pre-dispatch actionability trial" ) self._assert_qualification_environment_current() + close_expected = self._take_page_close_expectation() + open_before = not self._page_is_closed() try: if double: token_locator.dblclick(position=position, timeout=1000) @@ -2558,12 +2580,16 @@ def act_guarded_coordinate( button=button, ) except Exception as exc: - raise ActionDeliveryUncertain( - operation=operation, - native=False, - target_fingerprint=pending.fingerprint, - cause_type=type(exc).__name__, - ) from exc + if not ( + _is_target_closed_error(exc) + and self._closed_itself_as_expected(close_expected, open_before) + ): + raise ActionDeliveryUncertain( + operation=operation, + native=False, + target_fingerprint=pending.fingerprint, + cause_type=type(exc).__name__, + ) from exc except ActionDeliveryUncertain: raise except StructuralResolutionRefused: @@ -3130,12 +3156,77 @@ def _live_screenshot(self) -> bytes: ) def click(self, x: int, y: int, *, double: bool = False) -> None: - """Click (or double-click) at pixel coordinates via the mouse.""" + """Click (or double-click) at pixel coordinates via the mouse. + + A click that closes its own page surface completes normally when that + close was expected (:meth:`expect_page_close`). An unexpected report + that the page closed during the click raises + :class:`~openadapt_flow.backend.ActionDeliveryUncertain`. + """ self._assert_qualification_environment_current() - if double: - self.page.mouse.dblclick(x, y) - else: - self.page.mouse.click(x, y) + expected = self._take_page_close_expectation() + open_before = not self._page_is_closed() + try: + if double: + self.page.mouse.dblclick(x, y) + else: + self.page.mouse.click(x, y) + except Exception as exc: + if not _is_target_closed_error(exc): + raise + if self._closed_itself_as_expected(expected, open_before): + return + raise ActionDeliveryUncertain( + operation="coordinate_double_click" if double else "coordinate_click", + native=False, + cause_type=type(exc).__name__, + ) from exc + + # -- a click that closes its own popup --------------------------------- + + def expect_page_close(self) -> None: + """Expect the next click on this page to close it (one-shot). + + Only a page surface (``closed_page_frame=True``: a popup or tab a step + opened) honours it. On the primary page a close during a click stays + an uncertain delivery. + """ + self._page_close_expected = True + + def cancel_page_close(self) -> None: + """Drop an expectation no click consumed.""" + self._page_close_expected = False + + def page_closed(self) -> bool: + """Whether this page has closed.""" + return self._page_is_closed() + + def _take_page_close_expectation(self) -> bool: + expected, self._page_close_expected = self._page_close_expected, False + return expected and self._closed_page_frame + + def _closed_itself_as_expected(self, expected: bool, open_before: bool) -> bool: + """Whether a click that raised "page closed" was delivered. + + It was when the step expected its page to close, the page was open + when the click started, and the page is confirmed closed now. The + browser reports the close only after the page began handling input, + and an open page cannot close itself without running its own script. + """ + if not (expected and open_before): + return False + deadline = time.monotonic() + _PAGE_CLOSE_CONFIRM_S + while True: + if self._page_is_closed(): + return True + if time.monotonic() >= deadline: + return False + try: + # The sync API applies the page's close event only while it + # is inside a call; a short wait lets it arrive. + self.page.wait_for_timeout(20) + except Exception: + time.sleep(0.02) def right_click(self, x: int, y: int) -> None: """Open the context menu at a resolved point.""" diff --git a/packages/flow/openadapt_flow/bundle_validation.py b/packages/flow/openadapt_flow/bundle_validation.py index 148f25039..20a37855a 100644 --- a/packages/flow/openadapt_flow/bundle_validation.py +++ b/packages/flow/openadapt_flow/bundle_validation.py @@ -230,6 +230,16 @@ class _SealedCanonicalOmission: "fields; a recorded origin remains sealed content" ), ), + _SealedCanonicalOmission( + owner=Step, + field_name="closes_surface", + omitted_values=(False,), + reason=( + "a click that leaves its page open is what every earlier " + "bundle recorded; a click declared to close its popup is " + "sealed content" + ), + ), _SealedCanonicalOmission( owner=Step, field_name="dialog", diff --git a/packages/flow/openadapt_flow/compiler/compile.py b/packages/flow/openadapt_flow/compiler/compile.py index cbcd5bf7a..2c20a93ea 100644 --- a/packages/flow/openadapt_flow/compiler/compile.py +++ b/packages/flow/openadapt_flow/compiler/compile.py @@ -2525,6 +2525,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: stamped.surface = str(event["surface"]) if event.get("opens"): stamped.opens = [str(sid) for sid in event["opens"]] + if event.get("closes_surface") is True: + stamped.closes_surface = True # Collapse a demonstrated native/pixel option selection into one explicit # SELECT_OPTION contract. This is deliberately evidence-gated and remote-only: diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index 2b778aab8..1eb8803b9 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -1232,6 +1232,13 @@ def surface_contract_errors(workflow: "Workflow") -> list[str]: for step in all_steps: if step.surface is not None and step.surface not in by_id: errors.append(f"step {step.id!r} names undeclared surface") + if step.closes_surface and not ( + step.surface in by_id and by_id[step.surface].is_page + ): + errors.append( + f"step {step.id!r} closes its surface, but it does not run on " + "a page a step opened" + ) declared = [sid for _, sid in sorted(expected_opens.get(step.id, []))] if list(step.opens) != declared: errors.append( @@ -1462,6 +1469,17 @@ class Step(BaseModel): " the target page showed when the demonstration switched to it." ), ) + closes_surface: bool = Field( + default=False, + description=( + "A click on a page surface (a popup or tab a step opened) that " + "closed that page in the demonstration, such as an approval " + "window's final button. At replay, the browser backend may then " + "count the click as delivered when the browser reports the page " + "closed during the click and the page is confirmed closed. " + "Without it, that error leaves delivery uncertain." + ), + ) dialog: Optional[DialogResponse] = Field( default=None, description=( @@ -1477,6 +1495,11 @@ def _validate_surface_contract(self) -> "Step": for sid in self.opens: if not re.fullmatch(SURFACE_ID_PATTERN, sid): raise ValueError("opens holds an invalid surface id") + if self.closes_surface: + if self.action not in (ActionKind.CLICK, ActionKind.DOUBLE_CLICK): + raise ValueError("closes_surface is valid only on a click") + if self.surface is None: + raise ValueError("closes_surface needs the page surface it closes") if self.action is not ActionKind.SWITCH_SURFACE: if self.surface_origin is not None: raise ValueError("surface_origin is valid only on SWITCH_SURFACE") diff --git a/packages/flow/openadapt_flow/recorder.py b/packages/flow/openadapt_flow/recorder.py index 2d30ae60f..16883afa4 100644 --- a/packages/flow/openadapt_flow/recorder.py +++ b/packages/flow/openadapt_flow/recorder.py @@ -59,7 +59,11 @@ from PIL import Image, ImageDraw -from openadapt_flow.backend import Backend, RichPointerActionBackend +from openadapt_flow.backend import ( + Backend, + PageCloseExpectationBackend, + RichPointerActionBackend, +) from openadapt_flow.image_hash import perceptual_hash from openadapt_flow.ir import ( MAX_PAGES_OPENED_PER_STEP, @@ -526,6 +530,23 @@ def _opened_by_another_known_page(page: Any) -> bool: self._multi_surface = True return ids + def _closed_its_page(self, event: dict[str, Any]) -> bool: + """Whether a click left the popup or tab it ran on closed.""" + return ( + event.get("kind") in ("click", "double_click") + and self._active_surface in self._opened_page_ids() + and isinstance(self._backend, PageCloseExpectationBackend) + and self._backend.page_closed() + ) + + def _opened_page_ids(self) -> set[str]: + """Ids of the page surfaces (popups, tabs) a recorded action opened.""" + return { + str(meta["id"]) + for meta in self._surface_meta + if meta.get("opened_by_event") is not None + } + @property def event_count(self) -> int: """Number of complete events already persisted.""" @@ -584,7 +605,23 @@ def _record(self, event: dict[str, Any], act: Callable[[], None]) -> None: "backend record the clicks that answer the dialog instead" ) arm(dialog_response, None) - act() + # A popup or tab may close itself from the click that finishes it (an + # approval window). On a page surface, expect that close so the click + # counts as delivered, and record it so replay expects the same. + page_backend = ( + self._backend + if event.get("kind") in ("click", "double_click") + and self._active_surface in self._opened_page_ids() + and isinstance(self._backend, PageCloseExpectationBackend) + else None + ) + if page_backend is not None: + page_backend.expect_page_close() + try: + act() + finally: + if page_backend is not None: + page_backend.cancel_page_close() if dialog_response is not None: seen = self._backend.take_dialogs(_DIALOG_WAIT_S) # type: ignore[attr-defined] if len(seen) != 1: @@ -603,6 +640,10 @@ def _record(self, event: dict[str, Any], act: Callable[[], None]) -> None: opened = self._claim_new_pages(self._i) if opened: event = {**event, "opens": opened} + # Checked after the settle: a page closes asynchronously after the + # click that closes it. + if self._closed_its_page(event): + event = {**event, "closes_surface": True} self._commit(event, before, after, structural_before) def record_observed( @@ -679,6 +720,8 @@ def record_observed( opened = self._claim_new_pages(self._i) if opened: event = {**event, "opens": opened} + if self._closed_its_page(event): + event = {**event, "closes_surface": True} self._commit( event, before_png, diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index 8760c06e3..b2e00281e 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -11077,6 +11077,38 @@ def _act( resolution: Optional[Resolution], params: Mapping[str, RuntimeParamScalar], **kwargs: Any, + ) -> Optional[str]: + """Perform the step's action. + + A click recorded as closing its own popup (``Step.closes_surface``) + arms the page backend's one-shot expectation first + (:class:`~openadapt_flow.backend.PageCloseExpectationBackend`), so a + "page closed" report from that click counts as delivery once the page + is confirmed closed. Every other step leaves such a report uncertain. + """ + + expect = ( + getattr(self.backend, "expect_page_close", None) + if step.closes_surface + else None + ) + if not callable(expect): + return self._act_answering_dialog(step, resolution, params, **kwargs) + backend = self.backend + expect() + try: + return self._act_answering_dialog(step, resolution, params, **kwargs) + finally: + cancel = getattr(backend, "cancel_page_close", None) + if callable(cancel): + cancel() + + def _act_answering_dialog( + self, + step: Step, + resolution: Optional[Resolution], + params: Mapping[str, RuntimeParamScalar], + **kwargs: Any, ) -> Optional[str]: """Perform the step's action, answering the dialog it raises, if any. diff --git a/packages/flow/tests/test_bundle_schema_v2.py b/packages/flow/tests/test_bundle_schema_v2.py index 12bdba193..2f58faf45 100644 --- a/packages/flow/tests/test_bundle_schema_v2.py +++ b/packages/flow/tests/test_bundle_schema_v2.py @@ -197,6 +197,7 @@ def _pre_frame_path_content(wf: Workflow, shape: str) -> dict: assert step.pop("opens") == [] assert step.pop("surface_origin") is None assert step.pop("dialog") is None + assert step.pop("closes_surface") is False for effect in _effect_payloads(content, shape): # Reviewed v2 omission: the exact_new_set over-write guard postdates # these seals. Navigated to the exact owning Effect, never stripped by @@ -255,6 +256,7 @@ def _write_synthetic_pre_field_bundle( assert step.pop("opens") == [] assert step.pop("surface_origin") is None assert step.pop("dialog") is None + assert step.pop("closes_surface") is False for effect in _effect_payloads(raw, shape): assert effect.pop("new_records") == [] assert effect.pop("identity_field") == "id" diff --git a/packages/flow/tests/test_multi_surface_selfclose_delivery.py b/packages/flow/tests/test_multi_surface_selfclose_delivery.py new file mode 100644 index 000000000..511dd66e8 --- /dev/null +++ b/packages/flow/tests/test_multi_surface_selfclose_delivery.py @@ -0,0 +1,137 @@ +"""A click that closes its own popup, when the browser reports the close. + +The fixture's sign-in popup closes itself from its "approve" button. Playwright +can report "Target page, context or browser has been closed" from that click +after delivering it. These tests force that report on every click in the +popup and check both sides of the contract: + +- the recording marks the closing click ``closes_surface``; replay then counts + the click as delivered, because the step expected the close and the popup is + confirmed closed; +- the same report on a step that did not expect its popup to close leaves + delivery uncertain, as before. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("playwright.sync_api") + +from playwright._impl._errors import TargetClosedError # noqa: E402 +from playwright.sync_api import Locator, Mouse # noqa: E402 + +import tests.test_multi_surface_e2e as _e2e # noqa: E402 +from openadapt_flow.backends.playwright_backend import PlaywrightBackend # noqa: E402 +from openadapt_flow.compiler import compile_recording # noqa: E402 +from openadapt_flow.ir import ActionKind, Workflow # noqa: E402 +from openadapt_flow.recorder import Recorder # noqa: E402 + +pytestmark = pytest.mark.timeout(600) + +app_url = _e2e.app_url +browser = _e2e.browser + +QUERY = "/?popup=/selfclose.html" +POPUP_PATH = "/selfclose.html" +CLOSING_STEP = 3 + + +def _closed_after(page) -> bool: + for _ in range(50): + if page.is_closed(): + return True + try: + page.wait_for_timeout(20) + except Exception: + pass + return page.is_closed() + + +@pytest.fixture() +def racing_close(monkeypatch): + """Make every click that closes the popup also raise TargetClosedError.""" + + original_locator_click = Locator.click + original_mouse_click = Mouse.click + pages_by_mouse: dict[int, object] = {} + + def locator_click(self, *args, **kwargs): + page = self.page + result = original_locator_click(self, *args, **kwargs) + if not kwargs.get("trial") and POPUP_PATH in page.url and _closed_after(page): + raise TargetClosedError("Target page, context or browser has been closed") + return result + + def mouse_click(self, *args, **kwargs): + page = pages_by_mouse.get(id(self)) + result = original_mouse_click(self, *args, **kwargs) + if page is not None and POPUP_PATH in page.url and _closed_after(page): + raise TargetClosedError("Target page, context or browser has been closed") + return result + + monkeypatch.setattr(Locator, "click", locator_click) + monkeypatch.setattr(Mouse, "click", mouse_click) + return pages_by_mouse + + +def _record(browser, url: str, root: Path, pages_by_mouse: dict) -> Workflow: + page = _e2e._fresh_page(browser, url) + try: + recorder = Recorder( + PlaywrightBackend(page), + root / "recording", + app_url=url, + settle_interval_s=0.05, + settle_timeout_s=2.0, + ) + recorder.click(*_e2e._center(page, "#open-popup")) + recorder.switch_surface("popup_1") + popup = recorder.surface_backend("popup_1").page # type: ignore[attr-defined] + pages_by_mouse[id(popup.mouse)] = popup + recorder.click(*_e2e._center(popup, "#agree")) + # The browser reports the popup closed during this click; recording + # continues because the click ran on a popup that is now closed. + recorder.click(*_e2e._center(popup, "#approve-close")) + assert popup.is_closed() + recorder.switch_surface("main") + recorder.click(*_e2e._center(page, "#finish")) + recording = recorder.finish() + finally: + page.context.close() + return compile_recording(recording, root / "bundle", name="selfclose-race") + + +def test_expected_close_counts_as_delivered(app_url, browser, tmp_path, racing_close): + url = app_url + QUERY + workflow = _record(browser, url, tmp_path, racing_close) + assert [s.closes_surface for s in workflow.steps] == [ + s.id == workflow.steps[CLOSING_STEP].id for s in workflow.steps + ] + assert workflow.steps[CLOSING_STEP].action is ActionKind.CLICK + report, page = _e2e._replay(browser, url, tmp_path / "bundle", tmp_path / "run") + try: + assert report.success, _e2e._summary(report) + closing = report.results[CLOSING_STEP] + assert closing.delivery_uncertainty is None + assert page.locator("#done").inner_text() == "Referral Finished" + finally: + page.context.close() + + +def test_unexpected_close_stays_uncertain(app_url, browser, tmp_path, racing_close): + url = app_url + QUERY + workflow = _record(browser, url, tmp_path, racing_close) + bundle = tmp_path / "bundle" + # The same bundle, but the closing click is not declared to close. + workflow.steps[CLOSING_STEP].closes_surface = False + workflow.save(bundle) + report, page = _e2e._replay(browser, url, bundle, tmp_path / "run") + try: + closing = report.results[CLOSING_STEP] + assert closing.delivery_uncertainty is not None + assert closing.delivery_uncertainty.cause_type == "TargetClosedError" + finally: + page.context.close() diff --git a/packages/flow/tests/test_page_close_delivery.py b/packages/flow/tests/test_page_close_delivery.py new file mode 100644 index 000000000..e5aa25252 --- /dev/null +++ b/packages/flow/tests/test_page_close_delivery.py @@ -0,0 +1,256 @@ +"""When a click that reports its page closed counts as delivered. + +A popup can close itself from the click that finishes it, and Playwright can +then report the page closed after delivering the click. The click counts as +delivered only when the step expected its popup to close, the click ran on a +popup surface that was open when it started, and the popup is confirmed +closed. Every other "page closed" report leaves delivery uncertain. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import pytest + +from openadapt_flow.backend import ActionDeliveryUncertain, PageCloseExpectationBackend +from openadapt_flow.backends import playwright_backend as pw +from openadapt_flow.backends.playwright_backend import PlaywrightBackend +from openadapt_flow.ir import ActionKind, Step, SurfaceSpec, Workflow +from openadapt_flow.runtime.replayer import Replayer + + +class TargetClosedError(Exception): + """Stands in for Playwright's error of the same name.""" + + +class _Mouse: + def __init__(self, page: "_Page") -> None: + self._page = page + self.clicks: list[tuple[int, int]] = [] + + def click(self, x: int, y: int, **_: Any) -> None: + self.clicks.append((x, y)) + self._page.on_click() + + dblclick = click + + +class _Page: + """A page whose click closes it (or not) and then raises (or not).""" + + def __init__( + self, + *, + closes: bool = True, + raises: Optional[Exception] = None, + closed: bool = False, + ) -> None: + self.mouse = _Mouse(self) + self._closes = closes + self._raises = raises + self.closed = closed + + def on_click(self) -> None: + if self._closes: + self.closed = True + if self._raises is not None: + raise self._raises + + def is_closed(self) -> bool: + return self.closed + + def wait_for_timeout(self, ms: float) -> None: + if self.closed: + raise TargetClosedError("closed") + + +def _backend(page: _Page, *, popup: bool = True) -> PlaywrightBackend: + return PlaywrightBackend(page, closed_page_frame=popup) # type: ignore[arg-type] + + +@pytest.fixture(autouse=True) +def _fast_confirm(monkeypatch): + monkeypatch.setattr(pw, "_PAGE_CLOSE_CONFIRM_S", 0.05) + + +def test_expected_close_is_delivered() -> None: + page = _Page(raises=TargetClosedError("Target page has been closed")) + backend = _backend(page) + assert isinstance(backend, PageCloseExpectationBackend) + backend.expect_page_close() + backend.click(10, 20) + assert page.mouse.clicks == [(10, 20)] + assert backend.page_closed() + + +def test_unexpected_close_stays_uncertain() -> None: + backend = _backend(_Page(raises=TargetClosedError("closed"))) + with pytest.raises(ActionDeliveryUncertain) as caught: + backend.click(10, 20) + assert caught.value.operation == "coordinate_click" + assert caught.value.cause_type == "TargetClosedError" + + +def test_expectation_is_honoured_only_on_a_popup_surface() -> None: + backend = _backend(_Page(raises=TargetClosedError("closed")), popup=False) + backend.expect_page_close() + with pytest.raises(ActionDeliveryUncertain): + backend.click(10, 20) + + +def test_page_still_open_after_the_error_stays_uncertain() -> None: + backend = _backend(_Page(closes=False, raises=TargetClosedError("closed"))) + backend.expect_page_close() + with pytest.raises(ActionDeliveryUncertain): + backend.click(10, 20) + + +def test_page_already_closed_before_the_click_stays_uncertain() -> None: + backend = _backend(_Page(closed=True, raises=TargetClosedError("closed"))) + backend.expect_page_close() + with pytest.raises(ActionDeliveryUncertain): + backend.click(10, 20) + + +def test_expectation_is_one_shot() -> None: + page = _Page(closes=False, raises=None) + backend = _backend(page) + backend.expect_page_close() + backend.click(1, 1) # consumes the expectation without closing + page._closes, page._raises = True, TargetClosedError("closed") + with pytest.raises(ActionDeliveryUncertain): + backend.click(2, 2) + + +def test_cancelled_expectation_is_not_honoured() -> None: + backend = _backend(_Page(raises=TargetClosedError("closed"))) + backend.expect_page_close() + backend.cancel_page_close() + with pytest.raises(ActionDeliveryUncertain): + backend.click(10, 20) + + +def test_other_errors_are_not_reinterpreted() -> None: + backend = _backend(_Page(raises=RuntimeError("boom"))) + backend.expect_page_close() + with pytest.raises(RuntimeError, match="boom"): + backend.click(10, 20) + + +# ---------------------------------------------------------------- the IR + + +def _surfaces() -> list[SurfaceSpec]: + return [ + SurfaceSpec(id="main", kind="web"), + SurfaceSpec( + id="popup_1", + kind="web", + opened_by_step="s0", + opener="main", + opener_link="window_opener", + origin="https://app.test", + url_pattern=r"https://app\.test/approve", + ), + ] + + +def _steps(closing: Step) -> list[Step]: + return [ + Step(id="s0", intent="open", action=ActionKind.CLICK, opens=["popup_1"]), + Step( + id="s1", + intent="switch", + action=ActionKind.SWITCH_SURFACE, + surface="popup_1", + ), + closing, + ] + + +def test_closes_surface_is_valid_on_a_popup_click() -> None: + Workflow( + name="w", + surfaces=_surfaces(), + steps=_steps( + Step( + id="s2", + intent="approve", + action=ActionKind.CLICK, + surface="popup_1", + closes_surface=True, + ) + ), + ) + + +def test_closes_surface_needs_a_click() -> None: + with pytest.raises(ValueError, match="only on a click"): + Step( + id="s2", + intent="type", + action=ActionKind.TYPE, + text="x", + surface="popup_1", + closes_surface=True, + ) + + +def test_closes_surface_needs_a_page_surface() -> None: + with pytest.raises(ValueError, match="does not run on a page a step opened"): + Workflow( + name="w", + surfaces=_surfaces(), + steps=[ + Step( + id="s0", + intent="open", + action=ActionKind.CLICK, + surface="main", + opens=["popup_1"], + closes_surface=True, + ), + ], + ) + + +# ------------------------------------------------------------ the replayer + + +class _ExpectingBackend: + def __init__(self) -> None: + self.events: list[str] = [] + + def expect_page_close(self) -> None: + self.events.append("expect") + + def cancel_page_close(self) -> None: + self.events.append("cancel") + + def page_closed(self) -> bool: + return False + + +@pytest.mark.parametrize("closes", [True, False]) +def test_replayer_arms_the_expectation_only_for_a_closing_step( + monkeypatch, closes: bool +) -> None: + backend = _ExpectingBackend() + replayer = Replayer.__new__(Replayer) + replayer.backend = backend # type: ignore[assignment] + + def act(step, resolution, params, **kwargs): + backend.events.append("act") + return None + + monkeypatch.setattr(replayer, "_act_answering_dialog", act) + step = Step( + id="s2", + intent="approve", + action=ActionKind.CLICK, + surface="popup_1", + closes_surface=closes, + ) + assert replayer._act(step, None, {}) is None + assert backend.events == (["expect", "act", "cancel"] if closes else ["act"])