Files
orca/tests/e2e/helpers/ssh-config-host-picker.ts
T
Jinjing 637c7e94c9 Add SSH config host picker to add-host dialog (#12334)
* feat(ssh): add SSH config host picker for add-host form

Users can now click 'Fill from ~/.ssh/config…' to browse available SSH
config hosts in a picker, select one, and have the form automatically
prefill with resolved connection details (hostname, port, username, auth).

Previously, an 'import' button provided bulk sync on this form—confusing
and unhelpful when everything was already synced. That action is now
available as a secondary 'Add all' option in the picker.

* fix(ssh): import filter preservation and label fallback

- Reuse search loader on import completion to preserve active filter inside generation guard
- Fall back to hostname when manual host has no label, not empty string
- Make alias duplicate detection case-insensitive to match config picker behavior
- Validate host availability when restoring project group selection
- Add aria-selected attribute to picker options for accessibility

* fix(ssh): harden config picker import, alias folding, and host targeting

Review findings on the ~/.ssh/config picker + bulk add:

- Guard config-host resolution with a generation counter so a late resolve
  cannot overwrite a later pick or a form the user backed out of; freeze the
  other rows while a pick resolves.
- Stop "Add all N" from re-adopting deleted hosts — it now imports without
  reAdopt, matching the new-host count it advertises. Settings → Import keeps
  the explicit re-adopt path.
- Fold SSH aliases through a shared normalizeSshConfigAlias for import
  ownership, delete tombstones, reclaim, picker search, and the save-time
  duplicate check, which now occupies configHost *and* label like the picker.
- Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not
  when `ssh -G` merely echoes the /etc/ssh system default.
- Fail closed with unavailable/setup-not-found when an explicit
  projectHostSetupId names a non-actionable host instead of silently creating
  the workspace on a sibling host.
- Cache the parsed config for the picker session (refresh on open/retry) so
  filter keystrokes no longer reparse and Include-expand the file, keep the
  filter usable during loads, add a Retry on load errors, explain an empty
  Identity file after a config fill, and drop the always-false aria-selected.

* refactor(ssh): centralize host result limit and extract folder group val

Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message
cannot drift from the host's query limit. Extract findActionableFolderProjectGroup
to avoid repeating the folder-host-availability check across the composer hook.

* fix(ssh): pass -F to ssh -G when HOME differs from passwd home

In E2E tests and sandboxes, isolated HOME can differ from the system
passwd home. OpenSSH resolves the default config via getpwuid (passwd),
while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F
to explicitly specify the config path when they diverge, so ssh -G and
the picker resolve the same file.

* fix(ssh): verify config host exists before resolving with ssh -G

When a user edits ~/.ssh/config and removes a host, the import picker
should not fall back to ssh -G's echoed response (which treats any alias
as valid). Check the reloaded config file before resolving.

- Force reload config on each resolve to catch user edits post-open
- Reject aliases not in the current config before calling ssh -G
- Add test for deleted alias edge case
- Fix workspace-target fallback to honor explicit host selection

* fix(ssh): let tombstoned aliases be re-picked in the config picker

Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path.
2026-08-03 17:32:13 -07:00

283 lines
9.5 KiB
TypeScript

