mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(wsl-host): Start connects a stopped distro once and names it when it fails (#35438)
* fix(wsl-host): Start connects a stopped distro once and names it when it fails Start called the WSL host's connect and then the generic explicit connect, which routes to the same WSL host again, so one click deployed and supervised the server twice; a failure toasted main's raw text (a probe's command line). Start now makes only the explicit connect, which still refuses a host whose Remove is pending, and a failure says "Couldn’t start <distro>" with where to look. * test(wsl-host): cover moved Start admission and default failure toast * test(wsl-host): load actual registry outside test hooks * style(wsl-host): format admission test import * test(wsl-host): check pending removal fixture type
This commit is contained in:
@@ -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<typeof vi.fn>; remove: ReturnType<typeof vi.fn> }
|
||||
|
||||
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<typeof RuntimeEnvironmentStore>()),
|
||||
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<string, unknown
|
||||
return call![1]
|
||||
}
|
||||
|
||||
function register(activeRuntimeEnvironmentId?: string): void {
|
||||
function register(activeRuntimeEnvironmentId?: string, userDataPath = '/u'): void {
|
||||
registerRuntimeEnvironmentConnectivityHandlers({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: connect and remove read only settings.
|
||||
store: { getSettings: () => ({ 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<typeof makeConnector>
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
|
||||
+14
-30
@@ -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<boolean>>())
|
||||
const toastError = vi.hoisted(() => vi.fn())
|
||||
vi.mock('sonner', () => ({ toast: { error: toastError } }))
|
||||
const reconnectHost = vi.hoisted(() =>
|
||||
vi.fn<(environmentId: string, failureMessage?: string) => Promise<boolean>>()
|
||||
)
|
||||
|
||||
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<void>>()
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<boolean> {
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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
|
||||
|
||||
@@ -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<string, string>) =>
|
||||
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<void>((resolve) => {
|
||||
let finish!: (started: boolean) => void
|
||||
reloadProjects.mockReturnValueOnce(
|
||||
new Promise<boolean>((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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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<string, string>) =>
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -14,26 +14,29 @@ const starting = new Map<string, Promise<boolean>>()
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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))
|
||||
|
||||
@@ -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<string, { state: string; distro: string }> = {}
|
||||
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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user