diff --git a/apps/web/src/components/app/assisted-update-card.test.tsx b/apps/web/src/components/app/assisted-update-card.test.tsx
new file mode 100644
index 00000000..1d345df3
--- /dev/null
+++ b/apps/web/src/components/app/assisted-update-card.test.tsx
@@ -0,0 +1,477 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type {
+ AssistedCheckResult,
+ AssistedUpdateMetadata,
+ AssistedUpdateState,
+ ReleaseJob,
+ UpdateMigrationManifest,
+} from "@/hooks/use-release-stream";
+
+import {
+ AssistedUpdateGate,
+ AssistedUpdateProgress,
+ PendingMigrationsGate,
+} from "./assisted-update-card";
+
+// Markdown pulls in react-markdown and lazily boots mermaid off a live
+// CSSStyleDeclaration; the gates only use it as a body renderer, and the
+// behaviour under test is the disclosure toggle around it.
+vi.mock("@/components/ui/markdown", () => ({
+ Markdown: ({ children }: { children: string }) =>
{children}
,
+}));
+
+type AssistedJob = Extract;
+
+function makeManifest(
+ overrides: Partial = {}
+): UpdateMigrationManifest {
+ return {
+ id: "0001-bun-cutover",
+ title: "Cut over to bun",
+ summary: "Swaps the runtime.",
+ alreadySatisfied: { description: "bun is on PATH" },
+ instructions: ["install bun"],
+ validation: { requiredChecks: [] },
+ rollback: [],
+ ...overrides,
+ };
+}
+
+function makeAssistedState(
+ overrides: Partial = {}
+): AssistedUpdateState {
+ return {
+ tag: "v1.1.0",
+ fromTag: "v1.0.0",
+ metadata: null,
+ migrations: null,
+ requiredChecks: [],
+ phase: "apply",
+ token: "tok",
+ agentId: null,
+ startedAt: "2026-08-01T12:00:00.000Z",
+ updatedAt: "2026-08-01T12:00:00.000Z",
+ completedAt: null,
+ error: null,
+ checks: [],
+ notes: {},
+ ...overrides,
+ };
+}
+
+function makeJob(
+ assisted: Partial = {},
+ overrides: Partial = {}
+): AssistedJob {
+ const state = makeAssistedState(assisted);
+ return {
+ jobType: "update-assisted",
+ startedAt: "2026-08-01T12:00:00.000Z",
+ log: [],
+ runUrl: null,
+ tag: "v1.1.0",
+ error: null,
+ progress: null,
+ versionType: null,
+ phase: state.phase,
+ assisted: state,
+ ...overrides,
+ } as AssistedJob;
+}
+
+function renderProgress(job: AssistedJob): {
+ onDismiss: ReturnType;
+} {
+ const onDismiss = vi.fn();
+ render();
+ return { onDismiss };
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("the assisted takeover headline", () => {
+ // A run driven by migration manifests and a run driven by legacy release
+ // metadata are mutually exclusive on the server, but the state carries both
+ // fields — the card has to say which one is actually driving this run.
+ it("describes the snapshotted migrations rather than the release metadata", () => {
+ renderProgress(
+ makeJob({
+ metadata: {
+ mode: "required",
+ title: "Service manager rewrite",
+ summary: "The launchd plist changes shape.",
+ requiredChecks: [],
+ } as AssistedUpdateMetadata,
+ migrations: [
+ makeManifest({ id: "0001", title: "Move the socket" }),
+ makeManifest({ id: "0002", title: "Rewrite the plist" }),
+ ],
+ })
+ );
+
+ expect(screen.getByText("2 migrations pending")).toBeTruthy();
+ expect(
+ screen.getByText("Move the socket → Rewrite the plist")
+ ).toBeTruthy();
+ expect(screen.queryByText("Service manager rewrite")).toBeNull();
+ });
+
+ it("says migration, singular, for a one-manifest run", () => {
+ renderProgress(
+ makeJob({ migrations: [makeManifest({ title: "Move the socket" })] })
+ );
+
+ expect(screen.getByText("1 migration pending")).toBeTruthy();
+ });
+
+ it("falls back to the release metadata when no manifest drives the run", () => {
+ renderProgress(
+ makeJob({
+ // An empty array is the same "no manifests" signal as null and must
+ // not produce a "0 migrations pending" headline.
+ migrations: [],
+ metadata: {
+ mode: "required",
+ title: "Service manager rewrite",
+ summary: "The launchd plist changes shape.",
+ requiredChecks: [],
+ } as AssistedUpdateMetadata,
+ })
+ );
+
+ expect(screen.getByText("Service manager rewrite")).toBeTruthy();
+ expect(screen.getByText("The launchd plist changes shape.")).toBeTruthy();
+ expect(screen.queryByText("0 migrations pending")).toBeNull();
+ });
+
+ it("names the run generically when neither source is present", () => {
+ renderProgress(makeJob({ migrations: null, metadata: null }));
+
+ // "Assisted update" is also the static section label above the headline,
+ // so the fallback is what makes the phrase appear a second time.
+ expect(screen.getAllByText("Assisted update")).toHaveLength(2);
+ });
+});
+
+describe("the assisted phase walk", () => {
+ // Reading the whole column as text rather than asserting each label is
+ // present is what pins the ORDER: the walk marks every phase before the
+ // current one as complete, so a reordered list silently reports the wrong
+ // steps as already done.
+ it("walks the assisted phases in order and not the standard release ones", () => {
+ renderProgress(makeJob({ phase: "apply" }, { phase: "apply" }));
+
+ const column = screen.getByText("Progress").parentElement;
+ expect(column?.textContent).toBe(
+ // `done` is the terminal marker, not a step the operator waits through.
+ "ProgressInspect installPrepare migrationApply updateRestartingValidate checks"
+ );
+ });
+
+ // A non-empty log keeps the log pane's own "waiting for restart" spinner
+ // out of the way, so the only spinner left is the one on the phase row.
+ it("spins only while the restart phase is the current one", () => {
+ renderProgress(
+ makeJob(
+ { phase: "restarting" },
+ { phase: "restarting", log: ["applied"] }
+ )
+ );
+ expect(screen.getByRole("status", { name: "Loading" })).toBeTruthy();
+
+ cleanup();
+ renderProgress(
+ makeJob({ phase: "apply" }, { phase: "apply", log: ["applied"] })
+ );
+ expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
+ });
+});
+
+describe("the assisted takeover detail lists", () => {
+ it("numbers each migration against the total", () => {
+ renderProgress(
+ makeJob({
+ migrations: [
+ makeManifest({ id: "0001-socket", title: "Move the socket" }),
+ makeManifest({ id: "0002-plist", title: "Rewrite the plist" }),
+ ],
+ })
+ );
+
+ expect(screen.getByText("1/2")).toBeTruthy();
+ expect(screen.getByText("2/2")).toBeTruthy();
+ expect(screen.getByText("0001-socket")).toBeTruthy();
+ expect(screen.getByText("0002-plist")).toBeTruthy();
+ });
+
+ it("attributes each agent note to the phase that produced it", () => {
+ renderProgress(
+ makeJob({
+ notes: {
+ inspect: "launchd plist is the old shape",
+ apply: "rewrote the plist",
+ } as AssistedUpdateState["notes"],
+ })
+ );
+
+ expect(screen.getByText("Agent notes")).toBeTruthy();
+ expect(screen.getByText("inspect")).toBeTruthy();
+ expect(screen.getByText("launchd plist is the old shape")).toBeTruthy();
+ expect(screen.getByText("apply")).toBeTruthy();
+ expect(screen.getByText("rewrote the plist")).toBeTruthy();
+ });
+
+ it("omits the notes and checks sections when the agent reported neither", () => {
+ renderProgress(makeJob());
+
+ expect(screen.queryByText("Agent notes")).toBeNull();
+ expect(screen.queryByText("Required checks")).toBeNull();
+ expect(screen.queryByText("Migrations")).toBeNull();
+ });
+
+ it("reports the message of every required check, passing or not", () => {
+ const checks: AssistedCheckResult[] = [
+ { name: "server_responds", ok: true, message: "200 from /health" },
+ { name: "plist_shape", ok: false, message: "still the old shape" },
+ ];
+ renderProgress(makeJob({ checks }));
+
+ expect(screen.getByText("server_responds")).toBeTruthy();
+ expect(screen.getByText("200 from /health")).toBeTruthy();
+ expect(screen.getByText("plist_shape")).toBeTruthy();
+ expect(screen.getByText("still the old shape")).toBeTruthy();
+ });
+
+ it("links the launched update agent by its id", () => {
+ renderProgress(makeJob({ agentId: "agt_0123456789abcdef" }));
+
+ const link = screen.getByRole("link", { name: /View update agent/ });
+ expect(link.getAttribute("href")).toBe("/agents/agt_0123456789abcdef");
+ // The id is truncated for the row; the full id has to stay in the href
+ // or the link lands on a different agent.
+ expect(screen.getByText("agt_01234567")).toBeTruthy();
+ });
+
+ it("offers no agent link before one has been launched", () => {
+ renderProgress(makeJob({ agentId: null }));
+
+ expect(
+ screen.queryByRole("link", { name: /View update agent/ })
+ ).toBeNull();
+ });
+});
+
+describe("the assisted takeover outcome", () => {
+ it("confirms the installed tag and dismisses on Done", () => {
+ const { onDismiss } = renderProgress(
+ makeJob({ phase: "done" }, { phase: "done", tag: "v1.2.0" })
+ );
+
+ expect(screen.getByText("Updated to")).toBeTruthy();
+ expect(screen.getByText("v1.2.0")).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: "Done" }));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ ["failed" as const, "Update failed"],
+ ["rollback" as const, "Update rolled back"],
+ ["blocked" as const, "Update blocked — required checks did not pass"],
+ ])("names the %s outcome and dismisses", (phase, headline) => {
+ const { onDismiss } = renderProgress(
+ makeJob({ phase }, { phase, error: "job-level error" })
+ );
+
+ expect(screen.getByText(headline)).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Done" })).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it("prefers the assisted run's error over the job-level one", () => {
+ renderProgress(
+ makeJob(
+ { phase: "failed", error: "check plist_shape failed" },
+ { phase: "failed", error: "tarball fetch failed" }
+ )
+ );
+
+ expect(screen.getByText("check plist_shape failed")).toBeTruthy();
+ expect(screen.queryByText("tarball fetch failed")).toBeNull();
+ });
+
+ it("falls back to the job error when the run recorded none", () => {
+ renderProgress(
+ makeJob(
+ { phase: "failed", error: null },
+ { phase: "failed", error: "tarball fetch failed" }
+ )
+ );
+
+ expect(screen.getByText("tarball fetch failed")).toBeTruthy();
+ });
+
+ it("shows no outcome banner while the update is still running", () => {
+ renderProgress(makeJob({ phase: "apply" }, { phase: "apply" }));
+
+ expect(screen.queryByRole("button", { name: "Done" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
+ });
+
+ it("keeps the restart sentinel out of the streamed log", () => {
+ renderProgress(
+ makeJob(
+ {},
+ {
+ log: [
+ "fetching v1.1.0",
+ // Padded by the stream framing — matched after trimming.
+ " DISPATCH_RESTARTING ",
+ " ",
+ "applied",
+ ],
+ }
+ )
+ );
+
+ expect(screen.getByText("fetching v1.1.0")).toBeTruthy();
+ expect(screen.getByText("applied")).toBeTruthy();
+ expect(screen.queryByText("DISPATCH_RESTARTING")).toBeNull();
+ });
+});
+
+describe("the pre-launch gates", () => {
+ it("counts the pending update steps", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("2 complex update steps")).toBeTruthy();
+ expect(screen.getByText("Move the socket")).toBeTruthy();
+ expect(screen.getByText("Relocates it.")).toBeTruthy();
+ expect(screen.getByText("v1.1.0")).toBeTruthy();
+ });
+
+ it("says step, singular, for one pending migration", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("1 complex update step")).toBeTruthy();
+ });
+
+ // `required` is NOT metadata.mode — release-info raises assistedRequired for
+ // pending migrations and for an unevaluable migration set too, so a
+ // mode="required" release can still be offered as merely recommended. Holding
+ // the mode fixed is what proves the prop, not the metadata, drives the copy.
+ it("takes the required/recommended split from the prop, not the mode", () => {
+ const metadata: AssistedUpdateMetadata = {
+ mode: "required",
+ title: "Service manager rewrite",
+ summary: "The launchd plist changes shape.",
+ requiredChecks: [],
+ };
+ const { unmount } = render(
+
+ );
+ expect(screen.getByText("Agent-assisted update required")).toBeTruthy();
+
+ unmount();
+ render(
+
+ );
+ expect(screen.getByText("Agent-assisted update recommended")).toBeTruthy();
+ expect(screen.queryByText("Agent-assisted update required")).toBeNull();
+ });
+
+ it("accepts required checks named as bare strings or as objects", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("server_responds")).toBeTruthy();
+ expect(screen.getByText("plist_shape")).toBeTruthy();
+ });
+
+ it("opens the instructions and keeps rollback guidance folded away", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("Run the installer, then restart.")).toBeTruthy();
+ expect(screen.queryByText("Reinstall the previous tarball.")).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: /Rollback guidance/ }));
+ expect(screen.getByText("Reinstall the previous tarball.")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: /Instructions/ }));
+ expect(screen.queryByText("Run the installer, then restart.")).toBeNull();
+ });
+
+ it("offers no disclosure for guidance the release did not declare", () => {
+ render(
+
+ );
+
+ expect(screen.queryByRole("button", { name: /Instructions/ })).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: /Rollback guidance/ })
+ ).toBeNull();
+ expect(screen.queryByText("Required checks")).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/app/release-operation-takeover.test.tsx b/apps/web/src/components/app/release-operation-takeover.test.tsx
new file mode 100644
index 00000000..0e0c97ed
--- /dev/null
+++ b/apps/web/src/components/app/release-operation-takeover.test.tsx
@@ -0,0 +1,307 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { ReleaseJob } from "@/hooks/use-release-stream";
+
+import { OperationTakeover } from "./release-operation-takeover";
+import { UPDATE_PHASES } from "./release-utils";
+
+type UpdateJob = Extract;
+type CreateJob = Extract;
+
+function makeJob(overrides: Partial = {}): UpdateJob {
+ return {
+ jobType: "update",
+ phase: "fetching",
+ startedAt: "2026-08-01T12:00:00.000Z",
+ log: ["fetching v1.1.0"],
+ runUrl: null,
+ tag: "v1.1.0",
+ error: null,
+ progress: null,
+ versionType: null,
+ ...overrides,
+ } as UpdateJob;
+}
+
+/** The release half of the union — the takeover serves both job types. */
+function makeCreateJob(overrides: Partial = {}): CreateJob {
+ const { jobType: _jobType, versionType: _versionType, ...common } = makeJob();
+ return {
+ ...common,
+ jobType: "create",
+ versionType: "patch",
+ phase: "preflight",
+ ...overrides,
+ };
+}
+
+type RenderOverrides = {
+ job?: ReleaseJob;
+ phasesOrder?: string[];
+ isDone?: boolean;
+ isFailed?: boolean;
+ isRestarting?: boolean;
+ postRestartPolling?: boolean;
+ status?: { tag: string | null; deployedAt: string | null } | null;
+};
+
+function renderTakeover(overrides: RenderOverrides = {}): {
+ onDismiss: ReturnType;
+ container: HTMLElement;
+} {
+ const onDismiss = vi.fn();
+ const { container } = render(
+
+ );
+ return { onDismiss, container };
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("the current step card", () => {
+ it("stays hidden until the job reports progress", () => {
+ renderTakeover({ job: makeJob({ progress: null }) });
+
+ expect(screen.queryByText("Current step")).toBeNull();
+ });
+
+ it("shows the label, its detail, and the transferred byte count", () => {
+ renderTakeover({
+ job: makeJob({
+ progress: {
+ step: "download",
+ label: "Downloading release",
+ detail: "dispatch-release.tar.gz",
+ bytesReceived: 512 * 1024,
+ totalBytes: 1024 * 1024,
+ },
+ }),
+ });
+
+ expect(screen.getByText("Current step")).toBeTruthy();
+ expect(screen.getByText("Downloading release")).toBeTruthy();
+ expect(screen.getByText("dispatch-release.tar.gz")).toBeTruthy();
+ expect(screen.getByText("50% · 512 KB / 1.0 MB")).toBeTruthy();
+ });
+
+ // The bar is the one part of the progress card with no text of its own, so
+ // its width is the only thing that can report an overshooting byte count —
+ // and an unclamped width paints the fill straight out of its track.
+ it("clamps the progress bar when more bytes arrive than were announced", () => {
+ const { container } = renderTakeover({
+ job: makeJob({
+ progress: {
+ step: "download",
+ label: "Downloading release",
+ detail: null,
+ bytesReceived: 3 * 1024 * 1024,
+ totalBytes: 2 * 1024 * 1024,
+ },
+ }),
+ });
+
+ const bar = container.querySelector("div[style]");
+ expect(bar?.getAttribute("style")).toBe("width: 100%;");
+ });
+
+ it("omits the byte readout for a step that transfers nothing", () => {
+ renderTakeover({
+ job: makeJob({
+ progress: {
+ step: "download",
+ label: "Swapping the symlink",
+ detail: null,
+ bytesReceived: null,
+ totalBytes: null,
+ },
+ }),
+ });
+
+ expect(screen.getByText("Swapping the symlink")).toBeTruthy();
+ // Match the readout itself, not its separator: a lost null guard in
+ // formatProgressLabel renders a bare "0 B downloaded" with no separator.
+ expect(screen.queryByText(/\d+(\.\d+)?\s?(B|KB|MB|GB)/)).toBeNull();
+ });
+});
+
+describe("the phase walk", () => {
+ // Read as one string so the ORDER is pinned, not just the membership —
+ // every phase ahead of the current one is drawn as already complete.
+ it("walks the standard update phases in order and drops the terminal one", () => {
+ renderTakeover({ job: makeJob({ phase: "deploying" }) });
+
+ const column = screen.getByText("Progress").parentElement;
+ expect(column?.textContent).toBe("ProgressFetchingDeployingRestarting");
+ });
+
+ it("spins on the restart row only while the restart is under way", () => {
+ renderTakeover({
+ job: makeJob({ phase: "restarting" }),
+ isRestarting: true,
+ });
+ expect(screen.getByRole("status", { name: "Loading" })).toBeTruthy();
+
+ cleanup();
+ renderTakeover({
+ job: makeJob({ phase: "restarting" }),
+ isRestarting: false,
+ });
+ expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
+ });
+
+ // useReleaseUpdates raises isRestarting for any update job once the client
+ // starts polling for the server to come back, so it can be true while the
+ // job is still deploying — the restart row must not claim to be running.
+ it("leaves the restart row idle while an earlier phase is current", () => {
+ renderTakeover({
+ job: makeJob({ phase: "deploying" }),
+ isRestarting: true,
+ postRestartPolling: true,
+ });
+
+ expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
+ });
+});
+
+describe("the operation log", () => {
+ it("hides the restart sentinel and renders the surrounding lines", () => {
+ renderTakeover({
+ job: makeJob({
+ // The sentinel reaches the client padded by the stream framing, so
+ // it has to be matched after trimming, not by exact equality.
+ log: ["fetching v1.1.0", " DISPATCH_RESTARTING ", "deployed"],
+ }),
+ });
+
+ expect(screen.getByText("fetching v1.1.0")).toBeTruthy();
+ expect(screen.getByText("deployed")).toBeTruthy();
+ expect(screen.queryByText("DISPATCH_RESTARTING")).toBeNull();
+ });
+
+ it("explains the silence while the server is still coming back", () => {
+ renderTakeover({
+ job: makeJob({ log: [] }),
+ isRestarting: false,
+ postRestartPolling: true,
+ });
+
+ expect(screen.getByText("Waiting for Dispatch to restart...")).toBeTruthy();
+ });
+
+ it("drops the placeholder once the restarted server streams again", () => {
+ renderTakeover({
+ job: makeJob({ log: ["back up"] }),
+ isRestarting: true,
+ postRestartPolling: true,
+ });
+
+ expect(screen.queryByText("Waiting for Dispatch to restart...")).toBeNull();
+ expect(screen.getByText("back up")).toBeTruthy();
+ });
+});
+
+describe("the outcome banners", () => {
+ it("links the GitHub Actions run when the job recorded one", () => {
+ renderTakeover({
+ job: makeJob({ runUrl: "https://example.test/runs/1" }),
+ });
+
+ const link = screen.getByRole("link", { name: /View GitHub Actions run/ });
+ expect(link.getAttribute("href")).toBe("https://example.test/runs/1");
+ // An operator-followed link out of the app has to open a new tab, or the
+ // in-flight takeover is navigated away from.
+ expect(link.getAttribute("target")).toBe("_blank");
+ });
+
+ it("offers no run link for a job that never triggered a workflow", () => {
+ renderTakeover({ job: makeJob({ runUrl: null }) });
+
+ expect(
+ screen.queryByRole("link", { name: /View GitHub Actions run/ })
+ ).toBeNull();
+ });
+
+ it("says Updated to for a finished update and dismisses on Done", () => {
+ const { onDismiss } = renderTakeover({
+ job: makeJob({ phase: "done", tag: "v1.1.0" }),
+ isDone: true,
+ });
+
+ expect(screen.getByText("Updated to")).toBeTruthy();
+ expect(screen.getByText("v1.1.0")).toBeTruthy();
+ expect(screen.queryByText("Released")).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: "Done" }));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it("says Released for a finished release job", () => {
+ renderTakeover({
+ job: makeCreateJob({ phase: "done", tag: "v1.1.0" }),
+ isDone: true,
+ });
+
+ expect(screen.getByText("Released")).toBeTruthy();
+ expect(screen.queryByText("Updated to")).toBeNull();
+ });
+
+ it("falls back to the deployed status tag when the job carries none", () => {
+ renderTakeover({
+ job: makeJob({ phase: "done", tag: null }),
+ isDone: true,
+ status: { tag: "v1.0.9", deployedAt: null },
+ });
+
+ expect(screen.getByText("v1.0.9")).toBeTruthy();
+ });
+
+ it("strips the git plumbing off a failure and dismisses on Dismiss", () => {
+ const { onDismiss } = renderTakeover({
+ job: makeJob({
+ phase: "failed",
+ error:
+ "Command failed (git fetch), exitCode=128, stderr=fatal: could not resolve host",
+ }),
+ isFailed: true,
+ });
+
+ expect(screen.getByText("could not resolve host")).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Done" })).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it("names an unexplained failure rather than showing an empty banner", () => {
+ renderTakeover({
+ job: makeJob({ phase: "failed", error: null }),
+ isFailed: true,
+ });
+
+ expect(screen.getByText("Operation failed")).toBeTruthy();
+ });
+
+ it("shows no banner and no dismissal while the operation runs", () => {
+ renderTakeover({ job: makeJob({ phase: "fetching" }) });
+
+ expect(screen.queryByRole("button", { name: "Done" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/app/updates-section.test.tsx b/apps/web/src/components/app/updates-section.test.tsx
index e3fd4190..89d7910b 100644
--- a/apps/web/src/components/app/updates-section.test.tsx
+++ b/apps/web/src/components/app/updates-section.test.tsx
@@ -323,6 +323,12 @@ describe("an update in flight takes over the section", () => {
expect(
screen.queryByRole("button", { name: "Check for updates" })
).toBeNull();
+ // The takeover takes its phase list from the caller, so this is the only
+ // place the standard update's phases are chosen — an assisted list here
+ // would walk the operator through steps this job never runs.
+ expect(screen.getByText("Fetching")).toBeTruthy();
+ expect(screen.getByText("Deploying")).toBeTruthy();
+ expect(screen.queryByText("Apply update")).toBeNull();
});
it("replaces the controls while an assisted update runs", () => {