diff --git a/src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx b/src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx new file mode 100644 index 00000000000..5a7802d35b1 --- /dev/null +++ b/src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx @@ -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 { + 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 { + 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 { + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) +} + +async function click(element: HTMLElement): Promise { + await act(async () => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) +} + +async function type(input: HTMLInputElement, value: string): Promise { + 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(`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() + 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() + + 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() + + 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() + + 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() + + 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() + 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() + + await outsideClick() + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('still discards a typed key on Escape', async () => { + const onOpenChange = vi.fn() + await renderDialog() + + 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() + 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() + + 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() + + 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() + + 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() + + 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( + + ) + + 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( + + ) + + await renderDialog( + , + 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( + + ) + const email = inputByPlaceholder('you@example.com') + await type(email, 'typed@example.com') + + await renderDialog( + , + 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() + + 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() + + 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() + + await type(inputByPlaceholder('you@example.com'), 'dev@example.com') + await pressEscape() + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/src/renderer/src/components/jira-connect-dialog.tsx b/src/renderer/src/components/jira-connect-dialog.tsx index 0de9093185c..3db503ebac8 100644 --- a/src/renderer/src/components/jira-connect-dialog.tsx +++ b/src/renderer/src/components/jira-connect-dialog.tsx @@ -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 => { const trimmedSite = siteUrl.trim() const trimmedEmail = email.trim() @@ -164,6 +170,8 @@ export function JiraConnectDialog({ diff --git a/src/renderer/src/components/linear-api-key-dialog.tsx b/src/renderer/src/components/linear-api-key-dialog.tsx index 272814e901b..9e463ead652 100644 --- a/src/renderer/src/components/linear-api-key-dialog.tsx +++ b/src/renderer/src/components/linear-api-key-dialog.tsx @@ -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 => { const apiKey = apiKeyDraft.trim() if (!apiKey || connectState === 'connecting') { @@ -125,6 +130,8 @@ export function LinearApiKeyDialog({ { if (event.key === 'Enter' && apiKeyDraft.trim() && connectState !== 'connecting') { event.preventDefault() diff --git a/src/renderer/src/components/settings/SshTargetForm.test.tsx b/src/renderer/src/components/settings/SshTargetForm.test.tsx index d58001caa48..6c70f89feda 100644 --- a/src/renderer/src/components/settings/SshTargetForm.test.tsx +++ b/src/renderer/src/components/settings/SshTargetForm.test.tsx @@ -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', diff --git a/src/renderer/src/components/settings/SshTargetForm.tsx b/src/renderer/src/components/settings/SshTargetForm.tsx index 071fd5be88f..b2eeb096b43 100644 --- a/src/renderer/src/components/settings/SshTargetForm.tsx +++ b/src/renderer/src/components/settings/SshTargetForm.tsx @@ -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 (
('idle') const [connectError, setConnectError] = useState(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 => { if (!canSubmit) { return @@ -153,6 +168,8 @@ export function BitbucketCredentialsDialog({ { // Only from a text field: Enter on Cancel or the docs link must do // what that control does, not submit the form. diff --git a/src/renderer/src/lib/outside-dismiss-guard.test.ts b/src/renderer/src/lib/outside-dismiss-guard.test.ts new file mode 100644 index 00000000000..702419209f3 --- /dev/null +++ b/src/renderer/src/lib/outside-dismiss-guard.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/outside-dismiss-guard.ts b/src/renderer/src/lib/outside-dismiss-guard.ts new file mode 100644 index 00000000000..c28083afa18 --- /dev/null +++ b/src/renderer/src/lib/outside-dismiss-guard.ts @@ -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() + } + } +} diff --git a/tests/e2e/settings-integration-dialog-dismiss.spec.ts b/tests/e2e/settings-integration-dialog-dismiss.spec.ts new file mode 100644 index 00000000000..97102c8babf --- /dev/null +++ b/tests/e2e/settings-integration-dialog-dismiss.spec.ts @@ -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 { + 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 { + // 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() + }) +})