From b6cf5c97debc5783b2f3da5b85a1571ffb790e92 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 25 May 2026 10:24:58 -0500 Subject: [PATCH 1/2] feat(desktop): replace workspace sheet with multi-step wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces WorkspaceWizard, a 5-step guided dialog (provider → source → IDE → review → launch) that replaces CreateWorkspaceSheet. Step 1 hard- blocks until at least one initialized provider exists; the launch step streams workspace_up output via the race-safe pending-event buffer pattern, with a 5-minute watchdog and a close-during-launch confirm dialog. Adds 14 unit tests and rewrites workspaces.e2e.ts to walk the new flow. --- desktop/e2e/workspaces.e2e.ts | 135 +++- .../workspace/CreateWorkspaceSheet.svelte | 499 ------------ .../workspace/WorkspaceWizard.svelte | 725 ++++++++++++++++++ .../workspace/WorkspaceWizard.test.ts | 453 +++++++++++ .../renderer/src/pages/WorkspacesPage.svelte | 4 +- 5 files changed, 1280 insertions(+), 536 deletions(-) delete mode 100644 desktop/src/renderer/src/lib/components/workspace/CreateWorkspaceSheet.svelte create mode 100644 desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte create mode 100644 desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts diff --git a/desktop/e2e/workspaces.e2e.ts b/desktop/e2e/workspaces.e2e.ts index 8d65442cb..e559bbb9f 100644 --- a/desktop/e2e/workspaces.e2e.ts +++ b/desktop/e2e/workspaces.e2e.ts @@ -42,30 +42,56 @@ test.describe("Workspaces Page", () => { await expect(table).toContainText("Running") await expect(table).toContainText("Stopped") }) +}) - test("should open the create workspace sheet with templates", async () => { +test.describe.serial("Create Workspace Wizard", () => { + test("should open the wizard and show step 1 (provider)", async () => { await page.getByRole("button", { name: /create workspace/i }).click() - const sheet = page.locator('[role="dialog"]') - await expect(sheet).toBeVisible({ timeout: 5000 }) - - // Should show Quick Start Templates section - await expect(sheet).toContainText("Quick Start Templates") - // Should show language template buttons - await expect(sheet).toContainText("Python") - await expect(sheet).toContainText("Node.js") - await expect(sheet).toContainText("Go") - await expect(sheet).toContainText("Rust") - await expect(sheet).toContainText("Java") + const dialog = page.locator('[role="dialog"]').first() + await expect(dialog).toBeVisible({ timeout: 5000 }) + + // Step indicator labels — all 5 steps present + for (const label of ["Provider", "Source", "IDE", "Review", "Launch"]) { + await expect(dialog).toContainText(label) + } + + // Provider step heading visible + await expect( + dialog.getByRole("heading", { name: /choose a provider/i }), + ).toBeVisible() + + // Mock CLI exposes "docker" as the only initialized provider; ensure it is listed + await expect(dialog.locator("button", { hasText: "docker" })).toBeVisible({ + timeout: 10000, + }) + + // Continue is disabled until a provider is selected + const continueBtn = dialog.getByRole("button", { name: /^continue$/i }) + await expect(continueBtn).toBeDisabled() }) - test("should show language icons in template buttons", async () => { - const sheet = page.locator('[role="dialog"]') - // Template buttons contain LanguageIcon components which render tags - const icons = sheet.locator("button img") - const iconCount = await icons.count() - expect(iconCount).toBeGreaterThan(0) + test("should advance to source step with templates", async () => { + const dialog = page.locator('[role="dialog"]').first() + // Select the docker provider (the initialized one from the mock) + await dialog.locator("button", { hasText: "docker" }).first().click() + + const continueBtn = dialog.getByRole("button", { name: /^continue$/i }) + await expect(continueBtn).toBeEnabled() + await continueBtn.click() + + await expect( + dialog.getByRole("heading", { name: /choose a source/i }), + ).toBeVisible() - // Verify at least one icon loaded successfully (naturalWidth > 0) + // Quick Start Templates section + 5 core templates + await expect(dialog).toContainText("Quick Start Templates") + for (const lang of ["Python", "Node.js", "Go", "Rust", "Java"]) { + await expect(dialog.locator("button", { hasText: lang })).toBeVisible() + } + + // Language icons render + const icons = dialog.locator("button img") + expect(await icons.count()).toBeGreaterThan(0) const firstIcon = icons.first() await expect(firstIcon).toBeVisible() const naturalWidth = await firstIcon.evaluate( @@ -75,29 +101,68 @@ test.describe("Workspaces Page", () => { }) test("should select a template and populate the source field", async () => { - const sheet = page.locator('[role="dialog"]') - // Click the Python template - await sheet.locator("button", { hasText: "Python" }).click() + const dialog = page.locator('[role="dialog"]').first() + await dialog.locator("button", { hasText: "Python" }).click() - // Source input should be populated with the template URL - const sourceInput = sheet.locator('input[placeholder*="github"]') + const sourceInput = dialog.locator('input[placeholder*="github"]') await expect(sourceInput).toHaveValue( "https://github.com/microsoft/vscode-remote-try-python", ) }) - test("should submit workspace creation and show output", async () => { - const sheet = page.locator('[role="dialog"]') - // Source should already be filled from previous test - // Click Create Workspace button - await sheet.getByRole("button", { name: /create workspace/i }).click() - - // The mock CLI handles "up" and streams output lines - // Wait for output to appear - await expect(sheet).toContainText("Output", { timeout: 10000 }) - // The mock outputs: "Resolving source...", "Pulling image...", etc. - await expect(sheet).toContainText(/resolving|pulling|starting|ready/i, { + test("should walk through IDE step", async () => { + const dialog = page.locator('[role="dialog"]').first() + // Continue from source -> IDE + const continueBtn = dialog.getByRole("button", { name: /^continue$/i }) + await expect(continueBtn).toBeEnabled() + await continueBtn.click() + + await expect( + dialog.getByRole("heading", { name: /choose an ide/i }), + ).toBeVisible() + + // IDE combobox trigger (the popover button) defaults to "Select an IDE..." or "None" + // Since the default state has selectedIde = "none", the label should be "None". + await expect(dialog).toContainText("None") + + // Continue is always enabled here (IDE is optional); advance to Review + await dialog.getByRole("button", { name: /^continue$/i }).click() + }) + + test("should show review summary", async () => { + const dialog = page.locator('[role="dialog"]').first() + await expect( + dialog.getByRole("heading", { name: /^review$/i }), + ).toBeVisible() + + // Summary card shows chosen provider, source, ide label, workspace id + await expect(dialog).toContainText("docker") + await expect(dialog).toContainText( + "https://github.com/microsoft/vscode-remote-try-python", + ) + await expect(dialog).toContainText("None") + + // Workspace name was populated by selectTemplate("Python") -> "python" + const nameInput = dialog.locator( + 'input[placeholder*="derived from source"]', + ) + await expect(nameInput).toHaveValue("python") + }) + + test("should launch workspace and stream output", async () => { + const dialog = page.locator('[role="dialog"]').first() + // The review step's primary button is labeled "Launch" + await dialog.getByRole("button", { name: /^launch$/i }).click() + + // Mock CLI streams: "Resolving source...", "Pulling image...", + // "Starting workspace...", "Workspace ready." + await expect(dialog).toContainText(/resolving|pulling|starting|ready/i, { timeout: 10000, }) + + // On success the "Open Workspace" button appears + await expect( + dialog.getByRole("button", { name: /open workspace/i }), + ).toBeVisible({ timeout: 15000 }) }) }) diff --git a/desktop/src/renderer/src/lib/components/workspace/CreateWorkspaceSheet.svelte b/desktop/src/renderer/src/lib/components/workspace/CreateWorkspaceSheet.svelte deleted file mode 100644 index 1ffb84461..000000000 --- a/desktop/src/renderer/src/lib/components/workspace/CreateWorkspaceSheet.svelte +++ /dev/null @@ -1,499 +0,0 @@ - - - - - - Create Workspace - Set up a new development workspace from a source repository, image, or local path. - - -
-
-

Quick Start Templates

-
- {#each TEMPLATES as template (template.name)} - - {/each} -
-
- - {#if error} -
- {error} -
- {/if} - - {#if $providers.length === 0} -
- -
- No providers configured. You need at least one provider to create a workspace. - -
-
- {/if} - -
{ e.preventDefault(); handleSubmit() }}> -
- - (source = e.currentTarget.value)} - disabled={submitting} - class="h-9" - /> -
- -
- - (name = e.currentTarget.value)} - disabled={submitting} - class="h-9" - /> -
- -
- - - - {#snippet child({ props })} - - {/snippet} - - - - - - No provider found. - - {#each filteredProviders as p (p.name)} - { selectedProvider = p.name; providerComboOpen = false; providerSearch = "" }} - > - - {p.name} - {#if p.state?.initialized !== true} - (not initialized) - {/if} - - {/each} - - - { providerComboOpen = false; providerSearch = ""; open = false; goto('/providers/add') }} - > - + Add Provider - - - - - - - {#if selectedProvider && $providers.find(p => p.name === selectedProvider)?.state?.initialized !== true} -
- -
-

Provider not initialized

-

- This provider needs to be initialized before creating workspaces. - -

-
-
- {/if} -
- -
- - - - {#snippet child({ props })} - - {/snippet} - - - - - - No IDE found. - - {#each filteredIdes as ide (ide.value)} - { selectedIde = ide.value; ideComboOpen = false; ideSearch = "" }} - > - - {ide.label} - - {/each} - - - - - -
- - - - -
- - {#if outputLines.length > 0} -
-
-

Output

- -
-
- -
-
-
- {/if} - - {#if createdId} -
- -
- {/if} -
-
-
- - { open = false; confirmCancelOpen = false }} -/> diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte new file mode 100644 index 000000000..22be286f9 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -0,0 +1,725 @@ + + + + + + Create Workspace + + Step-by-step wizard to create and launch a new workspace. + + + + +
+ +
+ + +
+ {#each STEPS as step, i (step.id)} +
+
+ {#if stepStates[step.id] === "complete"} + + {:else if stepStates[step.id] === "error"} + + {:else} + {i + 1} + {/if} +
+ + {step.label} + +
+ {#if i < STEPS.length - 1} +
+ {/if} + {/each} +
+ + +
+ {#if currentStep === "provider"} +
+
+

Choose a Provider

+

Select an initialized provider to host your workspace.

+
+ + {#if initializedProviders.length === 0} + + + + At least one initialized provider is required to create a workspace. + + + + {:else} +
+ {#each initializedProviders as p (p.name)} + + {/each} +
+ {/if} + +
+ +
+
+ + {:else if currentStep === "source"} +
+
+

Choose a Source

+

Pick a template or enter a custom source (git URL, image, or local path).

+
+ +
+

Quick Start Templates

+
+ {#each TEMPLATES as template (template.name)} + + {/each} +
+
+ +
+ + (source = e.currentTarget.value)} + /> +
+ +
+ + {#if advancedOpen} +
+ + (workspaceFolder = e.currentTarget.value)} + /> +

+ Optional path inside the repository to use as the workspace root. +

+
+ {/if} +
+ +
+ + +
+
+ + {:else if currentStep === "ide"} +
+
+

Choose an IDE

+

Pick an IDE to open the workspace with. (Optional)

+
+ +
+ + + + {#snippet child({ props })} + + {/snippet} + + + + + + No IDE found. + + {#each filteredIdes as ide (ide.value)} + { selectedIde = ide.value; ideComboOpen = false; ideSearch = "" }} + > + + {ide.label} + + {/each} + + + + + +
+ +
+ + +
+
+ + {:else if currentStep === "review"} +
+
+

Review

+

Confirm your workspace configuration before launching.

+
+ +
+ + (workspaceName = e.currentTarget.value)} + /> +

+ Resolved id: {resolvedId || "—"} +

+
+ + {#if resolvedIdInvalid} + + + + Please provide an explicit workspace name. The derived id is empty or invalid. + + + {/if} + + {#if nameConflict} + + + + A workspace named "{resolvedId}" already exists. Choose a different name. + + + {/if} + +
+
+ Provider + {selectedProvider} +
+
+ Source + {source} +
+ {#if workspaceFolder} +
+ Workspace Folder + {workspaceFolder} +
+ {/if} +
+ IDE + {ideLabel} +
+
+ Workspace ID + {resolvedId} +
+
+ +
+ + +
+
+ + {:else if currentStep === "launch"} +
+
+

+ {#if launchRunning} + Creating Workspace + {:else if launchSuccess} + Workspace Ready + {:else if launchError} + Workspace Creation Failed + {:else} + Launching... + {/if} +

+

+ {#if launchRunning} + Running workspace up... + {:else if launchSuccess} + {launchedWorkspaceId ?? resolvedId} is ready to use. + {:else if launchError} + Something went wrong while creating the workspace. + {/if} +

+
+ + {#if launchError} + + + {launchError} + + {/if} + + {#if outputLines.length > 0} +
+ +
+
+ {:else if launchRunning} +
+ +
+ {/if} + +
+ {#if launchSuccess} + + + {:else if launchError} + + + {/if} +
+
+ {/if} +
+
+
+ + { + open = false + confirmCancelOpen = false + }} +/> diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts new file mode 100644 index 000000000..36d03e58e --- /dev/null +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts @@ -0,0 +1,453 @@ +import { tick } from "svelte" +import { fireEvent, render } from "@testing-library/svelte" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type { Provider, CommandProgress } from "$lib/types/index.js" + +const workspaceUp = vi.fn() +const onCommandProgress = vi.fn() + +vi.mock("$lib/ipc/commands.js", () => ({ + workspaceUp: (...args: unknown[]) => workspaceUp(...args), +})) + +vi.mock("$lib/ipc/events.js", () => ({ + onCommandProgress: (...args: unknown[]) => onCommandProgress(...args), +})) + +vi.mock("$lib/stores/providers.js", async () => { + const { writable } = await import("svelte/store") + return { providers: writable([]) } +}) + +vi.mock("$lib/stores/workspaces.js", async () => { + const { writable } = await import("svelte/store") + return { workspaces: writable<{ id: string }[]>([]) } +}) + +vi.mock("$lib/stores/toasts.js", () => ({ + 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: () => () => {} }, +})) + +import { providers } from "$lib/stores/providers.js" +import { workspaces } from "$lib/stores/workspaces.js" +import WorkspaceWizard from "./WorkspaceWizard.svelte" + +function makeProvider(name: string, initialized = true): Provider { + return { name, version: "0.1.0", state: { initialized } } +} + +async function flushAsync() { + await new Promise((r) => setTimeout(r, 30)) + await tick() + await tick() +} + +let progressCallback: ((progress: CommandProgress) => void) | null = null + +async function advanceToReview(getByText: (t: string) => HTMLElement) { + // provider + await fireEvent.click(getByText("docker")) + await flushAsync() + await fireEvent.click(getAllContinue(getByText)[0]) + await flushAsync() + // source: choose template (sets source) + await fireEvent.click(getByText("Python")) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + // ide + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() +} + +// Helpers +function getAllContinue(getByText: (t: string) => HTMLElement): HTMLElement[] { + // Multiple "Continue" buttons can exist (advanced toggle text differs); + // return a list and pick the first enabled non-disabled one. + const all = Array.from(document.querySelectorAll("button")).filter( + (b) => b.textContent?.trim() === "Continue", + ) as HTMLElement[] + return all +} + +function getActiveContinue(_: (t: string) => HTMLElement): HTMLElement { + const candidates = Array.from(document.querySelectorAll("button")).filter( + (b) => + b.textContent?.trim() === "Continue" && + !(b as HTMLButtonElement).disabled, + ) as HTMLElement[] + return candidates[0] +} + +describe("WorkspaceWizard", () => { + beforeEach(() => { + workspaceUp.mockReset() + onCommandProgress.mockReset() + providers.set([]) + workspaces.set([]) + progressCallback = null + + onCommandProgress.mockImplementation( + async (cb: (progress: CommandProgress) => void) => { + progressCallback = cb + return () => { + progressCallback = null + } + }, + ) + + workspaceUp.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 5)) + return "cmd-1" + }) + }) + + afterEach(() => { + vi.clearAllMocks() + document.body.innerHTML = "" + }) + + it("renders the provider step by default", async () => { + providers.set([makeProvider("docker")]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + expect(getByText("Choose a Provider")).toBeTruthy() + unmount() + }) + + it("hard-blocks when there are zero initialized providers", async () => { + providers.set([makeProvider("docker", false)]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + expect( + getByText(/At least one initialized provider is required/i), + ).toBeTruthy() + const continueBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Continue") as HTMLButtonElement + expect(continueBtn).toBeTruthy() + expect(continueBtn.disabled).toBe(true) + unmount() + }) + + it("only shows initialized providers on the provider step", async () => { + providers.set([makeProvider("docker", true), makeProvider("ssh", false)]) + const { queryByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + expect(queryByText("docker")).not.toBeNull() + expect(queryByText("ssh")).toBeNull() + unmount() + }) + + it("advances from provider to source after selecting and continuing", async () => { + providers.set([makeProvider("docker")]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await fireEvent.click(getByText("docker")) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + expect(getByText("Choose a Source")).toBeTruthy() + unmount() + }) + + it("source step requires a non-empty source to continue", async () => { + providers.set([makeProvider("docker")]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await fireEvent.click(getByText("docker")) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + // On source step, with empty source, no Continue should be enabled + const enabled = getActiveContinue(getByText) + expect(enabled).toBeUndefined() + unmount() + }) + + it("template selection populates the source field", async () => { + providers.set([makeProvider("docker")]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await fireEvent.click(getByText("docker")) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + await fireEvent.click(getByText("Python")) + await flushAsync() + + const input = document.querySelector( + 'input[placeholder*="github.com/org/repo"]', + ) as HTMLInputElement + expect(input.value).toContain("vscode-remote-try-python") + unmount() + }) + + it("review step shows summary and blocks Launch on name conflict", async () => { + providers.set([makeProvider("docker")]) + workspaces.set([{ id: "python" }]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await advanceToReview(getByText) + + // Review heading + step indicator both contain "Review", so check the h2 specifically + const heading = document.querySelector("h2") + expect(heading?.textContent).toBe("Review") + expect(getByText(/already exists/i)).toBeTruthy() + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + expect(launchBtn.disabled).toBe(true) + unmount() + }) + + it("registers the progress listener before awaiting workspaceUp", async () => { + providers.set([makeProvider("docker")]) + let listenerRegisteredBeforeUp = false + onCommandProgress.mockImplementation(async (cb) => { + progressCallback = cb + if (!workspaceUp.mock.calls.length) { + listenerRegisteredBeforeUp = true + } + return () => {} + }) + + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await advanceToReview(getByText) + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + await fireEvent.click(launchBtn) + await flushAsync() + + expect(listenerRegisteredBeforeUp).toBe(true) + expect(workspaceUp).toHaveBeenCalled() + unmount() + }) + + it("streamed log lines appear in the launch step", async () => { + providers.set([makeProvider("docker")]) + const { getByText, queryByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await advanceToReview(getByText) + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + await fireEvent.click(launchBtn) + await flushAsync() + + progressCallback?.({ + commandId: "cmd-1", + message: "Building workspace...", + done: false, + } as CommandProgress) + await flushAsync() + + expect(queryByText(/Building workspace/)).not.toBeNull() + unmount() + }) + + it("shows Open Workspace on success", async () => { + providers.set([makeProvider("docker")]) + const { getByText, queryByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await advanceToReview(getByText) + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + await fireEvent.click(launchBtn) + await flushAsync() + + progressCallback?.({ + commandId: "cmd-1", + message: "Exit code: 0", + done: true, + } as CommandProgress) + await flushAsync() + + expect(queryByText("Open Workspace")).not.toBeNull() + unmount() + }) + + it("shows Retry on error", async () => { + providers.set([makeProvider("docker")]) + const { getByText, queryByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await advanceToReview(getByText) + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + await fireEvent.click(launchBtn) + await flushAsync() + + progressCallback?.({ + commandId: "cmd-1", + message: "Exit code: 1", + done: true, + } as CommandProgress) + await flushAsync() + + expect(queryByText("Retry")).not.toBeNull() + unmount() + }) + + it("close-during-launch shows the confirm dialog instead of closing", async () => { + providers.set([makeProvider("docker")]) + // Hold workspaceUp open so launchRunning stays true + let resolveUp: ((v: string) => void) | undefined + workspaceUp.mockImplementation( + () => + new Promise((r) => { + resolveUp = r + }), + ) + + const { getByText, queryByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await advanceToReview(getByText) + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + await fireEvent.click(launchBtn) + await flushAsync() + + // While launchRunning is true (workspaceUp still pending), pressing Escape + // on the Dialog should surface the cancel confirmation. + await fireEvent.keyDown(document.body, { key: "Escape", code: "Escape" }) + await flushAsync() + + expect(queryByText(/Cancel workspace creation/i)).not.toBeNull() + + // Tidy up: resolve the pending promise so the component finishes. + resolveUp?.("cmd-1") + await flushAsync() + unmount() + }) +}) + +describe("launch watchdog", () => { + beforeEach(() => { + workspaceUp.mockReset() + onCommandProgress.mockReset() + providers.set([]) + workspaces.set([]) + progressCallback = null + + onCommandProgress.mockImplementation( + async (cb: (progress: CommandProgress) => void) => { + progressCallback = cb + return () => { + progressCallback = null + } + }, + ) + + workspaceUp.mockImplementation(async () => "cmd-1") + + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + document.body.innerHTML = "" + }) + + it("fires after 5 minutes when no done event arrives", async () => { + providers.set([makeProvider("docker")]) + const { getByText, queryByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await advanceToReview(getByText) + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + await fireEvent.click(launchBtn) + await flushAsync() + + // Advance past the 5-minute watchdog. + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 1) + await flushAsync() + + expect(queryByText(/timed out after 5 minutes/i)).not.toBeNull() + expect(queryByText("Open Workspace")).toBeNull() + expect(queryByText("Retry")).not.toBeNull() + unmount() + }) + + it("late done event after watchdog firing does not overwrite the error", async () => { + providers.set([makeProvider("docker")]) + const { getByText, queryByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await advanceToReview(getByText) + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + await fireEvent.click(launchBtn) + await flushAsync() + + // Trip the watchdog. + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 1) + await flushAsync() + + expect(queryByText(/timed out after 5 minutes/i)).not.toBeNull() + + // Late done event (the listener should have been disposed, so this is a no-op). + progressCallback?.({ + commandId: "cmd-1", + message: "Exit code: 0", + done: true, + } as CommandProgress) + await flushAsync() + + // Timeout error is still visible; Open Workspace did not appear. + expect(queryByText(/timed out after 5 minutes/i)).not.toBeNull() + expect(queryByText("Open Workspace")).toBeNull() + unmount() + }) +}) diff --git a/desktop/src/renderer/src/pages/WorkspacesPage.svelte b/desktop/src/renderer/src/pages/WorkspacesPage.svelte index ded06a61e..3c0f3d2ab 100644 --- a/desktop/src/renderer/src/pages/WorkspacesPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspacesPage.svelte @@ -18,7 +18,7 @@ import { Input } from "$lib/components/ui/input/index.js" import * as Table from "$lib/components/ui/table/index.js" import TableSkeleton from "$lib/components/ui/skeleton/TableSkeleton.svelte" import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte" -import CreateWorkspaceSheet from "$lib/components/workspace/CreateWorkspaceSheet.svelte" +import WorkspaceWizard from "$lib/components/workspace/WorkspaceWizard.svelte" import { workspaceUp, workspaceStop, @@ -277,4 +277,4 @@ async function handleDelete() { onconfirm={handleDelete} /> - + From 605c5388f1dd63814b879e4d6e4544c3ef0a15ff Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 25 May 2026 10:33:06 -0500 Subject: [PATCH 2/2] test(desktop): update integration e2e to walk WorkspaceWizard The Node.js and Python workspace-lifecycle flows still drove the old CreateWorkspaceSheet UX (click template tile directly, then submit). Update both to walk the 5-step wizard: pick docker provider, pick template, advance through IDE and Review, then Launch. --- desktop/e2e/integration.e2e.ts | 69 +++++++++++++++++----------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/desktop/e2e/integration.e2e.ts b/desktop/e2e/integration.e2e.ts index 9341cd61f..e49974c56 100644 --- a/desktop/e2e/integration.e2e.ts +++ b/desktop/e2e/integration.e2e.ts @@ -225,40 +225,39 @@ test.describe test("should create Node.js workspace", async () => { await page.getByRole("button", { name: /create workspace/i }).click() - const sheet = page.locator('[role="dialog"]') - await sheet.waitFor({ timeout: 5000 }) + const dialog = page.locator('[role="dialog"]').first() + await dialog.waitFor({ timeout: 5000 }) - // Click Node.js template - await sheet.locator("button", { hasText: "Node.js" }).click() + // Step 1 — Provider: select docker, continue + await dialog.locator("button", { hasText: "docker" }).first().click() + await dialog.getByRole("button", { name: /^continue$/i }).click() - // Verify source input is populated - const sourceInput = sheet.locator('input[placeholder*="github"]') + // Step 2 — Source: click Node.js template + await dialog.locator("button", { hasText: "Node.js" }).click() + const sourceInput = dialog.locator('input[placeholder*="github"]') await expect(sourceInput).toHaveValue( "https://github.com/microsoft/vscode-remote-try-node", { timeout: 5000 }, ) + await dialog.getByRole("button", { name: /^continue$/i }).click() - // Select IDE = None - await sheet.getByRole("button", { name: /select an ide/i }).click() - await page.locator('[role="option"]', { hasText: "None" }).click() + // Step 3 — IDE: default "None", continue + await dialog.getByRole("button", { name: /^continue$/i }).click() - // Submit - await sheet.getByRole("button", { name: /create workspace/i }).click() + // Step 4 — Review: Launch + await dialog.getByRole("button", { name: /^launch$/i }).click() - // Wait for output - await expect(sheet).toContainText("Output", { timeout: 15000 }) - await expect(sheet).toContainText(/resolving|pulling|starting|ready/i, { + // Step 5 — Launch: wait for streaming output and success + await expect(dialog).toContainText(/resolving|pulling|starting|ready/i, { timeout: 15000, }) - - // Wait for "Open Workspace" success button - await sheet + await dialog .getByRole("button", { name: /open workspace/i }) .waitFor({ timeout: 15000 }) - // Close the sheet + // Close the dialog await page.keyboard.press("Escape") - await sheet.waitFor({ state: "hidden", timeout: 5000 }) + await dialog.waitFor({ state: "hidden", timeout: 5000 }) // Wait for watcher to pick up the new workspace await page.waitForTimeout(4000) @@ -360,34 +359,36 @@ test.describe await page.getByRole("button", { name: /create workspace/i }).click() - const sheet = page.locator('[role="dialog"]') - await sheet.waitFor({ timeout: 5000 }) + const dialog = page.locator('[role="dialog"]').first() + await dialog.waitFor({ timeout: 5000 }) - // Click Python template - await sheet.locator("button", { hasText: "Python" }).click() + // Step 1 — Provider: select docker, continue + await dialog.locator("button", { hasText: "docker" }).first().click() + await dialog.getByRole("button", { name: /^continue$/i }).click() - // Verify source populated - const sourceInput = sheet.locator('input[placeholder*="github"]') + // Step 2 — Source: click Python template + await dialog.locator("button", { hasText: "Python" }).click() + const sourceInput = dialog.locator('input[placeholder*="github"]') await expect(sourceInput).toHaveValue( "https://github.com/microsoft/vscode-remote-try-python", { timeout: 5000 }, ) + await dialog.getByRole("button", { name: /^continue$/i }).click() - // Select IDE = None - await sheet.getByRole("button", { name: /select an ide/i }).click() - await page.locator('[role="option"]', { hasText: "None" }).click() + // Step 3 — IDE: default "None", continue + await dialog.getByRole("button", { name: /^continue$/i }).click() - // Submit - await sheet.getByRole("button", { name: /create workspace/i }).click() + // Step 4 — Review: Launch + await dialog.getByRole("button", { name: /^launch$/i }).click() - // Wait for success - await sheet + // Step 5 — Launch: wait for success + await dialog .getByRole("button", { name: /open workspace/i }) .waitFor({ timeout: 15000 }) - // Close sheet + // Close the dialog await page.keyboard.press("Escape") - await sheet.waitFor({ state: "hidden", timeout: 5000 }) + await dialog.waitFor({ state: "hidden", timeout: 5000 }) await page.waitForTimeout(4000) })