From b502f494e2dda5b26459c2a6a9af81c8645c668a Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 24 Oct 2026 02:12:50 +0900 Subject: [PATCH] test: wait for an approval dialog to compose before reading it The operation-journey driver read `.confirm-title` as soon as app.screen was a ConfirmScreen. Textual makes a pushed screen current before it composes the screen's widgets, so on a loaded runner the poll landed in between and the read raised "No nodes match '.confirm-title' on ConfirmScreen()". That failed two journeys on main's 3.11 (#478); the second NoMatches in that log came from the journey tearing down after the first. handle() now first waits, through _await_dialog_composed, until the current dialog has its title or is no longer current; a dialog that closes first falls through to the existing closed-before-a-decision path. The new test delays ConfirmScreen's compose by 0.3 seconds and still expects one matched dialog_observed and no hard failures; without the wait it fails every run with CI's exact pair of errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/evals/operation_app.py | 18 ++++++++++++++++++ tests/evals/test_operation_journeys.py | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/tests/evals/operation_app.py b/tests/evals/operation_app.py index 02f5d0d5..93b4d3ab 100644 --- a/tests/evals/operation_app.py +++ b/tests/evals/operation_app.py @@ -725,7 +725,25 @@ def _apply_intervention(self) -> None: f"{self._journey.id}: dialog_intervention replacement did not apply" ) + async def _await_dialog_composed(self, pilot: Any) -> None: + """Wait until a current approval dialog has its widgets, or closes. + + Textual makes a pushed screen current before it composes the screen's + widgets, and a loaded runner can poll in between (#478): reading the + title then would find none. A dialog that closes first is left to + `handle`, which already treats it as closed before a decision. + """ + screen = self._app.screen + if not isinstance(screen, ConfirmScreen): + return + await until( + pilot, + lambda: self._app.screen is not screen or bool(screen.query(".confirm-title")), + label="approval dialog composed", + ) + async def handle(self, pilot: Any) -> None: + await self._await_dialog_composed(pilot) screen = self._app.screen if not isinstance(screen, ConfirmScreen): if self._remaining <= 0 or not self._dialog_seen: diff --git a/tests/evals/test_operation_journeys.py b/tests/evals/test_operation_journeys.py index 54e97d94..7ba9d569 100644 --- a/tests/evals/test_operation_journeys.py +++ b/tests/evals/test_operation_journeys.py @@ -8,6 +8,7 @@ from __future__ import annotations +import asyncio import json import os from collections.abc import AsyncIterator, Callable @@ -31,6 +32,7 @@ from korvid.evals.scripted import ScriptedProvider from korvid.tools.executor import RecordedExecution, ToolOutcome from korvid.tools.structured import dump_yaml +from korvid.ui.widgets.confirm_screen import ConfirmScreen from . import operation_app from .operation_app import ( @@ -1331,6 +1333,25 @@ def polls_that_miss_every_dialog( assert run.audit == () +async def test_a_dialog_still_composing_is_read_once_it_has_its_widgets( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Textual makes a pushed screen current before it composes the screen's + widgets, and a loaded runner can poll in between (#478). The driver must + wait for the dialog's title instead of reading a dialog that has none.""" + original = ConfirmScreen._compose + + async def slow_compose(self: ConfirmScreen) -> None: + await asyncio.sleep(0.3) + await original(self) + + monkeypatch.setattr(ConfirmScreen, "_compose", slow_compose) + run = await run_scripted_journey("scale-deployment-up", tmp_path) + observed = [entry for entry in run.journal if entry["event"] == "dialog_observed"] + assert [entry["result"] for entry in observed] == ["matched"] + assert run.grade.hard_failures == () + + def test_the_replacement_journey_declares_its_intervention_in_the_fixture() -> None: """B1: the mid-dialog replacement is fixture data, not test code, so `tests.evals.operation_campaign` runs the identical journey."""