mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
test(web): migrate marketplace home specs to browser (#45952)
This commit is contained in:
1 parent
7bca398954
commit
116a990765
17 files changed
+1231
-2544
No files matched your search
Generated
-5
@@ -2923,11 +2923,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx": {
|
||||
"typescript/no-deprecated": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx": {
|
||||
"better-tailwindcss/enforce-logical-properties": {
|
||||
"count": 3
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import CatalogLanguagesFilter from '../catalog-languages-filter'
|
||||
import CatalogTagsFilter from '../catalog-tags-filter'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
...createReactI18nextMock({
|
||||
clearSearch: 'Clear {{label}}',
|
||||
'marketplace.languages': 'Languages',
|
||||
'marketplace.searchFilterLanguage': 'Search languages',
|
||||
'pluginTags.allTags': 'All tags',
|
||||
'pluginTags.clearSelectedTags': 'Clear {{tags}}',
|
||||
'pluginTags.searchTags': 'Search tags',
|
||||
}),
|
||||
useLocale: () => 'en-US',
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('react-i18next')>()
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
...original,
|
||||
...createReactI18nextMock({
|
||||
'pluginTags.allTags': 'All tags',
|
||||
'pluginTags.clearSelectedTags': 'Clear {{tags}}',
|
||||
'pluginTags.searchTags': 'Search tags',
|
||||
'pluginTags.tags.agent': 'Agent',
|
||||
'pluginTags.tags.rag': 'RAG',
|
||||
'pluginTags.tags.search': 'Search',
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
async function renderFilters(searchParams = '') {
|
||||
const { wrapper: UrlState, onUrlUpdate } = createNuqsTestWrapper({
|
||||
hasMemory: true,
|
||||
searchParams,
|
||||
})
|
||||
const screen = await render(
|
||||
<UrlState>
|
||||
<div aria-label="Catalog filters" role="group">
|
||||
<CatalogLanguagesFilter />
|
||||
<CatalogTagsFilter />
|
||||
</div>
|
||||
</UrlState>,
|
||||
)
|
||||
return { onUrlUpdate, screen }
|
||||
}
|
||||
|
||||
describe('Marketplace catalog filters', () => {
|
||||
beforeEach(async () => {
|
||||
await page.viewport(1000, 700)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps selected languages visible and restores focus after clearing', async () => {
|
||||
const { onUrlUpdate, screen } = await renderFilters()
|
||||
const trigger = screen.getByRole('button', { name: 'Languages' })
|
||||
|
||||
await trigger.click()
|
||||
const search = screen.getByRole('searchbox', { name: 'Search languages' })
|
||||
await expect.element(search).toHaveFocus()
|
||||
await search.fill('中文')
|
||||
await screen.getByRole('checkbox', { name: '中文' }).click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: '中文' })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('languages'))
|
||||
.toBe('zh-Hans')
|
||||
await expect.element(screen.getByRole('checkbox', { name: '中文' })).toBeChecked()
|
||||
|
||||
await userEvent.keyboard('{Escape}')
|
||||
const clear = screen.getByRole('button', { name: 'Clear 中文' })
|
||||
await clear.click()
|
||||
await expect.element(screen.getByRole('button', { name: 'Languages' })).toHaveFocus()
|
||||
await expect
|
||||
.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('languages'))
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
it('filters real tag options, preserves multiple selections, and restores focus after clearing', async () => {
|
||||
const { onUrlUpdate, screen } = await renderFilters()
|
||||
const trigger = screen.getByRole('button', { name: 'All tags' })
|
||||
|
||||
await trigger.click()
|
||||
const search = screen.getByRole('searchbox', { name: 'Search tags' })
|
||||
await expect.element(search).toHaveFocus()
|
||||
await search.fill('agent')
|
||||
await screen.getByRole('checkbox', { name: 'Agent' }).click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Agent' })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tags'))
|
||||
.toBe('agent')
|
||||
await expect.element(screen.getByRole('checkbox', { name: 'Agent' })).toBeChecked()
|
||||
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await screen.getByRole('button', { name: 'Clear Agent' }).click()
|
||||
await expect.element(screen.getByRole('button', { name: 'All tags' })).toHaveFocus()
|
||||
await expect.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('tags')).toBe(false)
|
||||
})
|
||||
})
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import CatalogLanguagesFilter from '../catalog-languages-filter'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string, options?: { ns?: string }) =>
|
||||
options?.ns ? `${options.ns}.${key}` : key,
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('CatalogLanguagesFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('writes selected languages into the URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderWithNuqs(<CatalogLanguagesFilter />)
|
||||
await user.click(screen.getByRole('button', { name: 'plugin.marketplace.languages' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: '中文' }))
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('languages')).toBe('zh-Hans')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import CatalogTagsFilter from '../catalog-tags-filter'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string, options?: { ns?: string }) =>
|
||||
options?.ns ? `${options.ns}.${key}` : key,
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
tags: [
|
||||
{ name: 'agent', label: 'Agent' },
|
||||
{ name: 'rag', label: 'RAG' },
|
||||
{ name: 'search', label: 'Search' },
|
||||
],
|
||||
tagsMap: {
|
||||
agent: { name: 'agent', label: 'Agent' },
|
||||
rag: { name: 'rag', label: 'RAG' },
|
||||
search: { name: 'search', label: 'Search' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CatalogTagsFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('writes selected tags into the URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderWithNuqs(<CatalogTagsFilter />)
|
||||
await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'Agent' }))
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tags')).toBe('agent')
|
||||
})
|
||||
})
|
||||
})
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { seedModelProviderQueries } from '@/test/console/model-provider'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
|
||||
import EmbeddedMarketplaceSearch from '../embedded-marketplace-search'
|
||||
|
||||
const boundary = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
searchPlugins: vi.fn(),
|
||||
searchTemplates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
...createReactI18nextMock({
|
||||
clearSearch: 'Clear search',
|
||||
loading: 'Loading',
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.searchPlaceholder': 'Search plugins or templates',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.loadError': 'Failed to load. Please try again.',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
}),
|
||||
useLocale: () => 'en-US',
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('react-i18next')>()
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
...original,
|
||||
...createReactI18nextMock({
|
||||
clearSearch: 'Clear search',
|
||||
loading: 'Loading',
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.loadError': 'Failed to load. Please try again.',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: boundary.push }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/marketplace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/marketplace')>()
|
||||
return {
|
||||
...actual,
|
||||
marketplaceQuery: {
|
||||
...actual.marketplaceQuery,
|
||||
searchAdvanced: {
|
||||
...actual.marketplaceQuery.searchAdvanced,
|
||||
queryOptions: (
|
||||
options: Parameters<typeof actual.marketplaceQuery.searchAdvanced.queryOptions>[0],
|
||||
) => ({
|
||||
...actual.marketplaceQuery.searchAdvanced.queryOptions(options),
|
||||
queryFn: () => boundary.searchPlugins(options.input),
|
||||
}),
|
||||
},
|
||||
templateSearch: {
|
||||
...actual.marketplaceQuery.templateSearch,
|
||||
queryOptions: (
|
||||
options: Parameters<typeof actual.marketplaceQuery.templateSearch.queryOptions>[0],
|
||||
) => ({
|
||||
...actual.marketplaceQuery.templateSearch.queryOptions(options),
|
||||
queryFn: () => boundary.searchTemplates(options.input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleResponses } = await import('@/test/console/service-responses')
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: withConsoleResponses(actual.consoleQuery, {
|
||||
'workspaces.current.plugin.list.installations.ids.post': async () => ({ plugins: [] }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// Keep the real dialog lifecycle while preventing its iframe from loading the external Marketplace.
|
||||
vi.mock('@/features/marketplace/links', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/features/marketplace/links')>()
|
||||
const toBlank = (url: string) =>
|
||||
`about:blank${new URL(url, 'https://marketplace.example.test').search}`
|
||||
return {
|
||||
...actual,
|
||||
marketplaceLinks: {
|
||||
...actual.marketplaceLinks,
|
||||
plugin: (...args: Parameters<typeof actual.marketplaceLinks.plugin>) =>
|
||||
toBlank(actual.marketplaceLinks.plugin(...args)),
|
||||
template: (...args: Parameters<typeof actual.marketplaceLinks.template>) =>
|
||||
toBlank(actual.marketplaceLinks.template(...args)),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const plugin = {
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
plugin_id: 'langgenius/google-search',
|
||||
latest_package_identifier: 'langgenius/google-search:1.0.0@test',
|
||||
label: { 'en-US': 'Google Search' },
|
||||
brief: { 'en-US': 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
} as Plugin
|
||||
|
||||
const template = {
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_unique_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
} as MarketplaceTemplate
|
||||
|
||||
async function renderSearch(edition: 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' = 'CLOUD') {
|
||||
const { wrapper: UrlState, onUrlUpdate } = createNuqsTestWrapper({ hasMemory: true })
|
||||
const { queryClient, wrapper: ConsoleProvider } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: edition },
|
||||
})
|
||||
seedModelProviderQueries(queryClient)
|
||||
const screen = await render(
|
||||
<ConsoleProvider>
|
||||
<UrlState>
|
||||
<EmbeddedMarketplaceSearch />
|
||||
</UrlState>
|
||||
</ConsoleProvider>,
|
||||
)
|
||||
return { onUrlUpdate, screen }
|
||||
}
|
||||
|
||||
describe('Embedded Marketplace search', () => {
|
||||
beforeEach(async () => {
|
||||
await page.viewport(1000, 800)
|
||||
onTestFinished(guardUnexpectedFetch())
|
||||
vi.clearAllMocks()
|
||||
boundary.searchPlugins.mockResolvedValue({ data: { plugins: [plugin], total: 1 } })
|
||||
boundary.searchTemplates.mockResolvedValue({ data: { templates: [template], total: 1 } })
|
||||
})
|
||||
|
||||
it('opens both Cloud result dialogs, closes them, and leaves catalog filters untouched', async () => {
|
||||
const { onUrlUpdate, screen } = await renderSearch()
|
||||
const search = screen.getByRole('combobox', { name: 'Search plugins or templates' })
|
||||
|
||||
await search.fill('search')
|
||||
await screen.getByRole('option', { name: /Google Search/ }).click()
|
||||
const pluginDialog = screen.getByRole('dialog', { name: /Google Search/ })
|
||||
await expect.element(pluginDialog).toBeVisible()
|
||||
await expect
|
||||
.element(screen.getByTitle(/^Google Search/))
|
||||
.toHaveAttribute('src', expect.stringContaining('installed=false'))
|
||||
await pluginDialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
await expect.element(pluginDialog).not.toBeInTheDocument()
|
||||
|
||||
await search.fill('legal')
|
||||
await screen.getByRole('option', { name: /Legal Research Agent/ }).click()
|
||||
const templateDialog = screen.getByRole('dialog', { name: /Legal Research Agent/ })
|
||||
await expect.element(templateDialog).toBeVisible()
|
||||
await templateDialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
await expect.element(templateDialog).not.toBeInTheDocument()
|
||||
await expect.element(search).toHaveFocus()
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses native Enter submission to filter the catalog instead of opening a result', async () => {
|
||||
const { onUrlUpdate, screen } = await renderSearch()
|
||||
const search = screen.getByRole('combobox', { name: 'Search plugins or templates' })
|
||||
|
||||
await search.fill('google')
|
||||
await expect.element(screen.getByRole('option', { name: /Google Search/ })).toBeVisible()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
await expect.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google')
|
||||
await expect.element(search).toHaveValue('google')
|
||||
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['COMMUNITY', 'plugin', 'Google Search'],
|
||||
['ENTERPRISE', 'template', 'Legal Research Agent'],
|
||||
] as const)(
|
||||
'opens a %s %s on the official site without changing the catalog',
|
||||
async (edition, kind, label) => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
boundary.searchPlugins.mockResolvedValue({
|
||||
data: { plugins: kind === 'plugin' ? [plugin] : [], total: kind === 'plugin' ? 1 : 0 },
|
||||
})
|
||||
boundary.searchTemplates.mockResolvedValue({
|
||||
data: {
|
||||
templates: kind === 'template' ? [template] : [],
|
||||
total: kind === 'template' ? 1 : 0,
|
||||
},
|
||||
})
|
||||
const { onUrlUpdate, screen } = await renderSearch(edition)
|
||||
const search = screen.getByRole('combobox', { name: 'Search plugins or templates' })
|
||||
|
||||
await search.fill('search')
|
||||
const option = screen.getByRole('option', { name: new RegExp(label) })
|
||||
await expect.element(option).toBeVisible()
|
||||
if (kind === 'plugin') await option.click()
|
||||
else {
|
||||
await userEvent.keyboard('{ArrowDown}')
|
||||
await userEvent.keyboard('{Enter}')
|
||||
}
|
||||
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
const opened = new URL(String(open.mock.calls[0]![0]))
|
||||
expect(opened.origin).toBe('https://marketplace.dify.ai')
|
||||
expect(opened.pathname).toBe(
|
||||
kind === 'plugin' ? '/plugin/langgenius/google-search' : '/template/dify/template-1',
|
||||
)
|
||||
expect(opened.searchParams.get('source')).toBe(window.location.origin)
|
||||
await expect.element(search).toHaveValue('search')
|
||||
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import EmbeddedMarketplaceSearch from '../embedded-marketplace-search'
|
||||
|
||||
const deploymentState = vi.hoisted(() => ({
|
||||
deploymentEdition: 'CLOUD' as 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE',
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/state', async () => {
|
||||
const { createSystemFeaturesStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createSystemFeaturesStateModuleMock(() => deploymentState)
|
||||
})
|
||||
|
||||
const { mockPluginSearch, mockTemplateSearch, push } = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
mockPluginSearch: vi.fn(),
|
||||
mockTemplateSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
const translations: Record<string, string> = {
|
||||
'marketplace.home.searchPlaceholder': 'Search plugins or templates',
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.loadError': 'Failed to load. Please try again.',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
clearSearch: 'Clear search',
|
||||
loading: 'Loading',
|
||||
}
|
||||
|
||||
return {
|
||||
useLocale: () => 'en-US',
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => translations[key] ?? key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/marketplace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/marketplace')>()
|
||||
return {
|
||||
...actual,
|
||||
marketplaceQuery: {
|
||||
...actual.marketplaceQuery,
|
||||
searchAdvanced: {
|
||||
...actual.marketplaceQuery.searchAdvanced,
|
||||
queryOptions: (
|
||||
options: Parameters<typeof actual.marketplaceQuery.searchAdvanced.queryOptions>[0],
|
||||
) => ({
|
||||
...actual.marketplaceQuery.searchAdvanced.queryOptions(options),
|
||||
queryFn: () => mockPluginSearch(),
|
||||
}),
|
||||
},
|
||||
templateSearch: {
|
||||
...actual.marketplaceQuery.templateSearch,
|
||||
queryOptions: (
|
||||
options: Parameters<typeof actual.marketplaceQuery.templateSearch.queryOptions>[0],
|
||||
) => ({
|
||||
...actual.marketplaceQuery.templateSearch.queryOptions(options),
|
||||
queryFn: () => mockTemplateSearch(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => ({ installedInfo: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}))
|
||||
|
||||
vi.mock('../../detail-dialog/use-silent-install', () => ({
|
||||
useSilentMarketplaceInstall: () => ({ install: vi.fn() }),
|
||||
}))
|
||||
// Exercise real dialog/frame message handling without loading external documents.
|
||||
vi.mock('@/features/marketplace/links', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/features/marketplace/links')>()
|
||||
const toBlank = (url: string) =>
|
||||
`about:blank${new URL(url, 'https://marketplace.example.test').search}`
|
||||
return {
|
||||
...actual,
|
||||
marketplaceLinks: {
|
||||
...actual.marketplaceLinks,
|
||||
plugin: (...args: Parameters<typeof actual.marketplaceLinks.plugin>) =>
|
||||
toBlank(actual.marketplaceLinks.plugin(...args)),
|
||||
template: (...args: Parameters<typeof actual.marketplaceLinks.template>) =>
|
||||
toBlank(actual.marketplaceLinks.template(...args)),
|
||||
},
|
||||
}
|
||||
})
|
||||
const plugin = {
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
plugin_id: 'langgenius/google-search',
|
||||
label: { 'en-US': 'Google Search' },
|
||||
brief: { 'en-US': 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
}
|
||||
const template = {
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_unique_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
}
|
||||
const renderSearch = () => {
|
||||
const { wrapper: UrlState, onUrlUpdate } = createNuqsTestWrapper()
|
||||
renderWithConsoleQuery(
|
||||
<UrlState>
|
||||
<EmbeddedMarketplaceSearch />
|
||||
</UrlState>,
|
||||
)
|
||||
return { onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('EmbeddedMarketplaceSearch', () => {
|
||||
beforeEach(() => {
|
||||
deploymentState.deploymentEdition = 'CLOUD'
|
||||
vi.clearAllMocks()
|
||||
mockPluginSearch.mockResolvedValue({ data: { plugins: [plugin], total: 1 } })
|
||||
mockTemplateSearch.mockResolvedValue({ data: { templates: [template], total: 1 } })
|
||||
})
|
||||
|
||||
it('opens plugin and template details from the popup without filtering the catalog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderSearch()
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'search')
|
||||
await user.click(await screen.findByText('Google Search'))
|
||||
|
||||
await screen.findByRole('dialog', { name: /Google Search/ })
|
||||
const pluginFrame = screen.getByTitle(/^Google Search/)
|
||||
expect(new URL(pluginFrame.getAttribute('src')!).searchParams.get('installed')).toBe('false')
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'operation.close' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'legal')
|
||||
await user.click(await screen.findByRole('option', { name: /Legal Research Agent/ }))
|
||||
await screen.findByRole('dialog', { name: /Legal Research Agent/ })
|
||||
const frame = screen.getByTitle(/^Legal Research Agent/) as HTMLIFrameElement
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent('message', {
|
||||
data: { type: 'dify-marketplace:install-template', templateId: 'template-1' },
|
||||
origin: 'null',
|
||||
source: frame.contentWindow,
|
||||
}),
|
||||
)
|
||||
expect(push).toHaveBeenCalledExactlyOnceWith('/apps?template-id=template-1')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('filters the current catalog when Enter is pressed instead of opening a result', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderSearch()
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
await screen.findByRole('option', { name: /Google Search/ })
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google')
|
||||
})
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['COMMUNITY', 'plugin', 'mouse'],
|
||||
['COMMUNITY', 'template', 'keyboard'],
|
||||
['ENTERPRISE', 'plugin', 'keyboard'],
|
||||
['ENTERPRISE', 'template', 'mouse'],
|
||||
] as const)(
|
||||
'opens a %s %s result using %s without changing the catalog',
|
||||
async (edition, kind, interaction) => {
|
||||
deploymentState.deploymentEdition = edition
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: { plugins: kind === 'plugin' ? [plugin] : [], total: kind === 'plugin' ? 1 : 0 },
|
||||
})
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: kind === 'template' ? [template] : [],
|
||||
total: kind === 'template' ? 1 : 0,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderSearch()
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.type(input, 'search')
|
||||
const option = await screen.findByRole('option')
|
||||
if (interaction === 'mouse') await user.click(option)
|
||||
else await user.keyboard('{ArrowDown}{Enter}')
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
const [href, target, features] = open.mock.calls[0]!
|
||||
const url = new URL(String(href))
|
||||
expect(url.origin).toBe('https://marketplace.dify.ai')
|
||||
expect(url.pathname).toBe(
|
||||
kind === 'plugin' ? '/plugin/langgenius/google-search' : '/template/dify/template-1',
|
||||
)
|
||||
expect(url.searchParams.get('source')).toBe(window.location.origin)
|
||||
expect(target).toBe('_blank')
|
||||
expect(features).toBe('noopener,noreferrer')
|
||||
expect(input).toHaveValue('search')
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import HomeCatalogNavigation from '../home-catalog-navigation'
|
||||
import HomeCatalogTabs from '../home-catalog-tabs'
|
||||
import { HomeStickyStateProvider } from '../home-sticky-state-provider'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string, options?: { ns?: string }) =>
|
||||
options?.ns ? `${options.ns}.${key}` : key,
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../plugin-type-switch', () => ({
|
||||
default: () => <div data-testid="plugin-type-switch" />,
|
||||
}))
|
||||
|
||||
describe('HomeCatalogNavigation', () => {
|
||||
const renderNavigation = (isMarketplacePlatform: boolean) => {
|
||||
return render(
|
||||
<HomeStickyStateProvider>
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
catalogTabs={<HomeCatalogTabs isMarketplacePlatform={isMarketplacePlatform} />}
|
||||
/>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('keeps template navigation inside the Marketplace platform', () => {
|
||||
renderNavigation(true)
|
||||
|
||||
const activeTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
expect(activeTab).toHaveAttribute('aria-current', 'page')
|
||||
expect(activeTab).toHaveAttribute('href', '/plugins')
|
||||
expect(
|
||||
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
|
||||
).toHaveAttribute('href', '/templates')
|
||||
})
|
||||
|
||||
it('leaves both catalog tabs inactive when no page is selected', () => {
|
||||
render(<HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} />)
|
||||
|
||||
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
|
||||
|
||||
expect(pluginsTab).toHaveAttribute('href', '/marketplace')
|
||||
expect(pluginsTab).not.toHaveAttribute('aria-current')
|
||||
expect(templatesTab).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('marks Templates as active when rendering the Templates catalog', () => {
|
||||
render(<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />)
|
||||
|
||||
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
|
||||
|
||||
expect(pluginsTab).not.toHaveAttribute('aria-current')
|
||||
expect(templatesTab).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
|
||||
it('uses request-localized labels and preserves the selected language', () => {
|
||||
render(
|
||||
<HomeCatalogTabs
|
||||
isMarketplacePlatform
|
||||
labels={{
|
||||
plugins: '插件',
|
||||
templates: '模板',
|
||||
}}
|
||||
language="zh-Hans"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: '插件' })).toHaveAttribute(
|
||||
'href',
|
||||
'/plugins?language=zh-Hans',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: '模板' })).toHaveAttribute(
|
||||
'href',
|
||||
'/templates?language=zh-Hans',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a supplied catalog category navigation', () => {
|
||||
render(
|
||||
<HomeStickyStateProvider>
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform
|
||||
catalogTabs={<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />}
|
||||
catalogCategories={<nav aria-label="Template categories">Template categories</nav>}
|
||||
/>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('navigation', { name: 'Template categories' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plugin-type-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps Dify catalog navigation on the current origin', () => {
|
||||
renderNavigation(false)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute(
|
||||
'href',
|
||||
'/marketplace',
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
|
||||
).toHaveAttribute('href', '/templates')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import HomeCatalogNavigation from '../home-catalog-navigation'
|
||||
import HomeCatalogTabs from '../home-catalog-tabs'
|
||||
import HomeHeader from '../home-header'
|
||||
import HomeHero from '../home-hero'
|
||||
import { HomeStickyStateProvider } from '../home-sticky-state-provider'
|
||||
|
||||
const { boundaries, translations } = vi.hoisted(() => ({
|
||||
boundaries: {
|
||||
marketplaceUrlPrefix: 'https://marketplace.dify.ai',
|
||||
useDocLink: vi.fn(
|
||||
() =>
|
||||
(path = '') =>
|
||||
`https://docs.dify.ai/console${path}`,
|
||||
),
|
||||
},
|
||||
translations: {
|
||||
'marketplace.home.creatorCenter': 'Creator Center',
|
||||
'marketplace.home.guide': 'Guide',
|
||||
pluginDevelopmentGuide: 'Plugin development guide',
|
||||
pluginPublishGuide: 'Publish a plugin',
|
||||
requestAPlugin: 'Request a plugin',
|
||||
templatePublishingGuide: 'Publish a template',
|
||||
'navigation.mainNav.marketplace': 'Marketplace navigation',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
...createReactI18nextMock(translations),
|
||||
useLocale: () => 'en-US',
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('react-i18next')>()
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return { ...original, ...createReactI18nextMock(translations) }
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
defaultDocBaseUrl: 'https://docs.dify.ai',
|
||||
useDocLink: boundaries.useDocLink,
|
||||
}))
|
||||
|
||||
vi.mock('@/env', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/env')>()
|
||||
return {
|
||||
...actual,
|
||||
env: {
|
||||
...actual.env,
|
||||
get NEXT_PUBLIC_MARKETPLACE_URL_PREFIX() {
|
||||
return boundaries.marketplaceUrlPrefix
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('Marketplace home chrome', () => {
|
||||
beforeEach(async () => {
|
||||
await page.viewport(1280, 800)
|
||||
boundaries.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps localized catalog navigation and the standalone guide keyboard reachable', async () => {
|
||||
const screen = await render(
|
||||
<TooltipProvider delay={0} closeDelay={0}>
|
||||
<HomeStickyStateProvider>
|
||||
<button type="button">Before header</button>
|
||||
<HomeHeader
|
||||
activeTab="templates"
|
||||
catalogLabels={{ plugins: '插件', templates: '模板' }}
|
||||
isMarketplacePlatform
|
||||
language="zh-Hans"
|
||||
/>
|
||||
<HomeHero
|
||||
isMarketplacePlatform
|
||||
title="Discover templates"
|
||||
subtitle="Start faster with ready-to-use workflows."
|
||||
/>
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform
|
||||
catalogCategories={<nav aria-label="Template categories">Template categories</nav>}
|
||||
catalogTabs={
|
||||
<HomeCatalogTabs
|
||||
activeTab="templates"
|
||||
isMarketplacePlatform
|
||||
labels={{ plugins: '插件', templates: '模板' }}
|
||||
language="zh-Hans"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</HomeStickyStateProvider>
|
||||
</TooltipProvider>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('heading', { name: 'Discover templates' })).toBeVisible()
|
||||
await expect
|
||||
.element(screen.getByText('Start faster with ready-to-use workflows.'))
|
||||
.toBeVisible()
|
||||
await expect
|
||||
.element(screen.getByRole('navigation', { name: 'Template categories' }))
|
||||
.toBeVisible()
|
||||
|
||||
const header = screen.getByRole('banner')
|
||||
await expect
|
||||
.element(header.getByRole('link', { name: 'Dify Marketplace' }))
|
||||
.toHaveAttribute('href', '/')
|
||||
const headerTemplate = header.getByRole('link', { name: '模板', includeHidden: true })
|
||||
await expect.element(headerTemplate).toHaveAttribute('href', '/templates?language=zh-Hans')
|
||||
await expect.element(headerTemplate).toHaveAttribute('aria-current', 'page')
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole('navigation', { name: 'Marketplace navigation' }).getByRole('link', {
|
||||
name: '模板',
|
||||
}),
|
||||
)
|
||||
.toBeVisible()
|
||||
await expect
|
||||
.element(header.getByRole('link', { name: 'Creator Center' }))
|
||||
.toHaveAttribute('href', 'https://creators.dify.ai/')
|
||||
|
||||
const before = screen.getByRole('button', { name: 'Before header' })
|
||||
await before.click()
|
||||
await userEvent.tab()
|
||||
await expect.element(header.getByRole('link', { name: 'Dify Marketplace' })).toHaveFocus()
|
||||
await userEvent.tab()
|
||||
await expect.element(header.getByRole('link', { name: 'Creator Center' })).toHaveFocus()
|
||||
await userEvent.tab()
|
||||
const guide = header.getByRole('button', { name: 'Guide' })
|
||||
await expect.element(guide).toHaveFocus()
|
||||
await expect.element(screen.getByRole('tooltip')).toHaveTextContent('Guide')
|
||||
|
||||
await userEvent.keyboard('{Enter}')
|
||||
const menu = screen.getByRole('menu')
|
||||
await expect.element(menu).toBeVisible()
|
||||
await expect
|
||||
.element(menu.getByRole('menuitem', { name: 'Plugin development guide' }))
|
||||
.toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/en/develop-plugin/getting-started/getting-started-dify-plugin',
|
||||
)
|
||||
await expect.element(screen.getByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps console navigation local and uses the deployment-aware guide after reopen', async () => {
|
||||
const screen = await render(
|
||||
<TooltipProvider delay={0} closeDelay={0}>
|
||||
<HomeStickyStateProvider>
|
||||
<HomeHeader activeTab={null} isMarketplacePlatform={false} />
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform={false}
|
||||
catalogCategories={<nav aria-label="Plugin categories">Plugin categories</nav>}
|
||||
catalogTabs={<HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} />}
|
||||
/>
|
||||
</HomeStickyStateProvider>
|
||||
</TooltipProvider>,
|
||||
)
|
||||
const header = screen.getByRole('banner')
|
||||
await expect
|
||||
.element(header.getByRole('link', { name: 'Dify Marketplace' }))
|
||||
.toHaveAttribute('href', '/marketplace')
|
||||
for (const link of header.getByRole('navigation').getByRole('link').all())
|
||||
await expect.element(link).not.toHaveAttribute('aria-current')
|
||||
|
||||
const guide = header.getByRole('button', { name: 'Guide' })
|
||||
await guide.click()
|
||||
await expect
|
||||
.element(screen.getByRole('menuitem', { name: 'Plugin development guide' }))
|
||||
.toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/console/develop-plugin/getting-started/getting-started-dify-plugin',
|
||||
)
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(screen.getByRole('menu')).not.toBeInTheDocument()
|
||||
await expect.element(guide).toHaveFocus()
|
||||
|
||||
await guide.click()
|
||||
await expect.element(screen.getByRole('menu')).toBeVisible()
|
||||
expect(boundaries.useDocLink).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import HomeGuide from '../home-guide'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
marketplaceUrlPrefix: 'https://marketplace.dify.ai',
|
||||
useDocLink: vi.fn(() => (path?: string) => `https://docs.dify.ai/console${path || ''}`),
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useLocale: () => 'en-US',
|
||||
useTranslation: () => ({
|
||||
i18n: {
|
||||
language: 'en-US',
|
||||
},
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
defaultDocBaseUrl: 'https://docs.dify.ai',
|
||||
useDocLink: mocks.useDocLink,
|
||||
}))
|
||||
|
||||
vi.mock('@/env', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/env')>()
|
||||
return {
|
||||
...actual,
|
||||
env: {
|
||||
...actual.env,
|
||||
get NEXT_PUBLIC_MARKETPLACE_URL_PREFIX() {
|
||||
return mocks.marketplaceUrlPrefix
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const GUIDE_BUTTON_NAME = /marketplace\.home\.guide/
|
||||
|
||||
const renderGuide = (isMarketplacePlatform: boolean) =>
|
||||
render(
|
||||
<TooltipProvider delay={0} closeDelay={0}>
|
||||
<HomeGuide isMarketplacePlatform={isMarketplacePlatform} />
|
||||
</TooltipProvider>,
|
||||
)
|
||||
|
||||
const openGuideMenu = async (isMarketplacePlatform: boolean) => {
|
||||
const user = userEvent.setup()
|
||||
renderGuide(isMarketplacePlatform)
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: GUIDE_BUTTON_NAME }))
|
||||
return within(await screen.findByRole('menu'))
|
||||
}
|
||||
|
||||
describe('HomeGuide', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
|
||||
})
|
||||
|
||||
it('opens a four-option dropdown on the standalone Marketplace instead of navigating away', async () => {
|
||||
const menu = await openGuideMenu(true)
|
||||
const options = menu.getAllByRole('menuitem')
|
||||
|
||||
expect(options).toHaveLength(4)
|
||||
expect(options[0]).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml',
|
||||
)
|
||||
expect(options[1]).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/en/develop-plugin/getting-started/getting-started-dify-plugin',
|
||||
)
|
||||
expect(options[2]).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/en/develop-plugin/publishing/marketplace-listing/release-overview',
|
||||
)
|
||||
expect(options[3]).toHaveAttribute('href', 'https://creators.dify.ai/')
|
||||
expect(mocks.useDocLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses Dify deployment-aware documentation links inside the console', async () => {
|
||||
const menu = await openGuideMenu(false)
|
||||
const options = menu.getAllByRole('menuitem')
|
||||
|
||||
expect(options).toHaveLength(4)
|
||||
expect(options[1]).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/console/develop-plugin/getting-started/getting-started-dify-plugin',
|
||||
)
|
||||
expect(options[2]).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/console/develop-plugin/publishing/marketplace-listing/release-overview',
|
||||
)
|
||||
expect(mocks.useDocLink).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('labels the in-app Guide icon and shows a matching tooltip on hover and focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGuide(false)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME })
|
||||
expect(trigger).toHaveAccessibleName(/marketplace\.home\.guide/)
|
||||
|
||||
await user.hover(trigger)
|
||||
expect((await screen.findByRole('tooltip')).textContent).toMatch(/marketplace\.home\.guide/)
|
||||
|
||||
await user.unhover(trigger)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.tab()
|
||||
expect(trigger).toHaveFocus()
|
||||
expect((await screen.findByRole('tooltip')).textContent).toMatch(/marketplace\.home\.guide/)
|
||||
})
|
||||
|
||||
it('keeps the Guide dropdown available after the tooltip is shown', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGuide(false)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME })
|
||||
await user.hover(trigger)
|
||||
expect(await screen.findByRole('tooltip')).toBeInTheDocument()
|
||||
|
||||
await user.click(trigger)
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import HomeHeader from '../home-header'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
marketplaceUrlPrefix: 'https://marketplace.dify.ai',
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useLocale: () => 'en-US',
|
||||
useTranslation: () => ({
|
||||
i18n: {
|
||||
language: 'en-US',
|
||||
},
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
defaultDocBaseUrl: 'https://docs.dify.ai',
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/console${path || ''}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/env', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/env')>()
|
||||
return {
|
||||
...actual,
|
||||
env: {
|
||||
...actual.env,
|
||||
get NEXT_PUBLIC_MARKETPLACE_URL_PREFIX() {
|
||||
return mocks.marketplaceUrlPrefix
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../home-sticky-state-provider', () => ({
|
||||
HomeStickyCatalogTabs: ({ children }: { children: React.ReactNode }) => children,
|
||||
}))
|
||||
|
||||
describe('HomeHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
|
||||
})
|
||||
|
||||
it('keeps Creator Center and docs in the in-app header', () => {
|
||||
render(<HomeHeader isMarketplacePlatform={false} />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /marketplace\.home\.guide/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links Creator Center alongside the docs dropdown', () => {
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
const creatorCenterLink = screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })
|
||||
expect(screen.getByRole('button', { name: /marketplace\.home\.guide/ })).toBeInTheDocument()
|
||||
|
||||
expect(creatorCenterLink).toHaveAttribute('href', 'https://creators.dify.ai/')
|
||||
expect(creatorCenterLink).toHaveAttribute('target', '_blank')
|
||||
expect(creatorCenterLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
// Creator Center must be a single interactive element, not a link-wrapped button.
|
||||
expect(creatorCenterLink.querySelector('button')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links Creator Center to the staging Creators site in staging', () => {
|
||||
mocks.marketplaceUrlPrefix = 'https://marketplace-staging.dify.dev'
|
||||
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://creators-staging.dify.dev/',
|
||||
)
|
||||
})
|
||||
|
||||
it('selects neither catalog tab on non-catalog pages', () => {
|
||||
render(
|
||||
<HomeHeader
|
||||
activeTab={null}
|
||||
catalogLabels={{
|
||||
plugins: 'Plugins',
|
||||
templates: 'Templates',
|
||||
}}
|
||||
isMarketplacePlatform
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Plugins' })).not.toHaveAttribute('aria-current')
|
||||
expect(screen.getByRole('link', { name: 'Templates' })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('marks the active Templates catalog and preserves its language', () => {
|
||||
render(
|
||||
<HomeHeader
|
||||
activeTab="templates"
|
||||
catalogLabels={{
|
||||
plugins: '插件',
|
||||
templates: '模板',
|
||||
}}
|
||||
isMarketplacePlatform
|
||||
language="zh-Hans"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: '插件' })).not.toHaveAttribute('aria-current')
|
||||
const templatesTab = screen.getByRole('link', { name: '模板' })
|
||||
expect(templatesTab).toHaveAttribute('aria-current', 'page')
|
||||
expect(templatesTab).toHaveAttribute('href', '/templates?language=zh-Hans')
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import HomeHero from '../home-hero'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('HomeHero', () => {
|
||||
it('renders catalog-specific copy when supplied', () => {
|
||||
render(
|
||||
<HomeHero
|
||||
isMarketplacePlatform
|
||||
title="Discover templates"
|
||||
subtitle="Start faster with ready-to-use workflows."
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Discover templates' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Start faster with ready-to-use workflows.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('marketplace.home.heroTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,469 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import { DirectionProvider } from '@langgenius/dify-ui/direction-provider'
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { seedModelProviderQueries } from '@/test/console/model-provider'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
|
||||
import HomeTrending from '../home-trending'
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
...createReactI18nextMock(),
|
||||
useLocale: () => 'en-US',
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/plugins', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/plugins')>()),
|
||||
fetchPluginInfoFromMarketPlace: vi
|
||||
.fn<typeof import('@/service/plugins').fetchPluginInfoFromMarketPlace>()
|
||||
.mockImplementation(async ({ org, name }) => ({
|
||||
data: {
|
||||
version: { version: '1.0.0' },
|
||||
plugin: {
|
||||
category: 'tool',
|
||||
latest_package_identifier: `${org}/${name}:1.0.0`,
|
||||
latest_version: '1.0.0',
|
||||
},
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleResponses } = await import('@/test/console/service-responses')
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: withConsoleResponses(actual.consoleQuery, {
|
||||
'workspaces.current.plugin.list.installations.ids.post': async () => ({ plugins: [] }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// Preserve real dialog and message handling without loading remote Marketplace documents.
|
||||
vi.mock('@/features/marketplace/links', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/features/marketplace/links')>()
|
||||
const toBlank = (url: string) =>
|
||||
`about:blank${new URL(url, 'https://marketplace.example.test').search}`
|
||||
return {
|
||||
...actual,
|
||||
marketplaceLinks: {
|
||||
...actual.marketplaceLinks,
|
||||
plugin: (...args: Parameters<typeof actual.marketplaceLinks.plugin>) =>
|
||||
toBlank(actual.marketplaceLinks.plugin(...args)),
|
||||
template: (...args: Parameters<typeof actual.marketplaceLinks.template>) =>
|
||||
toBlank(actual.marketplaceLinks.template(...args)),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const banners: PluginBanner[] = [
|
||||
{
|
||||
id: 'recommend',
|
||||
style_type: 'recommend',
|
||||
title: 'Trending',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
theme_type: 'hottest',
|
||||
heading: 'Popular plugins',
|
||||
description: 'Chosen from real usage.',
|
||||
cards: [
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/dropbox',
|
||||
display_name: 'Dropbox',
|
||||
icon_url: '/api/v1/plugins/langgenius/dropbox/icon',
|
||||
creator: 'langgenius',
|
||||
badges: ['partner', 'verified'],
|
||||
link: '/plugins/langgenius/dropbox',
|
||||
card_position: 0,
|
||||
auto_batch_id: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/zapier',
|
||||
display_name: 'Zapier',
|
||||
link: '/plugins/langgenius/zapier',
|
||||
card_position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blog',
|
||||
style_type: 'blog',
|
||||
title: 'Dify Updates',
|
||||
sort: 1,
|
||||
language: 'en',
|
||||
content: {
|
||||
blog_title: 'Dify v1.9 new launch',
|
||||
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',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
style_type: 'event',
|
||||
title: 'Duck Duck Go',
|
||||
sort: 2,
|
||||
language: 'en',
|
||||
content: {
|
||||
images: {
|
||||
desktop: '/api/v1/banners/images/banners/duckduckgo.png',
|
||||
mobile: '/api/v1/banners/images/banners/duckduckgo-mobile.png',
|
||||
},
|
||||
link: 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
|
||||
alt_text: 'DuckDuckGo plugin',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const mixedRecommendations: PluginBanner = {
|
||||
id: 'mixed',
|
||||
style_type: 'recommend',
|
||||
title: 'Trending',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
theme_type: 'newest',
|
||||
cards: [
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/dropbox',
|
||||
display_name: 'Dropbox',
|
||||
link: '',
|
||||
card_position: 0,
|
||||
},
|
||||
{
|
||||
item_type: 'template',
|
||||
item_id: 'tpl-1',
|
||||
display_name: 'Support Bot',
|
||||
creator: 'aisa-team',
|
||||
link: '',
|
||||
card_position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
async function renderTrending(
|
||||
props: {
|
||||
banners?: PluginBanner[]
|
||||
direction?: 'ltr' | 'rtl'
|
||||
edition?: 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
|
||||
isMarketplacePlatform?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
banners: items = banners,
|
||||
direction = 'ltr',
|
||||
edition = 'CLOUD',
|
||||
isMarketplacePlatform = true,
|
||||
} = props
|
||||
const { queryClient, wrapper: ConsoleProvider } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: edition },
|
||||
})
|
||||
seedModelProviderQueries(queryClient)
|
||||
return render(
|
||||
<ConsoleProvider>
|
||||
<button type="button">Before carousel</button>
|
||||
<DirectionProvider direction={direction}>
|
||||
<div dir={direction}>
|
||||
<HomeTrending
|
||||
banners={items}
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
page="plugins"
|
||||
/>
|
||||
</div>
|
||||
</DirectionProvider>
|
||||
<button type="button">After carousel</button>
|
||||
</ConsoleProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function expectSlideInsideCarousel(
|
||||
screen: Awaited<ReturnType<typeof renderTrending>>,
|
||||
name: string,
|
||||
) {
|
||||
return expect
|
||||
.poll(() => {
|
||||
const viewport = getCarousel(screen).element().getBoundingClientRect()
|
||||
const slide = screen.getByRole('group', { name }).element().getBoundingClientRect()
|
||||
return slide.left >= viewport.left - 1 && slide.right <= viewport.right + 1
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function getCarousel(screen: Awaited<ReturnType<typeof renderTrending>>) {
|
||||
const carousel = screen
|
||||
.getByRole('region', { name: 'plugin.marketplace.home.trendingTitle' })
|
||||
.all()
|
||||
.find((locator) => locator.element().hasAttribute('data-home-trending-carousel-root'))
|
||||
if (!carousel) throw new Error('Trending carousel was not rendered')
|
||||
return carousel
|
||||
}
|
||||
|
||||
describe('Marketplace home trending carousel', () => {
|
||||
beforeEach(async () => {
|
||||
await page.viewport(1200, 800)
|
||||
onTestFinished(guardUnexpectedFetch())
|
||||
document.documentElement.dataset.isMarketplace = ''
|
||||
window.__marketplaceTracking__ = {
|
||||
track: vi.fn(),
|
||||
rememberReferrer: vi.fn(),
|
||||
markSearch: vi.fn(),
|
||||
flushSearch: vi.fn(),
|
||||
markFilter: vi.fn(),
|
||||
flushFilter: vi.fn(),
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__marketplaceTracking__
|
||||
delete document.documentElement.dataset.isMarketplace
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('switches among delivered layouts and keeps only the selected slide visible', async () => {
|
||||
const screen = await renderTrending()
|
||||
const trending = screen.getByRole('group', { name: 'Trending' })
|
||||
const trendingElement = trending.element()
|
||||
await expect.element(screen.getByRole('heading', { name: 'Popular plugins' })).toBeVisible()
|
||||
await expect.element(trending.getByRole('link', { name: 'Dropbox' })).toBeVisible()
|
||||
await expectSlideInsideCarousel(screen, 'Trending')
|
||||
|
||||
await screen.getByRole('button', { name: 'Dify Updates' }).click()
|
||||
const blog = screen.getByRole('group', { name: 'Dify Updates' })
|
||||
await expect.element(blog).toBeVisible()
|
||||
await expect.element(blog.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeVisible()
|
||||
await expectSlideInsideCarousel(screen, 'Dify Updates')
|
||||
const viewport = getCarousel(screen).element().getBoundingClientRect()
|
||||
const previousSlide = trendingElement.getBoundingClientRect()
|
||||
expect(
|
||||
previousSlide.right <= viewport.left + 1 || previousSlide.left >= viewport.right - 1,
|
||||
).toBe(true)
|
||||
|
||||
await screen.getByRole('button', { name: 'Duck Duck Go' }).click()
|
||||
await expect
|
||||
.element(screen.getByRole('link', { name: 'DuckDuckGo plugin' }))
|
||||
.toHaveAttribute('href', 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo')
|
||||
await expectSlideInsideCarousel(screen, 'Duck Duck Go')
|
||||
})
|
||||
|
||||
it('uses real hover and keyboard focus to pause announcements and lets Play resume in place', async () => {
|
||||
const screen = await renderTrending()
|
||||
const carousel = getCarousel(screen)
|
||||
const track = carousel.element().querySelector('[aria-live]')!
|
||||
expect(track).toHaveAttribute('aria-live', 'off')
|
||||
|
||||
await carousel.hover()
|
||||
await expect.poll(() => track.getAttribute('aria-live')).toBe('polite')
|
||||
await screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }).hover()
|
||||
await expect.poll(() => track.getAttribute('aria-live')).toBe('polite')
|
||||
await userEvent.unhover(carousel)
|
||||
await expect.poll(() => track.getAttribute('aria-live')).toBe('off')
|
||||
|
||||
const second = screen.getByRole('button', { name: 'Dify Updates' })
|
||||
await second.click()
|
||||
await userEvent.unhover(carousel)
|
||||
await userEvent.tab({ shift: true })
|
||||
await expect.poll(() => track.getAttribute('aria-live')).toBe('polite')
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'Trending' }))
|
||||
.toHaveAttribute('aria-current', 'true')
|
||||
|
||||
const pause = screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' })
|
||||
await pause.click()
|
||||
const play = screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' })
|
||||
await expect.element(play).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }))
|
||||
.toHaveFocus()
|
||||
await expect.poll(() => track.getAttribute('aria-live')).toBe('off')
|
||||
})
|
||||
|
||||
it('keeps reduced-motion autoplay stopped until the user explicitly starts it', async () => {
|
||||
const nativeMatchMedia = window.matchMedia.bind(window)
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => {
|
||||
const result = nativeMatchMedia(query)
|
||||
if (query !== '(prefers-reduced-motion: reduce)') return result
|
||||
return new Proxy(result, {
|
||||
get(target, property) {
|
||||
if (property === 'matches') return true
|
||||
const value = Reflect.get(target, property)
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
},
|
||||
})
|
||||
})
|
||||
const screen = await renderTrending()
|
||||
const carousel = getCarousel(screen)
|
||||
const track = carousel.element().querySelector('[aria-live]')!
|
||||
|
||||
const play = screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' })
|
||||
await expect.element(play).toBeVisible()
|
||||
await play.click()
|
||||
await userEvent.unhover(carousel)
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }))
|
||||
.toBeVisible()
|
||||
await expect.poll(() => track.getAttribute('aria-live')).toBe('off')
|
||||
})
|
||||
|
||||
it('does not treat a real mouse drag on a narrow viewport as a touch swipe', async () => {
|
||||
await page.viewport(390, 844)
|
||||
const screen = await renderTrending()
|
||||
const carousel = getCarousel(screen)
|
||||
const card = screen.getByRole('link', { name: 'Dropbox' })
|
||||
const cardClick = vi.fn((event: Event) => event.preventDefault())
|
||||
card.element().addEventListener('click', cardClick)
|
||||
|
||||
await userEvent.pointer([
|
||||
{ target: carousel, coords: { x: 300, y: 100 }, keys: '[MouseLeft>]' },
|
||||
{ target: carousel, coords: { x: 80, y: 105 } },
|
||||
'[/MouseLeft]',
|
||||
])
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'Trending' }))
|
||||
.toHaveAttribute('aria-current', 'true')
|
||||
expect(cardClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens and closes embedded plugin and template details through their real cards', async () => {
|
||||
const screen = await renderTrending({
|
||||
banners: [mixedRecommendations],
|
||||
isMarketplacePlatform: false,
|
||||
})
|
||||
|
||||
await screen.getByRole('button', { name: 'Dropbox' }).click()
|
||||
const pluginDialog = screen.getByRole('dialog', { name: /Dropbox/ })
|
||||
await expect.element(pluginDialog).toBeVisible()
|
||||
await pluginDialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
await expect.element(pluginDialog).not.toBeInTheDocument()
|
||||
|
||||
await screen.getByRole('button', { name: 'Support Bot' }).click()
|
||||
const templateDialog = screen.getByRole('dialog', { name: /Support Bot/ })
|
||||
await expect.element(templateDialog).toBeVisible()
|
||||
await templateDialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
await expect.element(templateDialog).not.toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('button', { name: 'Support Bot' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['standalone', true, 'CLOUD'],
|
||||
['community console', false, 'COMMUNITY'],
|
||||
] as const)(
|
||||
'keeps %s recommendations on the public navigation contract',
|
||||
async (_, standalone, edition) => {
|
||||
const screen = await renderTrending({
|
||||
banners: [mixedRecommendations],
|
||||
edition,
|
||||
isMarketplacePlatform: standalone,
|
||||
})
|
||||
const plugin = screen.getByRole('link', { name: 'Dropbox' })
|
||||
const template = screen.getByRole('link', { name: 'Support Bot' })
|
||||
|
||||
if (standalone) {
|
||||
await expect.element(plugin).toHaveAttribute('href', '/plugin/langgenius/dropbox')
|
||||
await expect.element(template).toHaveAttribute('href', '/template/aisa-team/tpl-1')
|
||||
} else {
|
||||
expect(new URL(plugin.element().getAttribute('href')!, window.location.origin).origin).toBe(
|
||||
'https://marketplace.dify.ai',
|
||||
)
|
||||
expect(
|
||||
new URL(template.element().getAttribute('href')!, window.location.origin).origin,
|
||||
).toBe('https://marketplace.dify.ai')
|
||||
await expect.element(plugin).toHaveAttribute('target', '_blank')
|
||||
await expect.element(template).toHaveAttribute('target', '_blank')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('clamps a selected slide after a delivered banner refresh without leaving blank space', async () => {
|
||||
const screen = await renderTrending()
|
||||
await screen.getByRole('button', { name: 'Duck Duck Go' }).click()
|
||||
await expectSlideInsideCarousel(screen, 'Duck Duck Go')
|
||||
|
||||
await screen.rerender(
|
||||
<DirectionProvider direction="ltr">
|
||||
<HomeTrending banners={[banners[0]!]} isMarketplacePlatform page="plugins" />
|
||||
</DirectionProvider>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'Trending' })).toBeVisible()
|
||||
await expectSlideInsideCarousel(screen, 'Trending')
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'Duck Duck Go' }))
|
||||
.not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rejects unsafe CMS links and renders no carousel for an empty delivery', async () => {
|
||||
const unsafe: PluginBanner = {
|
||||
id: 'unsafe',
|
||||
style_type: 'blog',
|
||||
title: 'Unsafe Updates',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
blog_title: 'Unsafe launch',
|
||||
subtitle: 'Should not be clickable',
|
||||
link: 'javascript:alert(1)',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
}
|
||||
const screen = await renderTrending({ banners: [unsafe] })
|
||||
await expect.element(screen.getByRole('link')).not.toBeInTheDocument()
|
||||
await expect
|
||||
.element(screen.getByRole('heading', { name: 'Unsafe launch' }))
|
||||
.not.toBeInTheDocument()
|
||||
|
||||
await screen.rerender(
|
||||
<DirectionProvider direction="ltr">
|
||||
<HomeTrending banners={[]} isMarketplacePlatform page="plugins" />
|
||||
</DirectionProvider>,
|
||||
)
|
||||
await expect
|
||||
.element(screen.getByRole('region', { name: 'plugin.marketplace.home.trendingTitle' }))
|
||||
.not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tracks a recommendation only after its public link is activated', async () => {
|
||||
const screen = await renderTrending({ banners: [banners[0]!] })
|
||||
const link = screen.getByRole('link', { name: 'Dropbox' })
|
||||
link.element().addEventListener('click', (event) => event.preventDefault())
|
||||
|
||||
await link.click()
|
||||
|
||||
expect(vi.mocked(trackEvent)).toHaveBeenCalledWith(
|
||||
'marketplace_banner_item_click',
|
||||
expect.objectContaining({
|
||||
banner_id: 'recommend',
|
||||
item_id: 'langgenius/dropbox',
|
||||
item_type: 'plugin',
|
||||
}),
|
||||
)
|
||||
expect(window.__marketplaceTracking__?.track).toHaveBeenCalledWith(
|
||||
'marketplace_banner_click',
|
||||
expect.objectContaining({ item_name: 'Dropbox', item_type: 'plugin' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
+63
@@ -0,0 +1,63 @@
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import MarketplaceLiveSearch from '../marketplace-live-search'
|
||||
|
||||
const router = vi.hoisted(() => ({
|
||||
replace: vi.fn((href: string) => window.history.replaceState(null, '', href)),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => router,
|
||||
}))
|
||||
|
||||
describe('Marketplace live search routing', () => {
|
||||
beforeEach(async () => {
|
||||
await page.viewport(1000, 700)
|
||||
window.history.replaceState(null, '', '/plugins')
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('submits the trimmed query immediately and preserves the active catalog filters', async () => {
|
||||
const screen = await render(
|
||||
<MarketplaceLiveSearch
|
||||
action="/templates/knowledge"
|
||||
language="en-US"
|
||||
placeholder="Search templates"
|
||||
preserveParams={{ languages: ['ja', 'zh-Hans'], tags: ['agent'] }}
|
||||
query=""
|
||||
/>,
|
||||
)
|
||||
const search = screen.getByRole('searchbox', { name: 'Search templates' })
|
||||
|
||||
await search.fill(' legal research ')
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
await expect.poll(() => window.location.pathname).toBe('/templates/knowledge')
|
||||
expect(Object.fromEntries(new URLSearchParams(window.location.search))).toEqual({
|
||||
language: 'en-US',
|
||||
languages: 'ja,zh-Hans',
|
||||
q: 'legal research',
|
||||
tags: 'agent',
|
||||
})
|
||||
await expect.element(search).toHaveValue(' legal research ')
|
||||
|
||||
expect(router.replace).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('debounces typing and clears the query without leaving the active plugin tab', async () => {
|
||||
const screen = await render(
|
||||
<MarketplaceLiveSearch action="/plugins/tool" placeholder="Search plugins" query="maps" />,
|
||||
)
|
||||
const search = screen.getByRole('searchbox', { name: 'Search plugins' })
|
||||
|
||||
await search.fill('weather')
|
||||
expect(window.location.pathname).toBe('/plugins')
|
||||
await expect.poll(() => window.location.pathname).toBe('/plugins/tool')
|
||||
expect(new URLSearchParams(window.location.search).get('q')).toBe('weather')
|
||||
|
||||
await search.clear()
|
||||
await expect.poll(() => window.location.search).toBe('')
|
||||
expect(window.location.pathname).toBe('/plugins/tool')
|
||||
await expect.element(search).toHaveFocus()
|
||||
})
|
||||
})
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import MarketplaceLiveSearch from '../marketplace-live-search'
|
||||
|
||||
const { mockReplace } = vi.hoisted(() => ({
|
||||
mockReplace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}))
|
||||
|
||||
describe('MarketplaceLiveSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('updates the active tab result route while the user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MarketplaceLiveSearch
|
||||
action="/templates/knowledge"
|
||||
language="en-US"
|
||||
placeholder="Search templates"
|
||||
query=""
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('searchbox'), 'legal')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&language=en-US', {
|
||||
scroll: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the query without leaving the active plugin tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MarketplaceLiveSearch action="/plugins/tool" placeholder="Search plugins" query="maps" />,
|
||||
)
|
||||
|
||||
await user.clear(screen.getByRole('searchbox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenLastCalledWith('/plugins/tool', { scroll: false })
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves catalog filter params while the user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MarketplaceLiveSearch
|
||||
action="/templates/knowledge"
|
||||
placeholder="Search templates"
|
||||
query=""
|
||||
preserveParams={{ languages: ['ja'] }}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('searchbox'), 'legal')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&languages=ja', {
|
||||
scroll: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+171
-40
@@ -14,14 +14,14 @@ import {
|
||||
MarketplaceSearchForm,
|
||||
} from '../marketplace-search-autocomplete'
|
||||
|
||||
const { mockTemplateSearch } = vi.hoisted(() => ({
|
||||
mockTemplateSearch: vi.fn(),
|
||||
const api = vi.hoisted(() => ({
|
||||
searchPlugins: vi.fn(),
|
||||
searchTemplates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('react-i18next')>()
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
|
||||
return {
|
||||
...original,
|
||||
...createReactI18nextMock({
|
||||
@@ -38,30 +38,53 @@ vi.mock('react-i18next', async (importOriginal) => {
|
||||
|
||||
vi.mock('@/service/marketplace', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/service/marketplace')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
marketplaceQuery: {
|
||||
...original.marketplaceQuery,
|
||||
searchAdvanced: {
|
||||
...original.marketplaceQuery.searchAdvanced,
|
||||
queryOptions: (
|
||||
options: Parameters<typeof original.marketplaceQuery.searchAdvanced.queryOptions>[0],
|
||||
) => ({
|
||||
...original.marketplaceQuery.searchAdvanced.queryOptions(options),
|
||||
queryFn: () => ({ data: { plugins: [], total: 0 } }),
|
||||
queryFn: () => api.searchPlugins(options.input),
|
||||
}),
|
||||
},
|
||||
templateSearch: {
|
||||
...original.marketplaceQuery.templateSearch,
|
||||
queryOptions: (
|
||||
options: Parameters<typeof original.marketplaceQuery.templateSearch.queryOptions>[0],
|
||||
) => ({
|
||||
...original.marketplaceQuery.templateSearch.queryOptions(options),
|
||||
queryFn: () => mockTemplateSearch(options.input),
|
||||
queryFn: () => api.searchTemplates(options.input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const plugin = {
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
}
|
||||
|
||||
const template = {
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
}
|
||||
|
||||
let queryClient: QueryClient
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
@@ -70,7 +93,6 @@ function Wrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
function StickyTemplateSearch() {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
@@ -84,7 +106,6 @@ function StickyTemplateSearch() {
|
||||
|
||||
function PinnedHeaderState() {
|
||||
const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex h-12 items-center bg-background-default">
|
||||
<span>Dify Marketplace</span>
|
||||
@@ -97,16 +118,46 @@ function PinnedHeaderState() {
|
||||
)
|
||||
}
|
||||
|
||||
describe('Marketplace search autocomplete layout', () => {
|
||||
beforeEach(() => {
|
||||
function SelectionOwner() {
|
||||
const [value, setValue] = useState('')
|
||||
const [selection, setSelection] = useState('None')
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setSubmitted(true)
|
||||
}}
|
||||
>
|
||||
<MarketplaceSearchAutocomplete
|
||||
inputName="q"
|
||||
locale="en-US"
|
||||
onSuggestionSelect={(result) => {
|
||||
setSelection(
|
||||
result.kind === 'plugin' ? result.plugin.name : result.template.template_name,
|
||||
)
|
||||
}}
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins or templates"
|
||||
scope="all"
|
||||
value={value}
|
||||
/>
|
||||
<output aria-label="Selected result">{selection}</output>
|
||||
<output aria-label="Form submitted">{submitted ? 'yes' : 'no'}</output>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Marketplace search autocomplete', () => {
|
||||
beforeEach(async () => {
|
||||
await page.viewport(1280, 720)
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0, retry: false } } })
|
||||
mockTemplateSearch.mockReset()
|
||||
mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
|
||||
vi.clearAllMocks()
|
||||
api.searchPlugins.mockResolvedValue({ data: { plugins: [], total: 0 } })
|
||||
api.searchTemplates.mockResolvedValue({ data: { templates: [], total: 0 } })
|
||||
})
|
||||
|
||||
it('keeps the pinned catalog layout stable while the results popup opens', async () => {
|
||||
await page.viewport(1280, 720)
|
||||
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<HomeStickyStateProvider>
|
||||
@@ -131,49 +182,38 @@ describe('Marketplace search autocomplete layout', () => {
|
||||
</HomeStickyStateProvider>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const container = screen.getByTestId('marketplace-scroll-container')
|
||||
await expect.element(container).toBeVisible()
|
||||
const scrollContainer = container.element()
|
||||
scrollContainer.scrollTop = 300
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await new Promise(requestAnimationFrame)
|
||||
|
||||
const input = screen.getByRole('combobox', { name: 'Search templates' })
|
||||
await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible()
|
||||
|
||||
const catalogNavigation = screen
|
||||
const navigation = screen
|
||||
.getByRole('region', { name: 'navigation.mainNav.marketplace' })
|
||||
.element()
|
||||
const scrollTopBefore = scrollContainer.scrollTop
|
||||
await expect.element(input).toBeVisible()
|
||||
const inputTopBefore = input.element().getBoundingClientRect().top
|
||||
const navigationTopBefore = catalogNavigation.getBoundingClientRect().top
|
||||
const scrollTop = scrollContainer.scrollTop
|
||||
const inputTop = input.element().getBoundingClientRect().top
|
||||
const navigationTop = navigation.getBoundingClientRect().top
|
||||
|
||||
await input.fill('open')
|
||||
await expect.element(screen.getByText('No templates found')).toBeVisible()
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
|
||||
// Base UI hides the page outside the open suggestions from assistive technology, so the
|
||||
// pinned tabs are only reachable when hidden elements are included.
|
||||
expect(scrollContainer.scrollTop).toBe(scrollTop)
|
||||
expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTop)
|
||||
expect(navigation.getBoundingClientRect().top).toBeCloseTo(navigationTop)
|
||||
await expect
|
||||
.element(screen.getByRole('tablist', { name: 'Header catalog tabs', includeHidden: true }))
|
||||
.toBeVisible()
|
||||
expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
|
||||
expect(catalogNavigation.getBoundingClientRect().top).toBeCloseTo(navigationTopBefore)
|
||||
})
|
||||
|
||||
// The search owns no Enter handler: the browser's implicit submission submits the route form
|
||||
// when no suggestion is highlighted. Only a real browser performs that submission.
|
||||
it('submits the route search form on Enter when no suggestion is highlighted', async () => {
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
it('uses native implicit submission with the route form fields when no result is highlighted', async () => {
|
||||
let submitted: Record<string, FormDataEntryValue> | undefined
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceSearchForm
|
||||
action="/search/templates"
|
||||
language="en-US"
|
||||
locale="en-US"
|
||||
placeholder="Search templates"
|
||||
query=""
|
||||
@@ -181,16 +221,107 @@ describe('Marketplace search autocomplete layout', () => {
|
||||
/>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
screen.container.querySelector('form')!.addEventListener('submit', handleSubmit)
|
||||
|
||||
let action: string | undefined
|
||||
screen.container.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
const form = event.target
|
||||
if (!(form instanceof HTMLFormElement)) return
|
||||
action = form.action
|
||||
submitted = Object.fromEntries(new FormData(form))
|
||||
})
|
||||
const input = screen.getByRole('combobox', { name: 'Search templates' })
|
||||
|
||||
await input.fill('open')
|
||||
await expect.element(screen.getByText('No templates found')).toBeVisible()
|
||||
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledOnce()
|
||||
expect(action).toContain('/search/templates')
|
||||
expect(submitted).toEqual({ language: 'en-US', q: 'open' })
|
||||
await expect.element(input).toHaveValue('open')
|
||||
})
|
||||
|
||||
it('groups mixed results and selects the highlighted option without submitting its owner', async () => {
|
||||
api.searchPlugins.mockResolvedValue({ data: { plugins: [plugin], total: 1 } })
|
||||
api.searchTemplates.mockResolvedValue({ data: { templates: [template], total: 1 } })
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<SelectionOwner />
|
||||
</Wrapper>,
|
||||
)
|
||||
const input = screen.getByRole('combobox', { name: 'Search plugins or templates' })
|
||||
|
||||
await input.fill('search')
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'Templates' }).getByText('Legal Research Agent'))
|
||||
.toBeVisible()
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'Plugins' }).getByText('Google Search'))
|
||||
.toBeVisible()
|
||||
await userEvent.keyboard('{ArrowDown}')
|
||||
await expect.element(input).toHaveAttribute('aria-activedescendant')
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('status', { name: 'Selected result' }))
|
||||
.toHaveTextContent('Legal Research Agent')
|
||||
await expect
|
||||
.element(screen.getByRole('status', { name: 'Form submitted' }))
|
||||
.toHaveTextContent('no')
|
||||
await expect.element(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('keeps prior results during refresh but does not reopen after the user dismisses them', async () => {
|
||||
let resolveRefresh!: (value: unknown) => void
|
||||
api.searchPlugins.mockImplementation((input: { body: { query: string } }) => {
|
||||
if (input.body.query === 'google')
|
||||
return Promise.resolve({ data: { plugins: [plugin], total: 1 } })
|
||||
return new Promise((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
})
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<SelectionOwner />
|
||||
<button type="button">Outside search</button>
|
||||
</Wrapper>,
|
||||
)
|
||||
const input = screen.getByRole('combobox', { name: 'Search plugins or templates' })
|
||||
|
||||
await input.fill('google')
|
||||
await expect.element(screen.getByText('Google Search')).toBeVisible()
|
||||
await input.fill('google drive')
|
||||
await expect.element(screen.getByText('Google Search')).toBeVisible()
|
||||
await expect.element(screen.getByText('Loading')).toBeVisible()
|
||||
await expect.poll(() => typeof resolveRefresh).toBe('function')
|
||||
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(screen.getByRole('listbox')).not.toBeInTheDocument()
|
||||
resolveRefresh({ data: { plugins: [plugin], total: 1 } })
|
||||
await expect.element(screen.getByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('announces an empty result and clears the controlled value without querying the other scope', async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceSearchForm
|
||||
action="/plugins"
|
||||
locale="en-US"
|
||||
placeholder="Search plugins"
|
||||
query=""
|
||||
scope="plugins"
|
||||
/>
|
||||
</Wrapper>,
|
||||
)
|
||||
const input = screen.getByRole('combobox', { name: 'Search plugins' })
|
||||
|
||||
await input.fill('zzzz')
|
||||
await expect.element(screen.getByText('No integration found')).toBeVisible()
|
||||
await expect.element(screen.getByText('Loading')).not.toBeInTheDocument()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await screen.getByRole('button', { name: 'Clear search' }).click()
|
||||
|
||||
await expect.element(input).toHaveValue('')
|
||||
await expect.element(input).toHaveFocus()
|
||||
expect(api.searchTemplates).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
-580
@@ -1,580 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/features/marketplace/api'
|
||||
import {
|
||||
MarketplaceSearchAutocomplete,
|
||||
MarketplaceSearchForm,
|
||||
} from '../marketplace-search-autocomplete'
|
||||
|
||||
const { mockAssign, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({
|
||||
mockAssign: vi.fn(),
|
||||
mockPluginSearch: vi.fn(),
|
||||
mockTemplateSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
|
||||
return createReactI18nextMock({
|
||||
clearSearch: 'Clear search',
|
||||
loading: 'Loading',
|
||||
'marketplace.loadError': 'Failed to load. Please try again.',
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('@/service/marketplace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/marketplace')>()
|
||||
return {
|
||||
...actual,
|
||||
marketplaceQuery: {
|
||||
...actual.marketplaceQuery,
|
||||
searchAdvanced: {
|
||||
...actual.marketplaceQuery.searchAdvanced,
|
||||
queryOptions: (
|
||||
options: Parameters<typeof actual.marketplaceQuery.searchAdvanced.queryOptions>[0],
|
||||
) => ({
|
||||
...actual.marketplaceQuery.searchAdvanced.queryOptions(options),
|
||||
queryFn: () => mockPluginSearch(options.input),
|
||||
}),
|
||||
},
|
||||
templateSearch: {
|
||||
...actual.marketplaceQuery.templateSearch,
|
||||
queryOptions: (
|
||||
options: Parameters<typeof actual.marketplaceQuery.templateSearch.queryOptions>[0],
|
||||
) => ({
|
||||
...actual.marketplaceQuery.templateSearch.queryOptions(options),
|
||||
queryFn: () => mockTemplateSearch(options.input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
let queryClient: QueryClient
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
describe('MarketplaceSearchAutocomplete', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAssign.mockReset()
|
||||
vi.spyOn(window.location, 'assign').mockImplementation(mockAssign)
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } })
|
||||
mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows template suggestions and keeps the route search form contract', async () => {
|
||||
let resolveTemplateSearch!: (value: unknown) => void
|
||||
const templateSearchPromise = new Promise((resolve) => {
|
||||
resolveTemplateSearch = resolve
|
||||
})
|
||||
mockTemplateSearch.mockReturnValue(templateSearchPromise)
|
||||
const templateSearchResponse = {
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/templates/knowledge"
|
||||
category="knowledge"
|
||||
language="en-US"
|
||||
locale="en-US"
|
||||
placeholder="Search all templates..."
|
||||
query=""
|
||||
scope="templates"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'legal')
|
||||
expect(screen.queryByText('Legal Research Agent')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
|
||||
resolveTemplateSearch(templateSearchResponse)
|
||||
|
||||
expect(await screen.findByText('Legal Research Agent')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(/Loading/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Research legal questions with cited sources.')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Legal Research Agent'))
|
||||
expect(mockAssign).toHaveBeenCalledWith('/template/dify/template-1')
|
||||
|
||||
expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge')
|
||||
expect(Object.fromEntries(new FormData(container.querySelector('form')!))).toEqual({
|
||||
q: 'legal',
|
||||
language: 'en-US',
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(screen.getByRole('combobox')).toHaveValue('')
|
||||
expect(mockPluginSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows plugin suggestions while preserving the controlled search owner', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={(nextValue) => {
|
||||
onValueChange(nextValue)
|
||||
setValue(nextValue)
|
||||
}}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
|
||||
expect(await screen.findByText('Google Search')).toBeInTheDocument()
|
||||
expect(screen.getByText('Search the web from your workflow.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('listbox').querySelector('img')).toHaveAttribute(
|
||||
'src',
|
||||
`${MARKETPLACE_API_PREFIX}/plugins/langgenius/google-search/icon`,
|
||||
)
|
||||
expect(onValueChange).toHaveBeenLastCalledWith('google')
|
||||
expect(mockTemplateSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('groups mixed suggestions and opens the selected result instead of viewing more', async () => {
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/"
|
||||
locale="en-US"
|
||||
placeholder="Search plugins or templates"
|
||||
query=""
|
||||
scope="all"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'search')
|
||||
|
||||
const templateGroup = await screen.findByRole('group', { name: 'Templates' })
|
||||
const pluginGroup = screen.getByRole('group', { name: 'Plugins' })
|
||||
expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument()
|
||||
expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Google Search'))
|
||||
|
||||
expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search')
|
||||
expect(handleSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves Enter to the form submission when no suggestion is highlighted', async () => {
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/search/all"
|
||||
locale="en-US"
|
||||
placeholder="Search plugins or templates"
|
||||
query=""
|
||||
scope="all"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'search')
|
||||
await screen.findByRole('option', { name: /Legal Research Agent/ })
|
||||
|
||||
// An IME confirmation keeps the suggestions open and submits nothing.
|
||||
fireEvent.keyDown(screen.getByRole('combobox'), {
|
||||
key: 'Enter',
|
||||
keyCode: 229,
|
||||
which: 229,
|
||||
isComposing: true,
|
||||
})
|
||||
expect(handleSubmit).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('option', { name: /Legal Research Agent/ })).toBeInTheDocument()
|
||||
|
||||
// Nothing is highlighted, so Enter is not consumed and the browser's implicit form submission
|
||||
// runs; happy-dom has none, so the browser spec covers the submit.
|
||||
expect(fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' })).toBe(true)
|
||||
expect(screen.getByRole('combobox')).toHaveValue('search')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('selects a suggestion without submitting when the parent handles the result', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const onSuggestionSelect = vi.fn()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<form>
|
||||
<MarketplaceSearchAutocomplete
|
||||
inputName="q"
|
||||
locale="en-US"
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const { container } = render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
await user.click(await screen.findByText('Google Search'))
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveValue('')
|
||||
expect(mockAssign).not.toHaveBeenCalled()
|
||||
expect(onSuggestionSelect).toHaveBeenCalledOnce()
|
||||
expect(onSuggestionSelect.mock.calls[0]?.[0]).toMatchObject({
|
||||
kind: 'plugin',
|
||||
plugin: { name: 'google-search' },
|
||||
})
|
||||
expect(handleSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps keyboard selection working for the highlighted suggestion', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/plugins"
|
||||
locale="en-US"
|
||||
placeholder="Search plugins"
|
||||
query=""
|
||||
scope="plugins"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
expect(await screen.findByText('Google Search')).toBeInTheDocument()
|
||||
await user.keyboard('{ArrowDown}{Enter}')
|
||||
|
||||
expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search')
|
||||
expect(handleSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the previous suggestions listed under a loading status while a new search is pending', async () => {
|
||||
const googleResponse = {
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
}
|
||||
mockPluginSearch.mockImplementation((input: { body: { query: string } }) => {
|
||||
if (input.body.query === 'google') return Promise.resolve(googleResponse)
|
||||
// Keep the follow-up term pending so the loading state stays observable.
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
expect(await screen.findByText('Google Search')).toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByRole('combobox'), ' drive')
|
||||
expect(screen.getByText('Google Search')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(mockPluginSearch).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ body: expect.objectContaining({ query: 'google drive' }) }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(screen.getByText('Google Search')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not reopen after dismiss while a request is still pending', async () => {
|
||||
let resolvePluginSearch!: (value: unknown) => void
|
||||
mockPluginSearch.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePluginSearch = resolve
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const pluginResponse = {
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
<button type="button">Outside search</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
|
||||
// Base UI hides the rest of the page from assistive technology while the suggestions are
|
||||
// open, so the outside control is reached by text rather than by role.
|
||||
await user.click(screen.getByText('Outside search'))
|
||||
await waitFor(() => {
|
||||
const loading = screen.queryByText(/Loading/)
|
||||
if (loading) expect(loading).not.toBeVisible()
|
||||
})
|
||||
|
||||
await act(async () => resolvePluginSearch(pluginResponse))
|
||||
expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('announces an empty result after the search completes', async () => {
|
||||
mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } })
|
||||
const user = userEvent.setup()
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'zzzz')
|
||||
|
||||
expect(await screen.findByText('No integration found')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(/Loading/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user