chore(web): enhance tests follow the testing.md and skills (#29841)

This commit is contained in:
yyh
2025-12-18 16:54:00 +08:00
committed by GitHub
parent a954bd0616
commit 98b1ec0d29
6 changed files with 190 additions and 163 deletions

View File

@ -39,7 +39,7 @@ describe('ChatVariableTrigger', () => {
render(<ChatVariableTrigger />)
// Assert
expect(screen.queryByTestId('chat-variable-button')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'ChatVariableButton' })).not.toBeInTheDocument()
})
})
@ -54,7 +54,7 @@ describe('ChatVariableTrigger', () => {
render(<ChatVariableTrigger />)
// Assert
expect(screen.getByTestId('chat-variable-button')).toBeEnabled()
expect(screen.getByRole('button', { name: 'ChatVariableButton' })).toBeEnabled()
})
it('should render disabled ChatVariableButton when nodes are read-only', () => {
@ -66,7 +66,7 @@ describe('ChatVariableTrigger', () => {
render(<ChatVariableTrigger />)
// Assert
expect(screen.getByTestId('chat-variable-button')).toBeDisabled()
expect(screen.getByRole('button', { name: 'ChatVariableButton' })).toBeDisabled()
})
})
})

View File

@ -1,6 +1,9 @@
import type { ReactElement } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Plan } from '@/app/components/billing/type'
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
import { ToastContext } from '@/app/components/base/toast'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import FeaturesTrigger from './features-trigger'
@ -10,7 +13,6 @@ const mockUseNodesReadOnly = jest.fn()
const mockUseChecklist = jest.fn()
const mockUseChecklistBeforePublish = jest.fn()
const mockUseNodesSyncDraft = jest.fn()
const mockUseToastContext = jest.fn()
const mockUseFeatures = jest.fn()
const mockUseProviderContext = jest.fn()
const mockUseNodes = jest.fn()
@ -45,8 +47,6 @@ const mockWorkflowStore = {
setState: mockWorkflowStoreSetState,
}
let capturedAppPublisherProps: Record<string, unknown> | null = null
jest.mock('@/app/components/workflow/hooks', () => ({
__esModule: true,
useChecklist: (...args: unknown[]) => mockUseChecklist(...args),
@ -75,11 +75,6 @@ jest.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
}))
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
useToastContext: () => mockUseToastContext(),
}))
jest.mock('@/context/provider-context', () => ({
__esModule: true,
useProviderContext: () => mockUseProviderContext(),
@ -97,14 +92,33 @@ jest.mock('reactflow', () => ({
jest.mock('@/app/components/app/app-publisher', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
capturedAppPublisherProps = props
default: (props: AppPublisherProps) => {
const inputs = props.inputs ?? []
return (
<div
data-testid='app-publisher'
data-disabled={String(Boolean(props.disabled))}
data-publish-disabled={String(Boolean(props.publishDisabled))}
/>
data-start-node-limit-exceeded={String(Boolean(props.startNodeLimitExceeded))}
data-has-trigger-node={String(Boolean(props.hasTriggerNode))}
data-inputs={JSON.stringify(inputs)}
>
<button type="button" onClick={() => { props.onRefreshData?.() }}>
publisher-refresh
</button>
<button type="button" onClick={() => { props.onToggle?.(true) }}>
publisher-toggle-on
</button>
<button type="button" onClick={() => { props.onToggle?.(false) }}>
publisher-toggle-off
</button>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
publisher-publish
</button>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
publisher-publish-with-params
</button>
</div>
)
},
}))
@ -147,10 +161,17 @@ const createProviderContext = ({
isFetchedPlan,
})
const renderWithToast = (ui: ReactElement) => {
return render(
<ToastContext.Provider value={{ notify: mockNotify, close: jest.fn() }}>
{ui}
</ToastContext.Provider>,
)
}
describe('FeaturesTrigger', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedAppPublisherProps = null
workflowStoreState = {
showFeaturesPanel: false,
isRestoring: false,
@ -165,7 +186,6 @@ describe('FeaturesTrigger', () => {
mockUseChecklistBeforePublish.mockReturnValue({ handleCheckBeforePublish: mockHandleCheckBeforePublish })
mockHandleCheckBeforePublish.mockResolvedValue(true)
mockUseNodesSyncDraft.mockReturnValue({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft })
mockUseToastContext.mockReturnValue({ notify: mockNotify })
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({ features: { file: {} } }))
mockUseProviderContext.mockReturnValue(createProviderContext({}))
mockUseNodes.mockReturnValue([])
@ -182,7 +202,7 @@ describe('FeaturesTrigger', () => {
mockUseIsChatMode.mockReturnValue(false)
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(screen.queryByRole('button', { name: /workflow\.common\.features/i })).not.toBeInTheDocument()
@ -193,7 +213,7 @@ describe('FeaturesTrigger', () => {
mockUseIsChatMode.mockReturnValue(true)
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toBeInTheDocument()
@ -205,7 +225,7 @@ describe('FeaturesTrigger', () => {
mockUseTheme.mockReturnValue({ theme: 'dark' })
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass('rounded-lg')
@ -220,7 +240,7 @@ describe('FeaturesTrigger', () => {
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
@ -242,7 +262,7 @@ describe('FeaturesTrigger', () => {
isRestoring: false,
}
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
@ -260,10 +280,9 @@ describe('FeaturesTrigger', () => {
mockUseNodes.mockReturnValue([])
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(capturedAppPublisherProps?.disabled).toBe(true)
expect(screen.getByTestId('app-publisher')).toHaveAttribute('data-disabled', 'true')
})
})
@ -280,10 +299,15 @@ describe('FeaturesTrigger', () => {
])
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
const inputs = (capturedAppPublisherProps?.inputs as unknown as Array<{ type?: string; variable?: string }>) || []
const inputs = JSON.parse(screen.getByTestId('app-publisher').getAttribute('data-inputs') ?? '[]') as Array<{
type?: string
variable?: string
required?: boolean
label?: string
}>
expect(inputs).toContainEqual({
type: InputVarType.files,
variable: '__image',
@ -302,51 +326,49 @@ describe('FeaturesTrigger', () => {
])
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(capturedAppPublisherProps?.startNodeLimitExceeded).toBe(true)
expect(capturedAppPublisherProps?.publishDisabled).toBe(true)
expect(capturedAppPublisherProps?.hasTriggerNode).toBe(true)
const publisher = screen.getByTestId('app-publisher')
expect(publisher).toHaveAttribute('data-start-node-limit-exceeded', 'true')
expect(publisher).toHaveAttribute('data-publish-disabled', 'true')
expect(publisher).toHaveAttribute('data-has-trigger-node', 'true')
})
})
// Verifies callbacks wired from AppPublisher to stores and draft syncing.
describe('Callbacks', () => {
it('should set toolPublished when AppPublisher refreshes data', () => {
it('should set toolPublished when AppPublisher refreshes data', async () => {
// Arrange
render(<FeaturesTrigger />)
const refresh = capturedAppPublisherProps?.onRefreshData as unknown as (() => void) | undefined
expect(refresh).toBeDefined()
const user = userEvent.setup()
renderWithToast(<FeaturesTrigger />)
// Act
refresh?.()
await user.click(screen.getByRole('button', { name: 'publisher-refresh' }))
// Assert
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ toolPublished: true })
})
it('should sync workflow draft when AppPublisher toggles on', () => {
it('should sync workflow draft when AppPublisher toggles on', async () => {
// Arrange
render(<FeaturesTrigger />)
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
expect(onToggle).toBeDefined()
const user = userEvent.setup()
renderWithToast(<FeaturesTrigger />)
// Act
onToggle?.(true)
await user.click(screen.getByRole('button', { name: 'publisher-toggle-on' }))
// Assert
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should not sync workflow draft when AppPublisher toggles off', () => {
it('should not sync workflow draft when AppPublisher toggles off', async () => {
// Arrange
render(<FeaturesTrigger />)
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
expect(onToggle).toBeDefined()
const user = userEvent.setup()
renderWithToast(<FeaturesTrigger />)
// Act
onToggle?.(false)
await user.click(screen.getByRole('button', { name: 'publisher-toggle-off' }))
// Assert
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
@ -357,61 +379,62 @@ describe('FeaturesTrigger', () => {
describe('Publishing', () => {
it('should notify error and reject publish when checklist has warning nodes', async () => {
// Arrange
const user = userEvent.setup()
mockUseChecklist.mockReturnValue([{ id: 'warning' }])
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
renderWithToast(<FeaturesTrigger />)
// Act
await expect(onPublish?.()).rejects.toThrow('Checklist has unresolved items')
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
})
expect(mockPublishWorkflow).not.toHaveBeenCalled()
})
it('should reject publish when checklist before publish fails', async () => {
// Arrange
const user = userEvent.setup()
mockHandleCheckBeforePublish.mockResolvedValue(false)
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
renderWithToast(<FeaturesTrigger />)
// Act & Assert
await expect(onPublish?.()).rejects.toThrow('Checklist failed')
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
await waitFor(() => {
expect(mockHandleCheckBeforePublish).toHaveBeenCalled()
})
expect(mockPublishWorkflow).not.toHaveBeenCalled()
})
it('should publish workflow and update related stores when validation passes', async () => {
// Arrange
const user = userEvent.setup()
mockUseNodes.mockReturnValue([
{ id: 'start', data: { type: BlockEnum.Start } },
])
mockUseEdges.mockReturnValue([
{ source: 'start' },
])
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
renderWithToast(<FeaturesTrigger />)
// Act
await onPublish?.()
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: '',
releaseNotes: '',
})
expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id')
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id')
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: '',
releaseNotes: '',
})
expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id')
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id')
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
expect(mockSetAppDetail).toHaveBeenCalled()
})
@ -419,34 +442,32 @@ describe('FeaturesTrigger', () => {
it('should pass publish params to workflow publish mutation', async () => {
// Arrange
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as ((params: { title: string; releaseNotes: string }) => Promise<void>) | undefined
expect(onPublish).toBeDefined()
const user = userEvent.setup()
renderWithToast(<FeaturesTrigger />)
// Act
await onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })
await user.click(screen.getByRole('button', { name: 'publisher-publish-with-params' }))
// Assert
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: 'Test title',
releaseNotes: 'Test notes',
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: 'Test title',
releaseNotes: 'Test notes',
})
})
})
it('should log error when app detail refresh fails after publish', async () => {
// Arrange
const user = userEvent.setup()
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchAppDetail.mockRejectedValue(new Error('fetch failed'))
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
renderWithToast(<FeaturesTrigger />)
// Act
await onPublish?.()
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
await waitFor(() => {

View File

@ -1,16 +1,14 @@
import { render } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import type { App } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import type { HeaderProps } from '@/app/components/workflow/header'
import WorkflowHeader from './index'
import { fetchWorkflowRunHistory } from '@/service/workflow'
const mockUseAppStoreSelector = jest.fn()
const mockSetCurrentLogItem = jest.fn()
const mockSetShowMessageLogModal = jest.fn()
const mockResetWorkflowVersionHistory = jest.fn()
let capturedHeaderProps: HeaderProps | null = null
let appDetail: App
jest.mock('ky', () => ({
@ -39,8 +37,31 @@ jest.mock('@/app/components/app/store', () => ({
jest.mock('@/app/components/workflow/header', () => ({
__esModule: true,
default: (props: HeaderProps) => {
capturedHeaderProps = props
return <div data-testid='workflow-header' />
const historyFetcher = props.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher
const hasHistoryFetcher = typeof historyFetcher === 'function'
return (
<div
data-testid='workflow-header'
data-show-run={String(Boolean(props.normal?.runAndHistoryProps?.showRunButton))}
data-show-preview={String(Boolean(props.normal?.runAndHistoryProps?.showPreviewButton))}
data-history-url={props.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl ?? ''}
data-has-history-fetcher={String(hasHistoryFetcher)}
>
<button
type="button"
onClick={() => props.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal?.()}
>
clear-history
</button>
<button
type="button"
onClick={() => props.restoring?.onRestoreSettled?.()}
>
restore-settled
</button>
</div>
)
},
}))
@ -57,7 +78,6 @@ jest.mock('@/service/use-workflow', () => ({
describe('WorkflowHeader', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedHeaderProps = null
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
@ -74,7 +94,7 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps).not.toBeNull()
expect(screen.getByTestId('workflow-header')).toBeInTheDocument()
})
})
@ -93,10 +113,11 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(false)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(true)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/advanced-chat/workflow-runs')
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher).toBe(fetchWorkflowRunHistory)
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-run', 'false')
expect(header).toHaveAttribute('data-show-preview', 'true')
expect(header).toHaveAttribute('data-history-url', '/apps/app-id/advanced-chat/workflow-runs')
expect(header).toHaveAttribute('data-has-history-fetcher', 'true')
})
it('should configure run mode when app is not in advanced chat mode', () => {
@ -112,9 +133,11 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(true)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(false)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/workflow-runs')
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-run', 'true')
expect(header).toHaveAttribute('data-show-preview', 'false')
expect(header).toHaveAttribute('data-history-url', '/apps/app-id/workflow-runs')
expect(header).toHaveAttribute('data-has-history-fetcher', 'true')
})
})
@ -124,11 +147,8 @@ describe('WorkflowHeader', () => {
// Arrange
render(<WorkflowHeader />)
const clear = capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal
expect(clear).toBeDefined()
// Act
clear?.()
screen.getByRole('button', { name: 'clear-history' }).click()
// Assert
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
@ -143,7 +163,8 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.restoring?.onRestoreSettled).toBe(mockResetWorkflowVersionHistory)
screen.getByRole('button', { name: 'restore-settled' }).click()
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
})
})
})