diff --git a/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.browser.spec.tsx b/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.browser.spec.tsx new file mode 100644 index 00000000000..a838ba2b470 --- /dev/null +++ b/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.browser.spec.tsx @@ -0,0 +1,93 @@ +import { createContext } from 'react' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { humanInputV2FormClient } from '@/service/human-input-v2' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' +import FormContent from '../form' + +const RouteTokenContext = createContext('v2-token') + +vi.mock('@/next/navigation', async () => { + const { use } = await import('react') + return { useParams: () => ({ token: use(RouteTokenContext) }) } +}) + +vi.mock('@/service/human-input-v2', () => ({ + humanInputV2FormClient: { getForm: vi.fn(), requestAccess: vi.fn() }, +})) + +beforeEach(async () => { + await page.viewport(1000, 800) + onTestFinished(guardUnexpectedFetch()) + vi.mocked(humanInputV2FormClient.getForm).mockImplementation(async ({ params }) => ({ + form_content: `Review ${params.form_token}\n\n{{#$output.response#}}`, + inputs: [ + { + type: 'paragraph', + output_variable_name: 'response', + default: { type: 'constant', value: `Default for ${params.form_token}`, selector: [] }, + }, + ], + resolved_default_values: { response: `Default for ${params.form_token}` }, + user_actions: [{ id: 'approve', title: 'Approve', button_style: 'primary' }], + expiration_time: 1_900_000_000, + })) + vi.mocked(humanInputV2FormClient.requestAccess).mockImplementation(async ({ params }) => ({ + challenge_token: `challenge-${params.form_token}`, + expires_in_seconds: 300, + resend_after_seconds: 30, + })) + onTestFinished(() => { + vi.restoreAllMocks() + }) +}) + +it('loads the v2 form for the route token', async () => { + const { wrapper: Wrapper } = createConsoleQueryWrapper() + await render( + + + + + , + ) + + await expect + .element(page.getByRole('textbox', { name: 'Review v2-token' })) + .toHaveValue('Default for v2-token') + await expect + .element(page.getByRole('textbox', { name: 'share.humanInputV2.otpLabel' })) + .toBeVisible() + await expect.element(page.getByRole('button', { name: 'Approve' })).toBeEnabled() +}) + +it('replaces an edited form and verification proof when the route token changes', async () => { + const { wrapper: Wrapper } = createConsoleQueryWrapper() + const view = await render( + + + + + , + ) + await page.getByRole('textbox', { name: 'Review v2-token' }).fill('Previous draft') + const otp = page.getByRole('textbox', { name: 'share.humanInputV2.otpLabel' }) + await otp.fill('123456') + + await view.rerender( + + + + + , + ) + + await expect + .element(page.getByRole('textbox', { name: 'Review next-token' })) + .toHaveValue('Default for next-token') + await expect + .element(page.getByRole('textbox', { name: 'Review v2-token' })) + .not.toBeInTheDocument() + await expect.element(otp).toHaveValue('') +}) diff --git a/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx deleted file mode 100644 index cb21a2244af..00000000000 --- a/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { render, screen } from '@testing-library/react' -import FormContent from '../form' - -const mockRoute = vi.hoisted(() => ({ token: 'v2-token' })) -const mockHumanInputV2Form = vi.hoisted(() => - vi.fn(({ token }: { token: string }) =>
v2-form:{token}
), -) -const mockLegacyHook = vi.hoisted(() => vi.fn()) - -vi.mock('@/next/navigation', () => ({ - useParams: () => ({ token: mockRoute.token }), -})) - -vi.mock('@/features/human-input/public/form', () => ({ - default: (props: { token: string }) => mockHumanInputV2Form(props), -})) - -vi.mock('@/service/use-share', () => ({ - useGetHumanInputForm: mockLegacyHook, -})) - -vi.mock('@/hooks/use-document-title', () => ({ - default: vi.fn(), -})) - -describe('/form-v2/[token] route', () => { - beforeEach(() => { - vi.clearAllMocks() - mockRoute.token = 'v2-token' - }) - - it('passes the v2 route token to the isolated feature and never invokes the legacy form hook', () => { - render() - - expect(screen.getByText('v2-form:v2-token')).toBeInTheDocument() - expect(mockHumanInputV2Form).toHaveBeenCalledWith({ token: 'v2-token' }) - expect(mockLegacyHook).not.toHaveBeenCalled() - }) - - it('keys the feature session by the current route token', () => { - const { unmount } = render() - unmount() - mockRoute.token = 'next-token' - render() - - expect(screen.getByText('v2-form:next-token')).toBeInTheDocument() - expect(mockHumanInputV2Form).toHaveBeenLastCalledWith({ token: 'next-token' }) - }) -}) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.browser.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.browser.spec.tsx new file mode 100644 index 00000000000..dbdf2951f87 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.browser.spec.tsx @@ -0,0 +1,134 @@ +import type { + CredentialSlot, + WorkflowPath, + WorkflowReference, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { PluginCategory } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { page } from 'vite-plus/test/browser' +import { render as renderBrowser } from 'vitest-browser-react' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' +import { CredentialField } from '../credential-field' + +async function renderWithConsoleQueryBrowser(ui: React.ReactElement) { + const { wrapper: Wrapper } = createConsoleQueryWrapper() + return renderBrowser({ui}) +} + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock({ + 'deployments.studio.precheck.from': 'From', + 'deployments.studio.precheck.nodeCount': '{{count}} nodes', + }) +}) + +const credentialSlot: CredentialSlot = { + candidates: [ + { + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + credential_id: 'credential-1', + display_name: 'Deployment key', + from_enterprise: true, + provider_id: 'langgenius/deepseek', + }, + ], + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + provider_id: 'langgenius/deepseek', +} + +function workflowReference(name: string, suffix: string): WorkflowReference { + return { + app_id: `app-${suffix}`, + icon: '🤖', + icon_background: '#FFEAD5', + icon_type: 'emoji', + name, + workflow_id: `workflow-${suffix}`, + } +} + +function workflowPath(...workflows: WorkflowReference[]): WorkflowPath { + return { workflows } +} + +describe('CredentialField', () => { + it('opens guidance when no credentials are available', async () => { + await renderWithConsoleQueryBrowser( + , + ) + + const trigger = page.getByRole('combobox', { name: 'Deepseek' }) + await expect.element(trigger).toBeEnabled() + + await trigger.click() + + const listbox = page.getByRole('listbox') + await expect + .element(listbox.getByText('deployments.studio.noCredentialsYet')) + .toBeInTheDocument() + await expect + .element(listbox.getByText('deployments.studio.noCredentialsYetDescription')) + .toBeInTheDocument() + await expect.element(listbox.getByRole('option')).not.toBeInTheDocument() + }) + + it('shows the only source app name and opens its source preview', async () => { + const root = workflowReference('Deployed app', 'root') + const source = workflowReference('Order fulfillment', 'order') + await renderWithConsoleQueryBrowser( + , + ) + + const sourceButton = page.getByRole('button', { + name: 'Deepseek: From Order fulfillment', + }) + await sourceButton.hover() + + const preview = page.getByRole('dialog', { name: 'Deepseek' }) + await expect + .element(preview.getByRole('link', { name: /Deployed app.*Order fulfillment/ })) + .toHaveAttribute('href', '/app/app-order/workflow') + }) + + it('shows only a count when several source apps use the credential', async () => { + await renderWithConsoleQueryBrowser( + , + ) + + const sourceButton = page.getByRole('button', { name: 'Deepseek: From 2 nodes' }) + await sourceButton.click() + + const preview = page.getByRole('dialog', { name: 'Deepseek' }) + await expect.element(preview).toBeVisible() + await expect.poll(() => preview.getByRole('link').elements().length).toBe(2) + }) +}) + +beforeEach(async () => { + await page.viewport(1000, 800) + onTestFinished(guardUnexpectedFetch()) +}) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.spec.tsx deleted file mode 100644 index f8e2c563b05..00000000000 --- a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.spec.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import type { - CredentialSlot, - WorkflowPath, - WorkflowReference, -} from '@dify/contracts/enterprise-app-deploy/types.gen' -import { PluginCategory } from '@dify/contracts/enterprise-app-deploy/types.gen' -import { screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { API_PREFIX } from '@/config' -import { renderWithConsoleQuery } from '@/test/console/query-data' -import { CredentialField } from '../credential-field' - -vi.mock('react-i18next', async () => { - const { createReactI18nextMock } = await import('@/test/i18n-mock') - return createReactI18nextMock({ - 'deployments.studio.precheck.from': 'From', - 'deployments.studio.precheck.nodeCount': '{{count}} nodes', - }) -}) - -const themeState = vi.hoisted(() => ({ - theme: 'light', -})) - -vi.mock('@/hooks/use-theme', () => ({ - default: () => ({ theme: themeState.theme }), -})) - -const credentialSlot: CredentialSlot = { - candidates: [ - { - category: PluginCategory.PLUGIN_CATEGORY_MODEL, - credential_id: 'credential-1', - display_name: 'Deployment key', - from_enterprise: true, - provider_id: 'langgenius/deepseek', - }, - ], - category: PluginCategory.PLUGIN_CATEGORY_MODEL, - icon: 'deepseek-light.svg', - icon_dark: 'deepseek-dark.svg', - provider_id: 'langgenius/deepseek', -} - -function workflowReference(name: string, suffix: string): WorkflowReference { - return { - app_id: `app-${suffix}`, - icon: '🤖', - icon_background: '#FFEAD5', - icon_type: 'emoji', - name, - workflow_id: `workflow-${suffix}`, - } -} - -function workflowPath(...workflows: WorkflowReference[]): WorkflowPath { - return { workflows } -} - -function renderCredentialField() { - return renderWithConsoleQuery( - , - ) -} - -function getDecorativeIcon(container: HTMLElement) { - const icon = container.querySelector('img') - if (!icon) throw new Error('Expected the credential provider icon.') - - return icon -} - -describe('CredentialField', () => { - beforeEach(() => { - themeState.theme = 'light' - }) - - it('resolves the light credential provider icon through the current workspace', () => { - const { container } = renderCredentialField() - - expect(getDecorativeIcon(container)).toHaveAttribute( - 'src', - `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-1&filename=deepseek-light.svg`, - ) - }) - - it('resolves the dark credential provider icon in dark mode', () => { - themeState.theme = 'dark' - const { container } = renderCredentialField() - - expect(getDecorativeIcon(container)).toHaveAttribute( - 'src', - `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-1&filename=deepseek-dark.svg`, - ) - }) - - it('opens guidance when no credentials are available', async () => { - const user = userEvent.setup() - renderWithConsoleQuery( - , - ) - - const trigger = screen.getByRole('combobox', { name: 'Deepseek' }) - expect(trigger).toBeEnabled() - - await user.click(trigger) - - const listbox = await screen.findByRole('listbox') - expect(within(listbox).getByText('deployments.studio.noCredentialsYet')).toBeInTheDocument() - expect( - within(listbox).getByText('deployments.studio.noCredentialsYetDescription'), - ).toBeInTheDocument() - expect(within(listbox).queryByRole('option')).not.toBeInTheDocument() - }) - - it('shows the only source app name and opens its source preview', async () => { - const user = userEvent.setup() - const root = workflowReference('Deployed app', 'root') - const source = workflowReference('Order fulfillment', 'order') - renderWithConsoleQuery( - , - ) - - const sourceButton = screen.getByRole('button', { - name: 'Deepseek: From Order fulfillment', - }) - await user.hover(sourceButton) - - const preview = await screen.findByRole('dialog', { name: 'Deepseek' }) - expect( - within(preview).getByRole('link', { name: /Deployed app.*Order fulfillment/ }), - ).toHaveAttribute('href', '/app/app-order/workflow') - }) - - it('shows only a count when several source apps use the credential', async () => { - const user = userEvent.setup() - renderWithConsoleQuery( - , - ) - - const sourceButton = screen.getByRole('button', { name: 'Deepseek: From 2 nodes' }) - expect(sourceButton).not.toHaveTextContent('Order fulfillment') - await user.click(sourceButton) - - const preview = await screen.findByRole('dialog', { name: 'Deepseek' }) - expect(within(preview).getAllByRole('link')).toHaveLength(2) - }) -}) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.browser.spec.tsx similarity index 68% rename from web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.spec.tsx rename to web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.browser.spec.tsx index b0c45aab3d3..806866cf6b5 100644 --- a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.spec.tsx +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.browser.spec.tsx @@ -1,5 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, waitFor, within } from '@testing-library/react' +import { useState } from 'react' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' import { DeploymentConfiguration } from '../index' const APP_ID = 'app-1' @@ -18,25 +20,44 @@ function createQueryClient() { }) } -function renderConfiguration(queryClient: QueryClient) { +function ConfigurationSession() { + const [open, setOpen] = useState(true) + return open ? ( + setOpen(false)} + /> + ) : ( + + ) +} + +async function renderConfiguration() { + const queryClient = createQueryClient() return render( - + , ) } +beforeEach(async () => { + await page.viewport(1000, 800) + onTestFinished(() => { + vi.restoreAllMocks() + }) +}) + describe('DeploymentConfiguration query freshness', () => { it('fetches precheck and deployment options again whenever the form is reopened', async () => { const requestCounts = { @@ -75,25 +96,19 @@ describe('DeploymentConfiguration query freshness', () => { throw new Error(`Unexpected request: ${request.method} ${request.url}`) }) - const queryClient = createQueryClient() - const firstRender = renderConfiguration(queryClient) - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'common.appMenus.deploy' })).toBeEnabled() - }) + await renderConfiguration() + const deploy = page.getByRole('button', { name: 'common.appMenus.deploy' }) + await expect.element(deploy).toBeEnabled() expect(requestCounts).toEqual({ deploymentOptions: 1, precheck: 1 }) - firstRender.unmount() - await waitFor(() => { - expect(queryClient.getQueryCache().getAll()).toHaveLength(0) - }) + await page.getByRole('button', { name: 'common.operation.cancel' }).click() + await expect + .element(page.getByRole('heading', { name: 'deployments.studio.deployConfiguration' })) + .not.toBeInTheDocument() + await page.getByRole('button', { name: 'Configure deployment' }).click() - renderConfiguration(queryClient) - - await waitFor(() => { - expect(requestCounts).toEqual({ deploymentOptions: 2, precheck: 2 }) - expect(screen.getByRole('button', { name: 'common.appMenus.deploy' })).toBeEnabled() - }) + await expect.element(deploy).toBeEnabled() + await expect.poll(() => requestCounts).toEqual({ deploymentOptions: 2, precheck: 2 }) }) it('shows the backend message when precheck fails', async () => { @@ -121,10 +136,11 @@ describe('DeploymentConfiguration query freshness', () => { throw new Error(`Unexpected request: ${request.method} ${request.url}`) }) - const view = renderConfiguration(createQueryClient()) + await renderConfiguration() - const alert = await within(view.container).findByRole('alert') - expect(alert).toHaveTextContent('workflow tool dependencies form a cycle') + await expect + .element(page.getByRole('alert').getByText('workflow tool dependencies form a cycle')) + .toBeVisible() }) it('shows the backend message when deployment options fail', async () => { @@ -163,9 +179,10 @@ describe('DeploymentConfiguration query freshness', () => { throw new Error(`Unexpected request: ${request.method} ${request.url}`) }) - const view = renderConfiguration(createQueryClient()) + await renderConfiguration() - const alert = await within(view.container).findByRole('alert') - expect(alert).toHaveTextContent('workflow deployment options are invalid') + await expect + .element(page.getByRole('alert').getByText('workflow deployment options are invalid')) + .toBeVisible() }) }) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.browser.spec.tsx similarity index 60% rename from web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.spec.tsx rename to web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.browser.spec.tsx index 62d9f0e3929..486f13bc0fd 100644 --- a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.spec.tsx +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.browser.spec.tsx @@ -3,8 +3,10 @@ import type { WorkflowAsToolDependency, WorkflowReference, } from '@dify/contracts/enterprise-app-deploy/types.gen' -import { render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import { page } from 'vite-plus/test/browser' +import { render as renderBrowser } from 'vitest-browser-react' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' import { DeploymentPrecheckAlert } from '../deployment-precheck-alert' vi.mock('react-i18next', async () => { @@ -19,9 +21,10 @@ vi.mock('react-i18next', async () => { }) }) -vi.mock('../use-provider-icon', () => ({ - useGetProviderIcon: () => () => undefined, -})) +async function render(ui: React.ReactElement) { + const { wrapper: Wrapper } = createConsoleQueryWrapper() + return renderBrowser({ui}) +} function workflowReference(name: string, suffix: string): WorkflowReference { return { @@ -50,19 +53,18 @@ function unsupportedNode(id: string, owner: Partial = {}): Unsu } describe('DeploymentPrecheckAlert', () => { - it('does not show a source for an unsupported node from the deployed app', () => { - render() + it('does not show a source for an unsupported node from the deployed app', async () => { + await render() - expect(screen.getByText('Slack')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /From/ })).not.toBeInTheDocument() + await expect.element(page.getByText('Slack')).toBeInTheDocument() + await expect.element(page.getByRole('button', { name: /From/ })).not.toBeInTheDocument() }) it('shows the leaf subworkflow name and its complete dependency path', async () => { - const user = userEvent.setup() const root = workflowReference('Deployed app', 'root') const translation = workflowReference('Translation', 'translation') const source = workflowReference('Order fulfillment', 'order') - render( + await render( { />, ) - await user.hover(screen.getByRole('button', { name: 'Slack: From Order fulfillment' })) + await page.getByRole('button', { name: 'Slack: From Order fulfillment' }).hover() - const preview = await screen.findByRole('dialog', { name: 'Slack' }) - const sourceLink = within(preview).getByRole('link', { + const preview = page.getByRole('dialog', { name: 'Slack' }) + const sourceLink = preview.getByRole('link', { name: /Deployed app.*Translation.*Order fulfillment/, }) - expect(sourceLink).toHaveAttribute('href', '/app/app-order/workflow') - expect(sourceLink).toHaveAttribute('target', '_blank') + await expect.element(sourceLink).toHaveAttribute('href', '/app/app-order/workflow') + await expect.element(sourceLink).toHaveAttribute('target', '_blank') }) it('includes the deployed app when matching nodes also come from workflow-as-tool paths', async () => { - const user = userEvent.setup() const root = workflowReference('Deployed app', 'root') const orderSource = workflowReference('Order fulfillment', 'order') const auditSource = workflowReference('Audit workflow', 'audit') const sharedSource = workflowReference('Shared workflow', 'shared') const orderPath = [root, orderSource, sharedSource] const auditPath = [root, auditSource, sharedSource] - render( + await render( { />, ) - expect(screen.getAllByText('Slack')).toHaveLength(1) + await expect.element(page.getByText('Slack', { exact: true })).toBeVisible() - await user.click(screen.getByRole('button', { name: 'Slack: From 3 nodes' })) + await page.getByRole('button', { name: 'Slack: From 3 nodes' }).click() - const preview = await screen.findByRole('dialog', { name: 'Slack' }) - expect(within(preview).getAllByRole('link')).toHaveLength(3) - expect(within(preview).getByRole('link', { name: 'Deployed app' })).toHaveAttribute( - 'href', - '/app/app-root/workflow', - ) - expect( - within(preview).getByRole('link', { - name: /Deployed app.*Order fulfillment.*Shared workflow/, - }), - ).toBeInTheDocument() - expect( - within(preview).getByRole('link', { - name: /Deployed app.*Audit workflow.*Shared workflow/, - }), - ).toBeInTheDocument() + const preview = page.getByRole('dialog', { name: 'Slack' }) + await expect.element(preview).toBeVisible() + await expect.poll(() => preview.getByRole('link').elements().length).toBe(3) + await expect + .element(preview.getByRole('link', { name: 'Deployed app' })) + .toHaveAttribute('href', '/app/app-root/workflow') + await expect + .element( + preview.getByRole('link', { + name: /Deployed app.*Order fulfillment.*Shared workflow/, + }), + ) + .toBeInTheDocument() + await expect + .element( + preview.getByRole('link', { + name: /Deployed app.*Audit workflow.*Shared workflow/, + }), + ) + .toBeInTheDocument() }) }) + +beforeEach(async () => { + await page.viewport(1000, 800) + onTestFinished(guardUnexpectedFetch()) +}) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.browser.spec.tsx similarity index 61% rename from web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.spec.tsx rename to web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.browser.spec.tsx index a6cf416710d..67fd8237c90 100644 --- a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.spec.tsx +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.browser.spec.tsx @@ -1,18 +1,19 @@ import type { EnvironmentVariableSlot } from '@dify/contracts/enterprise-app-deploy/types.gen' import { EnvVarValueSource, EnvVarValueType } from '@dify/contracts/enterprise-app-deploy/types.gen' -import { screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { page, userEvent } from 'vite-plus/test/browser' +import { render as renderBrowser } from 'vitest-browser-react' import { consoleQuery } from '@/service/console' import { createCatalogModelFixture, createModelCatalogFixture, seedModelProviderQueries, } from '@/test/console/model-provider' -import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' -import { createNuqsTestRenderer } from '@/test/nuqs-testing' +import { createConsoleQueryClient, createConsoleQueryWrapper } from '@/test/console/query-data' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' import { EnvironmentVariableField } from '../environment-variable-field' -const render = createNuqsTestRenderer((ui) => { +async function render(ui: React.ReactElement) { const queryClient = createConsoleQueryClient() seedModelProviderQueries(queryClient, [ createModelCatalogFixture({ @@ -53,8 +54,13 @@ const render = createNuqsTestRenderer((ui) => { }, }, ) - return renderWithConsoleQuery(ui, { queryClient }) -}) + const { wrapper: Wrapper } = createConsoleQueryWrapper({ queryClient }) + return renderBrowser( + + {ui} + , + ) +} const llmSlot: EnvironmentVariableSlot = { configured_value: { @@ -76,9 +82,8 @@ describe('EnvironmentVariableField', () => { await import('@/app/components/workflow/llm-environment-variable-value-field') }) it('selects a compatible custom LLM and keeps its parameters structured', async () => { - const user = userEvent.setup() const onChange = vi.fn() - render( + await render( { />, ) - const modelSelector = await screen.findByRole('button', { name: /^Chat model/ }) - expect(modelSelector).toBeDisabled() - expect(screen.getByText('workflow.blocks.llm')).toBeInTheDocument() + const modelSelector = page.getByRole('button', { name: /^Chat model/ }) + await expect.element(modelSelector).toBeDisabled() + await expect.element(page.getByText('workflow.blocks.llm')).toBeInTheDocument() - await user.click(screen.getByRole('combobox', { name: /MODEL/ })) - await user.click( - await screen.findByRole('option', { + await page.getByRole('combobox', { name: /MODEL/ }).click() + await page + .getByRole('option', { name: 'deployments.deployDrawer.envVarSource.literal', - }), - ) + }) + .click() - await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })) - const picker = within( - await screen.findByRole('dialog', { name: 'plugin.detailPanel.configureModel' }), - ) - expect(picker.queryByRole('button', { name: /^Completion model/ })).not.toBeInTheDocument() - await user.click(picker.getByRole('button', { name: /^Chat model v2/ })) - await user.click(screen.getByRole('button', { name: /modelProvider.modelSettings/ })) - await user.click(screen.getByRole('switch', { name: 'Temperature' })) - const temperature = screen.getByRole('spinbutton', { name: 'Temperature' }) - await user.clear(temperature) - await user.type(temperature, '0.4') - await user.tab() + await page.getByRole('button', { name: 'plugin.detailPanel.configureModel' }).click() + const picker = page.getByRole('dialog', { name: 'plugin.detailPanel.configureModel' }) + await expect + .element(picker.getByRole('button', { name: /^Completion model/ })) + .not.toBeInTheDocument() + await picker.getByRole('button', { name: /^Chat model v2/ }).click() + await page.getByRole('button', { name: /modelProvider.modelSettings/ }).click() + await page.getByRole('switch', { name: 'Temperature' }).click() + const temperature = page.getByRole('spinbutton', { name: 'Temperature' }) + await temperature.clear() + await userEvent.type(temperature, '0.4') + await userEvent.tab() expect(onChange).toHaveBeenLastCalledWith('workflow-1', 'MODEL', { customValue: { @@ -123,7 +128,7 @@ describe('EnvironmentVariableField', () => { }) it('shows the last deployed LLM as a read-only source', async () => { - render( + await render( { />, ) - const modelSelector = await screen.findByRole('button', { name: /^Chat model/ }) - expect(modelSelector).toBeDisabled() - expect(modelSelector).toHaveTextContent('Chat model v2') - expect(screen.getByRole('combobox', { name: /MODEL/ })).toHaveTextContent( - 'deployments.deployDrawer.envVarSource.lastDeployment', - ) + const modelSelector = page.getByRole('button', { name: /^Chat model/ }) + await expect.element(modelSelector).toBeDisabled() + await expect.element(modelSelector.getByText('Chat model v2', { exact: true })).toBeVisible() + await expect + .element(page.getByRole('combobox', { name: /MODEL/ })) + .toHaveTextContent('deployments.deployDrawer.envVarSource.lastDeployment') }) }) + +beforeEach(async () => { + await page.viewport(1000, 800) + onTestFinished(guardUnexpectedFetch()) +}) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.browser.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.browser.spec.tsx new file mode 100644 index 00000000000..42a352afa59 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.browser.spec.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { page, userEvent } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' +import KnowledgeSpaceList from '../knowledge-space-list' + +const spaces = [ + { control_space_id: 'space-1', name: 'Handbook' }, + { control_space_id: 'space-2', name: 'Policies' }, +] + +function Selection({ initialSpaces = spaces }: { initialSpaces?: typeof spaces }) { + const [list, setList] = useState(initialSpaces) + return +} + +beforeEach(async () => { + await page.viewport(1000, 800) + onTestFinished(guardUnexpectedFetch()) +}) + +it('reveals the focused removal action and removes the space using the keyboard', async () => { + await render( + <> + + + + , + ) + await page.getByRole('button', { name: 'before' }).click() + await userEvent.tab() + const remove = page.getByRole('button', { name: 'common.operation.remove' }) + await expect.element(remove).toHaveFocus() + // toBeVisible alone ignores ancestor opacity; this proves the focused action is painted. + await expect.element(remove).toBeInViewport() + await expect.poll(() => remove.element().checkVisibility({ checkOpacity: true })).toBe(true) + await userEvent.keyboard('{Enter}') + await expect.element(page.getByText('Handbook')).not.toBeInTheDocument() + await expect + .element(page.getByText('workflow.nodes.knowledgeRetrievalV2.knowledgeTip')) + .toBeVisible() +}) + +it('removes only the chosen space through its revealed action', async () => { + await render() + await page.getByText('Policies').hover() + await page.getByRole('button', { name: 'common.operation.remove' }).nth(1).click() + await expect.element(page.getByText('Policies')).not.toBeInTheDocument() + await expect.element(page.getByText('Handbook')).toBeVisible() +}) + +it('hides removal actions when readonly', async () => { + await render() + await expect.element(page.getByText('Handbook')).toBeVisible() + await expect + .element(page.getByRole('button', { name: 'common.operation.remove' })) + .not.toBeInTheDocument() +}) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.spec.tsx deleted file mode 100644 index 27c7fc2a27a..00000000000 --- a/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.spec.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import KnowledgeSpaceList from '../knowledge-space-list' - -const spaces = [ - { control_space_id: 'space-1', name: 'Handbook' }, - { control_space_id: 'space-2', name: 'Policies' }, -] - -describe('KnowledgeSpaceList', () => { - it('keeps the removal action reachable and operable by keyboard', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render( - <> - - - - , - ) - - await user.click(screen.getByRole('button', { name: 'before' })) - await user.tab() - - const removeButton = screen.getByRole('button', { name: 'common.operation.remove' }) - expect(removeButton).toHaveFocus() - // The action stays rendered (revealed with opacity) instead of display:none until hover. - expect(removeButton.parentElement).not.toHaveClass('hidden') - expect(removeButton.parentElement).toHaveClass('focus-within:opacity-100') - - await user.keyboard('{Enter}') - expect(onChange).toHaveBeenCalledWith([]) - }) - - it('removes only the chosen space', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - - await user.click(screen.getAllByRole('button', { name: 'common.operation.remove' })[1]!) - - expect(onChange).toHaveBeenCalledWith([spaces[0]]) - }) - - it('hides the removal action when readonly', () => { - render() - - expect( - screen.queryByRole('button', { name: 'common.operation.remove' }), - ).not.toBeInTheDocument() - }) -}) diff --git a/web/features/human-input/form/__tests__/loaded-form-content.browser.spec.tsx b/web/features/human-input/form/__tests__/loaded-form-content.browser.spec.tsx new file mode 100644 index 00000000000..146f211ac1e --- /dev/null +++ b/web/features/human-input/form/__tests__/loaded-form-content.browser.spec.tsx @@ -0,0 +1,250 @@ +import type { HumanInputFormDefinition, LegacyHumanInputFormData } from '../types' +import { page, userEvent } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' +import { InputVarType } from '@/app/components/workflow/types' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' +import LoadedFormContent from '../loaded-form-content' +import { normalizeLegacyHumanInputForm } from '../normalize-legacy-definition' + +// Load the real code-split renderer before assertions start their interaction deadlines. +beforeAll(async () => { + await import('@/app/components/base/markdown/streamdown-wrapper') +}) + +describe('LoadedFormContent', () => { + const inputs: HumanInputFormDefinition['inputs'] = [ + { + type: InputVarType.paragraph, + output_variable_name: 'response', + default: { + type: 'constant', + value: 'initial response', + selector: [], + }, + }, + ] + const actions: HumanInputFormDefinition['actions'] = [ + { + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, + }, + ] + const legacyData: LegacyHumanInputFormData = { + site: { + site: { + title: 'Legacy app', + icon_type: 'emoji', + icon: 'L', + icon_background: '#fff', + icon_url: '', + }, + }, + form_content: '{{#$output.response#}}', + inputs, + resolved_default_values: {}, + user_actions: actions, + expiration_time: 1_900_000_000, + } + const v2Definition: HumanInputFormDefinition = { + formContent: '{{#$output.response#}}', + inputs, + resolvedDefaultValues: {}, + actions, + expirationTime: 1_900_000_000, + } + + it.each([ + ['legacy', normalizeLegacyHumanInputForm(legacyData)], + ['v2', v2Definition], + ] as const)( + 'renders and processes the version-neutral %s definition', + async (_version, definition) => { + const onSubmit = vi.fn() + + await render( + , + ) + const response = page.getByRole('textbox', { name: 'response' }) + await expect.element(response).toHaveValue('initial response') + await response.clear() + await response.fill('updated response') + await page.getByRole('button', { name: 'Approve' }).click() + + expect(onSubmit).toHaveBeenCalledWith({ response: 'updated response' }, 'approve') + await expect.element(page.getByText('share.chat.poweredBy')).toBeInTheDocument() + }, + ) + + it('blocks repeated submissions until the pending action resolves', async () => { + let finishSubmission: (() => void) | undefined + const submission = new Promise((resolve) => { + finishSubmission = resolve + }) + const onSubmit = vi.fn().mockReturnValue(submission) + + await render( + , + ) + const approve = page.getByRole('button', { name: 'Approve' }) + await approve.click() + + await expect.element(approve).toHaveAttribute('aria-disabled', 'true') + await expect.element(approve).toHaveFocus() + await userEvent.keyboard('{Enter}') + expect(onSubmit).toHaveBeenCalledTimes(1) + + finishSubmission?.() + await submission + await expect.element(approve).toBeEnabled() + }) + + it('requires nonblank text and a selected option before submitting their values', async () => { + const onSubmit = vi.fn() + const definition: HumanInputFormDefinition = { + ...v2Definition, + formContent: '{{#$output.response#}}\n\n{{#$output.reviewer#}}', + resolvedDefaultValues: { response: ' ' }, + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'response', + default: { type: 'constant', value: ' ', selector: [] }, + }, + { + type: InputVarType.select, + output_variable_name: 'reviewer', + option_source: { type: 'constant', selector: [], value: ['Alice', 'Bob'] }, + }, + ], + } + + await render( + , + ) + const approve = page.getByRole('button', { name: 'Approve' }) + await expect.element(approve).toBeEnabled() + await approve.click() + expect(onSubmit).not.toHaveBeenCalled() + await expect.element(page.getByText(/fieldRequired/).nth(1)).toBeVisible() + + const response = page.getByRole('textbox', { name: 'response' }) + await response.clear() + await response.fill('Ready for review') + await expect.element(approve).toBeEnabled() + + await page.getByRole('combobox', { name: 'reviewer' }).click() + await page.getByRole('option', { name: 'Bob' }).click() + await expect.element(approve).toBeEnabled() + await approve.click() + expect(onSubmit).toHaveBeenCalledWith( + { response: 'Ready for review', reviewer: 'Bob' }, + 'approve', + ) + }) + + it('retains entered values and exposes a safe error when workflow submission rejects', async () => { + const onSubmit = vi + .fn() + .mockRejectedValueOnce(new Error('internal transport details')) + .mockResolvedValue(undefined) + await render( + , + ) + const response = page.getByRole('textbox', { name: 'response' }) + await response.clear() + await response.fill('Keep this draft') + await page.getByRole('button', { name: 'Approve' }).click() + await expect.element(page.getByRole('alert')).toHaveTextContent('common.api.actionFailed') + await expect.element(page.getByText('internal transport details')).not.toBeInTheDocument() + await expect.element(response).toHaveValue('Keep this draft') + await page.getByRole('button', { name: 'Approve' }).click() + expect(onSubmit).toHaveBeenLastCalledWith({ response: 'Keep this draft' }, 'approve') + }) + + it('gives repeated placeholders distinct label associations while retaining a shared wire value', async () => { + const onSubmit = vi.fn() + await render( + , + ) + const first = page.getByRole('textbox', { name: 'First review' }) + const second = page.getByRole('textbox', { name: 'Second review' }) + await first.click() + await userEvent.tab() + await expect.element(second).toHaveFocus() + await second.clear() + await second.fill('One shared response') + await expect.element(first).toHaveValue('One shared response') + await page.getByRole('button', { name: 'Approve' }).click() + expect(onSubmit).toHaveBeenCalledWith({ response: 'One shared response' }, 'approve') + }) + + it('preserves Markdown content alongside the editable fields', async () => { + const definition: HumanInputFormDefinition = { + ...v2Definition, + formContent: [ + '# Review request', + 'Read the **summary** and [reference](https://example.com/reference). Use `status`.', + '3. First review\n4. Final review', + '| Field | Value |\n| --- | --- |\n| Owner | Alice |', + '![Seasonal landscape](/logo/logo-site.png)', + '{{#$output.response#}}', + ].join('\n\n'), + } + + await render( + , + ) + + await expect + .element(page.getByRole('heading', { level: 1, name: 'Review request' })) + .toBeInTheDocument() + await expect + .element(page.getByRole('link', { name: 'reference' })) + .toHaveAttribute('href', 'https://example.com/reference') + await expect.element(page.getByText('summary')).toBeVisible() + await expect.element(page.getByText('status')).toBeVisible() + const list = page.getByRole('list') + await expect.element(list).toHaveAttribute('start', '3') + await expect.element(list.getByRole('listitem').nth(1)).toHaveTextContent('Final review') + await expect + .element(page.getByRole('table').getByRole('cell', { name: 'Alice' })) + .toBeInTheDocument() + await expect + .element(page.getByRole('img', { name: 'Seasonal landscape' })) + .toHaveAttribute('src', '/logo/logo-site.png') + await expect + .element(page.getByRole('textbox', { name: 'response' })) + .toHaveValue('initial response') + }) + + it('updates optional branding when the definition changes', async () => { + const view = await render( + , + ) + await expect.element(page.getByRole('heading', { name: 'Legacy app' })).toBeInTheDocument() + await view.rerender( + , + ) + await expect.element(page.getByRole('heading', { name: 'Legacy app' })).not.toBeInTheDocument() + await expect.element(page.getByRole('img', { name: 'Dify', exact: true })).toBeInTheDocument() + }) +}) + +beforeEach(async () => { + await page.viewport(1000, 800) + onTestFinished(guardUnexpectedFetch()) +}) diff --git a/web/features/human-input/form/__tests__/loaded-form-content.spec.tsx b/web/features/human-input/form/__tests__/loaded-form-content.spec.tsx deleted file mode 100644 index dfbb2000f56..00000000000 --- a/web/features/human-input/form/__tests__/loaded-form-content.spec.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import type { HumanInputFormDefinition, LegacyHumanInputFormData } from '../types' -import { act, render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' -import { InputVarType } from '@/app/components/workflow/types' -import LoadedFormContent from '../loaded-form-content' -import { normalizeLegacyHumanInputForm } from '../normalize-legacy-definition' - -// Load the real code-split renderer before assertions start their interaction deadlines. -beforeAll(async () => { - await import('@/app/components/base/markdown/streamdown-wrapper') -}) - -vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-time', () => ({ - default: ({ expirationTime }: { expirationTime: number }) => ( -
expiration:{expirationTime}
- ), -})) - -vi.mock('@/app/components/base/app-icon', () => ({ - default: () =>
application icon
, -})) - -vi.mock('@/app/components/base/logo/dify-logo', () => ({ - DifyLogo: () =>
dify logo
, -})) - -describe('LoadedFormContent', () => { - const inputs: HumanInputFormDefinition['inputs'] = [ - { - type: InputVarType.paragraph, - output_variable_name: 'response', - default: { - type: 'constant', - value: 'initial response', - selector: [], - }, - }, - ] - const actions: HumanInputFormDefinition['actions'] = [ - { - id: 'approve', - title: 'Approve', - button_style: UserActionButtonType.Primary, - }, - ] - const legacyData: LegacyHumanInputFormData = { - site: { - site: { - title: 'Legacy app', - icon_type: 'emoji', - icon: 'L', - icon_background: '#fff', - icon_url: '', - }, - }, - form_content: '{{#$output.response#}}', - inputs, - resolved_default_values: {}, - user_actions: actions, - expiration_time: 60, - } - const v2Definition: HumanInputFormDefinition = { - formContent: '{{#$output.response#}}', - inputs, - resolvedDefaultValues: {}, - actions, - expirationTime: 60, - } - - it.each([ - ['legacy', normalizeLegacyHumanInputForm(legacyData)], - ['v2', v2Definition], - ] as const)( - 'renders and processes the version-neutral %s definition', - async (_version, definition) => { - const user = userEvent.setup() - const onSubmit = vi.fn() - - render() - const response = screen.getByRole('textbox', { name: 'response' }) - expect(response).toHaveValue('initial response') - await user.clear(response) - await user.type(response, 'updated response') - await user.click(screen.getByRole('button', { name: 'Approve' })) - - expect(onSubmit).toHaveBeenCalledWith({ response: 'updated response' }, 'approve') - expect(screen.getByText('expiration:60000')).toBeInTheDocument() - expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument() - }, - ) - - it('blocks repeated submissions until the pending action resolves', async () => { - const user = userEvent.setup() - let finishSubmission: (() => void) | undefined - const submission = new Promise((resolve) => { - finishSubmission = resolve - }) - const onSubmit = vi.fn().mockReturnValue(submission) - - render() - const approve = screen.getByRole('button', { name: 'Approve' }) - await user.click(approve) - - expect(approve).toHaveAttribute('aria-disabled', 'true') - await user.click(approve) - expect(onSubmit).toHaveBeenCalledTimes(1) - - await act(async () => { - finishSubmission?.() - await submission - }) - expect(approve).toBeEnabled() - }) - - it('requires nonblank text and a selected option before submitting their values', async () => { - const user = userEvent.setup() - const onSubmit = vi.fn() - const definition: HumanInputFormDefinition = { - ...v2Definition, - formContent: '{{#$output.response#}}\n\n{{#$output.reviewer#}}', - resolvedDefaultValues: { response: ' ' }, - inputs: [ - { - type: InputVarType.paragraph, - output_variable_name: 'response', - default: { type: 'constant', value: ' ', selector: [] }, - }, - { - type: InputVarType.select, - output_variable_name: 'reviewer', - option_source: { type: 'constant', selector: [], value: ['Alice', 'Bob'] }, - }, - ], - } - - render() - const approve = screen.getByRole('button', { name: 'Approve' }) - expect(approve).toBeEnabled() - await user.click(approve) - expect(onSubmit).not.toHaveBeenCalled() - expect(screen.getAllByText(/fieldRequired/)).toHaveLength(2) - - const response = screen.getByRole('textbox', { name: 'response' }) - await user.clear(response) - await user.type(response, 'Ready for review') - expect(approve).toBeEnabled() - - await user.click(screen.getByRole('combobox', { name: 'reviewer' })) - await user.click(await screen.findByRole('option', { name: 'Bob' })) - expect(approve).toBeEnabled() - await user.click(approve) - expect(onSubmit).toHaveBeenCalledWith( - { response: 'Ready for review', reviewer: 'Bob' }, - 'approve', - ) - }) - - it('retains entered values and exposes a safe error when workflow submission rejects', async () => { - const user = userEvent.setup() - const onSubmit = vi - .fn() - .mockRejectedValueOnce(new Error('internal transport details')) - .mockResolvedValue(undefined) - render() - const response = screen.getByRole('textbox', { name: 'response' }) - await user.clear(response) - await user.type(response, 'Keep this draft') - await user.click(screen.getByRole('button', { name: 'Approve' })) - expect(await screen.findByRole('alert')).toHaveTextContent('common.api.actionFailed') - expect(screen.queryByText('internal transport details')).not.toBeInTheDocument() - expect(response).toHaveValue('Keep this draft') - await user.click(screen.getByRole('button', { name: 'Approve' })) - expect(onSubmit).toHaveBeenLastCalledWith({ response: 'Keep this draft' }, 'approve') - }) - - it('gives repeated placeholders distinct label associations while retaining a shared wire value', async () => { - const user = userEvent.setup() - const onSubmit = vi.fn() - render( - , - ) - const first = screen.getByRole('textbox', { name: 'First review' }) - const second = screen.getByRole('textbox', { name: 'Second review' }) - const firstLabelId = first.getAttribute('aria-labelledby')! - const secondLabelId = second.getAttribute('aria-labelledby')! - expect(firstLabelId).not.toBe(secondLabelId) - expect(document.querySelectorAll('[id]')).toHaveLength( - new Set([...document.querySelectorAll('[id]')].map((element) => element.id)).size, - ) - expect(screen.getByText('Second review', { selector: 'label' })).toHaveClass('sr-only') - await user.click(screen.getByText('Second review', { selector: 'label' })) - expect(second).toHaveFocus() - await user.clear(second) - await user.type(second, 'One shared response') - expect(first).toHaveValue('One shared response') - await user.click(screen.getByRole('button', { name: 'Approve' })) - expect(onSubmit).toHaveBeenCalledWith({ response: 'One shared response' }, 'approve') - }) - - it('preserves Markdown content alongside the editable fields', async () => { - const definition: HumanInputFormDefinition = { - ...v2Definition, - formContent: [ - '# Review request', - 'Read the **summary** and [reference](https://example.com/reference). Use `status`.', - '3. First review\n4. Final review', - '| Field | Value |\n| --- | --- |\n| Owner | Alice |', - '![Seasonal landscape](https://example.com/landscape.png)', - '{{#$output.response#}}', - ].join('\n\n'), - } - - render() - - expect( - await screen.findByRole('heading', { level: 1, name: 'Review request' }), - ).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'reference' })).toHaveAttribute( - 'href', - 'https://example.com/reference', - ) - expect(screen.getByText('summary')).toBeVisible() - expect(await screen.findByText('status')).toBeVisible() - const list = screen.getByRole('list') - expect(list).toHaveAttribute('start', '3') - expect(within(list).getAllByRole('listitem')).toHaveLength(2) - expect( - within(screen.getByRole('table')).getByRole('cell', { name: 'Alice' }), - ).toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Seasonal landscape' })).toHaveAttribute( - 'src', - 'https://example.com/landscape.png', - ) - expect(screen.getByRole('textbox', { name: 'response' })).toHaveValue('initial response') - }) - - it('renders optional branding without synthesizing it for v2', () => { - const { unmount } = render( - , - ) - expect(screen.getByText('Legacy app')).toBeInTheDocument() - expect(screen.getByText('application icon')).toBeInTheDocument() - unmount() - - render() - expect(screen.queryByText('Legacy app')).not.toBeInTheDocument() - expect(screen.queryByText('application icon')).not.toBeInTheDocument() - expect(screen.getByText('dify logo')).toBeInTheDocument() - }) -})