diff --git a/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts b/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts index 4ec8871251b..29e59ce81a8 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts @@ -1,10 +1,15 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeEnvironmentStore from '../../../shared/runtime-environment-store' +import type { WslHostServerStop } from '../../../shared/wsl-host-types' type WslServiceFake = { connect: ReturnType; remove: ReturnType } const mocks = vi.hoisted(() => { const order: string[] = [] - const wsl: { service: WslServiceFake | null } = { service: null } + const wsl: { service: WslServiceFake | WslHostService | null } = { service: null } return { order, handle: vi.fn(), @@ -18,10 +23,12 @@ const mocks = vi.hoisted(() => { } }) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../../../shared/runtime-environment-store', () => ({ - addEnvironmentFromPairingCode: vi.fn(), - listEnvironments: vi.fn(() => []), +vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, + ipcMain: { handle: mocks.handle, removeHandler: vi.fn() } +})) +vi.mock('../../../shared/runtime-environment-store', async (importOriginal) => ({ + ...(await importOriginal()), removeEnvironment: mocks.removeEnvironment, resolveEnvironment: mocks.resolveEnvironment })) @@ -42,11 +49,30 @@ vi.mock('../../ssh/ssh-credential-decline', () => ({ vi.mock('../../wsl/wsl-host-service-instance', () => ({ getWslHostService: () => mocks.wsl.service })) +vi.mock('../../wsl/wsl-host-connector', () => ({ createWslHostConnector: vi.fn() })) +vi.mock('../../wsl/wsl-host-e2e-fake-connector', () => ({ + createFakeWslHostConnector: vi.fn(), + readFakeWslHostsConfig: () => null +})) +vi.mock('../../orcad/orcad-artifact-materializer', () => ({ hasOrcadTemplate: () => true })) +vi.mock('../../ssh/orcad-terminal-census-client', () => ({ requestOrcadTerminalCensus: vi.fn() })) +import { registerWslHostHandlers } from '../wsl-host-handlers' import { registerRuntimeEnvironmentConnectivityHandlers } from './runtime-environment-connectivity-handlers' +import { + findWslHostDeployment, + setWslHostPendingStop +} from '../../../shared/runtime-environment-wsl-host-store' +import { WslHostService } from '../../wsl/wsl-host-service' +import { UBUNTU, makeConnector } from '../../wsl/wsl-host-service-test-connector' +import { wslHostRemovalPendingMessage } from '../../wsl/wsl-host-add-admission' import { WSL_HOSTS_GATED_OFF_MESSAGE } from '../../wsl/wsl-host-gate' import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate' +const { resolveEnvironment: resolveActualEnvironment } = await vi.importActual< + typeof RuntimeEnvironmentStore +>('../../../shared/runtime-environment-store') + const endpoints = [ { id: 'ws-1', @@ -120,11 +146,11 @@ function handler(channel: string): (event: unknown, args: Record ({ activeRuntimeEnvironmentId }) } as never, - getUserDataPath: () => '/u', + getUserDataPath: () => userDataPath, invalidateTransport: vi.fn() }) } @@ -208,8 +234,13 @@ describe('runtimeEnvironments:remove by host kind', () => { it('carries what the WSL service kept, so the UI can say the host stays', async () => { useHost('wsl') - const serverStop = { stopped: false, reason: 'busy', verdict: 'live', kept: true } - mocks.wsl.service!.remove.mockResolvedValueOnce(serverStop) + const serverStop: WslHostServerStop = { + stopped: false, + reason: 'busy', + verdict: 'live', + kept: true + } + vi.mocked(mocks.wsl.service!.remove).mockResolvedValueOnce(serverStop) await expect( handler('runtimeEnvironments:remove')(null, { selector: 'wsl:ubuntu' }) ).resolves.toEqual({ removed: redacted('wsl'), serverStop }) @@ -258,3 +289,80 @@ describe('runtimeEnvironments:remove by host kind', () => { expect(mocks.closeRequest).not.toHaveBeenCalled() }) }) + +describe('stopped WSL Start admission through direct and generic IPC', () => { + let userDataPath: string + let connector: ReturnType + + beforeEach(async () => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-start-admission-')) + connector = makeConnector([UBUNTU]) + const service = new WslHostService({ + userDataPath: () => userDataPath, + isEnabled: () => true, + hasServerFiles: () => true, + publish: vi.fn(), + pairingChanged: vi.fn(), + ready: vi.fn(), + removed: vi.fn() + }) + service.attach(connector) + await expect(service.add({ distro: 'Ubuntu' })).resolves.toEqual({ + ok: true, + environmentId: 'wsl:ubuntu' + }) + await connector.disconnect('Ubuntu') + connector.distro.state = 'stopped' + mocks.wsl.service = service + mocks.resolveEnvironment.mockImplementation(resolveActualEnvironment) + mocks.handle.mockClear() + register(undefined, userDataPath) + registerWslHostHandlers({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connect handler does not read the store. + store: {} as never, + getUserDataPath: () => userDataPath, + invalidateTransport: vi.fn() + }) + connector.connect.mockClear() + connector.forgetPairingFailures.mockClear() + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + const paths = [ + ['wslHosts:connect', { environmentId: 'wsl:ubuntu', withdrawPendingRemoval: false }], + ['runtimeEnvironments:connect', { selector: 'wsl:ubuntu', timeoutMs: 5_000 }] + ] as const + + it('refuses both paths on the same saved pending Remove without advancing to connect or status', async () => { + const pendingStop = { since: 1, reason: 'busy', verdict: 'live' } as const + setWslHostPendingStop(userDataPath, 'wsl:ubuntu', pendingStop) + const record = findWslHostDeployment(userDataPath, 'wsl:ubuntu') + + for (const [channel, args] of paths) { + await expect(handler(channel)(null, args)).rejects.toThrow( + wslHostRemovalPendingMessage('Ubuntu') + ) + expect(findWslHostDeployment(userDataPath, 'wsl:ubuntu')).toEqual(record) + expect(connector.connect).not.toHaveBeenCalled() + expect(connector.forgetPairingFailures).not.toHaveBeenCalled() + expect(mocks.getStatus).not.toHaveBeenCalled() + } + }) + + it('passes the same distro and default connector options after either path admits Start', async () => { + for (const [channel, args] of paths) { + await handler(channel)(null, args) + } + expect(connector.connect.mock.calls).toEqual([ + ['Ubuntu', {}], + ['Ubuntu', {}] + ]) + expect(mocks.getStatus).toHaveBeenCalledOnce() + expect(mocks.getStatus).toHaveBeenCalledWith(userDataPath, 'wsl:ubuntu', 5_000, { + reconnect: true + }) + }) +}) diff --git a/src/main/wsl/wsl-host-connect-request.ts b/src/main/wsl/wsl-host-connect-request.ts index f049b4c83a7..b8573055809 100644 --- a/src/main/wsl/wsl-host-connect-request.ts +++ b/src/main/wsl/wsl-host-connect-request.ts @@ -6,8 +6,8 @@ export function wslHostConnectOptions(request: { withdrawPendingRemoval?: unknown }): { intent?: WslHostConnectIntent; withdrawPendingRemoval: boolean } { return { - // The Settings Connect is the user's own: it keeps a host whose Remove didn't finish. Other - // starts (a chat's Start) pass false and get the "removal pending" refusal instead. + // The Settings Connect is the user's own: it keeps a host whose Remove didn't finish. A caller + // passing false gets the "removal pending" refusal instead. withdrawPendingRemoval: request.withdrawPendingRemoval !== false, ...(request.intent === 'update' || request.intent === 'force' ? { intent: request.intent } : {}) } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.wsl-host-stopped.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.wsl-host-stopped.test.tsx index 68e70688e35..a48cc7cabc9 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.wsl-host-stopped.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.wsl-host-stopped.test.tsx @@ -2,15 +2,15 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' import type { WslHostStatus } from '../../../../shared/wsl-host-types' const { moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() ) -const reconnectHost = vi.hoisted(() => vi.fn<(environmentId: string) => Promise>()) -const toastError = vi.hoisted(() => vi.fn()) -vi.mock('sonner', () => ({ toast: { error: toastError } })) +const reconnectHost = vi.hoisted(() => + vi.fn<(environmentId: string, failureMessage?: string) => Promise>() +) vi.mock('@/runtime/structured-agent-session-client', () => moduleFactories.structuredAgentSessionClient() @@ -31,8 +31,6 @@ import { useAppStore } from '@/store' import { NativeChatStructuredSession } from './NativeChatStructuredSession' const initialState = useAppStore.getState() -const startHost = - vi.fn<(args: { environmentId: string; withdrawPendingRemoval?: boolean }) => Promise>() function setWslStatus(state: WslHostStatus['state']): void { act(() => { @@ -57,20 +55,10 @@ function renderPane() { ) } -beforeEach(() => { - vi.stubGlobal('api', { - ...window.api, - runtimeEnvironments: { ...window.api?.runtimeEnvironments, wslHosts: { connect: startHost } } - }) -}) - afterEach(() => { cleanup() - vi.unstubAllGlobals() resetStructuredSessionMocks() reconnectHost.mockReset() - startHost.mockReset() - toastError.mockReset() useAppStore.setState({ wslHostStatuses: initialState.wslHostStatuses }) }) @@ -81,34 +69,30 @@ it('names a stopped WSL distro above the composer and offers Start', async () => expect(screen.getByText('Ubuntu is stopped')).toHaveClass('text-destructive') expect(screen.getByRole('listitem')).toHaveAttribute('data-notice-kind', 'host') - startHost.mockResolvedValue() reconnectHost.mockResolvedValue(true) await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Start' })) }) - // Start is the explicit act that may boot the distro (E9); the chat then reconnects. - // Never cancels a Remove the user started: main refuses with its pending-removal words. - expect(startHost).toHaveBeenCalledWith({ - environmentId: 'wsl:ubuntu', - withdrawPendingRemoval: false - }) - expect(reconnectHost).toHaveBeenCalledWith('wsl:ubuntu') + // Start is the explicit act that may boot the distro (E9): one connect, which reconnects the chat. + expect(reconnectHost).toHaveBeenCalledOnce() + expect(reconnectHost).toHaveBeenCalledWith( + 'wsl:ubuntu', + 'Couldn’t start Ubuntu. Check it in Settings → Remote Orca Servers.' + ) setWslStatus('ready') expect(screen.queryByText('Ubuntu is stopped')).toBeNull() }) -it('shows the refusal and does not reconnect when the distro could not be started', async () => { +it('still offers Start after a start that failed', async () => { renderPane() setWslStatus('distro-stopped') - // Main's refusal for a host whose Remove is pending, shown as main words it. - const pending = 'Orca didn’t finish removing the WSL host Ubuntu.' - startHost.mockRejectedValue(new Error(pending)) + reconnectHost.mockResolvedValue(false) await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Start' })) }) - expect(toastError).toHaveBeenCalledWith(pending) - expect(reconnectHost).not.toHaveBeenCalled() + expect(screen.getByText('Ubuntu is stopped')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled() }) it('leaves an idle WSL host to reconnect on its own', () => { diff --git a/src/renderer/src/components/status-bar/runtime-environment-explicit-connect.ts b/src/renderer/src/components/status-bar/runtime-environment-explicit-connect.ts index c913630c3e3..330d845e3b3 100644 --- a/src/renderer/src/components/status-bar/runtime-environment-explicit-connect.ts +++ b/src/renderer/src/components/status-bar/runtime-environment-explicit-connect.ts @@ -48,8 +48,14 @@ export async function connectRuntimeHostForNavigation(args: { return true } -/** The status bar's Connect for a paired host; also the chat's Reconnect when its host is offline. */ -export async function connectRuntimeHostAndReloadProjects(environmentId: string): Promise { +/** + * The status bar's Connect for a paired host; also the chat's Reconnect when its host is offline + * and a stopped WSL host’s Start, which passes its own failure message. + */ +export async function connectRuntimeHostAndReloadProjects( + environmentId: string, + failureMessage?: string +): Promise { const store = useAppStore.getState() const reachable = await connectRuntimeHostForNavigation({ environmentId, @@ -60,10 +66,11 @@ export async function connectRuntimeHostAndReloadProjects(environmentId: string) }) if (!reachable) { toast.error( - translate( - 'auto.components.status.bar.SshStatusSegment.runtime_connect_unavailable', - 'Remote host is not reachable' - ) + failureMessage ?? + translate( + 'auto.components.status.bar.SshStatusSegment.runtime_connect_unavailable', + 'Remote host is not reachable' + ) ) } return reachable diff --git a/src/renderer/src/components/terminal-pane/WslHostStoppedCard.test.tsx b/src/renderer/src/components/terminal-pane/WslHostStoppedCard.test.tsx index c4f8990a8cb..7d8a225cd17 100644 --- a/src/renderer/src/components/terminal-pane/WslHostStoppedCard.test.tsx +++ b/src/renderer/src/components/terminal-pane/WslHostStoppedCard.test.tsx @@ -6,18 +6,13 @@ import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WslHostStoppedCard } from './WslHostStoppedCard' -const { connectWslHost, reloadProjects, toastError } = vi.hoisted(() => ({ - connectWslHost: vi.fn(), - reloadProjects: vi.fn(), - toastError: vi.fn() -})) +const { reloadProjects } = vi.hoisted(() => ({ reloadProjects: vi.fn() })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, options?: Record) => fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => options?.[name] ?? ''), getIntlLocale: () => 'en-US' })) -vi.mock('sonner', () => ({ toast: { error: toastError } })) vi.mock('@/components/status-bar/runtime-environment-explicit-connect', () => ({ connectRuntimeHostAndReloadProjects: reloadProjects })) @@ -27,10 +22,7 @@ function renderStoppedPane(onStarted = vi.fn()) { } beforeEach(() => { - connectWslHost.mockReset().mockResolvedValue(undefined) reloadProjects.mockReset().mockResolvedValue(true) - toastError.mockReset() - Object.assign(window, { api: { runtimeEnvironments: { wslHosts: { connect: connectWslHost } } } }) }) afterEach(() => { @@ -45,44 +37,30 @@ describe('a terminal on a stopped WSL distro', () => { expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument() expect(screen.queryByText(/retrying/i)).not.toBeInTheDocument() // Showing the card is not a start: only the click may boot the distro. - expect(connectWslHost).not.toHaveBeenCalled() + expect(reloadProjects).not.toHaveBeenCalled() }) - it('boots the distro on Start, refusing a pending Remove, then resumes the pane', async () => { + it('boots the distro on Start, then resumes the pane', async () => { const onStarted = renderStoppedPane() await userEvent.setup().click(screen.getByRole('button', { name: 'Start' })) await waitFor(() => expect(onStarted).toHaveBeenCalledOnce()) - expect(connectWslHost).toHaveBeenCalledWith({ - environmentId: 'env-1', - withdrawPendingRemoval: false - }) - expect(reloadProjects).toHaveBeenCalledWith('env-1') + expect(reloadProjects).toHaveBeenCalledOnce() }) - it('shows why a start was refused and leaves the pane as it is', async () => { - connectWslHost.mockRejectedValueOnce(new Error('Ubuntu is being removed.')) + it('leaves the pane as it is when the start fails', async () => { + reloadProjects.mockResolvedValueOnce(false) const onStarted = renderStoppedPane() await userEvent.setup().click(screen.getByRole('button', { name: 'Start' })) - await waitFor(() => expect(toastError).toHaveBeenCalledWith('Ubuntu is being removed.')) - expect(reloadProjects).not.toHaveBeenCalled() - expect(onStarted).not.toHaveBeenCalled() - }) - - it('toasts and resolves false when reconnecting after the boot fails', async () => { - reloadProjects.mockRejectedValueOnce(new Error('Reload failed.')) - const onStarted = renderStoppedPane() - await userEvent.setup().click(screen.getByRole('button', { name: 'Start' })) - - await waitFor(() => expect(toastError).toHaveBeenCalledWith('Reload failed.')) + await waitFor(() => expect(reloadProjects).toHaveBeenCalledOnce()) expect(onStarted).not.toHaveBeenCalled() }) it('starts the distro once for repeated clicks while it boots', async () => { - let finish!: () => void - connectWslHost.mockReturnValueOnce( - new Promise((resolve) => { + let finish!: (started: boolean) => void + reloadProjects.mockReturnValueOnce( + new Promise((resolve) => { finish = resolve }) ) @@ -90,9 +68,9 @@ describe('a terminal on a stopped WSL distro', () => { const user = userEvent.setup() await user.click(screen.getByRole('button', { name: 'Start' })) await user.click(screen.getByRole('button', { name: 'Start' })) - finish() + finish(true) await waitFor(() => expect(onStarted).toHaveBeenCalledTimes(2)) - expect(connectWslHost).toHaveBeenCalledOnce() + expect(reloadProjects).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f033ec6145f..6d01224f359 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19814,6 +19814,7 @@ "reconnect": "Reconnect", "stopped": "{{distro}} is stopped", "start": "Start", + "startFailed": "Couldn’t start {{distro}}. Check it in Settings → Remote Orca Servers.", "unverifiable": "Orca couldn't check whether {{distro}} is running", "startToSend": "Start {{distro}} to send." }, diff --git a/src/renderer/src/lib/wsl-host-user-start.test.ts b/src/renderer/src/lib/wsl-host-user-start.test.ts new file mode 100644 index 00000000000..4e6850309d5 --- /dev/null +++ b/src/renderer/src/lib/wsl-host-user-start.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { connectRuntime, connectWslHost, fetchRepos, toastError } = vi.hoisted(() => ({ + connectRuntime: vi.fn(), + connectWslHost: vi.fn(), + fetchRepos: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: toastError } })) +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => options?.[name] ?? '') +})) +vi.mock('@/hooks/runtime-project-refresh-scheduler', () => ({ + refreshRuntimeProjectWorktreesAndLineage: vi.fn() +})) +vi.mock('@/store', () => { + const state = { + wslHostStatuses: { 'env-1': { distro: 'Ubuntu', state: 'distro-stopped' } }, + setRuntimeEnvironmentStatus: vi.fn(), + fetchRuntimeEnvironmentRepos: fetchRepos, + fetchWorktrees: vi.fn(), + fetchWorktreeLineage: vi.fn() + } + return { useAppStore: { getState: () => state } } +}) + +import { startWslHostAndReloadProjects } from './wsl-host-user-start' +import { connectRuntimeHostAndReloadProjects } from '../components/status-bar/runtime-environment-explicit-connect' + +const START_FAILED = 'Couldn’t start Ubuntu. Check it in Settings → Remote Orca Servers.' + +beforeEach(() => { + connectRuntime.mockReset().mockResolvedValue({ ok: true, result: {} }) + connectWslHost.mockReset().mockResolvedValue(undefined) + fetchRepos.mockReset().mockResolvedValue([]) + toastError.mockReset() + Object.assign(globalThis, { + window: { + api: { + runtimeEnvironments: { connect: connectRuntime, wslHosts: { connect: connectWslHost } } + } + } + }) +}) + +describe('generic Connect and offline Reconnect failure', () => { + it('keeps the generic toast when the caller omits the failure message', async () => { + connectRuntime.mockRejectedValueOnce(new Error('host unavailable')) + + await expect(connectRuntimeHostAndReloadProjects('env-1')).resolves.toBe(false) + + expect(toastError.mock.calls).toEqual([['Remote host is not reachable']]) + expect(fetchRepos).not.toHaveBeenCalled() + }) +}) + +describe('startWslHostAndReloadProjects', () => { + it('connects the host once per Start', async () => { + await expect(startWslHostAndReloadProjects('env-1')).resolves.toBe(true) + // Main routes this one explicit connect to the WSL host; a second connect reruns its launch. + expect(connectRuntime).toHaveBeenCalledOnce() + expect(connectRuntime).toHaveBeenCalledWith(expect.objectContaining({ selector: 'env-1' })) + expect(connectWslHost).not.toHaveBeenCalled() + }) + + it('names the distro once, never the probe command, when the start fails', async () => { + connectRuntime.mockRejectedValueOnce( + new Error('Command "uname -r" failed (exit 1): Wsl/Service/E_UNEXPECTED') + ) + await expect(startWslHostAndReloadProjects('env-1')).resolves.toBe(false) + expect(toastError).toHaveBeenCalledOnce() + expect(toastError).toHaveBeenCalledWith(START_FAILED) + }) +}) diff --git a/src/renderer/src/lib/wsl-host-user-start.ts b/src/renderer/src/lib/wsl-host-user-start.ts index ade9e0d1e9f..5efcdd70894 100644 --- a/src/renderer/src/lib/wsl-host-user-start.ts +++ b/src/renderer/src/lib/wsl-host-user-start.ts @@ -14,26 +14,29 @@ const starting = new Map>() /** * Start is the user acting on the host, so it may boot the distro (E9); then the host reconnects. - * Repeated clicks while it boots share one start. + * One explicit connect: main hands it to the WSL host, which refuses a host whose Remove is pending + * instead of keeping it (E11). Repeated clicks while it boots share one start. */ export function startWslHostAndReloadProjects(environmentId: string): Promise { const pending = starting.get(environmentId) if (pending) { return pending } + // Read now: a failed start can leave the host no longer "stopped". Main's own words can be a + // probe's command line, so the toast names the distro and where its reason shows instead. + const failure = translate( + 'components.native-chat.hostOutage.startFailed', + 'Couldn’t start {{distro}}. Check it in Settings → Remote Orca Servers.', + { distro: useAppStore.getState().wslHostStatuses[environmentId]?.distro ?? environmentId } + ) const start = (async () => { try { - // A Start must not quietly cancel a Remove the user started; that refusal says so (E11). - await window.api.runtimeEnvironments.wslHosts?.connect({ - environmentId, - withdrawPendingRemoval: false - }) // Why lazy: terminal creation imports this module, and most never start a host. const { connectRuntimeHostAndReloadProjects } = await import('@/components/status-bar/runtime-environment-explicit-connect') - return await connectRuntimeHostAndReloadProjects(environmentId) - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + return await connectRuntimeHostAndReloadProjects(environmentId, failure) + } catch { + toast.error(failure) return false } })().finally(() => starting.delete(environmentId)) diff --git a/src/renderer/src/runtime/web-runtime-terminal-creation.wsl-host-start.test.ts b/src/renderer/src/runtime/web-runtime-terminal-creation.wsl-host-start.test.ts index c2ae04f03b0..dd460b5b16c 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-creation.wsl-host-start.test.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-creation.wsl-host-start.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { createResult, connectWslHost, reloadProjects, wslHostStatuses } = vi.hoisted(() => { +const { createResult, reloadProjects, wslHostStatuses } = vi.hoisted(() => { const wslHostStatuses: Record = {} return { createResult: vi.fn(), - connectWslHost: vi.fn(), reloadProjects: vi.fn(), wslHostStatuses } @@ -38,15 +37,11 @@ const ARGS = { worktreeId: 'wt-1', environmentId: 'env-1', activate: true } beforeEach(() => { createResult.mockReset().mockResolvedValue({ outcome: { status: 'created' } }) - connectWslHost.mockReset().mockResolvedValue(undefined) reloadProjects.mockReset().mockResolvedValue(true) for (const key of Object.keys(wslHostStatuses)) { delete wslHostStatuses[key] } wslHostStatuses['env-1'] = { state: 'distro-stopped', distro: 'Ubuntu' } - Object.assign(globalThis, { - window: { api: { runtimeEnvironments: { wslHosts: { connect: connectWslHost } } } } - }) }) describe('New Terminal on a stopped WSL host', () => { @@ -54,18 +49,15 @@ describe('New Terminal on a stopped WSL host', () => { await expect(createWebRuntimeSessionTerminal(ARGS, { userAct: true })).resolves.toEqual({ status: 'created' }) - expect(connectWslHost).toHaveBeenCalledWith({ - environmentId: 'env-1', - withdrawPendingRemoval: false - }) - expect(connectWslHost.mock.invocationCallOrder[0]).toBeLessThan( + expect(reloadProjects).toHaveBeenCalledOnce() + expect(reloadProjects.mock.invocationCallOrder[0]).toBeLessThan( createResult.mock.invocationCallOrder[0] ) }) it('never boots the distro for a terminal nobody asked for', async () => { await createWebRuntimeSessionTerminal(ARGS) - expect(connectWslHost).not.toHaveBeenCalled() + expect(reloadProjects).not.toHaveBeenCalled() expect(createResult).toHaveBeenCalledOnce() }) @@ -73,12 +65,12 @@ describe('New Terminal on a stopped WSL host', () => { wslHostStatuses['env-1'] = { state: 'ready', distro: 'Ubuntu' } await createWebRuntimeSessionTerminal(ARGS, { userAct: true }) await createWebRuntimeSessionTerminal({ ...ARGS, environmentId: 'ssh-1' }, { userAct: true }) - expect(connectWslHost).not.toHaveBeenCalled() + expect(reloadProjects).not.toHaveBeenCalled() expect(createResult).toHaveBeenCalledTimes(2) }) it('creates nothing when the start is refused, such as a pending Remove', async () => { - connectWslHost.mockRejectedValueOnce(new Error('Ubuntu is being removed.')) + reloadProjects.mockResolvedValueOnce(false) await expect(createWebRuntimeSessionTerminal(ARGS, { userAct: true })).resolves.toEqual({ status: 'failed', message: 'Ubuntu is stopped' diff --git a/tests/e2e/wsl-host-chat.spec.ts b/tests/e2e/wsl-host-chat.spec.ts index c948793ff29..eb1177a6f21 100644 --- a/tests/e2e/wsl-host-chat.spec.ts +++ b/tests/e2e/wsl-host-chat.spec.ts @@ -137,10 +137,12 @@ test.describe('native chat on a WSL host', () => { await start.click() await expect .poll(() => fakeWslConnects(connectLogFile).length, { timeout: 60_000 }) - .toBeGreaterThan(connectsBeforeStart) + .toBe(connectsBeforeStart + 1) await expect(stoppedNotice).toBeHidden({ timeout: 60_000 }) await send('after Start') await expectReplyAfter('after Start') + // One Start is one connect: a second would rerun the server's launch in the distro. + expect(fakeWslConnects(connectLogFile)).toHaveLength(connectsBeforeStart + 1) await page.screenshot({ path: testInfo.outputPath('wsl-host-chat-after-start.png') }) const connectsBeforeStop = await stopDistro() diff --git a/tests/e2e/wsl-host-terminal-start.spec.ts b/tests/e2e/wsl-host-terminal-start.spec.ts index 3bbcfa97e82..bbc0a19e2cd 100644 --- a/tests/e2e/wsl-host-terminal-start.spec.ts +++ b/tests/e2e/wsl-host-terminal-start.spec.ts @@ -120,8 +120,10 @@ test('a stopped WSL distro boots only for Start or New Terminal, never for a ter await stoppedCard.first().getByRole('button', { name: 'Start', exact: true }).click() await expect .poll(() => fakeWslConnects(connectLogFile).length, { timeout: 60_000 }) - .toBeGreaterThan(connectsBeforeStart) + .toBe(connectsBeforeStart + 1) await expect(stoppedCard).toHaveCount(0, { timeout: 60_000 }) + // One Start is one connect: a second would rerun the server's launch in the distro. + expect(fakeWslConnects(connectLogFile)).toHaveLength(connectsBeforeStart + 1) await page.screenshot({ path: testInfo.outputPath('wsl-host-terminal-after-start.png') }) // New Terminal on the stopped host is the user acting on it too. @@ -131,9 +133,10 @@ test('a stopped WSL distro boots only for Start or New Terminal, never for a ter await openNewTerminal(page) await expect .poll(() => fakeWslConnects(connectLogFile).length, { timeout: 60_000 }) - .toBeGreaterThan(connectsBeforeNewTerminal) + .toBe(connectsBeforeNewTerminal + 1) await expect.poll(() => terminals.count(), { timeout: 60_000 }).toBeGreaterThan(terminalCount) await expect(stoppedCard).toHaveCount(0, { timeout: 60_000 }) + expect(fakeWslConnects(connectLogFile)).toHaveLength(connectsBeforeNewTerminal + 1) await page.screenshot({ path: testInfo.outputPath('wsl-host-terminal-new-terminal.png') }) } finally { await desktop.dispose()