mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): prune and stabilize browser tests (#43831)
This commit is contained in:
66 files changed
+721
-3316
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
+1
-1
@@ -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()
|
||||
})
|
||||
+2
-58
@@ -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
-163
@@ -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()
|
||||
})
|
||||
+5
-38
@@ -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
|
||||
|
||||
|
||||
-3
@@ -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()
|
||||
|
||||
+8
-91
@@ -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()
|
||||
|
||||
+7
-21
@@ -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'])
|
||||
})
|
||||
-28
@@ -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)
|
||||
})
|
||||
})
|
||||
+2
-122
@@ -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(''))
|
||||
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()
|
||||
})
|
||||
-118
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
+55
-68
@@ -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', () => {
|
||||
|
||||
-176
@@ -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)
|
||||
|
||||
+42
-55
@@ -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)
|
||||
}
|
||||
})
|
||||
-64
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
-34
@@ -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)
|
||||
|
||||
-19
@@ -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(
|
||||
|
||||
-256
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
+57
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
-208
@@ -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" />)
|
||||
|
||||
|
||||
+1
-55
@@ -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)
|
||||
|
||||
|
||||
-116
@@ -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()
|
||||
})
|
||||
})
|
||||
+51
@@ -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()
|
||||
})
|
||||
})
|
||||
+13
-129
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
+4
-17
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
-137
@@ -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()
|
||||
})
|
||||
+4
-19
@@ -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)
|
||||
|
||||
+8
-2
@@ -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' })
|
||||
|
||||
+5
-67
@@ -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)
|
||||
})
|
||||
+1
-1
@@ -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()
|
||||
|
||||
-50
@@ -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()
|
||||
})
|
||||
-42
@@ -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()
|
||||
})
|
||||
-1
@@ -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')
|
||||
})
|
||||
})
|
||||
+27
-59
@@ -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
@@ -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.
|
||||
|
||||
-1
@@ -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
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user