chore(web): enhance frontend tests (#29869)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
yyh
2025-12-18 17:47:13 +08:00
committed by GitHub
parent 82220a645c
commit c12f0d16bb
9 changed files with 434 additions and 549 deletions

View File

@@ -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('<details><summary>Jest coverage table</summary>');
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('</details>');
}
NODE

View File

@@ -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 (
<div data-testid="agent-setting-modal">
<button onClick={() => props.onSave({ max_iteration: 5 } as AgentConfig)}>Save</button>
<button onClick={props.onCancel}>Cancel</button>
</div>
)
}
})
// 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)
})
})

View File

@@ -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<ModelConfig> = {}): 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>[] = []): 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<DatasetConfigs>(),
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 (
<div data-testid="chat-component">
<div data-testid="chat-list">
{chatList?.map((item: any) => (
{items.map((item: ChatItem) => (
<div key={item.id} data-testid={`chat-item-${item.id}`}>
{item.content}
</div>
@@ -434,14 +451,21 @@ jest.mock('@/app/components/base/chat/chat', () => {
>
Send
</button>
<button
data-testid="send-with-files"
onClick={() => onSend?.('test message', [mockFile])}
disabled={isResponding}
>
Send With Files
</button>
{isResponding && (
<button data-testid="stop-button" onClick={onStopResponding}>
Stop
</button>
)}
{suggestedQuestions?.length > 0 && (
{suggested.length > 0 && (
<div data-testid="suggested-questions">
{suggestedQuestions.map((q: string, i: number) => (
{suggested.map((q: string, i: number) => (
<button key={i} onClick={() => onSend?.(q, [])}>
{q}
</button>
@@ -451,7 +475,13 @@ jest.mock('@/app/components/base/chat/chat', () => {
{onRegenerate && (
<button
data-testid="regenerate-button"
onClick={() => onRegenerate({ id: 'msg-1', parentMessageId: 'msg-0' })}
onClick={() => onRegenerate({
id: 'msg-1',
content: 'Question',
isAnswer: false,
message_files: [],
parentMessageId: 'msg-0',
})}
>
Regenerate
</button>
@@ -506,12 +536,30 @@ jest.mock('@/app/components/base/chat/chat', () => {
// ============================================================================
describe('DebugWithSingleModel', () => {
let ref: React.RefObject<DebugWithSingleModelRefType | null>
let ref: RefObject<DebugWithSingleModelRefType | null>
beforeEach(() => {
jest.clearAllMocks()
ref = createRef<DebugWithSingleModelRefType | null>()
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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
// 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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} 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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} 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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} 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(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(useProviderContext).toHaveBeenCalled()
})
it('should use app context for user profile', () => {
const { useAppContext } = require('@/context/app-context')
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(useAppContext).toHaveBeenCalled()
})
it('should use features from features hook', () => {
const { useFeatures } = require('@/app/components/base/features/hooks')
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(useFeatures).toHaveBeenCalled()
})
it('should use config from debug context hook', () => {
const { useConfigFromDebugContext } = require('../hooks')
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(useConfigFromDebugContext).toHaveBeenCalled()
})
it('should subscribe to formatting changes', () => {
const { useFormattingChangedSubscription } = require('../hooks')
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
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(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('send-button'))
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(ssePost).toHaveBeenCalled()
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
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(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('send-button'))
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(ssePost).toHaveBeenCalled()
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
// 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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@@ -710,7 +724,7 @@ describe('DebugWithSingleModel', () => {
],
}))
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@@ -735,7 +749,7 @@ describe('DebugWithSingleModel', () => {
}),
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
// Component should render successfully with filtered variables
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
@@ -754,7 +768,7 @@ describe('DebugWithSingleModel', () => {
}),
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@@ -783,7 +797,7 @@ describe('DebugWithSingleModel', () => {
}),
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@@ -812,7 +826,7 @@ describe('DebugWithSingleModel', () => {
collectionList: [],
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@@ -828,7 +842,7 @@ describe('DebugWithSingleModel', () => {
inputs: {},
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@@ -846,7 +860,7 @@ describe('DebugWithSingleModel', () => {
},
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@@ -859,7 +873,7 @@ describe('DebugWithSingleModel', () => {
completionParams: {},
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(() => {
act(() => {
ref.current?.handleRestart()
}).not.toThrow()
})
})
// Memory and Performance Tests
describe('Memory and Performance', () => {
it('should properly memoize component', () => {
const { rerender } = render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
// Re-render with same props
rerender(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
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(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
// 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(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('send-with-files'))
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(ssePost).toHaveBeenCalled()
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
// 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(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('send-with-files'))
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(ssePost).toHaveBeenCalled()
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
const body = ssePost.mock.calls[0][1].body
expect(body.files).toHaveLength(1)
})
})
})

View File

@@ -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<string, string> = {
'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(<UpgradeBtn />)
// 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(<UpgradeBtn />)
// 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(<UpgradeBtn isShort />)
// 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(<UpgradeBtn labelKey="custom.label.key" />)
// 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(<UpgradeBtn size="s" />)
// 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(<UpgradeBtn />)
// 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(<UpgradeBtn size="custom" />)
// 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(<UpgradeBtn onClick={handleClick} />)
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(<UpgradeBtn />)
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(<UpgradeBtn loc={loc} />)
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(<UpgradeBtn />)
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(<UpgradeBtn loc="test-location" />)
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(<UpgradeBtn onClick={handleClick} loc={loc} />)
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(<UpgradeBtn className={undefined} />)
// 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(<UpgradeBtn style={undefined} />)
// 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(<UpgradeBtn onClick={undefined} />)
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(<UpgradeBtn loc={undefined} />)
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(<UpgradeBtn labelKey={undefined} />)
// 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(<UpgradeBtn className="" />)
// 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(<UpgradeBtn loc="" />)
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(<UpgradeBtn labelKey="" />)
// 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(<UpgradeBtn isPlain isShort />)
// 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(<UpgradeBtn isPlain labelKey="custom.key" />)
// 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(<UpgradeBtn isShort labelKey="custom.short.key" />)
// 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(<UpgradeBtn onClick={handleClick} />)
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(<UpgradeBtn loc="test" />)
const firstRender = screen.getByText(/upgrade to pro/i)
// Act - Rerender with same props
rerender(<UpgradeBtn loc="test" />)
// 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(<UpgradeBtn labelKey="custom.key" />)
expect(screen.getByText(/custom text/i)).toBeInTheDocument()
// Act - Rerender with different labelKey
rerender(<UpgradeBtn labelKey="custom.label.key" />)
// 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(<UpgradeBtn />)
// Act - Multiple rapid rerenders
for (let i = 0; i < 10; i++)
rerender(<UpgradeBtn />)
// 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 }) => <div>{children}</div>
const { rerender } = render(
<TestWrapper>
<UpgradeBtn />
</TestWrapper>,
)
const firstElement = screen.getByText(/upgrade to pro/i)
// Act - Rerender parent with same props
rerender(
<TestWrapper>
<UpgradeBtn />
</TestWrapper>,
)
// 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(<UpgradeBtn />)
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(<UpgradeBtn onClick={handleClick} loc="integration-test" />)
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(() => {

View File

@@ -172,7 +172,7 @@ describe('InstalledApp', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<InstalledApp id="installed-app-123" />)
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(<InstalledApp id="installed-app-123" />)
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(<InstalledApp id="installed-app-123" />)
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(<InstalledApp id="installed-app-123" />)
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(<InstalledApp id="installed-app-123" />)
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(<InstalledApp id="installed-app-123" />)
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(<InstalledApp id="installed-app-123" />)
// 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(<InstalledApp id="installed-app-123" />)
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(<InstalledApp id="installed-app-123" />)
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 InstalledApp>).$$typeof
expect(componentType).toBeDefined()
})
it('should re-render when props change', () => {
const { rerender } = render(<InstalledApp id="installed-app-123" />)
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(<InstalledApp id="different-app-456" />)
expect(screen.getByText(/different-app-456/)).toBeInTheDocument()
})
it('should maintain component stability across re-renders with same props', () => {
const { rerender } = render(<InstalledApp id="installed-app-123" />)
const initialCallCount = mockUpdateAppInfo.mock.calls.length
// Rerender with same props - useEffect may still run due to dependencies
rerender(<InstalledApp id="installed-app-123" />)
// 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({

View File

@@ -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',

View File

@@ -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()
})

View File

@@ -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",

3
web/pnpm-lock.yaml generated
View File

@@ -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))