From c12f0d16bb20572906faf19e0eba85528756b110 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:47:13 +0800 Subject: [PATCH] chore(web): enhance frontend tests (#29869) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .github/workflows/web-tests.yml | 108 +++-- .../assistant-type-picker/index.spec.tsx | 204 +++++---- .../debug-with-single-model/index.spec.tsx | 399 +++++++++--------- .../billing/upgrade-btn/index.spec.tsx | 174 ++------ .../explore/installed-app/index.spec.tsx | 77 +--- web/jest.config.ts | 1 + web/jest.setup.ts | 16 + web/package.json | 1 + web/pnpm-lock.yaml | 3 + 9 files changed, 434 insertions(+), 549 deletions(-) diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index dd311701b5..8b871403cc 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -70,6 +70,13 @@ jobs: node <<'NODE' >> "$GITHUB_STEP_SUMMARY" const fs = require('fs'); const path = require('path'); + let libCoverage = null; + + try { + libCoverage = require('istanbul-lib-coverage'); + } catch (error) { + libCoverage = null; + } const summaryPath = path.join('coverage', 'coverage-summary.json'); const finalPath = path.join('coverage', 'coverage-final.json'); @@ -91,6 +98,54 @@ jobs: ? JSON.parse(fs.readFileSync(finalPath, 'utf8')) : null; + const getLineCoverageFromStatements = (statementMap, statementHits) => { + const lineHits = {}; + + if (!statementMap || !statementHits) { + return lineHits; + } + + Object.entries(statementMap).forEach(([key, statement]) => { + const line = statement?.start?.line; + if (!line) { + return; + } + const hits = statementHits[key] ?? 0; + const previous = lineHits[line]; + lineHits[line] = previous === undefined ? hits : Math.max(previous, hits); + }); + + return lineHits; + }; + + const getFileCoverage = (entry) => ( + libCoverage ? libCoverage.createFileCoverage(entry) : null + ); + + const getLineHits = (entry, fileCoverage) => { + const lineHits = entry.l ?? {}; + if (Object.keys(lineHits).length > 0) { + return lineHits; + } + if (fileCoverage) { + return fileCoverage.getLineCoverage(); + } + return getLineCoverageFromStatements(entry.statementMap ?? {}, entry.s ?? {}); + }; + + const getUncoveredLines = (entry, fileCoverage, lineHits) => { + if (lineHits && Object.keys(lineHits).length > 0) { + return Object.entries(lineHits) + .filter(([, count]) => count === 0) + .map(([line]) => Number(line)) + .sort((a, b) => a - b); + } + if (fileCoverage) { + return fileCoverage.getUncoveredLines(); + } + return []; + }; + const totals = { lines: { covered: 0, total: 0 }, statements: { covered: 0, total: 0 }, @@ -106,7 +161,7 @@ jobs: totals[key].covered = totalEntry[key].covered ?? 0; totals[key].total = totalEntry[key].total ?? 0; } - }); + }); Object.entries(summary) .filter(([file]) => file !== 'total') @@ -122,7 +177,8 @@ jobs: }); } else if (coverage) { Object.entries(coverage).forEach(([file, entry]) => { - const lineHits = entry.l ?? {}; + const fileCoverage = getFileCoverage(entry); + const lineHits = getLineHits(entry, fileCoverage); const statementHits = entry.s ?? {}; const branchHits = entry.b ?? {}; const functionHits = entry.f ?? {}; @@ -228,7 +284,8 @@ jobs: }; const tableRows = Object.entries(coverage) .map(([file, entry]) => { - const lineHits = entry.l ?? {}; + const fileCoverage = getFileCoverage(entry); + const lineHits = getLineHits(entry, fileCoverage); const statementHits = entry.s ?? {}; const branchHits = entry.b ?? {}; const functionHits = entry.f ?? {}; @@ -254,10 +311,7 @@ jobs: tableTotals.functions.total += functionTotal; tableTotals.functions.covered += functionCovered; - const uncoveredLines = Object.entries(lineHits) - .filter(([, count]) => count === 0) - .map(([line]) => Number(line)) - .sort((a, b) => a - b); + const uncoveredLines = getUncoveredLines(entry, fileCoverage, lineHits); const filePath = entry.path ?? file; const relativePath = path.isAbsolute(filePath) @@ -294,46 +348,20 @@ jobs: }; const rowsForOutput = [allFilesRow, ...tableRows]; - const columnWidths = Object.fromEntries( - columns.map(({ key, header }) => [key, header.length]), - ); - - rowsForOutput.forEach((row) => { - columns.forEach(({ key }) => { - const value = String(row[key] ?? ''); - columnWidths[key] = Math.max(columnWidths[key], value.length); - }); - }); - - const formatRow = (row) => columns - .map(({ key, align }) => { - const value = String(row[key] ?? ''); - const width = columnWidths[key]; - return align === 'right' ? value.padStart(width) : value.padEnd(width); - }) - .join(' | '); - - const headerRow = columns - .map(({ header, key, align }) => { - const width = columnWidths[key]; - return align === 'right' ? header.padStart(width) : header.padEnd(width); - }) - .join(' | '); - - const dividerRow = columns - .map(({ key }) => '-'.repeat(columnWidths[key])) - .join('|'); + const formatRow = (row) => `| ${columns + .map(({ key }) => String(row[key] ?? '')) + .join(' | ')} |`; + const headerRow = `| ${columns.map(({ header }) => header).join(' | ')} |`; + const dividerRow = `| ${columns + .map(({ align }) => (align === 'right' ? '---:' : ':---')) + .join(' | ')} |`; console.log(''); console.log('
Jest coverage table'); console.log(''); - console.log('```'); - console.log(dividerRow); console.log(headerRow); console.log(dividerRow); rowsForOutput.forEach((row) => console.log(formatRow(row))); - console.log(dividerRow); - console.log('```'); console.log('
'); } NODE diff --git a/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx b/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx index f935a203fe..cda24ea045 100644 --- a/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx +++ b/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx @@ -5,31 +5,6 @@ import AssistantTypePicker from './index' import type { AgentConfig } from '@/models/debug' import { AgentStrategy } from '@/types/app' -// Type definition for AgentSetting props -type AgentSettingProps = { - isChatModel: boolean - payload: AgentConfig - isFunctionCall: boolean - onCancel: () => void - onSave: (payload: AgentConfig) => void -} - -// Track mock calls for props validation -let mockAgentSettingProps: AgentSettingProps | null = null - -// Mock AgentSetting component (complex modal with external hooks) -jest.mock('../agent/agent-setting', () => { - return function MockAgentSetting(props: AgentSettingProps) { - mockAgentSettingProps = props - return ( -
- - -
- ) - } -}) - // Test utilities const defaultAgentConfig: AgentConfig = { enabled: true, @@ -62,7 +37,6 @@ const getOptionByDescription = (descriptionRegex: RegExp) => { describe('AssistantTypePicker', () => { beforeEach(() => { jest.clearAllMocks() - mockAgentSettingProps = null }) // Rendering tests (REQUIRED) @@ -139,8 +113,8 @@ describe('AssistantTypePicker', () => { renderComponent() // Act - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Assert - Both options should be visible await waitFor(() => { @@ -225,8 +199,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'chat' }) // Act - Open dropdown - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Wait for dropdown and select agent await waitFor(() => { @@ -235,7 +209,7 @@ describe('AssistantTypePicker', () => { }) const agentOptions = screen.getAllByText(/agentAssistant.name/i) - await user.click(agentOptions[0].closest('div')!) + await user.click(agentOptions[0]) // Assert - Dropdown should remain open (agent settings should be visible) await waitFor(() => { @@ -250,8 +224,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'chat', onChange }) // Act - Open dropdown - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Wait for dropdown and click same option await waitFor(() => { @@ -260,7 +234,7 @@ describe('AssistantTypePicker', () => { }) const chatOptions = screen.getAllByText(/chatAssistant.name/i) - await user.click(chatOptions[1].closest('div')!) + await user.click(chatOptions[1]) // Assert expect(onChange).not.toHaveBeenCalled() @@ -276,8 +250,8 @@ describe('AssistantTypePicker', () => { renderComponent({ disabled: true, onChange }) // Act - Open dropdown (dropdown can still open when disabled) - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Wait for dropdown to open await waitFor(() => { @@ -298,8 +272,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: true }) // Act - Open dropdown - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) // Assert - Agent settings option should not be visible await waitFor(() => { @@ -313,8 +287,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false }) // Act - Open dropdown - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) // Assert - Agent settings option should be visible await waitFor(() => { @@ -331,20 +305,20 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false }) // Act - Open dropdown - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) // Click agent settings await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) // Assert await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) }) @@ -354,8 +328,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'chat', disabled: false }) // Act - Open dropdown - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Wait for dropdown to open await waitFor(() => { @@ -363,7 +337,7 @@ describe('AssistantTypePicker', () => { }) // Assert - Agent settings modal should not appear (value is 'chat') - expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument() + expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument() }) it('should call onAgentSettingChange when saving agent settings', async () => { @@ -373,26 +347,26 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false, onAgentSettingChange }) // Act - Open dropdown and agent settings - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) // Wait for modal and click save await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) - const saveButton = screen.getByText('Save') + const saveButton = screen.getByText(/common.operation.save/i) await user.click(saveButton) // Assert - expect(onAgentSettingChange).toHaveBeenCalledWith({ max_iteration: 5 }) + expect(onAgentSettingChange).toHaveBeenCalledWith(defaultAgentConfig) }) it('should close modal when saving agent settings', async () => { @@ -401,26 +375,26 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false }) // Act - Open dropdown, agent settings, and save - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/appDebug.agent.setting.name/i)).toBeInTheDocument() }) - const saveButton = screen.getByText('Save') + const saveButton = screen.getByText(/common.operation.save/i) await user.click(saveButton) // Assert await waitFor(() => { - expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument() + expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument() }) }) @@ -431,26 +405,26 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false, onAgentSettingChange }) // Act - Open dropdown, agent settings, and cancel - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) - const cancelButton = screen.getByText('Cancel') + const cancelButton = screen.getByText(/common.operation.cancel/i) await user.click(cancelButton) // Assert await waitFor(() => { - expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument() + expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument() }) expect(onAgentSettingChange).not.toHaveBeenCalled() }) @@ -461,19 +435,19 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false }) // Act - Open dropdown and agent settings - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) // Assert - Modal should be open and dropdown should close await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) // The dropdown should be closed (agent settings description should not be visible) @@ -492,10 +466,10 @@ describe('AssistantTypePicker', () => { renderComponent() // Act - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) - await user.click(trigger!) - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + await user.click(trigger) + await user.click(trigger) // Assert - Should not crash expect(trigger).toBeInTheDocument() @@ -538,8 +512,8 @@ describe('AssistantTypePicker', () => { }) }).not.toThrow() - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) }) it('should handle empty agentConfig', async () => { @@ -630,8 +604,8 @@ describe('AssistantTypePicker', () => { renderComponent() // Act - Open dropdown - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Assert - Descriptions should be visible await waitFor(() => { @@ -657,18 +631,14 @@ describe('AssistantTypePicker', () => { }) }) - // Props Validation for AgentSetting - describe('AgentSetting Props', () => { - it('should pass isFunctionCall and isChatModel props to AgentSetting', async () => { + // Agent Setting Integration + describe('AgentSetting Integration', () => { + it('should show function call mode when isFunctionCall is true', async () => { // Arrange const user = userEvent.setup() - renderComponent({ - value: 'agent', - isFunctionCall: true, - isChatModel: false, - }) + renderComponent({ value: 'agent', isFunctionCall: true, isChatModel: false }) - // Act - Open dropdown and trigger AgentSetting + // Act - Open dropdown and settings modal const trigger = screen.getByText(/agentAssistant.name/i) await user.click(trigger) @@ -679,17 +649,37 @@ describe('AssistantTypePicker', () => { const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) await user.click(agentSettingsTrigger) - // Assert - Verify AgentSetting receives correct props + // Assert await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) - - expect(mockAgentSettingProps).not.toBeNull() - expect(mockAgentSettingProps!.isFunctionCall).toBe(true) - expect(mockAgentSettingProps!.isChatModel).toBe(false) + expect(screen.getByText(/appDebug.agent.agentModeType.functionCall/i)).toBeInTheDocument() }) - it('should pass agentConfig payload to AgentSetting', async () => { + it('should show built-in prompt when isFunctionCall is false', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'agent', isFunctionCall: false, isChatModel: true }) + + // Act - Open dropdown and settings modal + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) + + // Assert + await waitFor(() => { + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() + }) + expect(screen.getByText(/tools.builtInPromptTitle/i)).toBeInTheDocument() + }) + + it('should initialize max iteration from agentConfig payload', async () => { // Arrange const user = userEvent.setup() const customConfig: AgentConfig = { @@ -699,12 +689,9 @@ describe('AssistantTypePicker', () => { tools: [], } - renderComponent({ - value: 'agent', - agentConfig: customConfig, - }) + renderComponent({ value: 'agent', agentConfig: customConfig }) - // Act - Open AgentSetting + // Act - Open dropdown and settings modal const trigger = screen.getByText(/agentAssistant.name/i) await user.click(trigger) @@ -715,13 +702,10 @@ describe('AssistantTypePicker', () => { const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) await user.click(agentSettingsTrigger) - // Assert - Verify payload was passed - await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() - }) - - expect(mockAgentSettingProps).not.toBeNull() - expect(mockAgentSettingProps!.payload).toEqual(customConfig) + // Assert + await screen.findByText(/common.operation.save/i) + const maxIterationInput = await screen.findByRole('spinbutton') + expect(maxIterationInput).toHaveValue(10) }) }) diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx index f76145f901..676456c3ea 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { createRef } from 'react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { type ReactNode, type RefObject, createRef } from 'react' import DebugWithSingleModel from './index' import type { DebugWithSingleModelRefType } from './index' import type { ChatItem } from '@/app/components/base/chat/types' @@ -8,7 +8,8 @@ import type { ProviderContextState } from '@/context/provider-context' import type { DatasetConfigs, ModelConfig } from '@/models/debug' import { PromptMode } from '@/models/debug' import { type Collection, CollectionType } from '@/app/components/tools/types' -import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app' +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import { AgentStrategy, AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app' // ============================================================================ // Test Data Factories (Following testing.md guidelines) @@ -67,21 +68,6 @@ function createMockModelConfig(overrides: Partial = {}): ModelConfi } } -/** - * Factory function for creating mock ChatItem list - * Note: Currently unused but kept for potential future test cases - */ -// eslint-disable-next-line unused-imports/no-unused-vars -function createMockChatList(items: Partial[] = []): ChatItem[] { - return items.map((item, index) => ({ - id: `msg-${index}`, - content: 'Test message', - isAnswer: false, - message_files: [], - ...item, - })) -} - /** * Factory function for creating mock Collection list */ @@ -156,9 +142,9 @@ const mockFetchSuggestedQuestions = jest.fn() const mockStopChatMessageResponding = jest.fn() jest.mock('@/service/debug', () => ({ - fetchConversationMessages: (...args: any[]) => mockFetchConversationMessages(...args), - fetchSuggestedQuestions: (...args: any[]) => mockFetchSuggestedQuestions(...args), - stopChatMessageResponding: (...args: any[]) => mockStopChatMessageResponding(...args), + fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args), + fetchSuggestedQuestions: (...args: unknown[]) => mockFetchSuggestedQuestions(...args), + stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args), })) jest.mock('next/navigation', () => ({ @@ -255,11 +241,11 @@ const mockDebugConfigContext = { score_threshold: 0.7, datasets: { datasets: [] }, } as DatasetConfigs, - datasetConfigsRef: { current: null } as any, + datasetConfigsRef: createRef(), setDatasetConfigs: jest.fn(), hasSetContextVar: false, isShowVisionConfig: false, - visionConfig: { enabled: false, number_limits: 2, detail: 'low' as any, transfer_methods: [] }, + visionConfig: { enabled: false, number_limits: 2, detail: Resolution.low, transfer_methods: [] }, setVisionConfig: jest.fn(), isAllowVideoUpload: false, isShowDocumentConfig: false, @@ -295,7 +281,19 @@ jest.mock('@/context/app-context', () => ({ useAppContext: jest.fn(() => mockAppContext), })) -const mockFeatures = { +type FeatureState = { + moreLikeThis: { enabled: boolean } + opening: { enabled: boolean; opening_statement: string; suggested_questions: string[] } + moderation: { enabled: boolean } + speech2text: { enabled: boolean } + text2speech: { enabled: boolean } + file: { enabled: boolean } + suggested: { enabled: boolean } + citation: { enabled: boolean } + annotationReply: { enabled: boolean } +} + +const defaultFeatures: FeatureState = { moreLikeThis: { enabled: false }, opening: { enabled: false, opening_statement: '', suggested_questions: [] }, moderation: { enabled: false }, @@ -306,13 +304,11 @@ const mockFeatures = { citation: { enabled: false }, annotationReply: { enabled: false }, } +type FeatureSelector = (state: { features: FeatureState }) => unknown +let mockFeaturesState: FeatureState = { ...defaultFeatures } jest.mock('@/app/components/base/features/hooks', () => ({ - useFeatures: jest.fn((selector) => { - if (typeof selector === 'function') - return selector({ features: mockFeatures }) - return mockFeatures - }), + useFeatures: jest.fn(), })) const mockConfigFromDebugContext = { @@ -345,7 +341,7 @@ jest.mock('../hooks', () => ({ const mockSetShowAppConfigureFeaturesModal = jest.fn() jest.mock('@/app/components/app/store', () => ({ - useStore: jest.fn((selector) => { + useStore: jest.fn((selector?: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => { if (typeof selector === 'function') return selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal }) return mockSetShowAppConfigureFeaturesModal @@ -384,12 +380,31 @@ jest.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ }, })) -// Mock external APIs that might be used -globalThis.ResizeObserver = jest.fn().mockImplementation(() => ({ - observe: jest.fn(), - unobserve: jest.fn(), - disconnect: jest.fn(), -})) +type MockChatProps = { + chatList?: ChatItem[] + isResponding?: boolean + onSend?: (message: string, files?: FileEntity[]) => void + onRegenerate?: (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => void + onStopResponding?: () => void + suggestedQuestions?: string[] + questionIcon?: ReactNode + answerIcon?: ReactNode + onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void + onAnnotationEdited?: (question: string, answer: string, index: number) => void + onAnnotationRemoved?: (index: number) => void + switchSibling?: (siblingMessageId: string) => void + onFeatureBarClick?: (state: boolean) => void +} + +const mockFile: FileEntity = { + id: 'file-1', + name: 'test.png', + size: 123, + type: 'image/png', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'image', +} // Mock Chat component (complex with many dependencies) // This is a pragmatic mock that tests the integration at DebugWithSingleModel level @@ -408,11 +423,13 @@ jest.mock('@/app/components/base/chat/chat', () => { onAnnotationRemoved, switchSibling, onFeatureBarClick, - }: any) { + }: MockChatProps) { + const items = chatList || [] + const suggested = suggestedQuestions ?? [] return (
- {chatList?.map((item: any) => ( + {items.map((item: ChatItem) => (
{item.content}
@@ -434,14 +451,21 @@ jest.mock('@/app/components/base/chat/chat', () => { > Send + {isResponding && ( )} - {suggestedQuestions?.length > 0 && ( + {suggested.length > 0 && (
- {suggestedQuestions.map((q: string, i: number) => ( + {suggested.map((q: string, i: number) => ( @@ -451,7 +475,13 @@ jest.mock('@/app/components/base/chat/chat', () => { {onRegenerate && ( @@ -506,12 +536,30 @@ jest.mock('@/app/components/base/chat/chat', () => { // ============================================================================ describe('DebugWithSingleModel', () => { - let ref: React.RefObject + let ref: RefObject beforeEach(() => { jest.clearAllMocks() ref = createRef() + const { useDebugConfigurationContext } = require('@/context/debug-configuration') + const { useProviderContext } = require('@/context/provider-context') + const { useAppContext } = require('@/context/app-context') + const { useConfigFromDebugContext, useFormattingChangedSubscription } = require('../hooks') + const { useFeatures } = require('@/app/components/base/features/hooks') as { useFeatures: jest.Mock } + + useDebugConfigurationContext.mockReturnValue(mockDebugConfigContext) + useProviderContext.mockReturnValue(mockProviderContext) + useAppContext.mockReturnValue(mockAppContext) + useConfigFromDebugContext.mockReturnValue(mockConfigFromDebugContext) + useFormattingChangedSubscription.mockReturnValue(undefined) + mockFeaturesState = { ...defaultFeatures } + useFeatures.mockImplementation((selector?: FeatureSelector) => { + if (typeof selector === 'function') + return selector({ features: mockFeaturesState }) + return mockFeaturesState + }) + // Reset mock implementations mockFetchConversationMessages.mockResolvedValue({ data: [] }) mockFetchSuggestedQuestions.mockResolvedValue({ data: [] }) @@ -521,7 +569,7 @@ describe('DebugWithSingleModel', () => { // Rendering Tests describe('Rendering', () => { it('should render without crashing', () => { - render(} />) + render(} />) // Verify Chat component is rendered expect(screen.getByTestId('chat-component')).toBeInTheDocument() @@ -532,7 +580,7 @@ describe('DebugWithSingleModel', () => { it('should render with custom checkCanSend prop', () => { const checkCanSend = jest.fn(() => true) - render(} checkCanSend={checkCanSend} />) + render(} checkCanSend={checkCanSend} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -543,122 +591,88 @@ describe('DebugWithSingleModel', () => { it('should respect checkCanSend returning true', async () => { const checkCanSend = jest.fn(() => true) - render(} checkCanSend={checkCanSend} />) + render(} checkCanSend={checkCanSend} />) const sendButton = screen.getByTestId('send-button') fireEvent.click(sendButton) + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } await waitFor(() => { expect(checkCanSend).toHaveBeenCalled() + expect(ssePost).toHaveBeenCalled() }) + + expect(ssePost.mock.calls[0][0]).toBe('apps/test-app-id/chat-messages') }) it('should prevent send when checkCanSend returns false', async () => { const checkCanSend = jest.fn(() => false) - render(} checkCanSend={checkCanSend} />) + render(} checkCanSend={checkCanSend} />) const sendButton = screen.getByTestId('send-button') fireEvent.click(sendButton) + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } await waitFor(() => { expect(checkCanSend).toHaveBeenCalled() expect(checkCanSend).toHaveReturnedWith(false) }) + expect(ssePost).not.toHaveBeenCalled() }) }) - // Context Integration Tests - describe('Context Integration', () => { - it('should use debug configuration context', () => { - const { useDebugConfigurationContext } = require('@/context/debug-configuration') + // User Interactions + describe('User Interactions', () => { + it('should open feature configuration when feature bar is clicked', () => { + render(} />) - render(} />) + fireEvent.click(screen.getByTestId('feature-bar-button')) - expect(useDebugConfigurationContext).toHaveBeenCalled() - }) - - it('should use provider context for model list', () => { - const { useProviderContext } = require('@/context/provider-context') - - render(} />) - - expect(useProviderContext).toHaveBeenCalled() - }) - - it('should use app context for user profile', () => { - const { useAppContext } = require('@/context/app-context') - - render(} />) - - expect(useAppContext).toHaveBeenCalled() - }) - - it('should use features from features hook', () => { - const { useFeatures } = require('@/app/components/base/features/hooks') - - render(} />) - - expect(useFeatures).toHaveBeenCalled() - }) - - it('should use config from debug context hook', () => { - const { useConfigFromDebugContext } = require('../hooks') - - render(} />) - - expect(useConfigFromDebugContext).toHaveBeenCalled() - }) - - it('should subscribe to formatting changes', () => { - const { useFormattingChangedSubscription } = require('../hooks') - - render(} />) - - expect(useFormattingChangedSubscription).toHaveBeenCalled() + expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true) }) }) // Model Configuration Tests describe('Model Configuration', () => { - it('should merge features into config correctly when all features enabled', () => { - const { useFeatures } = require('@/app/components/base/features/hooks') + it('should include opening features in request when enabled', async () => { + mockFeaturesState = { + ...defaultFeatures, + opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] }, + } - useFeatures.mockReturnValue((selector: any) => { - const features = { - moreLikeThis: { enabled: true }, - opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] }, - moderation: { enabled: true }, - speech2text: { enabled: true }, - text2speech: { enabled: true }, - file: { enabled: true }, - suggested: { enabled: true }, - citation: { enabled: true }, - annotationReply: { enabled: true }, - } - return typeof selector === 'function' ? selector({ features }) : features + render(} />) + + fireEvent.click(screen.getByTestId('send-button')) + + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } + await waitFor(() => { + expect(ssePost).toHaveBeenCalled() }) - render(} />) - - expect(screen.getByTestId('chat-component')).toBeInTheDocument() + const body = ssePost.mock.calls[0][1].body + expect(body.model_config.opening_statement).toBe('Hello!') + expect(body.model_config.suggested_questions).toEqual(['Q1']) }) - it('should handle opening feature disabled correctly', () => { - const { useFeatures } = require('@/app/components/base/features/hooks') + it('should omit opening statement when feature is disabled', async () => { + mockFeaturesState = { + ...defaultFeatures, + opening: { enabled: false, opening_statement: 'Should not appear', suggested_questions: ['Q1'] }, + } - useFeatures.mockReturnValue((selector: any) => { - const features = { - ...mockFeatures, - opening: { enabled: false, opening_statement: 'Should not appear', suggested_questions: ['Q1'] }, - } - return typeof selector === 'function' ? selector({ features }) : features + render(} />) + + fireEvent.click(screen.getByTestId('send-button')) + + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } + await waitFor(() => { + expect(ssePost).toHaveBeenCalled() }) - render(} />) - - // When opening is disabled, opening_statement should be empty - expect(screen.queryByText('Should not appear')).not.toBeInTheDocument() + const body = ssePost.mock.calls[0][1].body + expect(body.model_config.opening_statement).toBe('') + expect(body.model_config.suggested_questions).toEqual([]) }) it('should handle model without vision support', () => { @@ -689,7 +703,7 @@ describe('DebugWithSingleModel', () => { ], })) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -710,7 +724,7 @@ describe('DebugWithSingleModel', () => { ], })) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -735,7 +749,7 @@ describe('DebugWithSingleModel', () => { }), }) - render(} />) + render(} />) // Component should render successfully with filtered variables expect(screen.getByTestId('chat-component')).toBeInTheDocument() @@ -754,7 +768,7 @@ describe('DebugWithSingleModel', () => { }), }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -763,7 +777,7 @@ describe('DebugWithSingleModel', () => { // Tool Icons Tests describe('Tool Icons', () => { it('should map tool icons from collection list', () => { - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -783,7 +797,7 @@ describe('DebugWithSingleModel', () => { }), }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -812,7 +826,7 @@ describe('DebugWithSingleModel', () => { collectionList: [], }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -828,7 +842,7 @@ describe('DebugWithSingleModel', () => { inputs: {}, }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -846,7 +860,7 @@ describe('DebugWithSingleModel', () => { }, }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -859,7 +873,7 @@ describe('DebugWithSingleModel', () => { completionParams: {}, }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -868,7 +882,7 @@ describe('DebugWithSingleModel', () => { // Imperative Handle Tests describe('Imperative Handle', () => { it('should expose handleRestart method via ref', () => { - render(} />) + render(} />) expect(ref.current).not.toBeNull() expect(ref.current?.handleRestart).toBeDefined() @@ -876,65 +890,26 @@ describe('DebugWithSingleModel', () => { }) it('should call handleRestart when invoked via ref', () => { - render(} />) + render(} />) - expect(() => { + act(() => { ref.current?.handleRestart() - }).not.toThrow() - }) - }) - - // Memory and Performance Tests - describe('Memory and Performance', () => { - it('should properly memoize component', () => { - const { rerender } = render(} />) - - // Re-render with same props - rerender(} />) - - expect(screen.getByTestId('chat-component')).toBeInTheDocument() - }) - - it('should have displayName set for debugging', () => { - expect(DebugWithSingleModel).toBeDefined() - // memo wraps the component - expect(typeof DebugWithSingleModel).toBe('object') - }) - }) - - // Async Operations Tests - describe('Async Operations', () => { - it('should handle API calls during message send', async () => { - mockFetchConversationMessages.mockResolvedValue({ data: [] }) - - render(} />) - - const textarea = screen.getByRole('textbox', { hidden: true }) - fireEvent.change(textarea, { target: { value: 'Test message' } }) - - // Component should render without errors during async operations - await waitFor(() => { - expect(screen.getByTestId('chat-component')).toBeInTheDocument() - }) - }) - - it('should handle API errors gracefully', async () => { - mockFetchConversationMessages.mockRejectedValue(new Error('API Error')) - - render(} />) - - // Component should still render even if API calls fail - await waitFor(() => { - expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) }) }) // File Upload Tests describe('File Upload', () => { - it('should not include files when vision is not supported', () => { + it('should not include files when vision is not supported', async () => { + const { useDebugConfigurationContext } = require('@/context/debug-configuration') const { useProviderContext } = require('@/context/provider-context') - const { useFeatures } = require('@/app/components/base/features/hooks') + + useDebugConfigurationContext.mockReturnValue({ + ...mockDebugConfigContext, + modelConfig: createMockModelConfig({ + model_id: 'gpt-3.5-turbo', + }), + }) useProviderContext.mockReturnValue(createMockProviderContext({ textGenerationModelList: [ @@ -961,23 +936,34 @@ describe('DebugWithSingleModel', () => { ], })) - useFeatures.mockReturnValue((selector: any) => { - const features = { - ...mockFeatures, - file: { enabled: true }, // File upload enabled - } - return typeof selector === 'function' ? selector({ features }) : features + mockFeaturesState = { + ...defaultFeatures, + file: { enabled: true }, + } + + render(} />) + + fireEvent.click(screen.getByTestId('send-with-files')) + + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } + await waitFor(() => { + expect(ssePost).toHaveBeenCalled() }) - render(} />) - - // Should render but not allow file uploads - expect(screen.getByTestId('chat-component')).toBeInTheDocument() + const body = ssePost.mock.calls[0][1].body + expect(body.files).toEqual([]) }) - it('should support files when vision is enabled', () => { + it('should support files when vision is enabled', async () => { + const { useDebugConfigurationContext } = require('@/context/debug-configuration') const { useProviderContext } = require('@/context/provider-context') - const { useFeatures } = require('@/app/components/base/features/hooks') + + useDebugConfigurationContext.mockReturnValue({ + ...mockDebugConfigContext, + modelConfig: createMockModelConfig({ + model_id: 'gpt-4-vision', + }), + }) useProviderContext.mockReturnValue(createMockProviderContext({ textGenerationModelList: [ @@ -1004,17 +990,22 @@ describe('DebugWithSingleModel', () => { ], })) - useFeatures.mockReturnValue((selector: any) => { - const features = { - ...mockFeatures, - file: { enabled: true }, - } - return typeof selector === 'function' ? selector({ features }) : features + mockFeaturesState = { + ...defaultFeatures, + file: { enabled: true }, + } + + render(} />) + + fireEvent.click(screen.getByTestId('send-with-files')) + + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } + await waitFor(() => { + expect(ssePost).toHaveBeenCalled() }) - render(} />) - - expect(screen.getByTestId('chat-component')).toBeInTheDocument() + const body = ssePost.mock.calls[0][1].body + expect(body.files).toHaveLength(1) }) }) }) diff --git a/web/app/components/billing/upgrade-btn/index.spec.tsx b/web/app/components/billing/upgrade-btn/index.spec.tsx index f52cc97b01..d106dbe327 100644 --- a/web/app/components/billing/upgrade-btn/index.spec.tsx +++ b/web/app/components/billing/upgrade-btn/index.spec.tsx @@ -5,24 +5,6 @@ import UpgradeBtn from './index' // ✅ Import real project components (DO NOT mock these) // PremiumBadge, Button, SparklesSoft are all base components -// ✅ Mock i18n with actual translations instead of returning keys -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'billing.upgradeBtn.encourage': 'Upgrade to Pro', - 'billing.upgradeBtn.encourageShort': 'Upgrade', - 'billing.upgradeBtn.plain': 'Upgrade Plan', - 'custom.label.key': 'Custom Label', - 'custom.key': 'Custom Text', - 'custom.short.key': 'Short Custom', - 'custom.all': 'All Custom Props', - } - return translations[key] || key - }, - }), -})) - // ✅ Mock external dependencies only const mockSetShowPricingModal = jest.fn() jest.mock('@/context/modal-context', () => ({ @@ -52,7 +34,7 @@ describe('UpgradeBtn', () => { render() // Assert - should render with default text - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should render premium badge by default', () => { @@ -60,7 +42,7 @@ describe('UpgradeBtn', () => { render() // Assert - PremiumBadge renders with text content - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should render plain button when isPlain is true', () => { @@ -70,7 +52,7 @@ describe('UpgradeBtn', () => { // Assert - Button should be rendered with plain text const button = screen.getByRole('button') expect(button).toBeInTheDocument() - expect(screen.getByText(/upgrade plan/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument() }) it('should render short text when isShort is true', () => { @@ -78,7 +60,7 @@ describe('UpgradeBtn', () => { render() // Assert - expect(screen.getByText(/^upgrade$/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourageShort/i)).toBeInTheDocument() }) it('should render custom label when labelKey is provided', () => { @@ -86,7 +68,7 @@ describe('UpgradeBtn', () => { render() // Assert - expect(screen.getByText(/custom label/i)).toBeInTheDocument() + expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument() }) it('should render custom label in plain button when labelKey is provided with isPlain', () => { @@ -96,7 +78,7 @@ describe('UpgradeBtn', () => { // Assert const button = screen.getByRole('button') expect(button).toBeInTheDocument() - expect(screen.getByText(/custom label/i)).toBeInTheDocument() + expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument() }) }) @@ -155,7 +137,7 @@ describe('UpgradeBtn', () => { render() // Assert - Component renders successfully with size prop - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should render with size "m" by default', () => { @@ -163,7 +145,7 @@ describe('UpgradeBtn', () => { render() // Assert - Component renders successfully - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should render with size "custom"', () => { @@ -171,7 +153,7 @@ describe('UpgradeBtn', () => { render() // Assert - Component renders successfully with custom size - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) }) @@ -184,8 +166,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert expect(handleClick).toHaveBeenCalledTimes(1) @@ -213,8 +195,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) @@ -240,8 +222,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert expect(mockGtag).toHaveBeenCalledTimes(1) @@ -273,8 +255,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert expect(mockGtag).not.toHaveBeenCalled() @@ -287,8 +269,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert - should not throw error expect(mockGtag).not.toHaveBeenCalled() @@ -302,8 +284,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert expect(handleClick).toHaveBeenCalledTimes(1) @@ -321,7 +303,7 @@ describe('UpgradeBtn', () => { render() // Assert - should render without error - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should handle undefined style', () => { @@ -329,7 +311,7 @@ describe('UpgradeBtn', () => { render() // Assert - should render without error - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should handle undefined onClick', async () => { @@ -338,8 +320,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert - should fall back to setShowPricingModal expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) @@ -351,8 +333,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert - should not attempt to track gtag expect(mockGtag).not.toHaveBeenCalled() @@ -363,7 +345,7 @@ describe('UpgradeBtn', () => { render() // Assert - should use default label - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should handle empty string className', () => { @@ -371,7 +353,7 @@ describe('UpgradeBtn', () => { render() // Assert - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should handle empty string loc', async () => { @@ -380,8 +362,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert - empty loc should not trigger gtag expect(mockGtag).not.toHaveBeenCalled() @@ -392,7 +374,7 @@ describe('UpgradeBtn', () => { render() // Assert - empty labelKey is falsy, so it falls back to default label - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) }) @@ -403,7 +385,7 @@ describe('UpgradeBtn', () => { render() // Assert - isShort should not affect plain button text - expect(screen.getByText(/upgrade plan/i)).toBeInTheDocument() + expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument() }) it('should handle isPlain with custom labelKey', () => { @@ -411,8 +393,8 @@ describe('UpgradeBtn', () => { render() // Assert - labelKey should override plain text - expect(screen.getByText(/custom text/i)).toBeInTheDocument() - expect(screen.queryByText(/upgrade plan/i)).not.toBeInTheDocument() + expect(screen.getByText(/custom\.key/i)).toBeInTheDocument() + expect(screen.queryByText(/billing\.upgradeBtn\.plain/i)).not.toBeInTheDocument() }) it('should handle isShort with custom labelKey', () => { @@ -420,8 +402,8 @@ describe('UpgradeBtn', () => { render() // Assert - labelKey should override isShort behavior - expect(screen.getByText(/short custom/i)).toBeInTheDocument() - expect(screen.queryByText(/^upgrade$/i)).not.toBeInTheDocument() + expect(screen.getByText(/custom\.short\.key/i)).toBeInTheDocument() + expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() }) it('should handle all custom props together', async () => { @@ -443,14 +425,14 @@ describe('UpgradeBtn', () => { labelKey="custom.all" />, ) - const badge = screen.getByText(/all custom props/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/custom\.all/i) + await user.click(badge) // Assert const rootElement = container.firstChild as HTMLElement expect(rootElement).toHaveClass(customClass) expect(rootElement).toHaveStyle(customStyle) - expect(screen.getByText(/all custom props/i)).toBeInTheDocument() + expect(screen.getByText(/custom\.all/i)).toBeInTheDocument() expect(handleClick).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'test-loc', @@ -503,10 +485,10 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) // Click badge - await user.click(badge!) + await user.click(badge) // Assert expect(handleClick).toHaveBeenCalledTimes(1) @@ -522,70 +504,6 @@ describe('UpgradeBtn', () => { }) }) - // Performance Tests - describe('Performance', () => { - it('should not rerender when props do not change', () => { - // Arrange - const { rerender } = render() - const firstRender = screen.getByText(/upgrade to pro/i) - - // Act - Rerender with same props - rerender() - - // Assert - Component should still be in document - expect(firstRender).toBeInTheDocument() - expect(screen.getByText(/upgrade to pro/i)).toBe(firstRender) - }) - - it('should rerender when props change', () => { - // Arrange - const { rerender } = render() - expect(screen.getByText(/custom text/i)).toBeInTheDocument() - - // Act - Rerender with different labelKey - rerender() - - // Assert - Should show new label - expect(screen.getByText(/custom label/i)).toBeInTheDocument() - expect(screen.queryByText(/custom text/i)).not.toBeInTheDocument() - }) - - it('should handle rapid rerenders efficiently', () => { - // Arrange - const { rerender } = render() - - // Act - Multiple rapid rerenders - for (let i = 0; i < 10; i++) - rerender() - - // Assert - Component should still render correctly - expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument() - }) - - it('should be memoized with React.memo', () => { - // Arrange - const TestWrapper = ({ children }: { children: React.ReactNode }) =>
{children}
- - const { rerender } = render( - - - , - ) - - const firstElement = screen.getByText(/upgrade to pro/i) - - // Act - Rerender parent with same props - rerender( - - - , - ) - - // Assert - Element reference should be stable due to memo - expect(screen.getByText(/upgrade to pro/i)).toBe(firstElement) - }) - }) - // Integration Tests describe('Integration', () => { it('should work with modal context for pricing modal', async () => { @@ -594,8 +512,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert await waitFor(() => { @@ -610,8 +528,8 @@ describe('UpgradeBtn', () => { // Act render() - const badge = screen.getByText(/upgrade to pro/i).closest('div') - await user.click(badge!) + const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) + await user.click(badge) // Assert - Both onClick and gtag should be called await waitFor(() => { diff --git a/web/app/components/explore/installed-app/index.spec.tsx b/web/app/components/explore/installed-app/index.spec.tsx index 61ef575183..7dbf31aa42 100644 --- a/web/app/components/explore/installed-app/index.spec.tsx +++ b/web/app/components/explore/installed-app/index.spec.tsx @@ -172,7 +172,7 @@ describe('InstalledApp', () => { describe('Rendering', () => { it('should render without crashing', () => { render() - expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() + expect(screen.getByText(/Chat With History/i)).toBeInTheDocument() }) it('should render loading state when fetching app params', () => { @@ -296,8 +296,8 @@ describe('InstalledApp', () => { describe('App Mode Rendering', () => { it('should render ChatWithHistory for CHAT mode', () => { render() - expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() - expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument() + expect(screen.getByText(/Chat With History/i)).toBeInTheDocument() + expect(screen.queryByText(/Text Generation App/i)).not.toBeInTheDocument() }) it('should render ChatWithHistory for ADVANCED_CHAT mode', () => { @@ -314,8 +314,8 @@ describe('InstalledApp', () => { }) render() - expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() - expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument() + expect(screen.getByText(/Chat With History/i)).toBeInTheDocument() + expect(screen.queryByText(/Text Generation App/i)).not.toBeInTheDocument() }) it('should render ChatWithHistory for AGENT_CHAT mode', () => { @@ -332,8 +332,8 @@ describe('InstalledApp', () => { }) render() - expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() - expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument() + expect(screen.getByText(/Chat With History/i)).toBeInTheDocument() + expect(screen.queryByText(/Text Generation App/i)).not.toBeInTheDocument() }) it('should render TextGenerationApp for COMPLETION mode', () => { @@ -350,8 +350,7 @@ describe('InstalledApp', () => { }) render() - expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() - expect(screen.getByText(/Text Generation App/)).toBeInTheDocument() + expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument() expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument() }) @@ -369,7 +368,7 @@ describe('InstalledApp', () => { }) render() - expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() + expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument() expect(screen.getByText(/Workflow/)).toBeInTheDocument() }) }) @@ -566,22 +565,10 @@ describe('InstalledApp', () => { render() // Should find and render the correct app - expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() + expect(screen.getByText(/Chat With History/i)).toBeInTheDocument() expect(screen.getByText(/installed-app-123/)).toBeInTheDocument() }) - it('should apply correct CSS classes to container', () => { - const { container } = render() - const mainDiv = container.firstChild as HTMLElement - expect(mainDiv).toHaveClass('h-full', 'bg-background-default', 'py-2', 'pl-0', 'pr-2', 'sm:p-2') - }) - - it('should apply correct CSS classes to ChatWithHistory', () => { - render() - const chatComponent = screen.getByTestId('chat-with-history') - expect(chatComponent).toHaveClass('overflow-hidden', 'rounded-2xl', 'shadow-md') - }) - it('should handle rapid id prop changes', async () => { const app1 = { ...mockInstalledApp, id: 'app-1' } const app2 = { ...mockInstalledApp, id: 'app-2' } @@ -627,50 +614,6 @@ describe('InstalledApp', () => { }) }) - describe('Component Memoization', () => { - it('should be wrapped with React.memo', () => { - // React.memo wraps the component with a special $$typeof symbol - const componentType = (InstalledApp as React.MemoExoticComponent).$$typeof - expect(componentType).toBeDefined() - }) - - it('should re-render when props change', () => { - const { rerender } = render() - expect(screen.getByText(/installed-app-123/)).toBeInTheDocument() - - // Change to a different app - const differentApp = { - ...mockInstalledApp, - id: 'different-app-456', - app: { - ...mockInstalledApp.app, - name: 'Different App', - }, - } - ;(useContext as jest.Mock).mockReturnValue({ - installedApps: [differentApp], - isFetchingInstalledApps: false, - }) - - rerender() - expect(screen.getByText(/different-app-456/)).toBeInTheDocument() - }) - - it('should maintain component stability across re-renders with same props', () => { - const { rerender } = render() - const initialCallCount = mockUpdateAppInfo.mock.calls.length - - // Rerender with same props - useEffect may still run due to dependencies - rerender() - - // Component should render successfully - expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() - - // Mock calls might increase due to useEffect, but component should be stable - expect(mockUpdateAppInfo.mock.calls.length).toBeGreaterThanOrEqual(initialCallCount) - }) - }) - describe('Render Priority', () => { it('should show error before loading state', () => { ;(useGetInstalledAppParams as jest.Mock).mockReturnValue({ diff --git a/web/jest.config.ts b/web/jest.config.ts index 6c2d88448c..e86ec5af74 100644 --- a/web/jest.config.ts +++ b/web/jest.config.ts @@ -44,6 +44,7 @@ const config: Config = { // A list of reporter names that Jest uses when writing coverage reports coverageReporters: [ + 'json-summary', 'json', 'text', 'text-summary', diff --git a/web/jest.setup.ts b/web/jest.setup.ts index 9c3b0bf3bd..a4d358d805 100644 --- a/web/jest.setup.ts +++ b/web/jest.setup.ts @@ -42,6 +42,22 @@ if (typeof window !== 'undefined') { ensureWritable(HTMLElement.prototype, 'focus') } +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() { + return undefined + } + + unobserve() { + return undefined + } + + disconnect() { + return undefined + } + } +} + afterEach(() => { cleanup() }) diff --git a/web/package.json b/web/package.json index d54e6effb2..158dfbcae8 100644 --- a/web/package.json +++ b/web/package.json @@ -200,6 +200,7 @@ "eslint-plugin-tailwindcss": "^3.18.2", "globals": "^15.15.0", "husky": "^9.1.7", + "istanbul-lib-coverage": "^3.2.2", "jest": "^29.7.0", "jsdom-testing-mocks": "^1.16.0", "knip": "^5.66.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8523215a07..6dbc0fabd9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -512,6 +512,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + istanbul-lib-coverage: + specifier: ^3.2.2 + version: 3.2.2 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@18.15.0)(ts-node@10.9.2(@types/node@18.15.0)(typescript@5.9.3))