From cc39dfa176760f9894ce63c10a62df46e9a82681 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Wed, 19 Aug 2026 20:31:38 -0600 Subject: [PATCH] Cover the assisted-update and release takeover panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both takeover components rendered only through updates-section.test.tsx, which asserted no more than "the settings controls went away". Everything the operator actually reads during an in-flight update — which source drives the headline, which phases the walk lists and in what order, what a terminal phase says, the migration/notes/check lists, the agent link — was unpinned, along with release-shared's DISPATCH_RESTARTING log filter and its restart placeholder. - assisted-update-card.test.tsx (26 tests) covers AssistedUpdateProgress's headline precedence (migrations over metadata over the generic fallback, with an empty array reading as "no manifests"), the assisted phase set asserted as one ordered string so a reordering is caught, the failed/rollback/blocked messages and the assisted-over-job error precedence, the detail lists, and the agent link's full-id href. It also covers both pre-launch gates, including that the required/recommended copy follows the `required` prop rather than metadata.mode — the two diverge whenever release-info raises assistedRequired for pending migrations. - release-operation-takeover.test.tsx (18 tests) covers the current-step card and its clamped progress bar, the standard phase walk, the restart spinner's current-phase guard, the log's sentinel filter and restart placeholder, and the Updated-to/Released split with its job.tag-then-status.tag fallback. - updates-section.test.tsx gains three lines pinning UPDATE_PHASES as what the standard takeover walks; nothing else in the suite caught swapping in the assisted list. 52 mutants run against the code under test; 51 killed. The survivor is the log auto-scroll effect, which jsdom cannot observe — scrollHeight is always 0 with no layout engine — so it was recorded rather than faked. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/assisted-update-card.test.tsx | 477 ++++++++++++++++++ .../app/release-operation-takeover.test.tsx | 307 +++++++++++ .../components/app/updates-section.test.tsx | 6 + 3 files changed, 790 insertions(+) create mode 100644 apps/web/src/components/app/assisted-update-card.test.tsx create mode 100644 apps/web/src/components/app/release-operation-takeover.test.tsx 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", () => {