From 71cf2b322045892876d7ef254ce2eea96bf6ba64 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:20:50 +0800 Subject: [PATCH 1/9] fix(webui): sanitize automation action errors --- .../ironclaw_webui_v2/frontend/src/i18n/ar.ts | 1 + .../ironclaw_webui_v2/frontend/src/i18n/de.ts | 1 + .../ironclaw_webui_v2/frontend/src/i18n/en.ts | 1 + .../ironclaw_webui_v2/frontend/src/i18n/es.ts | 1 + .../ironclaw_webui_v2/frontend/src/i18n/fr.ts | 1 + .../ironclaw_webui_v2/frontend/src/i18n/hi.ts | 1 + .../ironclaw_webui_v2/frontend/src/i18n/ja.ts | 1 + .../ironclaw_webui_v2/frontend/src/i18n/ko.ts | 1 + .../frontend/src/i18n/pt-BR.ts | 1 + .../ironclaw_webui_v2/frontend/src/i18n/uk.ts | 1 + .../frontend/src/i18n/zh-CN.ts | 1 + .../pages/automations/automations-page.tsx | 17 +++++++-- .../pages/automations/hooks/useAutomations.ts | 35 +++++++++++++------ 13 files changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts index 7e5a44a25cc..63794539ee4 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts @@ -739,6 +739,7 @@ registerPack("ar", { "automations.filter.completed": "مكتمل", "automations.refresh": "تحديث عمليات الأتمتة", "automations.error.loadFailed": "غير قادر على تحميل عمليات الأتمتة", + "automations.error.actionFailed": "تعذّر تحديث الأتمتة. يُرجى المحاولة مرة أخرى.", "automations.rename.action": "إعادة تسمية الأتمتة", "automations.rename.nameLabel": "اسم الأتمتة", "automations.rename.nameRequired": "اسم الأتمتة مطلوب.", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts index 62d0a248760..e2b53bcf59b 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts @@ -739,6 +739,7 @@ registerPack("de", { "automations.filter.completed": "Abgeschlossen", "automations.refresh": "Automatisierungen aktualisieren", "automations.error.loadFailed": "Automatisierungen können nicht geladen werden", + "automations.error.actionFailed": "Die Automatisierung konnte nicht aktualisiert werden. Versuche es erneut.", "automations.rename.action": "Automatisierung umbenennen", "automations.rename.nameLabel": "Automatisierungsname", "automations.rename.nameRequired": "Automatisierungsname ist erforderlich.", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts index 2f89f28ac5a..1425519f4ea 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts @@ -786,6 +786,7 @@ registerPack("en", { "automations.filter.completed": "Completed", "automations.refresh": "Refresh automations", "automations.error.loadFailed": "Unable to load automations", + "automations.error.actionFailed": "Unable to update the automation. Please try again.", "automations.rename.action": "Rename automation", "automations.rename.nameLabel": "Automation name", "automations.rename.nameRequired": "Automation name is required.", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts index 81fed4dfee7..cf10e4b90bd 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts @@ -739,6 +739,7 @@ registerPack("es", { "automations.filter.completed": "Completadas", "automations.refresh": "Actualizar automatizaciones", "automations.error.loadFailed": "No se pueden cargar automatizaciones", + "automations.error.actionFailed": "No se pudo actualizar la automatización. Inténtalo de nuevo.", "automations.rename.action": "Cambiar nombre de automatización", "automations.rename.nameLabel": "Nombre de automatización", "automations.rename.nameRequired": "El nombre de la automatización es obligatorio.", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts index 3153f39dbeb..0b7f75152d1 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts @@ -739,6 +739,7 @@ registerPack("fr", { "automations.filter.completed": "Terminées", "automations.refresh": "Actualiser les automatisations", "automations.error.loadFailed": "Impossible de charger les automatisations", + "automations.error.actionFailed": "Impossible de mettre à jour l’automatisation. Réessayez.", "automations.rename.action": "Renommer l’automatisation", "automations.rename.nameLabel": "Nom de l’automatisation", "automations.rename.nameRequired": "Le nom de l’automatisation est requis.", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts index cd2dd4d8539..8ff05c0f81b 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts @@ -739,6 +739,7 @@ registerPack("hi", { "automations.filter.completed": "पूर्ण", "automations.refresh": "ऑटोमेशन ताज़ा करें", "automations.error.loadFailed": "ऑटोमेशन लोड करने में असमर्थ", + "automations.error.actionFailed": "ऑटोमेशन अपडेट नहीं किया जा सका। फिर से कोशिश करें।", "automations.rename.action": "ऑटोमेशन का नाम बदलें", "automations.rename.nameLabel": "ऑटोमेशन नाम", "automations.rename.nameRequired": "ऑटोमेशन नाम आवश्यक है.", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts index cdc117e9437..be92ccb313c 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts @@ -739,6 +739,7 @@ registerPack("ja", { "automations.filter.completed": "完了", "automations.refresh": "自動化の更新", "automations.error.loadFailed": "自動化をロードできません", + "automations.error.actionFailed": "自動化を更新できませんでした。もう一度お試しください。", "automations.rename.action": "自動化名を変更", "automations.rename.nameLabel": "自動化名", "automations.rename.nameRequired": "自動化名は必須です。", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts index 222b7f255fc..ab3d81173d6 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts @@ -739,6 +739,7 @@ registerPack("ko", { "automations.filter.completed": "완료됨", "automations.refresh": "자동화 새로 고침", "automations.error.loadFailed": "자동화를 로드할 수 없음", + "automations.error.actionFailed": "자동화를 업데이트할 수 없습니다. 다시 시도하세요.", "automations.rename.action": "자동화 이름 변경", "automations.rename.nameLabel": "자동화 이름", "automations.rename.nameRequired": "자동화 이름은 필수입니다.", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts index 45bb44f18a8..06f713aca8b 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts @@ -739,6 +739,7 @@ registerPack("pt-BR", { "automations.filter.completed": "Concluídas", "automations.refresh": "Atualizar automações", "automations.error.loadFailed": "Não é possível carregar automações", + "automations.error.actionFailed": "Não foi possível atualizar a automação. Tente novamente.", "automations.rename.action": "Renomear automação", "automations.rename.nameLabel": "Nome da automação", "automations.rename.nameRequired": "O nome da automação é obrigatório.", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts index 4fff8f41468..78a19c36efd 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts @@ -739,6 +739,7 @@ registerPack("uk", { "automations.filter.completed": "Завершені", "automations.refresh": "Оновити автоматизації", "automations.error.loadFailed": "Неможливо завантажити автоматизації", + "automations.error.actionFailed": "Не вдалося оновити автоматизацію. Спробуйте ще раз.", "automations.rename.action": "Перейменувати автоматизацію", "automations.rename.nameLabel": "Назва автоматизації", "automations.rename.nameRequired": "Назва автоматизації обов'язкова.", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts index d185fdf75d3..5e6a4362a70 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts @@ -739,6 +739,7 @@ registerPack("zh-CN", { "automations.filter.completed": "已完成", "automations.refresh": "更新最新自动化", "automations.error.loadFailed": "提取系统自动化计划列表不成功!", + "automations.error.actionFailed": "无法更新自动化。请重试。", "automations.rename.action": "重命名自动化", "automations.rename.nameLabel": "自动化名称", "automations.rename.nameRequired": "自动化名称不能为空。", diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx index 1e107e4a6e4..0c2e75dc359 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Icon } from "../../design-system/icons"; import { useT } from "../../lib/i18n"; import { AutomationDeliveryDefaultsPanel } from "./components/automation-delivery-defaults-panel"; import { AutomationsList } from "./components/automations-list"; @@ -60,9 +61,21 @@ export function AutomationsPage() { {automationsState.actionError && (
- {automationsState.actionError.message} + + {t("automations.error.actionFailed")} + +
)} diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts index d611933e30f..38077fb70cf 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts @@ -29,6 +29,7 @@ type RenameAutomationVariables = { export function useAutomations(includeCompleted = false) { const { t, lang } = useI18n(); const queryClient = useQueryClient(); + const [hasActionError, setHasActionError] = React.useState(false); const query = useQuery({ queryKey: ["automations", { includeCompleted }], queryFn: () => @@ -73,22 +74,40 @@ export function useAutomations(includeCompleted = false) { const invalidateAutomations = React.useCallback(() => { queryClient.invalidateQueries({ queryKey: ["automations"] }); }, [queryClient]); + const clearActionError = React.useCallback(() => { + setHasActionError(false); + }, []); + const showActionError = React.useCallback(() => { + setHasActionError(true); + }, []); + const handleActionSuccess = React.useCallback(() => { + clearActionError(); + invalidateAutomations(); + }, [clearActionError, invalidateAutomations]); const pauseMutation = useMutation({ mutationFn: (automationId: string) => pauseAutomation({ automationId }), - onSuccess: invalidateAutomations, + onMutate: clearActionError, + onError: showActionError, + onSuccess: handleActionSuccess, }); const resumeMutation = useMutation({ mutationFn: (automationId: string) => resumeAutomation({ automationId }), - onSuccess: invalidateAutomations, + onMutate: clearActionError, + onError: showActionError, + onSuccess: handleActionSuccess, }); const renameMutation = useMutation({ mutationFn: ({ automationId, name }: RenameAutomationVariables) => renameAutomation({ automationId, name }), - onSuccess: invalidateAutomations, + onMutate: clearActionError, + onError: showActionError, + onSuccess: handleActionSuccess, }); const deleteMutation = useMutation({ mutationFn: (automationId: string) => deleteAutomation({ automationId }), - onSuccess: invalidateAutomations, + onMutate: clearActionError, + onError: showActionError, + onSuccess: handleActionSuccess, }); return { @@ -103,12 +122,8 @@ export function useAutomations(includeCompleted = false) { renameMutation.isPending || deleteMutation.isPending, error: query.error || null, - actionError: - pauseMutation.error || - resumeMutation.error || - renameMutation.error || - deleteMutation.error || - null, + actionError: hasActionError, + dismissActionError: clearActionError, pauseAutomation: pauseMutation.mutate, resumeAutomation: resumeMutation.mutate, renameAutomation: renameMutation.mutate, From e4e99755340b1e1c6a8b8a93f312b464472d06b3 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:24:15 +0800 Subject: [PATCH 2/9] test(e2e): cover automation action error recovery --- tests/e2e/helpers.py | 1 + .../scenarios/test_reborn_webui_v2_smoke.py | 103 ++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 1201514af51..9537e5e4ce8 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -370,6 +370,7 @@ "automation_rename_button": "[data-testid='automation-rename-button']", "automation_rename_input": "[data-testid='automation-rename-input']", "automation_rename_save": "[data-testid='automation-rename-save']", + "automation_action_error": "[data-testid='automation-action-error']", "automation_run_open": "[data-testid='automation-run-open']", "automation_run_logs": "[data-testid='automation-run-logs']", "skills_card": "#skills-list .ext-card", diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py index 8469aa5b98e..1c96bc39035 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py @@ -424,6 +424,109 @@ async def test_reborn_v2_automation_rename_persists_from_ui( assert renamed["automation_id"] == automation_id +async def test_reborn_v2_automation_action_error_is_safe_dismissible_and_cleared_on_retry( + reborn_v2_server, reborn_v2_browser +): + """Automation mutations never expose raw errors and clear stale failures.""" + automation_id = "11111111-2222-3333-4444-555555555555" + raw_error = "postgres failed: secret_internal_automation_table" + attempt_count = 0 + retry_started = asyncio.Event() + release_retry = asyncio.Event() + retry_completed = asyncio.Event() + + context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) + page = await context.new_page() + + async def handle_automations(route) -> None: + nonlocal attempt_count + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "scheduler_enabled": True, + "automations": [ + { + "automation_id": automation_id, + "name": "Safe action error regression", + "source": { + "type": "schedule", + "cron": "0 9 * * *", + "timezone": "UTC", + }, + "state": "active", + "next_run_at": "2026-07-18T09:00:00Z", + "recent_runs": [], + } + ], + } + ), + ) + return + + attempt_count += 1 + if attempt_count <= 2: + await route.fulfill( + status=500, + content_type="text/plain", + body=raw_error, + ) + return + + retry_started.set() + await release_retry.wait() + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"updated": True}), + ) + retry_completed.set() + + await page.route("**/api/webchat/v2/automations**", handle_automations) + row_selector = SEL_V2["automation_row_for"].format(id=automation_id) + error_banner = page.locator(SEL_V2["automation_action_error"]) + + async def submit_rename(name: str) -> None: + 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() + await page.locator(SEL_V2["automation_rename_button"]).click() + rename_input = page.locator(SEL_V2["automation_rename_input"]) + await rename_input.fill(name) + await page.locator(SEL_V2["automation_rename_save"]).click() + + try: + await page.goto(f"{reborn_v2_server}/v2/automations?token={REBORN_V2_AUTH_TOKEN}") + + await submit_rename("First failed rename") + await expect(error_banner).to_be_visible(timeout=10000) + await expect(error_banner).to_have_text( + "Unable to update the automation. Please try again." + ) + await expect(error_banner).not_to_contain_text(raw_error) + + await error_banner.get_by_role("button", name="Dismiss").click() + await expect(error_banner).to_have_count(0) + + await submit_rename("Second failed rename") + await expect(error_banner).to_be_visible(timeout=10000) + + await submit_rename("Successful retry") + await asyncio.wait_for(retry_started.wait(), timeout=10) + await expect(error_banner).to_have_count(0) + + release_retry.set() + await asyncio.wait_for(retry_completed.wait(), timeout=10) + await expect(error_banner).to_have_count(0) + finally: + release_retry.set() + await context.close() + + async def test_reborn_v2_automation_failed_run_actions_are_clickable( reborn_v2_server, reborn_v2_browser ): From 534e5d3741ddc24e5948eeaf71483cb6d6dbca50 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:40:33 +0800 Subject: [PATCH 3/9] fix(webui): log automation action errors --- .../src/pages/automations/hooks/useAutomations.ts | 3 ++- tests/e2e/scenarios/test_reborn_webui_v2_smoke.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts index 38077fb70cf..54cc53126a9 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts @@ -77,7 +77,8 @@ export function useAutomations(includeCompleted = false) { const clearActionError = React.useCallback(() => { setHasActionError(false); }, []); - const showActionError = React.useCallback(() => { + const showActionError = React.useCallback((error: unknown) => { + console.error("Automation action failed:", error); setHasActionError(true); }, []); const handleActionSuccess = React.useCallback(() => { diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py index 1c96bc39035..de498829b1b 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py @@ -434,10 +434,17 @@ async def test_reborn_v2_automation_action_error_is_safe_dismissible_and_cleared retry_started = asyncio.Event() release_retry = asyncio.Event() retry_completed = asyncio.Event() + raw_error_logged = asyncio.Event() context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) page = await context.new_page() + def capture_console_error(message) -> None: + if message.type == "error" and raw_error in message.text: + raw_error_logged.set() + + page.on("console", capture_console_error) + async def handle_automations(route) -> None: nonlocal attempt_count if route.request.method == "GET": @@ -508,6 +515,7 @@ async def submit_rename(name: str) -> None: "Unable to update the automation. Please try again." ) await expect(error_banner).not_to_contain_text(raw_error) + await asyncio.wait_for(raw_error_logged.wait(), timeout=10) await error_banner.get_by_role("button", name="Dismiss").click() await expect(error_banner).to_have_count(0) From fd21497888ab4398ff392c0c97e648f960f3c719 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:32:01 +0800 Subject: [PATCH 4/9] fix(webui): secure automation action error state --- .../pages/automations/hooks/useAutomations.ts | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts index 54cc53126a9..fc6bd2daf07 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts @@ -26,10 +26,22 @@ type RenameAutomationVariables = { name: string; }; +type ActionMutationContext = { + sequence: number; +}; + +function isLatestAutomationAction( + context: ActionMutationContext | undefined, + latestSequence: number +) { + return context?.sequence === latestSequence; +} + export function useAutomations(includeCompleted = false) { const { t, lang } = useI18n(); const queryClient = useQueryClient(); const [hasActionError, setHasActionError] = React.useState(false); + const latestActionSequence = React.useRef(0); const query = useQuery({ queryKey: ["automations", { includeCompleted }], queryFn: () => @@ -77,36 +89,55 @@ export function useAutomations(includeCompleted = false) { const clearActionError = React.useCallback(() => { setHasActionError(false); }, []); - const showActionError = React.useCallback((error: unknown) => { - console.error("Automation action failed:", error); - setHasActionError(true); - }, []); - const handleActionSuccess = React.useCallback(() => { + const beginAction = React.useCallback((): ActionMutationContext => { + const sequence = latestActionSequence.current + 1; + latestActionSequence.current = sequence; clearActionError(); + return { sequence }; + }, [clearActionError]); + const showActionError = React.useCallback(( + _error: unknown, + _variables: unknown, + context: ActionMutationContext | undefined + ) => { + // A newer action deliberately supersedes older results: starting any + // action clears the banner, and a late completion must not resurrect it. + if (isLatestAutomationAction(context, latestActionSequence.current)) { + setHasActionError(true); + } + }, []); + const handleActionSuccess = React.useCallback(( + _data: unknown, + _variables: unknown, + context: ActionMutationContext | undefined + ) => { + if (isLatestAutomationAction(context, latestActionSequence.current)) { + clearActionError(); + } invalidateAutomations(); }, [clearActionError, invalidateAutomations]); const pauseMutation = useMutation({ mutationFn: (automationId: string) => pauseAutomation({ automationId }), - onMutate: clearActionError, + onMutate: beginAction, onError: showActionError, onSuccess: handleActionSuccess, }); const resumeMutation = useMutation({ mutationFn: (automationId: string) => resumeAutomation({ automationId }), - onMutate: clearActionError, + onMutate: beginAction, onError: showActionError, onSuccess: handleActionSuccess, }); const renameMutation = useMutation({ mutationFn: ({ automationId, name }: RenameAutomationVariables) => renameAutomation({ automationId, name }), - onMutate: clearActionError, + onMutate: beginAction, onError: showActionError, onSuccess: handleActionSuccess, }); const deleteMutation = useMutation({ mutationFn: (automationId: string) => deleteAutomation({ automationId }), - onMutate: clearActionError, + onMutate: beginAction, onError: showActionError, onSuccess: handleActionSuccess, }); From e857d48ecc35fa62716337f5ae7c7b55e066ad19 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:32:51 +0800 Subject: [PATCH 5/9] test(webui): cover ordered automation action failures --- .../automations/hooks/useAutomations.test.ts | 132 ++++++++++++++++++ .../scenarios/test_reborn_webui_v2_smoke.py | 43 ++++-- 2 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts new file mode 100644 index 00000000000..cc195de4ccc --- /dev/null +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts @@ -0,0 +1,132 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import ts from "typescript"; +import { test } from "vitest"; + +import { sourceTextForVmTest } from "../../../test-support/vm-module-harness"; + +function loadUseAutomations() { + const source = readFileSync(new URL("./useAutomations.ts", import.meta.url), "utf8"); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const mutationConfigs = []; + const stateUpdates = []; + const invalidations = []; + const consoleErrors = []; + const context = { + AUTOMATIONS_BASE_REFETCH_MS: 30000, + React: { + useCallback: (callback) => callback, + useEffect: () => {}, + useMemo: (factory) => factory(), + useRef: (initial) => ({ current: initial }), + useState: (initial) => [initial, (value) => stateUpdates.push(value)], + }, + automationSummary: () => ({}), + console: { + error: (...args) => consoleErrors.push(args), + }, + deleteAutomation: () => {}, + globalThis: {}, + listAutomations: () => {}, + nextAutomationsRefetchDelay: () => null, + normalizeAutomations: () => [], + pauseAutomation: () => {}, + renameAutomation: () => {}, + resumeAutomation: () => {}, + setTimeout, + clearTimeout, + useI18n: () => ({ lang: "en", t: (key) => key }), + useMutation: (config) => { + mutationConfigs.push(config); + return { isPending: false, mutate: () => {} }; + }, + useQuery: () => ({ + data: { automations: [], scheduler_enabled: true }, + error: null, + isFetching: false, + isLoading: false, + refetch: () => {}, + }), + useQueryClient: () => ({ + invalidateQueries: (query) => invalidations.push(query), + }), + }; + + vm.runInNewContext( + sourceTextForVmTest(transpiled, ["useAutomations"]), + context + ); + + return { + consoleErrors, + invalidations, + mutationConfigs, + stateUpdates, + useAutomations: context.globalThis.__testExports.useAutomations, + }; +} + +test("automation action results only update the latest action state", () => { + const { + consoleErrors, + invalidations, + mutationConfigs, + stateUpdates, + useAutomations, + } = loadUseAutomations(); + useAutomations(); + + assert.equal(mutationConfigs.length, 4); + for (const callbackName of ["onMutate", "onError", "onSuccess"]) { + assert.equal( + new Set(mutationConfigs.map((config) => config[callbackName])).size, + 1, + `all automation mutations must share ${callbackName}` + ); + } + + const firstAction = mutationConfigs[0].onMutate("automation-1"); + const secondAction = mutationConfigs[2].onMutate({ + automationId: "automation-2", + name: "New name", + }); + + mutationConfigs[0].onError( + new Error("raw backend detail"), + "automation-1", + firstAction + ); + assert.deepEqual(stateUpdates, [false, false]); + + mutationConfigs[2].onError( + new Error("raw backend detail"), + { automationId: "automation-2", name: "New name" }, + secondAction + ); + assert.deepEqual(stateUpdates, [false, false, true]); + assert.deepEqual(consoleErrors, []); + + const thirdAction = mutationConfigs[1].onMutate("automation-3"); + mutationConfigs[2].onSuccess( + { updated: true }, + { automationId: "automation-2", name: "New name" }, + secondAction + ); + assert.deepEqual(stateUpdates, [false, false, true, false]); + assert.equal(invalidations.length, 1); + + mutationConfigs[1].onSuccess( + { updated: true }, + "automation-3", + thirdAction + ); + assert.deepEqual(stateUpdates, [false, false, true, false, false]); + assert.equal(invalidations.length, 2); +}); diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py index de498829b1b..179e9d4a1e5 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py @@ -427,23 +427,20 @@ async def test_reborn_v2_automation_rename_persists_from_ui( async def test_reborn_v2_automation_action_error_is_safe_dismissible_and_cleared_on_retry( reborn_v2_server, reborn_v2_browser ): - """Automation mutations never expose raw errors and clear stale failures.""" + """Automation mutations keep raw errors private and clear stale failures.""" automation_id = "11111111-2222-3333-4444-555555555555" + automation_name = "Safe action error regression" raw_error = "postgres failed: secret_internal_automation_table" attempt_count = 0 + mutation_requests: list[tuple[str, str]] = [] + console_messages: list[str] = [] retry_started = asyncio.Event() release_retry = asyncio.Event() retry_completed = asyncio.Event() - raw_error_logged = asyncio.Event() context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) page = await context.new_page() - - def capture_console_error(message) -> None: - if message.type == "error" and raw_error in message.text: - raw_error_logged.set() - - page.on("console", capture_console_error) + page.on("console", lambda message: console_messages.append(message.text)) async def handle_automations(route) -> None: nonlocal attempt_count @@ -457,7 +454,7 @@ async def handle_automations(route) -> None: "automations": [ { "automation_id": automation_id, - "name": "Safe action error regression", + "name": automation_name, "source": { "type": "schedule", "cron": "0 9 * * *", @@ -473,6 +470,9 @@ async def handle_automations(route) -> None: ) return + mutation_requests.append( + (route.request.method, urlparse(route.request.url).path) + ) attempt_count += 1 if attempt_count <= 2: await route.fulfill( @@ -515,21 +515,40 @@ async def submit_rename(name: str) -> None: "Unable to update the automation. Please try again." ) await expect(error_banner).not_to_contain_text(raw_error) - await asyncio.wait_for(raw_error_logged.wait(), timeout=10) + assert not any(raw_error in message for message in console_messages) await error_banner.get_by_role("button", name="Dismiss").click() await expect(error_banner).to_have_count(0) - await submit_rename("Second failed rename") + pause_button = page.get_by_role( + "button", name=f"Pause: {automation_name}", exact=True + ) + await pause_button.click() await expect(error_banner).to_be_visible(timeout=10000) + assert not any(raw_error in message for message in console_messages) - await submit_rename("Successful retry") + await pause_button.click() await asyncio.wait_for(retry_started.wait(), timeout=10) await expect(error_banner).to_have_count(0) release_retry.set() await asyncio.wait_for(retry_completed.wait(), timeout=10) await expect(error_banner).to_have_count(0) + assert not any(raw_error in message for message in console_messages) + assert mutation_requests == [ + ( + "POST", + f"/api/webchat/v2/automations/{automation_id}", + ), + ( + "POST", + f"/api/webchat/v2/automations/{automation_id}/pause", + ), + ( + "POST", + f"/api/webchat/v2/automations/{automation_id}/pause", + ), + ] finally: release_retry.set() await context.close() From 96e0abd872193224b2016047332d28be1b3e400a Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:32:25 +0800 Subject: [PATCH 6/9] fix(webui): show automation action errors as toasts --- .../frontend/src/lib/toast.test.ts | 10 ++++++ .../frontend/src/lib/toast.ts | 5 +++ .../pages/automations/automations-page.tsx | 22 ------------ .../automations/hooks/useAutomations.test.ts | 36 +++++++++++++------ .../pages/automations/hooks/useAutomations.ts | 17 +++++---- 5 files changed, 51 insertions(+), 39 deletions(-) diff --git a/crates/ironclaw_webui_v2/frontend/src/lib/toast.test.ts b/crates/ironclaw_webui_v2/frontend/src/lib/toast.test.ts index 1a91d25b49c..c357830fcaf 100644 --- a/crates/ironclaw_webui_v2/frontend/src/lib/toast.test.ts +++ b/crates/ironclaw_webui_v2/frontend/src/lib/toast.test.ts @@ -3,12 +3,14 @@ import { beforeEach, test, vi } from "vitest"; const hotToast = vi.hoisted(() => ({ blank: vi.fn((..._args: unknown[]) => "blank-id"), + dismiss: vi.fn((..._args: unknown[]) => {}), error: vi.fn((..._args: unknown[]) => "error-id"), success: vi.fn((..._args: unknown[]) => "success-id"), })); vi.mock("react-hot-toast", () => ({ default: Object.assign(hotToast.blank, { + dismiss: hotToast.dismiss, error: hotToast.error, success: hotToast.success, }), @@ -17,6 +19,7 @@ vi.mock("react-hot-toast", () => ({ import { DEFAULT_ERROR_TOAST_DURATION, DEFAULT_TOAST_DURATION, + dismissToast, toast, } from "./toast"; @@ -62,3 +65,10 @@ test("toast preserves an explicit duration", () => { }, ]); }); + +test("dismissToast removes the matching active toast", () => { + dismissToast("error-id"); + dismissToast(null); + + assert.deepEqual(hotToast.dismiss.mock.calls, [["error-id"]]); +}); diff --git a/crates/ironclaw_webui_v2/frontend/src/lib/toast.ts b/crates/ironclaw_webui_v2/frontend/src/lib/toast.ts index 2913b3b5701..5e8af0aa11d 100644 --- a/crates/ironclaw_webui_v2/frontend/src/lib/toast.ts +++ b/crates/ironclaw_webui_v2/frontend/src/lib/toast.ts @@ -28,3 +28,8 @@ export function toast(message: string, opts: ToastOptions = {}) { if (tone === "success") return hotToast.success(message, options); return hotToast(message, options); } + +export function dismissToast(id: string | null | undefined) { + if (id == null) return; + hotToast.dismiss(id); +} diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx index 0c2e75dc359..28ca6aa8ec3 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/automations-page.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Icon } from "../../design-system/icons"; import { useT } from "../../lib/i18n"; import { AutomationDeliveryDefaultsPanel } from "./components/automation-delivery-defaults-panel"; import { AutomationsList } from "./components/automations-list"; @@ -58,27 +57,6 @@ export function AutomationsPage() { {t("automations.error.loadFailed")} )} - {automationsState.actionError && - ( -
- - {t("automations.error.actionFailed")} - - -
- )} - {showErrorOnly ? null : ( diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts index cc195de4ccc..a7575e981c5 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts @@ -16,9 +16,10 @@ function loadUseAutomations() { }, }).outputText; const mutationConfigs = []; - const stateUpdates = []; const invalidations = []; const consoleErrors = []; + const dismissedToastIds = []; + const toastCalls = []; const context = { AUTOMATIONS_BASE_REFETCH_MS: 30000, React: { @@ -26,13 +27,15 @@ function loadUseAutomations() { useEffect: () => {}, useMemo: (factory) => factory(), useRef: (initial) => ({ current: initial }), - useState: (initial) => [initial, (value) => stateUpdates.push(value)], }, automationSummary: () => ({}), console: { error: (...args) => consoleErrors.push(args), }, deleteAutomation: () => {}, + dismissToast: (id) => { + if (id != null) dismissedToastIds.push(id); + }, globalThis: {}, listAutomations: () => {}, nextAutomationsRefetchDelay: () => null, @@ -42,7 +45,11 @@ function loadUseAutomations() { resumeAutomation: () => {}, setTimeout, clearTimeout, - useI18n: () => ({ lang: "en", t: (key) => key }), + toast: (...args) => { + toastCalls.push(args); + return "toast-41"; + }, + useI18n: () => ({ lang: "en", t: (key) => `translated:${key}` }), useMutation: (config) => { mutationConfigs.push(config); return { isPending: false, mutate: () => {} }; @@ -66,19 +73,21 @@ function loadUseAutomations() { return { consoleErrors, + dismissedToastIds, invalidations, mutationConfigs, - stateUpdates, + toastCalls, useAutomations: context.globalThis.__testExports.useAutomations, }; } -test("automation action results only update the latest action state", () => { +test("only the latest automation action controls the error toast", () => { const { consoleErrors, + dismissedToastIds, invalidations, mutationConfigs, - stateUpdates, + toastCalls, useAutomations, } = loadUseAutomations(); useAutomations(); @@ -103,23 +112,30 @@ test("automation action results only update the latest action state", () => { "automation-1", firstAction ); - assert.deepEqual(stateUpdates, [false, false]); + assert.deepEqual(toastCalls, []); mutationConfigs[2].onError( new Error("raw backend detail"), { automationId: "automation-2", name: "New name" }, secondAction ); - assert.deepEqual(stateUpdates, [false, false, true]); + assert.deepEqual( + JSON.parse(JSON.stringify(toastCalls)), + [[ + "translated:automations.error.actionFailed", + { tone: "error" }, + ]] + ); assert.deepEqual(consoleErrors, []); const thirdAction = mutationConfigs[1].onMutate("automation-3"); + assert.deepEqual(dismissedToastIds, ["toast-41"]); mutationConfigs[2].onSuccess( { updated: true }, { automationId: "automation-2", name: "New name" }, secondAction ); - assert.deepEqual(stateUpdates, [false, false, true, false]); + assert.deepEqual(dismissedToastIds, ["toast-41"]); assert.equal(invalidations.length, 1); mutationConfigs[1].onSuccess( @@ -127,6 +143,6 @@ test("automation action results only update the latest action state", () => { "automation-3", thirdAction ); - assert.deepEqual(stateUpdates, [false, false, true, false, false]); + assert.deepEqual(dismissedToastIds, ["toast-41"]); assert.equal(invalidations.length, 2); }); diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts index fc6bd2daf07..b8ba8e2d2b1 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts @@ -8,6 +8,7 @@ import { resumeAutomation, } from "../../../lib/api"; import { useI18n } from "../../../lib/i18n"; +import { dismissToast, toast } from "../../../lib/toast"; import { automationSummary, @@ -40,8 +41,8 @@ function isLatestAutomationAction( export function useAutomations(includeCompleted = false) { const { t, lang } = useI18n(); const queryClient = useQueryClient(); - const [hasActionError, setHasActionError] = React.useState(false); const latestActionSequence = React.useRef(0); + const actionErrorToastId = React.useRef(null); const query = useQuery({ queryKey: ["automations", { includeCompleted }], queryFn: () => @@ -87,7 +88,8 @@ export function useAutomations(includeCompleted = false) { queryClient.invalidateQueries({ queryKey: ["automations"] }); }, [queryClient]); const clearActionError = React.useCallback(() => { - setHasActionError(false); + dismissToast(actionErrorToastId.current); + actionErrorToastId.current = null; }, []); const beginAction = React.useCallback((): ActionMutationContext => { const sequence = latestActionSequence.current + 1; @@ -101,11 +103,14 @@ export function useAutomations(includeCompleted = false) { context: ActionMutationContext | undefined ) => { // A newer action deliberately supersedes older results: starting any - // action clears the banner, and a late completion must not resurrect it. + // action clears the toast, and a late completion must not resurrect it. if (isLatestAutomationAction(context, latestActionSequence.current)) { - setHasActionError(true); + clearActionError(); + actionErrorToastId.current = toast(t("automations.error.actionFailed"), { + tone: "error", + }); } - }, []); + }, [clearActionError, t]); const handleActionSuccess = React.useCallback(( _data: unknown, _variables: unknown, @@ -154,8 +159,6 @@ export function useAutomations(includeCompleted = false) { renameMutation.isPending || deleteMutation.isPending, error: query.error || null, - actionError: hasActionError, - dismissActionError: clearActionError, pauseAutomation: pauseMutation.mutate, resumeAutomation: resumeMutation.mutate, renameAutomation: renameMutation.mutate, From a9f94f778d46e15084ccc7ed1263ae57ba96e36b Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:33:03 +0800 Subject: [PATCH 7/9] test(e2e): cover automation action error toast --- tests/e2e/helpers.py | 2 +- .../scenarios/test_reborn_webui_v2_smoke.py | 27 ++++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 5be45685fee..ec8ba8b1491 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -375,7 +375,7 @@ "automation_rename_button": "[data-testid='automation-rename-button']", "automation_rename_input": "[data-testid='automation-rename-input']", "automation_rename_save": "[data-testid='automation-rename-save']", - "automation_action_error": "[data-testid='automation-action-error']", + "toast": "[data-testid='toast']", "automation_run_open": "[data-testid='automation-run-open']", "automation_run_logs": "[data-testid='automation-run-logs']", "skills_card": "#skills-list .ext-card", diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py index 34502417eac..a0d6040a21c 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py @@ -516,10 +516,10 @@ async def test_reborn_v2_automation_rename_persists_from_ui( assert renamed["automation_id"] == automation_id -async def test_reborn_v2_automation_action_error_is_safe_dismissible_and_cleared_on_retry( +async def test_reborn_v2_automation_action_error_toast_is_safe_dismissible_and_cleared_on_retry( reborn_v2_server, reborn_v2_browser ): - """Automation mutations keep raw errors private and clear stale failures.""" + """Automation mutation toasts stay visible, private, and clear on retry.""" automation_id = "11111111-2222-3333-4444-555555555555" automation_name = "Safe action error regression" raw_error = "postgres failed: secret_internal_automation_table" @@ -585,7 +585,9 @@ async def handle_automations(route) -> None: await page.route("**/api/webchat/v2/automations**", handle_automations) row_selector = SEL_V2["automation_row_for"].format(id=automation_id) - error_banner = page.locator(SEL_V2["automation_action_error"]) + error_toast = page.locator(SEL_V2["toast"]).filter( + has_text="Unable to update the automation. Please try again." + ) async def submit_rename(name: str) -> None: row = page.locator(row_selector) @@ -599,33 +601,32 @@ async def submit_rename(name: str) -> None: await page.locator(SEL_V2["automation_rename_save"]).click() try: - await page.goto(f"{reborn_v2_server}/v2/automations?token={REBORN_V2_AUTH_TOKEN}") + await page.goto(f"{reborn_v2_server}/automations?token={REBORN_V2_AUTH_TOKEN}") await submit_rename("First failed rename") - await expect(error_banner).to_be_visible(timeout=10000) - await expect(error_banner).to_have_text( + await expect(error_toast).to_be_visible(timeout=10000) + await expect(error_toast).to_have_text( "Unable to update the automation. Please try again." ) - await expect(error_banner).not_to_contain_text(raw_error) + await expect(error_toast).not_to_contain_text(raw_error) assert not any(raw_error in message for message in console_messages) - - await error_banner.get_by_role("button", name="Dismiss").click() - await expect(error_banner).to_have_count(0) + await error_toast.get_by_role("button", name="Dismiss").click() + await expect(error_toast).to_have_count(0, timeout=3000) pause_button = page.get_by_role( "button", name=f"Pause: {automation_name}", exact=True ) await pause_button.click() - await expect(error_banner).to_be_visible(timeout=10000) + await expect(error_toast).to_be_visible(timeout=10000) assert not any(raw_error in message for message in console_messages) await pause_button.click() await asyncio.wait_for(retry_started.wait(), timeout=10) - await expect(error_banner).to_have_count(0) + await expect(error_toast).to_have_count(0) release_retry.set() await asyncio.wait_for(retry_completed.wait(), timeout=10) - await expect(error_banner).to_have_count(0) + await expect(error_toast).to_have_count(0) assert not any(raw_error in message for message in console_messages) assert mutation_requests == [ ( From c00fce16e86c61a24f08e2a8e3ffa25a88858ccc Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:00:24 +0800 Subject: [PATCH 8/9] refactor(webui): centralize automation mutation lifecycle --- .../frontend/src/lib/i18n.test.ts | 10 + .../automations/hooks/useAutomations.test.ts | 178 +++++------------ .../pages/automations/hooks/useAutomations.ts | 188 ++++++++++++------ 3 files changed, 188 insertions(+), 188 deletions(-) diff --git a/crates/ironclaw_webui_v2/frontend/src/lib/i18n.test.ts b/crates/ironclaw_webui_v2/frontend/src/lib/i18n.test.ts index 81d6fe61728..5f4bd91aceb 100644 --- a/crates/ironclaw_webui_v2/frontend/src/lib/i18n.test.ts +++ b/crates/ironclaw_webui_v2/frontend/src/lib/i18n.test.ts @@ -230,6 +230,16 @@ test("locale packs include skill auto-activation controls", () => { } }); +test("locale packs include automation action failure copy", () => { + const key = "automations.error.actionFailed"; + + for (const locale of LOCALES) { + const pack = loadLocalePack(locale); + assert.equal(typeof pack[key], "string", `${locale} missing ${key}`); + assert.notEqual(pack[key].trim(), "", `${locale} has empty ${key}`); + } +}); + test("locale packs include admin write-only secret management copy", () => { const requiredKeys = [ "admin.user.secrets.title", diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts index a7575e981c5..90e27c1db9e 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.test.ts @@ -1,148 +1,80 @@ -// @ts-nocheck import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import vm from "node:vm"; -import ts from "typescript"; import { test } from "vitest"; -import { sourceTextForVmTest } from "../../../test-support/vm-module-harness"; +import { + createAutomationMutationConfig, + createAutomationMutationLifecycle, +} from "./useAutomations"; -function loadUseAutomations() { - const source = readFileSync(new URL("./useAutomations.ts", import.meta.url), "utf8"); - const transpiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.ESNext, - target: ts.ScriptTarget.ES2022, - }, - }).outputText; - const mutationConfigs = []; - const invalidations = []; - const consoleErrors = []; - const dismissedToastIds = []; - const toastCalls = []; - const context = { - AUTOMATIONS_BASE_REFETCH_MS: 30000, - React: { - useCallback: (callback) => callback, - useEffect: () => {}, - useMemo: (factory) => factory(), - useRef: (initial) => ({ current: initial }), - }, - automationSummary: () => ({}), - console: { - error: (...args) => consoleErrors.push(args), - }, - deleteAutomation: () => {}, - dismissToast: (id) => { - if (id != null) dismissedToastIds.push(id); - }, - globalThis: {}, - listAutomations: () => {}, - nextAutomationsRefetchDelay: () => null, - normalizeAutomations: () => [], - pauseAutomation: () => {}, - renameAutomation: () => {}, - resumeAutomation: () => {}, - setTimeout, - clearTimeout, - toast: (...args) => { - toastCalls.push(args); - return "toast-41"; +test("automation mutation configs share an explicit latest-action lifecycle", async () => { + const latestActionSequence = { current: 0 }; + const actionErrorToastId = { current: null as string | null }; + const dismissedToastIds: Array = []; + const shownToastIds: string[] = []; + let invalidationCount = 0; + + const lifecycle = createAutomationMutationLifecycle({ + latestActionSequence, + actionErrorToastId, + dismissErrorToast: (id) => dismissedToastIds.push(id), + showErrorToast: () => { + const id = `toast-${shownToastIds.length + 1}`; + shownToastIds.push(id); + return id; }, - useI18n: () => ({ lang: "en", t: (key) => `translated:${key}` }), - useMutation: (config) => { - mutationConfigs.push(config); - return { isPending: false, mutate: () => {} }; + invalidateAutomations: () => { + invalidationCount += 1; }, - useQuery: () => ({ - data: { automations: [], scheduler_enabled: true }, - error: null, - isFetching: false, - isLoading: false, - refetch: () => {}, - }), - useQueryClient: () => ({ - invalidateQueries: (query) => invalidations.push(query), - }), - }; - - vm.runInNewContext( - sourceTextForVmTest(transpiled, ["useAutomations"]), - context - ); - - return { - consoleErrors, - dismissedToastIds, - invalidations, - mutationConfigs, - toastCalls, - useAutomations: context.globalThis.__testExports.useAutomations, - }; -} - -test("only the latest automation action controls the error toast", () => { - const { - consoleErrors, - dismissedToastIds, - invalidations, - mutationConfigs, - toastCalls, - useAutomations, - } = loadUseAutomations(); - useAutomations(); + }); + const pause = async (automationId: string) => ({ automationId }); + const rename = async (variables: { automationId: string; name: string }) => + variables; + const pauseConfig = createAutomationMutationConfig(pause, lifecycle); + const renameConfig = createAutomationMutationConfig(rename, lifecycle); - assert.equal(mutationConfigs.length, 4); - for (const callbackName of ["onMutate", "onError", "onSuccess"]) { - assert.equal( - new Set(mutationConfigs.map((config) => config[callbackName])).size, - 1, - `all automation mutations must share ${callbackName}` - ); + assert.equal(pauseConfig.mutationFn, pause); + assert.equal(renameConfig.mutationFn, rename); + for (const callbackName of ["onMutate", "onError", "onSuccess"] as const) { + assert.equal(pauseConfig[callbackName], lifecycle[callbackName]); + assert.equal(renameConfig[callbackName], lifecycle[callbackName]); } - const firstAction = mutationConfigs[0].onMutate("automation-1"); - const secondAction = mutationConfigs[2].onMutate({ + const firstAction = await lifecycle.onMutate("automation-1"); + const secondAction = await lifecycle.onMutate({ automationId: "automation-2", name: "New name", }); + assert.deepEqual(dismissedToastIds, []); - mutationConfigs[0].onError( + lifecycle.onError( new Error("raw backend detail"), "automation-1", firstAction ); - assert.deepEqual(toastCalls, []); + assert.deepEqual(shownToastIds, []); - mutationConfigs[2].onError( + lifecycle.onError( new Error("raw backend detail"), { automationId: "automation-2", name: "New name" }, secondAction ); - assert.deepEqual( - JSON.parse(JSON.stringify(toastCalls)), - [[ - "translated:automations.error.actionFailed", - { tone: "error" }, - ]] - ); - assert.deepEqual(consoleErrors, []); + assert.deepEqual(shownToastIds, ["toast-1"]); + assert.equal(actionErrorToastId.current, "toast-1"); - const thirdAction = mutationConfigs[1].onMutate("automation-3"); - assert.deepEqual(dismissedToastIds, ["toast-41"]); - mutationConfigs[2].onSuccess( - { updated: true }, - { automationId: "automation-2", name: "New name" }, - secondAction - ); - assert.deepEqual(dismissedToastIds, ["toast-41"]); - assert.equal(invalidations.length, 1); + // A late callback from the older action must not dismiss or overwrite the + // toast now owned by the latest action. + lifecycle.onError(new Error("late failure"), "automation-1", firstAction); + lifecycle.onSuccess({ updated: true }, "automation-1", firstAction); + assert.deepEqual(shownToastIds, ["toast-1"]); + assert.deepEqual(dismissedToastIds, []); + assert.equal(actionErrorToastId.current, "toast-1"); + assert.equal(invalidationCount, 1); - mutationConfigs[1].onSuccess( - { updated: true }, - "automation-3", - thirdAction - ); - assert.deepEqual(dismissedToastIds, ["toast-41"]); - assert.equal(invalidations.length, 2); + const thirdAction = await lifecycle.onMutate("automation-3"); + assert.deepEqual(dismissedToastIds, ["toast-1"]); + assert.equal(actionErrorToastId.current, null); + + lifecycle.onSuccess({ updated: true }, "automation-3", thirdAction); + assert.deepEqual(dismissedToastIds, ["toast-1"]); + assert.equal(invalidationCount, 2); }); diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts index b8ba8e2d2b1..ed127ad4249 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/automations/hooks/useAutomations.ts @@ -1,4 +1,10 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + type MutationFunction, + type UseMutationOptions, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import React from "react"; import { deleteAutomation, @@ -27,15 +33,83 @@ type RenameAutomationVariables = { name: string; }; -type ActionMutationContext = { +export type ActionMutationContext = { sequence: number; }; -function isLatestAutomationAction( - context: ActionMutationContext | undefined, - latestSequence: number -) { - return context?.sequence === latestSequence; +type MutableRef = { + current: T; +}; + +export type AutomationMutationLifecycle = { + onMutate: (_variables: unknown) => ActionMutationContext; + onError: ( + _error: unknown, + _variables: unknown, + context: ActionMutationContext | undefined + ) => void; + onSuccess: ( + _data: unknown, + _variables: unknown, + context: ActionMutationContext | undefined + ) => void; +}; + +type AutomationMutationLifecycleOptions = { + latestActionSequence: MutableRef; + actionErrorToastId: MutableRef; + dismissErrorToast: (id: string | null | undefined) => void; + showErrorToast: () => string; + invalidateAutomations: () => void; +}; + +export function createAutomationMutationLifecycle({ + latestActionSequence, + actionErrorToastId, + dismissErrorToast, + showErrorToast, + invalidateAutomations, +}: AutomationMutationLifecycleOptions): AutomationMutationLifecycle { + const clearActionError = () => { + if (actionErrorToastId.current !== null) { + dismissErrorToast(actionErrorToastId.current); + } + actionErrorToastId.current = null; + }; + + return { + onMutate: () => { + const sequence = latestActionSequence.current + 1; + latestActionSequence.current = sequence; + clearActionError(); + return { sequence }; + }, + onError: (_error, _variables, context) => { + // A newer action deliberately supersedes older results: a late failure + // must neither dismiss nor replace the latest action's toast. + if (context?.sequence !== latestActionSequence.current) return; + clearActionError(); + actionErrorToastId.current = showErrorToast(); + }, + onSuccess: (_data, _variables, context) => { + if (context?.sequence === latestActionSequence.current) { + clearActionError(); + } + invalidateAutomations(); + }, + }; +} + +export function createAutomationMutationConfig( + mutationFn: MutationFunction, + lifecycle: AutomationMutationLifecycle +): UseMutationOptions { + return { + mutationFn, + onMutate: lifecycle.onMutate, + onError: lifecycle.onError, + onSuccess: lifecycle.onSuccess, + }; } export function useAutomations(includeCompleted = false) { @@ -87,65 +161,49 @@ export function useAutomations(includeCompleted = false) { const invalidateAutomations = React.useCallback(() => { queryClient.invalidateQueries({ queryKey: ["automations"] }); }, [queryClient]); - const clearActionError = React.useCallback(() => { - dismissToast(actionErrorToastId.current); - actionErrorToastId.current = null; - }, []); - const beginAction = React.useCallback((): ActionMutationContext => { - const sequence = latestActionSequence.current + 1; - latestActionSequence.current = sequence; - clearActionError(); - return { sequence }; - }, [clearActionError]); - const showActionError = React.useCallback(( - _error: unknown, - _variables: unknown, - context: ActionMutationContext | undefined - ) => { - // A newer action deliberately supersedes older results: starting any - // action clears the toast, and a late completion must not resurrect it. - if (isLatestAutomationAction(context, latestActionSequence.current)) { - clearActionError(); - actionErrorToastId.current = toast(t("automations.error.actionFailed"), { + const showActionErrorToast = React.useCallback( + () => + toast(t("automations.error.actionFailed"), { tone: "error", - }); - } - }, [clearActionError, t]); - const handleActionSuccess = React.useCallback(( - _data: unknown, - _variables: unknown, - context: ActionMutationContext | undefined - ) => { - if (isLatestAutomationAction(context, latestActionSequence.current)) { - clearActionError(); - } - invalidateAutomations(); - }, [clearActionError, invalidateAutomations]); - const pauseMutation = useMutation({ - mutationFn: (automationId: string) => pauseAutomation({ automationId }), - onMutate: beginAction, - onError: showActionError, - onSuccess: handleActionSuccess, - }); - const resumeMutation = useMutation({ - mutationFn: (automationId: string) => resumeAutomation({ automationId }), - onMutate: beginAction, - onError: showActionError, - onSuccess: handleActionSuccess, - }); - const renameMutation = useMutation({ - mutationFn: ({ automationId, name }: RenameAutomationVariables) => - renameAutomation({ automationId, name }), - onMutate: beginAction, - onError: showActionError, - onSuccess: handleActionSuccess, - }); - const deleteMutation = useMutation({ - mutationFn: (automationId: string) => deleteAutomation({ automationId }), - onMutate: beginAction, - onError: showActionError, - onSuccess: handleActionSuccess, - }); + }), + [t] + ); + const mutationLifecycle = React.useMemo( + () => + createAutomationMutationLifecycle({ + latestActionSequence, + actionErrorToastId, + dismissErrorToast: dismissToast, + showErrorToast: showActionErrorToast, + invalidateAutomations, + }), + [invalidateAutomations, showActionErrorToast] + ); + const pauseMutation = useMutation( + createAutomationMutationConfig( + (automationId: string) => pauseAutomation({ automationId }), + mutationLifecycle + ) + ); + const resumeMutation = useMutation( + createAutomationMutationConfig( + (automationId: string) => resumeAutomation({ automationId }), + mutationLifecycle + ) + ); + const renameMutation = useMutation( + createAutomationMutationConfig( + ({ automationId, name }: RenameAutomationVariables) => + renameAutomation({ automationId, name }), + mutationLifecycle + ) + ); + const deleteMutation = useMutation( + createAutomationMutationConfig( + (automationId: string) => deleteAutomation({ automationId }), + mutationLifecycle + ) + ); return { automations, From 5edd838766b4032065b97319044e0c0b2878bdb6 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:17:38 +0800 Subject: [PATCH 9/9] test(e2e): use canonical automation page fixture --- tests/e2e/helpers.py | 2 -- tests/e2e/scenarios/test_reborn_webui_v2_smoke.py | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 22f2dbb01f4..489a325a2a3 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -265,7 +265,6 @@ "workspace_directory_entry_for": ( "[data-testid='workspace-directory-entry'][data-entry-path='{path}']" ), - "toast": "[data-testid='toast']", "thread_delete_for": ( '[data-testid="thread-delete"][data-thread-id="{id}"]' ), @@ -382,7 +381,6 @@ "automation_rename_button": "[data-testid='automation-rename-button']", "automation_rename_input": "[data-testid='automation-rename-input']", "automation_rename_save": "[data-testid='automation-rename-save']", - "toast": "[data-testid='toast']", "automation_run_open": "[data-testid='automation-run-open']", "automation_run_logs": "[data-testid='automation-run-logs']", "skills_card": "#skills-list .ext-card", diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py index a0d6040a21c..27cf9d10939 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py @@ -517,7 +517,7 @@ async def test_reborn_v2_automation_rename_persists_from_ui( async def test_reborn_v2_automation_action_error_toast_is_safe_dismissible_and_cleared_on_retry( - reborn_v2_server, reborn_v2_browser + reborn_v2_server, reborn_v2_page ): """Automation mutation toasts stay visible, private, and clear on retry.""" automation_id = "11111111-2222-3333-4444-555555555555" @@ -530,8 +530,7 @@ async def test_reborn_v2_automation_action_error_toast_is_safe_dismissible_and_c release_retry = asyncio.Event() retry_completed = asyncio.Event() - context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) - page = await context.new_page() + page = reborn_v2_page page.on("console", lambda message: console_messages.append(message.text)) async def handle_automations(route) -> None: @@ -644,7 +643,6 @@ async def submit_rename(name: str) -> None: ] finally: release_retry.set() - await context.close() async def test_reborn_v2_automation_failed_run_actions_are_clickable(