diff --git a/crates/ironclaw_webui/frontend/src/pages/automations/components/automation-detail-panel.test.ts b/crates/ironclaw_webui/frontend/src/pages/automations/components/automation-detail-panel.test.ts index 0296c3e54fb..a86575f117e 100644 --- a/crates/ironclaw_webui/frontend/src/pages/automations/components/automation-detail-panel.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/automations/components/automation-detail-panel.test.ts @@ -208,6 +208,28 @@ function createHarness({ onRenameAutomation = () => {} } = {}) { }; } +test("AutomationDetailPanel exposes stable lifecycle action selectors", () => { + const harness = createHarness(); + + let rendered = harness.render(); + let actionButton = componentProps(rendered, harness.Button).find( + (button) => button["data-testid"] === "automation-action-button", + ); + assert.ok(actionButton, "pause action button should render"); + assert.equal(actionButton["data-automation-id"], "automation-alpha"); + assert.equal(actionButton["data-automation-action"], "pause"); + + rendered = harness.render({ + automation: { ...automation(), state: "paused" }, + }); + actionButton = componentProps(rendered, harness.Button).find( + (button) => button["data-testid"] === "automation-action-button", + ); + assert.ok(actionButton, "resume action button should render"); + assert.equal(actionButton["data-automation-id"], "automation-alpha"); + assert.equal(actionButton["data-automation-action"], "resume"); +}); + test("AutomationDetailPanel deletes only after confirming the shared dialog", () => { const deletions = []; const harness = createHarness(); @@ -216,9 +238,10 @@ test("AutomationDetailPanel deletes only after confirming the shared dialog", () onDeleteAutomation: (automationId) => deletions.push(automationId), }); const deleteButton = componentProps(rendered, harness.Button).find( - (button) => button["aria-label"] === "Delete: Daily status", + (button) => button["data-testid"] === "automation-delete-button", ); assert.ok(deleteButton, "delete button should render"); + assert.equal(deleteButton["data-automation-id"], "automation-alpha"); deleteButton.onClick(); assert.deepEqual(deletions, []); diff --git a/crates/ironclaw_webui/frontend/src/pages/automations/components/automation-detail-panel.tsx b/crates/ironclaw_webui/frontend/src/pages/automations/components/automation-detail-panel.tsx index b123500d394..69143b6f986 100644 --- a/crates/ironclaw_webui/frontend/src/pages/automations/components/automation-detail-panel.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/automations/components/automation-detail-panel.tsx @@ -222,6 +222,9 @@ export function AutomationDetailPanel({ type="button" variant={canResume ? "primary" : "secondary"} size="icon-sm" + data-testid="automation-action-button" + data-automation-id={automation.automation_id} + data-automation-action={canResume ? "resume" : "pause"} aria-label={actionTitle} title={actionTitle} disabled={isMutating} @@ -234,6 +237,8 @@ export function AutomationDetailPanel({ type="button" variant="danger" size="icon-sm" + data-testid="automation-delete-button" + data-automation-id={automation.automation_id} aria-label={deleteTitle} title={deleteTitle} disabled={isMutating} @@ -290,14 +295,19 @@ export function AutomationDetailPanel({ )} - setDeleteDialogOpen(false)} - /> +
+ setDeleteDialogOpen(false)} + /> +
); } diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 5fe6d1c22d6..0906271b66a 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -369,7 +369,7 @@ entries. ### 6.6 Automations, routines & projects | The user can… | Evidence | |---|---| -| Create/rename/filter automations in the UI, retry failed runs, dismiss error toasts | `test_reborn_webui_v2_smoke.py` (automation tests), `test_reborn_webui_v2_automation_trace_outbound_api.py` (4) | +| Create an automation through chat; rename, pause, resume, reload, and delete it through the UI with persisted API state; filter automations, retry failed runs, and dismiss error toasts | `test_reborn_webui_v2_smoke.py::test_reborn_v2_automation_lifecycle_persists_from_ui`, other automation tests in that file, `test_reborn_webui_v2_automation_trace_outbound_api.py` (4) | | Create event-triggered routines, have them fire on match, respect cooldown, pause/resume | `test_routine_event_batch.py` (8) | | Run a full-job routine end-to-end with tools, trigger it manually, see failures in the UI | `test_routine_full_job.py` (3) | | Have routines run with injected OAuth credentials | `test_routine_oauth_credential_injection.py` (3) | diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 4cac93f3b98..172edf39e56 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -453,6 +453,16 @@ async def dismiss_native_dialog(dialog) -> None: "automation_name_button_for": ( "[data-testid='automation-name-button'][data-automation-id='{id}']" ), + "automation_action_for": ( + "[data-testid='automation-action-button'][data-automation-id='{id}']" + ), + "automation_delete_for": ( + "[data-testid='automation-delete-button'][data-automation-id='{id}']" + ), + "automation_delete_dialog_for": ( + "[data-testid='automation-delete-dialog'][data-automation-id='{id}'] " + "[role='dialog']" + ), "automation_filter_for": ( "[data-testid='automation-filter'][data-filter='{filter}']" ), diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py index e7f57f5c917..57d753e8767 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py @@ -27,7 +27,9 @@ import asyncio import json import re +import sys import uuid +from collections.abc import Callable from urllib.parse import parse_qs, urlparse import aiohttp @@ -179,30 +181,46 @@ def _assert_control_typography( ) -async def _wait_for_automation_named( +async def _wait_for_automation( client: httpx.AsyncClient, base_url: str, - name: str, + predicate: Callable[[dict], bool], + expectation: str, *, + absent: bool = False, timeout: float = 30.0, -) -> dict: +) -> dict | None: last_body: dict = {} try: async with asyncio.timeout(timeout): while True: response = await client.get( f"{base_url}/api/webchat/v2/automations", + params={ + "include_completed": "true", + "limit": 100, + "run_limit": 0, + }, timeout=5, ) response.raise_for_status() last_body = response.json() - for automation in last_body.get("automations", []): - if automation.get("name") == name: - return automation + automation = next( + ( + item + for item in last_body.get("automations", []) + if predicate(item) + ), + None, + ) + if absent and automation is None: + return None + if not absent and automation is not None: + return automation await asyncio.sleep(0.5) except TimeoutError: raise AssertionError( - f"Timed out waiting for automation {name!r}. Last body: {last_body}" + f"Timed out waiting for automation {expectation}. Last body: {last_body}" ) from None @@ -1249,68 +1267,190 @@ async def test_reborn_v2_ui_enter_submits_initial_and_follow_up_messages( await expect(assistant_messages.last).to_contain_text("I understand your request.") -async def test_reborn_v2_automation_rename_persists_from_ui( +async def test_reborn_v2_automation_lifecycle_persists_from_ui( reborn_v2_server, reborn_v2_browser ): - """Creating an automation through chat can be renamed from /automations.""" + """Automation UI mutations persist through the real served API.""" label = f"ui-{uuid.uuid4().hex[:8]}" original_name = f"E2E rename original {label}" renamed_name = f"E2E rename updated {label}" headers = {"Authorization": f"Bearer {REBORN_V2_AUTH_TOKEN}"} + automation_id = None + automation_deleted = False + context = None async with httpx.AsyncClient(headers=headers) as client: - thread_id = await _create_thread(client, reborn_v2_server) - await _send_message( - client, - reborn_v2_server, - thread_id, - f"reborn create automation rename target {label}", - ) - await _wait_for_assistant_message(client, reborn_v2_server, thread_id) - automation = await _wait_for_automation_named( - client, reborn_v2_server, original_name - ) - automation_id = automation["automation_id"] + try: + thread_id = await _create_thread(client, reborn_v2_server) + await _send_message( + client, + reborn_v2_server, + thread_id, + f"reborn create automation rename target {label}", + ) + await _wait_for_assistant_message(client, reborn_v2_server, thread_id) + automation = await _wait_for_automation( + client, + reborn_v2_server, + lambda item: item.get("name") == original_name, + f"named {original_name!r}", + ) + assert automation is not None + automation_id = automation["automation_id"] + + async def wait_for_automation_id( + *, + expected_name: str | None = None, + expected_state: str | None = None, + absent: bool = False, + ) -> dict | None: + def matches(item: dict) -> bool: + return ( + item.get("automation_id") == automation_id + and ( + expected_name is None + or item.get("name") == expected_name + ) + and ( + expected_state is None + or item.get("state") == expected_state + ) + ) + + details = [] + if expected_name is not None: + details.append(f"name {expected_name!r}") + if expected_state is not None: + details.append(f"state {expected_state!r}") + if absent: + expectation = f"{automation_id!r} to be absent" + elif details: + expectation = f"{automation_id!r} with {' and '.join(details)}" + else: + expectation = f"{automation_id!r} in any state" + return await _wait_for_automation( + client, + reborn_v2_server, + matches, + expectation, + absent=absent, + ) - context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) - page = await context.new_page() - try: - await page.goto(f"{reborn_v2_server}/automations?token={REBORN_V2_AUTH_TOKEN}") - row_selector = SEL_V2["automation_row_for"].format(id=automation_id) - row = page.locator(row_selector) - await expect(row).to_be_visible(timeout=15000) - await row.locator( - SEL_V2["automation_name_button_for"].format(id=automation_id) - ).click() + assert automation["state"] == "scheduled" - await expect(page.locator(SEL_V2["automation_detail"])).to_be_visible( - timeout=15000 - ) - await expect(page.locator(SEL_V2["automation_detail_title"])).to_contain_text( - original_name - ) + context = await reborn_v2_browser.new_context( + viewport={"width": 1280, "height": 720} + ) + page = await context.new_page() + await page.goto( + f"{reborn_v2_server}/automations?token={REBORN_V2_AUTH_TOKEN}" + ) + row_selector = SEL_V2["automation_row_for"].format(id=automation_id) + name_button_selector = SEL_V2["automation_name_button_for"].format( + id=automation_id + ) + action_button_selector = SEL_V2["automation_action_for"].format( + id=automation_id + ) + delete_button_selector = SEL_V2["automation_delete_for"].format( + id=automation_id + ) + delete_dialog_selector = SEL_V2[ + "automation_delete_dialog_for" + ].format(id=automation_id) + row = page.locator(row_selector) + await expect(row).to_be_visible(timeout=15000) + await row.locator(name_button_selector).click() + + await expect(page.locator(SEL_V2["automation_detail"])).to_be_visible( + timeout=15000 + ) + await expect( + page.locator(SEL_V2["automation_detail_title"]) + ).to_contain_text(original_name) - await page.locator(SEL_V2["automation_rename_button"]).click() - rename_input = page.locator(SEL_V2["automation_rename_input"]) - await expect(rename_input).to_have_value(original_name) - await rename_input.fill(f" {renamed_name} ") - await page.locator(SEL_V2["automation_rename_save"]).click() + await page.locator(SEL_V2["automation_rename_button"]).click() + rename_input = page.locator(SEL_V2["automation_rename_input"]) + await expect(rename_input).to_have_value(original_name) + await rename_input.fill(f" {renamed_name} ") + await page.locator(SEL_V2["automation_rename_save"]).click() - await expect(page.locator(SEL_V2["automation_detail_title"])).to_contain_text( - renamed_name, - timeout=15000, - ) - await expect(row).to_contain_text(renamed_name) + await expect( + page.locator(SEL_V2["automation_detail_title"]) + ).to_contain_text(renamed_name, timeout=15000) + renamed = await wait_for_automation_id(expected_name=renamed_name) + assert renamed is not None + assert renamed["automation_id"] == automation_id + + await page.reload() + row = page.locator(row_selector) + await expect(row).to_contain_text(renamed_name, timeout=15000) + await row.locator(name_button_selector).click() + + await page.locator(action_button_selector).click() + paused = await wait_for_automation_id(expected_state="paused") + assert paused is not None + assert paused["name"] == renamed_name + + await page.reload() + row = page.locator(row_selector) + await expect(row).to_contain_text("Paused", timeout=15000) + await row.locator(name_button_selector).click() + await expect(page.locator(action_button_selector)).to_have_attribute( + "data-automation-action", + "resume", + timeout=15000, + ) + paused_after_reload = await wait_for_automation_id( + expected_state="paused" + ) + assert paused_after_reload is not None + + await page.locator(action_button_selector).click() + resumed = await wait_for_automation_id(expected_state="scheduled") + assert resumed is not None + assert resumed["name"] == renamed_name + + await page.reload() + row = page.locator(row_selector) + await expect(row).to_contain_text("Scheduled", timeout=15000) + await row.locator(name_button_selector).click() + await expect(page.locator(action_button_selector)).to_have_attribute( + "data-automation-action", + "pause", + timeout=15000, + ) + resumed_after_reload = await wait_for_automation_id( + expected_state="scheduled" + ) + assert resumed_after_reload is not None - await page.reload() - row = page.locator(row_selector) - await expect(row).to_contain_text(renamed_name, timeout=15000) - finally: - await context.close() + await page.locator(delete_button_selector).click() + confirmation = page.locator(delete_dialog_selector) + await expect(confirmation).to_be_visible(timeout=15000) + await confirmation.locator(SEL_V2["confirm_dialog_confirm"]).click() - async with httpx.AsyncClient(headers=headers) as client: - renamed = await _wait_for_automation_named(client, reborn_v2_server, renamed_name) - assert renamed["automation_id"] == automation_id + await expect(page.locator(row_selector)).to_have_count(0, timeout=15000) + await wait_for_automation_id(absent=True) + automation_deleted = True + finally: + # Keep the module-scoped server isolated if an earlier assertion fails. + test_failed = sys.exc_info()[0] is not None + cleanup_error = None + if automation_id is not None and not automation_deleted: + try: + cleanup_response = await client.delete( + f"{reborn_v2_server}/api/webchat/v2/automations/{automation_id}", + timeout=5, + ) + cleanup_response.raise_for_status() + await wait_for_automation_id(absent=True) + except (AssertionError, httpx.HTTPError) as error: + cleanup_error = error + if context is not None: + await context.close() + if cleanup_error is not None and not test_failed: + raise cleanup_error async def test_reborn_v2_automation_filter_keeps_list_visible_while_loading(