From 280e3ef630bd15cc2630f2908ca1f4a8471dd05c Mon Sep 17 00:00:00 2001 From: Jocelyn Lin Date: Fri, 13 Feb 2026 13:36:56 -0800 Subject: [PATCH] feat(agentflow): add test infrastructure and unit tests (#5749) * feat(agentflow): add test infrastructure and unit tests Add Jest test suite with coverage enforcement for @flowise/agentflow. Includes 119 tests across core logic, API layer, search, and state management. Fixes trailing-slash bug in getNodeIconUrl and extracts reducer to its own module for cleaner separation. Co-Authored-By: Claude Opus 4.6 * address gemini review comment * fix lint errors --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/main.yml | 1 + package.json | 1 + packages/agentflow/.eslintignore | 1 + packages/agentflow/.prettierignore | 1 + packages/agentflow/README.md | 6 +- packages/agentflow/TESTS.md | 80 ++++++++ packages/agentflow/jest.config.js | 67 ++++++ packages/agentflow/package.json | 10 + packages/agentflow/src/__mocks__/styleMock.js | 1 + .../agentflow/src/__test_utils__/factories.ts | 50 +++++ .../agentflow/src/atoms/ConfirmDialog.tsx | 1 + packages/agentflow/src/atoms/Input.tsx | 1 + .../src/core/node-catalog/nodeFilters.test.ts | 82 ++++++++ .../core/node-config/nodeIconUtils.test.ts | 35 ++++ .../core/utils/connectionValidation.test.ts | 45 +++++ .../src/core/utils/flowExport.test.ts | 61 ++++++ .../agentflow/src/core/utils/flowExport.ts | 1 + .../src/core/utils/nodeFactory.test.ts | 190 ++++++++++++++++++ .../agentflow/src/core/utils/nodeFactory.ts | 2 + .../core/validation/flowValidation.test.ts | 133 ++++++++++++ .../src/core/validation/flowValidation.ts | 1 + .../features/node-editor/EditNodeDialog.tsx | 1 + .../src/features/node-palette/search.test.ts | 156 ++++++++++++++ .../src/infrastructure/api/chatflows.test.ts | 127 ++++++++++++ .../src/infrastructure/api/client.test.ts | 118 +++++++++++ .../src/infrastructure/api/hooks/useApi.ts | 1 + .../src/infrastructure/api/nodes.test.ts | 49 +++++ .../agentflow/src/infrastructure/api/nodes.ts | 4 +- .../infrastructure/store/AgentflowContext.tsx | 42 +--- .../store/agentflowReducer.test.ts | 118 +++++++++++ .../infrastructure/store/agentflowReducer.ts | 41 ++++ packages/agentflow/tsconfig.json | 3 +- pnpm-lock.yaml | 134 ++++++++++-- turbo.json | 1 + 34 files changed, 1502 insertions(+), 63 deletions(-) create mode 100644 packages/agentflow/TESTS.md create mode 100644 packages/agentflow/jest.config.js create mode 100644 packages/agentflow/src/__mocks__/styleMock.js create mode 100644 packages/agentflow/src/__test_utils__/factories.ts create mode 100644 packages/agentflow/src/core/node-catalog/nodeFilters.test.ts create mode 100644 packages/agentflow/src/core/node-config/nodeIconUtils.test.ts create mode 100644 packages/agentflow/src/core/utils/connectionValidation.test.ts create mode 100644 packages/agentflow/src/core/utils/flowExport.test.ts create mode 100644 packages/agentflow/src/core/utils/nodeFactory.test.ts create mode 100644 packages/agentflow/src/core/validation/flowValidation.test.ts create mode 100644 packages/agentflow/src/features/node-palette/search.test.ts create mode 100644 packages/agentflow/src/infrastructure/api/chatflows.test.ts create mode 100644 packages/agentflow/src/infrastructure/api/client.test.ts create mode 100644 packages/agentflow/src/infrastructure/api/nodes.test.ts create mode 100644 packages/agentflow/src/infrastructure/store/agentflowReducer.test.ts create mode 100644 packages/agentflow/src/infrastructure/store/agentflowReducer.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index be1e45aa925..93e13b50d6d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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' diff --git a/package.json b/package.json index 442a6b26369..0dc2be0826b 100644 --- a/package.json +++ b/package.json @@ -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}\"", diff --git a/packages/agentflow/.eslintignore b/packages/agentflow/.eslintignore index f9409561008..ffe8944378e 100644 --- a/packages/agentflow/.eslintignore +++ b/packages/agentflow/.eslintignore @@ -1,6 +1,7 @@ dist node_modules build +coverage *.config.ts *.config.js vite.config.ts diff --git a/packages/agentflow/.prettierignore b/packages/agentflow/.prettierignore index f9f2dc8cce2..ff7b5949f2d 100644 --- a/packages/agentflow/.prettierignore +++ b/packages/agentflow/.prettierignore @@ -1,6 +1,7 @@ dist node_modules build +coverage *.config.ts *.config.js vite.config.ts diff --git a/packages/agentflow/README.md b/packages/agentflow/README.md index 8adc4865f52..3f977c0df46 100644 --- a/packages/agentflow/README.md +++ b/packages/agentflow/README.md @@ -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 diff --git a/packages/agentflow/TESTS.md b/packages/agentflow/TESTS.md new file mode 100644 index 00000000000..79563b336da --- /dev/null +++ b/packages/agentflow/TESTS.md @@ -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 diff --git a/packages/agentflow/jest.config.js b/packages/agentflow/jest.config.js new file mode 100644 index 00000000000..2fd510dc62b --- /dev/null +++ b/packages/agentflow/jest.config.js @@ -0,0 +1,67 @@ +// Shared config inherited by both project types +const baseConfig = { + preset: 'ts-jest', + roots: ['/src'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json' + } + ] + }, + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + moduleNameMapper: { + '^@test-utils/(.*)$': '/src/__test_utils__/$1', + '^@/(.*)$': '/src/$1', + '\\.(css|less|scss|sass)$': '/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: ['/src/**/*.test.ts'] + }, + // .test.tsx → jsdom (browser-like DOM for React components) + { + ...baseConfig, + displayName: 'components', + testEnvironment: 'jsdom', + testEnvironmentOptions: { + customExportConditions: [''] + }, + testMatch: ['/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$': '/src/__mocks__/styleMock.js' + } + } + ] +} diff --git a/packages/agentflow/package.json b/packages/agentflow/package.json index 402ff625654..19a0b399e7e 100644 --- a/packages/agentflow/package.json +++ b/packages/agentflow/package.json @@ -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": { @@ -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", @@ -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" diff --git a/packages/agentflow/src/__mocks__/styleMock.js b/packages/agentflow/src/__mocks__/styleMock.js new file mode 100644 index 00000000000..4ba52ba2c8d --- /dev/null +++ b/packages/agentflow/src/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/packages/agentflow/src/__test_utils__/factories.ts b/packages/agentflow/src/__test_utils__/factories.ts new file mode 100644 index 00000000000..48185bb7a94 --- /dev/null +++ b/packages/agentflow/src/__test_utils__/factories.ts @@ -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 => ({ + 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 => ({ + 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 => + ({ id: '', name: 'testNode', label: 'Test Node', ...overrides } as NodeData) diff --git a/packages/agentflow/src/atoms/ConfirmDialog.tsx b/packages/agentflow/src/atoms/ConfirmDialog.tsx index c53c33ebd6d..61a60dc2b38 100644 --- a/packages/agentflow/src/atoms/ConfirmDialog.tsx +++ b/packages/agentflow/src/atoms/ConfirmDialog.tsx @@ -18,6 +18,7 @@ const ConfirmContext = createContext(null) let resolveCallback: (value: boolean) => void +// TODO: Integrate with destructive actions (node deletion, canvas clear, discard unsaved changes) /** * Hook to show confirmation dialogs * @example diff --git a/packages/agentflow/src/atoms/Input.tsx b/packages/agentflow/src/atoms/Input.tsx index 2d3065486a1..d1f2777b6bd 100644 --- a/packages/agentflow/src/atoms/Input.tsx +++ b/packages/agentflow/src/atoms/Input.tsx @@ -14,6 +14,7 @@ export interface InputProps { sx?: SxProps } +// TODO: Review if still necessary — NodeInputHandler and MUI TextField are used directly elsewhere /** * Basic input component for text, password, and number inputs */ diff --git a/packages/agentflow/src/core/node-catalog/nodeFilters.test.ts b/packages/agentflow/src/core/node-catalog/nodeFilters.test.ts new file mode 100644 index 00000000000..61236553da6 --- /dev/null +++ b/packages/agentflow/src/core/node-catalog/nodeFilters.test.ts @@ -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({}) + }) +}) diff --git a/packages/agentflow/src/core/node-config/nodeIconUtils.test.ts b/packages/agentflow/src/core/node-config/nodeIconUtils.test.ts new file mode 100644 index 00000000000..953ef8b968b --- /dev/null +++ b/packages/agentflow/src/core/node-config/nodeIconUtils.test.ts @@ -0,0 +1,35 @@ +import { getAgentflowIcon, getNodeColor } from './nodeIconUtils' + +describe('getAgentflowIcon', () => { + it('should return icon config for known node', () => { + const icon = getAgentflowIcon('startAgentflow') + expect(icon).toBeDefined() + expect(icon!.name).toBe('startAgentflow') + expect(icon!.color).toBe('#7EE787') + }) + + it('should return undefined for unknown node', () => { + expect(getAgentflowIcon('unknownNode')).toBeUndefined() + }) + + it('should return icon config for all default nodes', () => { + const knownNodes = ['conditionAgentflow', 'llmAgentflow', 'agentAgentflow', 'directReplyAgentflow'] + knownNodes.forEach((name) => { + expect(getAgentflowIcon(name)).toBeDefined() + }) + }) +}) + +describe('getNodeColor', () => { + it('should return correct color for known node', () => { + expect(getNodeColor('llmAgentflow')).toBe('#64B5F6') + }) + + it('should return default gray for unknown node', () => { + expect(getNodeColor('unknownNode')).toBe('#9e9e9e') + }) + + it('should return default gray for empty string', () => { + expect(getNodeColor('')).toBe('#9e9e9e') + }) +}) diff --git a/packages/agentflow/src/core/utils/connectionValidation.test.ts b/packages/agentflow/src/core/utils/connectionValidation.test.ts new file mode 100644 index 00000000000..fc4f46cf123 --- /dev/null +++ b/packages/agentflow/src/core/utils/connectionValidation.test.ts @@ -0,0 +1,45 @@ +import { makeFlowEdge, makeFlowNode } from '@test-utils/factories' + +import type { FlowEdge } from '../types' + +import { isValidConnectionAgentflowV2 } from './connectionValidation' + +describe('isValidConnectionAgentflowV2', () => { + const makeNode = makeFlowNode + const makeEdge = makeFlowEdge + + it('should reject self-connections', () => { + const nodes = [makeNode('a')] + const edges: FlowEdge[] = [] + + expect(isValidConnectionAgentflowV2({ source: 'a', target: 'a' }, nodes, edges)).toBe(false) + }) + + it('should allow valid connections', () => { + const nodes = [makeNode('a'), makeNode('b')] + const edges: FlowEdge[] = [] + + expect(isValidConnectionAgentflowV2({ source: 'a', target: 'b' }, nodes, edges)).toBe(true) + }) + + it('should reject connections that would create a direct cycle', () => { + const nodes = [makeNode('a'), makeNode('b')] + const edges = [makeEdge('a', 'b')] + + expect(isValidConnectionAgentflowV2({ source: 'b', target: 'a' }, nodes, edges)).toBe(false) + }) + + it('should reject connections that would create an indirect cycle', () => { + const nodes = [makeNode('a'), makeNode('b'), makeNode('c')] + const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')] + + expect(isValidConnectionAgentflowV2({ source: 'c', target: 'a' }, nodes, edges)).toBe(false) + }) + + it('should allow connections in the same direction (non-cyclic)', () => { + const nodes = [makeNode('a'), makeNode('b'), makeNode('c')] + const edges = [makeEdge('a', 'b')] + + expect(isValidConnectionAgentflowV2({ source: 'b', target: 'c' }, nodes, edges)).toBe(true) + }) +}) diff --git a/packages/agentflow/src/core/utils/flowExport.test.ts b/packages/agentflow/src/core/utils/flowExport.test.ts new file mode 100644 index 00000000000..6b0914f3494 --- /dev/null +++ b/packages/agentflow/src/core/utils/flowExport.test.ts @@ -0,0 +1,61 @@ +import { makeFlowEdge, makeFlowNode } from '@test-utils/factories' + +import type { FlowEdge, FlowNode } from '../types' + +import { generateExportFlowData } from './flowExport' + +const makeNode = (id: string, overrides?: Partial) => + makeFlowNode(id, { selected: true, data: { id, name: 'testNode', label: 'Test' }, ...overrides }) + +const makeEdge = (source: string, target: string, overrides?: Partial) => + makeFlowEdge(source, target, { selected: true, ...overrides }) + +describe('generateExportFlowData', () => { + it('should deselect all nodes and edges', () => { + const flowData = { + nodes: [makeNode('a'), makeNode('b')], + edges: [makeEdge('a', 'b')] + } + const result = generateExportFlowData(flowData) + result.nodes.forEach((n) => expect(n.selected).toBe(false)) + result.edges.forEach((e) => expect(e.selected).toBe(false)) + }) + + it('should strip credential data from nodes', () => { + const flowData = { + nodes: [ + makeNode('a', { + data: { id: 'a', name: 'llm', label: 'LLM', credential: 'secret-credential-id' } as FlowNode['data'] + }) + ], + edges: [] + } + const result = generateExportFlowData(flowData) + expect(result.nodes[0].data.credential).toBeUndefined() + }) + + it('should preserve other node data', () => { + const flowData = { + nodes: [ + makeNode('a', { + data: { id: 'a', name: 'llm', label: 'LLM', inputs: { model: 'gpt-4' } } + }) + ], + edges: [] + } + const result = generateExportFlowData(flowData) + expect(result.nodes[0].data.name).toBe('llm') + expect(result.nodes[0].data.inputs).toEqual({ model: 'gpt-4' }) + expect(result.nodes[0].position).toEqual({ x: 0, y: 0 }) + }) + + it('should not mutate the original flow data', () => { + const original = { + nodes: [makeNode('a')], + edges: [makeEdge('a', 'b')] + } + generateExportFlowData(original) + expect(original.nodes[0].selected).toBe(true) + expect(original.edges[0].selected).toBe(true) + }) +}) diff --git a/packages/agentflow/src/core/utils/flowExport.ts b/packages/agentflow/src/core/utils/flowExport.ts index 64e55bc924d..e915549c89f 100644 --- a/packages/agentflow/src/core/utils/flowExport.ts +++ b/packages/agentflow/src/core/utils/flowExport.ts @@ -1,5 +1,6 @@ import type { FlowEdge, FlowNode } from '../types' +// TODO: Integrate with save/export flow to strip credentials before persisting /** * Generate export-friendly flow data (strips sensitive info) */ diff --git a/packages/agentflow/src/core/utils/nodeFactory.test.ts b/packages/agentflow/src/core/utils/nodeFactory.test.ts new file mode 100644 index 00000000000..7b25d1c334a --- /dev/null +++ b/packages/agentflow/src/core/utils/nodeFactory.test.ts @@ -0,0 +1,190 @@ +import { makeFlowNode, makeNodeData } from '@test-utils/factories' + +import type { NodeData } from '../types' + +import { getUniqueNodeId, getUniqueNodeLabel, initializeDefaultNodeData, initNode } from './nodeFactory' + +const makeNode = (id: string, name: string, label: string) => makeFlowNode(id, { data: { id, name, label } }) + +describe('getUniqueNodeId', () => { + it('should return name_0 when no nodes exist', () => { + const nodeData = { id: '', name: 'llmChain', label: 'LLM Chain' } as NodeData + expect(getUniqueNodeId(nodeData, [])).toBe('llmChain_0') + }) + + it('should increment suffix when id already exists', () => { + const nodeData = { id: '', name: 'llmChain', label: 'LLM Chain' } as NodeData + const nodes = [makeNode('llmChain_0', 'llmChain', 'LLM Chain')] + expect(getUniqueNodeId(nodeData, nodes)).toBe('llmChain_1') + }) + + it('should skip multiple existing ids', () => { + const nodeData = { id: '', name: 'agent', label: 'Agent' } as NodeData + const nodes = [makeNode('agent_0', 'agent', 'Agent'), makeNode('agent_1', 'agent', 'Agent'), makeNode('agent_2', 'agent', 'Agent')] + expect(getUniqueNodeId(nodeData, nodes)).toBe('agent_3') + }) +}) + +describe('getUniqueNodeLabel', () => { + it('should return original label for StickyNote type', () => { + const nodeData = { id: '', name: 'stickyNote', label: 'Sticky Note', type: 'StickyNote' } as NodeData + expect(getUniqueNodeLabel(nodeData, [])).toBe('Sticky Note') + }) + + it('should return original label for startAgentflow', () => { + const nodeData = { id: '', name: 'startAgentflow', label: 'Start' } as NodeData + expect(getUniqueNodeLabel(nodeData, [])).toBe('Start') + }) + + it('should return label with suffix 0 for new nodes', () => { + const nodeData = { id: '', name: 'llmChain', label: 'LLM Chain' } as NodeData + expect(getUniqueNodeLabel(nodeData, [])).toBe('LLM Chain 0') + }) + + it('should increment suffix based on existing node ids', () => { + const nodeData = { id: '', name: 'llmChain', label: 'LLM Chain' } as NodeData + const nodes = [makeNode('llmChain_0', 'llmChain', 'LLM Chain 0')] + expect(getUniqueNodeLabel(nodeData, nodes)).toBe('LLM Chain 1') + }) +}) + +describe('initializeDefaultNodeData', () => { + it('should return empty object for empty params', () => { + expect(initializeDefaultNodeData([])).toEqual({}) + }) + + it('should use default values when provided', () => { + const params = [ + { name: 'temperature', default: 0.7 }, + { name: 'maxTokens', default: 1024 } + ] + expect(initializeDefaultNodeData(params)).toEqual({ + temperature: 0.7, + maxTokens: 1024 + }) + }) + + it('should use empty string when no default is provided', () => { + const params = [{ name: 'apiKey' }, { name: 'model', default: 'gpt-4' }] + expect(initializeDefaultNodeData(params)).toEqual({ + apiKey: '', + model: 'gpt-4' + }) + }) +}) + +describe('initNode', () => { + it('should set the new node id on the returned data', () => { + const result = initNode(makeNodeData(), 'node_0') + expect(result.id).toBe('node_0') + }) + + it('should classify whitelisted input types as inputParams', () => { + const nodeData = makeNodeData({ + inputParams: [ + { id: '', name: 'temp', label: 'Temperature', type: 'number' }, + { id: '', name: 'model', label: 'Model', type: 'options', default: 'gpt-4' }, + { id: '', name: 'code', label: 'Code', type: 'code' } + ] as NodeData['inputParams'] + }) + const result = initNode(nodeData, 'n1') + expect(result.inputParams).toHaveLength(3) + result.inputParams!.forEach((p) => { + expect(p.id).toMatch(/^n1-input-/) + }) + }) + + it('should classify non-whitelisted input types as inputAnchors', () => { + const nodeData = makeNodeData({ + inputParams: [ + { id: '', name: 'llm', label: 'LLM', type: 'BaseChatModel' }, + { id: '', name: 'memory', label: 'Memory', type: 'BaseMemory' } + ] as NodeData['inputParams'] + }) + const result = initNode(nodeData, 'n1') + expect(result.inputAnchors).toHaveLength(2) + expect(result.inputParams).toHaveLength(0) + }) + + it('should split mixed input types between params and anchors', () => { + const nodeData = makeNodeData({ + inputParams: [ + { id: '', name: 'temp', label: 'Temperature', type: 'number' }, + { id: '', name: 'llm', label: 'LLM', type: 'BaseChatModel' }, + { id: '', name: 'prompt', label: 'Prompt', type: 'string' } + ] as NodeData['inputParams'] + }) + const result = initNode(nodeData, 'n1') + expect(result.inputParams).toHaveLength(2) + expect(result.inputAnchors).toHaveLength(1) + expect(result.inputAnchors![0].name).toBe('llm') + }) + + it('should initialize default values for params', () => { + const nodeData = makeNodeData({ + inputParams: [ + { id: '', name: 'temp', label: 'Temperature', type: 'number', default: 0.7 }, + { id: '', name: 'model', label: 'Model', type: 'string' } + ] as NodeData['inputParams'] + }) + const result = initNode(nodeData, 'n1') + expect(result.inputs!['temp']).toBe(0.7) + // initNode only sets defaults for params with an explicit `default` value, + // unlike initializeDefaultNodeData which falls back to ''. Params without + // a default are intentionally omitted so the UI can distinguish "unset" from "empty". + expect(result.inputs!['model']).toBeUndefined() + }) + + it('should preserve existing inputs over defaults', () => { + const nodeData = makeNodeData({ + inputs: { temp: 0.9 }, + inputParams: [{ id: '', name: 'temp', label: 'Temperature', type: 'number', default: 0.7 }] as NodeData['inputParams'] + }) + const result = initNode(nodeData, 'n1') + expect(result.inputs!['temp']).toBe(0.9) + }) + + it('should fall back to inputAnchors when inputParams is absent', () => { + const nodeData = makeNodeData({ + inputAnchors: [{ id: '', name: 'llm', label: 'LLM', type: 'BaseChatModel' }] as NodeData['inputAnchors'] + }) + const result = initNode(nodeData, 'n1') + expect(result.inputAnchors).toHaveLength(1) + expect(result.inputAnchors![0].id).toBe('n1-input-llm-BaseChatModel') + }) + + // Output anchor tests (exercises createAgentFlowOutputs) + it('should create a single default output anchor for agentflow nodes', () => { + const result = initNode(makeNodeData({ name: 'llmAgentflow', label: 'LLM' }), 'n1') + expect(result.outputAnchors).toHaveLength(1) + expect(result.outputAnchors![0]).toEqual({ + id: 'n1-output-llmAgentflow', + label: 'LLM', + name: 'llmAgentflow' + }) + }) + + it('should create one output anchor per output entry', () => { + const nodeData = makeNodeData({ + outputs: [ + { label: 'Out1', name: 'out1', type: 'string' }, + { label: 'Out2', name: 'out2', type: 'string' } + ] + }) + const result = initNode(nodeData, 'n1') + expect(result.outputAnchors).toHaveLength(2) + expect(result.outputAnchors![0].id).toBe('n1-output-0') + expect(result.outputAnchors![1].id).toBe('n1-output-1') + }) + + it('should return empty outputAnchors when hideOutput is true', () => { + const nodeData = makeNodeData({ hideOutput: true } as Partial) + const result = initNode(nodeData, 'n1') + expect(result.outputAnchors).toHaveLength(0) + }) + + it('should return empty outputAnchors when isAgentflow is false', () => { + const result = initNode(makeNodeData(), 'n1', false) + expect(result.outputAnchors).toHaveLength(0) + }) +}) diff --git a/packages/agentflow/src/core/utils/nodeFactory.ts b/packages/agentflow/src/core/utils/nodeFactory.ts index 89083538b9e..9cbdaba6fe1 100644 --- a/packages/agentflow/src/core/utils/nodeFactory.ts +++ b/packages/agentflow/src/core/utils/nodeFactory.ts @@ -15,6 +15,7 @@ export function getUniqueNodeId(nodeData: NodeData, nodes: FlowNode[]): string { return baseId } +// TODO: Integrate with node drop/creation flow to assign unique labels per node type /** * Generate a unique node label based on existing nodes */ @@ -33,6 +34,7 @@ export function getUniqueNodeLabel(nodeData: NodeData, nodes: FlowNode[]): strin return `${nodeData.label} ${suffix}` } +// TODO: Integrate with node drop/creation flow to populate default input values /** * Initialize default values for node parameters */ diff --git a/packages/agentflow/src/core/validation/flowValidation.test.ts b/packages/agentflow/src/core/validation/flowValidation.test.ts new file mode 100644 index 00000000000..cd30610e18e --- /dev/null +++ b/packages/agentflow/src/core/validation/flowValidation.test.ts @@ -0,0 +1,133 @@ +import { makeFlowEdge, makeFlowNode } from '@test-utils/factories' + +import type { FlowEdge, FlowNode } from '../types' + +import { validateFlow, validateNode } from './flowValidation' + +const makeNode = (id: string, name: string, label?: string) => makeFlowNode(id, { data: { id, name, label: label || name } }) + +const makeEdge = makeFlowEdge + +describe('validateFlow', () => { + it('should return error for empty flow', () => { + const result = validateFlow([], []) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual(expect.objectContaining({ message: expect.stringContaining('empty') })) + }) + + it('should return error when no start node exists', () => { + const nodes = [makeNode('a', 'llmAgentflow')] + const result = validateFlow(nodes, []) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual(expect.objectContaining({ message: expect.stringContaining('start node') })) + }) + + it('should return error for multiple start nodes', () => { + const nodes = [makeNode('a', 'startAgentflow'), makeNode('b', 'startAgentflow')] + const result = validateFlow(nodes, []) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual(expect.objectContaining({ message: expect.stringContaining('only have one') })) + }) + + it('should pass for a valid simple flow', () => { + const nodes = [makeNode('a', 'startAgentflow'), makeNode('b', 'llmAgentflow'), makeNode('c', 'directReplyAgentflow')] + const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')] + const result = validateFlow(nodes, edges) + expect(result.valid).toBe(true) + }) + + it('should warn when start node has no outgoing connections', () => { + const nodes = [makeNode('a', 'startAgentflow')] + const result = validateFlow(nodes, []) + expect(result.errors).toContainEqual( + expect.objectContaining({ type: 'warning', message: expect.stringContaining('outgoing connection') }) + ) + // Warnings don't make the flow invalid + expect(result.valid).toBe(true) + }) + + it('should warn for disconnected non-start nodes with no incoming edges', () => { + const nodes = [makeNode('a', 'startAgentflow'), makeNode('b', 'llmAgentflow', 'LLM')] + const edges: FlowEdge[] = [] + const result = validateFlow(nodes, edges) + expect(result.errors).toContainEqual( + expect.objectContaining({ nodeId: 'b', type: 'warning', message: expect.stringContaining('no incoming') }) + ) + }) + + it('should warn for non-end nodes with no outgoing edges', () => { + const nodes = [makeNode('a', 'startAgentflow'), makeNode('b', 'llmAgentflow', 'LLM')] + const edges = [makeEdge('a', 'b')] + const result = validateFlow(nodes, edges) + expect(result.errors).toContainEqual( + expect.objectContaining({ nodeId: 'b', type: 'warning', message: expect.stringContaining('no outgoing') }) + ) + }) + + it('should not warn about outgoing edges for end nodes (directReplyAgentflow)', () => { + const nodes = [makeNode('a', 'startAgentflow'), makeNode('b', 'directReplyAgentflow')] + const edges = [makeEdge('a', 'b')] + const result = validateFlow(nodes, edges) + const outgoingWarnings = result.errors.filter((e) => e.nodeId === 'b' && e.message.includes('no outgoing')) + expect(outgoingWarnings).toHaveLength(0) + }) + + it('should ignore sticky notes in disconnection checks', () => { + const nodes = [makeNode('a', 'startAgentflow'), makeNode('b', 'stickyNoteAgentflow')] + const edges: FlowEdge[] = [] + const result = validateFlow(nodes, edges) + const stickyErrors = result.errors.filter((e) => e.nodeId === 'b') + expect(stickyErrors).toHaveLength(0) + }) + + it('should return error when flow contains a cycle', () => { + const nodes = [makeNode('a', 'startAgentflow'), makeNode('b', 'llmAgentflow'), makeNode('c', 'llmAgentflow')] + const edges = [makeEdge('a', 'b'), makeEdge('b', 'c'), makeEdge('c', 'b')] + const result = validateFlow(nodes, edges) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual(expect.objectContaining({ message: expect.stringContaining('cycle') })) + }) +}) + +describe('validateNode', () => { + it('should return error for node with no name', () => { + const node = makeNode('a', '') + const errors = validateNode(node, []) + expect(errors).toContainEqual(expect.objectContaining({ type: 'error', message: expect.stringContaining('missing a name') })) + }) + + it('should return no errors for valid node', () => { + const node = makeNode('a', 'llmAgentflow') + expect(validateNode(node, [])).toHaveLength(0) + }) + + it('should warn about missing required inputs', () => { + const node: FlowNode = { + ...makeNode('a', 'llmAgentflow'), + data: { + id: 'a', + name: 'llmAgentflow', + label: 'LLM', + inputParams: [{ id: 'p1', name: 'model', label: 'Model', type: 'string', optional: false }], + inputs: {} + } + } + const errors = validateNode(node, []) + expect(errors).toContainEqual(expect.objectContaining({ type: 'warning', message: expect.stringContaining('Model') })) + }) + + it('should not warn about optional inputs', () => { + const node: FlowNode = { + ...makeNode('a', 'llmAgentflow'), + data: { + id: 'a', + name: 'llmAgentflow', + label: 'LLM', + inputParams: [{ id: 'p1', name: 'apiKey', label: 'API Key', type: 'string', optional: true }], + inputs: {} + } + } + const errors = validateNode(node, []) + expect(errors).toHaveLength(0) + }) +}) diff --git a/packages/agentflow/src/core/validation/flowValidation.ts b/packages/agentflow/src/core/validation/flowValidation.ts index 1abb1f2ba69..f1641da7c52 100644 --- a/packages/agentflow/src/core/validation/flowValidation.ts +++ b/packages/agentflow/src/core/validation/flowValidation.ts @@ -135,6 +135,7 @@ function detectCycle(nodes: FlowNode[], edges: FlowEdge[]): boolean { return false } +// TODO: Integrate with per-node inline validation to surface errors on individual nodes in the canvas /** * Check if a specific node is valid */ diff --git a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx index 5e3585308a6..365ff5a5816 100644 --- a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx +++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx @@ -19,6 +19,7 @@ export interface EditNodeDialogProps { onCancel: () => void } +// TODO: Integrate with canvas node click/double-click to open this dialog for editing node properties /** * Dialog for editing node properties */ diff --git a/packages/agentflow/src/features/node-palette/search.test.ts b/packages/agentflow/src/features/node-palette/search.test.ts new file mode 100644 index 00000000000..1504c4cfe97 --- /dev/null +++ b/packages/agentflow/src/features/node-palette/search.test.ts @@ -0,0 +1,156 @@ +import { makeNodeData } from '@test-utils/factories' + +import { debounce, fuzzyScore, searchNodes } from './search' + +describe('fuzzyScore', () => { + it('should return 0 for empty search term', () => { + expect(fuzzyScore('', 'some text')).toBe(0) + }) + + it('should return 0 for null/undefined search term', () => { + expect(fuzzyScore(null as unknown as string, 'text')).toBe(0) + expect(fuzzyScore(undefined as unknown as string, 'text')).toBe(0) + }) + + it('should return 0 when no characters match', () => { + expect(fuzzyScore('xyz', 'abc')).toBe(0) + }) + + it('should return 0 when not all search characters are found', () => { + expect(fuzzyScore('abz', 'abc')).toBe(0) + }) + + describe('exact substring matches', () => { + it('should give high score for exact match at start', () => { + const score = fuzzyScore('start', 'startAgentflow') + expect(score).toBeGreaterThanOrEqual(1100) // 1000 base + 200 start bonus - length penalty + }) + + it('should give bonus for match at word boundary', () => { + const atBoundary = fuzzyScore('agent', 'start-agent') + const inMiddle = fuzzyScore('gent', 'startgentflow') + expect(atBoundary).toBeGreaterThan(inMiddle) + }) + + it('should penalize matches further into the string', () => { + const early = fuzzyScore('llm', 'llmAgentflow') + const late = fuzzyScore('llm', 'somethingllm') + expect(early).toBeGreaterThan(late) + }) + + it('should favor shorter targets (more precise match)', () => { + const short = fuzzyScore('llm', 'llm') + const long = fuzzyScore('llm', 'llmAgentflowSomethingElse') + expect(short).toBeGreaterThan(long) + }) + }) + + describe('fuzzy matches', () => { + it('should score consecutive character matches higher', () => { + const consecutive = fuzzyScore('abc', 'abcdef') // exact substring + const scattered = fuzzyScore('adf', 'abcdef') // fuzzy + expect(consecutive).toBeGreaterThan(scattered) + }) + + it('should give bonus for match at start of string', () => { + const startMatch = fuzzyScore('a', 'abcdef') + const midMatch = fuzzyScore('c', 'abcdef') + expect(startMatch).toBeGreaterThan(midMatch) + }) + + it('should give bonus for word boundary matches', () => { + const boundary = fuzzyScore('sa', 'start agentflow') // 'a' at word boundary + // score should include word boundary bonus + expect(boundary).toBeGreaterThan(0) + }) + }) +}) + +describe('searchNodes', () => { + const makeNode = (name: string, label: string, category?: string, description?: string) => + makeNodeData({ name, label, category, description }) + + const nodes = [ + makeNode('llmAgentflow', 'LLM', 'Agent Flows', 'Language model node'), + makeNode('agentAgentflow', 'Agent', 'Agent Flows', 'Autonomous agent'), + makeNode('startAgentflow', 'Start', 'Agent Flows', 'Entry point'), + makeNode('httpAgentflow', 'HTTP Request', 'Agent Flows', 'Make HTTP calls') + ] + + it('should return all nodes when search is empty', () => { + expect(searchNodes(nodes, '')).toEqual(nodes) + expect(searchNodes(nodes, ' ')).toEqual(nodes) + }) + + it('should filter nodes by name match', () => { + const results = searchNodes(nodes, 'llm') + expect(results.length).toBeGreaterThanOrEqual(1) + expect(results[0].name).toBe('llmAgentflow') + }) + + it('should filter nodes by label match', () => { + const results = searchNodes(nodes, 'HTTP') + expect(results.length).toBeGreaterThanOrEqual(1) + expect(results[0].name).toBe('httpAgentflow') + }) + + it('should return empty array when no nodes match', () => { + expect(searchNodes(nodes, 'zzzzz')).toEqual([]) + }) + + it('should rank exact matches higher', () => { + const results = searchNodes(nodes, 'agent') + // 'agentAgentflow' should rank higher than others since 'agent' is in its name and label + expect(results[0].name).toBe('agentAgentflow') + }) + + it('should search across description field', () => { + const results = searchNodes(nodes, 'autonomous') + expect(results.length).toBeGreaterThanOrEqual(1) + expect(results[0].name).toBe('agentAgentflow') + }) +}) + +describe('debounce', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should delay function execution', () => { + const fn = jest.fn() + const debounced = debounce(fn, 300) + + debounced() + expect(fn).not.toHaveBeenCalled() + + jest.advanceTimersByTime(300) + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should reset timer on subsequent calls', () => { + const fn = jest.fn() + const debounced = debounce(fn, 300) + + debounced() + jest.advanceTimersByTime(200) + debounced() // reset + jest.advanceTimersByTime(200) + expect(fn).not.toHaveBeenCalled() + + jest.advanceTimersByTime(100) + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should pass arguments to the debounced function', () => { + const fn = jest.fn() + const debounced = debounce(fn, 100) + + debounced('hello', 42) + jest.advanceTimersByTime(100) + expect(fn).toHaveBeenCalledWith('hello', 42) + }) +}) diff --git a/packages/agentflow/src/infrastructure/api/chatflows.test.ts b/packages/agentflow/src/infrastructure/api/chatflows.test.ts new file mode 100644 index 00000000000..c8184f7ed04 --- /dev/null +++ b/packages/agentflow/src/infrastructure/api/chatflows.test.ts @@ -0,0 +1,127 @@ +import type { AxiosInstance } from 'axios' + +import { createChatflowsApi } from './chatflows' + +const mockClient = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn() +} as unknown as jest.Mocked + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('createChatflowsApi', () => { + const api = createChatflowsApi(mockClient) + + describe('getAllChatflows', () => { + it('should call GET /chatflows', async () => { + const mockData = [{ id: '1', name: 'Flow 1' }] + ;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockData }) + + const result = await api.getAllChatflows() + expect(mockClient.get).toHaveBeenCalledWith('/chatflows') + expect(result).toEqual(mockData) + }) + }) + + describe('getChatflow', () => { + it('should call GET /chatflows/:id', async () => { + const mockData = { id: 'abc', name: 'My Flow' } + ;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockData }) + + const result = await api.getChatflow('abc') + expect(mockClient.get).toHaveBeenCalledWith('/chatflows/abc') + expect(result).toEqual(mockData) + }) + }) + + describe('createChatflow', () => { + it('should serialize FlowData object to JSON string', async () => { + const flowData = { nodes: [], edges: [] } + ;(mockClient.post as jest.Mock).mockResolvedValue({ data: {} }) + + await api.createChatflow({ name: 'New', flowData }) + expect(mockClient.post).toHaveBeenCalledWith('/chatflows', { + name: 'New', + flowData: JSON.stringify(flowData), + type: 'AGENTFLOW' + }) + }) + + it('should pass string flowData as-is', async () => { + ;(mockClient.post as jest.Mock).mockResolvedValue({ data: {} }) + + await api.createChatflow({ name: 'New', flowData: '{"nodes":[]}' }) + expect(mockClient.post).toHaveBeenCalledWith('/chatflows', { + name: 'New', + flowData: '{"nodes":[]}', + type: 'AGENTFLOW' + }) + }) + + it('should use custom type when provided', async () => { + ;(mockClient.post as jest.Mock).mockResolvedValue({ data: {} }) + + await api.createChatflow({ name: 'New', flowData: '{}', type: 'CHATFLOW' }) + expect(mockClient.post).toHaveBeenCalledWith('/chatflows', expect.objectContaining({ type: 'CHATFLOW' })) + }) + }) + + describe('updateChatflow', () => { + it('should serialize FlowData object to JSON string', async () => { + const flowData = { nodes: [], edges: [] } + ;(mockClient.put as jest.Mock).mockResolvedValue({ data: {} }) + + await api.updateChatflow('abc', { flowData }) + expect(mockClient.put).toHaveBeenCalledWith('/chatflows/abc', { flowData: JSON.stringify(flowData) }) + }) + + it('should pass string flowData as-is', async () => { + ;(mockClient.put as jest.Mock).mockResolvedValue({ data: {} }) + + await api.updateChatflow('abc', { flowData: '{"nodes":[]}' }) + expect(mockClient.put).toHaveBeenCalledWith('/chatflows/abc', { flowData: '{"nodes":[]}' }) + }) + + it('should pass non-flowData fields unchanged', async () => { + ;(mockClient.put as jest.Mock).mockResolvedValue({ data: {} }) + + await api.updateChatflow('abc', { name: 'Renamed', deployed: true }) + expect(mockClient.put).toHaveBeenCalledWith('/chatflows/abc', { name: 'Renamed', deployed: true }) + }) + }) + + describe('deleteChatflow', () => { + it('should call DELETE /chatflows/:id', async () => { + ;(mockClient.delete as jest.Mock).mockResolvedValue({}) + + await api.deleteChatflow('abc') + expect(mockClient.delete).toHaveBeenCalledWith('/chatflows/abc') + }) + }) + + describe('generateAgentflow', () => { + it('should call POST /agentflowv2-generator/generate', async () => { + const payload = { question: 'Build a chatbot', selectedChatModel: { name: 'gpt-4' } } + ;(mockClient.post as jest.Mock).mockResolvedValue({ data: { nodes: [], edges: [] } }) + + const result = await api.generateAgentflow(payload) + expect(mockClient.post).toHaveBeenCalledWith('/agentflowv2-generator/generate', payload) + expect(result).toEqual({ nodes: [], edges: [] }) + }) + }) + + describe('getChatModels', () => { + it('should call GET /assistants/chatmodels', async () => { + const mockModels = [{ name: 'gpt-4', label: 'GPT-4' }] + ;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockModels }) + + const result = await api.getChatModels() + expect(mockClient.get).toHaveBeenCalledWith('/assistants/chatmodels') + expect(result).toEqual(mockModels) + }) + }) +}) diff --git a/packages/agentflow/src/infrastructure/api/client.test.ts b/packages/agentflow/src/infrastructure/api/client.test.ts new file mode 100644 index 00000000000..c1d98f2b7bb --- /dev/null +++ b/packages/agentflow/src/infrastructure/api/client.test.ts @@ -0,0 +1,118 @@ +import axios from 'axios' + +import { createApiClient } from './client' + +jest.mock('axios', () => { + const mockResponseInterceptors = { use: jest.fn() } + const mockRequestInterceptors = { use: jest.fn() } + return { + create: jest.fn(() => ({ + interceptors: { + request: mockRequestInterceptors, + response: mockResponseInterceptors + } + })) + } +}) + +const mockedAxios = axios as jest.Mocked + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('createApiClient', () => { + it('should create client with correct baseURL', () => { + createApiClient('https://flowise.example.com') + expect(mockedAxios.create).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: 'https://flowise.example.com/api/v1' + }) + ) + }) + + it('should set Content-Type header', () => { + createApiClient('https://flowise.example.com') + expect(mockedAxios.create).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }) + }) + ) + }) + + it('should set Authorization header when token is provided', () => { + createApiClient('https://flowise.example.com', 'my-token') + expect(mockedAxios.create).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-token' + }) + }) + ) + }) + + it('should not set Authorization header when no token', () => { + createApiClient('https://flowise.example.com') + const headers = mockedAxios.create.mock.calls[0][0]?.headers as Record + expect(headers['Authorization']).toBeUndefined() + }) + + it('should register request and response interceptors', () => { + const client = createApiClient('https://flowise.example.com') + expect(client.interceptors.request.use).toHaveBeenCalledTimes(1) + expect(client.interceptors.response.use).toHaveBeenCalledTimes(1) + }) + + it('should pass config through request interceptor', () => { + const client = createApiClient('https://flowise.example.com') + const successHandler = (client.interceptors.request.use as jest.Mock).mock.calls[0][0] + const config = { url: '/chatflows', headers: {} } + expect(successHandler(config)).toBe(config) + }) + + it('should pass response through response interceptor', () => { + const client = createApiClient('https://flowise.example.com') + const successHandler = (client.interceptors.response.use as jest.Mock).mock.calls[0][0] + const response = { data: {}, status: 200 } + expect(successHandler(response)).toBe(response) + }) + + it('should reject request interceptor errors', async () => { + const client = createApiClient('https://flowise.example.com') + const errorHandler = (client.interceptors.request.use as jest.Mock).mock.calls[0][1] + const error = new Error('Network error') + await expect(errorHandler(error)).rejects.toBe(error) + }) + + it('should reject 401 errors through response interceptor', async () => { + const client = createApiClient('https://flowise.example.com', 'tok') + const errorHandler = (client.interceptors.response.use as jest.Mock).mock.calls[0][1] + + const error = { + response: { status: 401, data: { message: 'Unauthorized' } }, + config: { url: '/chatflows' }, + message: 'Request failed' + } + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + await expect(errorHandler(error)).rejects.toBe(error) + expect(consoleSpy).toHaveBeenCalledWith( + '[Agentflow] 401 Authentication error:', + expect.objectContaining({ url: '/chatflows', hasToken: true }) + ) + consoleSpy.mockRestore() + }) + + it('should pass through non-401 errors without logging', async () => { + const client = createApiClient('https://flowise.example.com') + const errorHandler = (client.interceptors.response.use as jest.Mock).mock.calls[0][1] + + const error = { response: { status: 500 }, message: 'Server error' } + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + await expect(errorHandler(error)).rejects.toBe(error) + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) +}) diff --git a/packages/agentflow/src/infrastructure/api/hooks/useApi.ts b/packages/agentflow/src/infrastructure/api/hooks/useApi.ts index 769c990fbe6..716ba0708f0 100644 --- a/packages/agentflow/src/infrastructure/api/hooks/useApi.ts +++ b/packages/agentflow/src/infrastructure/api/hooks/useApi.ts @@ -11,6 +11,7 @@ interface UseApiReturn extends UseApiState { reset: () => void } +// TODO: Review if still necessary — package uses ApiContext + dedicated hooks (useNodesApi, useChatflowsApi) instead /** * Hook for managing API call state * @param apiFunc - The API function to call diff --git a/packages/agentflow/src/infrastructure/api/nodes.test.ts b/packages/agentflow/src/infrastructure/api/nodes.test.ts new file mode 100644 index 00000000000..55a2a28c16c --- /dev/null +++ b/packages/agentflow/src/infrastructure/api/nodes.test.ts @@ -0,0 +1,49 @@ +import type { AxiosInstance } from 'axios' + +import { createNodesApi } from './nodes' + +const mockClient = { + get: jest.fn() +} as unknown as jest.Mocked + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('createNodesApi', () => { + const api = createNodesApi(mockClient) + + describe('getAllNodes', () => { + it('should call GET /nodes', async () => { + const mockNodes = [{ name: 'llmAgentflow', label: 'LLM' }] + ;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockNodes }) + + const result = await api.getAllNodes() + expect(mockClient.get).toHaveBeenCalledWith('/nodes') + expect(result).toEqual(mockNodes) + }) + }) + + describe('getNodeByName', () => { + it('should call GET /nodes/:name', async () => { + const mockNode = { name: 'llmAgentflow', label: 'LLM' } + ;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockNode }) + + const result = await api.getNodeByName('llmAgentflow') + expect(mockClient.get).toHaveBeenCalledWith('/nodes/llmAgentflow') + expect(result).toEqual(mockNode) + }) + }) + + describe('getNodeIconUrl', () => { + it('should construct correct icon URL', () => { + const url = api.getNodeIconUrl('https://flowise.example.com', 'llmAgentflow') + expect(url).toBe('https://flowise.example.com/api/v1/node-icon/llmAgentflow') + }) + + it('should handle trailing slash in instanceUrl', () => { + const url = api.getNodeIconUrl('https://flowise.example.com/', 'agentNode') + expect(url).toBe('https://flowise.example.com/api/v1/node-icon/agentNode') + }) + }) +}) diff --git a/packages/agentflow/src/infrastructure/api/nodes.ts b/packages/agentflow/src/infrastructure/api/nodes.ts index 89993949e66..d72e357bea1 100644 --- a/packages/agentflow/src/infrastructure/api/nodes.ts +++ b/packages/agentflow/src/infrastructure/api/nodes.ts @@ -27,7 +27,9 @@ export function createNodesApi(client: AxiosInstance) { * Get node icon URL */ getNodeIconUrl: (instanceUrl: string, nodeName: string): string => { - return `${instanceUrl}/api/v1/node-icon/${nodeName}` + // Strip trailing slashes so we never get double slashes in the URL. + const base = instanceUrl.replace(/\/+$/, '') + return `${base}/api/v1/node-icon/${nodeName}` } } } diff --git a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx index 7e08d93d9c8..3cd6a1ffa91 100644 --- a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx +++ b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx @@ -3,47 +3,7 @@ import type { ReactFlowInstance } from 'reactflow' import type { AgentflowAction, AgentflowState, FlowConfig, FlowData, FlowEdge, FlowNode } from '../../core/types' -// Initial state -const initialState: AgentflowState = { - nodes: [], - edges: [], - chatflow: null, - isDirty: false, - reactFlowInstance: null -} - -// Node types that size to content; strip stored width/height so they stay content-sized -const CONTENT_SIZED_NODE_TYPES = new Set(['agentFlow', 'stickyNote']) - -function normalizeNodes(nodes: FlowNode[]): FlowNode[] { - return nodes.map((node) => { - if (CONTENT_SIZED_NODE_TYPES.has(node.type)) { - const { width: _width, height: _height, ...rest } = node - return rest as FlowNode - } - return node - }) -} - -// Reducer -function agentflowReducer(state: AgentflowState, action: AgentflowAction): AgentflowState { - switch (action.type) { - case 'SET_NODES': - return { ...state, nodes: normalizeNodes(action.payload) } - case 'SET_EDGES': - return { ...state, edges: action.payload } - case 'SET_CHATFLOW': - return { ...state, chatflow: action.payload } - case 'SET_DIRTY': - return { ...state, isDirty: action.payload } - case 'SET_REACTFLOW_INSTANCE': - return { ...state, reactFlowInstance: action.payload } - case 'RESET': - return initialState - default: - return state - } -} +import { agentflowReducer, initialState, normalizeNodes } from './agentflowReducer' // Context value interface export interface AgentflowContextValue { diff --git a/packages/agentflow/src/infrastructure/store/agentflowReducer.test.ts b/packages/agentflow/src/infrastructure/store/agentflowReducer.test.ts new file mode 100644 index 00000000000..c87027a173c --- /dev/null +++ b/packages/agentflow/src/infrastructure/store/agentflowReducer.test.ts @@ -0,0 +1,118 @@ +import { makeFlowEdge, makeFlowNode } from '@test-utils/factories' + +import type { AgentflowAction, AgentflowState, FlowNode } from '../../core/types' + +import { agentflowReducer, normalizeNodes } from './agentflowReducer' + +const makeNode = (id: string, type = 'agentFlow', overrides?: Partial) => makeFlowNode(id, { type, ...overrides }) + +const makeEdge = makeFlowEdge + +const initialState: AgentflowState = { + nodes: [], + edges: [], + chatflow: null, + isDirty: false, + reactFlowInstance: null +} + +describe('normalizeNodes', () => { + it('should strip width and height from content-sized node types', () => { + const nodes = [makeNode('a', 'agentFlow', { width: 300, height: 200 })] + const result = normalizeNodes(nodes) + expect(result[0].width).toBeUndefined() + expect(result[0].height).toBeUndefined() + }) + + it('should strip width and height from stickyNote nodes', () => { + const nodes = [makeNode('s', 'stickyNote', { width: 200, height: 150 })] + const result = normalizeNodes(nodes) + expect(result[0].width).toBeUndefined() + expect(result[0].height).toBeUndefined() + }) + + it('should preserve width and height for other node types', () => { + const nodes = [makeNode('a', 'iterationNode', { width: 400, height: 300 })] + const result = normalizeNodes(nodes) + expect(result[0].width).toBe(400) + expect(result[0].height).toBe(300) + }) + + it('should preserve all other node properties', () => { + const nodes = [makeNode('a', 'agentFlow', { width: 300, height: 200, selected: true })] + const result = normalizeNodes(nodes) + expect(result[0].id).toBe('a') + expect(result[0].position).toEqual({ x: 0, y: 0 }) + expect(result[0].selected).toBe(true) + }) + + it('should return empty array for empty input', () => { + expect(normalizeNodes([])).toEqual([]) + }) +}) + +describe('agentflowReducer', () => { + it('should handle SET_NODES and normalize them', () => { + const nodes = [makeNode('a', 'agentFlow', { width: 300 })] + const result = agentflowReducer(initialState, { type: 'SET_NODES', payload: nodes }) + expect(result.nodes).toHaveLength(1) + expect(result.nodes[0].width).toBeUndefined() + }) + + it('should handle SET_EDGES', () => { + const edges = [makeEdge('a', 'b')] + const result = agentflowReducer(initialState, { type: 'SET_EDGES', payload: edges }) + expect(result.edges).toEqual(edges) + }) + + it('should handle SET_CHATFLOW', () => { + const chatflow = { id: 'flow-1', name: 'Test Flow' } + const result = agentflowReducer(initialState, { type: 'SET_CHATFLOW', payload: chatflow }) + expect(result.chatflow).toEqual(chatflow) + }) + + it('should handle SET_CHATFLOW with null', () => { + const state = { ...initialState, chatflow: { id: '1', name: 'Test' } } + const result = agentflowReducer(state, { type: 'SET_CHATFLOW', payload: null }) + expect(result.chatflow).toBeNull() + }) + + it('should handle SET_DIRTY', () => { + const result = agentflowReducer(initialState, { type: 'SET_DIRTY', payload: true }) + expect(result.isDirty).toBe(true) + }) + + it('should handle SET_REACTFLOW_INSTANCE', () => { + const mockInstance = { fitView: jest.fn() } as unknown as AgentflowState['reactFlowInstance'] + const result = agentflowReducer(initialState, { type: 'SET_REACTFLOW_INSTANCE', payload: mockInstance }) + expect(result.reactFlowInstance).toBe(mockInstance) + }) + + it('should handle RESET', () => { + const dirtyState: AgentflowState = { + nodes: [makeNode('a')], + edges: [makeEdge('a', 'b')], + chatflow: { id: '1', name: 'Test' }, + isDirty: true, + reactFlowInstance: null + } + const result = agentflowReducer(dirtyState, { type: 'RESET' }) + expect(result.nodes).toEqual([]) + expect(result.edges).toEqual([]) + expect(result.chatflow).toBeNull() + expect(result.isDirty).toBe(false) + }) + + it('should return current state for unknown action', () => { + const result = agentflowReducer(initialState, { type: 'UNKNOWN' } as unknown as AgentflowAction) + expect(result).toBe(initialState) + }) + + it('should not mutate previous state', () => { + const state: AgentflowState = { ...initialState, nodes: [makeNode('a', 'customNode')] } + const newNodes = [makeNode('b', 'customNode')] + agentflowReducer(state, { type: 'SET_NODES', payload: newNodes }) + expect(state.nodes).toHaveLength(1) + expect(state.nodes[0].id).toBe('a') + }) +}) diff --git a/packages/agentflow/src/infrastructure/store/agentflowReducer.ts b/packages/agentflow/src/infrastructure/store/agentflowReducer.ts new file mode 100644 index 00000000000..e0bc855db98 --- /dev/null +++ b/packages/agentflow/src/infrastructure/store/agentflowReducer.ts @@ -0,0 +1,41 @@ +import type { AgentflowAction, AgentflowState, FlowNode } from '../../core/types' + +// Node types that size to content; strip stored width/height so they stay content-sized +const CONTENT_SIZED_NODE_TYPES = new Set(['agentFlow', 'stickyNote']) + +export function normalizeNodes(nodes: FlowNode[]): FlowNode[] { + return nodes.map((node) => { + if (CONTENT_SIZED_NODE_TYPES.has(node.type)) { + const { width: _width, height: _height, ...rest } = node + return rest as FlowNode + } + return node + }) +} + +export const initialState: AgentflowState = { + nodes: [], + edges: [], + chatflow: null, + isDirty: false, + reactFlowInstance: null +} + +export function agentflowReducer(state: AgentflowState, action: AgentflowAction): AgentflowState { + switch (action.type) { + case 'SET_NODES': + return { ...state, nodes: normalizeNodes(action.payload) } + case 'SET_EDGES': + return { ...state, edges: action.payload } + case 'SET_CHATFLOW': + return { ...state, chatflow: action.payload } + case 'SET_DIRTY': + return { ...state, isDirty: action.payload } + case 'SET_REACTFLOW_INSTANCE': + return { ...state, reactFlowInstance: action.payload } + case 'RESET': + return initialState + default: + return state + } +} diff --git a/packages/agentflow/tsconfig.json b/packages/agentflow/tsconfig.json index 74b2f12a34d..b2e637ac384 100644 --- a/packages/agentflow/tsconfig.json +++ b/packages/agentflow/tsconfig.json @@ -16,7 +16,8 @@ "declarationMap": true, "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@test-utils/*": ["./src/__test_utils__/*"] } }, "include": ["src"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d972af0c50..a3688695623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,18 @@ importers: specifier: ^9.0.1 version: 9.0.1 devDependencies: + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^14.3.1 + version: 14.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.6.1(@testing-library/dom@9.3.4) + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 '@types/lodash': specifier: ^4.14.195 version: 4.17.20 @@ -165,9 +177,18 @@ importers: eslint-plugin-simple-import-sort: specifier: ^12.0.0 version: 12.1.1(eslint@8.57.0) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.16.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.4.6)(@types/node@22.16.3)(typescript@5.5.2)) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0(bufferutil@4.0.8)(canvas@2.11.2(encoding@0.1.13))(utf-8-validate@6.0.4) rimraf: specifier: ^5.0.5 version: 5.0.5 + ts-jest: + specifier: ^29.3.2 + version: 29.3.2(@babel/core@7.24.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.0))(jest@29.7.0(@types/node@22.16.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.4.6)(@types/node@22.16.3)(typescript@5.5.2)))(typescript@5.5.2) typescript: specifier: ^5.4.5 version: 5.5.2 @@ -1272,7 +1293,7 @@ importers: version: 4.2.1(vite@5.1.6(@types/node@22.16.3)(sass@1.71.1)(terser@5.29.1)) pretty-quick: specifier: ^3.1.3 - version: 3.3.1(prettier@3.2.5) + version: 3.3.1(prettier@2.8.8) react-scripts: specifier: ^5.0.1 version: 5.0.1(@babel/plugin-syntax-flow@7.23.3(@babel/core@7.24.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.24.0))(@swc/core@1.4.6)(@types/babel__core@7.20.5)(bufferutil@4.0.8)(canvas@2.11.2(encoding@0.1.13))(eslint@8.57.0)(react@18.2.0)(sass@1.71.1)(ts-node@10.9.2(@swc/core@1.4.6)(@types/node@22.16.3)(typescript@5.5.2))(type-fest@4.40.1)(typescript@5.5.2)(utf-8-validate@6.0.4)(vue-template-compiler@2.7.16) @@ -1303,6 +1324,9 @@ packages: '@adobe/css-tools@4.3.3': resolution: { integrity: sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ== } + '@adobe/css-tools@4.4.4': + resolution: { integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg== } + '@ai-sdk/openai@3.0.2': resolution: { integrity: sha512-GONwavgSWtcWO+t9+GpGK8l7nIYh+zNtCL/NYDSeHxHiw6ksQS9XMRWrZyE5NpJ0EXNxSAWCHIDmb1WvTqhq9Q== } engines: { node: '>=18' } @@ -7361,6 +7385,10 @@ packages: resolution: { integrity: sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg== } engines: { node: '>=8', npm: '>=6', yarn: '>=1' } + '@testing-library/jest-dom@6.9.1': + resolution: { integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA== } + engines: { node: '>=14', npm: '>=6', yarn: '>=1' } + '@testing-library/react@14.2.1': resolution: { integrity: sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A== } engines: { node: '>=14' } @@ -7368,12 +7396,25 @@ packages: react: ^18.0.0 react-dom: ^18.0.0 + '@testing-library/react@14.3.1': + resolution: { integrity: sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ== } + engines: { node: '>=14' } + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + '@testing-library/user-event@12.8.3': resolution: { integrity: sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ== } engines: { node: '>=10', npm: '>=6' } peerDependencies: '@testing-library/dom': '>=7.21.4' + '@testing-library/user-event@14.6.1': + resolution: { integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== } + engines: { node: '>=12', npm: '>=6' } + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tiptap/core@2.12.0': resolution: { integrity: sha512-3qX8oGVKFFZzQ0vit+ZolR6AJIATBzmEmjAA0llFhWk4vf3v64p1YcXcJsOBsr5scizJu5L6RYWEFatFwqckRg== } peerDependencies: @@ -7806,6 +7847,9 @@ packages: '@types/js-yaml@4.0.9': resolution: { integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== } + '@types/jsdom@20.0.1': + resolution: { integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ== } + '@types/jsdom@21.1.6': resolution: { integrity: sha512-/7kkMsC+/kMs7gAYmmBR9P0vGTnOoLhQhyhQJSlXGI5bzTHp6xdo0TtKWQAsz6pmSAeVqKSbqeyP6hytqr9FDw== } @@ -10523,6 +10567,9 @@ packages: dom-accessibility-api@0.5.16: resolution: { integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== } + dom-accessibility-api@0.6.3: + resolution: { integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== } + dom-converter@0.2.0: resolution: { integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== } @@ -11752,11 +11799,12 @@ packages: glob@10.3.10: resolution: { integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== } engines: { node: '>=16 || 14 >=14.17' } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.1.6: resolution: { integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== } - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: { integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== } @@ -12786,6 +12834,15 @@ packages: resolution: { integrity: sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw== } engines: { node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0 } + jest-environment-jsdom@29.7.0: + resolution: { integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA== } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jest-environment-node@27.5.1: resolution: { integrity: sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw== } engines: { node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0 } @@ -18493,10 +18550,12 @@ packages: whatwg-encoding@1.0.5: resolution: { integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== } + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-encoding@2.0.0: resolution: { integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== } engines: { node: '>=12' } + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: { integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg== } @@ -18932,6 +18991,8 @@ snapshots: '@adobe/css-tools@4.3.3': {} + '@adobe/css-tools@4.4.4': {} + '@ai-sdk/openai@3.0.2(zod@3.22.4)': dependencies: '@ai-sdk/provider': 3.0.1 @@ -28028,6 +28089,15 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + '@testing-library/react@14.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.24.0 @@ -28036,11 +28106,23 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + '@testing-library/react@14.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.26.10 + '@testing-library/dom': 9.3.4 + '@types/react-dom': 18.2.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + '@testing-library/user-event@12.8.3(@testing-library/dom@9.3.4)': dependencies: '@babel/runtime': 7.24.0 '@testing-library/dom': 9.3.4 + '@testing-library/user-event@14.6.1(@testing-library/dom@9.3.4)': + dependencies: + '@testing-library/dom': 9.3.4 + '@tiptap/core@2.12.0(@tiptap/pm@2.12.0)': dependencies: '@tiptap/pm': 2.12.0 @@ -28542,6 +28624,12 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 22.16.3 + '@types/tough-cookie': 4.0.5 + parse5: 7.1.2 + '@types/jsdom@21.1.6': dependencies: '@types/node': 20.12.12 @@ -31723,6 +31811,8 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-accessibility-api@0.6.3: {} + dom-converter@0.2.0: dependencies: utila: 0.4.0 @@ -32919,9 +33009,9 @@ snapshots: dependencies: pend: 1.2.0 - fdir@6.4.6(picomatch@4.0.2): + fdir@6.4.6(picomatch@4.0.3): optionalDependencies: - picomatch: 4.0.2 + picomatch: 4.0.3 fdir@6.5.0(picomatch@4.0.3): optionalDependencies: @@ -33484,7 +33574,7 @@ snapshots: dependencies: basic-ftp: 5.0.5 data-uri-to-buffer: 6.0.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 fs-extra: 11.2.0 transitivePeerDependencies: - supports-color @@ -34654,7 +34744,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -34908,6 +34998,23 @@ snapshots: - supports-color - utf-8-validate + jest-environment-jsdom@29.7.0(bufferutil@4.0.8)(canvas@2.11.2(encoding@0.1.13))(utf-8-validate@6.0.4): + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 22.16.3 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3(bufferutil@4.0.8)(canvas@2.11.2(encoding@0.1.13))(utf-8-validate@6.0.4) + optionalDependencies: + canvas: 2.11.2(encoding@0.1.13) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jest-environment-node@27.5.1: dependencies: '@jest/environment': 27.5.1 @@ -38540,17 +38647,6 @@ snapshots: prettier: 2.8.8 tslib: 2.6.2 - pretty-quick@3.3.1(prettier@3.2.5): - dependencies: - execa: 4.1.0 - find-up: 4.1.0 - ignore: 5.3.1 - mri: 1.2.0 - picocolors: 1.0.0 - picomatch: 3.0.1 - prettier: 3.2.5 - tslib: 2.6.2 - prism-react-renderer@1.3.5(react@18.2.0): dependencies: react: 18.2.0 @@ -40978,8 +41074,8 @@ snapshots: tinyglobby@0.2.14: dependencies: - fdir: 6.4.6(picomatch@4.0.2) - picomatch: 4.0.2 + fdir: 6.4.6(picomatch@4.0.3) + picomatch: 4.0.3 tinyglobby@0.2.15: dependencies: diff --git a/turbo.json b/turbo.json index a0c98004888..5161ad81f2e 100644 --- a/turbo.json +++ b/turbo.json @@ -6,6 +6,7 @@ "outputs": ["dist/**"] }, "test": {}, + "test:coverage": {}, "dev": { "cache": false }