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", () => {