diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000000..cdfb8b17a3 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,6 @@ +# Cursor Rules for Dify Project + +## Automated Test Generation + +- Use `web/testing/testing.md` as the canonical instruction set for generating frontend automated tests. +- When proposing or saving tests, re-read that document and follow every requirement. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..53afcbda1e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,12 @@ +# Copilot Instructions + +GitHub Copilot must follow the unified frontend testing requirements documented in `web/testing/testing.md`. + +Key reminders: + +- Generate tests using the mandated tech stack, naming, and code style (AAA pattern, `fireEvent`, descriptive test names, cleans up mocks). +- Cover rendering, prop combinations, and edge cases by default; extend coverage for hooks, routing, async flows, and domain-specific components when applicable. +- Target >95% line and branch coverage and 100% function/statement coverage. +- Apply the project's mocking conventions for i18n, toast notifications, and Next.js utilities. + +Any suggestions from Copilot that conflict with `web/testing/testing.md` should be revised before acceptance. diff --git a/.windsurf/rules/testing.md b/.windsurf/rules/testing.md new file mode 100644 index 0000000000..64fec20cb8 --- /dev/null +++ b/.windsurf/rules/testing.md @@ -0,0 +1,5 @@ +# Windsurf Testing Rules + +- Use `web/testing/testing.md` as the single source of truth for frontend automated testing. +- Honor every requirement in that document when generating or accepting tests. +- When proposing or saving tests, re-read that document and follow every requirement. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdc414b047..20a7d6c6f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,8 @@ How we prioritize: For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly. +**Testing**: All React components must have comprehensive test coverage. See [web/testing/testing.md](https://github.com/langgenius/dify/blob/main/web/testing/testing.md) for the canonical frontend testing guidelines and follow every requirement described there. + #### Backend For setting up the backend service, kindly refer to our detailed [instructions](https://github.com/langgenius/dify/blob/main/api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly. diff --git a/web/README.md b/web/README.md index 6daf1e922e..1855ebc3b8 100644 --- a/web/README.md +++ b/web/README.md @@ -99,9 +99,9 @@ If your IDE is VSCode, rename `web/.vscode/settings.example.json` to `web/.vscod ## Test -We start to use [Jest](https://jestjs.io/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for Unit Testing. +We use [Jest](https://jestjs.io/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for Unit Testing. -You can create a test file with a suffix of `.spec` beside the file that to be tested. For example, if you want to test a file named `util.ts`. The test file name should be `util.spec.ts`. +**πŸ“– Complete Testing Guide**: See [web/testing/testing.md](./testing/testing.md) for detailed testing specifications, best practices, and examples. Run test: @@ -109,10 +109,22 @@ Run test: pnpm run test ``` -If you are not familiar with writing tests, here is some code to refer to: +### Example Code -- [classnames.spec.ts](./utils/classnames.spec.ts) -- [index.spec.tsx](./app/components/base/button/index.spec.tsx) +If you are not familiar with writing tests, refer to: + +- [classnames.spec.ts](./utils/classnames.spec.ts) - Utility function test example +- [index.spec.tsx](./app/components/base/button/index.spec.tsx) - Component test example + +### Analyze Component Complexity + +Before writing tests, use the script to analyze component complexity: + +```bash +pnpm analyze-component app/components/your-component/index.tsx +``` + +This will help you determine the testing strategy. See [web/testing/testing.md](./testing/testing.md) for details. ## Documentation diff --git a/web/__tests__/workflow-onboarding-integration.test.tsx b/web/__tests__/workflow-onboarding-integration.test.tsx index c1a922bb1f..ded8c75bd1 100644 --- a/web/__tests__/workflow-onboarding-integration.test.tsx +++ b/web/__tests__/workflow-onboarding-integration.test.tsx @@ -1,6 +1,24 @@ import { BlockEnum } from '@/app/components/workflow/types' import { useWorkflowStore } from '@/app/components/workflow/store' +// Type for mocked store +type MockWorkflowStore = { + showOnboarding: boolean + setShowOnboarding: jest.Mock + hasShownOnboarding: boolean + setHasShownOnboarding: jest.Mock + hasSelectedStartNode: boolean + setHasSelectedStartNode: jest.Mock + setShouldAutoOpenStartNodeSelector: jest.Mock + notInitialWorkflow: boolean +} + +// Type for mocked node +type MockNode = { + id: string + data: { type?: BlockEnum } +} + // Mock zustand store jest.mock('@/app/components/workflow/store') @@ -39,7 +57,7 @@ describe('Workflow Onboarding Integration Logic', () => { describe('Onboarding State Management', () => { it('should initialize onboarding state correctly', () => { - const store = useWorkflowStore() + const store = useWorkflowStore() as unknown as MockWorkflowStore expect(store.showOnboarding).toBe(false) expect(store.hasSelectedStartNode).toBe(false) @@ -47,7 +65,7 @@ describe('Workflow Onboarding Integration Logic', () => { }) it('should update onboarding visibility', () => { - const store = useWorkflowStore() + const store = useWorkflowStore() as unknown as MockWorkflowStore store.setShowOnboarding(true) expect(mockSetShowOnboarding).toHaveBeenCalledWith(true) @@ -57,14 +75,14 @@ describe('Workflow Onboarding Integration Logic', () => { }) it('should track node selection state', () => { - const store = useWorkflowStore() + const store = useWorkflowStore() as unknown as MockWorkflowStore store.setHasSelectedStartNode(true) expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true) }) it('should track onboarding show state', () => { - const store = useWorkflowStore() + const store = useWorkflowStore() as unknown as MockWorkflowStore store.setHasShownOnboarding(true) expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true) @@ -205,60 +223,44 @@ describe('Workflow Onboarding Integration Logic', () => { it('should auto-expand for TriggerSchedule in new workflow', () => { const shouldAutoOpenStartNodeSelector = true - const nodeType = BlockEnum.TriggerSchedule + const nodeType: BlockEnum = BlockEnum.TriggerSchedule const isChatMode = false + const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin] - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( - nodeType === BlockEnum.Start - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin - ) && !isChatMode + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode expect(shouldAutoExpand).toBe(true) }) it('should auto-expand for TriggerWebhook in new workflow', () => { const shouldAutoOpenStartNodeSelector = true - const nodeType = BlockEnum.TriggerWebhook + const nodeType: BlockEnum = BlockEnum.TriggerWebhook const isChatMode = false + const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin] - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( - nodeType === BlockEnum.Start - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin - ) && !isChatMode + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode expect(shouldAutoExpand).toBe(true) }) it('should auto-expand for TriggerPlugin in new workflow', () => { const shouldAutoOpenStartNodeSelector = true - const nodeType = BlockEnum.TriggerPlugin + const nodeType: BlockEnum = BlockEnum.TriggerPlugin const isChatMode = false + const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin] - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( - nodeType === BlockEnum.Start - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin - ) && !isChatMode + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode expect(shouldAutoExpand).toBe(true) }) it('should not auto-expand for non-trigger nodes', () => { const shouldAutoOpenStartNodeSelector = true - const nodeType = BlockEnum.LLM + const nodeType: BlockEnum = BlockEnum.LLM const isChatMode = false + const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin] - const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( - nodeType === BlockEnum.Start - || nodeType === BlockEnum.TriggerSchedule - || nodeType === BlockEnum.TriggerWebhook - || nodeType === BlockEnum.TriggerPlugin - ) && !isChatMode + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode expect(shouldAutoExpand).toBe(false) }) @@ -321,7 +323,7 @@ describe('Workflow Onboarding Integration Logic', () => { const nodeData = { type: BlockEnum.Start, title: 'Start' } // Simulate node creation logic from workflow-children.tsx - const createdNodeData = { + const createdNodeData: Record = { ...nodeData, // Note: 'selected: true' should NOT be added } @@ -334,7 +336,7 @@ describe('Workflow Onboarding Integration Logic', () => { const nodeData = { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' } const toolConfig = { webhook_url: 'https://example.com/webhook' } - const createdNodeData = { + const createdNodeData: Record = { ...nodeData, ...toolConfig, // Note: 'selected: true' should NOT be added @@ -352,7 +354,7 @@ describe('Workflow Onboarding Integration Logic', () => { config: { interval: '1h' }, } - const createdNodeData = { + const createdNodeData: Record = { ...nodeData, } @@ -495,7 +497,7 @@ describe('Workflow Onboarding Integration Logic', () => { BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, ] - const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data?.type)) + const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data?.type as BlockEnum)) const isEmpty = nodes.length === 0 || !hasStartNode expect(isEmpty).toBe(true) @@ -516,7 +518,7 @@ describe('Workflow Onboarding Integration Logic', () => { BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, ] - const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type)) + const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data.type as BlockEnum)) const isEmpty = nodes.length === 0 || !hasStartNode expect(isEmpty).toBe(true) @@ -536,7 +538,7 @@ describe('Workflow Onboarding Integration Logic', () => { BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, ] - const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type)) + const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data.type as BlockEnum)) const isEmpty = nodes.length === 0 || !hasStartNode expect(isEmpty).toBe(false) @@ -571,7 +573,7 @@ describe('Workflow Onboarding Integration Logic', () => { }) // Simulate the check logic with hasShownOnboarding = true - const store = useWorkflowStore() + const store = useWorkflowStore() as unknown as MockWorkflowStore const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow expect(shouldTrigger).toBe(false) @@ -605,7 +607,7 @@ describe('Workflow Onboarding Integration Logic', () => { }) // Simulate the check logic with notInitialWorkflow = true - const store = useWorkflowStore() + const store = useWorkflowStore() as unknown as MockWorkflowStore const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow expect(shouldTrigger).toBe(false) diff --git a/web/app/components/app/overview/__tests__/toggle-logic.test.ts b/web/app/components/app/overview/__tests__/toggle-logic.test.ts index 0c1e1ea0d3..1769ed3b9d 100644 --- a/web/app/components/app/overview/__tests__/toggle-logic.test.ts +++ b/web/app/components/app/overview/__tests__/toggle-logic.test.ts @@ -1,4 +1,5 @@ import { getWorkflowEntryNode } from '@/app/components/workflow/utils/workflow-entry' +import type { Node } from '@/app/components/workflow/types' // Mock the getWorkflowEntryNode function jest.mock('@/app/components/workflow/utils/workflow-entry', () => ({ @@ -7,6 +8,9 @@ jest.mock('@/app/components/workflow/utils/workflow-entry', () => ({ const mockGetWorkflowEntryNode = getWorkflowEntryNode as jest.MockedFunction +// Mock entry node for testing (truthy value) +const mockEntryNode = { id: 'start-node', data: { type: 'start' } } as Node + describe('App Card Toggle Logic', () => { beforeEach(() => { jest.clearAllMocks() @@ -39,7 +43,7 @@ describe('App Card Toggle Logic', () => { describe('Entry Node Detection Logic', () => { it('should disable toggle when workflow missing entry node', () => { - mockGetWorkflowEntryNode.mockReturnValue(false) + mockGetWorkflowEntryNode.mockReturnValue(undefined) const result = calculateToggleState( 'workflow', @@ -55,7 +59,7 @@ describe('App Card Toggle Logic', () => { }) it('should enable toggle when workflow has entry node', () => { - mockGetWorkflowEntryNode.mockReturnValue(true) + mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode) const result = calculateToggleState( 'workflow', @@ -101,7 +105,7 @@ describe('App Card Toggle Logic', () => { }) it('should consider published state when workflow has graph', () => { - mockGetWorkflowEntryNode.mockReturnValue(true) + mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode) const result = calculateToggleState( 'workflow', @@ -117,7 +121,7 @@ describe('App Card Toggle Logic', () => { describe('Permissions Logic', () => { it('should disable webapp toggle when user lacks editor permissions', () => { - mockGetWorkflowEntryNode.mockReturnValue(true) + mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode) const result = calculateToggleState( 'workflow', @@ -132,7 +136,7 @@ describe('App Card Toggle Logic', () => { }) it('should disable api toggle when user lacks manager permissions', () => { - mockGetWorkflowEntryNode.mockReturnValue(true) + mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode) const result = calculateToggleState( 'workflow', @@ -147,7 +151,7 @@ describe('App Card Toggle Logic', () => { }) it('should enable toggle when user has proper permissions', () => { - mockGetWorkflowEntryNode.mockReturnValue(true) + mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode) const webappResult = calculateToggleState( 'workflow', @@ -172,7 +176,7 @@ describe('App Card Toggle Logic', () => { describe('Combined Conditions Logic', () => { it('should handle multiple disable conditions correctly', () => { - mockGetWorkflowEntryNode.mockReturnValue(false) + mockGetWorkflowEntryNode.mockReturnValue(undefined) const result = calculateToggleState( 'workflow', @@ -191,7 +195,7 @@ describe('App Card Toggle Logic', () => { }) it('should enable when all conditions are satisfied', () => { - mockGetWorkflowEntryNode.mockReturnValue(true) + mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode) const result = calculateToggleState( 'workflow', diff --git a/web/app/components/base/drawer/index.spec.tsx b/web/app/components/base/drawer/index.spec.tsx new file mode 100644 index 0000000000..87289cd869 --- /dev/null +++ b/web/app/components/base/drawer/index.spec.tsx @@ -0,0 +1,675 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import Drawer from './index' +import type { IDrawerProps } from './index' + +// Capture dialog onClose for testing +let capturedDialogOnClose: (() => void) | null = null + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock @headlessui/react +jest.mock('@headlessui/react', () => ({ + Dialog: ({ children, open, onClose, className, unmount }: { + children: React.ReactNode + open: boolean + onClose: () => void + className: string + unmount: boolean + }) => { + capturedDialogOnClose = onClose + if (!open) + return null + return ( +
+ {children} +
+ ) + }, + DialogBackdrop: ({ children, className, onClick }: { + children?: React.ReactNode + className: string + onClick: () => void + }) => ( +
+ {children} +
+ ), + DialogTitle: ({ children, as: _as, className, ...props }: { + children: React.ReactNode + as?: string + className?: string + }) => ( +
+ {children} +
+ ), +})) + +// Mock XMarkIcon +jest.mock('@heroicons/react/24/outline', () => ({ + XMarkIcon: ({ className, onClick }: { className: string; onClick?: () => void }) => ( + + ), +})) + +// Helper function to render Drawer with default props +const defaultProps: IDrawerProps = { + isOpen: true, + onClose: jest.fn(), + children:
Content
, +} + +const renderDrawer = (props: Partial = {}) => { + const mergedProps = { ...defaultProps, ...props } + return render() +} + +describe('Drawer', () => { + beforeEach(() => { + jest.clearAllMocks() + capturedDialogOnClose = null + }) + + // Basic rendering tests + describe('Rendering', () => { + it('should render when isOpen is true', () => { + // Arrange & Act + renderDrawer({ isOpen: true }) + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByTestId('drawer-content')).toBeInTheDocument() + }) + + it('should not render when isOpen is false', () => { + // Arrange & Act + renderDrawer({ isOpen: false }) + + // Assert + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('should render children content', () => { + // Arrange + const childContent =

Custom Content

+ + // Act + renderDrawer({ children: childContent }) + + // Assert + expect(screen.getByTestId('custom-child')).toBeInTheDocument() + expect(screen.getByText('Custom Content')).toBeInTheDocument() + }) + }) + + // Title and description tests + describe('Title and Description', () => { + it('should render title when provided', () => { + // Arrange & Act + renderDrawer({ title: 'Test Title' }) + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should not render title when not provided', () => { + // Arrange & Act + renderDrawer({ title: '' }) + + // Assert + const titles = screen.queryAllByTestId('dialog-title') + const titleWithText = titles.find(el => el.textContent !== '') + expect(titleWithText).toBeUndefined() + }) + + it('should render description when provided', () => { + // Arrange & Act + renderDrawer({ description: 'Test Description' }) + + // Assert + expect(screen.getByText('Test Description')).toBeInTheDocument() + }) + + it('should not render description when not provided', () => { + // Arrange & Act + renderDrawer({ description: '' }) + + // Assert + expect(screen.queryByText('Test Description')).not.toBeInTheDocument() + }) + + it('should render both title and description together', () => { + // Arrange & Act + renderDrawer({ + title: 'My Title', + description: 'My Description', + }) + + // Assert + expect(screen.getByText('My Title')).toBeInTheDocument() + expect(screen.getByText('My Description')).toBeInTheDocument() + }) + }) + + // Close button tests + describe('Close Button', () => { + it('should render close icon when showClose is true', () => { + // Arrange & Act + renderDrawer({ showClose: true }) + + // Assert + expect(screen.getByTestId('close-icon')).toBeInTheDocument() + }) + + it('should not render close icon when showClose is false', () => { + // Arrange & Act + renderDrawer({ showClose: false }) + + // Assert + expect(screen.queryByTestId('close-icon')).not.toBeInTheDocument() + }) + + it('should not render close icon by default', () => { + // Arrange & Act + renderDrawer({}) + + // Assert + expect(screen.queryByTestId('close-icon')).not.toBeInTheDocument() + }) + + it('should call onClose when close icon is clicked', () => { + // Arrange + const onClose = jest.fn() + renderDrawer({ showClose: true, onClose }) + + // Act + fireEvent.click(screen.getByTestId('close-icon')) + + // Assert + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + // Backdrop/Mask tests + describe('Backdrop and Mask', () => { + it('should render backdrop when noOverlay is false', () => { + // Arrange & Act + renderDrawer({ noOverlay: false }) + + // Assert + expect(screen.getByTestId('dialog-backdrop')).toBeInTheDocument() + }) + + it('should not render backdrop when noOverlay is true', () => { + // Arrange & Act + renderDrawer({ noOverlay: true }) + + // Assert + expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument() + }) + + it('should apply mask background when mask is true', () => { + // Arrange & Act + renderDrawer({ mask: true }) + + // Assert + const backdrop = screen.getByTestId('dialog-backdrop') + expect(backdrop.className).toContain('bg-black/30') + }) + + it('should not apply mask background when mask is false', () => { + // Arrange & Act + renderDrawer({ mask: false }) + + // Assert + const backdrop = screen.getByTestId('dialog-backdrop') + expect(backdrop.className).not.toContain('bg-black/30') + }) + + it('should call onClose when backdrop is clicked and clickOutsideNotOpen is false', () => { + // Arrange + const onClose = jest.fn() + renderDrawer({ onClose, clickOutsideNotOpen: false }) + + // Act + fireEvent.click(screen.getByTestId('dialog-backdrop')) + + // Assert + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should not call onClose when backdrop is clicked and clickOutsideNotOpen is true', () => { + // Arrange + const onClose = jest.fn() + renderDrawer({ onClose, clickOutsideNotOpen: true }) + + // Act + fireEvent.click(screen.getByTestId('dialog-backdrop')) + + // Assert + expect(onClose).not.toHaveBeenCalled() + }) + }) + + // Footer tests + describe('Footer', () => { + it('should render default footer with cancel and save buttons when footer is undefined', () => { + // Arrange & Act + renderDrawer({ footer: undefined }) + + // Assert + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() + expect(screen.getByText('common.operation.save')).toBeInTheDocument() + }) + + it('should not render footer when footer is null', () => { + // Arrange & Act + renderDrawer({ footer: null }) + + // Assert + expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.save')).not.toBeInTheDocument() + }) + + it('should render custom footer when provided', () => { + // Arrange + const customFooter =
Custom Footer
+ + // Act + renderDrawer({ footer: customFooter }) + + // Assert + expect(screen.getByTestId('custom-footer')).toBeInTheDocument() + expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument() + }) + + it('should call onCancel when cancel button is clicked', () => { + // Arrange + const onCancel = jest.fn() + renderDrawer({ onCancel }) + + // Act + const cancelButton = screen.getByText('common.operation.cancel') + fireEvent.click(cancelButton) + + // Assert + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should call onOk when save button is clicked', () => { + // Arrange + const onOk = jest.fn() + renderDrawer({ onOk }) + + // Act + const saveButton = screen.getByText('common.operation.save') + fireEvent.click(saveButton) + + // Assert + expect(onOk).toHaveBeenCalledTimes(1) + }) + + it('should not throw when onCancel is not provided and cancel is clicked', () => { + // Arrange + renderDrawer({ onCancel: undefined }) + + // Act & Assert + expect(() => { + fireEvent.click(screen.getByText('common.operation.cancel')) + }).not.toThrow() + }) + + it('should not throw when onOk is not provided and save is clicked', () => { + // Arrange + renderDrawer({ onOk: undefined }) + + // Act & Assert + expect(() => { + fireEvent.click(screen.getByText('common.operation.save')) + }).not.toThrow() + }) + }) + + // Custom className tests + describe('Custom ClassNames', () => { + it('should apply custom dialogClassName', () => { + // Arrange & Act + renderDrawer({ dialogClassName: 'custom-dialog-class' }) + + // Assert + expect(screen.getByRole('dialog').className).toContain('custom-dialog-class') + }) + + it('should apply custom dialogBackdropClassName', () => { + // Arrange & Act + renderDrawer({ dialogBackdropClassName: 'custom-backdrop-class' }) + + // Assert + expect(screen.getByTestId('dialog-backdrop').className).toContain('custom-backdrop-class') + }) + + it('should apply custom containerClassName', () => { + // Arrange & Act + const { container } = renderDrawer({ containerClassName: 'custom-container-class' }) + + // Assert + const containerDiv = container.querySelector('.custom-container-class') + expect(containerDiv).toBeInTheDocument() + }) + + it('should apply custom panelClassName', () => { + // Arrange & Act + const { container } = renderDrawer({ panelClassName: 'custom-panel-class' }) + + // Assert + const panelDiv = container.querySelector('.custom-panel-class') + expect(panelDiv).toBeInTheDocument() + }) + }) + + // Position tests + describe('Position', () => { + it('should apply center position class when positionCenter is true', () => { + // Arrange & Act + const { container } = renderDrawer({ positionCenter: true }) + + // Assert + const containerDiv = container.querySelector('.\\!justify-center') + expect(containerDiv).toBeInTheDocument() + }) + + it('should use end position by default when positionCenter is false', () => { + // Arrange & Act + const { container } = renderDrawer({ positionCenter: false }) + + // Assert + const containerDiv = container.querySelector('.justify-end') + expect(containerDiv).toBeInTheDocument() + }) + }) + + // Unmount prop tests + describe('Unmount Prop', () => { + it('should pass unmount prop to Dialog component', () => { + // Arrange & Act + renderDrawer({ unmount: true }) + + // Assert + expect(screen.getByTestId('dialog').getAttribute('data-unmount')).toBe('true') + }) + + it('should default unmount to false', () => { + // Arrange & Act + renderDrawer({}) + + // Assert + expect(screen.getByTestId('dialog').getAttribute('data-unmount')).toBe('false') + }) + }) + + // Edge cases + describe('Edge Cases', () => { + it('should handle empty string title', () => { + // Arrange & Act + renderDrawer({ title: '' }) + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle empty string description', () => { + // Arrange & Act + renderDrawer({ description: '' }) + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle special characters in title', () => { + // Arrange + const specialTitle = '' + + // Act + renderDrawer({ title: specialTitle }) + + // Assert + expect(screen.getByText(specialTitle)).toBeInTheDocument() + }) + + it('should handle very long title', () => { + // Arrange + const longTitle = 'A'.repeat(500) + + // Act + renderDrawer({ title: longTitle }) + + // Assert + expect(screen.getByText(longTitle)).toBeInTheDocument() + }) + + it('should handle complex children with multiple elements', () => { + // Arrange + const complexChildren = ( +
+

Heading

+

Paragraph

+ + +
+ ) + + // Act + renderDrawer({ children: complexChildren }) + + // Assert + expect(screen.getByTestId('complex-children')).toBeInTheDocument() + expect(screen.getByText('Heading')).toBeInTheDocument() + expect(screen.getByText('Paragraph')).toBeInTheDocument() + expect(screen.getByTestId('input-element')).toBeInTheDocument() + expect(screen.getByTestId('button-element')).toBeInTheDocument() + }) + + it('should handle null children gracefully', () => { + // Arrange & Act + renderDrawer({ children: null as unknown as React.ReactNode }) + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle undefined footer without crashing', () => { + // Arrange & Act + renderDrawer({ footer: undefined }) + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle rapid open/close toggles', () => { + // Arrange + const onClose = jest.fn() + const { rerender } = render( + +
Content
+
, + ) + + // Act - Toggle multiple times + rerender( + +
Content
+
, + ) + rerender( + +
Content
+
, + ) + rerender( + +
Content
+
, + ) + + // Assert + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + }) + + // Combined prop scenarios + describe('Combined Prop Scenarios', () => { + it('should render with all optional props', () => { + // Arrange & Act + renderDrawer({ + title: 'Full Feature Title', + description: 'Full Feature Description', + dialogClassName: 'custom-dialog', + dialogBackdropClassName: 'custom-backdrop', + containerClassName: 'custom-container', + panelClassName: 'custom-panel', + showClose: true, + mask: true, + positionCenter: true, + unmount: true, + noOverlay: false, + footer:
Footer
, + }) + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Full Feature Title')).toBeInTheDocument() + expect(screen.getByText('Full Feature Description')).toBeInTheDocument() + expect(screen.getByTestId('close-icon')).toBeInTheDocument() + expect(screen.getByTestId('custom-full-footer')).toBeInTheDocument() + }) + + it('should render minimal drawer with only required props', () => { + // Arrange + const minimalProps: IDrawerProps = { + isOpen: true, + onClose: jest.fn(), + children:
Minimal Content
, + } + + // Act + render() + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Minimal Content')).toBeInTheDocument() + }) + + it('should handle showClose with title simultaneously', () => { + // Arrange & Act + renderDrawer({ + title: 'Title with Close', + showClose: true, + }) + + // Assert + expect(screen.getByText('Title with Close')).toBeInTheDocument() + expect(screen.getByTestId('close-icon')).toBeInTheDocument() + }) + + it('should handle noOverlay with clickOutsideNotOpen', () => { + // Arrange + const onClose = jest.fn() + + // Act + renderDrawer({ + noOverlay: true, + clickOutsideNotOpen: true, + onClose, + }) + + // Assert - backdrop should not exist + expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument() + }) + }) + + // Dialog onClose callback tests (e.g., Escape key) + describe('Dialog onClose Callback', () => { + it('should call onClose when Dialog triggers close and clickOutsideNotOpen is false', () => { + // Arrange + const onClose = jest.fn() + renderDrawer({ onClose, clickOutsideNotOpen: false }) + + // Act - Simulate Dialog's onClose (e.g., pressing Escape) + capturedDialogOnClose?.() + + // Assert + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should not call onClose when Dialog triggers close and clickOutsideNotOpen is true', () => { + // Arrange + const onClose = jest.fn() + renderDrawer({ onClose, clickOutsideNotOpen: true }) + + // Act - Simulate Dialog's onClose (e.g., pressing Escape) + capturedDialogOnClose?.() + + // Assert + expect(onClose).not.toHaveBeenCalled() + }) + + it('should call onClose by default when Dialog triggers close', () => { + // Arrange + const onClose = jest.fn() + renderDrawer({ onClose }) + + // Act + capturedDialogOnClose?.() + + // Assert + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + // Event handler interaction tests + describe('Event Handler Interactions', () => { + it('should handle multiple consecutive close icon clicks', () => { + // Arrange + const onClose = jest.fn() + renderDrawer({ showClose: true, onClose }) + + // Act + const closeIcon = screen.getByTestId('close-icon') + fireEvent.click(closeIcon) + fireEvent.click(closeIcon) + fireEvent.click(closeIcon) + + // Assert + expect(onClose).toHaveBeenCalledTimes(3) + }) + + it('should handle onCancel and onOk being the same function', () => { + // Arrange + const handler = jest.fn() + renderDrawer({ onCancel: handler, onOk: handler }) + + // Act + fireEvent.click(screen.getByText('common.operation.cancel')) + fireEvent.click(screen.getByText('common.operation.save')) + + // Assert + expect(handler).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx b/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx index dc208047cb..d6a717e732 100644 --- a/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx +++ b/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx @@ -2,10 +2,12 @@ import React, { useCallback } from 'react' import { act, render } from '@testing-library/react' import { useTriggerStatusStore } from '../store/trigger-status' import { isTriggerNode } from '../types' +import type { BlockEnum } from '../types' import type { EntryNodeStatus } from '../store/trigger-status' -// Mock the isTriggerNode function +// Mock the isTriggerNode function while preserving BlockEnum jest.mock('../types', () => ({ + ...jest.requireActual('../types'), isTriggerNode: jest.fn(), })) @@ -17,7 +19,7 @@ const TestTriggerNode: React.FC<{ nodeType: string }> = ({ nodeId, nodeType }) => { const triggerStatus = useTriggerStatusStore(state => - mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', + mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', ) return ( @@ -271,7 +273,7 @@ describe('Trigger Status Synchronization Integration', () => { nodeType: string }> = ({ nodeId, nodeType }) => { const triggerStatusSelector = useCallback((state: any) => - mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', + mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', [nodeId, nodeType], ) const triggerStatus = useTriggerStatusStore(triggerStatusSelector) @@ -313,7 +315,7 @@ describe('Trigger Status Synchronization Integration', () => { const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => { const triggerStatusSelector = useCallback((state: any) => - mockIsTriggerNode(nodeType) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled', + mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled', ['test-node', nodeType], // Dependencies should match implementation ) const status = useTriggerStatusStore(triggerStatusSelector) diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts index 1b7d374d33..f9930ffeb0 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts @@ -1,6 +1,7 @@ import { isValidCronExpression, parseCronExpression } from './cron-parser' import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator' import type { ScheduleTriggerNodeType } from '../types' +import { BlockEnum } from '../../../types' // Comprehensive integration tests for cron-parser and execution-time-calculator compatibility describe('cron-parser + execution-time-calculator integration', () => { @@ -14,13 +15,13 @@ describe('cron-parser + execution-time-calculator integration', () => { }) const createCronData = (overrides: Partial = {}): ScheduleTriggerNodeType => ({ - id: 'test-cron', - type: 'schedule-trigger', + type: BlockEnum.TriggerSchedule, + title: 'test-schedule', mode: 'cron', frequency: 'daily', timezone: 'UTC', ...overrides, - }) + } as ScheduleTriggerNodeType) describe('backward compatibility validation', () => { it('maintains exact behavior for legacy cron expressions', () => { diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx index 77bab5e3bd..f7e65bac6f 100644 --- a/web/context/modal-context.test.tsx +++ b/web/context/modal-context.test.tsx @@ -43,13 +43,18 @@ jest.mock('@/app/components/billing/trigger-events-limit-modal', () => ({ })) type DefaultPlanShape = typeof defaultPlan +type ResetShape = { + apiRateLimit: number | null + triggerEvents: number | null +} +type PlanShape = Omit & { reset: ResetShape } type PlanOverrides = Partial> & { usage?: Partial total?: Partial - reset?: Partial + reset?: Partial } -const createPlan = (overrides: PlanOverrides = {}): DefaultPlanShape => ({ +const createPlan = (overrides: PlanOverrides = {}): PlanShape => ({ ...defaultPlan, ...overrides, usage: { diff --git a/web/package.json b/web/package.json index 3c4b5cd98e..9d597a80fc 100644 --- a/web/package.json +++ b/web/package.json @@ -37,6 +37,7 @@ "check:i18n-types": "node ./i18n-config/check-i18n-sync.js", "test": "jest", "test:watch": "jest --watch", + "analyze-component": "node testing/analyze-component.js", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "preinstall": "npx only-allow pnpm", diff --git a/web/testing/analyze-component.js b/web/testing/analyze-component.js new file mode 100755 index 0000000000..21e1b5adba --- /dev/null +++ b/web/testing/analyze-component.js @@ -0,0 +1,1057 @@ +#!/usr/bin/env node + +const fs = require('node:fs') +const path = require('node:path') + +// ============================================================================ +// Simple Analyzer +// ============================================================================ + +class ComponentAnalyzer { + analyze(code, filePath, absolutePath) { + const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath) + const fileName = path.basename(filePath, path.extname(filePath)) + const lineCount = code.split('\n').length + const complexity = this.calculateComplexity(code, lineCount) + + // Count usage references (may take a few seconds) + const usageCount = this.countUsageReferences(filePath, resolvedPath) + + // Calculate test priority + const priority = this.calculateTestPriority(complexity, usageCount) + + return { + name: fileName.charAt(0).toUpperCase() + fileName.slice(1), + path: filePath, + type: this.detectType(filePath, code), + hasProps: code.includes('Props') || code.includes('interface'), + hasState: code.includes('useState') || code.includes('useReducer'), + hasEffects: code.includes('useEffect'), + hasCallbacks: code.includes('useCallback'), + hasMemo: code.includes('useMemo'), + hasEvents: /on[A-Z]\w+/.test(code), + hasRouter: code.includes('useRouter') || code.includes('usePathname'), + hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'), + hasForwardRef: code.includes('forwardRef'), + hasComponentMemo: /React\.memo|memo\(/.test(code), + hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code), + hasPortal: code.includes('createPortal'), + hasImperativeHandle: code.includes('useImperativeHandle'), + hasSWR: code.includes('useSWR'), + hasReactQuery: code.includes('useQuery') || code.includes('useMutation'), + hasAhooks: code.includes("from 'ahooks'"), + complexity, + lineCount, + usageCount, + priority, + } + } + + detectType(filePath, code) { + const normalizedPath = filePath.replace(/\\/g, '/') + if (normalizedPath.includes('/hooks/')) return 'hook' + if (normalizedPath.includes('/utils/')) return 'util' + if (/\/page\.(t|j)sx?$/.test(normalizedPath)) return 'page' + if (/\/layout\.(t|j)sx?$/.test(normalizedPath)) return 'layout' + if (/\/providers?\//.test(normalizedPath)) return 'provider' + // Dify-specific types + if (normalizedPath.includes('/components/base/')) return 'base-component' + if (normalizedPath.includes('/context/')) return 'context' + if (normalizedPath.includes('/store/')) return 'store' + if (normalizedPath.includes('/service/')) return 'service' + if (/use[A-Z]\w+/.test(code)) return 'component' + return 'component' + } + + /** + * Calculate component complexity score + * Based on Cognitive Complexity + React-specific metrics + * + * Score Ranges: + * 0-10: 🟒 Simple (5-10 min to test) + * 11-30: 🟑 Medium (15-30 min to test) + * 31-50: 🟠 Complex (30-60 min to test) + * 51+: πŸ”΄ Very Complex (60+ min, consider splitting) + */ + calculateComplexity(code, lineCount) { + let score = 0 + + const count = pattern => this.countMatches(code, pattern) + + // ===== React Hooks (State Management Complexity) ===== + const stateHooks = count(/useState/g) + const reducerHooks = count(/useReducer/g) + const effectHooks = count(/useEffect/g) + const callbackHooks = count(/useCallback/g) + const memoHooks = count(/useMemo/g) + const refHooks = count(/useRef/g) + const imperativeHandleHooks = count(/useImperativeHandle/g) + + const builtinHooks = stateHooks + reducerHooks + effectHooks + + callbackHooks + memoHooks + refHooks + imperativeHandleHooks + const totalHooks = count(/use[A-Z]\w+/g) + const customHooks = Math.max(0, totalHooks - builtinHooks) + + score += stateHooks * 5 // Each state +5 (need to test state changes) + score += reducerHooks * 6 // Each reducer +6 (complex state management) + score += effectHooks * 6 // Each effect +6 (need to test deps & cleanup) + score += callbackHooks * 2 // Each callback +2 + score += memoHooks * 2 // Each memo +2 + score += refHooks * 1 // Each ref +1 + score += imperativeHandleHooks * 4 // Each imperative handle +4 (exposes methods) + score += customHooks * 3 // Each custom hook +3 + + // ===== Control Flow Complexity (Cyclomatic Complexity) ===== + score += count(/if\s*\(/g) * 2 // if statement + score += count(/else\s+if/g) * 2 // else if + score += count(/\?\s*[^:]+\s*:/g) * 1 // ternary operator + score += count(/switch\s*\(/g) * 3 // switch + score += count(/case\s+/g) * 1 // case branch + score += count(/&&/g) * 1 // logical AND + score += count(/\|\|/g) * 1 // logical OR + score += count(/\?\?/g) * 1 // nullish coalescing + + // ===== Loop Complexity ===== + score += count(/\.map\(/g) * 2 // map + score += count(/\.filter\(/g) * 1 // filter + score += count(/\.reduce\(/g) * 3 // reduce (complex) + score += count(/for\s*\(/g) * 2 // for loop + score += count(/while\s*\(/g) * 3 // while loop + + // ===== Props and Events Complexity ===== + // Count unique props from interface/type definitions only (avoid duplicates) + const propsCount = this.countUniqueProps(code) + score += Math.floor(propsCount / 2) // Every 2 props +1 + + // Count unique event handler names (avoid duplicates from type defs, params, usage) + const uniqueEventHandlers = this.countUniqueEventHandlers(code) + score += uniqueEventHandlers * 2 // Each unique event handler +2 + + // ===== API Call Complexity ===== + score += count(/fetch\(/g) * 4 // fetch + score += count(/axios\./g) * 4 // axios + score += count(/useSWR/g) * 4 // SWR + score += count(/useQuery/g) * 4 // React Query + score += count(/\.then\(/g) * 2 // Promise + score += count(/await\s+/g) * 2 // async/await + + // ===== Third-party Library Integration ===== + // Only count complex UI libraries that require integration testing + // Data fetching libs (swr, react-query, ahooks) don't add complexity + // because they are already well-tested; we only need to mock them + const complexUILibs = [ + { pattern: /reactflow|ReactFlow/, weight: 15 }, + { pattern: /@monaco-editor/, weight: 12 }, + { pattern: /echarts/, weight: 8 }, + { pattern: /lexical/, weight: 10 }, + ] + + complexUILibs.forEach(({ pattern, weight }) => { + if (pattern.test(code)) score += weight + }) + + // ===== Code Size Complexity ===== + if (lineCount > 500) score += 10 + else if (lineCount > 300) score += 6 + else if (lineCount > 150) score += 3 + + // ===== Nesting Depth (deep nesting reduces readability) ===== + const maxNesting = this.calculateNestingDepth(code) + score += Math.max(0, (maxNesting - 3)) * 2 // Over 3 levels, +2 per level + + // ===== Context and Global State ===== + score += count(/useContext/g) * 3 + score += count(/useStore|useAppStore/g) * 4 + score += count(/zustand|redux/g) * 3 + + // ===== React Advanced Features ===== + score += count(/React\.memo|memo\(/g) * 2 // Component memoization + score += count(/forwardRef/g) * 3 // Ref forwarding + score += count(/Suspense/g) * 4 // Suspense boundaries + score += count(/\blazy\(/g) * 3 // Lazy loading + score += count(/createPortal/g) * 3 // Portal rendering + + return Math.min(score, 100) // Max 100 points + } + + /** + * Calculate maximum nesting depth + */ + calculateNestingDepth(code) { + let maxDepth = 0 + let currentDepth = 0 + let inString = false + let stringChar = '' + let escapeNext = false + let inSingleLineComment = false + let inMultiLineComment = false + + for (let i = 0; i < code.length; i++) { + const char = code[i] + const nextChar = code[i + 1] + + if (inSingleLineComment) { + if (char === '\n') inSingleLineComment = false + continue + } + + if (inMultiLineComment) { + if (char === '*' && nextChar === '/') { + inMultiLineComment = false + i++ + } + continue + } + + if (inString) { + if (escapeNext) { + escapeNext = false + continue + } + + if (char === '\\') { + escapeNext = true + continue + } + + if (char === stringChar) { + inString = false + stringChar = '' + } + continue + } + + if (char === '/' && nextChar === '/') { + inSingleLineComment = true + i++ + continue + } + + if (char === '/' && nextChar === '*') { + inMultiLineComment = true + i++ + continue + } + + if (char === '"' || char === '\'' || char === '`') { + inString = true + stringChar = char + continue + } + + if (char === '{') { + currentDepth++ + maxDepth = Math.max(maxDepth, currentDepth) + continue + } + + if (char === '}') { + currentDepth = Math.max(currentDepth - 1, 0) + } + } + + return maxDepth + } + + /** + * Count how many times a component is referenced in the codebase + * Scans TypeScript sources for import statements referencing the component + */ + countUsageReferences(filePath, absolutePath) { + try { + const resolvedComponentPath = absolutePath ?? path.resolve(process.cwd(), filePath) + const fileName = path.basename(resolvedComponentPath, path.extname(resolvedComponentPath)) + + let searchName = fileName + if (fileName === 'index') { + const parentDir = path.dirname(resolvedComponentPath) + searchName = path.basename(parentDir) + } + + if (!searchName) return 0 + + const searchRoots = this.collectSearchRoots(resolvedComponentPath) + if (searchRoots.length === 0) return 0 + + const escapedName = ComponentAnalyzer.escapeRegExp(searchName) + const patterns = [ + new RegExp(`from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), + new RegExp(`import\\s*\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), + new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), + new RegExp(`require\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), + ] + + const visited = new Set() + let usageCount = 0 + + const stack = [...searchRoots] + while (stack.length > 0) { + const currentDir = stack.pop() + if (!currentDir || visited.has(currentDir)) continue + visited.add(currentDir) + + const entries = fs.readdirSync(currentDir, { withFileTypes: true }) + + entries.forEach(entry => { + const entryPath = path.join(currentDir, entry.name) + + if (entry.isDirectory()) { + if (this.shouldSkipDir(entry.name)) return + stack.push(entryPath) + return + } + + if (!this.shouldInspectFile(entry.name)) return + + const normalizedEntryPath = path.resolve(entryPath) + if (normalizedEntryPath === path.resolve(resolvedComponentPath)) return + + const source = fs.readFileSync(entryPath, 'utf-8') + if (!source.includes(searchName)) return + + if (patterns.some(pattern => { + pattern.lastIndex = 0 + return pattern.test(source) + })) { + usageCount += 1 + } + }) + } + + return usageCount + } + catch { + // If command fails, return 0 + return 0 + } + } + + collectSearchRoots(resolvedComponentPath) { + const roots = new Set() + + let currentDir = path.dirname(resolvedComponentPath) + const workspaceRoot = process.cwd() + + while (currentDir && currentDir !== path.dirname(currentDir)) { + if (path.basename(currentDir) === 'app') { + roots.add(currentDir) + break + } + + if (currentDir === workspaceRoot) break + currentDir = path.dirname(currentDir) + } + + const fallbackRoots = [ + path.join(workspaceRoot, 'app'), + path.join(workspaceRoot, 'web', 'app'), + path.join(workspaceRoot, 'src'), + ] + + fallbackRoots.forEach(root => { + if (fs.existsSync(root) && fs.statSync(root).isDirectory()) roots.add(root) + }) + + return Array.from(roots) + } + + shouldSkipDir(dirName) { + const normalized = dirName.toLowerCase() + return [ + 'node_modules', + '.git', + '.next', + 'dist', + 'out', + 'coverage', + 'build', + '__tests__', + '__mocks__', + ].includes(normalized) + } + + shouldInspectFile(fileName) { + const normalized = fileName.toLowerCase() + if (!(/\.(ts|tsx)$/i.test(fileName))) return false + if (normalized.endsWith('.d.ts')) return false + if (/\.(spec|test)\.(ts|tsx)$/.test(normalized)) return false + if (normalized.endsWith('.stories.tsx')) return false + return true + } + + countMatches(code, pattern) { + const matches = code.match(pattern) + return matches ? matches.length : 0 + } + + /** + * Count unique props from interface/type definitions + * Only counts props defined in type/interface blocks, not usage + */ + countUniqueProps(code) { + const uniqueProps = new Set() + + // Match interface or type definition blocks + const typeBlockPattern = /(?:interface|type)\s+\w*Props[^{]*\{([^}]+)\}/g + let match + + while ((match = typeBlockPattern.exec(code)) !== null) { + const blockContent = match[1] + // Match prop names (word followed by optional ? and :) + const propPattern = /(\w+)\s*\??:/g + let propMatch + while ((propMatch = propPattern.exec(blockContent)) !== null) { + uniqueProps.add(propMatch[1]) + } + } + + return Math.min(uniqueProps.size, 20) // Max 20 props + } + + /** + * Count unique event handler names (on[A-Z]...) + * Avoids counting the same handler multiple times across type defs, params, and usage + */ + countUniqueEventHandlers(code) { + const uniqueHandlers = new Set() + const pattern = /on[A-Z]\w+/g + let match + + while ((match = pattern.exec(code)) !== null) { + uniqueHandlers.add(match[0]) + } + + return uniqueHandlers.size + } + + static escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } + + /** + * Calculate test priority based on complexity and usage + * + * Priority Score = Complexity Score + Usage Score + * - Complexity: 0-100 + * - Usage: 0-50 + * - Total: 0-150 + * + * Priority Levels: + * - 0-30: Low + * - 31-70: Medium + * - 71-100: High + * - 100+: Critical + */ + calculateTestPriority(complexity, usageCount) { + const complexityScore = complexity + + // Usage score calculation + let usageScore + if (usageCount === 0) + usageScore = 0 + else if (usageCount <= 5) + usageScore = 10 + else if (usageCount <= 20) + usageScore = 20 + else if (usageCount <= 50) + usageScore = 35 + else + usageScore = 50 + + const totalScore = complexityScore + usageScore + + return { + score: totalScore, + level: this.getPriorityLevel(totalScore), + usageScore, + complexityScore, + } + } + + /** + * Get priority level based on score + */ + getPriorityLevel(score) { + if (score > 100) return 'πŸ”΄ CRITICAL' + if (score > 70) return '🟠 HIGH' + if (score > 30) return '🟑 MEDIUM' + return '🟒 LOW' + } +} + +// ============================================================================ +// Prompt Builder for AI Assistants +// ============================================================================ + +class TestPromptBuilder { + build(analysis) { + const testPath = analysis.path.replace(/\.tsx?$/, '.spec.tsx') + + return ` +╔════════════════════════════════════════════════════════════════════════════╗ +β•‘ πŸ“‹ GENERATE TEST FOR DIFY COMPONENT β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + +πŸ“ Component: ${analysis.name} +πŸ“‚ Path: ${analysis.path} +🎯 Test File: ${testPath} + +πŸ“Š Component Analysis: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Type: ${analysis.type} +Complexity: ${analysis.complexity} ${this.getComplexityLevel(analysis.complexity)} +Lines: ${analysis.lineCount} +Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''} +Test Priority: ${analysis.priority.score} ${analysis.priority.level} + +Features Detected: + ${analysis.hasProps ? 'βœ“' : 'βœ—'} Props/TypeScript interfaces + ${analysis.hasState ? 'βœ“' : 'βœ—'} Local state (useState/useReducer) + ${analysis.hasEffects ? 'βœ“' : 'βœ—'} Side effects (useEffect) + ${analysis.hasCallbacks ? 'βœ“' : 'βœ—'} Callbacks (useCallback) + ${analysis.hasMemo ? 'βœ“' : 'βœ—'} Memoization (useMemo) + ${analysis.hasEvents ? 'βœ“' : 'βœ—'} Event handlers + ${analysis.hasRouter ? 'βœ“' : 'βœ—'} Next.js routing + ${analysis.hasAPI ? 'βœ“' : 'βœ—'} API calls + ${analysis.hasSWR ? 'βœ“' : 'βœ—'} SWR data fetching + ${analysis.hasReactQuery ? 'βœ“' : 'βœ—'} React Query + ${analysis.hasAhooks ? 'βœ“' : 'βœ—'} ahooks + ${analysis.hasForwardRef ? 'βœ“' : 'βœ—'} Ref forwarding (forwardRef) + ${analysis.hasComponentMemo ? 'βœ“' : 'βœ—'} Component memoization (React.memo) + ${analysis.hasImperativeHandle ? 'βœ“' : 'βœ—'} Imperative handle +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +πŸ“ TASK: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Please generate a comprehensive test file for this component at: + ${testPath} + +The component is located at: + ${analysis.path} + +${this.getSpecificGuidelines(analysis)} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +πŸ“‹ PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT): +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Generate a comprehensive test file for @${analysis.path} + +Including but not limited to: +${this.buildFocusPoints(analysis)} + +Create the test file at: ${testPath} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +` + } + + getComplexityLevel(score) { + // Aligned with testing.md guidelines + if (score <= 10) return '🟒 Simple' + if (score <= 30) return '🟑 Medium' + if (score <= 50) return '🟠 Complex' + return 'πŸ”΄ Very Complex' + } + + buildFocusPoints(analysis) { + const points = [] + + if (analysis.hasState) points.push('- Testing state management and updates') + if (analysis.hasEffects) points.push('- Testing side effects and cleanup') + if (analysis.hasCallbacks) points.push('- Testing callback stability and memoization') + if (analysis.hasMemo) points.push('- Testing memoization logic and dependencies') + if (analysis.hasEvents) points.push('- Testing user interactions and event handlers') + if (analysis.hasRouter) points.push('- Mocking Next.js router hooks') + if (analysis.hasAPI) points.push('- Mocking API calls') + if (analysis.hasForwardRef) points.push('- Testing ref forwarding behavior') + if (analysis.hasComponentMemo) points.push('- Testing component memoization') + if (analysis.hasSuspense) points.push('- Testing Suspense boundaries and lazy loading') + if (analysis.hasPortal) points.push('- Testing Portal rendering') + if (analysis.hasImperativeHandle) points.push('- Testing imperative handle methods') + points.push('- Testing edge cases and error handling') + points.push('- Testing all prop variations') + + return points.join('\n') + } + + getSpecificGuidelines(analysis) { + const guidelines = [] + + // ===== Test Priority Guidance ===== + if (analysis.priority.level.includes('CRITICAL')) { + guidelines.push('πŸ”΄ CRITICAL PRIORITY component:') + guidelines.push(` - Used in ${analysis.usageCount} places across the codebase`) + guidelines.push(' - Changes will have WIDE impact') + guidelines.push(' - Require comprehensive test coverage') + guidelines.push(' - Add regression tests for all use cases') + guidelines.push(' - Consider integration tests with dependent components') + } + else if (analysis.usageCount > 50) { + guidelines.push('🟠 VERY HIGH USAGE component:') + guidelines.push(` - Referenced ${analysis.usageCount} times in the codebase`) + guidelines.push(' - Changes may affect many parts of the application') + guidelines.push(' - Comprehensive test coverage is CRITICAL') + guidelines.push(' - Add tests for all common usage patterns') + guidelines.push(' - Consider regression tests') + } + else if (analysis.usageCount > 20) { + guidelines.push('🟑 HIGH USAGE component:') + guidelines.push(` - Referenced ${analysis.usageCount} times in the codebase`) + guidelines.push(' - Test coverage is important to prevent widespread bugs') + guidelines.push(' - Add tests for common usage patterns') + } + + // ===== Complexity Warning ===== + if (analysis.complexity > 50) { + guidelines.push('πŸ”΄ VERY COMPLEX component detected. Consider:') + guidelines.push(' - Splitting component into smaller pieces before testing') + guidelines.push(' - Creating integration tests for complex workflows') + guidelines.push(' - Using test.each() for data-driven tests') + guidelines.push(' - Adding performance benchmarks') + } + else if (analysis.complexity > 30) { + guidelines.push('⚠️ This is a COMPLEX component. Consider:') + guidelines.push(' - Breaking tests into multiple describe blocks') + guidelines.push(' - Testing integration scenarios') + guidelines.push(' - Grouping related test cases') + } + + // ===== State Management ===== + if (analysis.hasState && analysis.hasEffects) { + guidelines.push('πŸ”„ State + Effects detected:') + guidelines.push(' - Test state initialization and updates') + guidelines.push(' - Test useEffect dependencies array') + guidelines.push(' - Test cleanup functions (return from useEffect)') + guidelines.push(' - Use waitFor() for async state changes') + } + else if (analysis.hasState) { + guidelines.push('πŸ“Š State management detected:') + guidelines.push(' - Test initial state values') + guidelines.push(' - Test all state transitions') + guidelines.push(' - Test state reset/cleanup scenarios') + } + else if (analysis.hasEffects) { + guidelines.push('⚑ Side effects detected:') + guidelines.push(' - Test effect execution conditions') + guidelines.push(' - Verify dependencies array correctness') + guidelines.push(' - Test cleanup on unmount') + } + + // ===== Performance Optimization ===== + if (analysis.hasCallbacks || analysis.hasMemo || analysis.hasComponentMemo) { + const features = [] + if (analysis.hasCallbacks) features.push('useCallback') + if (analysis.hasMemo) features.push('useMemo') + if (analysis.hasComponentMemo) features.push('React.memo') + + guidelines.push(`πŸš€ Performance optimization (${features.join(', ')}):`) + guidelines.push(' - Verify callbacks maintain referential equality') + guidelines.push(' - Test memoization dependencies') + guidelines.push(' - Ensure expensive computations are cached') + if (analysis.hasComponentMemo) { + guidelines.push(' - Test component re-render behavior with prop changes') + } + } + + // ===== Ref Forwarding ===== + if (analysis.hasForwardRef || analysis.hasImperativeHandle) { + guidelines.push('πŸ”— Ref forwarding detected:') + guidelines.push(' - Test ref attachment to DOM elements') + if (analysis.hasImperativeHandle) { + guidelines.push(' - Test all exposed imperative methods') + guidelines.push(' - Verify method behavior with different ref types') + } + } + + // ===== Suspense and Lazy Loading ===== + if (analysis.hasSuspense) { + guidelines.push('⏳ Suspense/Lazy loading detected:') + guidelines.push(' - Test fallback UI during loading') + guidelines.push(' - Test component behavior after lazy load completes') + guidelines.push(' - Test error boundaries with failed loads') + } + + // ===== Portal ===== + if (analysis.hasPortal) { + guidelines.push('πŸšͺ Portal rendering detected:') + guidelines.push(' - Test content renders in portal target') + guidelines.push(' - Test portal cleanup on unmount') + guidelines.push(' - Verify event bubbling through portal') + } + + // ===== API Calls ===== + if (analysis.hasAPI) { + guidelines.push('🌐 API calls detected:') + guidelines.push(' - Mock API calls/hooks (useSWR, useQuery, fetch, etc.)') + guidelines.push(' - Test loading, success, and error states') + guidelines.push(' - Focus on component behavior, not the data fetching lib') + } + + // ===== ahooks ===== + if (analysis.hasAhooks) { + guidelines.push('πŸͺ ahooks detected (mock only, no need to test the lib):') + guidelines.push(' - Mock ahooks utilities (useBoolean, useRequest, etc.)') + guidelines.push(' - Focus on testing how your component uses the hooks') + guidelines.push(' - Use fake timers if debounce/throttle is involved') + } + + // ===== Routing ===== + if (analysis.hasRouter) { + guidelines.push('πŸ”€ Next.js routing detected:') + guidelines.push(' - Mock useRouter, usePathname, useSearchParams') + guidelines.push(' - Test navigation behavior and parameters') + guidelines.push(' - Test query string handling') + guidelines.push(' - Verify route guards/redirects if any') + } + + // ===== Event Handlers ===== + if (analysis.hasEvents) { + guidelines.push('🎯 Event handlers detected:') + guidelines.push(' - Test all onClick, onChange, onSubmit handlers') + guidelines.push(' - Test keyboard events (Enter, Escape, etc.)') + guidelines.push(' - Verify event.preventDefault() calls if needed') + guidelines.push(' - Test event bubbling/propagation') + } + + // ===== Domain-Specific Components ===== + if (analysis.path.includes('workflow')) { + guidelines.push('βš™οΈ Workflow component:') + guidelines.push(' - Test node configuration and validation') + guidelines.push(' - Test data flow and variable passing') + guidelines.push(' - Test edge connections and graph structure') + guidelines.push(' - Verify error handling for invalid configs') + } + + if (analysis.path.includes('dataset')) { + guidelines.push('πŸ“š Dataset component:') + guidelines.push(' - Test file upload and validation') + guidelines.push(' - Test pagination and data loading') + guidelines.push(' - Test search and filtering') + guidelines.push(' - Verify data format handling') + } + + if (analysis.path.includes('app/configuration') || analysis.path.includes('config')) { + guidelines.push('βš™οΈ Configuration component:') + guidelines.push(' - Test form validation thoroughly') + guidelines.push(' - Test save/reset functionality') + guidelines.push(' - Test required vs optional fields') + guidelines.push(' - Verify configuration persistence') + } + + // ===== File Size Warning ===== + if (analysis.lineCount > 500) { + guidelines.push('πŸ“ Large component (500+ lines):') + guidelines.push(' - Consider splitting into smaller components') + guidelines.push(' - Test major sections separately') + guidelines.push(' - Use helper functions to reduce test complexity') + } + + return guidelines.length > 0 ? `\n${guidelines.join('\n')}\n` : '' + } +} + +class TestReviewPromptBuilder { + build({ analysis, testPath, testCode, originalPromptSection }) { + const formattedOriginalPrompt = originalPromptSection + ? originalPromptSection + .split('\n') + .map(line => (line.trim().length > 0 ? ` ${line}` : '')) + .join('\n') + .trimEnd() + : ' (original generation prompt unavailable)' + + return ` +╔════════════════════════════════════════════════════════════════════════════╗ +β•‘ βœ… REVIEW TEST FOR DIFY COMPONENT β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + +πŸ“‚ Component Path: ${analysis.path} +πŸ§ͺ Test File: ${testPath} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +πŸ“ REVIEW TASK: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +πŸ“‹ PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT): +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +You are reviewing the frontend test coverage for @${analysis.path}. + +Original generation requirements: +${formattedOriginalPrompt} + +Test file under review: +${testPath} + +Checklist (ensure every item is addressed in your review): +- Confirm the tests satisfy all requirements listed above and in web/testing/TESTING.md. +- Verify Arrange β†’ Act β†’ Assert structure, mocks, and cleanup follow project conventions. +- Ensure all detected component features (state, effects, routing, API, events, etc.) are exercised, including edge cases and error paths. +- Check coverage of prop variations, null/undefined inputs, and high-priority workflows implied by usage score. +- Validate mocks/stubs interact correctly with Next.js router, network calls, and async updates. +- Ensure naming, describe/it structure, and placement match repository standards. + +Output format: +1. Start with a single word verdict: PASS or FAIL. +2. If FAIL, list each missing requirement or defect as a separate bullet with actionable fixes. +3. Highlight any optional improvements or refactors after mandatory issues. +4. Mention any additional tests or tooling steps (e.g., pnpm lint/test) the developer should run. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +` + } +} + +function extractCopyContent(prompt) { + const marker = 'πŸ“‹ PROMPT FOR AI ASSISTANT' + const markerIndex = prompt.indexOf(marker) + if (markerIndex === -1) return '' + + const section = prompt.slice(markerIndex) + const lines = section.split('\n') + const firstDivider = lines.findIndex(line => line.includes('━━━━━━━━')) + if (firstDivider === -1) return '' + + const startIdx = firstDivider + 1 + let endIdx = lines.length + + for (let i = startIdx; i < lines.length; i++) { + if (lines[i].includes('━━━━━━━━')) { + endIdx = i + break + } + } + + if (startIdx >= endIdx) return '' + + return lines.slice(startIdx, endIdx).join('\n').trim() +} + +// ============================================================================ +// Main Function +// ============================================================================ + +function showHelp() { + console.log(` +πŸ“‹ Component Analyzer - Generate test prompts for AI assistants + +Usage: + node analyze-component.js [options] + pnpm analyze-component [options] + +Options: + --help Show this help message + --json Output analysis result as JSON (for programmatic use) + --review Generate a review prompt for existing test file + +Examples: + # Analyze a component and generate test prompt + pnpm analyze-component app/components/base/button/index.tsx + + # Output as JSON + pnpm analyze-component app/components/base/button/index.tsx --json + + # Review existing test + pnpm analyze-component app/components/base/button/index.tsx --review + +For complete testing guidelines, see: web/testing/testing.md +`) +} + +function main() { + const rawArgs = process.argv.slice(2) + + let isReviewMode = false + let isJsonMode = false + const args = [] + + rawArgs.forEach(arg => { + if (arg === '--review') { + isReviewMode = true + return + } + if (arg === '--json') { + isJsonMode = true + return + } + if (arg === '--help' || arg === '-h') { + showHelp() + process.exit(0) + } + args.push(arg) + }) + + if (args.length === 0) { + showHelp() + process.exit(1) + } + + let componentPath = args[0] + let absolutePath = path.resolve(process.cwd(), componentPath) + + // Check if path exists + if (!fs.existsSync(absolutePath)) { + console.error(`❌ Error: Path not found: ${componentPath}`) + process.exit(1) + } + + // If directory, try to find index file + if (fs.statSync(absolutePath).isDirectory()) { + const indexFiles = ['index.tsx', 'index.ts', 'index.jsx', 'index.js'] + let found = false + + for (const indexFile of indexFiles) { + const indexPath = path.join(absolutePath, indexFile) + if (fs.existsSync(indexPath)) { + absolutePath = indexPath + componentPath = path.join(componentPath, indexFile) + found = true + break + } + } + + if (!found) { + console.error(`❌ Error: Directory does not contain index file: ${componentPath}`) + console.error(` Expected one of: ${indexFiles.join(', ')}`) + process.exit(1) + } + } + + // Read source code + const sourceCode = fs.readFileSync(absolutePath, 'utf-8') + + // Analyze + const analyzer = new ComponentAnalyzer() + const analysis = analyzer.analyze(sourceCode, componentPath, absolutePath) + + // Check if component is too complex - suggest refactoring instead of testing + // Skip this check in JSON mode to always output analysis result + if (!isReviewMode && !isJsonMode && (analysis.complexity > 50 || analysis.lineCount > 300)) { + console.log(` +╔════════════════════════════════════════════════════════════════════════════╗ +β•‘ ⚠️ COMPONENT TOO COMPLEX TO TEST β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + +πŸ“ Component: ${analysis.name} +πŸ“‚ Path: ${analysis.path} + +πŸ“Š Component Metrics: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Complexity: ${analysis.complexity} ${analysis.complexity > 50 ? 'πŸ”΄ TOO HIGH' : '⚠️ WARNING'} +Lines: ${analysis.lineCount} ${analysis.lineCount > 300 ? 'πŸ”΄ TOO LARGE' : '⚠️ WARNING'} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +🚫 RECOMMENDATION: REFACTOR BEFORE TESTING +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +This component is too complex to test effectively. Please consider: + +1️⃣ **Split into smaller components** + - Extract reusable UI sections into separate components + - Separate business logic from presentation + - Create smaller, focused components (< 300 lines each) + +2️⃣ **Extract custom hooks** + - Move state management logic to custom hooks + - Extract complex data transformation logic + - Separate API calls into dedicated hooks + +3️⃣ **Simplify logic** + - Reduce nesting depth + - Break down complex conditions + - Extract helper functions + +4️⃣ **After refactoring** + - Run this tool again on each smaller component + - Generate tests for the refactored components + - Tests will be easier to write and maintain + +πŸ’‘ TIP: Aim for components with: + - Complexity score < 30 (preferably < 20) + - Line count < 300 (preferably < 200) + - Single responsibility principle + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +`) + process.exit(0) + } + + // Build prompt for AI assistant + const builder = new TestPromptBuilder() + const generationPrompt = builder.build(analysis) + + let prompt = generationPrompt + + if (isReviewMode) { + const providedTestPath = args[1] + const inferredTestPath = inferTestPath(componentPath) + const testPath = providedTestPath ?? inferredTestPath + const absoluteTestPath = path.resolve(process.cwd(), testPath) + + if (!fs.existsSync(absoluteTestPath)) { + console.error(`❌ Error: Test file not found: ${testPath}`) + process.exit(1) + } + + const testCode = fs.readFileSync(absoluteTestPath, 'utf-8') + const reviewBuilder = new TestReviewPromptBuilder() + const originalPromptSection = extractCopyContent(generationPrompt) + const normalizedTestPath = path.relative(process.cwd(), absoluteTestPath) || testPath + + prompt = reviewBuilder.build({ + analysis, + testPath: normalizedTestPath, + testCode, + originalPromptSection, + }) + } + + // JSON output mode + if (isJsonMode) { + console.log(JSON.stringify(analysis, null, 2)) + return + } + + // Output + console.log(prompt) + + try { + const { spawnSync } = require('node:child_process') + + const checkPbcopy = spawnSync('which', ['pbcopy'], { stdio: 'pipe' }) + if (checkPbcopy.status !== 0) return + const copyContent = extractCopyContent(prompt) + if (!copyContent) return + + const result = spawnSync('pbcopy', [], { + input: copyContent, + encoding: 'utf-8', + }) + + if (result.status === 0) { + console.log('\nπŸ“‹ Prompt copied to clipboard!') + console.log(' Paste it in your AI assistant:') + console.log(' - Cursor: Cmd+L (Chat) or Cmd+I (Composer)') + console.log(' - GitHub Copilot Chat: Cmd+I') + console.log(' - Or any other AI coding tool\n') + } + } + catch { + // pbcopy failed, but don't break the script + } +} + +function inferTestPath(componentPath) { + const ext = path.extname(componentPath) + if (!ext) return `${componentPath}.spec.ts` + return componentPath.replace(ext, `.spec${ext}`) +} + +// ============================================================================ +// Run +// ============================================================================ + +main() diff --git a/web/testing/testing.md b/web/testing/testing.md new file mode 100644 index 0000000000..6ad04eb376 --- /dev/null +++ b/web/testing/testing.md @@ -0,0 +1,432 @@ +# Frontend Testing Guide + +This document is the complete testing specification for the Dify frontend project. +Goal: Readable, change-friendly, reusable, and debuggable tests. +When I ask you to write/refactor/fix tests, follow these rules by default. + +## Tech Stack + +- **Framework**: Next.js 15 + React 19 + TypeScript +- **Testing Tools**: Jest 29.7 + React Testing Library 16.0 +- **Test Environment**: @happy-dom/jest-environment +- **File Naming**: `ComponentName.spec.tsx` (same directory as component) + +## Running Tests + +```bash +# Run all tests +pnpm test + +# Watch mode +pnpm test -- --watch + +# Generate coverage report +pnpm test -- --coverage + +# Run specific file +pnpm test -- path/to/file.spec.tsx +``` + +## Project Test Setup + +- **Configuration**: `jest.config.ts` loads the Testing Library presets, sets the `@happy-dom/jest-environment`, and respects our path aliases (`@/...`). Check this file before adding new transformers or module name mappers. +- **Global setup**: `jest.setup.ts` already imports `@testing-library/jest-dom` and runs `cleanup()` after every test. Add any environment-level mocks (for example `ResizeObserver`, `matchMedia`, `IntersectionObserver`, `TextEncoder`, `crypto`) here so they are shared consistently. +- **Manual mocks**: Place reusable mocks inside `web/__mocks__/`. Use `jest.mock('module-name')` to point to these helpers rather than redefining mocks in every spec. +- **Script utilities**: `web/testing/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands: + - `pnpm analyze-component ` - Analyze and generate test prompt + - `pnpm analyze-component --json` - Output analysis as JSON + - `pnpm analyze-component --review` - Generate test review prompt + - `pnpm analyze-component --help` - Show help +- **Integration suites**: Files in `web/__tests__/` exercise cross-component flows. Prefer adding new end-to-end style specs there rather than mixing them into component directories. + +## Test Authoring Principles + +- **Single behavior per test**: Each test verifies one user-observable behavior. +- **Black-box first**: Assert external behavior and observable outputs, avoid internal implementation details. +- **Semantic naming**: Use `should when ` and group related cases with `describe()`. +- **AAA / Given–When–Then**: Separate Arrange, Act, and Assert clearly with code blocks or comments. +- **Minimal but sufficient assertions**: Keep only the expectations that express the essence of the behavior. +- **Reusable test data**: Prefer test data builders or factories over hard-coded masses of data. +- **De-flake**: Control time, randomness, network, concurrency, and ordering. +- **Fast & stable**: Keep unit tests running in milliseconds; reserve integration tests for cross-module behavior with isolation. +- **Structured describe blocks**: Organize tests with `describe` sections and add a brief comment before each block to explain the scenario it covers so readers can quickly understand the scope. + +## Component Complexity Guidelines + +Use `pnpm analyze-component ` to analyze component complexity and adopt different testing strategies based on the results. + +### πŸ”΄ Very Complex Components (Complexity > 50) + +- **Refactor first**: Break component into smaller pieces +- **Integration tests**: Test complex workflows end-to-end +- **Data-driven tests**: Use `test.each()` for multiple scenarios +- **Performance benchmarks**: Add performance tests for critical paths + +### ⚠️ Complex Components (Complexity 30-50) + +- **Multiple describe blocks**: Group related test cases +- **Integration scenarios**: Test feature combinations +- **Organized structure**: Keep tests maintainable + +### πŸ“ Large Components (500+ lines) + +- **Consider refactoring**: Split into smaller components if possible +- **Section testing**: Test major sections separately +- **Helper functions**: Reduce test complexity with utilities + +## Basic Guidelines + +- βœ… AAA pattern: Arrange (setup) β†’ Act (execute) β†’ Assert (verify) +- βœ… Descriptive test names: `"should [behavior] when [condition]"` +- βœ… TypeScript: No `any` types +- βœ… **Cleanup**: `jest.clearAllMocks()` should be in `beforeEach()`, not `afterEach()`. This ensures mock call history is reset before each test, preventing test pollution when using assertions like `toHaveBeenCalledWith()` or `toHaveBeenCalledTimes()`. + +**⚠️ Mock components must accurately reflect actual component behavior**, especially conditional rendering based on props or state. + +**Rules**: + +1. **Match actual conditional rendering**: If the real component returns `null` or doesn't render under certain conditions, the mock must do the same. Always check the actual component implementation before creating mocks. +1. **Use shared state variables when needed**: When mocking components that depend on shared context or state (e.g., `PortalToFollowElem` with `PortalToFollowElemContent`), use module-level variables to track state and reset them in `beforeEach`. +1. **Always reset shared mock state in beforeEach**: Module-level variables used in mocks must be reset in `beforeEach` to ensure test isolation, even if you set default values elsewhere. +1. **Use fake timers only when needed**: Only use `jest.useFakeTimers()` if: + - Testing components that use real `setTimeout`/`setInterval` (not mocked) + - Testing time-based behavior (delays, animations) + - If you mock all time-dependent functions, fake timers are unnecessary +1. **Prefer importing over mocking project components**: When tests need other components from the project, import them directly instead of mocking them. Only mock external dependencies, APIs, or complex context providers that are difficult to set up. + +**Why this matters**: Mocks that don't match actual behavior can lead to: + +- **False positives**: Tests pass but code would fail in production +- **Missed bugs**: Tests don't catch real conditional rendering issues +- **Maintenance burden**: Tests become misleading documentation +- **State leakage**: Tests interfere with each other when shared state isn't reset + +## Testing Components with Dedicated Dependencies + +When a component has dedicated dependencies (custom hooks, managers, utilities) that are **only used by that component**, use the following strategy to balance integration testing and unit testing. + +### Summary Checklist + +When testing components with dedicated dependencies: + +- **Identify** which dependencies are dedicated vs. reusable +- **Write integration tests** for component + dedicated dependencies together +- **Write unit tests** for complex edge cases in dependencies +- **Avoid mocking** dedicated dependencies in integration tests +- **Use fake timers** if timing logic is involved +- **Test user behavior**, not implementation details +- **Document** the testing strategy in code comments +- **Ensure** integration tests cover 100% of user-facing scenarios +- **Reserve** unit tests for edge cases not practical in integration tests + +## Test Scenarios + +Apply the following test scenarios based on component features: + +### 1. Rendering Tests (REQUIRED - All Components) + +**Key Points**: + +- Verify component renders properly +- Check key elements exist +- Use semantic queries (getByRole, getByLabelText) + +### 2. Props Testing (REQUIRED - All Components) + +Exercise the prop combinations that change observable behavior. Show how required props gate functionality, how optional props fall back to their defaults, and how invalid combinations surface through user-facing safeguards. Let TypeScript catch structural issues; keep runtime assertions focused on what the component renders or triggers. + +### 3. State Management + +Treat component state as part of the public behavior: confirm the initial render in context, execute the interactions or prop updates that move the state machine, and assert the resulting UI or side effects. Use `waitFor()`/async queries whenever transitions resolve asynchronously, and only check cleanup paths when they change what a user sees or experiences (duplicate events, lingering timers, etc.). + +#### Context, Providers, and Stores + +- βœ… Wrap components with the actual provider from `web/context` or `app/components/.../context` whenever practical. +- βœ… When creating lightweight provider stubs, mirror the real default values and surface helper builders (for example `createMockWorkflowContext`). +- βœ… Reset shared stores (React context, Zustand, TanStack Query cache) between tests to avoid leaking state. Prefer helper factory functions over module-level singletons in specs. +- βœ… For hooks that read from context, use `renderHook` with a custom wrapper that supplies required providers. + +### 4. Performance Optimization + +Cover memoized callbacks or values only when they influence observable behaviorβ€”memoized children, subscription updates, expensive computations. Trigger realistic re-renders and assert the outcomes (avoided rerenders, reused results) instead of inspecting hook internals. + +### 5. Event Handlers + +Simulate the interactions that matter to usersβ€”primary clicks, change events, submits, and relevant keyboard shortcutsβ€”and confirm the resulting behavior. When handlers prevent defaults or rely on bubbling, cover the scenarios where that choice affects the UI or downstream flows. + +### 6. API Calls and Async Operations + +**Must Test**: + +- βœ… Mock all API calls using `jest.mock` +- βœ… Test retry logic (if applicable) +- βœ… Verify error handling and user feedback +- βœ… Use `waitFor()` for async operations +- βœ… For `@tanstack/react-query`, instantiate a fresh `QueryClient` per spec and wrap with `QueryClientProvider` +- βœ… Clear timers, intervals, and pending promises between tests when using fake timers + +**Guidelines**: + +- Prefer spying on `global.fetch`/`axios`/`ky` and returning deterministic responses over reaching out to the network. +- Use MSW (`msw` is already installed) when you need declarative request handlers across multiple specs. +- Keep async assertions inside `await waitFor(...)` blocks or the async `findBy*` queries to avoid race conditions. + +### 7. Next.js Routing + +Mock the specific Next.js navigation hooks your component consumes (`useRouter`, `usePathname`, `useSearchParams`) and drive realistic routing flowsβ€”query parameters, redirects, guarded routes, URL updatesβ€”while asserting the rendered outcome or navigation side effects. + +### 8. Edge Cases (REQUIRED - All Components) + +**Must Test**: + +- βœ… null/undefined/empty values +- βœ… Boundary conditions +- βœ… Error states +- βœ… Loading states +- βœ… Unexpected inputs + +### 9. Test Data Builders (Anti-hardcoding) + +For complex inputs/entities, use Builders with solid defaults and chainable overrides. + +### 10. Accessibility Testing (Optional) + +- Test keyboard navigation +- Verify ARIA attributes +- Test focus management +- Ensure screen reader compatibility + +### 11. Snapshot Testing (Use Sparingly) + +Reserve snapshots for static, deterministic fragments (icons, badges, layout chrome). Keep them tight, prefer explicit assertions for behavior, and review any snapshot updates deliberately instead of accepting them wholesale. + +**Note**: Dify is a desktop application. **No need for** responsive/mobile testing. + +## Code Style + +### Example Structure + +```typescript +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import Component from './index' + +// Mock dependencies +jest.mock('@/service/api') + +// Shared state for mocks (if needed) +let mockSharedState = false + +describe('ComponentName', () => { + beforeEach(() => { + jest.clearAllMocks() // βœ… Reset mocks before each test + mockSharedState = false // βœ… Reset shared state if used in mocks + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = { title: 'Test' } + + // Act + render() + + // Assert + expect(screen.getByText('Test')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should handle click events', () => { + const handleClick = jest.fn() + render() + + fireEvent.click(screen.getByRole('button')) + + expect(handleClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle null data', () => { + render() + expect(screen.getByText(/no data/i)).toBeInTheDocument() + }) + }) +}) +``` + +## Dify-Specific Components + +### General + +1. **i18n**: Always return key + + ```typescript + jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + })) + ``` + +1. **Forms**: Test validation logic thoroughly + +1. **Example - Correct mock with conditional rendering**: + +```typescript +// βœ… CORRECT: Matches actual component behavior +let mockPortalOpenState = false + +jest.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open, ...props }: any) => { + mockPortalOpenState = open || false // Update shared state + return
{children}
+ }, + PortalToFollowElemContent: ({ children }: any) => { + // βœ… Matches actual: returns null when open is false + if (!mockPortalOpenState) return null + return
{children}
+ }, +})) + +describe('Component', () => { + beforeEach(() => { + jest.clearAllMocks() // βœ… Reset mock call history + mockPortalOpenState = false // βœ… Reset shared state + }) +}) +``` + +### Workflow Components (`workflow/`) + +**Must Test**: + +- βš™οΈ **Node configuration**: Test all node configuration options +- βœ”οΈ **Data validation**: Verify input/output validation rules +- πŸ”„ **Variable passing**: Test data flow between nodes +- πŸ”— **Edge connections**: Test graph structure and connections +- ❌ **Error handling**: Verify invalid configuration handling +- πŸ§ͺ **Integration**: Test complete workflow execution paths + +### Dataset Components (`dataset/`) + +**Must Test**: + +- πŸ“€ **File upload**: Test file upload and validation +- πŸ“„ **File types**: Verify supported format handling +- πŸ“ƒ **Pagination**: Test data loading and pagination +- πŸ” **Search & filtering**: Test query functionality +- πŸ“Š **Data format handling**: Test various data formats +- ⚠️ **Error states**: Test upload failures and invalid data + +### Configuration Components (`app/configuration`, `config/`) + +**Must Test**: + +- βœ… **Form validation**: Test all validation rules thoroughly +- πŸ’Ύ **Save/reset functionality**: Test data persistence +- πŸ”’ **Required vs optional fields**: Verify field validation +- πŸ“Œ **Configuration persistence**: Test state preservation +- πŸ’¬ **Error feedback**: Verify user error messages +- 🎯 **Default values**: Test initial configuration state + +## Testing Strategy Quick Reference + +### Required (All Components) + +- βœ… Renders without crashing +- βœ… Props (required, optional, defaults) +- βœ… Edge cases (null, undefined, empty values) + +### Conditional (When Present in Component) + +- πŸ”„ **useState** β†’ State initialization, transitions, cleanup +- ⚑ **useEffect** β†’ Execution, dependencies, cleanup +- 🎯 **Event Handlers** β†’ All onClick, onChange, onSubmit, keyboard events +- 🌐 **API Calls** β†’ Loading, success, error states +- πŸ”€ **Routing** β†’ Navigation, params, query strings +- πŸš€ **useCallback/useMemo** β†’ Referential equality, dependencies +- βš™οΈ **Workflow** β†’ Node config, data flow, validation +- πŸ“š **Dataset** β†’ Upload, pagination, search +- πŸŽ›οΈ **Configuration** β†’ Form validation, persistence + +### Complex Components (Complexity 30+) + +- Group tests in multiple `describe` blocks +- Test integration scenarios +- Consider splitting component before testing + +## Coverage Goals + +### ⚠️ MANDATORY: Complete Coverage in Single Generation + +Aim for 100% coverage: + +- βœ… 100% function coverage (every exported function/method tested) +- βœ… 100% statement coverage (every line executed) +- βœ… >95% branch coverage (every if/else, switch case, ternary tested) +- βœ… >95% line coverage + +Generate comprehensive tests covering **all** code paths and scenarios. + +## Debugging Tips + +### View Rendered DOM + +```typescript +import { screen } from '@testing-library/react' + +// Print entire DOM +screen.debug() + +// Print specific element +screen.debug(screen.getByRole('button')) +``` + +### Finding Elements + +Priority order (recommended top to bottom): + +1. `getByRole` - Most recommended, follows accessibility standards +1. `getByLabelText` - Form fields +1. `getByPlaceholderText` - Only when no label +1. `getByText` - Non-interactive elements +1. `getByDisplayValue` - Current form value +1. `getByAltText` - Images +1. `getByTitle` - Last choice +1. `getByTestId` - Only as last resort + +### Async Debugging + +```typescript +// Wait for element to appear +await waitFor(() => { + expect(screen.getByText('Loaded')).toBeInTheDocument() +}) + +// Wait for element to disappear +await waitFor(() => { + expect(screen.queryByText('Loading')).not.toBeInTheDocument() +}) + +// Find async element +const element = await screen.findByText('Async Content') +``` + +## Reference Examples + +Test examples in the project: + +- [classnames.spec.ts](../utils/classnames.spec.ts) - Utility function tests +- [index.spec.tsx](../app/components/base/button/index.spec.tsx) - Component tests + +## Resources + +- [Jest Documentation](https://jestjs.io/docs/getting-started) +- [React Testing Library Documentation](https://testing-library.com/docs/react-testing-library/intro/) +- [Testing Library Best Practices](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) +- [Jest Mock Functions](https://jestjs.io/docs/mock-functions) + +______________________________________________________________________ + +**Remember**: Writing tests is not just about coverage, but ensuring code quality and maintainability. Good tests should be clear, concise, and meaningful. diff --git a/web/tsconfig.json b/web/tsconfig.json index 1d03daa576..2948f6682c 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -6,6 +6,7 @@ "dom.iterable", "esnext" ], + "types": ["jest", "node", "@testing-library/jest-dom"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -40,11 +41,6 @@ "app/components/develop/Prose.jsx" ], "exclude": [ - "node_modules", - "**/*.test.ts", - "**/*.test.tsx", - "**/*.spec.ts", - "**/*.spec.tsx", - "__tests__/**" + "node_modules" ] } diff --git a/web/utils/app-redirection.spec.ts b/web/utils/app-redirection.spec.ts index 8a41d4d010..e48d78fc53 100644 --- a/web/utils/app-redirection.spec.ts +++ b/web/utils/app-redirection.spec.ts @@ -2,6 +2,7 @@ * Test suite for app redirection utility functions * Tests navigation path generation based on user permissions and app modes */ +import { AppModeEnum } from '@/types/app' import { getRedirection, getRedirectionPath } from './app-redirection' describe('app-redirection', () => { @@ -12,44 +13,44 @@ describe('app-redirection', () => { */ describe('getRedirectionPath', () => { test('returns overview path when user is not editor', () => { - const app = { id: 'app-123', mode: 'chat' as const } + const app = { id: 'app-123', mode: AppModeEnum.CHAT } const result = getRedirectionPath(false, app) expect(result).toBe('/app/app-123/overview') }) test('returns workflow path for workflow mode when user is editor', () => { - const app = { id: 'app-123', mode: 'workflow' as const } + const app = { id: 'app-123', mode: AppModeEnum.WORKFLOW } const result = getRedirectionPath(true, app) expect(result).toBe('/app/app-123/workflow') }) test('returns workflow path for advanced-chat mode when user is editor', () => { - const app = { id: 'app-123', mode: 'advanced-chat' as const } + const app = { id: 'app-123', mode: AppModeEnum.ADVANCED_CHAT } const result = getRedirectionPath(true, app) expect(result).toBe('/app/app-123/workflow') }) test('returns configuration path for chat mode when user is editor', () => { - const app = { id: 'app-123', mode: 'chat' as const } + const app = { id: 'app-123', mode: AppModeEnum.CHAT } const result = getRedirectionPath(true, app) expect(result).toBe('/app/app-123/configuration') }) test('returns configuration path for completion mode when user is editor', () => { - const app = { id: 'app-123', mode: 'completion' as const } + const app = { id: 'app-123', mode: AppModeEnum.COMPLETION } const result = getRedirectionPath(true, app) expect(result).toBe('/app/app-123/configuration') }) test('returns configuration path for agent-chat mode when user is editor', () => { - const app = { id: 'app-456', mode: 'agent-chat' as const } + const app = { id: 'app-456', mode: AppModeEnum.AGENT_CHAT } const result = getRedirectionPath(true, app) expect(result).toBe('/app/app-456/configuration') }) test('handles different app IDs', () => { - const app1 = { id: 'abc-123', mode: 'chat' as const } - const app2 = { id: 'xyz-789', mode: 'workflow' as const } + const app1 = { id: 'abc-123', mode: AppModeEnum.CHAT } + const app2 = { id: 'xyz-789', mode: AppModeEnum.WORKFLOW } expect(getRedirectionPath(false, app1)).toBe('/app/abc-123/overview') expect(getRedirectionPath(true, app2)).toBe('/app/xyz-789/workflow') @@ -64,7 +65,7 @@ describe('app-redirection', () => { * Tests that the redirection function is called with the correct path */ test('calls redirection function with correct path for non-editor', () => { - const app = { id: 'app-123', mode: 'chat' as const } + const app = { id: 'app-123', mode: AppModeEnum.CHAT } const mockRedirect = jest.fn() getRedirection(false, app, mockRedirect) @@ -74,7 +75,7 @@ describe('app-redirection', () => { }) test('calls redirection function with workflow path for editor', () => { - const app = { id: 'app-123', mode: 'workflow' as const } + const app = { id: 'app-123', mode: AppModeEnum.WORKFLOW } const mockRedirect = jest.fn() getRedirection(true, app, mockRedirect) @@ -84,7 +85,7 @@ describe('app-redirection', () => { }) test('calls redirection function with configuration path for chat mode editor', () => { - const app = { id: 'app-123', mode: 'chat' as const } + const app = { id: 'app-123', mode: AppModeEnum.CHAT } const mockRedirect = jest.fn() getRedirection(true, app, mockRedirect) @@ -94,7 +95,7 @@ describe('app-redirection', () => { }) test('works with different redirection functions', () => { - const app = { id: 'app-123', mode: 'workflow' as const } + const app = { id: 'app-123', mode: AppModeEnum.WORKFLOW } const paths: string[] = [] const customRedirect = (path: string) => paths.push(path)