diff --git a/config/scripts/pr-e2e-route-contracts.test.mjs b/config/scripts/pr-e2e-route-contracts.test.mjs index 1852cf8eb78..74b17ac471c 100644 --- a/config/scripts/pr-e2e-route-contracts.test.mjs +++ b/config/scripts/pr-e2e-route-contracts.test.mjs @@ -498,6 +498,22 @@ function selectsManagedSshLane(file) { return selectPrE2eSpecs([file]).some((spec) => spec.startsWith(MANAGED_SSH_E2E_SPEC_PREFIX)) } +describe('wsl-host fake connector routing', () => { + it('runs both WSL host specs for a fake connector change', () => { + const specs = selectPrE2eSpecs(['src/main/wsl/wsl-host-e2e-fake-connector.ts']) + for (const spec of [ + 'tests/e2e/wsl-host-chat.spec.ts', + 'tests/e2e/wsl-host-fake-transport.spec.ts' + ]) { + expect(specs, spec).toContain(spec) + expect(existsSync(join(projectDir, spec)), spec).toBe(true) + } + expect(selectPrE2eSpecs(['src/main/wsl/wsl-host-e2e-fake-connector.test.ts'])).not.toContain( + 'tests/e2e/wsl-host-chat.spec.ts' + ) + }) +}) + describe('managed-SSH remote sources routing', () => { it('gives every managed-SSH spec a source route', () => { const routed = new Set(PR_E2E_SOURCE_ROUTES.flatMap((route) => route.specs)) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 783227735a8..ab191df0ab0 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -392,6 +392,11 @@ export const PR_E2E_SOURCE_ROUTES = [ file )) }, + { + id: 'wsl-host.fake-connector', + specs: ['tests/e2e/wsl-host-fake-transport.spec.ts', 'tests/e2e/wsl-host-chat.spec.ts'], + matches: (file) => file === 'src/main/wsl/wsl-host-e2e-fake-connector.ts' + }, { id: 'terminal.windows-wsl-launch-and-paste', specs: [ diff --git a/src/main/wsl/wsl-host-e2e-fake-connector.test.ts b/src/main/wsl/wsl-host-e2e-fake-connector.test.ts new file mode 100644 index 00000000000..9703a6c81d9 --- /dev/null +++ b/src/main/wsl/wsl-host-e2e-fake-connector.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { UNVERIFIABLE_ORCAD_TERMINAL_CENSUS } from '../../shared/orcad-terminal-census' +import { encodePairingOffer } from '../../shared/pairing' +import { createFakeWslHostConnector } from './wsl-host-e2e-fake-connector' + +const pairingCode = encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:42000', + deviceToken: 'fake-token', + publicKeyB64: Buffer.alloc(32, 7).toString('base64') +}) +const runtime = { + call: async () => ({}), + census: async () => UNVERIFIABLE_ORCAD_TERMINAL_CENSUS +} + +let dir: string +let distrosFile: string + +function writeState(state: 'running' | 'stopped'): void { + writeFileSync(distrosFile, JSON.stringify([{ name: 'Ubuntu', state, version: 2 }])) +} + +function connector(onChange = vi.fn()) { + const fake = createFakeWslHostConnector({ distros: [], distrosFile, pairingCode }, onChange, { + isKnownDistro: () => true, + runtime + }) + return { fake, onChange } +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-fake-wsl-')) + distrosFile = join(dir, 'distros.json') + writeState('running') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +it('drops the bridge of a distro the test stops and refuses a no-boot reconnect', async () => { + const { fake, onChange } = connector() + await fake.connect('Ubuntu') + expect(await fake.reachability('Ubuntu', 'wsl:ubuntu')).toBe('available') + writeState('stopped') + expect(await fake.reachability('Ubuntu', 'wsl:ubuntu')).toBe('stopped') + expect(fake.get('Ubuntu')).toBeNull() + expect(onChange).toHaveBeenLastCalledWith('Ubuntu', null, { state: 'closed' }) + await expect(fake.connect('Ubuntu', { noBoot: true })).rejects.toThrow('Ubuntu is not running.') +}) + +it('boots a stopped distro on a plain connect', async () => { + const { fake } = connector() + writeState('stopped') + await fake.connect('Ubuntu') + expect(JSON.parse(readFileSync(distrosFile, 'utf8'))).toEqual([ + { name: 'Ubuntu', state: 'running', version: 2 } + ]) + expect(await fake.reachability('Ubuntu', 'wsl:ubuntu')).toBe('available') +}) + +it('keeps a fixed listing always running', async () => { + const fake = createFakeWslHostConnector( + { distros: [{ name: 'Legacy', state: 'stopped', version: 1 }], pairingCode }, + vi.fn(), + { isKnownDistro: () => true, runtime } + ) + expect(await fake.reachability('Legacy', 'wsl:legacy')).toBe('unreachable') + await fake.connect('Legacy', { noBoot: true }) + expect(await fake.reachability('Legacy', 'wsl:legacy')).toBe('available') +}) diff --git a/src/main/wsl/wsl-host-e2e-fake-connector.ts b/src/main/wsl/wsl-host-e2e-fake-connector.ts index 02ab98527f3..f319e9af468 100644 --- a/src/main/wsl/wsl-host-e2e-fake-connector.ts +++ b/src/main/wsl/wsl-host-e2e-fake-connector.ts @@ -1,9 +1,10 @@ /** ORCA_E2E_FAKE_WSL_HOSTS maps fake distros directly to a headless server pairing so any CI OS can test WSL host UI without wsl.exe. */ -import { appendFileSync, readFileSync } from 'node:fs' +import { appendFileSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { z } from 'zod' import { parsePairingCode } from '../../shared/pairing' import type { WslHostConnector } from './wsl-host-service' import type { WslOrcadConnection, WslOrcadConnectionDependencies } from './wsl-orcad-connection' +import type { WslOrcadConnectIntent } from './wsl-orcad-connection-status' import { defaultWslOrcadEnvironmentId } from './wsl-orcad-deploy' import { stopWslHostServer, @@ -21,7 +22,8 @@ const FakeDistrosSchema = z.array( const FakeWslHostsSchema = z.object({ distros: FakeDistrosSchema, - /** Re-read on every listing, so a test can register a distro while the app runs. */ + /** Re-read on every listing, so a test can register a distro while the app runs, or stop one: + * a distro marked `stopped` loses its bridge, and only a booting connect starts it again. */ distrosFile: z.string().optional(), pairingCode: z.string().min(1), /** Each distro the fake is asked to start, one per line, so e2e can prove nothing respawns. */ @@ -48,11 +50,44 @@ export function createFakeWslHostConnector( const port = offer ? Number(new URL(offer.endpoint).port) : 0 const active = new Map() const key = (distro: string): string => distro.toLowerCase() - const connect = async (distro: string): Promise => { + const readDistros = (): z.infer => + config.distrosFile + ? FakeDistrosSchema.parse(JSON.parse(readFileSync(config.distrosFile, 'utf8'))) + : config.distros + // Only a distros file can stop a distro, so fixed listings keep their always-running hosts. + const isStopped = (distro: string): boolean => + Boolean(config.distrosFile) && + readDistros().some((entry) => key(entry.name) === key(distro) && entry.state === 'stopped') + const dropBridge = (distro: string): void => { + if (active.delete(key(distro))) { + onChange(distro, null, { state: 'closed' }) + } + } + const connect = async ( + distro: string, + intent?: WslOrcadConnectIntent + ): Promise => { // Same guard as the real connector: nothing starts for a host that is gone. if (!options.isKnownDistro(distro)) { throw new Error(`${distro} is no longer a WSL host; Orca will not start anything for it.`) } + if (isStopped(distro)) { + if (intent?.noBoot || !config.distrosFile) { + throw new Error(`${distro} is not running.`) + } + // A booting connect starts the distro, as wsl.exe would; atomic so no read sees half a file. + // Own temp name: the test also stages distros.json writes beside it. + const tmp = `${config.distrosFile}.${process.pid}.tmp` + writeFileSync( + tmp, + JSON.stringify( + readDistros().map((entry) => + key(entry.name) === key(distro) ? { ...entry, state: 'running' } : entry + ) + ) + ) + renameSync(tmp, config.distrosFile) + } if (config.connectLogFile) { appendFileSync(config.connectLogFile, `${distro}\n`) } @@ -76,25 +111,24 @@ export function createFakeWslHostConnector( } return { listDistros: async () => - (config.distrosFile - ? FakeDistrosSchema.parse(JSON.parse(readFileSync(config.distrosFile, 'utf8'))) - : config.distros - ).map((distro, index) => ({ + readDistros().map((distro, index) => ({ ...distro, id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}` })), readOrcadPublicKey: async () => null, connect, get: (distro) => active.get(key(distro)) ?? null, - isReachable: async (distro) => active.has(key(distro)), + isReachable: async (distro) => !isStopped(distro) && active.has(key(distro)), isSettling: () => false, - // The fake distro always runs, so a missing server is reachable by a reconnect. - reachability: async (distro) => (active.has(key(distro)) ? 'available' : 'unreachable'), - disconnect: async (distro) => { - if (active.delete(key(distro))) { - onChange(distro, null, { state: 'closed' }) + // A running fake distro's missing server is reachable by a reconnect; a stopped one's bridge is gone. + reachability: async (distro) => { + if (isStopped(distro)) { + dropBridge(distro) + return 'stopped' } + return active.has(key(distro)) ? 'available' : 'unreachable' }, + disconnect: async (distro) => dropBridge(distro), // Use real terminal RPCs against the headless fixture server for removal proof; fake only decommission. stopServer: async (distro, environmentId) => { // As the real connector: the fake distro always runs, so an unbridged server is reconnected diff --git a/tests/e2e/wsl-host-chat.spec.ts b/tests/e2e/wsl-host-chat.spec.ts new file mode 100644 index 00000000000..4a6e68d2643 --- /dev/null +++ b/tests/e2e/wsl-host-chat.spec.ts @@ -0,0 +1,245 @@ +/** + * Native chat on a WSL host with a fake WSL transport: the HUB stands in for the distro's orcad and + * runs the Claude stub. A second desktop adds the "distro" as a host, opens a chat on a project + * there, then sees the distro stop: chat calls refuse without booting it (E9) and switching back + * to the workspace boots it so the chat continues. + */ +// Minimum launch copied from wsl-host-fake-transport.spec.ts; move to a shared helper once one is on main. +import { execFileSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { + _electron as electron, + type ElectronApplication, + type Page +} from '@stablyai/playwright-test' +import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile' +import { getOrcaElectronLaunchArgs } from './helpers/electron-launch-args' +import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown' +import { createElectronHomeIsolation } from './helpers/electron-home-isolation' +import { expect, forwardElectronProcessLogs } from './helpers/orca-app' +import { createRuntimeDesktopPairingOffer } from './helpers/paired-electron-client' +import { callEnvironment } from './helpers/paired-host-terminal' +import { buildFakeAgentCommandOverride } from './helpers/fake-agent-command-override' +import { SUPPORTED, test } from './helpers/structured-claude-stub' +import { waitForSessionReady } from './helpers/store' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../src/shared/wsl-host-internal-gate' + +const WSL_ENVIRONMENT_ID = 'wsl:ubuntu' +const RUNNING = [{ name: 'Ubuntu', state: 'running', version: 2 }] +const STOPPED = [{ name: 'Ubuntu', state: 'stopped', version: 2 }] + +async function launchDesktopWithFakeWsl( + fake: Record, + testInfo: Parameters[1] +): Promise<{ app: ElectronApplication; page: Page; dispose: () => Promise }> { + const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-chat-desktop-')) + writeFileSync( + path.join(userDataDir, 'orca-data.json'), + `${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n` + ) + const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env + void _unused + const isolation = createElectronHomeIsolation({ + inheritedEnv: cleanEnv, + launchEnv: {}, + extraEnv: { + [WSL_HOSTS_INTERNAL_GATE_ENV]: '1', + ORCA_E2E_FAKE_WSL_HOSTS: JSON.stringify(fake) + }, + userDataDir + }) + const app = await electron.launch({ + args: getOrcaElectronLaunchArgs(path.join(process.cwd(), 'out', 'main', 'index.js'), false), + env: { ...isolation.env, NODE_ENV: 'development', ORCA_E2E_HEADLESS: '1' } + }) + forwardElectronProcessLogs(app, testInfo) + const dispose = async (): Promise => { + await closeElectronAppForE2E(app) + await cleanupE2EDaemons(userDataDir) + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) + } + try { + const page = await app.firstWindow({ timeout: 120_000 }) + await page.waitForLoadState('domcontentloaded') + await waitForSessionReady(page) + return { app, page, dispose } + } catch (error) { + await dispose() + throw error + } +} + +function createGitProject(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-chat-project-')) + execFileSync('git', ['init', '-q', '-b', 'main', dir]) + writeFileSync(path.join(dir, 'README.md'), '# wsl host chat project\n') + execFileSync('git', ['-C', dir, 'add', '.']) + execFileSync( + 'git', + ['-C', dir, '-c', 'user.name=e2e', '-c', 'user.email=e2e@example.com', 'commit', '-qm', 'init'], + { stdio: 'ignore' } + ) + return dir +} + +/** The fake re-reads this file on every check, so it must never see a half-written one. */ +function writeDistros(distrosFile: string, distros: readonly object[]): void { + const tmp = `${distrosFile}.tmp` + writeFileSync(tmp, JSON.stringify(distros)) + renameSync(tmp, distrosFile) +} + +function readDistroState(distrosFile: string): string | undefined { + const distros: { state?: string }[] = JSON.parse(readFileSync(distrosFile, 'utf8')) + return distros[0]?.state +} + +function fakeWslConnects(connectLogFile: string): string[] { + return existsSync(connectLogFile) + ? readFileSync(connectLogFile, 'utf8').split('\n').filter(Boolean) + : [] +} + +/** Focuses the project's worktree on the desktop, which is the act that may boot its distro. */ +async function focusProject(page: Page, repoPath: string): Promise { + return page.evaluate( + ({ repoPath, hostId }) => { + const state = window.__store?.getState() + const repo = state?.repos.find((entry) => entry.path === repoPath) + const worktree = repo + ? state?.allWorktrees().find((entry) => entry.repoId === repo.id) + : undefined + if (!state || !worktree) { + return null + } + state.setActiveWorktree(worktree.id, hostId) + return worktree.id + }, + { repoPath, hostId: toRuntimeExecutionHostId(WSL_ENVIRONMENT_ID) } + ) +} + +test.describe('native chat on a WSL host', () => { + test.skip(!SUPPORTED, 'the Claude stub wrapper is POSIX-only') + + test('admits, streams, refuses while the distro is stopped, and continues after focus boots it', async ({ + orcaPage: hub, + claudeStub + }, testInfo) => { + // Two Electron apps, a host add and two chat turns. + test.setTimeout(300_000) + await waitForSessionReady(hub) + // The HUB is the distro's orcad: its settings are the host's, so the chat runs its stub. + await hub.evaluate(async (command) => { + await window.__store?.getState().updateSettings({ + experimentalNativeChat: true, + agentCmdOverrides: { claude: command } + }) + }, buildFakeAgentCommandOverride(claudeStub.command)) + const offer = await createRuntimeDesktopPairingOffer(hub) + const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-chat-fake-')) + const distrosFile = path.join(fakeDir, 'distros.json') + const connectLogFile = path.join(fakeDir, 'connects.log') + writeDistros(distrosFile, RUNNING) + const desktop = await launchDesktopWithFakeWsl( + { distros: RUNNING, distrosFile, connectLogFile, pairingCode: offer.pairingUrl }, + testInfo + ) + const projectPath = createGitProject() + try { + const { page } = desktop + await page.evaluate(async () => { + const settings = await window.api.settings.set({ experimentalWslHosts: true }) + window.__store?.setState({ settings }) + await window.__store?.getState().updateSettings({ + experimentalNativeChat: true, + defaultTuiAgent: 'claude' + }) + }) + const added = await page.evaluate(() => + window.api.runtimeEnvironments.wslHosts?.add({ distro: 'Ubuntu' }) + ) + expect(added, JSON.stringify(added)).toMatchObject({ ok: true }) + await callEnvironment(page, WSL_ENVIRONMENT_ID, 'repo.add', { + path: projectPath, + kind: 'git' + }) + await expect.poll(() => focusProject(page, projectPath), { timeout: 60_000 }).toBeTruthy() + + // Admission: the host, not this desktop, answers whether it can run the chat. + const askSupport = () => + callEnvironment(page, WSL_ENVIRONMENT_ID, 'agentSession.createSupport', { + worktree: `path:${projectPath}`, + agent: 'claude' + }) + expect(await askSupport()).toMatchObject({ supported: true }) + + await page.getByRole('button', { name: 'New tab', exact: true }).first().click() + await page + .getByRole('menu') + .getByText('Claude', { exact: true }) + .first() + .click({ timeout: 60_000 }) + const chat = page.locator('[data-native-chat-root="true"]') + await expect(chat).toBeVisible({ timeout: 60_000 }) + const composer = chat.getByRole('textbox') + const send = async (text: string): Promise => { + // Typing before the composer is editable loses keys, notably right after a reconnect. + await expect(composer).toBeEditable({ timeout: 60_000 }) + await composer.click() + await page.keyboard.type(text) + await expect(composer).toContainText(text) + await chat.getByRole('button', { name: 'Send', exact: true }).click() + } + // The transcript only, so the composer still holding the prompt can't stand in for the message. + const transcript = chat.locator('[data-native-chat-scroll]') + /** The prompt's message, followed later in the transcript by a stub reply. */ + const expectReplyAfter = async (text: string): Promise => { + const prompt = transcript.getByText(text, { exact: true }).last() + await expect(prompt).toBeVisible({ timeout: 60_000 }) + await expect( + prompt.locator('xpath=following::*[text()="Stub reply."]').first() + ).toBeVisible({ timeout: 120_000 }) + } + await send('hello from the desktop') + await expectReplyAfter('hello from the desktop') + await page.screenshot({ path: testInfo.outputPath('wsl-host-chat-streamed.png') }) + + // The distro stops: a chat call is a runtime call, so it refuses rather than boot it. + const connectsBeforeStop = fakeWslConnects(connectLogFile).length + writeDistros(distrosFile, STOPPED) + await expect + .poll( + () => + askSupport().then( + () => 'answered', + (error: unknown) => String(error) + ), + { timeout: 30_000 } + ) + .toContain('Ubuntu was stopped') + await page.screenshot({ path: testInfo.outputPath('wsl-host-chat-stopped.png') }) + + // Switching away and back is the user acting on the host: that boots it and the chat goes on. + await page.evaluate(() => window.__store?.getState().setActiveWorktree(null)) + // The whole stopped window, up to the focus, booted nothing. + expect(fakeWslConnects(connectLogFile)).toHaveLength(connectsBeforeStop) + expect(readDistroState(distrosFile)).toBe('stopped') + await expect.poll(() => focusProject(page, projectPath), { timeout: 30_000 }).toBeTruthy() + await expect + .poll(() => fakeWslConnects(connectLogFile).length, { timeout: 60_000 }) + .toBeGreaterThan(connectsBeforeStop) + await expect(chat).toBeVisible({ timeout: 60_000 }) + await send('after the distro restarted') + await expectReplyAfter('after the distro restarted') + await page.screenshot({ path: testInfo.outputPath('wsl-host-chat-after-boot.png') }) + } finally { + await desktop.dispose() + rmSync(fakeDir, { recursive: true, force: true }) + rmSync(projectPath, { recursive: true, force: true }) + } + }) +})