From a7d430fe9ee3ae1fd1d86ff0083318fc1e96005b Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:28:56 -0300 Subject: [PATCH] fix(dashboard): carry conductor requirements and focus the repeated task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawer's "Repeat" for a Conductor task dropped the runner/model pinning and left the operator staring at the finished run: - `hubTaskSchema` now parses the hub's `requirements` (`.catch(null)` so an odd shape never fails the whole task parse), and `ConductorTaskDetail` exposes `cli`/`model` (`null` when the hub sends none). - `repeatReqForConductor` carries `cli`/`model` when present and OMITS them otherwise — the route's Zod takes both as optional strings, so a `null` would 400. The two fields are independent. - `performAction` reads the response body once and returns it, so the repeat can report the CANVAS id of the created task (`task_id` / `data.id` / `result.task.id`, each with its node prefix). `OrchestrationPageClient` then refetches and focuses it via `?node=`; History keeps its current behavior. - `conductor-routes-auth.test.ts` covers the creation route through its `ROUTES` array; the duplicated source assertion left `conductor-create-route.test.ts`. Refs #12639 --- .../orchestration/OrchestrationPageClient.tsx | 18 +- .../drawer/OrchestrationDrawer.tsx | 19 +- .../orchestration/drawer/useDrawerDetail.ts | 105 ++++++++-- src/lib/conductor/hubProxy.ts | 30 ++- tests/unit/conductor-create-route.test.ts | 17 +- tests/unit/conductor-routes-auth.test.ts | 12 +- .../ui/orchestrationDrawerRepeat.test.tsx | 187 +++++++++++++++++- 7 files changed, 335 insertions(+), 53 deletions(-) diff --git a/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx b/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx index 3d71421c52..b5b95610b8 100644 --- a/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx @@ -133,6 +133,13 @@ export default function OrchestrationPageClient() { [collapsed, setParams] ); const closeDrawer = useCallback(() => setParams({ node: null }), [setParams]); + const onActionDone = useCallback( + (newNodeId?: string) => { + refetch(); + if (newNodeId) setParams({ node: newNodeId }); + }, + [refetch, setParams] + ); const selectedNode = nodeId ? (snapshot.nodes.find((n) => n.id === nodeId) ?? null) : null; const onNodeClick = (id: string) => @@ -188,8 +195,17 @@ export default function OrchestrationPageClient() { {tab === "history" && } + {/* A successful repeat hands back the CANVAS id of the task it created: refetch, then + focus it (`?node=`) so the operator lands on the new run instead of staring at the + finished one. No id (approve/cancel, or a creation response without one) keeps the + current selection. The History tab renders its own drawer and deliberately does NOT + navigate (HistoryTab.tsx) — its runs are not addressable in the live snapshot. */} {tab !== "history" && ( - + )} ); diff --git a/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx b/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx index 1480e4e3d5..3a50a1589c 100644 --- a/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx @@ -4,7 +4,7 @@ import { useTranslations } from "next-intl"; import { StatusDot } from "@/shared/components/flow/StatusDot"; import { orchStateColor, type OrchNode, type OrchState } from "../model/orchestrationTypes"; import { useDrawerDetail } from "./useDrawerDetail"; -import type { DrawerError } from "./useDrawerDetail"; +import type { DrawerError, RepeatOutcome } from "./useDrawerDetail"; import type { CloudAgentTask } from "@/lib/cloudAgent/types"; import type { A2ATask } from "@/lib/a2a/taskManager"; @@ -349,15 +349,18 @@ function RepeatButton({ }: { canRepeat: boolean; busy: boolean; - repeat: () => Promise; - onActionDone: () => void; + repeat: () => Promise; + onActionDone: (newNodeId?: string) => void; onToast: (text: string) => void; t: Translate; }) { const { confirming, onClick } = useTwoClickConfirm(() => { void (async () => { - if (await repeat()) { - onActionDone(); + // The created task's CANVAS id is handed to `onActionDone` so the page can focus it — + // `undefined` when the creation response carried no usable id (plain refetch, no jump). + const { ok, newNodeId } = await repeat(); + if (ok) { + onActionDone(newNodeId ?? undefined); onToast(t("repeatDone")); } })(); @@ -395,8 +398,8 @@ function DrawerActions({ busy: boolean; approve: () => Promise; cancel: () => Promise; - repeat: () => Promise; - onActionDone: () => void; + repeat: () => Promise; + onActionDone: (newNodeId?: string) => void; onToast: (text: string) => void; t: Translate; }) { @@ -488,7 +491,7 @@ export function OrchestrationDrawer({ }: { node: OrchNode | null; onClose: () => void; - onActionDone: () => void; + onActionDone: (newNodeId?: string) => void; }) { const t = useTranslations("orchestration"); const { diff --git a/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts b/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts index c89f3aeb68..50a51cc0f8 100644 --- a/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts +++ b/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts @@ -143,7 +143,14 @@ function repeatReqForA2a( }; } -/** conductor repeat builder — `POST /api/conductor/tasks` (D1 task-creation route). */ +/** + * conductor repeat builder — `POST /api/conductor/tasks` (D1 task-creation route). + * `cli`/`model` come from the hub's `requirements` (`ConductorTaskDetail`, hubProxy.ts) and are + * carried over so the repeat lands on the SAME runner profile/model the original task was + * pinned to. Both are `z.string().optional()` in the route's Zod: a `null` would 400, so a + * missing requirement must OMIT the field (`undefined`) rather than send `null` — and the two + * are independent (one may be set while the other is not). + */ function repeatReqForConductor(detail: unknown): { url: string; init: RequestInit } | null { const d = detail as ConductorTaskDetail | null; if (!d?.repo || !d?.prompt) return null; @@ -154,6 +161,8 @@ function repeatReqForConductor(detail: unknown): { url: string; init: RequestIni prompt: d.prompt, baseRef: d.base_ref ?? undefined, mode: d.mode, + cli: d.cli ?? undefined, + model: d.model ?? undefined, }), }; } @@ -168,6 +177,36 @@ export function repeatReqFor( return null; } +/** `` when `id` is a non-empty string, `null` otherwise (never a bare prefix). */ +function prefixedNodeId(prefix: string, id: unknown): string | null { + return typeof id === "string" && id.length > 0 ? `${prefix}${id}` : null; +} + +/** + * CANVAS node id of the task a successful repeat just created, from the creation response + * body — `null` whenever the body does not carry a usable id (the caller then simply refetches + * without focusing anything). The canvas addresses nodes by PREFIXED id + * (`mergeSnapshot.ts`), so the raw upstream id is never returned on its own. Response + * envelopes, verified against the live routes: + * - conductor (`POST /api/conductor/tasks`): `{ task_id }`. + * - cloud-agent (`POST /api/v1/agents/tasks`): `{ data: { id } }`. + * - a2a (`POST /a2a`, JSON-RPC `message/send`): `{ result: { task: { id } } }`. + */ +export function newNodeIdFrom(node: OrchNode, body: unknown): string | null { + if (!body || typeof body !== "object") return null; + const b = body as Record; + if (node.id.startsWith("conductor:task:")) return prefixedNodeId("conductor:task:", b.task_id); + if (node.id.startsWith("cloud-agent:")) { + const data = b.data as { id?: unknown } | undefined; + return prefixedNodeId("cloud-agent:", data?.id); + } + if (node.id.startsWith("a2a:")) { + const result = b.result as { task?: { id?: unknown } } | undefined; + return prefixedNodeId("a2a:", result?.task?.id); + } + return null; +} + /** * Unwraps a task-detail GET response to the actual task payload. Each source's * route has its own envelope — verified against the live handlers, not assumed: @@ -258,38 +297,57 @@ function useFetchDetail( * that never happened, so the `/a2a` action also inspects the envelope. Only the numeric * `error.code` is surfaced (`RPC `) — never the upstream `error.message`. */ -async function jsonRpcErrorCode(res: { - json?: () => Promise; -}): Promise { +function jsonRpcErrorCode(body: unknown): number | undefined { + const b = body as { error?: { code?: unknown } } | undefined | null; + const code = b?.error?.code; + return typeof code === "number" ? code : b?.error ? -32603 : undefined; +} + +/** + * Reads an action response body ONCE, tolerating a non-JSON/empty body. A body that cannot be + * parsed is not evidence of failure — the status already stood — so it yields `null` and the + * action stays successful (it just has no new-task id to focus). + */ +async function readJsonBody(res: { json?: () => Promise }): Promise { try { - const body = (await res.json?.()) as { error?: { code?: unknown } } | undefined; - const code = body?.error?.code; - return typeof code === "number" ? code : body?.error ? -32603 : undefined; + return (await res.json?.()) ?? null; } catch { - // A non-JSON / already-consumed body is not evidence of failure — the status stands. - return undefined; + return null; } } +/** Outcome of an action POST: whether it succeeded, plus the parsed body on success. */ +interface ActionOutcome { + ok: boolean; + body: unknown; +} + async function performAction( req: { url: string; init: RequestInit } | null, setActionError: (text: string) => void -): Promise { - if (!req) return false; +): Promise { + if (!req) return { ok: false, body: null }; try { const res = await fetch(req.url, req.init); if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await readJsonBody(res); if (req.url === "/a2a") { - const code = await jsonRpcErrorCode(res); + const code = jsonRpcErrorCode(body); if (code !== undefined) throw new Error(`RPC ${code}`); } - return true; + return { ok: true, body }; } catch (err) { setActionError(toSafeErrorText(err)); - return false; + return { ok: false, body: null }; } } +/** Result of the drawer's repeat action: success plus the canvas id of the created task. */ +export interface RepeatOutcome { + ok: boolean; + newNodeId: string | null; +} + export function useDrawerDetail(node: OrchNode | null) { const [detail, setDetail] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -306,8 +364,10 @@ export function useDrawerDetail(node: OrchNode | null) { const { canApprove, canCancel } = deriveActionAvailability(route, node); const repeatReq = node ? repeatReqFor(node, detail) : null; - const runAction = async (req: { url: string; init: RequestInit } | null): Promise => { - if (busy) return false; + const runAction = async ( + req: { url: string; init: RequestInit } | null + ): Promise => { + if (busy) return { ok: false, body: null }; setBusy(true); try { return await performAction(req, setActionError); @@ -315,6 +375,8 @@ export function useDrawerDetail(node: OrchNode | null) { setBusy(false); } }; + const runBooleanAction = async (req: { url: string; init: RequestInit } | null) => + (await runAction(req)).ok; return { detail, @@ -325,8 +387,13 @@ export function useDrawerDetail(node: OrchNode | null) { canApprove, canCancel, canRepeat: !!repeatReq && !busy, - approve: () => runAction(route?.approveReq ?? null), - cancel: () => runAction(route?.cancelReq ?? null), - repeat: () => runAction(repeatReq), + approve: () => runBooleanAction(route?.approveReq ?? null), + cancel: () => runBooleanAction(route?.cancelReq ?? null), + // Only the repeat reports a new node id: approve/cancel act on the task already open, so + // there is nothing new to focus (and their responses can echo the SAME task's id back). + repeat: async (): Promise => { + const { ok, body } = await runAction(repeatReq); + return { ok, newNodeId: ok && node ? newNodeIdFrom(node, body) : null }; + }, }; } diff --git a/src/lib/conductor/hubProxy.ts b/src/lib/conductor/hubProxy.ts index 8992aa9257..b0679d14c1 100644 --- a/src/lib/conductor/hubProxy.ts +++ b/src/lib/conductor/hubProxy.ts @@ -45,6 +45,10 @@ export interface ConductorTaskDetail extends FleetTask { tests: unknown; council: unknown; created_at: string | null; + /** Runner profile the task was pinned to (hub `requirements.cli`), `null` when unconstrained. */ + cli: string | null; + /** Model the task was pinned to (hub `requirements.model`), `null` when unconstrained. */ + model: string | null; } // ============ Untrusted hub shapes (parse only what we read) ============ @@ -74,6 +78,13 @@ const hubTaskSchema = z.object({ tests: z.unknown().optional(), }) .nullish(), + // The hub echoes back the `requirements` object `createConductorTask` sends on creation. + // `.catch(null)` keeps an unexpected shape from failing the WHOLE task parse — a single odd + // requirement must not blank the fleet snapshot (the list parses with this same schema). + requirements: z + .object({ cli: z.string().nullish(), model: z.string().nullish() }) + .nullish() + .catch(null), council: z.unknown().optional(), created_at: z.string().optional(), updated_at: z.string().optional(), @@ -159,13 +170,16 @@ export async function getFleetSnapshot(opts: HubProxyOptions = {}): Promise ({ - id: r.id, - name: r.capabilities.name ?? "?", - clis: (r.capabilities.clis ?? []).map((c) => c.profile), - online: r.online !== false, - draining: r.draining === true, - })); + const runners = z + .array(hubRunnerSchema) + .parse(rawRunners) + .map((r) => ({ + id: r.id, + name: r.capabilities.name ?? "?", + clis: (r.capabilities.clis ?? []).map((c) => c.profile), + online: r.online !== false, + draining: r.draining === true, + })); const tasks = z.array(hubTaskSchema).parse(rawTasks).map(toFleetTask); emitFleetTransitions(tasks); return { offline: false, runners, tasks }; @@ -190,6 +204,8 @@ export async function getConductorTaskDetail( tests: t.manifest?.tests ?? null, council: t.council ?? null, created_at: t.created_at ?? null, + cli: t.requirements?.cli ?? null, + model: t.requirements?.model ?? null, }; } catch { return null; diff --git a/tests/unit/conductor-create-route.test.ts b/tests/unit/conductor-create-route.test.ts index d16d47b1d3..815107fb8b 100644 --- a/tests/unit/conductor-create-route.test.ts +++ b/tests/unit/conductor-create-route.test.ts @@ -57,21 +57,8 @@ test.after(async () => { } }); -test("route: requireManagementAuth antes de criar a task no hub", () => { - const src = fs.readFileSync( - path.join(process.cwd(), "src/app/api/conductor/tasks/route.ts"), - "utf8" - ); - const authAt = src.indexOf("requireManagementAuth("); - assert.ok(authAt > 0, "handler chama requireManagementAuth"); - assert.match(src, /if \(authError\) return authError;/, "curto-circuito no erro de auth"); - const proxyAt = src.indexOf("createConductorTask("); - assert.ok(proxyAt > authAt, "proxy ao hub só depois do gate de auth"); - assert.ok( - !src.includes("CONDUCTOR_HUB_TOKEN"), - "token nunca manuseado na rota (vive no hubProxy)" - ); -}); +// A prova de fonte "requireManagementAuth antes do proxy ao hub" desta rota vive no array +// `ROUTES` de `conductor-routes-auth.test.ts` (uma única implementação para as quatro rotas). test("POST /api/conductor/tasks: body válido + hub ok → 201 {task_id}", async () => { process.env.CONDUCTOR_HUB_URL = await fakeHub({ diff --git a/tests/unit/conductor-routes-auth.test.ts b/tests/unit/conductor-routes-auth.test.ts index 8c35f1f5a6..9dff394bb6 100644 --- a/tests/unit/conductor-routes-auth.test.ts +++ b/tests/unit/conductor-routes-auth.test.ts @@ -8,6 +8,9 @@ const ROUTES = [ "src/app/api/conductor/fleet/route.ts", "src/app/api/conductor/tasks/[id]/route.ts", "src/app/api/conductor/tasks/[id]/cancel/route.ts", + // Rota de criação (repeat do drawer): coberta aqui pelo mesmo padrão, em vez de + // duplicar o teste de fonte dentro de `conductor-create-route.test.ts`. + "src/app/api/conductor/tasks/route.ts", ]; for (const route of ROUTES) { @@ -16,8 +19,13 @@ for (const route of ROUTES) { const authAt = src.indexOf("requireManagementAuth("); assert.ok(authAt > 0, "handler chama requireManagementAuth"); assert.match(src, /if \(authError\) return authError;/, "curto-circuito no erro de auth"); - const proxyAt = src.search(/getFleetSnapshot\(|getConductorTaskDetail\(|cancelConductorTask\(/); + const proxyAt = src.search( + /getFleetSnapshot\(|getConductorTaskDetail\(|cancelConductorTask\(|createConductorTask\(/ + ); assert.ok(proxyAt > authAt, "proxy ao hub só depois do gate de auth"); - assert.ok(!src.includes("CONDUCTOR_HUB_TOKEN"), "token nunca manuseado na rota (vive no hubProxy)"); + assert.ok( + !src.includes("CONDUCTOR_HUB_TOKEN"), + "token nunca manuseado na rota (vive no hubProxy)" + ); }); } diff --git a/tests/unit/ui/orchestrationDrawerRepeat.test.tsx b/tests/unit/ui/orchestrationDrawerRepeat.test.tsx index 761b0d922f..dcbd0d0a5b 100644 --- a/tests/unit/ui/orchestrationDrawerRepeat.test.tsx +++ b/tests/unit/ui/orchestrationDrawerRepeat.test.tsx @@ -9,7 +9,10 @@ vi.mock("next-intl", () => ({ })); import { OrchestrationDrawer } from "@/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer"; -import { repeatReqFor } from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail"; +import { + newNodeIdFrom, + repeatReqFor, +} from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail"; function render(el: React.ReactElement) { const c = document.createElement("div"); @@ -784,3 +787,185 @@ describe("OrchestrationDrawer repeat action (two-click confirm)", () => { cleanup(); }); }); + +describe("repeatReqFor conductor requirements (cli/model)", () => { + const node = { + id: "conductor:task:1", + kind: "work", + source: "conductor", + state: "succeeded", + label: "x", + }; + + it("carries cli/model when the hub detail exposes them (a repeat must land on the same runner profile/model)", () => { + const detail = { + repo: "https://github.com/x/y", + prompt: "fix the bug", + base_ref: "main", + mode: "auto", + cli: "claude", + model: "sonnet", + }; + const req = repeatReqFor(node as never, detail); + expect(JSON.parse(String(req?.init.body))).toEqual({ + repoUrl: "https://github.com/x/y", + prompt: "fix the bug", + baseRef: "main", + mode: "auto", + cli: "claude", + model: "sonnet", + }); + }); + + it("omits cli/model entirely when the detail has them null (the route's Zod treats both as optional strings — `null` would 400)", () => { + const detail = { + repo: "https://github.com/x/y", + prompt: "fix the bug", + base_ref: "main", + mode: "auto", + cli: null, + model: null, + }; + const body = String(repeatReqFor(node as never, detail)?.init.body); + expect(JSON.parse(body)).toEqual({ + repoUrl: "https://github.com/x/y", + prompt: "fix the bug", + baseRef: "main", + mode: "auto", + }); + expect(body).not.toContain("cli"); + expect(body).not.toContain("model"); + }); + + it("carries only the field that is present (cli set, model null — they are independent)", () => { + const detail = { + repo: "https://github.com/x/y", + prompt: "fix the bug", + base_ref: null, + mode: "solo", + cli: "codex", + model: null, + }; + expect(JSON.parse(String(repeatReqFor(node as never, detail)?.init.body))).toEqual({ + repoUrl: "https://github.com/x/y", + prompt: "fix the bug", + mode: "solo", + cli: "codex", + }); + }); +}); + +describe("newNodeIdFrom", () => { + it("maps each source's creation response to the CANVAS node id (prefixed), never the raw id", () => { + const cond = { id: "conductor:task:1", source: "conductor" }; + expect(newNodeIdFrom(cond as never, { task_id: "t2" })).toBe("conductor:task:t2"); + const ca = { id: "cloud-agent:1", source: "cloud-agent" }; + expect(newNodeIdFrom(ca as never, { data: { id: "c2" } })).toBe("cloud-agent:c2"); + const a2a = { id: "a2a:1", source: "a2a" }; + expect(newNodeIdFrom(a2a as never, { result: { task: { id: "n2" } } })).toBe("a2a:n2"); + }); + + it("returns null for a body that does not carry a usable id (never throws, never yields a bare prefix)", () => { + const cond = { id: "conductor:task:1", source: "conductor" }; + expect(newNodeIdFrom(cond as never, null)).toBeNull(); + expect(newNodeIdFrom(cond as never, {})).toBeNull(); + expect(newNodeIdFrom(cond as never, { task_id: "" })).toBeNull(); + expect(newNodeIdFrom(cond as never, { task_id: 7 })).toBeNull(); + expect(newNodeIdFrom(cond as never, "nope")).toBeNull(); + const ca = { id: "cloud-agent:1", source: "cloud-agent" }; + expect(newNodeIdFrom(ca as never, { data: {} })).toBeNull(); + const a2a = { id: "a2a:1", source: "a2a" }; + expect(newNodeIdFrom(a2a as never, { result: {} })).toBeNull(); + }); + + it("returns null for a node whose source has no repeat contract", () => { + const overflow = { id: "overflow:1", kind: "overflow" }; + expect(newNodeIdFrom(overflow as never, { task_id: "t2" })).toBeNull(); + }); +}); + +describe("OrchestrationDrawer repeat focuses the newly created task", () => { + const CONDUCTOR_DETAIL = { + id: "1", + status: "succeeded", + mode: "solo", + repo: "https://github.com/x/y", + runner: null, + summary: null, + branch: null, + error: null, + updated_at: null, + prompt: "fix the bug", + base_ref: "main", + tests: null, + council: null, + created_at: null, + cli: "claude", + model: "sonnet", + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + function findRepeatButton(c: HTMLElement): HTMLButtonElement { + return Array.from(c.querySelectorAll("button")).find( + (b) => b.textContent?.includes("actionRepeat") || b.textContent?.includes("repeatConfirm") + ) as HTMLButtonElement; + } + + async function repeatOnce(onActionDone: (id?: string) => void) { + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return Promise.resolve({ + ok: true, + status: 201, + json: () => Promise.resolve({ task_id: "t_new" }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve(CONDUCTOR_DETAIL) }); + }); + vi.stubGlobal("fetch", fetchMock); + const node = { + id: "conductor:task:1", + kind: "work", + source: "conductor", + state: "running", + label: "x", + }; + const { c, cleanup } = render( + {}} onActionDone={onActionDone} /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + findRepeatButton(c).click(); + }); + await act(async () => { + findRepeatButton(c).click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + return { c, cleanup, fetchMock }; + } + + it("hands the canvas id of the created task to onActionDone, so the page can focus it", async () => { + const seen: Array = []; + const { c, cleanup, fetchMock } = await repeatOnce((id) => seen.push(id)); + const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === "POST"); + expect(post![0]).toBe("/api/conductor/tasks"); + expect(JSON.parse(String((post![1] as RequestInit).body))).toMatchObject({ + cli: "claude", + model: "sonnet", + }); + expect(seen).toEqual(["conductor:task:t_new"]); + expect(c.textContent).toContain("repeatDone"); + cleanup(); + }); +});