Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
cache-dependency-path: 'pnpm-lock.yaml'
- run: pnpm install
- run: pnpm lint
- run: pnpm test:coverage
- run: pnpm build
env:
NODE_OPTIONS: '--max_old_space_size=4096'
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"user:windows": "cd packages/server/bin && run user",
"user:default": "cd packages/server/bin && ./run user",
"test": "turbo run test",
"test:coverage": "turbo run test:coverage",
"clean": "pnpm --filter \"./packages/**\" clean",
"nuke": "pnpm --filter \"./packages/**\" nuke && rimraf node_modules .turbo",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
Expand Down
1 change: 1 addition & 0 deletions packages/agentflow/.eslintignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
dist
node_modules
build
coverage
*.config.ts
*.config.js
vite.config.ts
Expand Down
1 change: 1 addition & 0 deletions packages/agentflow/.prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
dist
node_modules
build
coverage
*.config.ts
*.config.js
vite.config.ts
Expand Down
6 changes: 5 additions & 1 deletion packages/agentflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,19 @@ pnpm install
# Build the package
pnpm build

# Run tests
pnpm test

# Run examples
cd examples && pnpm install && pnpm dev
```

Visit the [examples](./examples) directory for more usage patterns.
Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status.

## Documentation

- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration
- [Examples](./examples/README.md) - Usage examples and demos

## Contributing
Expand Down
80 changes: 80 additions & 0 deletions packages/agentflow/TESTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# @flowise/agentflow Test Plan

## Running Tests

```bash
# Fast run (no coverage)
pnpm test

# With coverage enforcement
pnpm test:coverage

