Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,86 @@ import { workspaceJobs } from "$lib/stores/workspaces.js"
import { toasts } from "$lib/stores/toasts.js"
import { extractErrorMessage } from "$lib/utils/error.js"
import { workspaceRefresh } from "$lib/ipc/commands.js"
import { Spinner } from "$lib/components/ui/spinner/index.js"
import { workspaceJobBusy, workspaceJobLabel, workspaceJobPhase } from "$shared/workspace-operation.js"
import { Loader2 } from "@lucide/svelte"
import {
presentWorkspaceStatus,
type WorkspaceJob,
} from "$shared/workspace-operation.js"
import { goto } from "$lib/router.js"
import { badgeVariants } from "$lib/components/ui/badge/index.js"
import { Button } from "$lib/components/ui/button/index.js"
let { id, status }: { id: string; status?: string } = $props()
let job = $derived($workspaceJobs[id])
let label = $derived(workspaceJobLabel(job))
let phase = $derived(workspaceJobPhase(job))

let {
id,
status,
job: jobOverride,
density = "compact",
onViewLogs,
}: {
id: string
status?: string
job?: WorkspaceJob
density?: "compact" | "expanded"
onViewLogs?: () => void
} = $props()
let job = $derived(jobOverride ?? $workspaceJobs[id])
let view = $derived(presentWorkspaceStatus({ lifecycle: status, job }))
let refreshing = $state(false)
function viewLogs(event: MouseEvent) {
event.stopPropagation()
if (onViewLogs) onViewLogs()
else goto(`/workspaces/${id}?tab=logs`)
}
async function retryRefresh(event: MouseEvent) {
event.stopPropagation()
refreshing = true
try {
await workspaceRefresh(id)
} catch (error) {
toasts.error(`Could not refresh workspace status: ${extractErrorMessage(error)}`)
toasts.error(
`Could not refresh workspace status: ${extractErrorMessage(error)}`,
)
} finally {
refreshing = false
}
}
const badgeVariant = $derived(
view.tone === "warning"
? "secondary"
: (view.tone as "default" | "secondary" | "outline" | "destructive"),
)
</script>
<div aria-live="polite" aria-busy={workspaceJobBusy(job)} class="flex flex-col items-start gap-1">
<span class={badgeVariants({ variant: job?.error ? "destructive" : label ? "secondary" : status?.toLowerCase() === "running" ? "default" : "outline" })}>
{#if workspaceJobBusy(job)}<Spinner class="size-3" />{/if}
{label ?? status ?? "Checking"}

<div role="status" aria-live={view.error ? "assertive" : "polite"} aria-busy={view.busy} class="flex min-h-10 flex-col items-start gap-1">
<span class={badgeVariants({ variant: badgeVariant })}>
{#if view.busy}<Loader2 class="size-3 animate-spin" aria-hidden="true" />{/if}
{view.headline}
</span>
<span
class="max-w-full truncate text-xs {view.error
? 'text-destructive'
: view.recovery
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground'}"
title={view.error ?? view.recovery?.message ?? view.phase ?? undefined}
>
{#if view.error}
{view.error}{#if density === "expanded"}{" · "}<button
type="button"
class="font-medium text-foreground underline underline-offset-2"
aria-label="View logs for {id}"
onclick={viewLogs}>View logs</button>{/if}
{:else if view.recovery}
&#9888; {view.recovery.message}{#if view.recovery.canRetry}{" · "}<button
type="button"
class="font-medium text-foreground underline underline-offset-2"
aria-label="Retry status for {id}"
disabled={refreshing}
onclick={retryRefresh}>Retry</button
>{/if}
{:else if view.phase}
{view.phase}
{:else}
<span aria-hidden="true">&nbsp;</span>
{/if}
</span>
{#if phase}<span class="text-xs text-muted-foreground">{phase}</span>{/if}
{#if job?.error}<span class="text-xs text-destructive">{job.error}</span>{/if}
{#if job?.refreshError}
<Button variant="ghost" size="sm" disabled={refreshing} onclick={retryRefresh}>Retry refresh</Button>
{/if}
</div>
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { mockInvoke, resetTauriMocks } from "$lib/__mocks__/tauri.js"
import { workspaceJobs } from "$lib/stores/workspaces.js"
import { toasts } from "$lib/stores/toasts.js"
import { workspaceJobs } from "$lib/stores/workspaces.js"
import WorkspaceOperation from "./WorkspaceOperation.svelte"

vi.mock("$lib/stores/toasts.js", () => ({
toasts: { success: vi.fn(), error: vi.fn() },
toasts: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}))
vi.mock("$lib/router.js", () => ({
goto: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
router: {},
location: { subscribe: () => () => {} },
querystring: { subscribe: () => () => {} },
}))
afterEach(cleanup)
beforeEach(() => {
Expand All @@ -14,28 +23,43 @@ beforeEach(() => {
vi.clearAllMocks()
})
describe("WorkspaceOperation", () => {
it("keeps the status pill at intrinsic width inside the live region", () => {
it("shows the observed lifecycle with the phase line reserved", () => {
const ui = render(WorkspaceOperation, { id: "ws", status: "Running" })
const badge = ui.getByText("Running")
const liveRegion = badge.parentElement
expect(liveRegion?.classList.contains("flex-col")).toBe(true)
expect(liveRegion?.classList.contains("items-start")).toBe(true)
expect(ui.getByText("Running")).toBeTruthy()
const region = ui.getByRole("status")
expect(region.getAttribute("aria-busy")).toBe("false")
expect(region.querySelector('[aria-hidden="true"]')).toBeTruthy()
})
it("keeps the action visible over a stale runtime observation", () => {
it("keeps the active command ahead of a stale runtime observation", () => {
workspaceJobs.set({
ws: {
commandId: "delete",
activity: "deleting",
state: "running",
phase: "Closing connections",
phase: "closing_connections",
},
})
const ui = render(WorkspaceOperation, { id: "ws", status: "Running" })
expect(ui.getByText("Deleting")).toBeTruthy()
expect(ui.getByText("Closing connections")).toBeTruthy()
expect(ui.queryByText("Running")).toBeNull()
})
it("retries observation only and reports an IPC rejection", async () => {
it("says Confirming removal while a delete awaits confirmation, never Deleted", () => {
workspaceJobs.set({
ws: {
commandId: "delete",
activity: "deleting",
state: "reconciling",
phase: "Refreshing list",
},
})
const ui = render(WorkspaceOperation, { id: "ws", status: "Running" })
expect(ui.getByText("Deleting")).toBeTruthy()
expect(ui.getByText("Confirming removal")).toBeTruthy()
expect(ui.queryByText("Deleted")).toBeNull()
expect(ui.getByRole("status").getAttribute("aria-busy")).toBe("true")
})
it("shows recovery wording with an inline Retry that only re-refreshes", async () => {
workspaceJobs.set({
ws: {
commandId: "delete",
Expand All @@ -47,8 +71,12 @@ describe("WorkspaceOperation", () => {
})
mockInvoke.mockRejectedValue(new Error("IPC unavailable"))
const ui = render(WorkspaceOperation, { id: "ws", status: "Running" })
expect(ui.getByText("Deleted")).toBeTruthy()
await fireEvent.click(ui.getByRole("button", { name: "Retry refresh" }))
expect(ui.getByText(/List may be out of date/)).toBeTruthy()
expect(ui.queryByText("Deleted")).toBeNull()
expect(ui.getByRole("status").getAttribute("aria-busy")).toBe("false")
await fireEvent.click(
ui.getByRole("button", { name: "Retry status for ws" }),
)
await waitFor(() =>
expect(toasts.error).toHaveBeenCalledWith(
expect.stringContaining("IPC unavailable"),
Expand All @@ -60,6 +88,42 @@ describe("WorkspaceOperation", () => {
expect(
mockInvoke.mock.calls.some((call) => call[0] === "workspace_delete"),
).toBe(false)
expect(ui.getByText("Deleted")).toBeTruthy()
})
it("renders an operation failure with the error and View logs when expanded", async () => {
workspaceJobs.set({
ws: {
commandId: "stop",
activity: "stopping",
state: "failed",
phase: "stopping_workspace",
error: "provider unavailable",
},
})
const onViewLogs = vi.fn()
const ui = render(WorkspaceOperation, {
id: "ws",
status: "Running",
density: "expanded",
onViewLogs,
})
expect(ui.getByText("Stop failed")).toBeTruthy()
expect(ui.getByText(/provider unavailable/)).toBeTruthy()
expect(ui.getByRole("status").getAttribute("aria-live")).toBe("assertive")
await fireEvent.click(ui.getByRole("button", { name: "View logs for ws" }))
expect(onViewLogs).toHaveBeenCalledOnce()
})
it("does not nest a View logs button in compact density", () => {
workspaceJobs.set({
ws: {
commandId: "stop",
activity: "stopping",
state: "failed",
phase: "stopping_workspace",
error: "provider unavailable",
},
})
const ui = render(WorkspaceOperation, { id: "ws", status: "Running" })
expect(ui.getByText("Stop failed")).toBeTruthy()
expect(ui.queryByRole("button", { name: "View logs for ws" })).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ vi.mock("$lib/stores/providers.js", async () => {
})
vi.mock("$lib/stores/workspaces.js", async () => {
const { writable } = await import("svelte/store")
return { workspaces: writable<{ id: string }[]>([]), workspaceJobs: writable({}) }
return {
workspaces: writable<{ id: string }[]>([]),
workspaceJobs: writable({}),
}
})
vi.mock("$lib/stores/toasts.js", () => ({
toasts: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
Expand Down Expand Up @@ -206,7 +209,9 @@ describe("WorkspaceWizard platform compatibility", () => {
await gotoReviewWithImage(getByText, "ubuntu:22.04")

await waitFor(() =>
expect(getByText(/Compatible with your machine \(linux\/arm64\)/i)).toBeTruthy(),
expect(
getByText(/Compatible with your machine \(linux\/arm64\)/i),
).toBeTruthy(),
)
expect(getByText(/linux\/amd64, linux\/arm64/i)).toBeTruthy()
unmount()
Expand All @@ -221,7 +226,7 @@ describe("WorkspaceWizard platform compatibility", () => {
await gotoReviewWithImage(getByText, "ubuntu:22.04")

await waitFor(() =>
expect(getByText(/Couldn't verify compatibility/i)).toBeTruthy(),
expect(getByText(/Could not verify compatibility/i)).toBeTruthy(),
)
expect(queryByText(/no build for your machine/i)).toBeNull()
expect(document.querySelector('input[type="checkbox"]')).toBeNull()
Expand All @@ -245,7 +250,7 @@ describe("WorkspaceWizard platform compatibility", () => {
await gotoReviewWithImage(getByText, "ubuntu:22.04")

await waitFor(() =>
expect(getByText(/Couldn't verify compatibility/i)).toBeTruthy(),
expect(getByText(/Could not verify compatibility/i)).toBeTruthy(),
)
expect(queryByText(/no build for your machine/i)).toBeNull()
unmount()
Expand Down
Loading
Loading