fix(settings): keep integration connect dialog drafts on backdrop click (#20932)

* fix(settings): keep integration connect dialog drafts on backdrop click

A backdrop click in the Settings → Integrations Jira/Linear/Bitbucket
connect dialogs dismissed the Radix modal, and each dialog's reset-on-open
then wiped the typed credential.

Generalize SshTargetForm's dirty-gated outside dismissal into a shared
preventOutsideDismissWhenDirty factory and wire it into the three dialogs
(and SshTargetForm), so an accidental backdrop click no longer discards a
draft while Escape / Cancel / × remain the explicit discard paths.

Bitbucket compares email/baseUrl against a props-seeded baseline and only
counts the active auth mode's fields, so a mid-edit status refresh and a
mode toggle cannot make the form sticky.

STA-7332

* test(e2e): drop ticket id from dismiss spec comment
This commit is contained in:
Jinjing
2026-09-15 22:03:16 -07:00
committed by GitHub
parent 9ed561c1d2
commit bfc297df99
9 changed files with 593 additions and 13 deletions
@@ -0,0 +1,378 @@
// @vitest-environment happy-dom
import { act } from 'react'
import type { ReactElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { JiraConnectDialog } from './jira-connect-dialog'
import { LinearApiKeyDialog } from './linear-api-key-dialog'
import { BitbucketCredentialsDialog } from './settings/bitbucket-credentials-dialog'
type StoreState = {
settings: { activeRuntimeEnvironmentId: string | null }
connectJira: (input: unknown) => Promise<{ ok: boolean; error?: string }>
connectLinear: (apiKey: string) => Promise<{ ok: boolean; error?: string }>
}
const mocks = vi.hoisted(() => {
const store: { current: StoreState | null } = { current: null }
return { store }
})
vi.mock('@/store', () => ({
useAppStore: (selector: (state: StoreState) => unknown) => {
if (!mocks.store.current) {
throw new Error('Store state was not installed')
}
return selector(mocks.store.current)
}
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
getActiveRuntimeTarget: () => ({ kind: 'local' })
}))
let root: Root | null = null
beforeEach(() => {
mocks.store.current = {
settings: { activeRuntimeEnvironmentId: null },
connectJira: vi.fn(async () => ({ ok: true })),
connectLinear: vi.fn(async () => ({ ok: true }))
}
})
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount()
})
}
root = null
document.body.innerHTML = ''
mocks.store.current = null
})
async function renderDialog(ui: ReactElement, existingRoot?: Root): Promise<Root> {
const targetRoot = existingRoot ?? createRoot(appendContainer())
root = targetRoot
await act(async () => {
targetRoot.render(ui)
})
// Why: Radix attaches its document pointerdown listener on a setTimeout(0), so a
// synchronous dispatch right after mount is missed.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
return targetRoot
}
function appendContainer(): HTMLDivElement {
const container = document.createElement('div')
document.body.appendChild(container)
return container
}
async function outsideClick(): Promise<void> {
await act(async () => {
// Why: modal DialogContent sets deferPointerDownOutside, so the dismissal resolves on the
// click that follows the outside pointerdown. Events must bubble to reach the document listeners.
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
}
async function pressEscape(): Promise<void> {
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
}
async function click(element: HTMLElement): Promise<void> {
await act(async () => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
}
async function type(input: HTMLInputElement, value: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
function inputByPlaceholder(placeholder: string): HTMLInputElement {
const input = document.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)
if (!input) {
throw new Error(`missing input with placeholder ${placeholder}`)
}
return input
}
function buttonByText(label: string): HTMLButtonElement {
const match = Array.from(document.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === label
)
if (!match) {
throw new Error(`missing ${label} button`)
}
return match
}
describe('JiraConnectDialog outside dismiss', () => {
it('keeps a typed site URL when the backdrop is clicked', async () => {
const onOpenChange = vi.fn()
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
const siteUrl = inputByPlaceholder('https://example.atlassian.net')
await type(siteUrl, 'https://acme.atlassian.net')
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
expect(siteUrl.value).toBe('https://acme.atlassian.net')
})
it('keeps a typed email and API token when the backdrop is clicked', async () => {
const onOpenChange = vi.fn()
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
await type(inputByPlaceholder('you@example.com'), 'dev@example.com')
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
await type(inputByPlaceholder('Atlassian API token'), 'jira-token')
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
})
it('still dismisses on a backdrop click while the form is clean', async () => {
const onOpenChange = vi.fn()
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
await outsideClick()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
// Why: mode switches clear the credential fields, so a toggle alone leaves nothing to lose.
it('still dismisses on a backdrop click after a mode toggle with nothing typed', async () => {
const onOpenChange = vi.fn()
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
await click(buttonByText('Self-hosted'))
expect(inputByPlaceholder('https://jira.example.com')).not.toBeNull()
await outsideClick()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('still discards a typed draft on Escape', async () => {
const onOpenChange = vi.fn()
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
await type(inputByPlaceholder('https://example.atlassian.net'), 'https://acme.atlassian.net')
await pressEscape()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
describe('LinearApiKeyDialog outside dismiss', () => {
it('keeps a typed key when the backdrop is clicked', async () => {
const onOpenChange = vi.fn()
await renderDialog(<LinearApiKeyDialog open onOpenChange={onOpenChange} />)
const key = inputByPlaceholder('lin_api_...')
await type(key, 'lin_api_secret')
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
expect(key.value).toBe('lin_api_secret')
})
it('still dismisses on a backdrop click while the key is empty', async () => {
const onOpenChange = vi.fn()
await renderDialog(<LinearApiKeyDialog open onOpenChange={onOpenChange} />)
await outsideClick()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('still discards a typed key on Escape', async () => {
const onOpenChange = vi.fn()
await renderDialog(<LinearApiKeyDialog open onOpenChange={onOpenChange} />)
await type(inputByPlaceholder('lin_api_...'), 'lin_api_secret')
await pressEscape()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
describe('BitbucketCredentialsDialog outside dismiss', () => {
it('keeps a typed email when the backdrop is clicked', async () => {
const onOpenChange = vi.fn()
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
const email = inputByPlaceholder('you@example.com')
await type(email, 'dev@example.com')
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
expect(email.value).toBe('dev@example.com')
})
it('keeps a typed API token and base URL when the backdrop is clicked', async () => {
const onOpenChange = vi.fn()
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
await type(inputByPlaceholder('Atlassian API token'), 'bb-token')
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
await type(inputByPlaceholder('https://api.bitbucket.org/2.0'), 'https://api.internal/2.0')
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
})
it('keeps a typed access token in token mode when the backdrop is clicked', async () => {
const onOpenChange = vi.fn()
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
await click(buttonByText('Access token'))
await type(inputByPlaceholder('Repository, project, or workspace access token'), 'bb-access')
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
})
// Why: the base URL is submitted in both auth modes, so it must block dismissal in token mode too.
it('keeps a typed base URL in token mode when the backdrop is clicked', async () => {
const onOpenChange = vi.fn()
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
await click(buttonByText('Access token'))
await type(inputByPlaceholder('https://api.bitbucket.org/2.0'), 'https://api.internal/2.0')
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
})
it('still dismisses on a backdrop click while the form is clean', async () => {
const onOpenChange = vi.fn()
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
await outsideClick()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
// Why: the baseline is seeded from the initial props, so an untouched prefilled edit form is
// clean and must still dismiss on a backdrop click.
it('still dismisses on a backdrop click for an untouched prefilled edit', async () => {
const onOpenChange = vi.fn()
await renderDialog(
<BitbucketCredentialsDialog
open
onOpenChange={onOpenChange}
initialAuthMode="basic"
initialEmail="prefilled@example.com"
initialBaseUrl="https://api.example.com"
/>
)
await outsideClick()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
// Why: a status refresh may rewrite the stored metadata mid-edit; the baseline must stay at the
// values captured at open so an untouched form does not become sticky.
it('stays clean when the stored metadata is refreshed mid-edit', async () => {
const onOpenChange = vi.fn()
const targetRoot = await renderDialog(
<BitbucketCredentialsDialog
open
onOpenChange={onOpenChange}
initialEmail="first@example.com"
/>
)
await renderDialog(
<BitbucketCredentialsDialog
open
onOpenChange={onOpenChange}
initialEmail="refreshed@example.com"
initialBaseUrl="https://api.example.com"
/>,
targetRoot
)
await outsideClick()
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(inputByPlaceholder('you@example.com').value).toBe('first@example.com')
})
it('keeps a typed draft when the stored metadata is refreshed mid-edit', async () => {
const onOpenChange = vi.fn()
const targetRoot = await renderDialog(
<BitbucketCredentialsDialog
open
onOpenChange={onOpenChange}
initialEmail="first@example.com"
/>
)
const email = inputByPlaceholder('you@example.com')
await type(email, 'typed@example.com')
await renderDialog(
<BitbucketCredentialsDialog
open
onOpenChange={onOpenChange}
initialEmail="refreshed@example.com"
/>,
targetRoot
)
await outsideClick()
expect(onOpenChange).not.toHaveBeenCalled()
expect(email.value).toBe('typed@example.com')
})
// Why: mode switches clear the secret fields, so a toggle alone leaves nothing to lose.
it('still dismisses on a backdrop click after a mode toggle with nothing typed', async () => {
const onOpenChange = vi.fn()
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
await click(buttonByText('Access token'))
expect(inputByPlaceholder('Repository, project, or workspace access token')).not.toBeNull()
await outsideClick()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
// Why: a basic-mode email is not submitted in token mode, so it must not make the token form
// sticky — only the fields the active mode submits count as the draft.
it('dismisses after switching to token mode with only a basic-mode email typed', async () => {
const onOpenChange = vi.fn()
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
await type(inputByPlaceholder('you@example.com'), 'dev@example.com')
await click(buttonByText('Access token'))
expect(inputByPlaceholder('Repository, project, or workspace access token')).not.toBeNull()
await outsideClick()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('still discards a typed draft on Escape', async () => {
const onOpenChange = vi.fn()
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
await type(inputByPlaceholder('you@example.com'), 'dev@example.com')
await pressEscape()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
@@ -16,6 +16,7 @@ import { Label } from '@/components/ui/label'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context'
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
import { translate } from '@/i18n/i18n'
type JiraConnectDialogProps = {
@@ -112,6 +113,11 @@ export function JiraConnectDialog({
}
}
// Why: a stray backdrop click must not discard typed credentials. Mode switches clear the
// credential fields, so a toggle alone is not dirty. Escape / Cancel / × stay explicit.
const isDraftDirty = (): boolean => siteUrl !== '' || email !== '' || apiToken !== ''
const guardOutsideDismiss = preventOutsideDismissWhenDirty(isDraftDirty)
const handleConnect = async (): Promise<void> => {
const trimmedSite = siteUrl.trim()
const trimmedEmail = email.trim()
@@ -164,6 +170,8 @@ export function JiraConnectDialog({
<DialogContent
overlayClassName={overlayClassName}
className={cn('sm:max-w-md', contentClassName)}
onPointerDownOutside={guardOutsideDismiss}
onInteractOutside={guardOutsideDismiss}
>
<DialogHeader className="gap-3">
<DialogTitle className="leading-tight">
@@ -20,6 +20,7 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
import {
createLinearApiKeyDialogState,
resolveLinearApiKeyDialogState
@@ -74,6 +75,10 @@ export function LinearApiKeyDialog({
}
}
// Why: a stray backdrop click must not discard a typed API key. Escape / Cancel / × stay explicit.
const isDraftDirty = (): boolean => apiKeyDraft !== ''
const guardOutsideDismiss = preventOutsideDismissWhenDirty(isDraftDirty)
const handleConnect = async (): Promise<void> => {
const apiKey = apiKeyDraft.trim()
if (!apiKey || connectState === 'connecting') {
@@ -125,6 +130,8 @@ export function LinearApiKeyDialog({
<DialogContent
overlayClassName={overlayClassName}
className={cn('sm:max-w-lg', contentClassName)}
onPointerDownOutside={guardOutsideDismiss}
onInteractOutside={guardOutsideDismiss}
onKeyDown={(event) => {
if (event.key === 'Enter' && apiKeyDraft.trim() && connectState !== 'connecting') {
event.preventDefault()
@@ -184,6 +184,34 @@ describe('SshTargetForm', () => {
act(() => root.unmount())
})
it('blocks a backdrop dismissal while the draft differs from the baseline', async () => {
const editTarget: EditingTarget = { ...EMPTY_FORM, label: 'dev-box', host: 'dev-box.lan' }
const onOpenChange = vi.fn()
const root = await renderForm({ open: false, onOpenChange })
await renderForm({ open: true, editingId: 'target-1', form: editTarget, onOpenChange }, root)
await renderForm(
{
open: true,
editingId: 'target-1',
form: { ...editTarget, host: 'other.lan' },
onOpenChange
},
root
)
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
await act(async () => {
document.body.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, cancelable: true })
)
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
expect(onOpenChange).not.toHaveBeenCalled()
act(() => root.unmount())
})
it('opens Advanced by default when the target already has advanced values', async () => {
const root = await renderForm({
editingId: 'target-1',
@@ -19,6 +19,7 @@ import {
type EditingTarget
} from './ssh-target-draft'
import { translate } from '@/i18n/i18n'
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
export { EMPTY_FORM, type EditingTarget } from './ssh-target-draft'
type SshTargetFormProps = {
@@ -90,21 +91,18 @@ export function SshTargetForm({
isEditing &&
(editingLabel !== '' || (endpointSummary !== '' && endpointSummary !== editingLabel))
const preventOutsideDismiss = (event: Event): void => {
// Why: outside click is easy to hit by accident with a long multi-field form;
// keep Escape / Cancel / × as explicit discard paths. Read both refs at call
// time — the session effect can rewrite the baseline without a re-render.
if (isSshTargetFormDirty(formRef.current, baselineRef.current)) {
event.preventDefault()
}
}
// Why: outside click is easy to hit by accident with a long multi-field form; keep Escape /
// Cancel / × as explicit discard paths. Read both refs at call time — the session effect can
// rewrite the baseline without a re-render.
const isDraftDirty = (): boolean => isSshTargetFormDirty(formRef.current, baselineRef.current)
const guardOutsideDismiss = preventOutsideDismissWhenDirty(isDraftDirty)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[calc(100vh-3rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
onPointerDownOutside={preventOutsideDismiss}
onInteractOutside={preventOutsideDismiss}
onPointerDownOutside={guardOutsideDismiss}
onInteractOutside={guardOutsideDismiss}
>
<form
className="flex min-h-0 flex-1 flex-col"
@@ -1,4 +1,4 @@
import { useId, useLayoutEffect, useState } from 'react'
import { useId, useLayoutEffect, useRef, useState } from 'react'
import { ExternalLink, LoaderCircle, Lock } from 'lucide-react'
import type { BitbucketAuthMode } from '../../../../shared/bitbucket-credentials'
import { useAppStore } from '@/store'
@@ -17,6 +17,7 @@ import { Label } from '@/components/ui/label'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context'
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
import { translate } from '@/i18n/i18n'
const API_TOKEN_DOCS_URL = 'https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/'
@@ -63,6 +64,9 @@ export function BitbucketCredentialsDialog({
const [baseUrl, setBaseUrl] = useState('')
const [connectState, setConnectState] = useState<ConnectState>('idle')
const [connectError, setConnectError] = useState<string | null>(null)
// Why: the form is seeded from `initial*` props that a status refresh may rewrite mid-edit, so
// compare text fields against the values captured at open rather than the live props.
const baselineRef = useRef({ email: '', baseUrl: '' })
// Re-sync from the latest stored metadata on every open, not just on mount, so
// the Edit flow never shows values from a previous connection. Secrets always
@@ -71,9 +75,12 @@ export function BitbucketCredentialsDialog({
if (!open) {
return
}
const seedEmail = initialEmail ?? ''
const seedBaseUrl = initialBaseUrl ?? ''
baselineRef.current = { email: seedEmail, baseUrl: seedBaseUrl }
setAuthMode(initialAuthMode ?? 'basic')
setEmail(initialEmail ?? '')
setBaseUrl(initialBaseUrl ?? '')
setEmail(seedEmail)
setBaseUrl(seedBaseUrl)
setApiToken('')
setAccessToken('')
setConnectState('idle')
@@ -106,6 +113,14 @@ export function BitbucketCredentialsDialog({
}
}
// Why: a stray backdrop click must not discard typed credentials. Only the active mode's fields
// are submitted, so compare just those — a stale basic-mode email is not submitted in token mode
// and must not make the form sticky. Escape / Cancel / × stay explicit.
const isDraftDirty = (): boolean =>
baseUrl !== baselineRef.current.baseUrl ||
(isTokenMode ? accessToken !== '' : email !== baselineRef.current.email || apiToken !== '')
const guardOutsideDismiss = preventOutsideDismissWhenDirty(isDraftDirty)
const handleConnect = async (): Promise<void> => {
if (!canSubmit) {
return
@@ -153,6 +168,8 @@ export function BitbucketCredentialsDialog({
<DialogContent
overlayClassName={overlayClassName}
className={cn('sm:max-w-lg', contentClassName)}
onPointerDownOutside={guardOutsideDismiss}
onInteractOutside={guardOutsideDismiss}
onKeyDown={(event) => {
// Only from a text field: Enter on Cancel or the docs link must do
// what that control does, not submit the form.
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest'
import { preventOutsideDismissWhenDirty } from './outside-dismiss-guard'
describe('preventOutsideDismissWhenDirty', () => {
it('prevents the outside dismiss while the draft is dirty', () => {
const event = { preventDefault: vi.fn() }
preventOutsideDismissWhenDirty(() => true)(event)
expect(event.preventDefault).toHaveBeenCalledOnce()
})
it('allows the outside dismiss while the draft is clean', () => {
const event = { preventDefault: vi.fn() }
preventOutsideDismissWhenDirty(() => false)(event)
expect(event.preventDefault).not.toHaveBeenCalled()
})
it('reads the predicate at event time, not when the handler is created', () => {
let dirty = false
const guard = preventOutsideDismissWhenDirty(() => dirty)
const event = { preventDefault: vi.fn() }
guard(event)
dirty = true
guard(event)
expect(event.preventDefault).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,21 @@
// Why a structural event shape and not Radix's event type: this module stays dependency-free and
// the handler only needs `preventDefault`, which every outside-dismiss event provides.
type PreventableOutsideEvent = { preventDefault: () => void }
/**
* Block Radix outside-dismiss while `isDirty()` is true, so an accidental backdrop click cannot
* discard a draft. Escape / Cancel / × stay the explicit discard paths.
*
* Why a predicate instead of a boolean: callers that mutate their dirty baseline in an effect
* need the check evaluated at event time, not captured at render. Do not memoize the returned
* handler — it must be recreated each render so Radix reads the latest predicate.
*/
export function preventOutsideDismissWhenDirty(
isDirty: () => boolean
): (event: PreventableOutsideEvent) => void {
return (event) => {
if (isDirty()) {
event.preventDefault()
}
}
}
@@ -0,0 +1,91 @@
/**
* A backdrop click in the Settings → Integrations Linear and Jira connect dialogs must
* not close the dialog and discard typed credentials. Escape / Cancel stay the explicit discard
* paths. (Bitbucket's baseline-seeded predicate is covered by the component tests.)
*
* Mirrors the SSH host form modal guard (tests/e2e/ssh-host-form-modal.spec.ts).
*/
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker'
import { waitForSessionReady } from './helpers/store'
async function openIntegrationsSettings(page: Page): Promise<void> {
await page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
// Why: the spec asserts on English strings; the host may run a non-English locale.
await store.getState().updateSettings({ uiLanguage: 'en' })
store.getState().openSettingsTarget({ pane: 'integrations', repoId: null })
store.getState().openSettingsPage()
})
await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 })
await dismissTransientAnnouncement(page)
}
async function clickBackdrop(page: Page): Promise<void> {
// Why: the overlay is fixed inset-0 and the dialog sits over its center, so click near a corner.
await page.locator('[data-slot="dialog-overlay"]').click({ position: { x: 8, y: 8 } })
}
test.describe('Settings integrations connect dialogs', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await openIntegrationsSettings(orcaPage)
})
test('Linear API key draft survives a backdrop click but clears on cancel', async ({
orcaPage
}) => {
const card = orcaPage.locator('[data-settings-section="integrations-linear"]')
// Why: the button label depends on connection state; a fresh profile is disconnected.
const openButton = card.getByRole('button', {
name: /^(Add Linear access|Add workspace access)$/
})
await expect(openButton).toBeVisible({ timeout: 15_000 })
await openButton.click()
const dialog = orcaPage.getByRole('dialog', { name: 'Add Linear access' })
await expect(dialog).toBeVisible()
const keyInput = dialog.locator('input[type="password"]')
await keyInput.fill('lin_api_e2e_secret')
await clickBackdrop(orcaPage)
// Why: assert the settled open state, not the exit-animation frame a broken guard would leave.
await expect(dialog).toHaveAttribute('data-state', 'open')
await expect(keyInput).toHaveValue('lin_api_e2e_secret')
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toBeHidden()
// Explicit cancel discards; reopening starts empty.
await openButton.click()
await expect(dialog.locator('input[type="password"]')).toHaveValue('')
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toBeHidden()
})
test('Jira site URL draft survives a backdrop click', async ({ orcaPage }) => {
const card = orcaPage.locator('[data-settings-section="integrations-jira"]')
// Why: the button label depends on connection state; a fresh profile is disconnected.
const openButton = card.getByRole('button', { name: /^(Connect Jira|Add Jira site)$/ })
await expect(openButton).toBeVisible({ timeout: 15_000 })
await openButton.click()
const dialog = orcaPage.getByRole('dialog', { name: 'Connect Jira site' })
await expect(dialog).toBeVisible()
const siteUrlInput = dialog.locator('input[placeholder="https://example.atlassian.net"]')
await siteUrlInput.fill('https://acme.atlassian.net')
await clickBackdrop(orcaPage)
// Why: assert the settled open state, not the exit-animation frame a broken guard would leave.
await expect(dialog).toHaveAttribute('data-state', 'open')
await expect(siteUrlInput).toHaveValue('https://acme.atlassian.net')
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toBeHidden()
})
})