# Watch mode during development
pnpm test:watch
```

## Test Coverage by Tier

Add tests when actively working on these files. Each tier reflects impact and testability.

### Tier 1 — Core Logic

These modules carry the highest risk. Test in the same PR when modifying.

| File | Key exports to test | Status |
|------|-------------------|--------|
| `src/core/validation/` | `validateFlow`, `validateNode` — empty flows, missing/multiple starts, disconnected nodes, cycles, required inputs | ✅ Done |
| `src/core/utils/` | `getUniqueNodeId`, `getUniqueNodeLabel`, `initializeDefaultNodeData`, `initNode`, `generateExportFlowData`, `isValidConnectionAgentflowV2` | ✅ Done |
| `src/core/node-catalog/` | `filterNodesByComponents`, `isAgentflowNode`, `groupNodesByCategory` | ✅ Done |
| `src/core/node-config/` | `getAgentflowIcon`, `getNodeColor` | ✅ Done |
| `src/infrastructure/api/client.ts` | `createApiClient` — headers, auth token, 401 interceptor | ✅ Done |
| `src/infrastructure/api/chatflows.ts` | All CRUD + `generateAgentflow` + `getChatModels`, FlowData serialization | ✅ Done |
| `src/infrastructure/api/nodes.ts` | `getAllNodes`, `getNodeByName`, `getNodeIconUrl` | ✅ Done |
| `src/infrastructure/store/AgentflowContext.tsx` | `agentflowReducer` (all action types), `normalizeNodes`. Remaining: `deleteNode()`, `duplicateNode()`, `updateNodeData()`, `getFlowData()` — React callbacks, test when provider stabilizes | 🟡 Partial |
| `src/useAgentflow.ts` | `getFlow()`, `toJSON()`, `validate()`, `addNode()`, `clear()` | ⬜ Not yet — thin wrapper, test when hook gains own logic |
| `src/features/canvas/hooks/useFlowHandlers.ts` | `onConnect`, `onNodesChange`, `onEdgesChange`, `onAddNode` | ⬜ Not yet — heavily coupled to ReactFlow, test when handlers stabilize |

### Tier 2 — Feature Hooks & Dialogs

Test when adding features or fixing bugs in these areas.

| File | Key exports to test | Status |
|------|-------------------|--------|
| `src/features/node-palette/search.ts` | `fuzzyScore`, `searchNodes`, `debounce` | ✅ Done |
| `src/features/canvas/hooks/useFlowNodes.ts` | `useFlowNodes()` — category filtering, component whitelist, error states | ⬜ Not yet |
| `src/features/canvas/hooks/useDragAndDrop.ts` | `useDragAndDrop()` — JSON parse error handling, node init on drop | ⬜ Not yet |
| `src/features/canvas/hooks/useNodeColors.ts` | `useNodeColors()` — color calculations for selected/hover/dark mode | ⬜ Not yet |
| `src/infrastructure/store/ConfigContext.tsx` | `ConfigProvider` — theme detection (light/dark/system), media query listener | ⬜ Not yet |
| `src/features/generator/GenerateFlowDialog.tsx` | Dialog state machine — API call flow, error handling, progress state | ⬜ Not yet |
| `src/features/node-editor/EditNodeDialog.tsx` | Label editing — keyboard handling (Enter/Escape), node data updates | ⬜ Not yet |
| `src/infrastructure/api/hooks/useApi.ts` | `useApi()` — loading/error/data state transitions | ⬜ Not yet — may be deprecated, check before investing |

### Tier 3 — UI Components

Mostly JSX with minimal logic. Only add tests if business logic is introduced.

| File | When to add tests | Status |
|------|------------------|--------|
| `src/features/node-palette/AddNodesDrawer.tsx` | If category grouping or drag serialization logic changes | ⬜ Not yet |
| `src/features/canvas/components/NodeOutputHandles.tsx` | Has `getMinimumNodeHeight()` pure function — test if calculation logic changes | ⬜ Not yet |
| `src/features/canvas/containers/AgentFlowNode.tsx` | If warning state or color logic becomes more complex | ⬜ Not yet |
| `src/features/canvas/containers/AgentFlowEdge.tsx` | If edge deletion or interaction logic changes | ⬜ Not yet |
| `src/features/canvas/containers/IterationNode.tsx` | If resize or dimension calculation logic changes | ⬜ Not yet |
| `src/atoms/ConfirmDialog.tsx` | If promise-based confirmation pattern is modified | ⬜ Not yet |
| `src/atoms/NodeInputHandler.tsx` | If input rendering or position calculation logic changes | ⬜ Not yet |
| `src/features/canvas/components/ConnectionLine.tsx` | If edge label determination logic becomes more complex | ⬜ Not yet |
| `src/features/canvas/components/NodeStatusIndicator.tsx` | If status-to-color/icon mapping expands | ⬜ Not yet |
| `src/Agentflow.tsx` | Integration test when component orchestration is stable | ⬜ Not yet |

Files that are pure styling or data constants (`styled.ts`, `nodeIcons.ts`, `MainCard.tsx`, `Input.tsx`, etc.) do not need dedicated tests.

## Configuration

- **Jest config**: `jest.config.js` — two projects: `unit` (node env, `.test.ts`) and `components` (jsdom env, `.test.tsx`)
- **Coverage thresholds**: uniform 80% floor (`branches`, `functions`, `lines`, `statements`) enforced per-path:
- `./src/core/`
- `./src/infrastructure/api/`
- `./src/features/node-palette/search.ts`
- **Exclusions**: `src/infrastructure/api/hooks/useApi.ts` is excluded from coverage collection (potentially deprecated — check before investing in tests)
- **CI**: `pnpm test:coverage` runs in GitHub Actions between lint and build
- **Reports**: `coverage/lcov-report/index.html` for detailed HTML report
67 changes: 67 additions & 0 deletions packages/agentflow/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Shared config inherited by both project types
const baseConfig = {
preset: 'ts-jest',
roots: ['<rootDir>/src'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'tsconfig.json'
}
]
},
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
moduleNameMapper: {
'^@test-utils/(.*)$': '<rootDir>/src/__test_utils__/$1',
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': '<rootDir>/src/__mocks__/styleMock.js'
}
}

module.exports = {
verbose: true,
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.test.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
'!src/__mocks__/**',
// Potentially deprecated — exclude until resolved (see TESTS.md)
'!src/infrastructure/api/hooks/useApi.ts'
],
// text: per-folder table, text-summary: totals, lcov: HTML report at coverage/lcov-report/
coverageReporters: ['text', 'text-summary', 'lcov'],
coverageDirectory: 'coverage',
// 80% floor to catch regressions without blocking active development.
// Add new paths here as more modules gain test coverage.
coverageThreshold: {
'./src/core/': { branches: 80, functions: 80, lines: 80, statements: 80 },
'./src/infrastructure/api/': { branches: 80, functions: 80, lines: 80, statements: 80 },
'./src/features/node-palette/search.ts': { branches: 80, functions: 80, lines: 80, statements: 80 }
},
projects: [
// .test.ts → node (fast, no DOM)
{
...baseConfig,
displayName: 'unit',
testEnvironment: 'node',
testMatch: ['<rootDir>/src/**/*.test.ts']
},
// .test.tsx → jsdom (browser-like DOM for React components)
{
...baseConfig,
displayName: 'components',
testEnvironment: 'jsdom',
testEnvironmentOptions: {
customExportConditions: ['']
},
testMatch: ['<rootDir>/src/**/*.test.tsx'],
setupFilesAfterEnv: ['@testing-library/jest-dom'],
moduleNameMapper: {
...baseConfig.moduleNameMapper,
// jsdom tries to load native canvas module which isn't built — mock it
'^canvas$': '<rootDir>/src/__mocks__/styleMock.js'
}
}
]
}
10 changes: 10 additions & 0 deletions packages/agentflow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"format:check": "prettier --check \"{src,examples}/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"lint": "eslint \"{src,examples/src}/**/*.{js,jsx,ts,tsx}\"",
"lint:fix": "eslint \"{src,examples/src}/**/*.{js,jsx,ts,tsx}\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"nuke": "rimraf dist node_modules .turbo"
},
"peerDependencies": {
Expand All @@ -47,6 +50,10 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.14.195",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
Expand All @@ -56,7 +63,10 @@
"@vitejs/plugin-react": "^4.2.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"rimraf": "^5.0.5",
"ts-jest": "^29.3.2",
"typescript": "^5.4.5",
"vite": "^5.0.2",
"vite-plugin-dts": "^3.7.0"
Expand Down
1 change: 1 addition & 0 deletions packages/agentflow/src/__mocks__/styleMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = {}
50 changes: 50 additions & 0 deletions packages/agentflow/src/__test_utils__/factories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { FlowEdge, FlowNode, NodeData } from '../core/types'

/**
* Create a {@link FlowNode} with sensible defaults.
*
* The returned node uses `id` for every identity field (`id`, `data.id`,
* `data.name`, `data.label`) so a single string is enough for most tests.
* Pass `overrides` to customise any property.
*
* @example
* makeFlowNode('a')
* makeFlowNode('a', { type: 'agentFlow', selected: true })
* makeFlowNode('a', { data: { id: 'a', name: 'llmAgentflow', label: 'LLM' } })
*/
export const makeFlowNode = (id: string, overrides?: Partial<FlowNode>): FlowNode => ({
id,
type: 'customNode',
position: { x: 0, y: 0 },
data: { id, name: id, label: id },
...overrides
})

/**
* Create a {@link FlowEdge} between two node ids.
*
* The edge `id` is derived as `"${source}-${target}"` by default.
*
* @example
* makeFlowEdge('a', 'b')
* makeFlowEdge('a', 'b', { selected: true, animated: true })
*/
export const makeFlowEdge = (source: string, target: string, overrides?: Partial<FlowEdge>): FlowEdge => ({
id: `${source}-${target}`,
source,
target,
type: 'default',
...overrides
})

/**
* Create a {@link NodeData} descriptor (the `data` payload of a node).
*
* Useful for testing palette search, node filtering, and `initNode`.
*
* @example
* makeNodeData()
* makeNodeData({ name: 'llmAgentflow', label: 'LLM', category: 'AI' })
*/
export const makeNodeData = (overrides?: Partial<NodeData>): NodeData =>
({ id: '', name: 'testNode', label: 'Test Node', ...overrides } as NodeData)
1 change: 1 addition & 0 deletions packages/agentflow/src/atoms/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const ConfirmContext = createContext<ConfirmContextValue | null>(null)

let resolveCallback: (value: boolean) => void

// TODO: Integrate with destructive actions (node deletion, canvas clear, discard unsaved changes)
/**
* Hook to show confirmation dialogs
* @example
Expand Down
1 change: 1 addition & 0 deletions packages/agentflow/src/atoms/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface InputProps {
sx?: SxProps<Theme>
}

// TODO: Review if still necessary — NodeInputHandler and MUI TextField are used directly elsewhere
/**
* Basic input component for text, password, and number inputs
*/
Expand Down
82 changes: 82 additions & 0 deletions packages/agentflow/src/core/node-catalog/nodeFilters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { makeNodeData } from '@test-utils/factories'

import { DEFAULT_AGENTFLOW_NODES } from '../node-config'

import { filterNodesByComponents, groupNodesByCategory, isAgentflowNode } from './nodeFilters'

const makeNode = (name: string, category?: string) => makeNodeData({ name, label: name, category: category || 'Agent Flows' })

describe('filterNodesByComponents', () => {
const allNodes = [
makeNode('startAgentflow'),
makeNode('llmAgentflow'),
makeNode('agentAgentflow'),
makeNode('customNodeNotInDefaults'),
makeNode('anotherCustom')
]

it('should return only default agentflow nodes when no components specified', () => {
const result = filterNodesByComponents(allNodes)
result.forEach((node) => {
expect(DEFAULT_AGENTFLOW_NODES).toContain(node.name)
})
expect(result.find((n) => n.name === 'customNodeNotInDefaults')).toBeUndefined()
})

it('should return only default agentflow nodes for empty components array', () => {
const result = filterNodesByComponents(allNodes, [])
expect(result.find((n) => n.name === 'customNodeNotInDefaults')).toBeUndefined()
})

it('should filter to specified components', () => {
const result = filterNodesByComponents(allNodes, ['llmAgentflow', 'customNodeNotInDefaults'])
const names = result.map((n) => n.name)
expect(names).toContain('llmAgentflow')
expect(names).toContain('customNodeNotInDefaults')
})

it('should always include startAgentflow even if not in components list', () => {
const result = filterNodesByComponents(allNodes, ['llmAgentflow'])
const names = result.map((n) => n.name)
expect(names).toContain('startAgentflow')
})

it('should return empty array when no nodes match', () => {
const result = filterNodesByComponents(allNodes, ['nonExistent'])
// Only startAgentflow should be present (always included)
expect(result.map((n) => n.name)).toEqual(['startAgentflow'])
})
})

describe('isAgentflowNode', () => {
it('should return true for default agentflow nodes', () => {
expect(isAgentflowNode('startAgentflow')).toBe(true)
expect(isAgentflowNode('llmAgentflow')).toBe(true)
expect(isAgentflowNode('directReplyAgentflow')).toBe(true)
})

it('should return false for non-agentflow nodes', () => {
expect(isAgentflowNode('randomNode')).toBe(false)
expect(isAgentflowNode('')).toBe(false)
})
})

describe('groupNodesByCategory', () => {
it('should group nodes by their category', () => {
const nodes = [makeNode('llmAgentflow', 'AI'), makeNode('agentAgentflow', 'AI'), makeNode('httpAgentflow', 'Utilities')]
const grouped = groupNodesByCategory(nodes)
expect(grouped['AI']).toHaveLength(2)
expect(grouped['Utilities']).toHaveLength(1)
})

it('should use "Other" for nodes without a category', () => {
const nodes = [makeNode('test')]
nodes[0].category = undefined
const grouped = groupNodesByCategory(nodes)
expect(grouped['Other']).toHaveLength(1)
})

it('should return empty object for empty input', () => {
expect(groupNodesByCategory([])).toEqual({})
})
})
Loading
Loading