test(web): prune and stabilize browser tests (#43831)

This commit is contained in:
yyh authored and GitHub committed 2026-10-10 10:46:30 +00:00
1 parent ddd256f908
commit 5e567166b9
66 files changed
+721 -3316

No files matched your search

-2
View File
@@ -17,12 +17,10 @@ export default defineConfig({
},
test: {
browser: {
expect: { toMatchScreenshot: { screenshotDirectory: './.vitest-browser/screenshots' } },
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
headless: true,
screenshotDirectory: './.vitest-browser/screenshots',
screenshotFailures: true,
},
coverage: {
+8
View File
@@ -1,3 +1,4 @@
import { page, server, userEvent } from 'vite-plus/test/browser'
import './vitest.css'
document.documentElement.dataset.theme = 'light'
@@ -7,3 +8,10 @@ document.documentElement.dataset.theme = 'light'
BASE_UI_ANIMATIONS_DISABLED: boolean
}
).BASE_UI_ANIMATIONS_DISABLED = true
// Tests in one file share a page, so the viewport and pointer position outlive the test that set them.
beforeEach(async () => {
const { width, height } = server.config.browser.viewport
await page.viewport(width, height)
await userEvent.unhover(document.body)
})
@@ -87,9 +87,6 @@ describe('Home banner browser interactions', () => {
const firstPicker = screen.getByRole('button', { name: '01 First banner' })
await expect.element(stopRotationControl).toBeVisible()
const rotationControlElement = stopRotationControl.element()
expect(rotationControlElement.getBoundingClientRect().left).toBeLessThan(
firstPicker.element().getBoundingClientRect().left,
)
await screen.getByRole('button', { name: 'Before carousel' }).click()
await userEvent.tab()
@@ -150,44 +147,6 @@ describe('Home banner browser interactions', () => {
await expect.poll(() => slidesContainer.getAttribute('aria-live')).toBe('off')
})
it('keeps an explicit pause after a real pointer drag released inside the carousel', async () => {
// Embla's pointer lifecycle and owner-window timer depend on browser behavior that happy-dom cannot reproduce faithfully.
const screen = await renderBanner()
const carousel = screen.getByRole('region', { name: 'explore.banner.carouselLabel' })
const stopRotationControl = screen.getByRole('button', {
name: 'explore.banner.stopRotation',
})
const slidesContainer = getSlidesContainer(carousel.element())
const slidesRect = slidesContainer.getBoundingClientRect()
const firstSlide = screen.getByRole('group', { name: 'First banner' })
const firstSlideElement = firstSlide.element()
await stopRotationControl.click()
await userEvent.unhover(carousel)
await expect
.element(screen.getByRole('button', { name: 'explore.banner.startRotation' }))
.toBeVisible()
vi.useFakeTimers()
try {
await userEvent.dragAndDrop(slidesContainer, slidesContainer, {
sourcePosition: { x: slidesRect.width / 2 - 4, y: slidesRect.height / 2 },
targetPosition: { x: slidesRect.width / 2 + 4, y: slidesRect.height / 2 },
steps: 2,
})
await vi.advanceTimersByTimeAsync(1000)
const firstSlideStateAfterDrag = firstSlideElement.getAttribute('aria-hidden')
await vi.advanceTimersByTimeAsync(5001)
expect(firstSlideElement.getAttribute('aria-hidden')).toBe(firstSlideStateAfterDrag)
} finally {
vi.useRealTimers()
}
expect(slidesContainer).toHaveAttribute('aria-live', 'polite')
await expect
.element(screen.getByRole('button', { name: 'explore.banner.startRotation' }))
.toBeVisible()
})
it('keeps autoplay initialized but stopped for reduced motion so Play remains safe', async () => {
// The real browser implementation is retained while the media-query result is emulated before Embla initializes.
const nativeMatchMedia = window.matchMedia.bind(window)
@@ -83,5 +83,4 @@ it('keeps native submit focus during password saving and supports retry without
),
)
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
})
@@ -143,6 +143,20 @@ describe('useAccessPointActions', () => {
)
})
it('reports a successful save when the background detail refresh fails', async () => {
mocks.fetchAppDetail.mockRejectedValueOnce(new Error('Refresh failed'))
const { result } = renderActions()
await act(async () => {
expect(await result.current.saveSiteConfig(siteConfig)).toBe(true)
})
await waitFor(() => expect(mocks.fetchAppDetail).toHaveBeenCalledOnce())
expect(mocks.toast).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedSuccessfully', {
type: 'success',
})
})
it('keeps site configuration behind Access Point management permission', async () => {
const { result } = renderActions('app-1', false)
@@ -1,60 +0,0 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import VarItem from '../var-item'
// Real CSS and native Tab navigation catch actions hidden behind group-hover;
// happy-dom cannot establish whether those actions are actually visible and focusable.
it('keeps variable actions visible and keyboard reachable without hovering and skips readonly actions', async () => {
const onEdit = vi.fn()
const onRemove = vi.fn()
const screen = await render(
<div className="w-96 p-4">
<button type="button" className="mb-8">
Before variables
</button>
<VarItem
name="region"
label="Region"
required={false}
type="string"
onEdit={onEdit}
onRemove={onRemove}
/>
<section aria-label="Readonly variable">
<VarItem
readonly
name="system_region"
label="System region"
required={false}
type="string"
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
</section>
<button type="button">After variables</button>
</div>,
)
// Leave the pointer on this separate control throughout keyboard navigation.
await screen.getByRole('button', { name: 'Before variables' }).click()
const edit = screen.getByRole('button', { name: 'common.operation.edit' })
const remove = screen.getByRole('button', { name: 'common.operation.delete' })
await expect.element(edit).toBeVisible()
await expect.element(remove).toBeVisible()
await userEvent.tab()
await expect.element(edit).toHaveFocus()
expect(edit.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard('{Enter}')
expect(onEdit).toHaveBeenCalledTimes(1)
await userEvent.tab()
await expect.element(remove).toHaveFocus()
expect(remove.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard(' ')
expect(onRemove).toHaveBeenCalledTimes(1)
await expect
.element(screen.getByRole('region', { name: 'Readonly variable' }).getByRole('button'))
.not.toBeInTheDocument()
await userEvent.tab()
await expect.element(screen.getByRole('button', { name: 'After variables' })).toHaveFocus()
})
@@ -61,7 +61,7 @@ describe('Prompt generator reflow', () => {
await page.viewport(1280, 720)
})
it.each([320, 720, 1440])(
it.each([320, 1440])(
'keeps inputs and generated result actions within a %i CSS pixel viewport',
async (width) => {
// Unit tests cannot detect the original fixed 1140px popup being clipped.
@@ -1,156 +0,0 @@
import type { Ref } from 'react'
import type { ChatItem } from '@/app/components/base/chat/types'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import Chat from '@/app/components/base/chat/chat'
import { FeaturesProvider } from '@/app/components/base/features/context'
import ConfigContext, { useDebugConfigurationContext } from '@/context/debug-configuration'
import Debug from '../index'
vi.mock('@tanstack/react-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@tanstack/react-query')>()),
useQuery: () => ({ data: [] }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: () => ({ data: undefined }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({}),
}))
vi.mock('@/app/components/app/configuration/debug/chat-user-input', () => ({ default: () => null }))
vi.mock('@/app/components/app/configuration/prompt-value-panel', () => ({ default: () => null }))
vi.mock('../debug-with-multiple-model', () => ({ default: () => null }))
vi.mock('@/app/components/base/agent-log-modal', () => ({ default: () => null }))
vi.mock('@/app/components/app/text-generate/item', () => ({ default: () => null }))
vi.mock('@/service/debug', () => ({ sendCompletionMessage: vi.fn() }))
vi.mock('@/app/components/app/configuration/toast', () => ({ toast: { error: vi.fn() } }))
// Replace model execution, keeping the production Chat scroll container and log action path.
vi.mock('../debug-with-single-model', () => ({
default: ({
onOpenLog,
chatContainerRef,
}: {
onOpenLog: (item: ChatItem) => void
chatContainerRef: Ref<HTMLDivElement>
}) => (
<Chat
chatList={[
{
id: 'answer',
isAnswer: true,
content: 'Answer',
log: [{ role: 'user', text: 'Measured prompt' }],
},
]}
onOpenLog={onOpenLog}
chatContainerRef={chatContainerRef}
chatContainerClassName="debug-log-scrollbar"
noChatInput
/>
),
}))
vi.mock('@/app/components/base/chat/chat/answer', async () => {
const { default: Operation } = await import('@/app/components/base/chat/chat/answer/operation')
return {
default: ({ item }: { item: ChatItem }) => (
<div className="group">
<div style={{ height: 900 }}>{item.content}</div>
<Operation
item={item}
question=""
index={0}
maxSize={500}
contentWidth={100}
hasWorkflowProcess={false}
/>
</div>
),
}
})
vi.mock('@/app/components/base/chat/chat/question', () => ({ default: () => null }))
vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({ default: () => null }))
vi.mock('@/app/components/base/new-audio-button', () => ({ default: () => null }))
vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({ default: () => null }))
vi.mock(
'@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button',
() => ({ default: () => null }),
)
function DebugSession({ width, doubleGutter = false }: { width: number; doubleGutter?: boolean }) {
const defaults = useDebugConfigurationContext()
return (
// oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector exposes a Provider, not a React 19 provider component.
<ConfigContext.Provider
value={{
...defaults,
canTestAndRun: true,
modelConfig: {
...defaults.modelConfig,
configs: { prompt_template: '', prompt_variables: [] },
},
}}
>
<FeaturesProvider>
<style>{`.debug-log-scrollbar { scrollbar-gutter: stable; } .debug-log-scrollbar::-webkit-scrollbar { width: 16px; } .debug-double-gutter .debug-log-scrollbar { scrollbar-gutter: stable both-edges; }`}</style>
<section
aria-label="Debug pane"
className={doubleGutter ? 'debug-double-gutter' : undefined}
style={{
position: 'fixed',
top: 64,
right: 8,
bottom: 16,
width,
display: 'flex',
flexDirection: 'column',
}}
>
<Debug
isPreview
onSetting={vi.fn()}
inputs={{}}
modelParameterParams={{ setModel: vi.fn(), onCompletionParamsChange: vi.fn() }}
debugWithMultipleModel={false}
multipleModelConfigs={[]}
onMultipleModelConfigsChange={vi.fn()}
/>
</section>
</FeaturesProvider>
</ConfigContext.Provider>
)
}
it('keeps the log beside the actual scrolling chat after resizing its pane', async () => {
await page.viewport(1440, 900)
const view = await render(<DebugSession width={540} />)
const chat = page.getByTestId('chat-container').element()
expect(chat.scrollHeight).toBeGreaterThan(chat.clientHeight)
expect(chat.clientWidth).toBeLessThan(chat.getBoundingClientRect().width)
await page.getByText('Answer', { exact: true }).hover()
await page.getByRole('button', { name: 'common.operation.log' }).click()
await expect.element(page.getByText('Measured prompt')).toBeVisible()
const title = page.getByText('PROMPT LOG').element()
const initialTitleLeft = title.getBoundingClientRect().left
const initialChatWidth = chat.clientWidth
expect(title.getBoundingClientRect().right).toBeLessThan(chat.getBoundingClientRect().left)
await view.rerender(<DebugSession width={540} doubleGutter />)
await expect.poll(() => chat.clientWidth).toBeLessThan(initialChatWidth)
await expect
.poll(() => title.getBoundingClientRect().left - initialTitleLeft)
.toBe(initialChatWidth - chat.clientWidth)
await view.rerender(<DebugSession width={420} doubleGutter />)
await expect
.poll(() => title.getBoundingClientRect().left - initialTitleLeft)
.toBe(initialChatWidth - chat.clientWidth)
expect(title.getBoundingClientRect().right).toBeLessThan(chat.getBoundingClientRect().left)
await expect.element(page.getByText('Measured prompt')).toBeVisible()
await page.getByRole('button', { name: 'common.operation.close' }).click()
await expect.element(page.getByText('Measured prompt')).not.toBeInTheDocument()
await page.getByText('Answer', { exact: true }).hover()
await page.getByRole('button', { name: 'common.operation.log' }).click()
await expect.element(page.getByText('Measured prompt')).toBeVisible()
})
@@ -1,17 +1,13 @@
import { Dialog, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import copy from 'copy-to-clipboard'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { InputVarType } from '@/app/components/workflow/types'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { EmbeddedDialogContent } from '../index'
const { getProfile } = vi.hoisted(() => ({ getProfile: vi.fn() }))
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
vi.mock('@/service/base', () => ({
get: getProfile,
get: vi.fn(),
post: vi.fn(),
request: vi.fn(),
getPublic: vi.fn(),
@@ -71,43 +67,7 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals())
it('keeps Close focused while account data loads and returns to the keyboard trigger', async () => {
const queryClient = createClient()
const { profile } = seedAccountProfileQuery(createClient())
let resolveProfile!: (response: Response) => void
getProfile.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveProfile = resolve
}),
)
const screen = await render(dialogView(queryClient))
const trigger = screen.getByRole('button', { name: triggerLabel })
await userEvent.tab()
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard('{Enter}')
const dialog = screen.getByRole('dialog', { name: `${prefix}.title` })
const close = dialog.getByRole('button', { name: 'common.operation.close' })
await expect.element(close).toHaveFocus()
const initialClose = close.element()
await expect.poll(() => getProfile.mock.calls.length).toBe(1)
await expect
.element(dialog.getByRole('tab', { name: `${prefix}.iframe` }))
.not.toBeInTheDocument()
resolveProfile(
new Response(JSON.stringify(profile), {
headers: { 'content-type': 'application/json', 'x-env': 'PRODUCTION' },
}),
)
await expect.element(dialog.getByRole('tab', { name: `${prefix}.iframe` })).toBeVisible()
expect(close.element()).toBe(initialClose)
await expect.element(close).toHaveFocus()
await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
})
it('preserves the hidden input, selected tab and copied preview through exit, then starts fresh', async () => {
it('preserves the hidden input, selected tab and copied preview through the exit animation', async () => {
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
const queryClient = createClient()
seedAccountProfileQuery(queryClient)
@@ -129,7 +89,6 @@ it('preserves the hidden input, selected tab and copied preview through exit, th
await panel.getByRole('button', { name: `${prefix}.copy`, exact: true }).click()
const copied = panel.getByRole('button', { name: `${prefix}.copied`, exact: true })
await expect.element(copied).toBeVisible()
expect(copy).toHaveBeenLastCalledWith(expect.stringContaining('draft secret'))
const popup = dialog.element()
const draftInput = input.element() as HTMLInputElement
@@ -165,19 +124,4 @@ it('preserves the hidden input, selected tab and copied preview through exit, th
expect(closing.preview).toContain('draft secret')
expect(closing.copied).toBe(`${prefix}.copied`)
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard(' ')
await expect
.element(dialog.getByRole('tab', { name: `${prefix}.iframe` }))
.toHaveAttribute('aria-selected', 'true')
await expect.element(disclosure).toHaveAttribute('aria-expanded', 'false')
await expect
.element(dialog.getByRole('button', { name: `${prefix}.copy`, exact: true }))
.toBeVisible()
await disclosure.click()
await expect.element(input).toHaveValue('initial value')
await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
})
@@ -3,23 +3,14 @@ import { QueryClient, useQuery } from '@tanstack/react-query'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { useAccessPointActions } from '@/app/components/app/access-point/shared/use-access-point-actions'
import { emojiCatalogOptions } from '@/app/components/base/icon-picker/emoji-data'
import { consoleQuery } from '@/service/console'
import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
import { SettingsDialog } from '../index'
const { saveSite, fetchApp, notify } = vi.hoisted(() => ({
saveSite: vi.fn(),
fetchApp: vi.fn(),
notify: vi.fn(),
}))
const saveSite = vi.fn()
vi.mock('@/app/notifications', () => ({
toast: Object.assign(notify, { success: notify, error: notify, warning: notify, info: notify }),
}))
vi.mock('@/next/navigation', () => ({ useParams: () => ({}) }))
const settingsTitle = 'appOverview.overview.appInfo.settings.title'
@@ -46,14 +37,13 @@ function createApp(title = 'Original app'): AppDetailWithSite {
function SettingsOwner() {
const { data: appInfo } = useQuery(appQuery)
const { saveSiteConfig } = useAccessPointActions('settings-app', true)
return (
<SettingsDialog
isChat
appInfo={
appInfo?.site ? { id: appInfo.id, mode: appInfo.mode, site: appInfo.site } : undefined
}
onSave={saveSiteConfig}
onSave={saveSite}
/>
)
}
@@ -65,13 +55,6 @@ async function renderSettings() {
client.setQueryData(appQuery.queryKey, createApp())
seedSystemFeatures(client, { deployment_edition: 'COMMUNITY' })
seedFeatures(client, { webapp_copyright_enabled: true })
client.setQueryData(emojiCatalogOptions.queryKey, [
{
id: 'faces',
label: 'Faces',
items: [{ emoji: '😎', label: 'Cool face', version: 1 }],
},
])
const screen = await render(
<QueryClientTestProvider queryClient={client}>
<NuqsTestingAdapter>
@@ -82,108 +65,11 @@ async function renderSettings() {
return { screen, client }
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const appId = /\/apps\/([^/]+)/.exec(new URL(request.url).pathname)?.[1]
if (request.method === 'GET')
return Response.json(await fetchApp({ params: { app_id: appId } }))
return Response.json(
await saveSite({
params: { app_id: appId },
body: await request.json(),
}),
)
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('retains a failed save draft and closes a successful retry before the background refresh finishes', async () => {
let rejectSave!: (error: Error) => void
saveSite.mockReturnValueOnce(
new Promise((_, reject) => {
rejectSave = reject
}),
)
saveSite.mockResolvedValueOnce({})
let finishRefresh!: (value: AppDetailWithSite) => void
fetchApp.mockReturnValueOnce(
new Promise<AppDetailWithSite>((resolve) => {
finishRefresh = resolve
}),
)
const { screen, client } = await renderSettings()
const trigger = screen.getByRole('button', { name: triggerLabel })
await trigger.click()
const dialog = screen.getByRole('dialog', { name: settingsTitle })
const title = dialog.getByRole('textbox', { name: titleLabel })
await title.fill('Saved after retry')
const save = dialog.getByRole('button', { name: 'common.operation.save' })
await save.click()
await expect.element(save).toHaveFocus()
await expect.element(save).toHaveAttribute('aria-disabled', 'true')
await expect.element(title).toHaveAttribute('readonly')
await expect
.element(dialog.getByRole('button', { name: 'common.operation.close' }))
.toBeDisabled()
await expect
.element(dialog.getByRole('button', { name: 'common.operation.cancel' }))
.toBeDisabled()
await userEvent.keyboard('{Enter}{Escape}')
await expect.element(dialog).toBeVisible()
expect(saveSite).toHaveBeenCalledTimes(1)
expect(fetchApp).not.toHaveBeenCalled()
rejectSave(new Error('Save failed'))
await expect.element(save).not.toHaveAttribute('aria-disabled', 'true')
await expect.element(title).not.toHaveAttribute('readonly')
await expect.element(title).toHaveValue('Saved after retry')
await expect.element(dialog).toBeVisible()
await save.click()
await expect.poll(() => fetchApp.mock.calls.length).toBe(1)
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
expect(saveSite).toHaveBeenCalledTimes(2)
expect(client.getQueryData(appQuery.queryKey)?.site?.title).toBe('Original app')
const refreshed = createApp('Saved after retry')
finishRefresh(refreshed)
await expect.poll(() => client.getQueryData(appQuery.queryKey)).toEqual(refreshed)
await trigger.click()
await expect.element(title).toHaveValue('Saved after retry')
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
await expect.element(dialog).not.toBeInTheDocument()
expect(saveSite).toHaveBeenLastCalledWith({
params: { app_id: 'settings-app' },
body: expect.objectContaining({ title: 'Saved after retry' }),
})
client.clear()
})
it('closes after a successful POST even when the background detail refresh fails', async () => {
saveSite.mockResolvedValueOnce({})
fetchApp.mockRejectedValueOnce(new Error('Refresh failed'))
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { screen, client } = await renderSettings()
const trigger = screen.getByRole('button', { name: triggerLabel })
await trigger.click()
const dialog = screen.getByRole('dialog', { name: settingsTitle })
await dialog.getByRole('textbox', { name: titleLabel }).fill('Saved remotely')
await dialog.getByRole('button', { name: 'common.operation.save' }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
await expect.poll(() => fetchApp.mock.calls.length).toBe(1)
expect(notify).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedSuccessfully', {
type: 'success',
})
expect(saveSite).toHaveBeenCalledTimes(1)
client.clear()
consoleError.mockRestore()
})
it('keeps the draft through the exit animation and reopens with the latest owner metadata', async () => {
it('keeps the draft through the exit animation', async () => {
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
const { screen, client } = await renderSettings()
const trigger = screen.getByRole('button', { name: triggerLabel })
@@ -209,52 +95,6 @@ it('keeps the draft through the exit animation and reopens with the latest owner
expect(closing.value).toBe('Cancelled draft')
expect(closing.opacity).toBeGreaterThan(0)
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
client.setQueryData(appQuery.queryKey, createApp('Fresh metadata'))
await userEvent.keyboard(' ')
await expect.element(title).toHaveValue('Fresh metadata')
await title.fill('Another local draft')
client.setQueryData(appQuery.queryKey, createApp('Changed while open'))
await expect.element(title).toHaveValue('Changed while open')
await expect
.poll(() => {
const close = dialog.getByRole('button', { name: 'common.operation.close' }).element()
return document.activeElement === title.element() || document.activeElement === close
? 'settings control'
: document.activeElement?.tagName
})
.toBe('settings control')
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
expect(saveSite).not.toHaveBeenCalled()
client.clear()
})
it('keeps icon search and confirmation inside the nested picker without submitting Settings', async () => {
const { screen, client } = await renderSettings()
await screen.getByRole('button', { name: triggerLabel }).click()
const settings = screen.getByRole('dialog', { name: settingsTitle })
const title = settings.getByRole('textbox', { name: titleLabel })
await title.fill('Keep this draft')
const iconTrigger = settings.getByRole('button', { name: 'app.iconPicker.title' })
await iconTrigger.click()
const picker = screen.getByRole('dialog', { name: 'app.iconPicker.title' })
await picker.getByRole('combobox', { name: 'app.iconPicker.search' }).fill('Cool')
await userEvent.keyboard('{ArrowDown}{Enter}')
expect(saveSite).not.toHaveBeenCalled()
await picker.getByRole('button', { name: 'app.iconPicker.ok' }).click()
await expect.element(picker).not.toBeInTheDocument()
await expect.element(settings).toBeVisible()
await expect.element(iconTrigger).toHaveFocus()
await expect.element(title).toHaveValue('Keep this draft')
expect(saveSite).not.toHaveBeenCalled()
await userEvent.keyboard('{Enter}')
await expect.element(picker).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(picker).not.toBeInTheDocument()
await expect.element(settings).toBeVisible()
await expect.element(iconTrigger).toHaveFocus()
expect(saveSite).not.toHaveBeenCalled()
client.clear()
})
@@ -78,11 +78,6 @@ it('keeps desktop keyboard focus visible across sidebar states and conversation
const more = screen.getByRole('button', { name: 'common.operation.more' }).first()
await expect.element(more).toHaveFocus()
expect(getComputedStyle(more.element()).opacity).toBe('1')
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('menuitem', { name: 'explore.sidebar.action.pin' }))
.toBeVisible()
await userEvent.keyboard('{Escape}')
await userEvent.tab()
await userEvent.tab()
await expect.element(chat).toHaveFocus()
@@ -101,7 +96,7 @@ it('keeps desktop keyboard focus visible across sidebar states and conversation
expect(collapse.element().checkVisibility({ checkOpacity: true })).toBe(true)
})
it('keeps the floating sidebar available through its portalled menu and closes after focus leaves', async () => {
it('keeps the floating sidebar available while its menu or focus is inside and closes after focus leaves', async () => {
// Pointer leave and Escape cross the real portal boundary; focus must return
// to a visible, interactive trigger instead of a newly inert offscreen panel.
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
@@ -130,41 +125,13 @@ it('keeps the floating sidebar available through its portalled menu and closes a
await expect.element(more).toHaveFocus()
expect(more.element().checkVisibility({ checkOpacity: true })).toBe(true)
expect(more.element().closest('[inert]')).toBeNull()
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('menuitem', { name: 'explore.sidebar.action.pin' }))
.toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(more).toHaveFocus()
await screen.getByRole('button', { name: 'Outside sidebar' }).click()
await expect.poll(() => conversation.element().closest('[inert]')).not.toBeNull()
})
it('closes the floating sidebar when both the pointer and keyboard focus leave it', async () => {
// Native pointer leave precedes blur here. Tab between sidebar controls must
// keep the panel usable, but moving focus outside must make its actions inert.
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const screen = await render(
<QueryClientProvider client={client}>
<section aria-label="Chat preview" className="h-150 w-240">
<button type="button">Outside sidebar</button>
<ChatWithHistory />
</section>
</QueryClientProvider>,
)
await screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }).click()
await screen.getByRole('region', { name: 'Chat preview' }).hover({ position: { x: 2, y: 150 } })
const conversation = screen.getByRole('button', { name: 'Saved conversation' }).last()
await conversation.click()
await screen.getByRole('button', { name: 'Outside sidebar' }).hover()
await expect.element(conversation).toHaveFocus()
await userEvent.tab()
const more = screen.getByRole('button', { name: 'common.operation.more' }).nth(2)
await expect.element(more).toHaveFocus()
// The pointer has already left: Tab between sidebar controls must keep the
// panel usable, but moving focus outside must make its actions inert.
await userEvent.tab({ shift: true })
await expect.element(conversation).toHaveFocus()
await userEvent.tab()
await expect.element(more).toHaveFocus()
const outside = screen.getByRole('button', { name: 'Outside sidebar' })
await outside.click()
await expect.element(outside).toHaveFocus()
@@ -121,8 +121,6 @@ describe('Feedback dialog focus', () => {
// Browser-owned contract: CSS hover visibility must not make the return target unfocusable.
it.each([
{ admin: false, close: 'Escape' },
{ admin: true, close: 'cancel' },
{ admin: false, close: 'submit' },
{ admin: true, close: 'submit' },
])(
'returns to the visible feedback button after $close (admin: $admin)',
@@ -144,89 +142,12 @@ describe('Feedback dialog focus', () => {
await expect.element(dialog).toBeVisible()
await dialog.getByRole('textbox').hover()
if (close === 'Escape') await userEvent.keyboard('{Escape}')
else if (close === 'submit')
await dialog.getByRole('button', { name: 'common.operation.submit' }).click()
else await dialog.getByRole('button', { name: 'common.operation.cancel' }).click()
else await dialog.getByRole('button', { name: 'common.operation.submit' }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toBeVisible()
await expect.element(trigger).toHaveFocus()
expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
if (close === 'submit') {
await expect.element(trigger).toHaveAttribute('aria-pressed', 'true')
expect(feedback.onFeedback).toHaveBeenCalledWith('answer', {
rating: 'dislike',
content: '',
})
}
},
)
})
it('keeps the pending feedback focused and retries the draft after a failure', async () => {
let rejectFeedback!: (reason: Error) => void
feedback.onFeedback
.mockReset()
.mockReturnValueOnce(
new Promise<void>((_resolve, reject) => {
rejectFeedback = reject
}),
)
.mockResolvedValue(undefined)
const screen = await render(<FeedbackAnswer />)
await screen.getByRole('article', { name: 'Answer' }).hover()
const trigger = screen.getByRole('button', {
name: 'appLog.table.header.userRate: appLog.detail.operation.dislike',
})
await trigger.click()
const dialog = screen.getByRole('dialog', { name: 'common.feedback.title' })
const textbox = dialog.getByRole('textbox', { name: 'common.feedback.content' })
await textbox.fill('Please explain')
await userEvent.keyboard('{End}{Enter}the answer')
const draft = 'Please explain\nthe answer'
await expect.element(textbox).toHaveValue(draft)
expect(feedback.onFeedback).not.toHaveBeenCalled()
const submit = dialog.getByRole('button', { name: 'common.operation.submit' })
await submit.click()
await expect.element(submit).toHaveFocus()
await expect.element(submit).toHaveAttribute('aria-disabled', 'true')
await expect.element(textbox).toHaveAttribute('readonly')
await expect
.element(dialog.getByRole('button', { name: 'common.operation.close' }))
.toBeDisabled()
await expect
.element(dialog.getByRole('button', { name: 'common.operation.cancel' }))
.toBeDisabled()
await userEvent.keyboard('{Enter}{Enter}{Escape}')
await expect.element(dialog).toBeVisible()
expect(feedback.onFeedback).toHaveBeenCalledTimes(1)
expect(feedback.onFeedback).toHaveBeenCalledWith('answer', {
rating: 'dislike',
content: draft,
})
const outside = document.elementFromPoint(4, 4)!
await userEvent.click(outside, { position: { x: 4, y: 4 } })
await expect.element(dialog).toBeVisible()
await userEvent.tab()
await expect.poll(() => dialog.element().contains(document.activeElement)).toBe(true)
await textbox.click()
await userEvent.keyboard('ignored')
await expect.element(textbox).toHaveValue(draft)
rejectFeedback(new Error('Feedback request failed'))
await expect.element(textbox).not.toHaveAttribute('readonly')
await expect.element(submit).not.toHaveAttribute('aria-disabled', 'true')
await expect.element(dialog).toBeVisible()
await expect.element(textbox).toHaveValue(draft)
await submit.click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
await expect.element(trigger).toHaveAttribute('aria-pressed', 'true')
expect(feedback.onFeedback).toHaveBeenCalledTimes(2)
expect(feedback.onFeedback).toHaveBeenLastCalledWith('answer', {
rating: 'dislike',
content: draft,
})
})
@@ -298,6 +298,7 @@ describe('Operation', () => {
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: 'operation.cancel' }))
await user.click(screen.getByRole('button', { name: 'operation.close' }))
await user.click(document.body)
await user.click(submit)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(onFeedback).toHaveBeenCalledTimes(1)
@@ -1,24 +0,0 @@
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { CopyFeedback } from '..'
it('keeps the copied tooltip visible after clicking the copy button', async () => {
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
try {
const screen = await render(<CopyFeedback content="test content" />)
const copyButton = screen.getByRole('button', { name: 'common.operation.copy' })
await copyButton.hover()
await expect.element(page.getByText('common.operation.copy')).toBeVisible()
await copyButton.click()
await expect.element(page.getByText('common.operation.copied')).toBeVisible()
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toBeVisible()
expect(writeText).toHaveBeenCalledExactlyOnceWith('test content')
} finally {
writeText.mockRestore()
}
})
@@ -27,6 +27,18 @@ describe('CopyFeedback', () => {
expect(mockCopy).toHaveBeenCalledWith('test content')
})
it('keeps the tooltip open after the action is clicked', async () => {
const user = userEvent.setup()
render(<CopyFeedback content="test content" />)
const button = screen.getByRole('button', { name: 'common.operation.copy' })
await user.hover(button)
expect(await screen.findByText('common.operation.copy')).toBeInTheDocument()
await user.click(button)
expect(screen.getByText('common.operation.copy')).toBeInTheDocument()
})
it('announces the copied state through the action name', () => {
mockCopied = true
@@ -46,9 +46,6 @@ describe('Follow-up settings keyboard access', () => {
await expect.element(trigger).toHaveFocus()
expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard('{Enter}')
await expect
.element(dialog.getByRole('radio', { name: /modal.defaultPromptOption/ }))
.toBeChecked()
await dialog.getByRole('button', { name: 'common.operation.save' }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
@@ -1,110 +1,28 @@
import type { ReactNode } from 'react'
import type { FileEntity } from '../../types'
import { useEffect, useState } from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { TransferMethod } from '@/types/app'
import { FileItem } from '../file-item'
const pdfEngine = vi.hoisted(() => ({ ready: Promise.resolve() }))
// Keep the preview, zoom controls and lazy boundary real; replace only PDF decoding.
vi.mock('../../pdf-highlighter-adapter', () => ({
PdfLoader: ({
children,
beforeLoad,
}: {
children: (document: object) => ReactNode
beforeLoad: ReactNode
}) => {
const [loaded, setLoaded] = useState(false)
useEffect(() => {
let active = true
void pdfEngine.ready.then(() => {
if (active) setLoaded(true)
})
return () => {
active = false
}
}, [])
return loaded ? children({}) : beforeLoad
},
PdfHighlighter: () => (
<div role="document" aria-label="PDF page" style={{ width: 200, height: 300 }}>
A PDF page
</div>
),
}))
function createFile(overrides: Partial<FileEntity> = {}): FileEntity {
return {
id: 'file-1',
name: 'document.pdf',
size: 2048,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
uploadedId: 'uploaded-1',
url: 'https://example.com/document.pdf',
...overrides,
}
}
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('keeps Close focused through PDF loading, handles zoom keys there and starts a fresh preview', async () => {
let resolvePdf!: () => void
pdfEngine.ready = new Promise<void>((resolve) => {
resolvePdf = resolve
})
const screen = await render(<FileItem file={createFile()} canPreview />)
const filename = screen.getByRole('button', { name: 'document.pdf' })
await userEvent.tab()
await expect.element(filename).toHaveFocus()
await userEvent.keyboard('{Enter}')
const dialog = screen.getByRole('dialog', { name: 'document.pdf' })
const close = dialog.getByRole('button', { name: 'common.operation.close' })
await expect.element(close).toHaveFocus()
const initialClose = close.element()
const page = dialog.getByRole('document', { name: 'PDF page' })
await expect.element(page).not.toBeInTheDocument()
resolvePdf()
await expect.element(page).toBeVisible()
expect(close.element()).toBe(initialClose)
await expect.element(close).toHaveFocus()
await expect.poll(() => Math.round(page.element().getBoundingClientRect().width)).toBe(200)
await userEvent.keyboard('{ArrowUp}')
await expect.poll(() => Math.round(page.element().getBoundingClientRect().width)).toBe(240)
await userEvent.keyboard('{ArrowDown}')
await expect.poll(() => Math.round(page.element().getBoundingClientRect().width)).toBe(200)
await userEvent.keyboard('{ArrowUp}{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(filename).toHaveFocus()
await userEvent.keyboard(' ')
await expect.element(close).toHaveFocus()
await expect.element(page).toBeVisible()
await expect.poll(() => Math.round(page.element().getBoundingClientRect().width)).toBe(200)
await close.click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(filename).toHaveFocus()
})
it('retains the local media resource during the real exit transition and releases it afterwards', async () => {
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
const createUrl = vi.spyOn(URL, 'createObjectURL')
const revokeUrl = vi.spyOn(URL, 'revokeObjectURL')
const file = createFile({
const file: FileEntity = {
id: 'file-1',
name: 'recording.wav',
size: 2048,
type: 'audio/wav',
url: undefined,
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
uploadedId: 'uploaded-1',
originalFile: new File(['recording'], 'recording.wav', { type: 'audio/wav' }),
})
}
const screen = await render(<FileItem file={file} canPreview />)
const filename = screen.getByRole('button', { name: 'recording.wav' })
expect(createUrl).not.toHaveBeenCalled()
@@ -133,5 +51,4 @@ it('retains the local media resource during the real exit transition and release
expect(closing.revoked).toBe(false)
await expect.element(dialog).not.toBeInTheDocument()
await expect.poll(() => revokeUrl.mock.calls.some(([url]) => url === resource)).toBe(true)
await expect.element(filename).toHaveFocus()
})
@@ -105,38 +105,6 @@ it('ignores crop completion during the closing animation while Cancel remains av
client.clear()
})
it('ignores a preview read that completes after cancellation and a new session opens', async () => {
const { client, trigger, dialog, save, file, onConfirm } = await openImageSession()
cropImage.mockResolvedValueOnce(file)
const originalRead = FileReader.prototype.readAsDataURL
let finishRead!: () => Promise<void>
const read = vi.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementationOnce(function (
this: FileReader,
blob: Blob,
) {
finishRead = () =>
new Promise<void>((resolve) => {
this.addEventListener('loadend', () => resolve(), { once: true })
originalRead.call(this, blob)
})
})
await save.click()
await expect.poll(() => read.mock.calls.length).toBe(1)
await dialog.getByRole('button', { name: 'app.iconPicker.cancel' }).click()
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard('{Enter}')
const search = dialog.getByRole('combobox', { name: 'app.iconPicker.search' })
await expect.element(search).toHaveFocus()
await search.fill('new session')
await finishRead()
await userEvent.keyboard('{End}')
expect(uploadImage).not.toHaveBeenCalled()
expect(onConfirm).not.toHaveBeenCalled()
await expect.element(dialog).toBeVisible()
await expect.element(search).toHaveValue('new session')
client.clear()
})
it('ignores an old upload when reopened as soon as keyboard focus returns', async () => {
let finishUpload!: (response: PostFilesUploadResponse) => void
const pendingUpload = new Promise<PostFilesUploadResponse>((resolve) => {
@@ -102,6 +102,13 @@ it('opens the file input directly and preserves the existing image on cancellati
</QueryClientProvider>,
)
await user.click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
expect(screen.getByRole('tab', { name: 'app.iconPicker.image' })).toHaveAttribute(
'aria-selected',
'true',
)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'common.operation.change' })).toHaveFocus(),
)
const input = screen.getByTestId('image-input')
const openFilePicker = vi.spyOn(input, 'click')
await user.click(screen.getByRole('button', { name: 'common.operation.change' }))
@@ -1,15 +1,5 @@
import type { EmojiIcon } from '..'
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@langgenius/dify-ui/dialog'
import {
Drawer,
DrawerBackdrop,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { userEvent } from 'vite-plus/test/browser'
@@ -38,107 +28,76 @@ function createClient() {
return client
}
function NestedOwner({ kind, onSubmit }: { kind: 'dialog' | 'drawer'; onSubmit: () => void }) {
function NestedOwner({ onSubmit }: { onSubmit: () => void }) {
const [value, setValue] = useState(initial)
const content = (
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
>
<label>
Owner name
<input defaultValue="Untouched" />
</label>
<IconPickerDialog
aria-label="Choose icon"
value={value}
enableImageUpload={false}
onConfirm={(next) => {
if (next.type === 'emoji') setValue(next)
}}
/>
<output aria-label="Saved icon">{value.icon}</output>
<button type="submit">Save owner</button>
</form>
)
if (kind === 'drawer') {
return (
<Drawer swipeDirection="right">
<DrawerTrigger>Open owner</DrawerTrigger>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup>
<DrawerContent>
<DrawerTitle>Owner</DrawerTitle>
{content}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
return (
<Dialog>
<DialogTrigger>Open owner</DialogTrigger>
<DialogContent>
<DialogTitle>Owner</DialogTitle>
{content}
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
>
<label>
Owner name
<input defaultValue="Untouched" />
</label>
<IconPickerDialog
aria-label="Choose icon"
value={value}
enableImageUpload={false}
onConfirm={(next) => {
if (next.type === 'emoji') setValue(next)
}}
/>
<output aria-label="Saved icon">{value.icon}</output>
<button type="submit">Save owner</button>
</form>
</DialogContent>
</Dialog>
)
}
it.each(['dialog', 'drawer'] as const)(
'confirms and dismisses only the nested picker inside a %s',
async (kind) => {
const client = createClient()
const onSubmit = vi.fn()
const screen = await render(
<QueryClientProvider client={client}>
<NestedOwner kind={kind} onSubmit={onSubmit} />
</QueryClientProvider>,
)
const ownerTrigger = screen.getByRole('button', { name: 'Open owner' })
await ownerTrigger.click()
const owner = screen.getByRole('dialog', { name: 'Owner', exact: true })
if (kind === 'drawer') {
await expect.element(owner).toHaveFocus()
await userEvent.tab()
}
await expect.element(owner.getByRole('textbox', { name: 'Owner name' })).toHaveFocus()
await userEvent.tab()
const trigger = screen.getByRole('button', { name: 'Choose icon' })
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard('{Enter}')
const picker = screen.getByRole('dialog', { name: 'app.iconPicker.title' })
const search = picker.getByRole('combobox', { name: 'app.iconPicker.search' })
await expect.element(search).toHaveFocus()
await search.fill('Cool')
await userEvent.keyboard('{ArrowDown}{Enter}')
expect(onSubmit).not.toHaveBeenCalled()
await picker.getByRole('button', { name: 'app.iconPicker.ok' }).click()
await expect.element(picker).not.toBeInTheDocument()
await expect.element(owner).toBeVisible()
await expect.element(trigger).toHaveFocus()
await expect.element(owner.getByRole('status', { name: 'Saved icon' })).toHaveTextContent('😎')
await expect
.element(owner.getByRole('textbox', { name: 'Owner name' }))
.toHaveValue('Untouched')
expect(onSubmit).not.toHaveBeenCalled()
it('confirms and dismisses only the nested picker inside a dialog', async () => {
const client = createClient()
const onSubmit = vi.fn()
const screen = await render(
<QueryClientProvider client={client}>
<NestedOwner onSubmit={onSubmit} />
</QueryClientProvider>,
)
await screen.getByRole('button', { name: 'Open owner' }).click()
const owner = screen.getByRole('dialog', { name: 'Owner', exact: true })
await expect.element(owner.getByRole('textbox', { name: 'Owner name' })).toHaveFocus()
await userEvent.tab()
const trigger = screen.getByRole('button', { name: 'Choose icon' })
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard('{Enter}')
const picker = screen.getByRole('dialog', { name: 'app.iconPicker.title' })
const search = picker.getByRole('combobox', { name: 'app.iconPicker.search' })
await expect.element(search).toHaveFocus()
await search.fill('Cool')
const candidate = picker.getByRole('gridcell', { name: 'Cool face' })
await expect.element(candidate).toBeVisible()
await userEvent.keyboard('{ArrowDown}')
await expect.element(search).toHaveAttribute('aria-activedescendant', candidate.element().id)
await userEvent.keyboard('{Enter}')
expect(onSubmit).not.toHaveBeenCalled()
await picker.getByRole('button', { name: 'app.iconPicker.ok' }).click()
await expect.element(picker).not.toBeInTheDocument()
await expect.element(owner).toBeVisible()
await expect.element(trigger).toHaveFocus()
await expect.element(owner.getByRole('status', { name: 'Saved icon' })).toHaveTextContent('😎')
await expect.element(owner.getByRole('textbox', { name: 'Owner name' })).toHaveValue('Untouched')
expect(onSubmit).not.toHaveBeenCalled()
await userEvent.keyboard(' ')
await expect.element(picker).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(picker).not.toBeInTheDocument()
await expect.element(owner).toBeVisible()
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard('{Escape}')
await expect.element(owner).not.toBeInTheDocument()
await expect.element(ownerTrigger).toHaveFocus()
client.clear()
},
)
await userEvent.keyboard(' ')
await expect.element(picker).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(picker).not.toBeInTheDocument()
await expect.element(owner).toBeVisible()
client.clear()
})
@@ -87,28 +87,15 @@ it('navigates a visible candidate after filtering, confirms with Enter, and disc
await input.fill('Face')
await expect.poll(() => page.getByRole('gridcell').all().length).toBe(20)
await userEvent.keyboard('{ArrowDown}{ArrowRight}')
const activeId = input.element().getAttribute('aria-activedescendant')!
const active = document.getElementById(activeId)!
expect(active).not.toBeNull()
const style = getComputedStyle(active)
const inactive = page
.getByRole('gridcell')
.all()
.find((cell) => cell.element() !== active)!
expect(style.backgroundColor).not.toBe(getComputedStyle(inactive.element()).backgroundColor)
const keyboardBackground = style.backgroundColor
await inactive.hover()
await expect.element(input).toHaveAttribute('aria-activedescendant', inactive.element().id)
expect(getComputedStyle(inactive.element()).backgroundColor).toBe(keyboardBackground)
expect(document.querySelectorAll('[role="gridcell"][data-highlighted]')).toHaveLength(1)
const first = page.getByRole('gridcell').first()
await expect.element(input).not.toHaveAttribute('aria-activedescendant', first.element().id)
await first.hover()
await expect.element(input).toHaveAttribute('aria-activedescendant', first.element().id)
await userEvent.keyboard('{ArrowDown}')
const keyboardCandidate = document.getElementById(
await expect.element(input).not.toHaveAttribute('aria-activedescendant', first.element().id)
const chosen = document.getElementById(
input.element().getAttribute('aria-activedescendant')!,
)!
expect(keyboardCandidate).not.toBe(inactive.element())
expect(getComputedStyle(keyboardCandidate).backgroundColor).toBe(keyboardBackground)
expect(getComputedStyle(inactive.element()).backgroundColor).toBe('rgba(0, 0, 0, 0)')
const chosen = keyboardCandidate.textContent!
)!.textContent!
await userEvent.keyboard('{Enter}')
await expect.element(input).toHaveValue('Face')
const green = page.getByRole('radio', { name: 'app.iconPicker.color.green' })
@@ -147,73 +134,7 @@ it('navigates a visible candidate after filtering, confirms with Enter, and disc
client.clear()
})
it('focuses the visible image action when reopening an existing image icon', async () => {
const client = new QueryClient()
client.setQueryData(emojiCatalogOptions.queryKey, [])
await render(
<QueryClientProvider client={client}>
<IconPickerDialog
aria-label="Choose image"
value={{
type: 'image',
fileId: 'image-id',
url: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
}}
onConfirm={() => {}}
/>
</QueryClientProvider>,
)
await page.getByRole('button', { name: 'Choose image' }).click()
await expect
.element(page.getByRole('tab', { name: 'app.iconPicker.image' }))
.toHaveAttribute('aria-selected', 'true')
await expect.element(page.getByRole('button', { name: 'common.operation.change' })).toHaveFocus()
client.clear()
})
it.each(['light', 'dark'])(
'distinguishes keyboard focus from the selected color in %s mode',
async (theme) => {
document.documentElement.dataset.theme = theme
const client = new QueryClient()
client.setQueryData(emojiCatalogOptions.queryKey, [])
try {
await render(
<QueryClientProvider client={client}>
<Harness />
</QueryClientProvider>,
)
await page.getByRole('button', { name: 'Choose', exact: true }).click()
const red = page.getByRole('radio', { name: 'app.iconPicker.color.red' })
const rose = page.getByRole('radio', { name: 'app.iconPicker.color.rose' })
await expect.element(red).toBeChecked()
expect(getComputedStyle(red.element()).outlineStyle).toBe('none')
await userEvent.tab()
await expect.element(red).toHaveFocus()
expect(getComputedStyle(red.element()).outlineStyle).toBe('solid')
expect(Number.parseFloat(getComputedStyle(red.element()).outlineWidth)).toBeGreaterThan(0)
await userEvent.keyboard('{ArrowRight}')
await expect.element(rose).toHaveFocus()
await expect.element(rose).toBeChecked()
expect(getComputedStyle(rose.element()).outlineStyle).toBe('solid')
expect(getComputedStyle(red.element()).outlineStyle).toBe('none')
await userEvent.tab()
await expect
.element(page.getByRole('button', { name: 'app.iconPicker.tryYourLuck' }))
.toHaveFocus()
await expect.element(rose).toBeChecked()
expect(getComputedStyle(rose.element()).outlineStyle).toBe('none')
} finally {
document.documentElement.dataset.theme = 'light'
client.clear()
}
},
)
it('discards the cancelled draft when reopened immediately from the keyboard', async () => {
it('distinguishes keyboard focus from the selected color', async () => {
const client = new QueryClient()
client.setQueryData(emojiCatalogOptions.queryKey, [])
await render(
@@ -222,10 +143,28 @@ it('discards the cancelled draft when reopened immediately from the keyboard', a
</QueryClientProvider>,
)
await page.getByRole('button', { name: 'Choose', exact: true }).click()
await page.getByRole('radio', { name: 'app.iconPicker.color.green' }).click()
await userEvent.keyboard('{Escape}{Enter}')
await expect.element(page.getByRole('dialog', { name: 'app.iconPicker.title' })).toBeVisible()
await expect.element(page.getByRole('radio', { name: 'app.iconPicker.color.red' })).toBeChecked()
const red = page.getByRole('radio', { name: 'app.iconPicker.color.red' })
const rose = page.getByRole('radio', { name: 'app.iconPicker.color.rose' })
await expect.element(red).toBeChecked()
expect(getComputedStyle(red.element()).outlineStyle).toBe('none')
await userEvent.tab()
await expect.element(red).toHaveFocus()
expect(getComputedStyle(red.element()).outlineStyle).toBe('solid')
expect(Number.parseFloat(getComputedStyle(red.element()).outlineWidth)).toBeGreaterThan(0)
await userEvent.keyboard('{ArrowRight}')
await expect.element(rose).toHaveFocus()
await expect.element(rose).toBeChecked()
expect(getComputedStyle(rose.element()).outlineStyle).toBe('solid')
expect(getComputedStyle(red.element()).outlineStyle).toBe('none')
await userEvent.tab()
await expect
.element(page.getByRole('button', { name: 'app.iconPicker.tryYourLuck' }))
.toHaveFocus()
await expect.element(rose).toBeChecked()
expect(getComputedStyle(rose.element()).outlineStyle).toBe('none')
client.clear()
})
@@ -259,19 +198,14 @@ it('locks image editing during submission and restores it after failure', async
cropElement.focus()
await expect.element(crop).toHaveFocus()
const zoom = page.getByRole('slider', { name: 'app.iconPicker.zoom' })
const change = page.getByRole('button', { name: 'common.operation.change' })
await page.getByRole('button', { name: 'app.iconPicker.ok' }).click()
await expect.poll(() => uploadImage.mock.calls.length).toBe(1)
await expect.element(zoom).toBeDisabled()
await expect.element(change).toBeDisabled()
cropElement.focus()
expect(document.activeElement).not.toBe(cropElement)
const cancel = page.getByRole('button', { name: 'app.iconPicker.cancel' })
await expect.element(cancel).toBeEnabled()
rejectUpload(new Error('Upload failed'))
await expect.element(page.getByRole('alert')).toBeVisible()
await expect.element(zoom).toBeEnabled()
await expect.element(change).toBeEnabled()
cropElement.focus()
await expect.element(crop).toHaveFocus()
await userEvent.tab()
@@ -3,27 +3,18 @@ import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
const save = vi.fn()
function Options() {
const [options, setOptions] = useState(['Alpha', 'Beta', 'Gamma'])
return (
<div style={{ width: 400, padding: 32 }}>
<button type="button">Before options</button>
<ConfigSelect
options={options}
onChange={(next) => {
save(next)
setOptions(next)
}}
/>
<ConfigSelect options={options} onChange={setOptions} />
</div>
)
}
it('keeps native focus on reordered options and preserves pointer sorting', async () => {
// Browser-owned: native Tab, focus after React moves DOM nodes, and SortableJS pointer hit testing.
save.mockClear()
await page.viewport(800, 600)
const screen = await render(<Options />)
const before = screen.getByRole('button', { name: 'Before options' })
@@ -40,22 +31,17 @@ it('keeps native focus on reordered options and preserves pointer sorting', asyn
await expect.element(alpha).toHaveFocus()
const focusStyle = getComputedStyle(alpha.element())
expect(focusStyle.boxShadow !== 'none' || focusStyle.outlineStyle !== 'none').toBe(true)
await userEvent.keyboard(' {ArrowDown}')
expect(values()).toEqual(['Beta', 'Alpha', 'Gamma'])
await userEvent.keyboard(' ')
await expect.element(alpha).toHaveAttribute('aria-pressed', 'true')
await userEvent.keyboard('{ArrowDown}')
await expect.poll(values).toEqual(['Beta', 'Alpha', 'Gamma'])
await expect.element(alpha).toHaveFocus()
expect(alpha.element().getBoundingClientRect().top).toBeGreaterThan(
beta.element().getBoundingClientRect().top,
)
expect(save).not.toHaveBeenCalled()
await userEvent.keyboard('{Escape}')
expect(values()).toEqual(['Alpha', 'Beta', 'Gamma'])
await userEvent.keyboard('{Enter}')
await expect.element(alpha).toHaveAttribute('aria-pressed', 'false')
await expect.element(alpha).toHaveFocus()
await userEvent.keyboard('{Enter}{ArrowDown}{Enter}')
await expect.element(alpha).toHaveFocus()
expect(save).toHaveBeenCalledExactlyOnceWith(['Beta', 'Alpha', 'Gamma'])
await userEvent.tab()
await userEvent.keyboard('{ArrowUp}')
expect(save).toHaveBeenCalledTimes(1)
await userEvent.dragAndDrop(alpha, gamma, { targetPosition: { x: 12, y: 23 } })
await expect.poll(values).toEqual(['Beta', 'Gamma', 'Alpha'])
})
@@ -95,32 +95,4 @@ describe('Template card focus', () => {
await expect.element(details).toHaveFocus()
expect(details.element().checkVisibility({ checkOpacity: true })).toBe(true)
})
it('keeps close focused when template details finish loading and returns focus to the trigger', async () => {
const queryClient = new QueryClient()
const screen = await render(<TemplateCardFixture queryClient={queryClient} />)
await screen.getByRole('button', { name: 'Before template' }).click()
await userEvent.tab()
await userEvent.tab()
const details = screen.getByRole('button', { name: 'datasetPipeline.operations.details' })
await expect.element(details).toHaveFocus()
await userEvent.keyboard('{Enter}')
const dialog = screen.getByRole('dialog', { name: pipeline.name })
await expect.element(dialog).toBeVisible()
const close = dialog.getByRole('button', { name: 'common.operation.close' })
await expect.element(close).toHaveFocus()
await expect
.element(dialog.getByRole('button', { name: 'datasetPipeline.operations.useTemplate' }))
.not.toBeInTheDocument()
queryClient.setQueryData(templateQueryOptions.queryKey, templateInfo)
await expect
.element(dialog.getByRole('button', { name: 'datasetPipeline.operations.useTemplate' }))
.toBeVisible()
await expect.element(close).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(details).toHaveFocus()
expect(details.element().checkVisibility({ checkOpacity: true })).toBe(true)
})
})
@@ -4,20 +4,6 @@ import { render } from 'vitest-browser-react'
import { ResultItem } from '../result-item'
import { ResultItemExternal } from '../result-item-external'
const { copy } = vi.hoisted(() => ({ copy: vi.fn() }))
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({ copy, copied: false, reset: vi.fn() }),
}))
// Only syntax highlighting is replaced; Markdown and its copy control stay real.
vi.mock('@/app/components/base/markdown-blocks/shiki-highlight', () => ({
highlightCode: async ({ code }: { code: string }) => (
<pre>
<code>{code}</code>
</pre>
),
}))
function createResult(overrides: Partial<HitTesting> = {}): HitTesting {
const segment: HitTesting['segment'] = {
id: 'segment-1',
@@ -48,11 +34,6 @@ function createResult(overrides: Partial<HitTesting> = {}): HitTesting {
}
}
function withContent(content: string): HitTesting {
const result = createResult()
return { ...result, segment: { ...result.segment, content } }
}
function createImageUrl() {
const canvas = document.createElement('canvas')
canvas.width = 80
@@ -79,94 +60,7 @@ beforeEach(async () => {
await page.viewport(1280, 900)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('opens internal details from metadata and footer, preserves fold state and returns focus after exit', async () => {
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
const screen = await render(
<ResultItem
payload={createResult({
child_chunks: [
{ id: 'child-1', position: 1, score: 0.8, content: 'Matched child content' },
],
})}
/>,
)
const footer = screen.getByRole('button', { name: 'datasetHitTesting.open handbook.pdf' })
const fold = screen.getByRole('button', { name: 'datasetHitTesting.hitChunks:{"num":1}' })
const dialog = screen.getByRole('dialog', { name: 'datasetHitTesting.chunkDetail' })
await userEvent.tab()
await expect.element(fold).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(fold).toHaveAttribute('aria-expanded', 'false')
await expect.element(screen.getByText('Matched child content')).not.toBeInTheDocument()
await expect.element(dialog).not.toBeInTheDocument()
await screen.getByText('Parent-Chunk-01').click()
const close = dialog.getByRole('button', { name: 'common.operation.close' })
await expect.element(close).toHaveFocus()
await expect.element(dialog.getByText('Retrieval preview content')).toBeVisible()
const popup = dialog.element()
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
const exit = observeExit(popup)
await close.click()
const closing = await exit
expect(closing.opacity).toBeGreaterThan(0)
expect(closing.content).toContain('Retrieval preview content')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(footer).toHaveFocus()
await expect.element(fold).toHaveAttribute('aria-expanded', 'false')
await userEvent.keyboard('{Enter}')
await expect.element(close).toHaveFocus()
await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(footer).toHaveFocus()
await userEvent.tab({ shift: true })
await userEvent.keyboard(' ')
await expect.element(fold).toHaveAttribute('aria-expanded', 'true')
await expect.element(screen.getByText('Matched child content')).toBeVisible()
await expect.element(dialog).not.toBeInTheDocument()
})
it('leaves real Markdown link, code copy and photo actions independent of parent details', async () => {
const view = (content: string) => (
<div className="chat-answer-container">
<ResultItem payload={withContent(content)} />
<h2 id="reference">Reference section</h2>
</div>
)
const screen = await render(view('[Read reference](#reference)'))
const detail = screen.getByRole('dialog', { name: 'datasetHitTesting.chunkDetail' })
const scroll = vi.spyOn(
screen.getByRole('heading', { name: 'Reference section' }).element(),
'scrollIntoView',
)
await screen.getByRole('link', { name: 'Read reference' }).click()
expect(scroll).toHaveBeenCalledWith({ behavior: 'smooth' })
await expect.element(detail).not.toBeInTheDocument()
await screen.rerender(view('```text\nCopied retrieval content\n```'))
await screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }).click()
expect(copy).toHaveBeenCalledWith('Copied retrieval content')
await expect.element(detail).not.toBeInTheDocument()
await screen.rerender(view('![Photo](/logo/logo-site.png)'))
const photo = screen.getByRole('button', {
name: 'common.imageGallery.previewImage:{"index":1,"total":1}',
})
await photo.click()
const preview = screen.getByRole('dialog', { name: 'workflow.common.preview' })
await expect.element(preview).toBeVisible()
await expect.element(detail).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await expect.element(preview).not.toBeInTheDocument()
await expect.element(photo).toHaveFocus()
await expect.element(detail).not.toBeInTheDocument()
})
afterEach(() => vi.unstubAllGlobals())
it('opens attachments independently and nests their preview only while details are open', async () => {
const screen = await render(
@@ -193,23 +87,15 @@ it('opens attachments independently and nests their preview only while details a
await expect.element(detail).not.toBeInTheDocument()
await preview.getByRole('button', { name: 'common.operation.close' }).click()
await expect.element(preview).not.toBeInTheDocument()
await expect.element(thumbnail).toHaveFocus()
await expect.element(detail).not.toBeInTheDocument()
const footer = screen.getByRole('button', { name: 'datasetHitTesting.open handbook.pdf' })
await footer.click()
await screen.getByRole('button', { name: 'datasetHitTesting.open handbook.pdf' }).click()
const nestedThumbnail = detail.getByRole('button', { name: 'diagram.png' })
await nestedThumbnail.click()
await expect.element(preview).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(preview).not.toBeInTheDocument()
await expect.element(detail).toBeVisible()
await expect.element(nestedThumbnail).toHaveFocus()
await userEvent.tab()
await expect.element(detail.getByRole('button', { name: 'common.operation.close' })).toHaveFocus()
await userEvent.keyboard('{Escape}')
await expect.element(detail).not.toBeInTheDocument()
await expect.element(footer).toHaveFocus()
})
it('keeps external details scrollable and Close reachable on a narrow viewport through keyboard and exit', async () => {
@@ -254,10 +140,4 @@ it('keeps external details scrollable and Close reachable on a narrow viewport t
expect(closing.opacity).toBeGreaterThan(0)
expect(closing.content).toContain(payload.content)
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(card).toHaveFocus()
await userEvent.keyboard(' ')
await expect.element(dialog.getByText(payload.content)).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(card).toHaveFocus()
})
@@ -1,118 +0,0 @@
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createStore, Provider } from 'jotai'
import { render } from 'vitest-browser-react'
import { DatasetCardTags } from '@/features/tag-management/components/dataset-card-tags'
import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture'
const tagList: Tag[] = [
{
id: 'knowledge-tag-1',
name: 'Knowledge',
type: 'knowledge',
binding_count: '',
},
]
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
workspacePermissionKeys: ['dataset.tag.manage'],
}))
})
vi.mock('@/service/console', () => ({
consoleQuery: {
tags: {
get: {
queryOptions: () => ({
queryFn: async () => tagList,
queryKey: ['tags', 'knowledge'],
}),
},
post: {
mutationOptions: () => ({
mutationFn: async () => tagList[0]!,
}),
},
},
},
}))
vi.mock('@/features/tag-management/hooks/use-tag-mutations', () => ({
useApplyTagBindingsMutation: () => ({
mutate: vi.fn(),
}),
}))
const renderTagSelector = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const store = createStore()
seedRegisteredConsoleStateFixture(store)
return render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<DatasetCardTags datasetId="dataset-1" embeddingAvailable tags={[]} canBindOrUnbindTags />
</QueryClientProvider>
</Provider>,
)
}
describe('Dataset card tag selector navigation', () => {
it('does not trigger the parent click handler when selecting an existing tag', async () => {
const onOuterClick = vi.fn()
document.addEventListener('click', onOuterClick)
try {
const screen = await renderTagSelector()
await screen.getByRole('combobox', { name: 'common.tag.addTag' }).click()
await screen.getByRole('option', { name: 'Knowledge' }).click()
expect(onOuterClick).not.toHaveBeenCalled()
} finally {
document.removeEventListener('click', onOuterClick)
}
})
it('does not trigger the parent click handler when creating a tag', async () => {
const onOuterClick = vi.fn()
document.addEventListener('click', onOuterClick)
try {
const screen = await renderTagSelector()
await screen.getByRole('combobox', { name: 'common.tag.addTag' }).click()
const input = screen.getByRole('combobox', { name: 'common.tag.selectorPlaceholder' })
await input.fill('NewTag')
await screen.getByRole('option', { name: /NewTag/ }).click()
expect(onOuterClick).not.toHaveBeenCalled()
} finally {
document.removeEventListener('click', onOuterClick)
}
})
it('does not trigger the parent click handler when opening tag management', async () => {
const onOuterClick = vi.fn()
document.addEventListener('click', onOuterClick)
try {
const screen = await renderTagSelector()
await screen.getByRole('combobox', { name: 'common.tag.addTag' }).click()
await screen.getByRole('button', { name: 'common.tag.manageTags' }).click()
expect(onOuterClick).not.toHaveBeenCalled()
} finally {
document.removeEventListener('click', onOuterClick)
}
})
})
@@ -28,72 +28,59 @@ vi.mock('@/features/system-features/client', () => ({
}),
}))
it.each([true, false])(
'keeps operations visible and keyboard accessible with embedding available: %s',
async (embeddingAvailable) => {
// Real CSS and native Tab navigation catch visibility-hidden triggers that happy-dom misses.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
queryClient.setQueryData(userProfileQueryOptions().queryKey, {
profile: {
id: 'user-1',
name: 'Test User',
email: 'test@dify.ai',
avatar: '',
avatar_url: null,
is_password_set: false,
timezone: 'Asia/Shanghai',
},
meta: { currentVersion: null, currentEnv: null },
})
queryClient.setQueryData(
systemFeaturesQueryOptions().queryKey,
createSystemFeaturesFixture({ rbac_enabled: true }),
)
const openRenameModal = vi.fn()
const dataset = {
id: 'dataset-1',
name: 'Test Dataset',
runtime_mode: 'general',
embedding_available: embeddingAvailable,
permission_keys: [DatasetACLPermission.Edit],
} as DataSet
const screen = await render(
<QueryClientProvider client={queryClient}>
<button type="button">Before dataset</button>
<div className="group relative mt-10 h-40 w-80">
<CornerLabels dataset={dataset} />
<OperationsDropdown
dataset={dataset}
openRenameModal={openRenameModal}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
openAccessConfig={vi.fn()}
/>
</div>
<button type="button">After dataset</button>
</QueryClientProvider>,
)
it('keeps operations visible on keyboard focus without covering the unavailable label', async () => {
// Real CSS and native Tab navigation catch visibility-hidden triggers that happy-dom misses.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
queryClient.setQueryData(userProfileQueryOptions().queryKey, {
profile: {
id: 'user-1',
name: 'Test User',
email: 'test@dify.ai',
avatar: '',
avatar_url: null,
is_password_set: false,
timezone: 'Asia/Shanghai',
},
meta: { currentVersion: null, currentEnv: null },
})
queryClient.setQueryData(
systemFeaturesQueryOptions().queryKey,
createSystemFeaturesFixture({ rbac_enabled: true }),
)
const dataset = {
id: 'dataset-1',
name: 'Test Dataset',
runtime_mode: 'general',
embedding_available: false,
permission_keys: [DatasetACLPermission.Edit],
} as DataSet
const screen = await render(
<QueryClientProvider client={queryClient}>
<button type="button">Before dataset</button>
<div className="group relative mt-10 h-40 w-80">
<CornerLabels dataset={dataset} />
<OperationsDropdown
dataset={dataset}
openRenameModal={vi.fn()}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
openAccessConfig={vi.fn()}
/>
</div>
<button type="button">After dataset</button>
</QueryClientProvider>,
)
screen.getByRole('button', { name: 'Before dataset' }).element().focus()
await userEvent.keyboard('{Tab}')
const trigger = screen.getByRole('button', { name: 'Dataset operations' })
await expect.element(trigger).toHaveFocus()
await expect.element(trigger).toBeVisible()
if (!embeddingAvailable) {
const unavailableLabel = screen.getByText('dataset.cornerLabel.unavailable')
await expect.element(unavailableLabel).toBeVisible()
expect(unavailableLabel.element().getBoundingClientRect().bottom).toBeLessThanOrEqual(
trigger.element().getBoundingClientRect().top,
)
}
await userEvent.keyboard('{Enter}')
const edit = screen.getByRole('menuitem', { name: 'common.operation.edit' })
await expect.element(edit).toHaveFocus()
await userEvent.keyboard('{Enter}')
expect(openRenameModal).toHaveBeenCalledTimes(1)
await expect.element(screen.getByRole('menu')).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
},
)
screen.getByRole('button', { name: 'Before dataset' }).element().focus()
await userEvent.keyboard('{Tab}')
const trigger = screen.getByRole('button', { name: 'Dataset operations' })
await expect.element(trigger).toHaveFocus()
await expect.element(trigger).toBeVisible()
const unavailableLabel = screen.getByText('dataset.cornerLabel.unavailable')
await expect.element(unavailableLabel).toBeVisible()
expect(unavailableLabel.element().getBoundingClientRect().bottom).toBeLessThanOrEqual(
trigger.element().getBoundingClientRect().top,
)
})
@@ -135,7 +135,6 @@ const expectTextWithin = (element: Element, container: Element) => {
expect(line.top).toBeGreaterThanOrEqual(bounds.top - 1)
expect(line.bottom).toBeLessThanOrEqual(bounds.bottom + 1)
}
expect(bounds.right).toBeLessThanOrEqual(220)
}
describe('Knowledge settings narrow-screen readability', () => {
@@ -1,176 +0,0 @@
import type { AutoUpdateConfig } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import {
AUTO_UPDATE_MODE,
AUTO_UPDATE_STRATEGY,
} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { UpdateSettingDialog } from '../update-setting-dialog'
const { getSettings, saveSettings } = vi.hoisted(() => ({
getSettings: vi.fn(),
saveSettings: vi.fn(),
}))
vi.mock('@/service/base', () => ({
get: getSettings,
post: saveSettings,
request: vi.fn(),
getPublic: vi.fn(),
getMarketplace: vi.fn(),
postPublic: vi.fn(),
postMarketplace: vi.fn(),
put: vi.fn(),
del: vi.fn(),
delPublic: vi.fn(),
patch: vi.fn(),
patchPublic: vi.fn(),
upload: vi.fn(),
ssePost: vi.fn(),
sseGet: vi.fn(),
sseGeneratorPost: vi.fn(),
handleStream: vi.fn(),
buildSigninUrlWithRedirect: vi.fn(),
isWebAppSigninPath: vi.fn(),
buildWebAppSigninUrlWithRedirect: vi.fn(),
}))
const savedConfig: AutoUpdateConfig = {
strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
include_plugins: [],
exclude_plugins: [],
}
async function renderSettings() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
})
queryClient.setQueryData(userProfileQueryOptions().queryKey, {
profile: {
id: 'user-1',
name: 'Test User',
email: 'test@dify.ai',
avatar: '',
avatar_url: null,
is_password_set: false,
timezone: 'UTC',
},
meta: { currentVersion: null, currentEnv: null },
})
return render(
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<UpdateSettingDialog category={PluginCategoryEnum.model} />
</NuqsTestingAdapter>
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
getSettings.mockImplementation(async (url: string) => {
if (url !== '/workspaces/current/plugin/auto-upgrade/fetch')
throw new Error(`Unexpected request: ${url}`)
return { category: PluginCategoryEnum.model, auto_upgrade: savedConfig }
})
})
afterEach(() => vi.unstubAllGlobals())
it('discards cancelled and escaped drafts without changing the trigger badge and restores focus', async () => {
const screen = await renderSettings()
const trigger = screen.getByRole('button', { name: /^plugin\.autoUpdate\.autoUpdate / })
await expect
.element(trigger)
.toHaveTextContent('plugin.autoUpdate.autoUpdateplugin.autoUpdate.strategy.disabled.name')
const triggerElement = trigger.element()
const dialog = screen.getByRole('dialog', { name: 'plugin.autoUpdate.autoUpdateSettings' })
for (const dismissal of ['Cancel', 'Escape']) {
await trigger.click()
await expect
.element(screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.disabled.name' }))
.toBeChecked()
await screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.latest.name' }).click()
await expect
.element(triggerElement)
.toHaveTextContent('plugin.autoUpdate.autoUpdateplugin.autoUpdate.strategy.disabled.name')
await expect
.element(triggerElement)
.not.toHaveTextContent('plugin.autoUpdate.autoUpdateplugin.autoUpdate.strategy.latest.name')
if (dismissal === 'Cancel')
await screen.getByRole('button', { name: 'common.operation.cancel' }).click()
else await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
}
await trigger.click()
await expect
.element(screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.disabled.name' }))
.toBeChecked()
expect(saveSettings).not.toHaveBeenCalled()
})
it('closes an optimistic save immediately, blocks another pending save and restores rolled-back settings on reopening', async () => {
let rejectSave!: (error: Error) => void
saveSettings.mockReturnValue(
new Promise((_, reject) => {
rejectSave = reject
}),
)
const screen = await renderSettings()
const trigger = screen.getByRole('button', { name: /^plugin\.autoUpdate\.autoUpdate / })
await expect
.element(trigger)
.toHaveTextContent('plugin.autoUpdate.autoUpdateplugin.autoUpdate.strategy.disabled.name')
const triggerElement = trigger.element()
const dialog = screen.getByRole('dialog', { name: 'plugin.autoUpdate.autoUpdateSettings' })
await trigger.click()
await screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.latest.name' }).click()
await screen.getByRole('button', { name: 'common.operation.save' }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
await expect
.element(trigger)
.toHaveTextContent('plugin.autoUpdate.autoUpdateplugin.autoUpdate.strategy.latest.name')
await expect.poll(() => saveSettings.mock.calls.length).toBe(1)
expect(saveSettings).toHaveBeenCalledWith('/workspaces/current/plugin/auto-upgrade/change', {
body: {
category: PluginCategoryEnum.model,
auto_upgrade: { ...savedConfig, strategy_setting: AUTO_UPDATE_STRATEGY.latest },
},
})
await trigger.click()
const save = screen.getByRole('button', { name: 'common.operation.save' })
await expect.element(save).toBeDisabled()
expect(saveSettings).toHaveBeenCalledOnce()
await expect.element(dialog).toBeVisible()
rejectSave(new Error('Save failed'))
await expect
.element(triggerElement)
.toHaveTextContent('plugin.autoUpdate.autoUpdateplugin.autoUpdate.strategy.disabled.name')
await expect.element(save).toBeEnabled()
await expect
.element(screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.latest.name' }))
.toBeChecked()
await screen.getByRole('button', { name: 'common.operation.cancel' }).click()
await expect.element(dialog).not.toBeInTheDocument()
await trigger.click()
await expect
.element(screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.disabled.name' }))
.toBeChecked()
await userEvent.keyboard('{Escape}')
await expect.element(trigger).toHaveFocus()
})
@@ -159,6 +159,36 @@ it('keeps an earlier category rollback separate from the current category sessio
)
})
it('blocks another save while one is pending and rolls back a failed save without losing the open draft', async () => {
const pendingSave = deferred<void>()
saveSettings.mockReturnValue(pendingSave.promise)
const user = userEvent.setup()
renderSettings()
await waitFor(() =>
expect(settingsTrigger()).toHaveTextContent('plugin.autoUpdate.strategy.disabled.name'),
)
await user.click(settingsTrigger())
await user.click(strategy('latest'))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(settingsTrigger()).toHaveTextContent('plugin.autoUpdate.strategy.latest.name')
const trigger = settingsTrigger()
await user.click(trigger)
const save = screen.getByRole('button', { name: 'common.operation.save' })
expect(save).toBeDisabled()
await act(async () => {
pendingSave.reject(new Error('Save failed'))
})
await waitFor(() => expect(trigger).toHaveTextContent('plugin.autoUpdate.strategy.disabled.name'))
expect(save).toBeEnabled()
expect(strategy('latest')).toBeChecked()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(settingsTrigger())
expect(strategy('disabled')).toBeChecked()
expect(saveSettings).toHaveBeenCalledOnce()
})
it('reports success after the submitted form has closed', async () => {
const pendingSave = deferred<void>()
saveSettings.mockReturnValue(pendingSave.promise)
@@ -49,61 +49,48 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals())
it.each(['click', 'Enter'] as const)(
'holds focus through %s submission, hands it to the results dialog and returns it to Invite',
async (submission) => {
let resolveInvite!: (value: MemberInviteResponse) => void
inviteMember.mockReturnValue(
new Promise<MemberInviteResponse>((resolve) => {
resolveInvite = resolve
}),
)
const screen = await render(
<QueryClientTestProvider queryClient={queryClient}>
<MembersPage />
</QueryClientTestProvider>,
)
const trigger = screen.getByRole('button', { name: /members\.invite$/ })
await trigger.click()
const inviteDialog = screen.getByRole('dialog', { name: /members\.inviteTeamMember$/ })
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/ })
await expect.element(input).toHaveFocus()
await input.fill('person@example.com')
if (submission === 'Enter') {
await userEvent.keyboard('{Enter}')
await expect.element(input).toHaveValue('')
}
await screen.getByRole('combobox', { name: /members\.role/ }).click()
await screen.getByRole('option', { name: /Admin/ }).click()
const submit = screen.getByRole('button', { name: /members\.sendInvite/ })
if (submission === 'Enter') {
await input.click()
await userEvent.keyboard('{Enter}')
} else {
await submit.click()
}
it('holds focus through Enter submission, hands it to the results dialog and returns it to Invite', async () => {
let resolveInvite!: (value: MemberInviteResponse) => void
inviteMember.mockReturnValue(
new Promise<MemberInviteResponse>((resolve) => {
resolveInvite = resolve
}),
)
const screen = await render(
<QueryClientTestProvider queryClient={queryClient}>
<MembersPage />
</QueryClientTestProvider>,
)
const trigger = screen.getByRole('button', { name: /members\.invite$/ })
await trigger.click()
const inviteDialog = screen.getByRole('dialog', { name: /members\.inviteTeamMember$/ })
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/ })
await input.fill('person@example.com')
await userEvent.keyboard('{Enter}')
await expect.element(input).toHaveValue('')
await screen.getByRole('combobox', { name: /members\.role/ }).click()
await screen.getByRole('option', { name: /Admin/ }).click()
const submit = screen.getByRole('button', { name: /members\.sendInvite/ })
await input.click()
await userEvent.keyboard('{Enter}')
await expect.element(submit).toHaveFocus()
await userEvent.keyboard('{Escape}')
const outside = document.elementFromPoint(4, 4)!
await userEvent.click(outside, { position: { x: 4, y: 4 } })
await userEvent.tab()
await expect.element(submit).toHaveFocus()
await expect.element(inviteDialog).toBeVisible()
await expect
.element(inviteDialog.getByRole('button', { name: /operation\.close/ }))
.toBeDisabled()
await expect.element(submit).toHaveFocus()
await userEvent.keyboard('{Escape}')
const outside = document.elementFromPoint(4, 4)!
await userEvent.click(outside, { position: { x: 4, y: 4 } })
await userEvent.tab()
await expect.element(submit).toHaveFocus()
await expect.element(inviteDialog).toBeVisible()
resolveInvite({ result: 'success', tenant_id: 'tenant-id', invitation_results: [] })
const result = screen.getByRole('dialog', { name: /members\.invitationSent$/ })
await expect.element(result).toBeVisible()
await expect.element(result.getByRole('button', { name: /operation\.close/ })).toHaveFocus()
// The closing invitation dialog must not take focus back while it exits.
await expect.element(inviteDialog).not.toBeInTheDocument()
expect(result.element().contains(document.activeElement)).toBe(true)
resolveInvite({ result: 'success', tenant_id: 'tenant-id', invitation_results: [] })
const result = screen.getByRole('dialog', { name: /members\.invitationSent$/ })
await expect.element(result).toBeVisible()
await expect.element(result.getByRole('button', { name: /operation\.close/ })).toHaveFocus()
// The closing invitation dialog must not take focus back while it exits.
await expect.element(inviteDialog).not.toBeInTheDocument()
expect(result.element().contains(document.activeElement)).toBe(true)
await result.getByRole('button', { name: /members\.ok$/ }).click()
await expect.element(result).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
},
)
await result.getByRole('button', { name: /members\.ok$/ }).click()
await expect.element(result).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
})
@@ -144,33 +144,3 @@ it('tabs through the virtualized apps and can leave the list', async () => {
}
await expect.element(screen.getByRole('button', { name: 'After apps' })).toHaveFocus()
})
it('does not flash a scrollbar when rapidly toggling a list that fits', async () => {
const { screen } = await renderSection(2)
const toggle = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
const frames: { viewportHeight: number; contentHeight: number; hasOverflow: boolean }[] = []
let frame = 0
const sample = () => {
const viewport = screen.getByRole('navigation', { name: 'explore.sidebar.webApps' }).query()
if (viewport) {
frames.push({
viewportHeight: viewport.clientHeight,
contentHeight: viewport.scrollHeight,
hasOverflow: viewport.hasAttribute('data-has-overflow-y'),
})
}
frame = requestAnimationFrame(sample)
}
frame = requestAnimationFrame(sample)
try {
for (let index = 0; index < 12; index++) await toggle.click()
await expect.element(screen.getByRole('link', { name: 'App 0', exact: true })).toBeVisible()
} finally {
cancelAnimationFrame(frame)
}
expect(frames.length).toBeGreaterThan(0)
for (const snapshot of frames) {
expect(snapshot.viewportHeight).toBeGreaterThanOrEqual(snapshot.contentHeight)
expect(snapshot.hasOverflow).toBe(false)
}
})
@@ -1,64 +0,0 @@
import type { CreatorProfileViewModel } from '../model'
import { render } from 'vitest-browser-react'
import CreatorProfileView from '../view'
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: () => ({
t: withSelectorKey((key: string) => key),
}),
}
})
vi.mock('../creator-sidebar', () => ({
default: () => <aside>Creator sidebar</aside>,
}))
vi.mock('../creator-content', () => ({
default: () => (
<section data-testid="creator-creations" style={{ height: 640, flexShrink: 0 }}>
Creator content
</section>
),
}))
const profile: CreatorProfileViewModel = {
profile: {
kind: 'individual',
displayName: 'Creator',
handle: 'creator',
avatarUrl: '',
backgroundUrl: '',
badges: [],
socialLinks: [],
},
creations: [],
}
describe('CreatorProfileView layout', () => {
it('keeps the profile background behind content taller than its scrollport', async () => {
const screen = await render(
<div
data-testid="creator-scrollport"
style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }}
>
<CreatorProfileView
profile={profile}
homeHref="/"
isMarketplacePlatform={false}
getCreationAction={() => ({ type: 'link', href: '/' })}
/>
</div>,
)
const scrollport = screen.getByTestId('creator-scrollport').element()
const profileRoot = scrollport.firstElementChild as HTMLElement
const creations = screen.getByTestId('creator-creations').element()
expect(profileRoot.getBoundingClientRect().bottom).toBeGreaterThanOrEqual(
creations.getBoundingClientRect().bottom,
)
})
})
@@ -3,11 +3,6 @@ import { render } from 'vitest-browser-react'
import { MARKETPLACE_CONTAINER_ID } from '../../constants'
import HomeCatalogNavigation from '../home-catalog-navigation'
import HomeCatalogTabs from '../home-catalog-tabs'
import {
HOME_HEADER_HEIGHT_PX,
HOME_SEARCH_HEIGHT_PX,
HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX,
} from '../home-constants'
import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider'
import styles from '../home-sticky.module.css'
@@ -82,7 +77,6 @@ describe('Marketplace catalog tab handoff', () => {
expect(initialHeaderSlotWidth).toBeGreaterThan(0)
expect(initialHeaderSlotHeight).toBeGreaterThan(0)
expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('none')
contentPluginsLink.focus()
expect(document.activeElement).toBe(contentPluginsLink)
@@ -101,10 +95,7 @@ describe('Marketplace catalog tab handoff', () => {
expect(
contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom,
).toBeCloseTo(1)
expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
expect(contentTabsSlot).not.toHaveAttribute('inert')
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
expect(headerTabsSlot).toHaveAttribute('inert')
expect(document.activeElement).toBe(contentPluginsLink)
scrollContainer.scrollTop = handoffScrollTop
@@ -116,9 +107,6 @@ describe('Marketplace catalog tab handoff', () => {
expect(
categories.getBoundingClientRect().top - navigation.getBoundingClientRect().top,
).toBeCloseTo(initialCategoryOffset)
expect(
categories.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
).toBeCloseTo(64)
expect(
contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom,
).toBeCloseTo(0)
@@ -126,17 +114,8 @@ describe('Marketplace catalog tab handoff', () => {
expect(headerTabsSlot.getBoundingClientRect().height).toBeCloseTo(initialHeaderSlotHeight)
expect(followingContent.offsetTop).toBe(initialFollowingOffset)
expect(scrollContainer.scrollHeight).toBe(initialScrollHeight)
expect(getComputedStyle(contentTabsSlot).display).not.toBe('none')
expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('none')
expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true')
expect(contentTabsSlot).toHaveAttribute('inert')
expect(headerTabsSlot).not.toHaveAttribute('aria-hidden')
expect(headerTabsSlot).not.toHaveAttribute('inert')
expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('auto')
await vi.waitFor(
() => {
expect(getComputedStyle(contentTabsSlot).opacity).toBe('0')
expect(getComputedStyle(headerTabsSlot).opacity).toBe('1')
expect(document.activeElement).toBe(headerPluginsLink)
},
{ timeout: 500 },
@@ -152,10 +131,6 @@ describe('Marketplace catalog tab handoff', () => {
expect(
contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom,
).toBeCloseTo(1)
expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
expect(contentTabsSlot).not.toHaveAttribute('inert')
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
expect(headerTabsSlot).toHaveAttribute('inert')
})
it('keeps the in-flow tabs active when the standalone header slot is hidden on mobile', async () => {
@@ -197,17 +172,8 @@ describe('Marketplace catalog tab handoff', () => {
expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
expect(contentTabsSlot).not.toHaveAttribute('inert')
expect(getComputedStyle(contentTabsSlot).opacity).toBe('1')
expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('auto')
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
expect(headerTabsSlot).toHaveAttribute('inert')
expect(
contentTabsSlot.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
).toBeCloseTo(
HOME_HEADER_HEIGHT_PX +
HOME_SEARCH_HEIGHT_PX +
HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX /* .catalogTabsRegion padding-top, tucked under search padding */,
)
await page.viewport(880, 800)
await expect.poll(() => contentTabsSlot.hasAttribute('inert')).toBe(true)
@@ -86,25 +86,6 @@ describe('Marketplace mobile search layout', () => {
expect(isCenterClickable(searchInput)).toBe(true)
})
it('keeps the desktop search in the header gap while scrolling', async () => {
await page.viewport(1280, 900)
const screen = await renderMarketplaceHome()
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
const header = screen.getByRole('banner').element()
const searchInput = screen
.getByRole('textbox', { name: 'Search plugins or templates' })
.element()
scrollContainer.scrollTop = 400
scrollContainer.dispatchEvent(new Event('scroll'))
await nextFrame()
expect(
searchInput.getBoundingClientRect().top - header.getBoundingClientRect().top,
).toBeCloseTo(6, 0)
})
it('keeps a search-results search below the header when there is no hero to overlap', async () => {
await page.viewport(1280, 900)
const screen = await render(
@@ -1,256 +0,0 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import HomeTrending from '../home-trending'
import { HomeBannerSlide } from '../home-trending-slides'
const createBlogBanner = (id: string, title: string, sort: number): PluginBanner => ({
id,
style_type: 'blog',
title,
sort,
language: 'en',
content: {
blog_title: title,
subtitle: 'New Agent node support',
description: 'Build agent workflows with the new Agent node.',
cover_image: '/api/v1/banners/images/banners/blog-cover.png',
link: 'https://dify.ai/blog',
link_target_type: 'blog',
},
})
const blogBanner = createBlogBanner('blog', 'Dify v1.9 new launch', 0)
const adBanner: PluginBanner = {
id: 'ad',
style_type: 'ad',
title: 'Partner campaign',
sort: 1,
language: 'en',
content: {
images: {
desktop: '/api/v1/banners/images/banners/ad.png',
},
link: 'https://partner.example.com',
alt_text: 'Partner campaign',
},
}
const eventBanner: PluginBanner = {
id: 'event',
style_type: 'event',
title: 'Launch event',
sort: 2,
language: 'en',
content: {
images: {
desktop: '/api/v1/banners/images/banners/event.png',
},
link: 'https://dify.ai/event',
alt_text: 'Launch event',
},
}
const carouselBanners = [
createBlogBanner('first', 'First banner', 0),
createBlogBanner('second', 'Second banner', 1),
createBlogBanner('third', 'Third banner', 2),
]
const visibleReadMore = (slide: Element) =>
[...slide.querySelectorAll('[aria-hidden]')].find((el) => {
const text = el.textContent ?? ''
return /Read more|trendingReadMore/.test(text) && el.getBoundingClientRect().height > 0
}) ?? null
describe('Marketplace home trending layout', () => {
it('keeps standalone mobile blog banners at the stacked 357px height', async () => {
await page.viewport(600, 900)
await render(
<div data-marketplace-standalone className="w-[560px]">
<div data-testid="blog-banner">
<HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" />
</div>
</div>,
)
const blogSlide = document.querySelector<HTMLElement>('[data-testid="blog-banner"] > a')!
expect(blogSlide.getBoundingClientRect().height).toBe(357)
})
it('clamps standalone mobile blog subtitle to one line and description to two', async () => {
await page.viewport(600, 900)
const subtitleText =
'On September 10, 2026, LangGenius K.K. will host its flagship annual conference in Tokyo.'
const descriptionText =
'It is a full day dedicated to turning generative AI from isolated pilots into real operations. Registration is open now for the second year of the conference.'
const longTag = 'IF Con Tokyo 2026 Annual Conference Extra Long Label'
const longTitle = 'IF Con Tokyo 2026: Turn “What If” into Production'
const longBlog: PluginBanner = {
id: 'blog-long',
style_type: 'blog',
title: longTag,
sort: 0,
language: 'en',
content: {
blog_title: longTitle,
subtitle: subtitleText,
description: descriptionText,
cover_image: '/api/v1/banners/images/banners/blog-cover.png',
link: 'https://dify.ai/blog',
link_target_type: 'blog',
},
}
const screen = await render(
<div data-marketplace-standalone className="w-[360px]">
<HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" />
</div>,
)
const slide = screen.getByRole('link').element()
const tag = screen.getByText(longTag).element()
const title = screen.getByRole('heading', { name: longTitle }).element()
const subtitle = screen.getByText(subtitleText).element()
const description = screen.getByText(descriptionText).element()
const slideBox = slide.getBoundingClientRect()
const titleBox = title.getBoundingClientRect()
expect(getComputedStyle(tag).whiteSpace).toBe('nowrap')
expect(getComputedStyle(tag).textOverflow).toBe('ellipsis')
expect(getComputedStyle(title).whiteSpace).toBe('normal')
expect(titleBox.height).toBeGreaterThan(24)
expect(titleBox.left - slideBox.left).toBeCloseTo(20, 0)
expect(slideBox.right - titleBox.right).toBeCloseTo(20, 0)
expect(slideBox.height).toBeGreaterThan(357)
expect(getComputedStyle(subtitle).whiteSpace).toBe('nowrap')
expect(getComputedStyle(subtitle).textOverflow).toBe('ellipsis')
expect(getComputedStyle(description).webkitLineClamp).toBe('2')
expect(description.getBoundingClientRect().height).toBeCloseTo(40, 0)
expect(visibleReadMore(slide)).toBeNull()
})
it('clamps the desktop blog tag to one line and lets the title wrap', async () => {
await page.viewport(1200, 900)
const longTag =
"Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents — extra-long green label"
const longTitle =
"Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and AgentsDify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents"
const longBlog: PluginBanner = {
...createBlogBanner('blog-desktop-long', longTitle, 0),
title: longTag,
}
const screen = await render(
<div className="w-[1100px]">
<HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" />
</div>,
)
const tag = screen.getByText(longTag).element()
const title = screen.getByRole('heading', { name: longTitle }).element()
const tagBox = tag.getBoundingClientRect()
const titleBox = title.getBoundingClientRect()
expect(getComputedStyle(tag).whiteSpace).toBe('nowrap')
expect(getComputedStyle(tag).textOverflow).toBe('ellipsis')
expect(tagBox.height).toBeLessThanOrEqual(20)
expect(getComputedStyle(title).whiteSpace).toBe('normal')
expect(titleBox.height).toBeGreaterThan(24)
expect(visibleReadMore(screen.getByRole('link').element())).not.toBeNull()
})
it('shows the standalone mobile event poster at the 800:721 delivery ratio', async () => {
await page.viewport(600, 900)
const screen = await render(
<div data-marketplace-standalone className="w-[360px]">
<HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" />
</div>,
)
const slide = screen.getByRole('link', { name: 'Launch event' }).element()
const box = slide.getBoundingClientRect()
const artwork = slide.querySelector('img')
expect(box.height).toBeCloseTo((box.width * 721) / 800, 1)
expect(artwork).not.toBeNull()
expect(getComputedStyle(artwork!).objectFit).toBe('contain')
expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
})
it('keeps event and ad artwork left-aligned so desktop cropping stays on the right', async () => {
await page.viewport(1000, 900)
const screen = await render(
<div data-marketplace-standalone className="w-[960px]">
<HomeBannerSlide banner={adBanner} isMarketplacePlatform page="plugins" />
<HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" />
</div>,
)
for (const name of ['Partner campaign', 'Launch event']) {
const artwork = screen.getByRole('link', { name }).element().querySelector('img')
expect(artwork).not.toBeNull()
expect(getComputedStyle(artwork!).objectFit).toBe('cover')
expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
}
})
it('keeps blog artwork at 400px on desktop so shrinking clips the right', async () => {
await page.viewport(1200, 900)
const screen = await render(
<div className="w-[900px]">
<HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" />
</div>,
)
const artwork = screen.getByRole('link').element().querySelector('img')
expect(artwork).not.toBeNull()
expect(artwork!.getBoundingClientRect().width).toBe(400)
expect(getComputedStyle(artwork!).objectFit).toBe('cover')
expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
})
it('keeps desktop event artwork at least 1200px wide so overflow clips the right', async () => {
await page.viewport(1000, 900)
const screen = await render(
<div data-marketplace-standalone className="w-[900px]">
<HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" />
</div>,
)
const artwork = screen
.getByRole('link', { name: 'Launch event' })
.element()
.querySelector('img')
expect(artwork).not.toBeNull()
expect(artwork!.getBoundingClientRect().width).toBeGreaterThanOrEqual(1200)
expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
})
it('wraps from the last banner back to the first visible slide', async () => {
const screen = await render(
<HomeTrending banners={carouselBanners} isMarketplacePlatform page="plugins" />,
)
await screen.getByRole('button', { name: 'Third banner' }).click()
expect(screen.getByRole('button', { name: 'Third banner' }).element()).toHaveAttribute(
'aria-current',
'true',
)
await expect
.poll(
() =>
screen
.getByRole('button', { name: 'First banner' })
.element()
.getAttribute('aria-current'),
{ timeout: 8000 },
)
.toBe('true')
expect(screen.getByRole('group', { name: 'First banner' }).element()).not.toHaveAttribute(
'inert',
)
})
})
@@ -0,0 +1,57 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import { render } from 'vitest-browser-react'
import HomeTrending from '../home-trending'
const createBlogBanner = (id: string, title: string, sort: number): PluginBanner => ({
id,
style_type: 'blog',
title,
sort,
language: 'en',
content: {
blog_title: title,
subtitle: 'New Agent node support',
description: 'Build agent workflows with the new Agent node.',
cover_image: '/api/v1/banners/images/banners/blog-cover.png',
link: 'https://dify.ai/blog',
link_target_type: 'blog',
},
})
const carouselBanners = [
createBlogBanner('first', 'First banner', 0),
createBlogBanner('second', 'Second banner', 1),
createBlogBanner('third', 'Third banner', 2),
]
describe('Marketplace home trending loop', () => {
// The unit spec stubs the autoplay animation and fires a synthetic
// transitionend; only real rendering proves the track transition completes
// and returns the carousel from the loop clone to the first slide.
it('wraps from the last banner back to the first visible slide', async () => {
const screen = await render(
<HomeTrending banners={carouselBanners} isMarketplacePlatform page="plugins" />,
)
await screen.getByRole('button', { name: 'Third banner' }).click()
expect(screen.getByRole('button', { name: 'Third banner' }).element()).toHaveAttribute(
'aria-current',
'true',
)
await expect
.poll(
() =>
screen
.getByRole('button', { name: 'First banner' })
.element()
.getAttribute('aria-current'),
{ timeout: 8000 },
)
.toBe('true')
expect(screen.getByRole('group', { name: 'First banner' }).element()).not.toHaveAttribute(
'inert',
)
})
})
@@ -1,208 +0,0 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import HomeTrending from '../home-trending'
const createBanner = (id: string, title: string, sort: number): PluginBanner => ({
id,
style_type: 'blog',
title,
sort,
language: 'en',
content: {
blog_title: title,
subtitle: `${title} subtitle`,
description: `${title} description`,
cover_image: '/api/v1/banners/images/banners/blog-cover.png',
link: `https://example.com/${id}`,
link_target_type: 'blog',
},
})
const banners = [
createBanner('first', 'First banner', 0),
createBanner('second', 'Second banner', 1),
createBanner('third', 'Third banner', 2),
]
const dispatchTouchPointer = (
target: Element,
type: 'pointerdown' | 'pointermove' | 'pointerup',
init: Pick<PointerEventInit, 'clientX' | 'clientY' | 'pointerId'>,
) =>
target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
cancelable: true,
isPrimary: true,
pointerType: 'touch',
...init,
}),
)
describe('Marketplace home trending mobile swipe', () => {
it('switches in both directions without activating a dragged link or clearing Pause', async () => {
await page.viewport(600, 900)
const screen = await render(
<div data-marketplace-standalone>
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />
</div>,
)
const firstSlideLocator = screen.getByRole('group', { name: 'First banner' })
const secondSlideLocator = screen.getByRole('group', {
name: 'Second banner',
includeHidden: true,
})
const firstSlide = firstSlideLocator.element()
const firstLink = firstSlide.querySelector<HTMLAnchorElement>('a')!
dispatchTouchPointer(firstSlide, 'pointerdown', {
pointerId: 1,
clientX: 480,
clientY: 160,
})
dispatchTouchPointer(firstSlide, 'pointermove', {
pointerId: 1,
clientX: 300,
clientY: 166,
})
await expect.element(secondSlideLocator).toBeVisible()
dispatchTouchPointer(firstSlide, 'pointerup', {
pointerId: 1,
clientX: 300,
clientY: 166,
})
const clickWasNotCanceled = firstLink.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true }),
)
expect(clickWasNotCanceled).toBe(false)
await expect
.element(screen.getByRole('button', { name: 'Second banner' }))
.toHaveAttribute('aria-current', 'true')
await screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }).click()
const secondSlide = secondSlideLocator.element()
dispatchTouchPointer(secondSlide, 'pointerdown', {
pointerId: 2,
clientX: 260,
clientY: 160,
})
dispatchTouchPointer(secondSlide, 'pointermove', {
pointerId: 2,
clientX: 440,
clientY: 166,
})
dispatchTouchPointer(secondSlide, 'pointerup', {
pointerId: 2,
clientX: 440,
clientY: 166,
})
await expect
.element(screen.getByRole('button', { name: 'First banner' }))
.toHaveAttribute('aria-current', 'true')
await expect
.element(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' }))
.toBeInTheDocument()
})
it('suppresses the trailing click when a horizontal drag is pulled back before release', async () => {
await page.viewport(600, 900)
const screen = await render(
<div data-marketplace-standalone>
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />
</div>,
)
const firstSlide = screen.getByRole('group', { name: 'First banner' }).element()
const firstLink = firstSlide.querySelector<HTMLAnchorElement>('a')!
dispatchTouchPointer(firstSlide, 'pointerdown', {
pointerId: 1,
clientX: 400,
clientY: 160,
})
dispatchTouchPointer(firstSlide, 'pointermove', {
pointerId: 1,
clientX: 280,
clientY: 164,
})
dispatchTouchPointer(firstSlide, 'pointermove', {
pointerId: 1,
clientX: 396,
clientY: 162,
})
dispatchTouchPointer(firstSlide, 'pointerup', {
pointerId: 1,
clientX: 396,
clientY: 162,
})
expect(
firstLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })),
).toBe(false)
await expect
.element(screen.getByRole('button', { name: 'First banner' }))
.toHaveAttribute('aria-current', 'true')
})
it('keeps vertical gestures on the current slide and ignores desktop touch input', async () => {
await page.viewport(600, 900)
let screen = await render(
<div data-marketplace-standalone>
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />
</div>,
)
let activeSlide = screen.getByRole('group', { name: 'First banner' }).element()
dispatchTouchPointer(activeSlide, 'pointerdown', {
pointerId: 1,
clientX: 300,
clientY: 120,
})
dispatchTouchPointer(activeSlide, 'pointermove', {
pointerId: 1,
clientX: 270,
clientY: 300,
})
dispatchTouchPointer(activeSlide, 'pointerup', {
pointerId: 1,
clientX: 270,
clientY: 300,
})
await expect
.element(screen.getByRole('button', { name: 'First banner' }))
.toHaveAttribute('aria-current', 'true')
screen.unmount()
await page.viewport(1000, 900)
screen = await render(
<div data-marketplace-standalone>
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />
</div>,
)
activeSlide = screen.getByRole('group', { name: 'First banner' }).element()
dispatchTouchPointer(activeSlide, 'pointerdown', {
pointerId: 2,
clientX: 480,
clientY: 160,
})
dispatchTouchPointer(activeSlide, 'pointermove', {
pointerId: 2,
clientX: 260,
clientY: 160,
})
dispatchTouchPointer(activeSlide, 'pointerup', {
pointerId: 2,
clientX: 260,
clientY: 160,
})
await expect
.element(screen.getByRole('button', { name: 'First banner' }))
.toHaveAttribute('aria-current', 'true')
})
})
@@ -997,6 +997,107 @@ describe('HomeTrending', () => {
expect(screen.queryByRole('button', { name: 'Duck Duck Go' })).not.toBeInTheDocument()
})
describe('mobile swipe', () => {
const stubViewport = (isMobile: boolean) =>
vi.spyOn(window, 'matchMedia').mockImplementation(
(query) =>
({
matches: isMobile && query === '(max-width: 879px)',
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList,
)
const swipe = (slide: HTMLElement, points: Array<[x: number, y: number]>) => {
const touch = { pointerId: 1, pointerType: 'touch', isPrimary: true }
const [start, ...moves] = points
fireEvent.pointerDown(slide, { ...touch, clientX: start![0], clientY: start![1] })
for (const [clientX, clientY] of moves)
fireEvent.pointerMove(slide, { ...touch, clientX, clientY })
const [clientX, clientY] = points.at(-1)!
fireEvent.pointerUp(slide, { ...touch, clientX, clientY })
}
const renderCarousel = () =>
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
afterEach(() => {
vi.restoreAllMocks()
})
it('switches in both directions without activating a dragged link or clearing Pause', async () => {
const user = userEvent.setup()
stubViewport(true)
renderCarousel()
swipe(screen.getByRole('group', { name: 'Trending' }), [
[480, 160],
[300, 166],
])
expect(fireEvent.click(screen.getByRole('link', { name: 'Dropbox', hidden: true }))).toBe(
false,
)
expect(screen.getByRole('button', { name: 'Dify Updates' })).toHaveAttribute(
'aria-current',
'true',
)
await user.click(
screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }),
)
swipe(screen.getByRole('group', { name: 'Dify Updates' }), [
[260, 160],
[440, 166],
])
expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute(
'aria-current',
'true',
)
expect(
screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' }),
).toBeInTheDocument()
})
it('suppresses the trailing click when a horizontal drag is pulled back before release', () => {
stubViewport(true)
renderCarousel()
swipe(screen.getByRole('group', { name: 'Trending' }), [
[400, 160],
[280, 164],
[396, 162],
])
expect(fireEvent.click(screen.getByRole('link', { name: 'Dropbox' }))).toBe(false)
expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute(
'aria-current',
'true',
)
})
it.each([
{ name: 'a vertical gesture on mobile', isMobile: true, end: [270, 300] as [number, number] },
{ name: 'touch input on desktop', isMobile: false, end: [80, 160] as [number, number] },
])('stays on the current slide for $name', ({ isMobile, end }) => {
stubViewport(isMobile)
renderCarousel()
swipe(screen.getByRole('group', { name: 'Trending' }), [[300, 120], end])
expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute(
'aria-current',
'true',
)
})
})
it('renders no carousel when the API returns no banners', () => {
render(<HomeTrending banners={[]} isMarketplacePlatform page="plugins" />)
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { page, userEvent } from 'vite-plus/test/browser'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { MARKETPLACE_CONTAINER_ID } from '../../constants'
import HomeCatalogNavigation from '../home-catalog-navigation'
@@ -113,60 +113,6 @@ describe('Marketplace search autocomplete layout', () => {
mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
})
it('preserves the keyboard activation target while the pointer visits another suggestion', async () => {
mockTemplateSearch.mockResolvedValue({
data: {
templates: ['First', 'Second'].map((name) => ({
id: name.toLowerCase(),
template_name: `${name} template`,
overview: '',
publisher_handle: 'dify',
usage_count: 1,
categories: [],
icon: '📄',
icon_background: '#FFFFFF',
icon_file_key: '',
})),
total: 2,
},
})
const onSelect = vi.fn()
function Search() {
const [value, setValue] = useState('')
return (
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={setValue}
onSuggestionSelect={onSelect}
placeholder="Search templates"
scope="templates"
value={value}
/>
)
}
const screen = await render(
<Wrapper>
<Search />
</Wrapper>,
)
const input = screen.getByRole('combobox', { name: 'Search templates' })
await input.fill('template')
const first = screen.getByRole('option', { name: /First template/ })
const second = screen.getByRole('option', { name: /Second template/ })
await expect.element(first).toBeVisible()
await userEvent.keyboard('{ArrowDown}')
await expect.element(input).toHaveAttribute('aria-activedescendant', first.element().id)
await second.hover()
await expect.element(input).toHaveAttribute('aria-activedescendant', first.element().id)
await userEvent.keyboard('{Enter}')
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'template',
template: expect.objectContaining({ id: 'first' }),
}),
)
})
it('keeps the pinned catalog layout stable while the results popup opens', async () => {
await page.viewport(1280, 720)
@@ -1,116 +0,0 @@
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { MARKETPLACE_CONTAINER_ID } from '../../constants'
import { preserveStickySearchScroll } from '../preserve-sticky-search-scroll'
const nextFrame = () =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
const SearchPage = ({ popup }: { popup?: boolean }) => (
<>
<div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}>
<div style={{ height: 48, flexShrink: 0 }}>Header</div>
<div style={{ height: 180, flexShrink: 0 }}>Hero</div>
<div
data-testid="search-root"
style={{ position: 'sticky', top: 6, height: 36, marginTop: -36 }}
>
<input aria-label="Search plugins or templates" style={{ height: 36, width: '100%' }} />
</div>
<div style={{ height: 900, flexShrink: 0 }}>Catalog</div>
</div>
{popup ? (
<div
data-testid="search-popup"
style={{
height: 80,
overflowY: 'auto',
position: 'fixed',
top: 50,
left: 100,
width: 200,
}}
>
Short
</div>
) : null}
</>
)
describe('Sticky search scroll guard', () => {
it('keeps the scroll position when Chromium focuses the in-flow sticky input', async () => {
await page.viewport(1280, 900)
const screen = await render(<SearchPage />)
const container = document.getElementById(MARKETPLACE_CONTAINER_ID)!
const searchRoot = screen.getByTestId('search-root').element()
const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element()
const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container)
container.scrollTop = 400
container.dispatchEvent(new Event('scroll'))
await nextFrame()
const scrollTopBefore = container.scrollTop
HTMLInputElement.prototype.focus.call(input)
await nextFrame()
expect(container.scrollTop).toBe(scrollTopBefore)
stop()
})
it('keeps visitor-initiated scroll after typing in the sticky search', async () => {
await page.viewport(1280, 900)
const screen = await render(<SearchPage />)
const container = document.getElementById(MARKETPLACE_CONTAINER_ID)!
const searchRoot = screen.getByTestId('search-root').element()
const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element()
const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container)
container.scrollTop = 400
container.dispatchEvent(new Event('scroll'))
await nextFrame()
input.focus()
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'open' }))
await nextFrame()
expect(container.scrollTop).toBe(400)
container.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }))
container.scrollTop = 520
container.dispatchEvent(new Event('scroll'))
await nextFrame()
expect(container.scrollTop).toBe(520)
stop()
})
it('scrolls the page when the visitor wheels over a portaled popup that cannot scroll', async () => {
await page.viewport(1280, 900)
const screen = await render(<SearchPage popup />)
const container = document.getElementById(MARKETPLACE_CONTAINER_ID)!
const searchRoot = screen.getByTestId('search-root').element()
const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element()
const popup = screen.getByTestId('search-popup').element()
const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container)
container.scrollTop = 400
container.dispatchEvent(new Event('scroll'))
await nextFrame()
input.focus()
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'open' }))
await nextFrame()
popup.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }))
await nextFrame()
expect(container.scrollTop).toBe(520)
stop()
})
})
@@ -0,0 +1,51 @@
import { fireEvent } from '@testing-library/react'
import { preserveStickySearchScroll } from '../preserve-sticky-search-scroll'
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
const setup = () => {
document.body.innerHTML = `
<div id="container"><div id="search-root"><input /></div></div>
<div id="popup">Short</div>
`
const container = document.getElementById('container')!
const input = container.querySelector('input')!
const stop = preserveStickySearchScroll(document.getElementById('search-root')!, container)
container.scrollTop = 400
fireEvent.scroll(container)
input.focus()
fireEvent.input(input, { target: { value: 'open' } })
return { container, popup: document.getElementById('popup')!, stop }
}
describe('preserveStickySearchScroll', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('keeps visitor-initiated scroll after typing in the sticky search', async () => {
const { container, stop } = setup()
await nextFrame()
expect(container.scrollTop).toBe(400)
fireEvent.wheel(container, { deltaY: 120 })
container.scrollTop = 520
fireEvent.scroll(container)
await nextFrame()
expect(container.scrollTop).toBe(520)
stop()
})
it('scrolls the page when the visitor wheels over a portaled popup that cannot scroll', async () => {
const { container, popup, stop } = setup()
await nextFrame()
fireEvent.wheel(popup, { deltaY: 120 })
expect(container.scrollTop).toBe(520)
stop()
})
})
@@ -4,24 +4,17 @@ import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import ListWithCollection from '../list-with-collection'
const mockState = vi.hoisted(() => ({
becomePartnerText: 'Become a Partner',
}))
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
const translations: Record<string, string> = {
'marketplace.becomePartner': 'Torne-se um parceiro',
'marketplace.carousel.scrollPrevious': 'Previous',
}
return {
useLocale: () => 'en-US',
useTranslation: () => ({
t: withSelectorKey((key: string) =>
key === 'marketplace.becomePartner'
? mockState.becomePartnerText
: (translations[key] ?? key),
),
t: withSelectorKey((key: string) => translations[key] ?? key),
}),
}
})
@@ -59,124 +52,11 @@ const partnerPlugins = Array.from({ length: 9 }, (_, index) => ({
name: `Partner plugin ${index}`,
})) as Plugin[]
const renderPartnerCollection = ({
pluginCount = 9,
standalone = true,
width = 350,
}: {
pluginCount?: number
standalone?: boolean
width?: number
} = {}) =>
render(
<div
data-testid="collection-shell"
data-marketplace-standalone={standalone || undefined}
style={{ width }}
>
<ListWithCollection
marketplaceCollections={[partnerCollection]}
marketplaceCollectionPluginsMap={{ partners: partnerPlugins.slice(0, pluginCount) }}
/>
</div>,
)
const getTextRect = (element: Element) => {
const range = document.createRange()
range.selectNodeContents(element)
return range.getBoundingClientRect()
}
describe('Partner collection header layout', () => {
beforeEach(() => {
mockState.becomePartnerText = 'Become a Partner'
})
it('keeps the mobile call to action beside the title and clear of carousel controls', async () => {
await page.viewport(390, 844)
const screen = await renderPartnerCollection()
const title = screen.getByText('Partners', { exact: true }).element()
const description = screen.getByText('Plugins verified by Dify partners.').element()
const separator = screen.getByText('|').element()
const partnerLink = screen.getByRole('link', { name: 'Become a Partner' }).element()
const previousButton = screen.getByRole('button', { name: 'Previous' }).element()
const titleRect = getTextRect(title)
const descriptionRect = description.getBoundingClientRect()
const partnerLinkRect = partnerLink.getBoundingClientRect()
const previousButtonRect = previousButton.getBoundingClientRect()
const titleCenter = titleRect.top + titleRect.height / 2
const partnerLinkCenter = partnerLinkRect.top + partnerLinkRect.height / 2
expect(Math.abs(titleCenter - partnerLinkCenter)).toBeLessThanOrEqual(2)
expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0)
expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8)
expect(descriptionRect.top).toBeGreaterThanOrEqual(
Math.max(titleRect.bottom, partnerLinkRect.bottom),
)
expect(getComputedStyle(separator).display).toBe('none')
})
it('keeps the mobile action 12px from the title when navigation is absent', async () => {
await page.viewport(390, 844)
const screen = await renderPartnerCollection({ pluginCount: 2 })
const shellRect = screen.getByTestId('collection-shell').element().getBoundingClientRect()
const titleRect = getTextRect(screen.getByText('Partners', { exact: true }).element())
const partnerLinkRect = screen
.getByRole('link', { name: 'Become a Partner' })
.element()
.getBoundingClientRect()
expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0)
expect(partnerLinkRect.right).toBeLessThanOrEqual(shellRect.right)
expect(screen.getByRole('button', { name: 'Previous' }).query()).toBeNull()
})
it('keeps the mobile action clear of navigation at a 320px viewport', async () => {
it('keeps a long mobile call to action clear of carousel controls at a 320px viewport', async () => {
await page.viewport(320, 844)
mockState.becomePartnerText = 'Torne-se um parceiro'
const screen = await renderPartnerCollection({ width: 280 })
const titleRect = getTextRect(screen.getByText('Partners', { exact: true }).element())
const partnerLinkRect = screen
.getByRole('link', { name: 'Torne-se um parceiro' })
.element()
.getBoundingClientRect()
const previousButtonRect = screen
.getByRole('button', { name: 'Previous' })
.element()
.getBoundingClientRect()
expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0)
expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8)
})
it('preserves the narrow embedded metadata row', async () => {
await page.viewport(390, 844)
const screen = await renderPartnerCollection({ standalone: false })
const shellRect = screen.getByTestId('collection-shell').element().getBoundingClientRect()
const descriptionRect = screen
.getByText('Plugins verified by Dify partners.')
.element()
.getBoundingClientRect()
const partnerLinkRect = screen
.getByRole('link', { name: 'Become a Partner' })
.element()
.getBoundingClientRect()
expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2)
expect(partnerLinkRect.right).toBeLessThanOrEqual(shellRect.right)
expect(getComputedStyle(screen.getByText('|').element()).display).not.toBe('none')
})
it('preserves the desktop title and metadata rows', async () => {
await page.viewport(1280, 900)
const screen = await render(
<div className="w-[1200px]" data-marketplace-standalone>
<div data-marketplace-standalone style={{ width: 280 }}>
<ListWithCollection
marketplaceCollections={[partnerCollection]}
marketplaceCollectionPluginsMap={{ partners: partnerPlugins }}
@@ -192,14 +72,18 @@ describe('Partner collection header layout', () => {
.getByText('Plugins verified by Dify partners.')
.element()
.getBoundingClientRect()
const separator = screen.getByText('|').element()
const partnerLinkRect = screen
.getByRole('link', { name: 'Become a Partner' })
.getByRole('link', { name: 'Torne-se um parceiro' })
.element()
.getBoundingClientRect()
const previousButtonRect = screen
.getByRole('button', { name: 'Previous' })
.element()
.getBoundingClientRect()
expect(descriptionRect.top).toBeGreaterThanOrEqual(titleRect.bottom)
expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2)
expect(getComputedStyle(separator).display).not.toBe('none')
expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8)
expect(descriptionRect.top).toBeGreaterThanOrEqual(
Math.max(titleRect.bottom, partnerLinkRect.bottom),
)
})
})
@@ -16,13 +16,10 @@ const plugins = Array.from({ length: 5 }, (_, index) => ({
describe('Marketplace search result layout', () => {
// Native grid layout determines whether the result cards remain readable;
// happy-dom cannot reproduce the four 75px columns seen on mobile.
it.each([
{ viewportWidth: 390, columns: 1 },
{ viewportWidth: 1280, columns: 4 },
])('keeps readable cards at $viewportWidth px', async ({ viewportWidth, columns }) => {
await page.viewport(viewportWidth, 844)
it('keeps readable single-column cards at a 390px viewport', async () => {
await page.viewport(390, 844)
const screen = await render(
<div style={{ width: viewportWidth - 40 }}>
<div style={{ width: 350 }}>
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
@@ -37,19 +34,9 @@ describe('Marketplace search result layout', () => {
)
const first = screen.getByRole('link', { name: 'Plugin 1' }).element().getBoundingClientRect()
const nextRow = screen
.getByRole('link', { name: `Plugin ${columns + 1}` })
.element()
.getBoundingClientRect()
const nextRow = screen.getByRole('link', { name: 'Plugin 2' }).element().getBoundingClientRect()
expect(first.width).toBeGreaterThanOrEqual(250)
expect(nextRow.top).toBeGreaterThanOrEqual(first.bottom)
if (columns > 1) {
const lastInRow = screen
.getByRole('link', { name: `Plugin ${columns}` })
.element()
.getBoundingClientRect()
expect(lastInRow.top).toBe(first.top)
}
})
})
@@ -1,137 +0,0 @@
import type {
MarketplaceTemplate,
MarketplaceTemplateCollection,
} from '@dify/contracts/marketplace'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import TemplateCollectionList from '../template-collection-list'
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useLocale: () => 'en-US',
useTranslation: () => ({
t: withSelectorKey((key: string) =>
key === 'marketplace.carousel.scrollPrevious' ? 'Previous' : key,
),
}),
}
})
vi.mock('../template-card', () => ({
default: ({ template }: { template: MarketplaceTemplate }) => <div>{template.template_name}</div>,
}))
const partnerCollection: MarketplaceTemplateCollection = {
name: 'partners',
label: { en_US: 'Partners' },
description: { en_US: 'Plugins verified by Dify partners.' },
searchable: false,
search_params: {},
priority: 0,
}
const partnerTemplates = Array.from({ length: 9 }, (_, index) => ({
id: `template-${index}`,
template_name: `Partner template ${index}`,
overview: 'Partner template',
icon: '📄',
icon_background: '#fff',
icon_file_key: '',
publisher_unique_handle: 'dify',
usage_count: 10,
categories: ['marketing'],
})) as MarketplaceTemplate[]
const renderPartnerCollection = ({
templateCount = 9,
standalone = true,
width = 350,
}: {
templateCount?: number
standalone?: boolean
width?: number
} = {}) =>
render(
<div
data-testid="collection-shell"
data-marketplace-standalone={standalone || undefined}
style={{ width }}
>
<TemplateCollectionList
becomePartnerText="Become a Partner"
collections={[partnerCollection]}
locale="en-US"
partnerText="Verified"
templatesByCollection={{ partners: partnerTemplates.slice(0, templateCount) }}
viewMoreText="View more"
/>
</div>,
)
const getTextRect = (element: Element) => {
const range = document.createRange()
range.selectNodeContents(element)
return range.getBoundingClientRect()
}
describe('Template partner collection header layout', () => {
it('keeps the mobile call to action beside the title and clear of carousel controls', async () => {
await page.viewport(390, 844)
const screen = await renderPartnerCollection()
const title = screen.getByText('Partners', { exact: true }).element()
const description = screen.getByText('Plugins verified by Dify partners.').element()
const separator = screen.getByText('|').element()
const partnerLink = screen.getByRole('link', { name: 'Become a Partner' }).element()
const previousButton = screen.getByRole('button', { name: 'Previous' }).element()
const titleRect = getTextRect(title)
const descriptionRect = description.getBoundingClientRect()
const partnerLinkRect = partnerLink.getBoundingClientRect()
const previousButtonRect = previousButton.getBoundingClientRect()
const titleCenter = titleRect.top + titleRect.height / 2
const partnerLinkCenter = partnerLinkRect.top + partnerLinkRect.height / 2
expect(Math.abs(titleCenter - partnerLinkCenter)).toBeLessThanOrEqual(2)
expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0)
expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8)
expect(descriptionRect.top).toBeGreaterThanOrEqual(
Math.max(titleRect.bottom, partnerLinkRect.bottom),
)
expect(getComputedStyle(separator).display).toBe('none')
})
it('preserves the desktop title and metadata rows', async () => {
await page.viewport(1280, 900)
const screen = await render(
<div className="w-[1200px]" data-marketplace-standalone>
<TemplateCollectionList
becomePartnerText="Become a Partner"
collections={[partnerCollection]}
locale="en-US"
partnerText="Verified"
templatesByCollection={{ partners: partnerTemplates }}
viewMoreText="View more"
/>
</div>,
)
const titleRect = screen
.getByText('Partners', { exact: true })
.element()
.getBoundingClientRect()
const descriptionRect = screen
.getByText('Plugins verified by Dify partners.')
.element()
.getBoundingClientRect()
const partnerLinkRect = screen
.getByRole('link', { name: 'Become a Partner' })
.element()
.getBoundingClientRect()
expect(descriptionRect.top).toBeGreaterThanOrEqual(titleRect.bottom)
expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2)
expect(getComputedStyle(screen.getByText('|').element()).display).not.toBe('none')
})
})
@@ -220,7 +220,6 @@ describe('Snippet list reflow', () => {
const gridElement = card.element().closest('[aria-busy]')
if (!gridElement) throw new Error('Snippet card did not render inside the list grid')
expect(mainElement.clientWidth).toBe(320)
expect(mainElement.scrollWidth).toBe(mainElement.clientWidth)
expect(gridElement.scrollWidth).toBe(gridElement.clientWidth)
;[creatorFilter, statusFilter, tagFilter, search, createAction, card].forEach((locator) => {
@@ -1,96 +0,0 @@
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useRef, useState } from 'react'
import ReactFlow, { ReactFlowProvider, useReactFlow } from 'reactflow'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { useWorkflowControlScale } from '../hooks/use-workflow-control-scale'
import 'reactflow/dist/style.css'
function ControlNode() {
const [activations, setActivations] = useState(0)
return (
<div
role="group"
aria-label="Canvas node"
style={{ width: 200, height: 100, display: 'grid', placeItems: 'center' }}
>
<IconButton
aria-label="Canvas action"
className="nodrag nopan"
size="md"
style={{ scale: 'var(--workflow-control-scale, 1)' }}
onClick={() => setActivations((count) => count + 1)}
>
<span aria-hidden className="i-ri-add-line size-4" />
</IconButton>
<output aria-label="Action count" style={{ position: 'absolute', top: 0, left: 0 }}>
{activations}
</output>
</div>
)
}
const nodeTypes = { control: ControlNode }
const nodes = [{ id: 'control', type: 'control', position: { x: 200, y: 200 }, data: {} }]
function Canvas() {
const containerRef = useRef<HTMLDivElement>(null)
const { zoomTo, setViewport, getViewport } = useReactFlow()
useWorkflowControlScale(containerRef)
return (
<>
{[0.25, 0.5, 1].map((zoom) => (
<button type="button" key={zoom} onClick={() => zoomTo(zoom)}>
Zoom {zoom * 100}%
</button>
))}
<button
type="button"
onClick={() => {
const viewport = getViewport()
setViewport({ ...viewport, x: viewport.x + 20, y: viewport.y + 20 })
}}
>
Pan canvas
</button>
<div ref={containerRef} style={{ width: 800, height: 600 }}>
<ReactFlow defaultNodes={nodes} nodeTypes={nodeTypes} minZoom={0.25} />
</div>
</>
)
}
it('keeps a 24 CSS pixel canvas action reachable while zooming out and panning', async () => {
// Browser-owned: nested CSS transforms determine the actual pointer target, not its layout size.
await page.viewport(1000, 800)
const screen = await render(
<ReactFlowProvider>
<Canvas />
</ReactFlowProvider>,
)
const action = screen.getByRole('button', { name: 'Canvas action' })
for (const [index, zoom] of [1, 0.5, 0.25, 1].entries()) {
await screen.getByRole('button', { name: `Zoom ${zoom * 100}%` }).click()
await expect.poll(() => action.element().getBoundingClientRect().width).toBeCloseTo(24)
const target = action.element().getBoundingClientRect()
const node = screen
.getByRole('group', { name: 'Canvas node' })
.element()
.getBoundingClientRect()
expect(target.height).toBeCloseTo(24)
expect(target.x + target.width / 2).toBeCloseTo(node.x + node.width / 2)
expect(target.y + target.height / 2).toBeCloseTo(node.y + node.height / 2)
await action.click({ position: { x: 2, y: 2 } })
await expect.element(screen.getByLabelText('Action count')).toHaveTextContent(String(index + 1))
}
const beforePan = action.element().getBoundingClientRect()
await screen.getByRole('button', { name: 'Pan canvas' }).click()
await expect.poll(() => action.element().getBoundingClientRect().x).toBeCloseTo(beforePan.x + 20)
expect(action.element().getBoundingClientRect().width).toBeCloseTo(24)
await action.click({ position: { x: 22, y: 22 } })
await expect.element(screen.getByLabelText('Action count')).toHaveTextContent('5')
})
@@ -49,7 +49,6 @@ function TestNode({ data }: NodeProps) {
<div style={{ width: 200, height: 100 }}>
<Handle type="target" position={Position.Left} />
<span>{data.title}</span>
<textarea className="nodrag" aria-label={`${data.title} editor`} />
<Handle type="source" position={Position.Right} />
</div>
)
@@ -95,30 +94,19 @@ function Fixture() {
)
}
it('moves a Tab-focused node without selecting it first, at canvas scale without consuming editor keys', async () => {
// Browser-owned: native tab order, focus visibility, and transformed canvas geometry.
it('moves a Tab-focused node without selecting it first, at canvas scale', async () => {
// Browser-owned: native tab order and transformed canvas geometry.
await page.viewport(1000, 800)
const screen = await render(<Fixture />)
await screen.getByRole('button', { name: 'Before canvas' }).click()
await userEvent.tab()
const node = screen.getByRole('button', { name: 'Code', exact: true })
await expect.element(node).toHaveFocus()
expect(getComputedStyle(node.element()).outlineStyle).toBe('solid')
const edge = screen.getByRole('img', { name: 'Edge from node to output' })
await expect.element(edge).toBeVisible()
const initial = node.element().getBoundingClientRect()
const initialEdge = edge.element().getBoundingClientRect()
await userEvent.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
await expect.poll(() => node.element().getBoundingClientRect().x).toBe(initial.x + 2.5)
expect(node.element().getBoundingClientRect().y).toBe(initial.y + 10)
expect(edge.element().getBoundingClientRect().x).toBe(initialEdge.x + 2.5)
expect(edge.element().getBoundingClientRect().y).toBe(initialEdge.y + 10)
await expect.element(node).toHaveFocus()
await userEvent.tab()
await expect.element(screen.getByRole('textbox', { name: 'Code editor' })).toHaveFocus()
await userEvent.keyboard('{ArrowRight}')
expect(node.element().getBoundingClientRect().x).toBe(initial.x + 2.5)
await userEvent.tab({ shift: true })
await userEvent.keyboard('{Escape}{ArrowRight}')
await expect.poll(() => node.element().getBoundingClientRect().x).toBe(initial.x + 5)
})
@@ -189,6 +189,71 @@ describe('Comment thread focus', () => {
expect(onClose).not.toHaveBeenCalled()
})
it('keeps reply deletion pending until the reply disappears, then focuses the reply input', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onDelete = vi.fn()
let finishDeletion!: () => void
const deletion = new Promise<void>((resolve) => {
finishDeletion = resolve
})
const initial = createComment()
initial.replies = [
{
id: 'reply-1',
content: 'A reply',
created_at: 2,
created_by: 'user-1',
created_by_account: initial.created_by_account,
},
]
function Thread() {
const [comment, setComment] = useState(initial)
const [loading, setLoading] = useState(false)
return (
<CommentThread
comment={comment}
loading={loading}
onClose={() => {}}
onReply={() => {}}
onReplyEdit={() => {}}
onReplyDeleteDirect={async (id) => {
setLoading(true)
onDelete(id)
await deletion
setComment({ ...initial, replies: [] })
setLoading(false)
}}
/>
)
}
renderWithAccountProfile(<Thread />)
await act(() => vi.advanceTimersByTimeAsync(100))
await user.click(
screen.getByRole('button', { name: 'workflowComments.comments.aria.replyActions' }),
)
await user.click(
screen.getByRole('menuitem', { name: 'workflowComments.comments.actions.deleteReply' }),
)
const dialog = await screen.findByRole('alertdialog')
const confirm = within(dialog).getByRole('button', { name: 'common.operation.delete' })
await user.click(confirm)
await waitFor(() => expect(confirm).toHaveAttribute('aria-disabled', 'true'))
expect(within(dialog).getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled()
await user.click(confirm)
await user.keyboard('{Escape}')
expect(dialog).toBeInTheDocument()
expect(onDelete).toHaveBeenCalledExactlyOnceWith('reply-1')
await act(async () => finishDeletion())
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(
screen.queryByRole('button', { name: 'workflowComments.comments.aria.replyActions' }),
).not.toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus())
})
it('focuses the reply input when navigating to another comment', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onReply = vi.fn()
@@ -111,7 +111,9 @@ it('preserves draft mouse dragging and lets keyboard users move the same handle'
await userEvent.tab()
await expect.element(handle).toHaveFocus()
const dragged = handle.element().getBoundingClientRect()
await userEvent.keyboard('{Enter}{ArrowRight}{Shift>}{ArrowDown}{/Shift}{Enter}')
await userEvent.keyboard('{Enter}')
await expect.element(handle).toHaveAttribute('aria-pressed', 'true')
await userEvent.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}{Enter}')
await expect.poll(() => handle.element().getBoundingClientRect().x).toBe(dragged.x + 5)
expect(handle.element().getBoundingClientRect().y).toBe(dragged.y + 20)
await expect.element(handle).toHaveAttribute('aria-pressed', 'false')
@@ -48,15 +48,7 @@ const comment: WorkflowCommentDetail = {
],
}
function Fixture({
onDelete,
detail = comment,
loading = false,
}: {
onDelete: (id: string) => Promise<void> | void
detail?: WorkflowCommentDetail
loading?: boolean
}) {
function Fixture({ onDelete }: { onDelete: (id: string) => void }) {
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
)
@@ -66,8 +58,7 @@ function Fixture({
<button type="button">Outside thread</button>
<div id="workflow-container" style={{ position: 'relative', width: 1000, height: 650 }}>
<CommentThread
comment={detail}
loading={loading}
comment={comment}
onClose={() => {}}
onReply={() => {}}
onReplyEdit={() => {}}
@@ -112,61 +103,3 @@ it('lets keyboard users request reply deletion and returns focus to reply action
await expect.element(trigger).toHaveFocus()
expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
})
function createPendingRequest() {
let resolve!: () => void
const promise = new Promise<void>((complete) => {
resolve = complete
})
return { promise, resolve }
}
it('keeps reply deletion pending through refetch and restores focus to the input after the reply disappears', async () => {
const deletion = createPendingRequest()
const refetch = createPendingRequest()
const onDelete = vi.fn()
function DeletionFixture() {
const [detail, setDetail] = useState(comment)
const [loading, setLoading] = useState(false)
return (
<Fixture
detail={detail}
loading={loading}
onDelete={async (id) => {
setLoading(true)
onDelete(id)
await deletion.promise
await refetch.promise
setDetail({ ...comment, replies: [] })
setLoading(false)
}}
/>
)
}
const screen = await render(<DeletionFixture />)
const replyInput = screen.getByRole('textbox')
await expect.element(replyInput).toHaveFocus()
await userEvent.tab({ shift: true })
await userEvent.keyboard('{ArrowDown}{End}{Enter}')
const confirmation = screen.getByRole('alertdialog')
const confirm = confirmation.getByRole('button', { name: 'common.operation.delete' })
const cancel = confirmation.getByRole('button', { name: 'common.operation.cancel' })
await confirm.click()
await expect.element(confirm).toBeDisabled()
await expect.element(cancel).toBeDisabled()
await userEvent.keyboard('{Enter}{Escape}')
await expect.element(confirmation).toBeVisible()
expect(onDelete).toHaveBeenCalledExactlyOnceWith('reply')
deletion.resolve()
await expect.element(confirmation).toBeVisible()
await expect.element(confirm).toBeDisabled()
refetch.resolve()
await expect.element(confirmation).not.toBeInTheDocument()
await expect
.element(screen.getByRole('button', { name: 'workflowComments.comments.aria.replyActions' }))
.not.toBeInTheDocument()
await expect.element(replyInput).toHaveFocus()
})
@@ -117,6 +117,8 @@ it.each([0, 1])(
const node = screen.getByRole('button', { name: 'Code node', exact: true })
const trigger = screen.getByRole('button', { name: 'workflow.common.addBlock' }).nth(index)
await before.click()
await expect.element(trigger).toBeInTheDocument()
expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(false)
await userEvent.tab()
await expect.element(node).toHaveFocus()
// Click the real React Flow handle: its transparent button intentionally does not receive pointer events.
@@ -126,31 +128,15 @@ it.each([0, 1])(
await userEvent.keyboard('{Escape}')
await expect.element(trigger).toHaveFocus()
await expect.poll(() => trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard('{Enter}')
await expect.element(screen.getByRole('dialog')).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(trigger).toHaveFocus()
},
)
it('reveals an unselected node handle when reached by Tab', async () => {
const screen = await renderCanvas()
const trigger = screen.getByRole('button', { name: 'workflow.common.addBlock' }).first()
await screen.getByRole('button', { name: 'Before canvas' }).click()
expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(false)
await userEvent.tab()
await userEvent.tab()
await expect.element(screen.getByRole('button', { name: 'common.operation.more' })).toHaveFocus()
await userEvent.tab()
await expect.element(trigger).toHaveFocus()
await expect.poll(() => trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
})
it('keeps More visible and focused after closing an unselected node menu away from hover', async () => {
const screen = await renderCanvas()
const before = screen.getByRole('button', { name: 'Before canvas' })
const more = screen.getByRole('button', { name: 'common.operation.more' })
await before.click()
await expect.element(more).toBeInTheDocument()
expect(more.element().checkVisibility({ checkOpacity: true })).toBe(false)
await screen.getByRole('button', { name: 'Code node', exact: true }).hover()
await more.click()
@@ -159,8 +145,6 @@ it('keeps More visible and focused after closing an unselected node menu away fr
await userEvent.keyboard('{Escape}')
await expect.element(more).toHaveFocus()
expect(more.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard('{Enter}')
await expect.element(screen.getByRole('menu')).toBeVisible()
})
it.each([0, 1])(
@@ -170,6 +154,7 @@ it.each([0, 1])(
const trigger = screen.getByRole('button', { name: 'workflow.common.addBlock' }).nth(index)
const editor = screen.getByRole('textbox', { name: 'Outside editor' })
const input = editor.element()
await expect.element(trigger).toBeInTheDocument()
await userEvent.click(trigger.element().parentElement!)
await expect.element(screen.getByRole('dialog')).toBeVisible()
await userEvent.click(input)
@@ -125,7 +125,7 @@ function Canvas({
useWorkflowControlScale(containerRef)
return (
<>
{[1, 0.5, 0.25].map((zoom) => (
{[1, 0.25].map((zoom) => (
<button key={zoom} type="button" onClick={() => setViewport({ x: 0, y: 0, zoom })}>
Zoom {zoom * 100}%
</button>
@@ -183,13 +183,15 @@ function Fixture(props: { initialZoom?: number; showEdge?: boolean; humanInput?:
)
}
it.each([1, 0.5, 0.25])(
it.each([1, 0.25])(
'keeps real handles clickable at their edges and draggable at zoom %s',
async (zoom) => {
// Native hit testing under both ReactFlow and control transforms is not represented by DOM unit tests.
await page.viewport(1100, 700)
const screen = await render(<Fixture />)
await screen.getByRole('button', { name: `Zoom ${zoom * 100}%` }).click()
await expect.element(screen.getByRole('group', { name: 'Source node' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'Target node' })).toBeInTheDocument()
// ReactFlow handle elements are a canvas boundary without a semantic locator.
const source = page.elementLocator(
screen
@@ -236,6 +238,8 @@ it.each([1, 0.5, 0.25])(
it('keeps a saved connection anchored to the port centers when initialized at 25% zoom', async () => {
await page.viewport(1100, 700)
const screen = await render(<Fixture initialZoom={0.25} showEdge />)
await expect.element(screen.getByRole('group', { name: 'Source node' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'Target node' })).toBeInTheDocument()
const source = screen
.getByRole('group', { name: 'Source node' })
.element()
@@ -276,6 +280,8 @@ it('adds and connects the intended human input branch at 25% zoom', async () =>
await page.viewport(1100, 700)
const screen = await render(<Fixture initialZoom={0.25} humanInput />)
const sourceNode = screen.getByRole('group', { name: 'Human input node' })
await expect.element(sourceNode).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'Target node' })).toBeInTheDocument()
const target = page.elementLocator(
screen
.getByRole('group', { name: 'Target node' })
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
import type { NodeProps, ResizeParamsWithDirection } from 'reactflow'
import type { CommonNodeType } from '@/app/components/workflow/types'
import type { ResizeParamsWithDirection } from 'reactflow'
import ReactFlow from 'reactflow'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
@@ -9,7 +8,6 @@ import NoteNode from '@/app/components/workflow/note-node'
import { NoteTheme } from '@/app/components/workflow/note-node/types'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import NodeResizer from '../node-resizer'
import 'reactflow/dist/style.css'
vi.mock('../../../../hooks/use-workflow', () => ({
@@ -61,33 +59,11 @@ vi.mock('../../../../hooks/use-nodes-interactions', async () => {
}
})
function ResizableNode({ id, data }: NodeProps<CommonNodeType>) {
return (
<div
role="group"
aria-label="Resizable node"
className="group relative rounded-lg bg-components-panel-bg"
style={{ width: data.width, height: data.height }}
>
<NodeResizer nodeId={id} nodeData={data} />
</div>
)
}
const nodeTypes = { resize: ResizableNode }
const noteNodeTypes = { note: NoteNode }
const nodes = [
{
id: 'node',
type: 'resize',
position: { x: 100, y: 100 },
data: { title: 'Note', desc: '', type: BlockEnum.Iteration, width: 300, height: 200 },
},
]
it('hides an unselected Note resize handle from pointer targeting until hover or keyboard focus', async () => {
it('hides an unselected Note resize handle from pointer targeting until hover or keyboard focus, then resizes by dragging', async () => {
// Browser-owned: transparent descendants still participate in CSS hit testing,
// while native Tab focus must reveal the real Note's resize affordance.
// native Tab focus must reveal the real Note's resize affordance, and D3 drag needs real pointer events.
await page.viewport(1000, 800)
const store = createWorkflowStore({})
const screen = await render(
@@ -134,54 +110,16 @@ it('hides an unselected Note resize handle from pointer targeting until hover or
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(true)
const hoveredHit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)
expect(control.element().contains(hoveredHit)).toBe(true)
expect(node.element().getBoundingClientRect().width).toBe(300)
expect(node.element().getBoundingClientRect().height).toBe(200)
await beforeCanvas.click()
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(false)
await userEvent.tab()
await userEvent.tab()
await expect.element(control).toHaveFocus()
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard('{ArrowRight}{ArrowDown}')
await expect.poll(() => node.element().getBoundingClientRect().width).toBe(308)
expect(node.element().getBoundingClientRect().height).toBe(208)
})
it('reveals the resize handle on keyboard focus and preserves mouse dragging', async () => {
// Browser-owned: opacity on ancestors, native Tab navigation, focus rings and D3 drag hit testing.
await page.viewport(1000, 800)
const screen = await render(
<>
<button type="button">Before canvas</button>
<div role="group" aria-label="Canvas" style={{ width: 800, height: 600 }}>
<ReactFlow defaultNodes={nodes} nodeTypes={nodeTypes} />
</div>
</>,
)
const node = screen.getByRole('group', { name: 'Resizable node', exact: true })
const control = screen.getByRole('button', { name: 'common.resize.node' })
await expect.element(node).toBeVisible()
await screen.getByRole('button', { name: 'Before canvas' }).click()
await userEvent.tab()
await userEvent.tab()
await expect.element(control).toHaveFocus()
expect(control.element().checkVisibility({ checkOpacity: true })).toBe(true)
const focusStyle = getComputedStyle(control.element())
expect(focusStyle.boxShadow !== 'none' || focusStyle.outlineStyle !== 'none').toBe(true)
const original = node.element().getBoundingClientRect()
await userEvent.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
await expect.poll(() => node.element().getBoundingClientRect().width).toBe(308)
expect(node.element().getBoundingClientRect().height).toBe(232)
expect(node.element().getBoundingClientRect().left).toBe(original.left)
expect(node.element().getBoundingClientRect().top).toBe(original.top)
await userEvent.dragAndDrop(control, screen.getByRole('group', { name: 'Canvas', exact: true }), {
targetPosition: { x: 550, y: 450 },
})
await expect.poll(() => node.element().getBoundingClientRect().width).toBeGreaterThan(308)
expect(node.element().getBoundingClientRect().height).toBeGreaterThan(232)
await userEvent.keyboard('{Enter}')
await expect.poll(() => node.element().getBoundingClientRect().width).toBe(258)
expect(node.element().getBoundingClientRect().height).toBe(152)
await expect.poll(() => node.element().getBoundingClientRect().width).toBeGreaterThan(300)
expect(node.element().getBoundingClientRect().height).toBeGreaterThan(200)
})
@@ -44,7 +44,7 @@ it('reveals next-step actions to keyboard users and keeps restored focus visible
await expect.element(more).toHaveFocus()
expect(more.element().checkVisibility({ opacityProperty: true })).toBe(true)
await userEvent.keyboard('{Enter}')
await expect.element(page.getByRole('menuitem', { name: 'workflow.panel.change' })).toHaveFocus()
await expect.element(page.getByRole('menu')).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(page.getByRole('menu')).not.toBeInTheDocument()
@@ -1,50 +0,0 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { CurlImportDialog } from '../curl-import-dialog'
afterEach(() => vi.unstubAllGlobals())
it('keeps Enter as a newline and restores focus with a fresh draft after Cancel and Escape', async () => {
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
const onImport = vi.fn()
const screen = await render(<CurlImportDialog onImport={onImport} readOnly={false} />)
const trigger = screen.getByRole('button', { name: 'workflowIntegrations.nodes.http.curl.title' })
const dialog = screen.getByRole('dialog', { name: 'workflowIntegrations.nodes.http.curl.title' })
const textarea = screen.getByRole('textbox', {
name: 'workflowIntegrations.nodes.http.curl.title',
})
for (const dismissal of ['Cancel', 'Escape']) {
await trigger.click()
await expect.element(textarea).toHaveFocus()
await expect.element(textarea).toHaveValue('')
await textarea.fill('curl https://example.com')
await userEvent.keyboard('{Enter}')
await expect.element(textarea).toHaveValue('curl https://example.com\n')
await expect.element(dialog).toBeVisible()
expect(onImport).not.toHaveBeenCalled()
if (dismissal === 'Cancel')
await screen.getByRole('button', { name: 'common.operation.cancel' }).click()
else await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
}
})
it('returns focus to the trigger after a successful import', async () => {
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
const onImport = vi.fn()
const screen = await render(<CurlImportDialog onImport={onImport} readOnly={false} />)
const trigger = screen.getByRole('button', { name: 'workflowIntegrations.nodes.http.curl.title' })
await trigger.click()
const dialog = screen.getByRole('dialog', { name: 'workflowIntegrations.nodes.http.curl.title' })
await screen
.getByRole('textbox', { name: 'workflowIntegrations.nodes.http.curl.title' })
.fill('curl https://example.com')
await screen.getByRole('button', { name: 'common.operation.save' }).click()
expect(onImport).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ method: 'get', url: 'https://example.com' }),
)
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
})
@@ -1,7 +1,6 @@
import type { StructuredOutput } from '../../types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { consoleQuery } from '@/service/console'
@@ -77,37 +76,6 @@ function renderOutput() {
beforeEach(() => vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false))
afterEach(() => vi.unstubAllGlobals())
it.each(['configure', 'notConfiguredTip'])(
'returns focus to the %s trigger and starts a fresh schema session',
async (entrypoint) => {
const screen = await renderOutput()
const trigger = screen.getByRole('button', { name: `app.structOutput.${entrypoint}` })
const dialog = screen.getByRole('dialog', { name: 'workflowModels.nodes.llm.jsonSchema.title' })
await trigger.click()
await expect.element(dialog).toBeVisible()
const visualTab = screen.getByRole('tab', { name: 'Visual Editor' })
const jsonTab = screen.getByRole('tab', { name: 'JSON Schema' })
await expect.element(visualTab).toHaveAttribute('aria-selected', 'true')
await jsonTab.click()
await screen.getByRole('textbox', { name: 'JSON schema editor' }).fill('unfinished draft')
if (entrypoint === 'configure')
await screen.getByRole('button', { name: 'common.operation.cancel' }).click()
else await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
await trigger.click()
await expect.element(visualTab).toHaveAttribute('aria-selected', 'true')
await jsonTab.click()
await expect
.element(screen.getByRole('textbox', { name: 'JSON schema editor' }))
.toHaveValue(JSON.stringify(emptySchema, null, 2))
await screen.getByRole('button', { name: 'common.operation.save' }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
},
)
it('returns focus to Configure when saving a schema removes the empty-state trigger', async () => {
const screen = await renderOutput()
const emptyTrigger = screen.getByRole('button', { name: 'app.structOutput.notConfiguredTip' })
@@ -122,14 +90,4 @@ it('returns focus to Configure when saving a schema removes the empty-state trig
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(emptyTrigger).not.toBeInTheDocument()
await expect.element(configure).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(dialog).toBeVisible()
await screen.getByRole('tab', { name: 'JSON Schema' }).click()
await expect
.element(screen.getByRole('textbox', { name: 'JSON schema editor' }))
.toHaveValue(JSON.stringify(namedSchema, null, 2))
await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(configure).toHaveFocus()
})
@@ -33,6 +33,5 @@ describe('Webhook parameter table keyboard visibility', () => {
)
effectiveOpacity *= Number(getComputedStyle(element).opacity)
expect(effectiveOpacity).toBe(1)
expect(getComputedStyle(deleteButton.element()).boxShadow).not.toBe('none')
})
})
@@ -52,7 +52,7 @@ function Harness({ secondLinkUrl }: { secondLinkUrl?: string }) {
}
// Chromium owns contenteditable selection and the native input that follows focus restoration.
it.each(['URL input', 'confirm button', 'toolbar edit button'])(
it.each(['URL input', 'toolbar edit button'])(
'dismisses the entire link popup with Escape from the %s until explicitly reopened',
async (target) => {
await render(<Harness />)
@@ -71,12 +71,6 @@ it.each(['URL input', 'confirm button', 'toolbar edit button'])(
} else {
await edit.click()
await expect.element(urlInput).toHaveFocus()
if (target === 'confirm button') {
await userEvent.keyboard('{Tab}')
await expect
.element(page.getByRole('button', { name: 'common.operation.ok' }))
.toHaveFocus()
}
}
await userEvent.keyboard('{Escape}')
@@ -112,8 +106,8 @@ it('keeps focus on the outside action when it dismisses link editing', async ()
await expect.element(outside).toHaveFocus()
})
// Native button activation and sequential focus navigation must preserve the Lexical selection.
it.each(['{Enter}', '{Space}'])('opens link editing with %s from the keyboard', async (key) => {
// Removing the link unmounts the focused toolbar; the editor must regain focus and its selection.
it('removes the selected link from the keyboard', async () => {
await render(<Harness />)
const note = page.getByRole('region', { name: 'Note editor' }).getByRole('textbox')
await note.click()
@@ -122,64 +116,38 @@ it.each(['{Enter}', '{Space}'])('opens link editing with %s from the keyboard',
await expect.element(openLink).toBeVisible()
;(openLink.element() as HTMLAnchorElement).focus()
await userEvent.keyboard('{Tab}')
const edit = page.getByRole('button', { name: 'common.operation.edit', exact: true })
await expect.element(edit).toHaveFocus()
await expect
.element(page.getByRole('button', { name: 'common.operation.edit', exact: true }))
.toHaveFocus()
await userEvent.keyboard('{Tab}')
await expect
.element(page.getByRole('button', { name: 'workflow.nodes.note.editor.unlink' }))
.toHaveFocus()
await userEvent.keyboard('{Shift>}{Tab}{/Shift}')
await expect.element(edit).toHaveFocus()
await userEvent.keyboard(key)
await userEvent.keyboard('{Enter}')
await expect.element(page.getByPlaceholder('workflow.nodes.note.editor.enterUrl')).toHaveFocus()
await userEvent.keyboard('{Escape}X')
await expect.element(page.getByRole('link', { name: 'hello' })).not.toBeInTheDocument()
await expect.element(note).toHaveFocus()
await userEvent.keyboard('X')
await expect.element(note).toHaveTextContent('heXllo')
})
it.each(['{Enter}', '{Space}'])(
'removes the selected link with %s from the keyboard',
async (key) => {
await render(<Harness />)
const note = page.getByRole('region', { name: 'Note editor' }).getByRole('textbox')
await note.click()
await userEvent.keyboard('{Home}{ArrowRight}{ArrowRight}')
const openLink = page.getByRole('link', { name: /workflow.nodes.note.editor.openLink/ })
await expect.element(openLink).toBeVisible()
;(openLink.element() as HTMLAnchorElement).focus()
await userEvent.keyboard('{Tab}{Tab}')
await expect
.element(page.getByRole('button', { name: 'workflow.nodes.note.editor.unlink' }))
.toHaveFocus()
it('opens a different link with the same URL after dismissing the first', async () => {
const secondLinkUrl = 'https://example.com'
await render(<Harness secondLinkUrl={secondLinkUrl} />)
const note = page.getByRole('region', { name: 'Note editor' }).getByRole('textbox')
const openLink = page.getByRole('link', { name: /workflow.nodes.note.editor.openLink/ })
await expect.element(page.getByRole('link', { name: 'hello', exact: true })).toBeVisible()
await expect.element(page.getByRole('link', { name: 'world', exact: true })).toBeVisible()
await note.click()
await userEvent.keyboard('{Home}{ArrowRight}')
await page.getByRole('button', { name: 'common.operation.edit', exact: true }).click()
await userEvent.keyboard('{Escape}')
await expect.element(note).toHaveFocus()
await expect.element(openLink).not.toBeInTheDocument()
await userEvent.keyboard(key)
await userEvent.keyboard('{End}{ArrowLeft}')
await expect.element(page.getByRole('link', { name: 'hello' })).not.toBeInTheDocument()
await expect.element(note).toHaveFocus()
await userEvent.keyboard('X')
await expect.element(note).toHaveTextContent('heXllo')
},
)
it.each(['https://second.example.com', 'https://example.com'])(
'opens a different link after dismissing the first when its URL is %s',
async (secondLinkUrl) => {
await render(<Harness secondLinkUrl={secondLinkUrl} />)
const note = page.getByRole('region', { name: 'Note editor' }).getByRole('textbox')
const openLink = page.getByRole('link', { name: /workflow.nodes.note.editor.openLink/ })
await expect.element(page.getByRole('link', { name: 'hello', exact: true })).toBeVisible()
await expect.element(page.getByRole('link', { name: 'world', exact: true })).toBeVisible()
await note.click()
await userEvent.keyboard('{Home}{ArrowRight}')
await page.getByRole('button', { name: 'common.operation.edit', exact: true }).click()
await userEvent.keyboard('{Escape}')
await expect.element(note).toHaveFocus()
await expect.element(openLink).not.toBeInTheDocument()
await userEvent.keyboard('{End}{ArrowLeft}')
await expect.element(openLink).toBeVisible()
await expect.element(openLink).toHaveAttribute('href', secondLinkUrl)
},
)
await expect.element(openLink).toBeVisible()
await expect.element(openLink).toHaveAttribute('href', secondLinkUrl)
})
@@ -1,14 +1,9 @@
import type { ContextMenuActions } from '@langgenius/dify-ui/context-menu'
import type { Edge, Node, NodeProps } from 'reactflow'
import type { StoreApi } from 'zustand'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@langgenius/dify-ui/context-menu'
import { ContextMenu, ContextMenuTrigger } from '@langgenius/dify-ui/context-menu'
import { detectPlatform } from '@tanstack/react-hotkeys'
import { useEffect, useRef, useState } from 'react'
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Handle, Position, ReactFlowProvider, useStore as useReactFlowStore } from 'reactflow'
import { page, userEvent } from 'vite-plus/test/browser'
@@ -216,11 +211,7 @@ function Canvas({
<SelectionContextmenu onClose={() => contextMenuActionsRef.current?.close()} />
) : contextMenuType === 'edge' ? (
<EdgeContextmenu onClose={() => contextMenuActionsRef.current?.close()} />
) : (
<ContextMenuContent>
<ContextMenuItem>Canvas menu action</ContextMenuItem>
</ContextMenuContent>
)}
) : null}
</ContextMenu>
<input aria-label="Outside canvas input" />
</ReactFlowProvider>
@@ -244,7 +235,6 @@ it('admits and releases keyboard focus while keeping character shortcuts inside
const before = screen.getByRole('button', { name: 'Before canvas' })
const canvas = screen.getByRole('region', { name: 'app.types.workflow' })
const node = screen.getByRole('button', { name: 'Workflow node' })
const outside = screen.getByRole('textbox', { name: 'Outside canvas input' })
await userEvent.keyboard('{Tab}')
await expect.element(before).toHaveFocus()
@@ -269,19 +259,8 @@ it('admits and releases keyboard focus while keeping character shortcuts inside
await userEvent.keyboard('vhc{Shift>}[Digit5]{/Shift}')
expect(node.element().getBoundingClientRect().width).toBeCloseTo(initialWidth)
await userEvent.keyboard('{Tab}')
await expect.element(canvas).toHaveFocus()
for (let steps = 0; steps < 10 && document.activeElement !== outside.element(); steps++)
await userEvent.keyboard('{Tab}')
await expect.element(outside).toHaveFocus()
await userEvent.keyboard('vhc')
await expect.element(outside).toHaveValue('vhc')
await screen.getByRole('textbox', { name: 'workflow.nodes.note.editor.label' }).click()
await userEvent.keyboard('vhc')
await screen.getByRole('button', { name: 'Popup action' }).click()
await userEvent.keyboard('vhc{Shift>}[Digit5]{/Shift}')
expect(node.element().getBoundingClientRect().width).toBeCloseTo(initialWidth)
expect(actions.pointer).toHaveBeenCalledOnce()
expect(actions.hand).toHaveBeenCalledOnce()
expect(actions.comment).toHaveBeenCalledOnce()
@@ -357,7 +336,7 @@ it('keeps canvas focus when delete, undo, or redo removes the focused node', asy
await expect.element(node).toBeInTheDocument()
})
it('lets the production Note editor own text history and returns graph history on a pane click', async () => {
it('lets the production Note editor own text history', async () => {
await page.viewport(1000, 700)
const screen = await render(<Canvas />)
const note = screen.getByRole('textbox', { name: 'workflow.nodes.note.editor.label' })
@@ -371,27 +350,6 @@ it('lets the production Note editor own text history and returns graph history o
await userEvent.keyboard(`{${mod}>}{Shift>}z{/Shift}{/${mod}}`)
await expect.element(note).toHaveTextContent('Note text')
expect(actions.redo).not.toHaveBeenCalled()
const canvas = screen.getByRole('region', { name: 'app.types.workflow' })
await canvas.click({ position: { x: 650, y: 400 } })
await expect.element(canvas).toHaveFocus()
await userEvent.keyboard(`{${mod}>}z{/${mod}}`)
expect(actions.undo).toHaveBeenCalledTimes(1)
})
it('returns focus to the canvas after a context menu opened from outside focus closes', async () => {
await page.viewport(1000, 700)
const screen = await render(<Canvas />)
const outsideInput = screen.getByRole('textbox', { name: 'Outside canvas input' })
await outsideInput.click()
const canvas = screen.getByRole('region', { name: 'app.types.workflow' })
await canvas.click({ button: 'right', position: { x: 650, y: 400 } })
await expect.element(screen.getByRole('menuitem', { name: 'Canvas menu action' })).toBeVisible()
await userEvent.keyboard('{ArrowDown}{Escape}')
await expect.element(canvas).toHaveFocus()
const mod = detectPlatform() === 'mac' ? 'Meta' : 'Control'
await userEvent.keyboard(`{${mod}>}z{/${mod}}`)
expect(actions.undo).toHaveBeenCalledTimes(1)
})
it.each(['menu', 'note'])(
@@ -575,63 +533,6 @@ function VariableConnectionCanvas({ onSelect }: { onSelect: (value: string[]) =>
)
}
function KeyboardPanelFocusCanvas({ store }: { store: ReturnType<typeof createWorkflowStore> }) {
const [open, setOpen] = useState(false)
const pendingFocusId = useStore(store, (state) => state.pendingNodePanelFocusId)
const panelRef = useRef<HTMLDivElement>(null)
const descriptionRef = useRef<HTMLTextAreaElement>(null)
const handleNodeKeyDown = useNodeKeyboardInteractions(() => setOpen(true))
useEffect(() => {
if (!open || pendingFocusId !== 'node') return
;(descriptionRef.current ?? panelRef.current)?.focus({ preventScroll: true })
store.getState().setPendingNodePanelFocusId(undefined)
}, [open, pendingFocusId, store])
return (
<>
<div style={{ width: 800, height: 500 }}>
<WorkflowCanvas
nodes={[
{
id: 'node',
type: 'focus',
position: { x: 100, y: 100 },
data: { type: BlockEnum.Code },
},
]}
edges={[]}
nodeTypes={{ focus: () => <div>Workflow node</div> }}
onKeyDownCapture={handleNodeKeyDown}
/>
</div>
{open && (
<div ref={panelRef} role="region" aria-label="Node configuration" tabIndex={-1}>
<textarea ref={descriptionRef} aria-label="Description" />
<button type="button">First setting</button>
</div>
)}
</>
)
}
it('keeps focus in the panel after Enter opens it from a real ReactFlow node', async () => {
const store = createWorkflowStore({})
const screen = await render(
<WorkflowContext value={store}>
<ReactFlowProvider>
<KeyboardPanelFocusCanvas store={store} />
</ReactFlowProvider>
</WorkflowContext>,
)
const node = screen.getByTestId('rf__node-node')
await node.click()
await expect.element(node).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(screen.getByRole('textbox', { name: 'Description' })).toHaveFocus()
})
it('selects a connection variable before node movement handles the focused node keys', async () => {
// Real handle dragging preserves node focus; React capture must not move that node before the picker sees its keys.
await page.viewport(1000, 700)
@@ -657,7 +558,13 @@ it('selects a connection variable before node movement handles the focused node
await expect.element(source).toHaveFocus()
const sourcePosition = source.element().getBoundingClientRect()
await userEvent.keyboard('{ArrowDown}{Enter}')
await userEvent.keyboard('{ArrowDown}')
await expect
.poll(() =>
screen.getByText('second', { exact: true }).element().closest('[data-selected="true"]'),
)
.not.toBeNull()
await userEvent.keyboard('{Enter}')
expect(onSelect).toHaveBeenCalledExactlyOnceWith(
['source', 'second'],
@@ -3,195 +3,72 @@ import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { AppToastHost } from '../host'
// Use the real clipboard adapter and browser focus order: the copy action must remain
// keyboard-operable, including when the adapter falls back to selection-based copying.
it('copies through the keyboard action and retains focus after feedback', async () => {
const description = Array.from(
{ length: 20 },
(_, index) => `Storage request ${index}: connection refused at storage.internal:19000`,
).join('\n')
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
const manager = createToastManager()
const toast = createToast(manager)
try {
const screen = await render(<AppToastHost manager={manager} timeout={0} />)
toast.error('Upload failed', { description })
await expect.element(screen.getByText('Upload failed')).toBeInTheDocument()
await userEvent.keyboard('{F6}{Tab}{Tab}')
const action = screen.getByRole('button', { name: 'common.operation.copyErrorDetails' })
await expect.element(action).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toHaveFocus()
expect(writeText).toHaveBeenCalledExactlyOnceWith(`Upload failed\n${description}`)
} finally {
toast.dismiss()
writeText.mockRestore()
}
})
it('preserves whitespace and action focus when clipboard copying falls back to execCommand', async () => {
const description = 'First line\n indented text\n\ttabbed text'
let selectedText = ''
const writeText = vi
.spyOn(navigator.clipboard, 'writeText')
.mockRejectedValue(new Error('Denied'))
const execCommand = vi.spyOn(document, 'execCommand').mockImplementation(() => {
selectedText = document.getSelection()?.toString() ?? ''
return true
})
const manager = createToastManager()
const toast = createToast(manager)
try {
const screen = await render(<AppToastHost manager={manager} timeout={0} />)
toast.error('Connection failed', { description })
await expect.element(screen.getByText('Connection failed')).toBeInTheDocument()
await userEvent.keyboard('{F6}{Tab}{Tab}{Enter}')
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toHaveFocus()
expect(execCommand).toHaveBeenCalledExactlyOnceWith('copy')
expect(selectedText).toBe(`Connection failed\n${description}`)
} finally {
toast.dismiss()
writeText.mockRestore()
execCommand.mockRestore()
}
})
it('keeps an accessible focused action when copying fails and is retried with Space', async () => {
const writeText = vi
.spyOn(navigator.clipboard, 'writeText')
.mockRejectedValueOnce(new Error('Denied'))
.mockResolvedValue(undefined)
const execCommand = vi.spyOn(document, 'execCommand').mockReturnValue(false)
const manager = createToastManager()
const toast = createToast(manager)
try {
const screen = await render(<AppToastHost manager={manager} timeout={0} />)
toast.error('Connection failed')
await expect.element(screen.getByText('Connection failed')).toBeInTheDocument()
await userEvent.keyboard('{F6}{Tab}{Tab}{Enter}')
await expect
.element(screen.getByRole('button', { name: 'common.operation.copyErrorFailed' }))
.toHaveFocus()
await userEvent.keyboard(' ')
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toHaveFocus()
expect(writeText).toHaveBeenCalledTimes(2)
expect(writeText).toHaveBeenLastCalledWith('Connection failed')
} finally {
toast.dismiss()
writeText.mockRestore()
execCommand.mockRestore()
}
})
// Updating the card must not remount the focused copy button.
it('retains focus and copies current content after an error update', async () => {
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
const manager = createToastManager()
const toast = createToast(manager)
try {
const screen = await render(<AppToastHost manager={manager} timeout={0} />)
const id = toast.error('First error')
await expect.element(screen.getByText('First error')).toBeInTheDocument()
await userEvent.keyboard('{F6}{Tab}{Tab}{Enter}')
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toHaveFocus()
toast.update(id, { title: 'Updated error' })
await expect.element(screen.getByText('Updated error')).toBeInTheDocument()
await expect
.element(screen.getByRole('button', { name: 'common.operation.copyErrorDetails' }))
.toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toHaveFocus()
expect(writeText).toHaveBeenLastCalledWith('Updated error')
} finally {
toast.dismiss()
writeText.mockRestore()
}
})
// Real CSS and pointer hit testing prove the hidden floating action can be discovered
// and clicked without overflowing the card or starting swipe-to-dismiss.
it.each([1280, 360])(
'reveals a contained copy action on hover at %ipx and keeps the card open after copying',
async (width) => {
await page.viewport(width, 800)
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
const manager = createToastManager()
const toast = createToast(manager)
try {
const screen = await render(
<>
<button type="button" style={{ position: 'fixed', bottom: 16, left: 16 }}>
Outside notifications
</button>
<AppToastHost manager={manager} timeout={0} />
</>,
)
const outside = screen.getByRole('button', { name: 'Outside notifications' })
await userEvent.hover(outside)
toast.error('Upload failed', {
description: 'Storage connection refused. Please retry the request.',
})
const card = screen.getByRole('dialog', { name: 'Upload failed' })
const copy = screen.getByRole('button', { name: 'common.operation.copyErrorDetails' })
await expect.element(copy).toHaveStyle({ opacity: '0' })
await userEvent.hover(card)
await expect.element(copy).toHaveStyle({ opacity: '1' })
const cardBounds = card.element().getBoundingClientRect()
const buttonBounds = copy.element().getBoundingClientRect()
expect(buttonBounds.left).toBeGreaterThanOrEqual(cardBounds.left)
expect(buttonBounds.right).toBeLessThanOrEqual(cardBounds.right)
expect(buttonBounds.top).toBeGreaterThanOrEqual(cardBounds.top)
expect(buttonBounds.bottom).toBeLessThanOrEqual(cardBounds.bottom)
await userEvent.click(copy)
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toHaveStyle({ opacity: '1' })
expect(writeText).toHaveBeenCalledExactlyOnceWith(
'Upload failed\nStorage connection refused. Please retry the request.',
)
await expect.element(card).toBeInTheDocument()
await userEvent.hover(outside)
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toHaveStyle({ opacity: '0' })
await userEvent.hover(card)
await userEvent.click(screen.getByRole('button', { name: 'Close notification' }))
await expect.element(card).not.toBeInTheDocument()
} finally {
toast.dismiss()
writeText.mockRestore()
await page.viewport(1280, 720)
}
},
)
it('reveals the action for keyboard focus, supports reverse tabbing, and restores focus on Escape', async () => {
it('reveals a contained copy action on hover at 360px and keeps the card open after copying', async () => {
await page.viewport(360, 800)
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
const manager = createToastManager()
const toast = createToast(manager)
try {
const screen = await render(
<>
<button type="button">Return target</button>
<button type="button" style={{ position: 'fixed', bottom: 16, left: 16 }}>
Outside notifications
</button>
<AppToastHost manager={manager} timeout={0} />
</>,
)
const target = screen.getByRole('button', { name: 'Return target' })
await userEvent.click(target)
const outside = screen.getByRole('button', { name: 'Outside notifications' })
await userEvent.hover(outside)
toast.error('Upload failed', {
description: 'Storage connection refused. Please retry the request.',
})
const card = screen.getByRole('dialog', { name: 'Upload failed' })
const copy = screen.getByRole('button', { name: 'common.operation.copyErrorDetails' })
await expect.element(copy).toHaveStyle({ opacity: '0' })
await userEvent.hover(card)
await expect.element(copy).toHaveStyle({ opacity: '1' })
const cardBounds = card.element().getBoundingClientRect()
const buttonBounds = copy.element().getBoundingClientRect()
expect(buttonBounds.left).toBeGreaterThanOrEqual(cardBounds.left)
expect(buttonBounds.right).toBeLessThanOrEqual(cardBounds.right)
expect(buttonBounds.top).toBeGreaterThanOrEqual(cardBounds.top)
expect(buttonBounds.bottom).toBeLessThanOrEqual(cardBounds.bottom)
await userEvent.click(copy)
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toHaveStyle({ opacity: '1' })
expect(writeText).toHaveBeenCalledExactlyOnceWith(
'Upload failed\nStorage connection refused. Please retry the request.',
)
await expect.element(card).toBeInTheDocument()
await userEvent.hover(outside)
await expect
.element(screen.getByRole('button', { name: 'common.operation.copied' }))
.toHaveStyle({ opacity: '0' })
await userEvent.hover(card)
await userEvent.click(screen.getByRole('button', { name: 'Close notification' }))
await expect.element(card).not.toBeInTheDocument()
} finally {
toast.dismiss()
writeText.mockRestore()
await page.viewport(1280, 720)
}
})
it('reveals the action for keyboard focus and supports reverse tabbing', async () => {
const manager = createToastManager()
const toast = createToast(manager)
try {
const screen = await render(<AppToastHost manager={manager} timeout={0} />)
toast.error('Keyboard error')
const card = screen.getByRole('dialog', { name: 'Keyboard error' })
const copy = screen.getByRole('button', { name: 'common.operation.copyErrorDetails' })
await expect.element(copy).toHaveStyle({ opacity: '0' })
await userEvent.keyboard('{F6}{Tab}')
await userEvent.keyboard('{F6}')
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveFocus()
await userEvent.keyboard('{Tab}')
await expect.element(card).toHaveFocus()
await expect.element(copy).toHaveStyle({ opacity: '1' })
await userEvent.keyboard('{Tab}')
@@ -201,9 +78,6 @@ it('reveals the action for keyboard focus, supports reverse tabbing, and restore
await expect.element(screen.getByRole('button', { name: 'Close notification' })).toHaveFocus()
await userEvent.keyboard('{Shift>}{Tab}{/Shift}')
await expect.element(copy).toHaveFocus()
await userEvent.keyboard('{Escape}')
await expect.element(card).not.toBeInTheDocument()
await expect.element(target).toHaveFocus()
} finally {
toast.dismiss()
}
@@ -55,11 +55,15 @@ it('updates copy content and removes generated actions when the tone changes', a
act(() => {
id = toast('First error', { type: 'error' })
})
await user.click(screen.getByRole('button', { name: 'common.operation.copyErrorDetails' }))
act(() => {
toast.update(id, { title: 'Updated error' })
})
await user.click(screen.getByRole('button', { name: 'common.operation.copyErrorDetails' }))
expect(copy).toHaveBeenCalledWith('Updated error')
// Updating the card must not remount the focused copy action.
const action = screen.getByRole('button', { name: 'common.operation.copyErrorDetails' })
expect(action).toHaveFocus()
await user.click(action)
expect(copy).toHaveBeenLastCalledWith('Updated error')
act(() => {
toast.update(id, { type: 'success', title: 'Recovered' })
})
+6 -3
View File
@@ -40,8 +40,8 @@ Web defaults to the `unit` project in `happy-dom`. This is a project tradeoff, n
A new browser test must establish all three:
- **Value:** name the user-visible failure or stable visual requirement worth protecting. Layout, focus, portals, observers, and “happy-dom cannot do this” alone are not justification.
- **Additional evidence:** identify what existing owner tests miss and what real rendering or browser interaction adds. Do not repeat a state matrix just because a browser fixture exists.
- **Value:** name the user-visible failure or stable visual requirement worth protecting. Layout, focus, portals, observers, and “happy-dom cannot do this” alone are not justification; neither are primitive or native defaults such as focus returning to the trigger that opened an overlay or Space activating a button.
- **Additional evidence:** identify what existing owner tests miss and what real rendering or browser interaction adds. Do not repeat a state matrix just because a browser fixture exists; a viewport, theme, key, or variant row needs its own failure mode, otherwise keep the hardest row.
- **Faithful boundary:** exercise the production owner and assert the claimed result. A copied layout cannot prove page integration; cursor or hover styling cannot prove that clicking performs an action.
Use the smallest scenario that supplies that evidence. Native selection, focus restoration to CSS-hidden controls, hit testing, scrolling, and animation completion can justify browser coverage. Ordinary event handling and DOM state usually belong in `unit`. Synthetic events can test handler responses but do not prove native gesture generation, capture, or event ordering; use the [Vitest Interactivity API] when those are the contract.
@@ -50,6 +50,8 @@ Do not add permanent tests solely to freeze decorative spacing, radii, or artwor
Each `*.browser.spec.{ts,tsx}` must make its regression and additional evidence clear from its scenario and assertions; add a short explanation only when needed. Do not use forced interaction, fixed sleeps, private DOM assertions, or real network requests.
Browser input is real and nothing waits between keys. Split a `userEvent.keyboard` sequence wherever a later key depends on state an earlier key produces, such as an opened menu or moved focus, and await that state with `expect.element` before continuing.
Browser Mode remains a focused component or feature test and currently proves Chromium only. Use the end-to-end suite for a running application, authentication, real routing, backend APIs, persistence, or complete journeys.
## Assert Behavior, Not Implementation
@@ -78,7 +80,7 @@ When repeated content creates ambiguity, narrow to a semantic container, then qu
If an interactive control cannot be found semantically, first check whether the production markup needs a real button, link, label, landmark, or accessible name.
- In React Testing Library tests, use a `userEvent.setup()` instance inside the test. Use `fireEvent` only when the low-level event itself is the contract.
- In Browser Mode, interact through awaited locators. Use `.element()` only for DOM APIs that locators do not expose.
- In Browser Mode, interact through awaited locators. Use `.element()` only for DOM APIs that locators do not expose; it does not wait, so await the element with `expect.element` first.
- Semantic queries and automated checks do not constitute complete accessibility conformance.
- Exact copy assertions are valid when the copy or translation key is the contract; otherwise prefer a semantic query or resilient match.
- In React Testing Library, use `queryBy*` for synchronous absence, `findBy*` for asynchronous appearance, and `waitForElementToBeRemoved` or `waitFor` for asynchronous disappearance. In Browser Mode, use `expect.element` for eventual assertions.
@@ -113,6 +115,7 @@ Mocks must preserve the public contract needed by the test. Do not mock interact
- Following [Vite+ testing configuration], tests under `web/` use two explicit projects in `web/vite.config.ts`. Supported commands and CI select one project explicitly: `unit` runs in `happy-dom` and loads `web/vitest.setup.ts`, while `browser` runs matching `{app,features}/**/*.browser.spec.{ts,tsx}` files in Playwright Chromium and loads `web/vitest.browser.setup.ts`. Bare `vp test` runs both registered projects.
- Browser string locators use Vitest 5's exact, case-sensitive matching. Use a regular expression or a per-locator `{ exact: false }` only when partial matching protects the intended behavior. Node tests use `@testing-library/jest-dom/vitest`; a regular-expression check against rendered text can use `expect(element.textContent).toMatch(pattern)` when the Vitest 5 browser matcher types conflict with jest-dom's `toHaveTextContent` overload.
- Tests in one browser file share a page. `web/vitest.browser.setup.ts` resets the viewport and pointer before each test; set the viewport a test depends on inside that test.
- Browser failures keep screenshots and Playwright traces under `web/.vitest-browser/`. CI uploads that directory only when failure artifacts exist; Browser Mode does not own coverage or report merging.
- New component and feature specs should generally use a sibling `__tests__/` directory. Existing colocated utility and hook specs may follow their owning module's convention. Cross-feature integration specs belong in `web/__tests__/`.
- The shared `react-i18next` mock is loaded globally. Use `createReactI18nextMock` from `web/test/i18n-mock` only when a test needs custom translations.
@@ -114,7 +114,6 @@ it('preserves keyboard focus through loading, publication, and dismissal', async
await expect.poll(() => liveRegion.textContent).toBe(successTitle)
await expect.poll(() => document.activeElement).toBe(bar)
expect(bar.matches(':focus-visible')).toBe(true)
expect(bar.querySelector('[role="status"]')).toBe(liveRegion)
await userEvent.tab()
const dismiss = screen.getByRole('button', { name: dismissLabel })
await expect.element(dismiss).toHaveFocus()
@@ -188,6 +188,9 @@ describe('TagSelector', () => {
await user.click(screen.getByRole('combobox', { name: i18n.addTag }))
await user.click(await screen.findByRole('option', { name: 'Knowledge' }))
await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'NewTag')
await user.click(await screen.findByRole('option', { name: /NewTag/ }))
await user.click(screen.getByRole('button', { name: i18n.manageTags }))
expect(onOuterClick).not.toHaveBeenCalled()
} finally {
+2 -4
View File
@@ -93,6 +93,8 @@ export default defineConfig(({ command, mode, isPreview }) => {
// Vitest config
test: {
// Vitest 5 stores Browser Mode failure screenshots as attachments; keep them beside the traces.
attachmentsDir: './.vitest-browser/attachments',
coverage: {
provider: 'v8',
reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
@@ -135,14 +137,10 @@ export default defineConfig(({ command, mode, isPreview }) => {
setupFiles: ['./vitest.browser.setup.ts'],
include: [browserTestPattern],
browser: {
expect: {
toMatchScreenshot: { screenshotDirectory: './.vitest-browser/screenshots' },
},
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
headless: true,
screenshotDirectory: './.vitest-browser/screenshots',
screenshotFailures: true,
trace: {
mode: 'retain-on-failure',
+8
View File
@@ -1,4 +1,5 @@
import { vi } from 'vite-plus/test'
import { page, server, userEvent } from 'vite-plus/test/browser'
import './app/styles/globals.css'
document.documentElement.dataset.theme = 'light'
@@ -7,6 +8,13 @@ document.documentElement.dataset.theme = 'light'
globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean }
).BASE_UI_ANIMATIONS_DISABLED = true
// Tests in one file share a page, so the viewport and pointer position outlive the test that set them.
beforeEach(async () => {
const { width, height } = server.config.browser.viewport
await page.viewport(width, height)
await userEvent.unhover(document.body)
})
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
const { createReactI18nextMock } = await import('./test/i18n-mock')