From 116a990765661cc8231300a263c5798cd8aacb2d Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Tue, 10 Nov 2026 08:58:46 +0000 Subject: [PATCH] test(web): migrate marketplace home specs to browser (#45952) --- oxlint-suppressions.json | 5 - .../catalog-filters.browser.spec.tsx | 106 ++ .../catalog-languages-filter.spec.tsx | 32 - .../__tests__/catalog-tags-filter.spec.tsx | 47 - ...bedded-marketplace-search.browser.spec.tsx | 236 ++++ .../embedded-marketplace-search.spec.tsx | 226 ---- .../home-catalog-navigation.spec.tsx | 114 -- .../__tests__/home-chrome.browser.spec.tsx | 186 +++ .../home/__tests__/home-guide.spec.tsx | 137 -- .../home/__tests__/home-header.spec.tsx | 117 -- .../home/__tests__/home-hero.spec.tsx | 28 - .../__tests__/home-trending.browser.spec.tsx | 469 +++++++ .../home/__tests__/home-trending.spec.tsx | 1144 ----------------- .../marketplace-live-search.browser.spec.tsx | 63 + .../marketplace-live-search.spec.tsx | 74 -- ...place-search-autocomplete.browser.spec.tsx | 211 ++- .../marketplace-search-autocomplete.spec.tsx | 580 --------- 17 files changed, 1231 insertions(+), 2544 deletions(-) create mode 100644 web/app/components/plugins/marketplace/home/__tests__/catalog-filters.browser.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.browser.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-chrome.browser.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-trending.browser.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.browser.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index a7fa9540958..b9524889951 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 diff --git a/web/app/components/plugins/marketplace/home/__tests__/catalog-filters.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/catalog-filters.browser.spec.tsx new file mode 100644 index 00000000000..4f7b94e4c6b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/catalog-filters.browser.spec.tsx @@ -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() + 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( + +
+ + +
+
, + ) + 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) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx deleted file mode 100644 index 79571b30f3e..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx +++ /dev/null @@ -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() - 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') - }) - }) -}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx deleted file mode 100644 index 97bdd8e1ce5..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx +++ /dev/null @@ -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() - 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') - }) - }) -}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.browser.spec.tsx new file mode 100644 index 00000000000..1b9ed2b737f --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.browser.spec.tsx @@ -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() + 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() + return { + ...actual, + marketplaceQuery: { + ...actual.marketplaceQuery, + searchAdvanced: { + ...actual.marketplaceQuery.searchAdvanced, + queryOptions: ( + options: Parameters[0], + ) => ({ + ...actual.marketplaceQuery.searchAdvanced.queryOptions(options), + queryFn: () => boundary.searchPlugins(options.input), + }), + }, + templateSearch: { + ...actual.marketplaceQuery.templateSearch, + queryOptions: ( + options: Parameters[0], + ) => ({ + ...actual.marketplaceQuery.templateSearch.queryOptions(options), + queryFn: () => boundary.searchTemplates(options.input), + }), + }, + }, + } +}) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + 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() + const toBlank = (url: string) => + `about:blank${new URL(url, 'https://marketplace.example.test').search}` + return { + ...actual, + marketplaceLinks: { + ...actual.marketplaceLinks, + plugin: (...args: Parameters) => + toBlank(actual.marketplaceLinks.plugin(...args)), + template: (...args: Parameters) => + 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( + + + + + , + ) + 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() + }, + ) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx deleted file mode 100644 index 8b61c413c14..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx +++ /dev/null @@ -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 = { - '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() - return { - ...actual, - marketplaceQuery: { - ...actual.marketplaceQuery, - searchAdvanced: { - ...actual.marketplaceQuery.searchAdvanced, - queryOptions: ( - options: Parameters[0], - ) => ({ - ...actual.marketplaceQuery.searchAdvanced.queryOptions(options), - queryFn: () => mockPluginSearch(), - }), - }, - templateSearch: { - ...actual.marketplaceQuery.templateSearch, - queryOptions: ( - options: Parameters[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() - const toBlank = (url: string) => - `about:blank${new URL(url, 'https://marketplace.example.test').search}` - return { - ...actual, - marketplaceLinks: { - ...actual.marketplaceLinks, - plugin: (...args: Parameters) => - toBlank(actual.marketplaceLinks.plugin(...args)), - template: (...args: Parameters) => - 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( - - - , - ) - 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() - }, - ) -}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx deleted file mode 100644 index 939dc98a90a..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx +++ /dev/null @@ -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: () =>
, -})) - -describe('HomeCatalogNavigation', () => { - const renderNavigation = (isMarketplacePlatform: boolean) => { - return render( - - } - /> - , - ) - } - - 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() - - 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() - - 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( - , - ) - - 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( - - } - catalogCategories={} - /> - , - ) - - 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') - }) -}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-chrome.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-chrome.browser.spec.tsx new file mode 100644 index 00000000000..ef949e13730 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-chrome.browser.spec.tsx @@ -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() + 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() + 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( + + + + + + Template categories} + catalogTabs={ + + } + /> + + , + ) + + 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( + + + + Plugin categories} + catalogTabs={} + /> + + , + ) + 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() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx deleted file mode 100644 index a89dda9df0a..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx +++ /dev/null @@ -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() - 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( - - - , - ) - -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() - }) -}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx deleted file mode 100644 index cb626740da4..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx +++ /dev/null @@ -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() - 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() - - 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() - - 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() - - 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( - , - ) - - 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( - , - ) - - 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') - }) -}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx deleted file mode 100644 index 568c4b35751..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx +++ /dev/null @@ -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( - , - ) - - 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() - }) -}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.browser.spec.tsx new file mode 100644 index 00000000000..24c8fee1fa6 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.browser.spec.tsx @@ -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()), + fetchPluginInfoFromMarketPlace: vi + .fn() + .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() + 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() + const toBlank = (url: string) => + `about:blank${new URL(url, 'https://marketplace.example.test').search}` + return { + ...actual, + marketplaceLinks: { + ...actual.marketplaceLinks, + plugin: (...args: Parameters) => + toBlank(actual.marketplaceLinks.plugin(...args)), + template: (...args: Parameters) => + 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( + + + +
+ +
+
+ +
, + ) +} + +function expectSlideInsideCarousel( + screen: Awaited>, + 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>) { + 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( + + + , + ) + + 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( + + + , + ) + 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' }), + ) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx deleted file mode 100644 index 1d9579b738c..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx +++ /dev/null @@ -1,1144 +0,0 @@ -import type { PluginBanner } from '@dify/contracts/marketplace' -import type { Window } from 'happy-dom' -import type { ReactElement } from 'react' -import { DirectionProvider } from '@langgenius/dify-ui/direction-provider' -import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { trackEvent } from '@/app/components/base/amplitude' -import { seedModelProviderQueries } from '@/test/console/model-provider' -import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' -import { mockIntersectionObserver } from '@/test/intersection-observer' -import HomeTrending from '../home-trending' - -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: vi.fn(), -})) - -vi.mock('@/next/navigation', async (importOriginal) => ({ - ...(await importOriginal()), - useRouter: () => ({ push: vi.fn() }), -})) - -vi.mock('@/service/plugins', async (importOriginal) => ({ - ...(await importOriginal()), - fetchPluginInfoFromMarketPlace: vi - .fn() - .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', - }, - }, - })), -})) - -// Keep real dialogs and message handling without loading external documents. -vi.mock('@/features/marketplace/links', async (importOriginal) => { - const actual = await importOriginal() - const toBlank = (url: string) => - `about:blank${new URL(url, 'https://marketplace.example.test').search}` - return { - ...actual, - marketplaceLinks: { - ...actual.marketplaceLinks, - plugin: (...args: Parameters) => - toBlank(actual.marketplaceLinks.plugin(...args)), - template: (...args: Parameters) => - toBlank(actual.marketplaceLinks.template(...args)), - }, - } -}) - -vi.mock('@/service/console', async (importOriginal) => { - const actual = await importOriginal() - const { withConsoleResponses } = await import('@/test/console/service-responses') - return { - ...actual, - consoleQuery: withConsoleResponses(actual.consoleQuery, { - 'workspaces.current.plugin.list.installations.ids.post': async () => ({ plugins: [] }), - }), - } -}) - -const render = (ui: ReactElement, options: Parameters[1] = {}) => { - const queryClient = createConsoleQueryClient() - seedModelProviderQueries(queryClient) - return renderWithConsoleQuery(ui, { - queryClient, - systemFeatures: { deployment_edition: 'CLOUD' }, - ...options, - }) -} - -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, - }, - { - item_type: 'plugin', - item_id: 'langgenius/notion', - display_name: 'Notion', - link: '/plugins/langgenius/notion', - card_position: 2, - }, - { - item_type: 'plugin', - item_id: 'langgenius/slack', - display_name: 'Slack', - link: '/plugins/langgenius/slack', - card_position: 3, - }, - ], - }, - }, - { - 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.', - 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 mockTrackEvent = vi.mocked(trackEvent) -const mockTrackMarketplaceSiteEvent = vi.fn() - -const originalAnimateDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'animate') -const originalVisibilityDescriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState') -const mockProgressAnimation = () => { - const animation = { - cancel: vi.fn(), - pause: vi.fn(), - play: vi.fn(), - finished: Promise.resolve(), - onfinish: null, - } - Object.defineProperty(Element.prototype, 'animate', { - configurable: true, - value: vi.fn(() => animation), - }) - return animation -} - -const navigationSettings = (window as unknown as Window).happyDOM.settings.navigation -const originalNavigation = navigationSettings.disableMainFrameNavigation - -beforeEach(() => { - navigationSettings.disableMainFrameNavigation = true - document.documentElement.setAttribute('data-is-marketplace', '') - window.__marketplaceTracking__ = { - track: mockTrackMarketplaceSiteEvent, - rememberReferrer: vi.fn(), - markSearch: vi.fn(), - flushSearch: vi.fn(), - markFilter: vi.fn(), - flushFilter: vi.fn(), - } -}) - -afterEach(() => { - delete window.__marketplaceTracking__ - document.documentElement.removeAttribute('data-is-marketplace') - navigationSettings.disableMainFrameNavigation = originalNavigation - vi.unstubAllGlobals() - vi.restoreAllMocks() - if (originalAnimateDescriptor) - Object.defineProperty(Element.prototype, 'animate', originalAnimateDescriptor) - else Reflect.deleteProperty(Element.prototype, 'animate') - if (originalVisibilityDescriptor) - Object.defineProperty(document, 'visibilityState', originalVisibilityDescriptor) - else Reflect.deleteProperty(document, 'visibilityState') -}) - -describe('HomeTrending', () => { - it('renders and switches between the three API-backed banner layouts', async () => { - const user = userEvent.setup() - - render() - - expect(screen.getByRole('heading', { name: 'Popular plugins' })).toBeInTheDocument() - const recommendationSlide = screen.getByRole('group', { name: 'Trending' }) - expect( - within(recommendationSlide) - .getAllByRole('link') - .map((link) => link.getAttribute('aria-label')), - ).toEqual(['Dropbox', 'Zapier', 'Notion', 'Slack']) - - await user.click(screen.getByRole('button', { name: 'Dify Updates' })) - - expect(screen.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeInTheDocument() - const blogSlide = screen.getByRole('group', { name: 'Dify Updates' }) - const blogLink = within(blogSlide).getByRole('link', { - name: /^plugin\.marketplace\.home\.trendingReadMoreAbout/, - }) - expect(blogLink).toHaveAttribute('href', 'https://dify.ai/blog') - expect(within(blogSlide).getAllByRole('link')).toHaveLength(1) - expect( - within(blogLink).getByRole('heading', { name: 'Dify v1.9 new launch' }), - ).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'Duck Duck Go' })) - - expect(screen.getByRole('link', { name: 'DuckDuckGo plugin' })).toHaveAttribute( - 'href', - 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo', - ) - }) - - it('renders the delivered blog cover instead of bundled art', () => { - const blogBanner: PluginBanner = { - id: 'blog-cover', - style_type: 'blog', - title: 'Research', - sort: 0, - language: 'en', - content: { - blog_title: 'Trust Is a Feature', - subtitle: 'Security and governance', - link: 'https://dify.ai/blog/trust', - link_target_type: 'blog', - cover_image: '/api/v1/banners/images/banners/trust-cover.png', - }, - } - - render() - - const blogLink = screen.getByRole('link', { - name: /^plugin\.marketplace\.home\.trendingReadMoreAbout/, - }) - expect(blogLink.querySelector('img')?.getAttribute('src')).toContain( - '/api/v1/banners/images/banners/trust-cover.png', - ) - }) - - it('omits blog cover art when the payload has no cover_image', () => { - render() - - const blogLink = screen.getByRole('link', { - name: /^plugin\.marketplace\.home\.trendingReadMoreAbout/, - }) - expect(blogLink.querySelector('img')).toBeNull() - }) - - it('keeps only the selected slide in the accessibility tree', async () => { - const user = userEvent.setup() - render() - const firstSlide = screen.getByRole('group', { name: 'Trending' }) - expect(firstSlide).not.toHaveAttribute('inert') - expect(screen.queryByRole('group', { name: 'Dify Updates' })).not.toBeInTheDocument() - expect(screen.queryByRole('group', { name: 'Duck Duck Go' })).not.toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'Dify Updates' })) - expect(screen.getByRole('group', { name: 'Dify Updates' })).not.toHaveAttribute('inert') - expect(firstSlide).toHaveAttribute('inert') - expect(screen.queryByRole('group', { name: 'Trending' })).not.toBeInTheDocument() - }) - - it('switches to the selected slide from the pagination with the keyboard', async () => { - const user = userEvent.setup() - - render() - - const duckDuckGoButton = screen.getByRole('button', { name: 'Duck Duck Go' }) - - act(() => duckDuckGoButton.focus()) - await user.keyboard('{Enter}') - - expect(duckDuckGoButton).toHaveAttribute('aria-current', 'true') - expect(screen.getByRole('button', { name: 'Trending' })).not.toHaveAttribute('aria-current') - expect(screen.getByRole('group', { name: 'Duck Duck Go' })).toHaveAttribute( - 'aria-hidden', - 'false', - ) - }) - - it('does not reject when the autoplay animation is canceled on unmount', async () => { - let rejectFinished: (reason: unknown) => void = () => {} - const finished = new Promise((_resolve, reject) => { - rejectFinished = reject - }) - const progressAnimation = { - cancel: vi.fn(() => { - rejectFinished( - Object.assign(new Error('The animation was canceled.'), { name: 'AbortError' }), - ) - }), - onfinish: null, - pause: vi.fn(), - play: vi.fn(), - finished, - } as unknown as Animation - Object.defineProperty(Element.prototype, 'animate', { - configurable: true, - value: vi.fn(() => progressAnimation), - }) - - const { unmount } = render( - , - ) - - unmount() - await act(async () => { - await Promise.resolve() - }) - - expect(progressAnimation.cancel).toHaveBeenCalled() - }) - - it('toggles the carousel between paused and playing states', async () => { - const user = userEvent.setup() - - render() - - const carousel = document.querySelector('[data-home-trending-carousel-root]')! - const liveTrack = carousel.querySelector('[aria-live]')! - expect(liveTrack).toHaveAttribute('aria-live', 'off') - - const pauseButton = screen.getByRole('button', { - name: 'plugin.marketplace.home.trendingPause', - }) - - act(() => pauseButton.focus()) - await user.keyboard('{Enter}') - - expect(liveTrack).toHaveAttribute('aria-live', 'polite') - - const playButton = screen.getByRole('button', { - name: 'plugin.marketplace.home.trendingPlay', - }) - - act(() => playButton.focus()) - await user.keyboard(' ') - - expect( - screen.getByRole('button', { - name: 'plugin.marketplace.home.trendingPause', - }), - ).toBeInTheDocument() - }) - - it('starts with autoplay paused when reduced motion is enabled', () => { - const matchMedia = vi.spyOn(window, 'matchMedia').mockReturnValue({ - matches: true, - media: '(prefers-reduced-motion: reduce)', - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - }) - - render() - - expect( - screen.getByRole('button', { - name: 'plugin.marketplace.home.trendingPlay', - }), - ).toBeInTheDocument() - - matchMedia.mockRestore() - }) - - it('keeps embedded autoplay paused until every pause reason is cleared', async () => { - const user = userEvent.setup() - const pause = vi.fn() - const play = vi.fn() - const cancel = vi.fn() - const progressAnimation = { - cancel, - finished: Promise.resolve(), - onfinish: null, - pause, - play, - } as unknown as Animation - Object.defineProperty(Element.prototype, 'animate', { - configurable: true, - value: vi.fn(() => progressAnimation), - }) - const intersect = mockIntersectionObserver() - let reducedMotion = false - let reducedMotionListener: (() => void) | undefined - vi.stubGlobal('matchMedia', () => ({ - get matches() { - return reducedMotion - }, - media: '(prefers-reduced-motion: reduce)', - onchange: null, - addEventListener: (_event: string, listener: () => void) => { - reducedMotionListener = listener - }, - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })) - const marketplaceContainer = document.createElement('div') - marketplaceContainer.id = 'marketplace-container' - document.body.appendChild(marketplaceContainer) - - const { unmount } = render( - , - { - container: marketplaceContainer, - }, - ) - const outsideControl = document.createElement('button') - document.body.appendChild(outsideControl) - const carouselRoot = marketplaceContainer.querySelector('[data-home-trending-carousel-root]')! - const setIntersectionRatio = (intersectionRatio: number) => - intersect(intersectionRatio > 0, { target: carouselRoot, intersectionRatio }) - - expect(pause).toHaveBeenCalled() - - setIntersectionRatio(0.25) - expect(play).toHaveBeenCalledOnce() - - await user.hover(carouselRoot) - setIntersectionRatio(0) - await user.unhover(carouselRoot) - expect(play).toHaveBeenCalledOnce() - - setIntersectionRatio(0.25) - expect(play).toHaveBeenCalledTimes(2) - - const playsBeforeFocus = play.mock.calls.length - const focusTarget = screen.getByRole('button', { name: 'Dropbox' }) - act(() => focusTarget.focus()) - setIntersectionRatio(0) - setIntersectionRatio(0.25) - expect(play).toHaveBeenCalledTimes(playsBeforeFocus) - act(() => outsideControl.focus()) - expect(play.mock.calls.length).toBeGreaterThan(playsBeforeFocus) - - // Navigation controls sit inside the pause boundary, so focusing them - // also stops the rotation. - const playsBeforeControlFocus = play.mock.calls.length - const paginationButton = screen.getByRole('button', { name: 'Dify Updates' }) - act(() => paginationButton.focus()) - setIntersectionRatio(0) - setIntersectionRatio(0.25) - expect(play).toHaveBeenCalledTimes(playsBeforeControlFocus) - act(() => outsideControl.focus()) - expect(play.mock.calls.length).toBeGreaterThan(playsBeforeControlFocus) - - const playsBeforeUserPause = play.mock.calls.length - await user.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' })) - setIntersectionRatio(0) - setIntersectionRatio(0.25) - expect(play).toHaveBeenCalledTimes(playsBeforeUserPause) - await user.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' })) - expect(play.mock.calls.length).toBeGreaterThan(playsBeforeUserPause) - - const playsBeforeVisibilityPause = play.mock.calls.length - Object.defineProperty(document, 'visibilityState', { - configurable: true, - value: 'hidden', - }) - fireEvent(document, new Event('visibilitychange')) - setIntersectionRatio(0.25) - expect(play).toHaveBeenCalledTimes(playsBeforeVisibilityPause) - - Object.defineProperty(document, 'visibilityState', { - configurable: true, - value: 'visible', - }) - fireEvent(document, new Event('visibilitychange')) - expect(play.mock.calls.length).toBeGreaterThan(playsBeforeVisibilityPause) - - const playsBeforeReducedMotion = play.mock.calls.length - reducedMotion = true - act(() => reducedMotionListener?.()) - setIntersectionRatio(0) - setIntersectionRatio(0.25) - expect(play).toHaveBeenCalledTimes(playsBeforeReducedMotion) - - reducedMotion = false - act(() => reducedMotionListener?.()) - expect(play.mock.calls.length).toBeGreaterThan(playsBeforeReducedMotion) - - unmount() - marketplaceContainer.remove() - outsideControl.remove() - }) - - it('resumes autoplay after a pointer click on pagination without waiting for blur', async () => { - const { play } = mockProgressAnimation() - const user = userEvent.setup() - - render() - - const carouselRoot = document.querySelector('[data-home-trending-carousel-root]')! - const paginationButton = screen.getByRole('button', { name: 'Dify Updates' }) - - // Pointer activation hovers and focuses the control, which normally - // pauses rotation until mouseleave/focusout. - await user.hover(carouselRoot) - act(() => paginationButton.focus()) - - const playsBeforeSelect = play.mock.calls.length - await user.click(paginationButton) - - expect(paginationButton).toHaveAttribute('aria-current', 'true') - expect(document.activeElement).toBe(paginationButton) - expect(play.mock.calls.length).toBeGreaterThan(playsBeforeSelect) - }) - - it('keeps autoplay paused when pagination is selected from the keyboard', async () => { - const { play } = mockProgressAnimation() - const user = userEvent.setup() - - render() - - const paginationButton = screen.getByRole('button', { name: 'Dify Updates' }) - act(() => paginationButton.focus()) - - const playsBeforeSelect = play.mock.calls.length - await user.keyboard('{Enter}') - - expect(paginationButton).toHaveAttribute('aria-current', 'true') - expect(document.activeElement).toBe(paginationButton) - expect(play).toHaveBeenCalledTimes(playsBeforeSelect) - }) - - it('resumes autoplay when Play is activated without moving keyboard focus', async () => { - const { pause, play } = mockProgressAnimation() - const user = userEvent.setup() - - render() - - const toggleButton = screen.getByRole('button', { - name: 'plugin.marketplace.home.trendingPause', - }) - - // Focusing the toggle adds the implicit focus pause reason, then Enter - // adds the explicit user pause. - act(() => toggleButton.focus()) - await user.keyboard('{Enter}') - expect(pause).toHaveBeenCalled() - - // Play must resume the rotation even though the button is still focused - // (and would normally keep the focus pause reason active). - const playsBeforePlay = play.mock.calls.length - await user.keyboard('{Enter}') - - expect(play.mock.calls.length).toBeGreaterThan(playsBeforePlay) - expect(document.activeElement).toBe(toggleButton) - expect( - screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }), - ).toBeInTheDocument() - }) - - it('opens embedded recommend plugin cards in the plugin dialog', async () => { - const user = userEvent.setup() - const bannerWithMixedLinks: PluginBanner = { - id: 'recommend-mixed', - style_type: 'recommend', - title: 'Trending', - sort: 0, - language: 'en', - content: { - theme_type: 'hottest', - cards: [ - { - item_type: 'plugin', - item_id: 'langgenius/dropbox', - display_name: 'Dropbox', - link: 'https://external.example.com/dropbox', - card_position: 0, - }, - { - item_type: 'plugin', - item_id: 'langgenius/notion', - display_name: 'Notion', - link: '', - card_position: 1, - }, - { - item_type: 'template', - item_id: 'tpl-1', - display_name: 'Support Bot', - link: '', - card_position: 2, - }, - ], - }, - } - - render( - , - ) - - expect(screen.queryByRole('link', { name: 'Dropbox' })).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'Notion' })).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'Support Bot' })).not.toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'Dropbox' })) - - expect( - screen.getByRole('dialog', { name: 'Dropbox · plugin.detailPanel.operation.detail' }), - ).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'common.operation.close' })) - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) - expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith( - 'marketplace_banner_click', - expect.objectContaining({ - click_target: 'recommendation', - item_id: 'langgenius/dropbox', - item_type: 'plugin', - item_name: 'Dropbox', - }), - ) - }) - - it('opens embedded recommend template cards in the template dialog', async () => { - const user = userEvent.setup() - const bannerWithTemplates: PluginBanner = { - id: 'recommend-templates-embedded', - style_type: 'recommend', - title: 'Trending', - sort: 0, - language: 'en', - content: { - theme_type: 'newest', - cards: [ - { - item_type: 'template', - item_id: 'tpl-1', - display_name: 'Support Bot', - creator: 'aisa-team', - link: 'https://external.example.com/support-bot', - card_position: 0, - }, - ], - }, - } - - render( - , - ) - - expect(screen.queryByRole('link', { name: 'Support Bot' })).not.toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'Support Bot' })) - - expect( - screen.getByRole('dialog', { name: 'Support Bot · plugin.detailPanel.operation.detail' }), - ).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'common.operation.close' })) - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) - expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith( - 'marketplace_banner_click', - expect.objectContaining({ - click_target: 'recommendation', - item_id: 'tpl-1', - item_type: 'template', - item_name: 'Support Bot', - }), - ) - }) - - it('opens embedded recommend plugin cards from /plugins links when item_id is not org/name', async () => { - const user = userEvent.setup() - const banner: PluginBanner = { - id: 'recommend-plugin-href', - style_type: 'recommend', - title: 'Trending', - sort: 0, - language: 'en', - content: { - theme_type: 'hottest', - cards: [ - { - item_type: 'plugin', - item_id: 'baserow', - display_name: 'Baserow', - link: '/plugins/langgenius/baserow', - card_position: 0, - }, - ], - }, - } - - render() - - expect(screen.queryByRole('link', { name: 'Baserow' })).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Baserow' })) - expect( - screen.getByRole('dialog', { name: 'Baserow · plugin.detailPanel.operation.detail' }), - ).toBeInTheDocument() - }) - - it('links recommend template cards by ID with the served author or template fallback', () => { - const banner: PluginBanner = { - id: 'recommend-templates', - style_type: 'recommend', - title: 'Trending', - sort: 0, - language: 'en', - content: { - theme_type: 'newest', - cards: [ - { - item_type: 'template', - item_id: 'tpl-authored', - display_name: 'Go-to-Market', - creator: 'aisa-team', - link: '/templates/tpl-authored', - card_position: 0, - }, - { - item_type: 'template', - item_id: 'tpl-anonymous', - display_name: 'Untitled Flow', - link: '/templates/tpl-anonymous', - card_position: 1, - }, - ], - }, - } - - render() - - const authored = screen.getByRole('link', { name: 'Go-to-Market' }) - const anonymous = screen.getByRole('link', { name: 'Untitled Flow' }) - - expect(authored).toHaveAttribute('href', '/template/aisa-team/tpl-authored') - expect(anonymous).toHaveAttribute('href', '/template/template/tpl-anonymous') - expect( - within(authored).getByText(/^plugin\.marketplace\.home\.trendingByCreator/), - ).toBeInTheDocument() - expect( - within(anonymous).queryByText('plugin.marketplace.home.trendingByCreator'), - ).not.toBeInTheDocument() - }) - - it('renders persisted emoji-mart IDs on recommend template cards as native emoji', () => { - const banner: PluginBanner = { - id: 'recommend-template-emoji', - style_type: 'recommend', - title: 'Trending', - sort: 0, - language: 'en', - content: { - theme_type: 'newest', - cards: [ - { - item_type: 'template', - item_id: 'tpl-emoji-id', - display_name: 'Prompt Helper', - icon: 'face_with_monocle', - icon_background: '#FFEAD5', - link: '/templates/tpl-emoji-id', - card_position: 0, - }, - { - item_type: 'template', - item_id: 'tpl-native-emoji', - display_name: 'Offer Arena', - icon: '🤖', - icon_background: '#FFEAD5', - link: '/templates/tpl-native-emoji', - card_position: 1, - }, - ], - }, - } - - render() - - const emojiIdCard = screen.getByRole('link', { name: 'Prompt Helper' }) - expect(within(emojiIdCard).getByText('🧐')).toBeInTheDocument() - expect(within(emojiIdCard).queryByText('face_with_monocle')).not.toBeInTheDocument() - expect( - within(screen.getByRole('link', { name: 'Offer Arena' })).getByText('🤖'), - ).toBeInTheDocument() - }) - - it('keeps standalone recommend plugin cards on local detail routes', () => { - render() - - expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute( - 'href', - '/plugin/langgenius/dropbox', - ) - }) - - it('clamps the active slide when a refetch shrinks the banner list', async () => { - const user = userEvent.setup() - const { rerender } = render( - , - ) - - await user.click(screen.getByRole('button', { name: 'Duck Duck Go' })) - expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute( - 'aria-current', - 'true', - ) - - rerender() - - await waitFor(() => { - expect(screen.getByRole('group', { name: 'Trending' })).not.toHaveAttribute('inert') - }) - expect(screen.queryByRole('button', { name: 'Duck Duck Go' })).not.toBeInTheDocument() - }) - - describe('mobile swipe', () => { - const stubViewport = (isNarrow: boolean) => - vi.spyOn(window, 'matchMedia').mockImplementation( - (query) => - ({ - matches: isNarrow && query === '(max-width: 879px)', - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - }) as MediaQueryList, - ) - - const swipe = (slide: HTMLElement, points: Array<[x: number, y: number]>) => { - const touch = { pointerId: 1, pointerType: 'touch', isPrimary: true } - const [start, ...moves] = points - fireEvent.pointerDown(slide, { ...touch, clientX: start![0], clientY: start![1] }) - for (const [clientX, clientY] of moves) - fireEvent.pointerMove(slide, { ...touch, clientX, clientY }) - const [clientX, clientY] = points.at(-1)! - fireEvent.pointerUp(slide, { ...touch, clientX, clientY }) - } - - const renderCarousel = (direction: 'ltr' | 'rtl' = 'ltr') => - render( - -
- -
-
, - ) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it.each(['ltr', 'rtl'] as const)( - 'swipes forward and back without activating a link or clearing Pause in %s', - async (direction) => { - const user = userEvent.setup() - stubViewport(true) - renderCarousel(direction) - - swipe(screen.getByRole('group', { name: 'Trending' }), [ - [direction === 'ltr' ? 480 : 300, 160], - [direction === 'ltr' ? 300 : 480, 166], - ]) - - expect(fireEvent.click(screen.getByRole('link', { name: 'Dropbox', hidden: true }))).toBe( - false, - ) - expect(screen.getByRole('button', { name: 'Dify Updates' })).toHaveAttribute( - 'aria-current', - 'true', - ) - - await user.click( - screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }), - ) - swipe(screen.getByRole('group', { name: 'Dify Updates' }), [ - [direction === 'ltr' ? 260 : 440, 160], - [direction === 'ltr' ? 440 : 260, 166], - ]) - - expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute( - 'aria-current', - 'true', - ) - expect( - screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' }), - ).toBeInTheDocument() - }, - ) - - it('suppresses the trailing click when a horizontal drag is pulled back before release', () => { - stubViewport(true) - renderCarousel() - - swipe(screen.getByRole('group', { name: 'Trending' }), [ - [400, 160], - [280, 164], - [396, 162], - ]) - - expect(fireEvent.click(screen.getByRole('link', { name: 'Dropbox' }))).toBe(false) - expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute( - 'aria-current', - 'true', - ) - }) - - it.each([ - { - name: 'a vertical gesture on a narrow viewport', - isNarrow: true, - end: [270, 300] as [number, number], - }, - { - name: 'touch input on a wide viewport', - isNarrow: false, - end: [80, 160] as [number, number], - }, - ])('stays on the current slide for $name', ({ isNarrow, end }) => { - stubViewport(isNarrow) - renderCarousel() - - swipe(screen.getByRole('group', { name: 'Trending' }), [[300, 120], end]) - - expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute( - 'aria-current', - 'true', - ) - }) - }) - - it('renders no carousel when the API returns no banners', () => { - render() - - expect( - screen.queryByRole('region', { - name: 'plugin.marketplace.home.trendingTitle', - }), - ).not.toBeInTheDocument() - }) - - it('tracks recommend card clicks as item clicks without a frame click', async () => { - const user = userEvent.setup() - - render() - - expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute( - 'href', - '/plugin/langgenius/dropbox', - ) - await user.click(screen.getByRole('link', { name: 'Dropbox' })) - - expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_item_click', { - banner_id: 'recommend', - sort: 0, - page: 'templates', - language: 'en', - style_type: 'recommend', - item_type: 'plugin', - item_id: 'langgenius/dropbox', - card_position: 0, - theme_type: 'hottest', - auto_batch_id: '11111111-1111-4111-8111-111111111111', - }) - expect(mockTrackEvent).not.toHaveBeenCalledWith('marketplace_banner_click', expect.anything()) - expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith( - 'marketplace_banner_click', - expect.objectContaining({ - click_target: 'recommendation', - item_id: 'langgenius/dropbox', - item_type: 'plugin', - item_name: 'Dropbox', - }), - ) - }) - - it('tracks whole-slide blog and event links as frame clicks', async () => { - const user = userEvent.setup() - - render() - - await user.click(screen.getByRole('button', { name: 'Dify Updates' })) - await user.click( - screen.getByRole('link', { name: /^plugin\.marketplace\.home\.trendingReadMoreAbout/ }), - ) - - expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', { - banner_id: 'blog', - sort: 1, - page: 'plugins', - language: 'en', - style_type: 'blog', - }) - - await user.click(screen.getByRole('button', { name: 'Duck Duck Go' })) - await user.click(screen.getByRole('link', { name: 'DuckDuckGo plugin' })) - - expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', { - banner_id: 'event', - sort: 2, - page: 'plugins', - language: 'en', - style_type: 'event', - }) - }) - - it('does not render banner slides whose CMS link is not http(s) or relative', () => { - const unsafeBlog: PluginBanner = { - id: 'blog-unsafe', - style_type: 'blog', - title: 'Unsafe Updates', - sort: 0, - language: 'en', - content: { - blog_title: 'Unsafe launch', - subtitle: 'Should not be clickable', - description: 'Reject javascript hrefs from CMS payloads.', - link: 'javascript:alert(1)', - link_target_type: 'blog', - }, - } - - render() - - expect(screen.queryByRole('link')).not.toBeInTheDocument() - expect(screen.queryByRole('heading', { name: 'Unsafe launch' })).not.toBeInTheDocument() - }) - - it('dual-writes banner impressions to Amplitude and marketplace site tracking', () => { - vi.useFakeTimers() - const intersect = mockIntersectionObserver() - - try { - const blogBanner = banners[1] - if (!blogBanner) throw new Error('Expected a blog banner fixture') - - render() - - intersect() - act(() => { - vi.advanceTimersByTime(1000) - }) - - const properties = { - banner_id: 'blog', - sort: 1, - page: 'plugins', - language: 'en', - style_type: 'blog', - } - expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_impression', properties) - expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith( - 'marketplace_banner_impression', - properties, - ) - } finally { - vi.useRealTimers() - } - }) - it.each(['COMMUNITY', 'ENTERPRISE'] as const)( - 'links %s recommendations directly to the official site', - (edition) => { - const mixedBanner: PluginBanner = { - id: 'mixed', - style_type: 'recommend', - title: 'Trending', - sort: 0, - language: 'en', - content: { - theme_type: 'hottest', - 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, - }, - ], - }, - } - render( - , - { systemFeatures: { deployment_edition: edition } }, - ) - const links = [ - screen.getByRole('link', { name: 'Dropbox' }), - screen.getByRole('link', { name: 'Support Bot' }), - ] - expect(new URL(links[0]!.getAttribute('href')!).pathname).toBe('/plugin/langgenius/dropbox') - expect(new URL(links[1]!.getAttribute('href')!).pathname).toBe('/template/aisa-team/tpl-1') - for (const link of links) - expect(new URL(link.getAttribute('href')!).origin).toBe('https://marketplace.dify.ai') - for (const link of links) { - expect(link).toHaveAttribute('target', '_blank') - expect(new URL(link.getAttribute('href')!).searchParams.get('source')).toBe( - window.location.origin, - ) - } - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - }, - ) -}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.browser.spec.tsx new file mode 100644 index 00000000000..c78b6ab56f7 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.browser.spec.tsx @@ -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( + , + ) + 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( + , + ) + 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() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx deleted file mode 100644 index 4a604272152..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx +++ /dev/null @@ -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( - , - ) - - 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( - , - ) - - 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( - , - ) - - await user.type(screen.getByRole('searchbox'), 'legal') - - await waitFor(() => { - expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&languages=ja', { - scroll: false, - }) - }) - }) -}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx index abb6a4d8211..70e8a7ed9d9 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx @@ -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() 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() - return { ...original, marketplaceQuery: { + ...original.marketplaceQuery, searchAdvanced: { + ...original.marketplaceQuery.searchAdvanced, queryOptions: ( options: Parameters[0], ) => ({ ...original.marketplaceQuery.searchAdvanced.queryOptions(options), - queryFn: () => ({ data: { plugins: [], total: 0 } }), + queryFn: () => api.searchPlugins(options.input), }), }, templateSearch: { + ...original.marketplaceQuery.templateSearch, queryOptions: ( options: Parameters[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 ( Dify Marketplace @@ -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 ( +
{ + event.preventDefault() + setSubmitted(true) + }} + > + { + setSelection( + result.kind === 'plugin' ? result.plugin.name : result.template.template_name, + ) + }} + onValueChange={setValue} + placeholder="Search plugins or templates" + scope="all" + value={value} + /> + {selection} + {submitted ? 'yes' : 'no'} + + ) +} + +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( @@ -131,49 +182,38 @@ describe('Marketplace search autocomplete layout', () => { , ) - 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 | undefined const screen = await render( { /> , ) - - 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( + + + , + ) + 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( + + + + , + ) + 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( + + + , + ) + 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() + }) }) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx deleted file mode 100644 index 20c284fca61..00000000000 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx +++ /dev/null @@ -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() - return { - ...actual, - marketplaceQuery: { - ...actual.marketplaceQuery, - searchAdvanced: { - ...actual.marketplaceQuery.searchAdvanced, - queryOptions: ( - options: Parameters[0], - ) => ({ - ...actual.marketplaceQuery.searchAdvanced.queryOptions(options), - queryFn: () => mockPluginSearch(options.input), - }), - }, - templateSearch: { - ...actual.marketplaceQuery.templateSearch, - queryOptions: ( - options: Parameters[0], - ) => ({ - ...actual.marketplaceQuery.templateSearch.queryOptions(options), - queryFn: () => mockTemplateSearch(options.input), - }), - }, - }, - } -}) - -let queryClient: QueryClient - -function Wrapper({ children }: { children: ReactNode }) { - return {children} -} - -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( - , - { 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 ( - { - onValueChange(nextValue) - setValue(nextValue) - }} - placeholder="Search plugins" - scope="plugins" - value={value} - /> - ) - } - - render(, { 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( - , - { 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( - , - { 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 ( -
- - - ) - } - - const { container } = render(, { 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( - , - { 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 ( - - ) - } - - render(, { 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 ( - <> - - - - ) - } - - render(, { 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 ( - - ) - } - - render(, { 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() - }) -})