From bfdc39510b247c24a6b991af90cf0e7e5facad00 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:05:43 +0800 Subject: [PATCH] test: add unit and integration tests for share, develop, and goto-anything modules (#32246) Co-authored-by: CodingOnStar --- .../develop/api-key-management-flow.test.tsx | 192 ++++++++++++ .../develop/develop-page-flow.test.tsx | 241 +++++++++++++++ .../slash-command-modes.test.tsx | 8 +- .../text-generation-run-batch-flow.test.tsx | 121 ++++++++ .../text-generation-run-once-flow.test.tsx | 218 ++++++++++++++ .../{ => __tests__}/ApiServer.spec.tsx | 8 +- .../develop/{ => __tests__}/code.spec.tsx | 18 +- .../components/develop/__tests__/doc.spec.tsx | 206 +++++++++++++ .../develop/{ => __tests__}/index.spec.tsx | 22 +- .../develop/{ => __tests__}/md.spec.tsx | 2 +- .../develop/{ => __tests__}/tag.spec.tsx | 5 +- web/app/components/develop/index.tsx | 2 +- .../{ => __tests__}/input-copy.spec.tsx | 156 +++++----- .../secret-key-button.spec.tsx | 16 +- .../secret-key-generate.spec.tsx | 181 ++++++------ .../{ => __tests__}/secret-key-modal.spec.tsx | 248 ++++++++-------- .../{ => __tests__}/command-selector.spec.tsx | 8 +- .../{ => __tests__}/context.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 32 +- .../actions/__tests__/app.spec.ts | 71 +++++ .../actions/__tests__/index.spec.ts | 276 ++++++++++++++++++ .../actions/__tests__/knowledge.spec.ts | 93 ++++++ .../actions/__tests__/plugin.spec.ts | 72 +++++ .../commands/__tests__/command-bus.spec.ts | 68 +++++ .../__tests__/direct-commands.spec.ts | 212 ++++++++++++++ .../commands/__tests__/language.spec.ts | 89 ++++++ .../commands/__tests__/registry.spec.ts | 267 +++++++++++++++++ .../actions/commands/__tests__/theme.spec.ts | 73 +++++ .../actions/commands/__tests__/zen.spec.ts | 84 ++++++ .../{ => __tests__}/empty-state.spec.tsx | 19 +- .../{ => __tests__}/footer.spec.tsx | 18 +- .../components/__tests__/result-item.spec.tsx | 82 ++++++ .../components/__tests__/result-list.spec.tsx | 86 ++++++ .../{ => __tests__}/search-input.spec.tsx | 8 +- .../use-goto-anything-modal.spec.ts | 19 +- .../use-goto-anything-navigation.spec.ts | 18 +- .../use-goto-anything-results.spec.ts | 7 +- .../use-goto-anything-search.spec.ts | 11 +- .../share/{ => __tests__}/utils.spec.ts | 2 +- .../{ => __tests__}/info-modal.spec.tsx | 71 ++--- .../{ => __tests__}/menu-dropdown.spec.tsx | 48 ++- .../no-data/{ => __tests__}/index.spec.tsx | 2 +- .../run-batch/{ => __tests__}/index.spec.tsx | 6 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../csv-reader/{ => __tests__}/index.spec.tsx | 13 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../run-once/{ => __tests__}/index.spec.tsx | 7 +- web/eslint-suppressions.json | 10 - web/vitest.config.ts | 11 + 49 files changed, 2880 insertions(+), 555 deletions(-) create mode 100644 web/__tests__/develop/api-key-management-flow.test.tsx create mode 100644 web/__tests__/develop/develop-page-flow.test.tsx create mode 100644 web/__tests__/share/text-generation-run-batch-flow.test.tsx create mode 100644 web/__tests__/share/text-generation-run-once-flow.test.tsx rename web/app/components/develop/{ => __tests__}/ApiServer.spec.tsx (96%) rename web/app/components/develop/{ => __tests__}/code.spec.tsx (97%) create mode 100644 web/app/components/develop/__tests__/doc.spec.tsx rename web/app/components/develop/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/develop/{ => __tests__}/md.spec.tsx (99%) rename web/app/components/develop/{ => __tests__}/tag.spec.tsx (97%) rename web/app/components/develop/secret-key/{ => __tests__}/input-copy.spec.tsx (60%) rename web/app/components/develop/secret-key/{ => __tests__}/secret-key-button.spec.tsx (94%) rename web/app/components/develop/secret-key/{ => __tests__}/secret-key-generate.spec.tsx (53%) rename web/app/components/develop/secret-key/{ => __tests__}/secret-key-modal.spec.tsx (69%) rename web/app/components/goto-anything/{ => __tests__}/command-selector.spec.tsx (96%) rename web/app/components/goto-anything/{ => __tests__}/context.spec.tsx (96%) rename web/app/components/goto-anything/{ => __tests__}/index.spec.tsx (95%) create mode 100644 web/app/components/goto-anything/actions/__tests__/app.spec.ts create mode 100644 web/app/components/goto-anything/actions/__tests__/index.spec.ts create mode 100644 web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts create mode 100644 web/app/components/goto-anything/actions/__tests__/plugin.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/zen.spec.ts rename web/app/components/goto-anything/components/{ => __tests__}/empty-state.spec.tsx (88%) rename web/app/components/goto-anything/components/{ => __tests__}/footer.spec.tsx (92%) create mode 100644 web/app/components/goto-anything/components/__tests__/result-item.spec.tsx create mode 100644 web/app/components/goto-anything/components/__tests__/result-list.spec.tsx rename web/app/components/goto-anything/components/{ => __tests__}/search-input.spec.tsx (96%) rename web/app/components/goto-anything/hooks/{ => __tests__}/use-goto-anything-modal.spec.ts (91%) rename web/app/components/goto-anything/hooks/{ => __tests__}/use-goto-anything-navigation.spec.ts (95%) rename web/app/components/goto-anything/hooks/{ => __tests__}/use-goto-anything-results.spec.ts (97%) rename web/app/components/goto-anything/hooks/{ => __tests__}/use-goto-anything-search.spec.ts (96%) rename web/app/components/share/{ => __tests__}/utils.spec.ts (97%) rename web/app/components/share/text-generation/{ => __tests__}/info-modal.spec.tsx (73%) rename web/app/components/share/text-generation/{ => __tests__}/menu-dropdown.spec.tsx (80%) rename web/app/components/share/text-generation/no-data/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/share/text-generation/run-batch/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/share/text-generation/run-batch/csv-download/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/share/text-generation/run-batch/csv-reader/{ => __tests__}/index.spec.tsx (81%) rename web/app/components/share/text-generation/run-batch/res-download/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/share/text-generation/run-once/{ => __tests__}/index.spec.tsx (98%) diff --git a/web/__tests__/develop/api-key-management-flow.test.tsx b/web/__tests__/develop/api-key-management-flow.test.tsx new file mode 100644 index 0000000000..188b8e6304 --- /dev/null +++ b/web/__tests__/develop/api-key-management-flow.test.tsx @@ -0,0 +1,192 @@ +/** + * Integration test: API Key management flow + * + * Tests the cross-component interaction: + * ApiServer → SecretKeyButton → SecretKeyModal + * + * Renders real ApiServer, SecretKeyButton, and SecretKeyModal together + * with only service-layer mocks. Deep modal interactions (create/delete) + * are covered by unit tests in secret-key-modal.spec.tsx. + */ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ApiServer from '@/app/components/develop/ApiServer' + +// ---------- fake timers (HeadlessUI Dialog transitions) ---------- +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() +}) + +async function flushUI() { + await act(async () => { + vi.runAllTimers() + }) +} + +// ---------- mocks ---------- + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + currentWorkspace: { id: 'ws-1', name: 'Workspace' }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceEditor: true, + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: vi.fn((val: number) => `Time:${val}`), + formatDate: vi.fn((val: string) => `Date:${val}`), + }), +})) + +vi.mock('@/service/apps', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/service/datasets', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +const mockApiKeys = vi.fn().mockReturnValue({ data: [] }) +const mockIsLoading = vi.fn().mockReturnValue(false) + +vi.mock('@/service/use-apps', () => ({ + useAppApiKeys: () => ({ + data: mockApiKeys(), + isLoading: mockIsLoading(), + }), + useInvalidateAppApiKeys: () => vi.fn(), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiKeys: () => ({ data: null, isLoading: false }), + useInvalidateDatasetApiKeys: () => vi.fn(), +})) + +// ---------- tests ---------- + +describe('API Key management flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApiKeys.mockReturnValue({ data: [] }) + mockIsLoading.mockReturnValue(false) + }) + + it('ApiServer renders URL, status badge, and API Key button', () => { + render() + + expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument() + expect(screen.getByText('appApi.ok')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() + }) + + it('clicking API Key button opens SecretKeyModal with real modal content', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + render() + + // Click API Key button (rendered by SecretKeyButton) + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + // SecretKeyModal should render with real HeadlessUI Dialog + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument() + }) + }) + + it('modal shows loading state when API keys are being fetched', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + mockIsLoading.mockReturnValue(true) + + render() + + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + // Loading indicator should be present + expect(document.body.querySelector('[role="status"]')).toBeInTheDocument() + }) + + it('modal can be closed by clicking X icon', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + render() + + // Open modal + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + // Click X icon to close + const closeIcon = document.body.querySelector('svg.cursor-pointer') + expect(closeIcon).toBeInTheDocument() + + await act(async () => { + await user.click(closeIcon!) + }) + await flushUI() + + // Modal should close + await waitFor(() => { + expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument() + }) + }) + + it('renders correctly with different API URLs', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + const { rerender } = render( + , + ) + + expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument() + + // Open modal and verify it works with the same appId + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + // Close modal, update URL and re-verify + const xIcon = document.body.querySelector('svg.cursor-pointer') + await act(async () => { + await user.click(xIcon!) + }) + await flushUI() + + rerender( + , + ) + + expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument() + }) +}) diff --git a/web/__tests__/develop/develop-page-flow.test.tsx b/web/__tests__/develop/develop-page-flow.test.tsx new file mode 100644 index 0000000000..6b46ee025c --- /dev/null +++ b/web/__tests__/develop/develop-page-flow.test.tsx @@ -0,0 +1,241 @@ +/** + * Integration test: DevelopMain page flow + * + * Tests the full page lifecycle: + * Loading state → App loaded → Header (ApiServer) + Content (Doc) rendered + * + * Uses real DevelopMain, ApiServer, and Doc components with minimal mocks. + */ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DevelopMain from '@/app/components/develop' +import { AppModeEnum, Theme } from '@/types/app' + +// ---------- fake timers ---------- +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() +}) + +async function flushUI() { + await act(async () => { + vi.runAllTimers() + }) +} + +// ---------- store mock ---------- + +let storeAppDetail: unknown + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => { + return selector({ appDetail: storeAppDetail }) + }, +})) + +// ---------- Doc dependencies ---------- + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: Theme.light }), +})) + +vi.mock('@/i18n-config/language', () => ({ + LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'], +})) + +// ---------- SecretKeyModal dependencies ---------- + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + currentWorkspace: { id: 'ws-1', name: 'Workspace' }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceEditor: true, + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: vi.fn((val: number) => `Time:${val}`), + formatDate: vi.fn((val: string) => `Date:${val}`), + }), +})) + +vi.mock('@/service/apps', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/service/datasets', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/service/use-apps', () => ({ + useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }), + useInvalidateAppApiKeys: () => vi.fn(), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiKeys: () => ({ data: null, isLoading: false }), + useInvalidateDatasetApiKeys: () => vi.fn(), +})) + +// ---------- tests ---------- + +describe('DevelopMain page flow', () => { + beforeEach(() => { + vi.clearAllMocks() + storeAppDetail = undefined + }) + + it('should show loading indicator when appDetail is not available', () => { + storeAppDetail = undefined + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + // No content should be visible + expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument() + }) + + it('should render full page when appDetail is loaded', () => { + storeAppDetail = { + id: 'app-1', + name: 'Test App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.CHAT, + } + + render() + + // ApiServer section should be visible + expect(screen.getByText('appApi.apiServer')).toBeInTheDocument() + expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument() + expect(screen.getByText('appApi.ok')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() + + // Loading should NOT be visible + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('should render Doc component with correct app mode template', () => { + storeAppDetail = { + id: 'app-1', + name: 'Chat App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.CHAT, + } + + const { container } = render() + + // Doc renders an article element with prose classes + const article = container.querySelector('article') + expect(article).toBeInTheDocument() + expect(article?.className).toContain('prose') + }) + + it('should transition from loading to content when appDetail becomes available', () => { + // Start with no data + storeAppDetail = undefined + const { rerender } = render() + expect(screen.getByRole('status')).toBeInTheDocument() + + // Simulate store update + storeAppDetail = { + id: 'app-1', + name: 'My App', + api_base_url: 'https://api.example.com/v1', + mode: AppModeEnum.COMPLETION, + } + rerender() + + // Content should now be visible + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument() + }) + + it('should open API key modal from the page', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + storeAppDetail = { + id: 'app-1', + name: 'Test App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.WORKFLOW, + } + + render() + + // Click API Key button in the header + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + // SecretKeyModal should open + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + }) + + it('should render correctly for different app modes', () => { + const modes = [ + AppModeEnum.CHAT, + AppModeEnum.COMPLETION, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.WORKFLOW, + ] + + for (const mode of modes) { + storeAppDetail = { + id: 'app-1', + name: `${mode} App`, + api_base_url: 'https://api.test.com/v1', + mode, + } + + const { container, unmount } = render() + + // ApiServer should always be present + expect(screen.getByText('appApi.apiServer')).toBeInTheDocument() + + // Doc should render an article + expect(container.querySelector('article')).toBeInTheDocument() + + unmount() + } + }) + + it('should have correct page layout structure', () => { + storeAppDetail = { + id: 'app-1', + name: 'Test App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.CHAT, + } + + render() + + // Main container: flex column with full height + const mainDiv = screen.getByTestId('develop-main') + expect(mainDiv.className).toContain('flex') + expect(mainDiv.className).toContain('flex-col') + expect(mainDiv.className).toContain('h-full') + + // Header section with border + const header = mainDiv.querySelector('.border-b') + expect(header).toBeInTheDocument() + + // Content section with overflow scroll + const content = mainDiv.querySelector('.overflow-auto') + expect(content).toBeInTheDocument() + }) +}) diff --git a/web/__tests__/goto-anything/slash-command-modes.test.tsx b/web/__tests__/goto-anything/slash-command-modes.test.tsx index 9a2f7c1eac..38c965e383 100644 --- a/web/__tests__/goto-anything/slash-command-modes.test.tsx +++ b/web/__tests__/goto-anything/slash-command-modes.test.tsx @@ -49,14 +49,14 @@ describe('Slash Command Dual-Mode System', () => { beforeEach(() => { vi.clearAllMocks() - ;(slashCommandRegistry as any).findCommand = vi.fn((name: string) => { + vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => { if (name === 'docs') return mockDirectCommand if (name === 'theme') return mockSubmenuCommand - return null + return undefined }) - ;(slashCommandRegistry as any).getAllCommands = vi.fn(() => [ + vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ mockDirectCommand, mockSubmenuCommand, ]) @@ -147,7 +147,7 @@ describe('Slash Command Dual-Mode System', () => { unregister: vi.fn(), } - ;(slashCommandRegistry as any).findCommand = vi.fn(() => commandWithoutMode) + vi.mocked(slashCommandRegistry.findCommand).mockReturnValue(commandWithoutMode) const handler = slashCommandRegistry.findCommand('test') // Default behavior should be submenu when mode is not specified diff --git a/web/__tests__/share/text-generation-run-batch-flow.test.tsx b/web/__tests__/share/text-generation-run-batch-flow.test.tsx new file mode 100644 index 0000000000..a511527e16 --- /dev/null +++ b/web/__tests__/share/text-generation-run-batch-flow.test.tsx @@ -0,0 +1,121 @@ +/** + * Integration test: RunBatch CSV upload → Run flow + * + * Tests the complete user journey: + * Upload CSV → parse → enable run → click run → results finish → run again + */ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import RunBatch from '@/app/components/share/text-generation/run-batch' + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(() => 'pc'), + MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, +})) + +// Capture the onParsed callback from CSVReader to simulate CSV uploads +let capturedOnParsed: ((data: string[][]) => void) | undefined + +vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({ + default: ({ onParsed }: { onParsed: (data: string[][]) => void }) => { + capturedOnParsed = onParsed + return
CSV Reader
+ }, +})) + +vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({ + default: ({ vars }: { vars: { name: string }[] }) => ( +
+ {vars.map(v => v.name).join(', ')} +
+ ), +})) + +describe('RunBatch – integration flow', () => { + const vars = [{ name: 'prompt' }, { name: 'context' }] + + beforeEach(() => { + capturedOnParsed = undefined + vi.clearAllMocks() + }) + + it('full lifecycle: upload CSV → run → finish → run again', async () => { + const onSend = vi.fn() + + const { rerender } = render( + , + ) + + // Phase 1 – verify child components rendered + expect(screen.getByTestId('csv-reader')).toBeInTheDocument() + expect(screen.getByTestId('csv-download')).toHaveTextContent('prompt, context') + + // Run button should be disabled before CSV is parsed + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + expect(runButton).toBeDisabled() + + // Phase 2 – simulate CSV upload + const csvData = [ + ['prompt', 'context'], + ['Hello', 'World'], + ['Goodbye', 'Moon'], + ] + await act(async () => { + capturedOnParsed?.(csvData) + }) + + // Run button should now be enabled + await waitFor(() => { + expect(runButton).not.toBeDisabled() + }) + + // Phase 3 – click run + fireEvent.click(runButton) + expect(onSend).toHaveBeenCalledTimes(1) + expect(onSend).toHaveBeenCalledWith(csvData) + + // Phase 4 – simulate results still running + rerender() + expect(runButton).toBeDisabled() + + // Phase 5 – results finish → can run again + rerender() + await waitFor(() => { + expect(runButton).not.toBeDisabled() + }) + + onSend.mockClear() + fireEvent.click(runButton) + expect(onSend).toHaveBeenCalledTimes(1) + }) + + it('should remain disabled when CSV not uploaded even if all finished', () => { + const onSend = vi.fn() + render() + + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + expect(runButton).toBeDisabled() + + fireEvent.click(runButton) + expect(onSend).not.toHaveBeenCalled() + }) + + it('should show spinner icon when results are still running', async () => { + const onSend = vi.fn() + const { container } = render( + , + ) + + // Upload CSV first + await act(async () => { + capturedOnParsed?.([['data']]) + }) + + // Button disabled + spinning icon + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + expect(runButton).toBeDisabled() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass('animate-spin') + }) +}) diff --git a/web/__tests__/share/text-generation-run-once-flow.test.tsx b/web/__tests__/share/text-generation-run-once-flow.test.tsx new file mode 100644 index 0000000000..2a5d1b882c --- /dev/null +++ b/web/__tests__/share/text-generation-run-once-flow.test.tsx @@ -0,0 +1,218 @@ +/** + * Integration test: RunOnce form lifecycle + * + * Tests the complete user journey: + * Init defaults → edit fields → submit → running state → stop + */ +import type { InputValueTypes } from '@/app/components/share/text-generation/types' +import type { PromptConfig, PromptVariable } from '@/models/debug' +import type { SiteInfo } from '@/models/share' +import type { VisionSettings } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { useRef, useState } from 'react' +import RunOnce from '@/app/components/share/text-generation/run-once' +import { Resolution, TransferMethod } from '@/types/app' + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(() => 'pc'), + MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => ( +