diff --git a/crates/ironclaw_webui/frontend/src/i18n/ar.ts b/crates/ironclaw_webui/frontend/src/i18n/ar.ts index bd7f1e62250..ddb95fc921e 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/ar.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/ar.ts @@ -743,6 +743,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/frontend/src/i18n/de.ts b/crates/ironclaw_webui/frontend/src/i18n/de.ts index d9e646fa345..820ab29f161 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/de.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/de.ts @@ -743,6 +743,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/frontend/src/i18n/en.ts b/crates/ironclaw_webui/frontend/src/i18n/en.ts index eed71bfb0d8..96911d871f4 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/en.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/en.ts @@ -790,6 +790,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/frontend/src/i18n/es.ts b/crates/ironclaw_webui/frontend/src/i18n/es.ts index 28f92ac54da..3077f0e5264 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/es.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/es.ts @@ -743,6 +743,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/frontend/src/i18n/fr.ts b/crates/ironclaw_webui/frontend/src/i18n/fr.ts index ca43464842f..37dcfba422a 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/fr.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/fr.ts @@ -743,6 +743,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/frontend/src/i18n/hi.ts b/crates/ironclaw_webui/frontend/src/i18n/hi.ts index 68cbfbb9117..81b0f7fa915 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/hi.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/hi.ts @@ -743,6 +743,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/frontend/src/i18n/ja.ts b/crates/ironclaw_webui/frontend/src/i18n/ja.ts index 6f7fbb62309..19d88fec9af 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/ja.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/ja.ts @@ -743,6 +743,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/frontend/src/i18n/ko.ts b/crates/ironclaw_webui/frontend/src/i18n/ko.ts index 51f6be15a8e..624920f8ccf 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/ko.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/ko.ts @@ -743,6 +743,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/frontend/src/i18n/pt-BR.ts b/crates/ironclaw_webui/frontend/src/i18n/pt-BR.ts index c231a26d4be..0ddbd003b56 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/pt-BR.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/pt-BR.ts @@ -743,6 +743,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/frontend/src/i18n/uk.ts b/crates/ironclaw_webui/frontend/src/i18n/uk.ts index eb5abffee26..b8b23e8bcb7 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/uk.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/uk.ts @@ -743,6 +743,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/frontend/src/i18n/zh-CN.ts b/crates/ironclaw_webui/frontend/src/i18n/zh-CN.ts index e97a72e8279..d611093bd3e 100644 --- a/crates/ironclaw_webui/frontend/src/i18n/zh-CN.ts +++ b/crates/ironclaw_webui/frontend/src/i18n/zh-CN.ts @@ -743,6 +743,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/frontend/src/lib/i18n.test.ts b/crates/ironclaw_webui/frontend/src/lib/i18n.test.ts index 81d6fe61728..5f4bd91aceb 100644 --- a/crates/ironclaw_webui/frontend/src/lib/i18n.test.ts +++ b/crates/ironclaw_webui/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/frontend/src/lib/toast.test.ts b/crates/ironclaw_webui/frontend/src/lib/toast.test.ts index 1a91d25b49c..c357830fcaf 100644 --- a/crates/ironclaw_webui/frontend/src/lib/toast.test.ts +++ b/crates/ironclaw_webui/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/frontend/src/lib/toast.ts b/crates/ironclaw_webui/frontend/src/lib/toast.ts index 2913b3b5701..5e8af0aa11d 100644 --- a/crates/ironclaw_webui/frontend/src/lib/toast.ts +++ b/crates/ironclaw_webui/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/frontend/src/pages/automations/automations-page.tsx b/crates/ironclaw_webui/frontend/src/pages/automations/automations-page.tsx index 1e107e4a6e4..28ca6aa8ec3 100644 --- a/crates/ironclaw_webui/frontend/src/pages/automations/automations-page.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/automations/automations-page.tsx @@ -57,15 +57,6 @@ export function AutomationsPage() { {t("automations.error.loadFailed")} )} - {automationsState.actionError && - ( -
- {automationsState.actionError.message} -
- )} - {showErrorOnly ? null : ( diff --git a/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.test.ts b/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.test.ts new file mode 100644 index 00000000000..90e27c1db9e --- /dev/null +++ b/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + createAutomationMutationConfig, + createAutomationMutationLifecycle, +} from "./useAutomations"; + +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; + }, + invalidateAutomations: () => { + invalidationCount += 1; + }, + }); + 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(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 = await lifecycle.onMutate("automation-1"); + const secondAction = await lifecycle.onMutate({ + automationId: "automation-2", + name: "New name", + }); + assert.deepEqual(dismissedToastIds, []); + + lifecycle.onError( + new Error("raw backend detail"), + "automation-1", + firstAction + ); + assert.deepEqual(shownToastIds, []); + + lifecycle.onError( + new Error("raw backend detail"), + { automationId: "automation-2", name: "New name" }, + secondAction + ); + assert.deepEqual(shownToastIds, ["toast-1"]); + assert.equal(actionErrorToastId.current, "toast-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); + + 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/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.ts index d611933e30f..ed127ad4249 100644 --- a/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.ts +++ b/crates/ironclaw_webui/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, @@ -8,6 +14,7 @@ import { resumeAutomation, } from "../../../lib/api"; import { useI18n } from "../../../lib/i18n"; +import { dismissToast, toast } from "../../../lib/toast"; import { automationSummary, @@ -26,9 +33,90 @@ type RenameAutomationVariables = { name: string; }; +export type ActionMutationContext = { + sequence: number; +}; + +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) { const { t, lang } = useI18n(); const queryClient = useQueryClient(); + const latestActionSequence = React.useRef(0); + const actionErrorToastId = React.useRef(null); const query = useQuery({ queryKey: ["automations", { includeCompleted }], queryFn: () => @@ -73,23 +161,49 @@ export function useAutomations(includeCompleted = false) { const invalidateAutomations = React.useCallback(() => { queryClient.invalidateQueries({ queryKey: ["automations"] }); }, [queryClient]); - const pauseMutation = useMutation({ - mutationFn: (automationId: string) => pauseAutomation({ automationId }), - onSuccess: invalidateAutomations, - }); - const resumeMutation = useMutation({ - mutationFn: (automationId: string) => resumeAutomation({ automationId }), - onSuccess: invalidateAutomations, - }); - const renameMutation = useMutation({ - mutationFn: ({ automationId, name }: RenameAutomationVariables) => - renameAutomation({ automationId, name }), - onSuccess: invalidateAutomations, - }); - const deleteMutation = useMutation({ - mutationFn: (automationId: string) => deleteAutomation({ automationId }), - onSuccess: invalidateAutomations, - }); + const showActionErrorToast = React.useCallback( + () => + toast(t("automations.error.actionFailed"), { + tone: "error", + }), + [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, @@ -103,12 +217,6 @@ export function useAutomations(includeCompleted = false) { renameMutation.isPending || deleteMutation.isPending, error: query.error || null, - actionError: - pauseMutation.error || - resumeMutation.error || - renameMutation.error || - deleteMutation.error || - null, pauseAutomation: pauseMutation.mutate, resumeAutomation: resumeMutation.mutate, renameAutomation: renameMutation.mutate, diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 512280a5388..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}"]' ), diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py index 76ea6615c22..27cf9d10939 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py @@ -516,6 +516,135 @@ async def test_reborn_v2_automation_rename_persists_from_ui( assert renamed["automation_id"] == automation_id +async def test_reborn_v2_automation_action_error_toast_is_safe_dismissible_and_cleared_on_retry( + reborn_v2_server, reborn_v2_page +): + """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" + 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() + + page = reborn_v2_page + page.on("console", lambda message: console_messages.append(message.text)) + + 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": automation_name, + "source": { + "type": "schedule", + "cron": "0 9 * * *", + "timezone": "UTC", + }, + "state": "active", + "next_run_at": "2026-07-18T09:00:00Z", + "recent_runs": [], + } + ], + } + ), + ) + return + + mutation_requests.append( + (route.request.method, urlparse(route.request.url).path) + ) + 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_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) + 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}/automations?token={REBORN_V2_AUTH_TOKEN}") + + await submit_rename("First failed rename") + 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_toast).not_to_contain_text(raw_error) + assert not any(raw_error in message for message in console_messages) + 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_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_toast).to_have_count(0) + + release_retry.set() + await asyncio.wait_for(retry_completed.wait(), timeout=10) + await expect(error_toast).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() + + async def test_reborn_v2_automation_failed_run_actions_are_clickable( reborn_v2_server, reborn_v2_browser ):