/**
* Shared helpers for SSH config host picker / import E2E specs.
* Prefer role/label locators and user-visible copy over ids / data-*.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { ElectronApplication, Locator, Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
export function makeSshConfigHostPrefix(): string {
return `e2e-ssh-cfg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
export type SeededSshConfigHost = {
alias: string
hostname: string
user: string
port: number
}
export function hostEndpointSummary(
host: Pick<SeededSshConfigHost, 'user' | 'hostname' | 'port'>
): string {
return `${host.user}@${host.hostname}:${host.port}`
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function buildSshConfigBody(hosts: SeededSshConfigHost[]): string {
return hosts
.map(
(host) =>
`Host ${host.alias}\n HostName ${host.hostname}\n User ${host.user}\n Port ${host.port}\n`
)
.join('\n')
}
/** Write ~/.ssh/config into the Electron-isolated HOME (same path os.homedir() uses). */
export async function seedIsolatedSshConfig(
electronApp: ElectronApplication,
configBody: string
): Promise<string> {
const home = await electronApp.evaluate(({ app }) => app.getPath('home'))
const sshDir = path.join(home, '.ssh')
mkdirSync(sshDir, { recursive: true, mode: 0o700 })
writeFileSync(path.join(sshDir, 'config'), configBody, { mode: 0o600 })
return home
}
export async function dismissTransientAnnouncement(page: Page): Promise<void> {
const maybeLaterButton = page.getByRole('button', { name: 'Maybe Later' })
const visible = await expect(maybeLaterButton)
.toBeVisible({ timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (visible) {
await maybeLaterButton.click()
}
}
export async function closeSettingsPage(page: Page): Promise<void> {
await page.evaluate(() => {
window.__store?.getState().closeSettingsPage()
})
await expect(page.getByPlaceholder('Search settings'))
.toBeHidden({ timeout: 5_000 })
.catch(() => undefined)
}
export async function closeOpenDialogs(page: Page): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const dialogCount = await page.getByRole('dialog').count()
if (dialogCount === 0) {
return
}
const dialog = page.getByRole('dialog').last()
const cancelOrBack = dialog.getByRole('button', { name: /^(Cancel|Back)$/ })
await ((await cancelOrBack
.first()
.isVisible()
.catch(() => false))
? cancelOrBack.first().click()
: page.keyboard.press('Escape'))
await expect
.poll(async () => page.getByRole('dialog').count(), { timeout: 3_000 })
.toBeLessThan(dialogCount)
.catch(() => undefined)
}
}
/** Leave settings / overlays so the main shell (Add Project) is reachable. */
export async function returnToAppShell(page: Page): Promise<void> {
await closeOpenDialogs(page)
await closeSettingsPage(page)
await closeOpenDialogs(page)
}
/** Add Project → Host → Add remote host → Add SSH host → form dialog. */
export async function openAddSshHostDialog(page: Page): Promise<Locator> {
await returnToAppShell(page)
await page
.getByRole('button', { name: /Add Project/i })
.first()
.click()
const addProjectDialog = page.getByRole('dialog', { name: /Add a project/i })
await expect(addProjectDialog).toBeVisible({ timeout: 10_000 })
const hostCombobox = addProjectDialog.getByRole('combobox')
await expect(hostCombobox).toBeVisible()
await hostCombobox.click()
// cmdk exposes options; accessible name includes the detail line.
const addRemoteHostItem = page.getByRole('option', { name: /Add remote host/i })
await expect(addRemoteHostItem).toBeVisible({ timeout: 5_000 })
await addRemoteHostItem.click()
// Nested popover is portaled; name includes the “existing machine over SSH” detail.
const addSshHostAction = page.getByRole('button', {
name: /Add SSH host.*existing machine over SSH/i
})
await expect(addSshHostAction).toBeVisible({ timeout: 5_000 })
await addSshHostAction.click()
const sshDialog = page.getByRole('dialog', { name: 'Add SSH host' })
await expect(sshDialog).toBeVisible({ timeout: 10_000 })
await expect(sshDialog.getByRole('heading', { name: 'Add SSH host' })).toBeVisible()
return sshDialog
}
export async function openSshConfigHostPicker(page: Page): Promise<Locator> {
const sshDialog = await openAddSshHostDialog(page)
await sshDialog.getByRole('button', { name: /Fill from ~\/\.ssh\/config/i }).click()
const pickerDialog = page.getByRole('dialog', { name: 'Choose from ~/.ssh/config' })
await expect(pickerDialog).toBeVisible({ timeout: 10_000 })
await expect(
pickerDialog.getByRole('heading', { name: 'Choose from ~/.ssh/config' })
).toBeVisible()
// Wait past the loading empty-state before asserting host rows.
await expect(pickerDialog.getByText('Reading ~/.ssh/config…')).toBeHidden({ timeout: 10_000 })
return pickerDialog
}
/** Settings → SSH pane: section the user sees under the “SSH Hosts” heading. */
export async function openSshHostSettings(page: Page): Promise<Locator> {
await closeOpenDialogs(page)
await page.evaluate(() => {
const state = window.__store?.getState()
if (!state) {
throw new Error('store unavailable')
}
state.openSettingsTarget({ pane: 'ssh', repoId: null })
state.openSettingsPage()
})
await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 })
await dismissTransientAnnouncement(page)
const sshSection = page
.locator('section')
.filter({ has: page.getByRole('heading', { name: 'SSH Hosts' }) })
await expect(sshSection).toBeVisible({ timeout: 10_000 })
await expect(sshSection.getByRole('button', { name: 'Import' })).toBeVisible()
await expect(sshSection.getByRole('button', { name: 'Add Target' })).toBeVisible()
return sshSection
}
/** Form fields on Add SSH host — labels the user reads. */
export function addSshHostFormFields(dialog: Locator): {
label: Locator
host: Locator
username: Locator
port: Locator
identityFile: Locator
} {
return {
label: dialog.getByLabel('Label', { exact: true }),
host: dialog.getByLabel('Host or alias'),
username: dialog.getByLabel('Username'),
port: dialog.getByLabel('Port'),
identityFile: dialog.getByLabel('Identity file')
}
}
/**
* Picker row for one config Host: the list button whose accessible name includes
* the alias and the user@host:port line the user sees.
*/
export function configHostRow(
pickerDialog: Locator,
host: Pick<SeededSshConfigHost, 'alias' | 'user' | 'hostname' | 'port'>
): Locator {
const alias = escapeRegExp(host.alias)
const endpoint = escapeRegExp(hostEndpointSummary(host))
return pickerDialog
.getByRole('list', { name: 'SSH config hosts' })
.getByRole('button', { name: new RegExp(`${alias}[\\s\\S]*${endpoint}`) })
}
/**
* Assert a saved host appears in Settings. Card subtitle is
* `user@host:port • terminal timeout: …`, so match the endpoint as a prefix.
*/
export async function expectSshHostListedInSettings(
sshSection: Locator,
host: SeededSshConfigHost
): Promise<void> {
await expect(sshSection.getByText(host.alias, { exact: true })).toBeVisible({ timeout: 10_000 })
await expect(
sshSection.getByText(new RegExp(`^${escapeRegExp(hostEndpointSummary(host))}\\b`))
).toBeVisible()
}
export async function expectSshHostAbsentFromSettings(
sshSection: Locator,
host: SeededSshConfigHost
): Promise<void> {
await expect(sshSection.getByText(host.alias, { exact: true })).toHaveCount(0)
await expect(
sshSection.getByText(new RegExp(escapeRegExp(hostEndpointSummary(host))))
).toHaveCount(0)
}
export async function seedOrcaSshTargetMatchingAlias(
page: Page,
args: { alias: string; hostname: string; username?: string; port?: number }
): Promise<string> {
return page.evaluate(async ({ alias, hostname, username, port }) => {
const result = await window.api.ssh.addTarget({
target: {
label: alias,
configHost: alias,
host: hostname,
port: port ?? 22,
username: username ?? 'deploy',
relayGracePeriodSeconds: 60
}
})
window.__store?.getState().recordSshRepoReadoptions(result.repoReadoptions)
return result.target.id
}, args)
}
export async function removeSshTargetsByPrefix(page: Page, prefix: string): Promise<void> {
await page.evaluate(async (labelPrefix) => {
const targets = (await window.api.ssh.listTargets()) as {
id: string
label: string
configHost?: string
}[]
for (const target of targets) {
const matches =
target.label.startsWith(labelPrefix) ||
(target.configHost != null && target.configHost.startsWith(labelPrefix))
if (!matches) {
continue
}
try {
await window.api.ssh.removeTarget({ id: target.id })
} catch {
// Best-effort cleanup.
}
}
}, prefix)
}
export async function removeSshTargetByAlias(page: Page, alias: string): Promise<void> {
await page.evaluate(async (hostAlias) => {
const targets = (await window.api.ssh.listTargets()) as {
id: string
label: string
configHost?: string
}[]
const match = targets.find(
(target) => target.configHost === hostAlias || target.label === hostAlias
)
if (!match) {
throw new Error(`No SSH target for alias ${hostAlias}`)
}
await window.api.ssh.removeTarget({ id: match.id })
}, alias)
}