diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0cad0d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,153 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + NODE_VERSION: '18' + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run ESLint + run: bun run lint + + - name: Check formatting + run: bun run format:check + + type-check: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run TypeScript type checking + run: bun run type-check + + test: + name: Unit & Integration Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests with coverage + run: bun run test:ci + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + files: ./coverage/lcov.info + fail_ci_if_error: false + verbose: true + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: | + coverage/ + junit.xml + retention-days: 30 + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, type-check, test] + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build application + run: bun run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build + path: .next/ + retention-days: 7 + + e2e: + name: E2E Tests + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build + path: .next/ + + - name: Run Cypress tests + uses: cypress-io/github-action@v6 + with: + start: bun run start + wait-on: 'http://localhost:3001' + wait-on-timeout: 120 + browser: chrome + record: false + + - name: Upload Cypress screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-screenshots + path: cypress/screenshots + retention-days: 7 + + - name: Upload Cypress videos + uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-videos + path: cypress/videos + retention-days: 7 diff --git a/.husky/_/husky.sh b/.husky/_/husky.sh new file mode 100755 index 0000000..cec959a --- /dev/null +++ b/.husky/_/husky.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env sh +if [ -z "$husky_skip_init" ]; then + debug () { + if [ "$HUSKY_DEBUG" = "1" ]; then + echo "husky (debug) - $1" + fi + } + + readonly hook_name="$(basename -- "$0")" + debug "starting $hook_name..." + + if [ "$HUSKY" = "0" ]; then + debug "HUSKY env variable is set to 0, skipping hook" + exit 0 + fi + + if [ -f ~/.huskyrc ]; then + debug "sourcing ~/.huskyrc" + . ~/.huskyrc + fi + + readonly husky_skip_init=1 + export husky_skip_init + sh -e "$0" "$@" + exitCode="$?" + + if [ $exitCode != 0 ]; then + echo "husky - $hook_name hook exited with code $exitCode (error)" + fi + + if [ $exitCode = 127 ]; then + echo "husky - command not found in PATH=$PATH" + fi + + exit $exitCode +fi diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..c160a77 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit ${1} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..d24fdfc --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..08c0169 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +out/ +coverage/ +.git/ +*.min.js +*.min.css +bun.lockb +package-lock.json +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..01d128a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "useTabs": false, + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/__tests__/api/plants.test.ts b/__tests__/api/plants.test.ts new file mode 100644 index 0000000..5cb31a6 --- /dev/null +++ b/__tests__/api/plants.test.ts @@ -0,0 +1,180 @@ +/** + * Plants API Tests + * Integration tests for plant-related API endpoints + */ + +// Mock the blockchain manager +jest.mock('../../lib/blockchain/manager', () => ({ + getBlockchain: jest.fn(() => ({ + getChain: jest.fn(() => [ + // Genesis block + { + index: 0, + timestamp: '2024-01-01T00:00:00Z', + plant: { id: 'genesis' }, + previousHash: '0', + hash: 'genesis-hash', + nonce: 0, + }, + // Test plants + { + index: 1, + timestamp: '2024-01-02T00:00:00Z', + plant: { + id: 'plant-1', + name: 'Cherry Tomato', + species: 'Tomato', + variety: 'Cherry', + generation: 1, + propagationType: 'original', + status: 'healthy', + location: { latitude: 40.7128, longitude: -74.006 }, + }, + previousHash: 'genesis-hash', + hash: 'hash-1', + nonce: 1, + }, + { + index: 2, + timestamp: '2024-01-03T00:00:00Z', + plant: { + id: 'plant-2', + name: 'Sweet Basil', + species: 'Basil', + variety: 'Genovese', + generation: 1, + propagationType: 'seed', + parentPlantId: 'plant-1', + status: 'thriving', + location: { latitude: 40.7228, longitude: -74.016 }, + }, + previousHash: 'hash-1', + hash: 'hash-2', + nonce: 2, + }, + ]), + addPlant: jest.fn((plant) => ({ + index: 3, + timestamp: new Date().toISOString(), + plant, + previousHash: 'hash-2', + hash: 'hash-3', + nonce: 3, + })), + findPlant: jest.fn((id) => { + if (id === 'plant-1') { + return { + index: 1, + plant: { + id: 'plant-1', + name: 'Cherry Tomato', + species: 'Tomato', + variety: 'Cherry', + generation: 1, + status: 'healthy', + }, + }; + } + return undefined; + }), + isValid: jest.fn(() => true), + })), +})); + +describe('Plants API', () => { + describe('GET /api/plants', () => { + it('should return plant list', async () => { + const { getBlockchain } = require('../../lib/blockchain/manager'); + const blockchain = getBlockchain(); + const chain = blockchain.getChain(); + + expect(chain.length).toBeGreaterThan(1); + expect(chain[1].plant.name).toBe('Cherry Tomato'); + }); + }); + + describe('GET /api/plants/[id]', () => { + it('should return plant by ID', async () => { + const { getBlockchain } = require('../../lib/blockchain/manager'); + const blockchain = getBlockchain(); + const plant = blockchain.findPlant('plant-1'); + + expect(plant).toBeDefined(); + expect(plant.plant.name).toBe('Cherry Tomato'); + }); + + it('should return undefined for non-existent plant', async () => { + const { getBlockchain } = require('../../lib/blockchain/manager'); + const blockchain = getBlockchain(); + const plant = blockchain.findPlant('non-existent'); + + expect(plant).toBeUndefined(); + }); + }); + + describe('POST /api/plants/register', () => { + it('should register new plant', async () => { + const { getBlockchain } = require('../../lib/blockchain/manager'); + const blockchain = getBlockchain(); + + const newPlant = { + id: 'plant-3', + name: 'New Plant', + species: 'Test', + variety: 'Test', + generation: 1, + propagationType: 'seed', + status: 'healthy', + location: { latitude: 40.7, longitude: -74.0 }, + }; + + const block = blockchain.addPlant(newPlant); + + expect(block).toBeDefined(); + expect(block.plant.name).toBe('New Plant'); + expect(blockchain.addPlant).toHaveBeenCalledWith(newPlant); + }); + }); + + describe('GET /api/plants/network', () => { + it('should return network statistics', async () => { + const { getBlockchain } = require('../../lib/blockchain/manager'); + const blockchain = getBlockchain(); + const chain = blockchain.getChain(); + + // Calculate network stats + const plants = chain.slice(1); // Skip genesis + const totalPlants = plants.length; + const speciesCounts: Record = {}; + + plants.forEach((block: any) => { + const species = block.plant.species; + speciesCounts[species] = (speciesCounts[species] || 0) + 1; + }); + + expect(totalPlants).toBe(2); + expect(speciesCounts['Tomato']).toBe(1); + expect(speciesCounts['Basil']).toBe(1); + }); + }); + + describe('GET /api/plants/lineage/[id]', () => { + it('should return lineage for plant with parent', async () => { + const { getBlockchain } = require('../../lib/blockchain/manager'); + const blockchain = getBlockchain(); + const chain = blockchain.getChain(); + + const plant2 = chain[2].plant; + expect(plant2.parentPlantId).toBe('plant-1'); + }); + }); + + describe('Blockchain Validation', () => { + it('should validate chain integrity', async () => { + const { getBlockchain } = require('../../lib/blockchain/manager'); + const blockchain = getBlockchain(); + + expect(blockchain.isValid()).toBe(true); + }); + }); +}); diff --git a/__tests__/setup.ts b/__tests__/setup.ts new file mode 100644 index 0000000..7b2eebe --- /dev/null +++ b/__tests__/setup.ts @@ -0,0 +1,48 @@ +/** + * Jest Test Setup + * Global configuration and utilities for all tests + */ + +// Extend Jest matchers if needed +// import '@testing-library/jest-dom'; + +// Mock console methods to reduce noise in tests +const originalConsole = { ...console }; + +beforeAll(() => { + // Suppress console.log during tests unless DEBUG is set + if (!process.env.DEBUG) { + console.log = jest.fn(); + console.info = jest.fn(); + } +}); + +afterAll(() => { + // Restore console + console.log = originalConsole.log; + console.info = originalConsole.info; +}); + +// Global timeout for async operations +jest.setTimeout(10000); + +// Clean up after each test +afterEach(() => { + jest.clearAllMocks(); +}); + +// Utility function for creating mock dates +export function mockDate(date: Date | string): void { + const mockDateValue = new Date(date); + jest.spyOn(global, 'Date').mockImplementation(() => mockDateValue as any); +} + +// Utility function for waiting in tests +export function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Utility to create test IDs +export function createTestId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} diff --git a/__tests__/unit/agents/AgentOrchestrator.test.ts b/__tests__/unit/agents/AgentOrchestrator.test.ts new file mode 100644 index 0000000..a0d149a --- /dev/null +++ b/__tests__/unit/agents/AgentOrchestrator.test.ts @@ -0,0 +1,317 @@ +/** + * AgentOrchestrator Tests + * Tests for the agent orchestration system + */ + +import { AgentOrchestrator, getOrchestrator } from '../../../lib/agents/AgentOrchestrator'; + +// Mock all agents +jest.mock('../../../lib/agents/PlantLineageAgent', () => ({ + PlantLineageAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'plant-lineage-agent', name: 'Plant Lineage Agent', priority: 'high' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'plant-lineage-agent', + tasksCompleted: 5, + tasksFailed: 0, + averageExecutionMs: 100, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getPlantLineageAgent: jest.fn(), +})); + +jest.mock('../../../lib/agents/TransportTrackerAgent', () => ({ + TransportTrackerAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'transport-tracker-agent', name: 'Transport Tracker Agent', priority: 'high' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'transport-tracker-agent', + tasksCompleted: 3, + tasksFailed: 1, + averageExecutionMs: 150, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getTransportTrackerAgent: jest.fn(), +})); + +jest.mock('../../../lib/agents/DemandForecastAgent', () => ({ + DemandForecastAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'demand-forecast-agent', name: 'Demand Forecast Agent', priority: 'high' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'demand-forecast-agent', + tasksCompleted: 2, + tasksFailed: 0, + averageExecutionMs: 200, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getDemandForecastAgent: jest.fn(), +})); + +jest.mock('../../../lib/agents/VerticalFarmAgent', () => ({ + VerticalFarmAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'vertical-farm-agent', name: 'Vertical Farm Agent', priority: 'critical' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'vertical-farm-agent', + tasksCompleted: 10, + tasksFailed: 0, + averageExecutionMs: 50, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getVerticalFarmAgent: jest.fn(), +})); + +jest.mock('../../../lib/agents/EnvironmentAnalysisAgent', () => ({ + EnvironmentAnalysisAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'environment-analysis-agent', name: 'Environment Analysis Agent', priority: 'medium' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'environment-analysis-agent', + tasksCompleted: 1, + tasksFailed: 0, + averageExecutionMs: 300, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getEnvironmentAnalysisAgent: jest.fn(), +})); + +jest.mock('../../../lib/agents/MarketMatchingAgent', () => ({ + MarketMatchingAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'market-matching-agent', name: 'Market Matching Agent', priority: 'high' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'market-matching-agent', + tasksCompleted: 4, + tasksFailed: 0, + averageExecutionMs: 120, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getMarketMatchingAgent: jest.fn(), +})); + +jest.mock('../../../lib/agents/SustainabilityAgent', () => ({ + SustainabilityAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'sustainability-agent', name: 'Sustainability Agent', priority: 'medium' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'sustainability-agent', + tasksCompleted: 1, + tasksFailed: 0, + averageExecutionMs: 400, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getSustainabilityAgent: jest.fn(), +})); + +jest.mock('../../../lib/agents/NetworkDiscoveryAgent', () => ({ + NetworkDiscoveryAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'network-discovery-agent', name: 'Network Discovery Agent', priority: 'medium' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'network-discovery-agent', + tasksCompleted: 1, + tasksFailed: 0, + averageExecutionMs: 500, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getNetworkDiscoveryAgent: jest.fn(), +})); + +jest.mock('../../../lib/agents/QualityAssuranceAgent', () => ({ + QualityAssuranceAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'quality-assurance-agent', name: 'Quality Assurance Agent', priority: 'critical' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'quality-assurance-agent', + tasksCompleted: 8, + tasksFailed: 0, + averageExecutionMs: 80, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getQualityAssuranceAgent: jest.fn(), +})); + +jest.mock('../../../lib/agents/GrowerAdvisoryAgent', () => ({ + GrowerAdvisoryAgent: jest.fn().mockImplementation(() => ({ + config: { id: 'grower-advisory-agent', name: 'Grower Advisory Agent', priority: 'high' }, + status: 'idle', + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + getMetrics: jest.fn().mockReturnValue({ + agentId: 'grower-advisory-agent', + tasksCompleted: 6, + tasksFailed: 1, + averageExecutionMs: 180, + errors: [], + }), + getAlerts: jest.fn().mockReturnValue([]), + })), + getGrowerAdvisoryAgent: jest.fn(), +})); + +describe('AgentOrchestrator', () => { + let orchestrator: AgentOrchestrator; + + beforeEach(() => { + // Reset singleton + (globalThis as any).__orchestratorInstance = undefined; + orchestrator = new AgentOrchestrator(); + }); + + afterEach(async () => { + const status = orchestrator.getStatus(); + if (status.isRunning) { + await orchestrator.stopAll(); + } + }); + + describe('Initialization', () => { + it('should initialize with all 10 agents', () => { + const status = orchestrator.getStatus(); + expect(status.totalAgents).toBe(10); + }); + + it('should not be running initially', () => { + const status = orchestrator.getStatus(); + expect(status.isRunning).toBe(false); + }); + + it('should have no running agents initially', () => { + const status = orchestrator.getStatus(); + expect(status.runningAgents).toBe(0); + }); + }); + + describe('Starting Agents', () => { + it('should start all agents', async () => { + await orchestrator.startAll(); + const status = orchestrator.getStatus(); + expect(status.isRunning).toBe(true); + }); + + it('should start individual agent', async () => { + await orchestrator.startAgent('plant-lineage-agent'); + const health = orchestrator.getAgentHealth('plant-lineage-agent'); + expect(health).toBeDefined(); + }); + }); + + describe('Stopping Agents', () => { + it('should stop all agents', async () => { + await orchestrator.startAll(); + await orchestrator.stopAll(); + const status = orchestrator.getStatus(); + expect(status.isRunning).toBe(false); + }); + + it('should stop individual agent', async () => { + await orchestrator.startAgent('plant-lineage-agent'); + await orchestrator.stopAgent('plant-lineage-agent'); + // Agent should be stopped + }); + }); + + describe('Agent Health', () => { + it('should return health for existing agent', async () => { + await orchestrator.startAll(); + const health = orchestrator.getAgentHealth('plant-lineage-agent'); + expect(health).toHaveProperty('agentId'); + expect(health?.agentId).toBe('plant-lineage-agent'); + }); + + it('should return undefined for non-existent agent', () => { + const health = orchestrator.getAgentHealth('non-existent-agent'); + expect(health).toBeUndefined(); + }); + }); + + describe('Status', () => { + it('should return correct status structure', () => { + const status = orchestrator.getStatus(); + expect(status).toHaveProperty('isRunning'); + expect(status).toHaveProperty('totalAgents'); + expect(status).toHaveProperty('runningAgents'); + expect(status).toHaveProperty('healthyAgents'); + }); + + it('should track uptime when running', async () => { + await orchestrator.startAll(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const status = orchestrator.getStatus(); + expect(status.uptime).toBeGreaterThan(0); + }); + }); + + describe('Dashboard', () => { + it('should return dashboard data', async () => { + await orchestrator.startAll(); + const dashboard = orchestrator.getDashboard(); + expect(dashboard).toHaveProperty('status'); + expect(dashboard).toHaveProperty('agents'); + expect(dashboard).toHaveProperty('recentAlerts'); + }); + + it('should include all agents in dashboard', async () => { + await orchestrator.startAll(); + const dashboard = orchestrator.getDashboard(); + expect(dashboard.agents.length).toBe(10); + }); + }); + + describe('Alerts', () => { + it('should return empty alerts initially', () => { + const alerts = orchestrator.getAlerts(); + expect(alerts).toEqual([]); + }); + + it('should filter alerts by severity', () => { + const criticalAlerts = orchestrator.getAlerts('critical'); + expect(Array.isArray(criticalAlerts)).toBe(true); + }); + }); + + describe('Singleton', () => { + it('should return same instance from getOrchestrator', () => { + const orch1 = getOrchestrator(); + const orch2 = getOrchestrator(); + expect(orch1).toBe(orch2); + }); + }); +}); diff --git a/__tests__/unit/agents/BaseAgent.test.ts b/__tests__/unit/agents/BaseAgent.test.ts new file mode 100644 index 0000000..1b0e7c1 --- /dev/null +++ b/__tests__/unit/agents/BaseAgent.test.ts @@ -0,0 +1,268 @@ +/** + * BaseAgent Tests + * Tests for the abstract base agent class + */ + +import { BaseAgent } from '../../../lib/agents/BaseAgent'; +import { AgentConfig, AgentTask, AgentStatus } from '../../../lib/agents/types'; + +// Concrete implementation for testing +class TestAgent extends BaseAgent { + public runOnceResult: AgentTask | null = null; + public runOnceError: Error | null = null; + public runOnceCallCount = 0; + + constructor(config?: Partial) { + super({ + id: 'test-agent', + name: 'Test Agent', + description: 'Agent for testing', + enabled: true, + intervalMs: 100, + priority: 'medium', + maxRetries: 3, + timeoutMs: 5000, + ...config, + }); + } + + async runOnce(): Promise { + this.runOnceCallCount++; + if (this.runOnceError) { + throw this.runOnceError; + } + return this.runOnceResult; + } + + // Expose protected methods for testing + public testCreateAlert( + severity: 'info' | 'warning' | 'error' | 'critical', + title: string, + message: string + ) { + return this.createAlert(severity, title, message); + } + + public testHandleError(error: Error) { + return this.handleError(error); + } + + public testAddTask(type: string, payload: Record) { + return this.addTask(type, payload); + } + + public testCreateTaskResult( + type: string, + status: 'completed' | 'failed', + result?: any + ) { + return this.createTaskResult(type, status, result); + } +} + +describe('BaseAgent', () => { + let agent: TestAgent; + + beforeEach(() => { + agent = new TestAgent(); + jest.clearAllMocks(); + }); + + afterEach(async () => { + if (agent.status === 'running') { + await agent.stop(); + } + }); + + describe('Initialization', () => { + it('should initialize with correct config', () => { + expect(agent.config.id).toBe('test-agent'); + expect(agent.config.name).toBe('Test Agent'); + expect(agent.config.enabled).toBe(true); + }); + + it('should initialize with idle status', () => { + expect(agent.status).toBe('idle'); + }); + + it('should initialize with empty metrics', () => { + const metrics = agent.getMetrics(); + expect(metrics.tasksCompleted).toBe(0); + expect(metrics.tasksFailed).toBe(0); + expect(metrics.averageExecutionMs).toBe(0); + expect(metrics.errors).toEqual([]); + }); + + it('should initialize with empty alerts', () => { + expect(agent.getAlerts()).toEqual([]); + }); + }); + + describe('Lifecycle', () => { + it('should start and update status to running', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'completed'); + await agent.start(); + expect(agent.status).toBe('running'); + }); + + it('should not start twice', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'completed'); + await agent.start(); + const firstCallCount = agent.runOnceCallCount; + await agent.start(); // Second call + expect(agent.runOnceCallCount).toBe(firstCallCount); + }); + + it('should stop and update status to idle', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'completed'); + await agent.start(); + await agent.stop(); + expect(agent.status).toBe('idle'); + }); + + it('should pause and resume', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'completed'); + await agent.start(); + agent.pause(); + expect(agent.status).toBe('paused'); + agent.resume(); + expect(agent.status).toBe('running'); + }); + + it('should not pause if not running', () => { + agent.pause(); + expect(agent.status).toBe('idle'); + }); + + it('should not resume if not paused', () => { + agent.resume(); + expect(agent.status).toBe('idle'); + }); + }); + + describe('Task Execution', () => { + it('should run task on start', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'completed'); + await agent.start(); + expect(agent.runOnceCallCount).toBe(1); + await agent.stop(); + }); + + it('should increment tasksCompleted on success', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'completed', { data: 'test' }); + await agent.start(); + await agent.stop(); + expect(agent.getMetrics().tasksCompleted).toBe(1); + }); + + it('should increment tasksFailed on failure', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'failed', null); + await agent.start(); + await agent.stop(); + expect(agent.getMetrics().tasksFailed).toBe(1); + }); + + it('should update lastRunAt after execution', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'completed'); + await agent.start(); + await agent.stop(); + expect(agent.getMetrics().lastRunAt).not.toBeNull(); + }); + }); + + describe('Error Handling', () => { + it('should handle thrown errors', async () => { + agent.runOnceError = new Error('Test error'); + await agent.start(); + await agent.stop(); + const metrics = agent.getMetrics(); + expect(metrics.errors.length).toBe(1); + expect(metrics.errors[0].message).toBe('Test error'); + }); + + it('should record error timestamp', async () => { + agent.testHandleError(new Error('Test error')); + const metrics = agent.getMetrics(); + expect(metrics.lastErrorAt).not.toBeNull(); + }); + + it('should limit errors to 50', () => { + for (let i = 0; i < 60; i++) { + agent.testHandleError(new Error(`Error ${i}`)); + } + expect(agent.getMetrics().errors.length).toBe(50); + }); + }); + + describe('Alerts', () => { + it('should create alerts with correct structure', () => { + const alert = agent.testCreateAlert('warning', 'Test Alert', 'Test message'); + expect(alert.id).toBeDefined(); + expect(alert.severity).toBe('warning'); + expect(alert.title).toBe('Test Alert'); + expect(alert.message).toBe('Test message'); + expect(alert.acknowledged).toBe(false); + }); + + it('should add alerts to the list', () => { + agent.testCreateAlert('info', 'Alert 1', 'Message 1'); + agent.testCreateAlert('warning', 'Alert 2', 'Message 2'); + expect(agent.getAlerts().length).toBe(2); + }); + + it('should limit alerts to 100', () => { + for (let i = 0; i < 110; i++) { + agent.testCreateAlert('info', `Alert ${i}`, 'Message'); + } + expect(agent.getAlerts().length).toBe(100); + }); + }); + + describe('Metrics', () => { + it('should calculate uptime when running', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'completed'); + await agent.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const metrics = agent.getMetrics(); + expect(metrics.uptime).toBeGreaterThan(0); + await agent.stop(); + }); + + it('should calculate average execution time', async () => { + agent.runOnceResult = agent.testCreateTaskResult('test', 'completed'); + await agent.start(); + await agent.stop(); + const metrics = agent.getMetrics(); + expect(metrics.averageExecutionMs).toBeGreaterThanOrEqual(0); + }); + }); + + describe('Task Queue', () => { + it('should add tasks with unique IDs', () => { + const task1 = agent.testAddTask('type1', { key: 'value1' }); + const task2 = agent.testAddTask('type2', { key: 'value2' }); + expect(task1.id).not.toBe(task2.id); + }); + + it('should set correct task properties', () => { + const task = agent.testAddTask('test-type', { data: 'value' }); + expect(task.type).toBe('test-type'); + expect(task.payload).toEqual({ data: 'value' }); + expect(task.status).toBe('pending'); + expect(task.agentId).toBe('test-agent'); + }); + }); + + describe('Task Results', () => { + it('should create completed task result', () => { + const result = agent.testCreateTaskResult('scan', 'completed', { count: 10 }); + expect(result.status).toBe('completed'); + expect(result.result).toEqual({ count: 10 }); + }); + + it('should create failed task result', () => { + const result = agent.testCreateTaskResult('scan', 'failed', null); + expect(result.status).toBe('failed'); + }); + }); +}); diff --git a/__tests__/unit/agents/PlantLineageAgent.test.ts b/__tests__/unit/agents/PlantLineageAgent.test.ts new file mode 100644 index 0000000..64ceb61 --- /dev/null +++ b/__tests__/unit/agents/PlantLineageAgent.test.ts @@ -0,0 +1,241 @@ +/** + * PlantLineageAgent Tests + * Tests for the plant lineage tracking agent + */ + +import { PlantLineageAgent, getPlantLineageAgent } from '../../../lib/agents/PlantLineageAgent'; + +// Mock the blockchain manager +jest.mock('../../../lib/blockchain/manager', () => ({ + getBlockchain: jest.fn(() => ({ + getChain: jest.fn(() => [ + // Genesis block + { + index: 0, + timestamp: '2024-01-01T00:00:00Z', + plant: { id: 'genesis' }, + previousHash: '0', + hash: 'genesis-hash', + nonce: 0, + }, + // First generation plant + { + index: 1, + timestamp: '2024-01-02T00:00:00Z', + plant: { + id: 'plant-1', + name: 'Original Tomato', + species: 'Tomato', + variety: 'Cherry', + generation: 1, + propagationType: 'original', + parentPlantId: undefined, + childPlants: ['plant-2'], + status: 'thriving', + dateAcquired: '2024-01-02', + location: { latitude: 40.7128, longitude: -74.006 }, + environment: { light: 'full_sun' }, + growthMetrics: { height: 50 }, + }, + previousHash: 'genesis-hash', + hash: 'hash-1', + nonce: 1, + }, + // Second generation plant + { + index: 2, + timestamp: '2024-01-15T00:00:00Z', + plant: { + id: 'plant-2', + name: 'Cloned Tomato', + species: 'Tomato', + variety: 'Cherry', + generation: 2, + propagationType: 'cutting', + parentPlantId: 'plant-1', + childPlants: ['plant-3'], + status: 'healthy', + dateAcquired: '2024-01-15', + location: { latitude: 40.73, longitude: -73.99 }, + environment: { light: 'partial_sun' }, + growthMetrics: { height: 30 }, + }, + previousHash: 'hash-1', + hash: 'hash-2', + nonce: 2, + }, + // Third generation plant + { + index: 3, + timestamp: '2024-02-01T00:00:00Z', + plant: { + id: 'plant-3', + name: 'Third Gen Tomato', + species: 'Tomato', + variety: 'Cherry', + generation: 3, + propagationType: 'seed', + parentPlantId: 'plant-2', + childPlants: [], + status: 'healthy', + dateAcquired: '2024-02-01', + location: { latitude: 40.75, longitude: -73.98 }, + environment: { light: 'full_sun' }, + growthMetrics: { height: 20 }, + }, + previousHash: 'hash-2', + hash: 'hash-3', + nonce: 3, + }, + ]), + })), +})); + +describe('PlantLineageAgent', () => { + let agent: PlantLineageAgent; + + beforeEach(() => { + agent = new PlantLineageAgent(); + }); + + afterEach(async () => { + if (agent.status === 'running') { + await agent.stop(); + } + }); + + describe('Initialization', () => { + it('should initialize with correct config', () => { + expect(agent.config.id).toBe('plant-lineage-agent'); + expect(agent.config.name).toBe('Plant Lineage Agent'); + expect(agent.config.priority).toBe('high'); + expect(agent.config.intervalMs).toBe(60000); + }); + + it('should start in idle status', () => { + expect(agent.status).toBe('idle'); + }); + }); + + describe('runOnce', () => { + it('should complete a scan cycle', async () => { + const result = await agent.runOnce(); + expect(result).not.toBeNull(); + expect(result?.status).toBe('completed'); + expect(result?.type).toBe('lineage_scan'); + }); + + it('should scan plants and update cache', async () => { + await agent.runOnce(); + expect(agent.getLineageAnalysis('plant-1')).not.toBeNull(); + expect(agent.getLineageAnalysis('plant-2')).not.toBeNull(); + expect(agent.getLineageAnalysis('plant-3')).not.toBeNull(); + }); + + it('should return scan statistics', async () => { + const result = await agent.runOnce(); + expect(result?.result).toHaveProperty('plantsScanned'); + expect(result?.result).toHaveProperty('anomaliesFound'); + expect(result?.result).toHaveProperty('cacheSize'); + expect(result?.result.plantsScanned).toBe(3); + }); + }); + + describe('Lineage Analysis', () => { + beforeEach(async () => { + await agent.runOnce(); + }); + + it('should find ancestors correctly', () => { + const analysis = agent.getLineageAnalysis('plant-3'); + expect(analysis?.ancestors).toContain('plant-2'); + expect(analysis?.ancestors).toContain('plant-1'); + expect(analysis?.ancestors.length).toBe(2); + }); + + it('should find descendants correctly', () => { + const analysis = agent.getLineageAnalysis('plant-1'); + expect(analysis?.descendants).toContain('plant-2'); + expect(analysis?.descendants).toContain('plant-3'); + }); + + it('should track generation depth', () => { + const analysis1 = agent.getLineageAnalysis('plant-1'); + const analysis3 = agent.getLineageAnalysis('plant-3'); + expect(analysis1?.generation).toBe(1); + expect(analysis3?.generation).toBe(3); + }); + + it('should calculate lineage size', () => { + const analysis = agent.getLineageAnalysis('plant-2'); + // plant-2 has 1 ancestor (plant-1) and 1 descendant (plant-3) + itself = 3 + expect(analysis?.totalLineageSize).toBe(3); + }); + + it('should build propagation chain', () => { + const analysis = agent.getLineageAnalysis('plant-3'); + expect(analysis?.propagationChain).toEqual(['original', 'cutting', 'seed']); + }); + + it('should calculate health score', () => { + const analysis = agent.getLineageAnalysis('plant-1'); + expect(analysis?.healthScore).toBeGreaterThan(0); + expect(analysis?.healthScore).toBeLessThanOrEqual(100); + }); + + it('should return null for non-existent plant', () => { + const analysis = agent.getLineageAnalysis('non-existent'); + expect(analysis).toBeNull(); + }); + }); + + describe('Network Statistics', () => { + beforeEach(async () => { + await agent.runOnce(); + }); + + it('should calculate total plants', () => { + const stats = agent.getNetworkStats(); + expect(stats.totalPlants).toBe(3); + }); + + it('should calculate total lineages (root plants)', () => { + const stats = agent.getNetworkStats(); + expect(stats.totalLineages).toBe(1); // Only plant-1 has no ancestors + }); + + it('should calculate average generation depth', () => { + const stats = agent.getNetworkStats(); + // (1 + 2 + 3) / 3 = 2 + expect(stats.avgGenerationDepth).toBe(2); + }); + + it('should return empty stats when no data', () => { + const emptyAgent = new PlantLineageAgent(); + const stats = emptyAgent.getNetworkStats(); + expect(stats.totalPlants).toBe(0); + expect(stats.totalLineages).toBe(0); + }); + }); + + describe('Anomaly Detection', () => { + it('should return empty anomalies initially', () => { + expect(agent.getAnomalies()).toEqual([]); + }); + + it('should detect anomalies during scan', async () => { + await agent.runOnce(); + const anomalies = agent.getAnomalies(); + // The mock data is valid, so no anomalies should be detected + expect(Array.isArray(anomalies)).toBe(true); + }); + }); + + describe('Singleton', () => { + it('should return same instance from getPlantLineageAgent', () => { + const agent1 = getPlantLineageAgent(); + const agent2 = getPlantLineageAgent(); + expect(agent1).toBe(agent2); + }); + }); +}); diff --git a/__tests__/unit/blockchain/PlantChain.test.ts b/__tests__/unit/blockchain/PlantChain.test.ts new file mode 100644 index 0000000..b6073fc --- /dev/null +++ b/__tests__/unit/blockchain/PlantChain.test.ts @@ -0,0 +1,169 @@ +/** + * PlantChain Tests + * Tests for the blockchain implementation + */ + +import { PlantChain } from '../../../lib/blockchain/PlantChain'; +import { PlantData } from '../../../lib/blockchain/types'; + +describe('PlantChain', () => { + let chain: PlantChain; + + beforeEach(() => { + chain = new PlantChain(); + }); + + describe('Initialization', () => { + it('should create chain with genesis block', () => { + expect(chain.getChain().length).toBe(1); + }); + + it('should have valid genesis block', () => { + const genesisBlock = chain.getChain()[0]; + expect(genesisBlock.index).toBe(0); + expect(genesisBlock.previousHash).toBe('0'); + }); + }); + + describe('Adding Plants', () => { + const createTestPlant = (overrides?: Partial): PlantData => ({ + id: `plant-${Date.now()}`, + name: 'Test Plant', + species: 'Test Species', + variety: 'Test Variety', + generation: 1, + propagationType: 'original', + dateAcquired: new Date().toISOString(), + location: { + latitude: 40.7128, + longitude: -74.006, + city: 'New York', + }, + status: 'healthy', + ...overrides, + }); + + it('should add new plant to chain', () => { + const plant = createTestPlant(); + chain.addPlant(plant); + expect(chain.getChain().length).toBe(2); + }); + + it('should generate valid block hash', () => { + const plant = createTestPlant(); + chain.addPlant(plant); + const newBlock = chain.getChain()[1]; + expect(newBlock.hash).toBeDefined(); + expect(newBlock.hash.length).toBeGreaterThan(0); + }); + + it('should link blocks correctly', () => { + const plant = createTestPlant(); + chain.addPlant(plant); + const genesisBlock = chain.getChain()[0]; + const newBlock = chain.getChain()[1]; + expect(newBlock.previousHash).toBe(genesisBlock.hash); + }); + + it('should store plant data correctly', () => { + const plant = createTestPlant({ name: 'My Tomato' }); + chain.addPlant(plant); + const newBlock = chain.getChain()[1]; + expect(newBlock.plant.name).toBe('My Tomato'); + }); + + it('should add multiple plants', () => { + chain.addPlant(createTestPlant({ id: 'plant-1' })); + chain.addPlant(createTestPlant({ id: 'plant-2' })); + chain.addPlant(createTestPlant({ id: 'plant-3' })); + expect(chain.getChain().length).toBe(4); + }); + }); + + describe('Finding Plants', () => { + beforeEach(() => { + chain.addPlant({ + id: 'tomato-1', + name: 'Cherry Tomato', + species: 'Tomato', + variety: 'Cherry', + generation: 1, + propagationType: 'original', + dateAcquired: new Date().toISOString(), + location: { latitude: 40.7, longitude: -74.0 }, + status: 'healthy', + }); + chain.addPlant({ + id: 'basil-1', + name: 'Sweet Basil', + species: 'Basil', + variety: 'Genovese', + generation: 1, + propagationType: 'seed', + dateAcquired: new Date().toISOString(), + location: { latitude: 40.8, longitude: -73.9 }, + status: 'thriving', + }); + }); + + it('should find plant by ID', () => { + const block = chain.findPlant('tomato-1'); + expect(block).toBeDefined(); + expect(block?.plant.name).toBe('Cherry Tomato'); + }); + + it('should return undefined for non-existent plant', () => { + const block = chain.findPlant('non-existent'); + expect(block).toBeUndefined(); + }); + }); + + describe('Chain Validation', () => { + it('should validate empty chain (genesis only)', () => { + expect(chain.isValid()).toBe(true); + }); + + it('should validate chain with plants', () => { + chain.addPlant({ + id: 'plant-1', + name: 'Test Plant', + species: 'Test', + variety: 'Test', + generation: 1, + propagationType: 'original', + dateAcquired: new Date().toISOString(), + location: { latitude: 40.7, longitude: -74.0 }, + status: 'healthy', + }); + expect(chain.isValid()).toBe(true); + }); + }); + + describe('Serialization', () => { + beforeEach(() => { + chain.addPlant({ + id: 'plant-1', + name: 'Test Plant', + species: 'Test', + variety: 'Test', + generation: 1, + propagationType: 'original', + dateAcquired: new Date().toISOString(), + location: { latitude: 40.7, longitude: -74.0 }, + status: 'healthy', + }); + }); + + it('should export to JSON', () => { + const json = chain.toJSON(); + expect(json).toBeDefined(); + expect(Array.isArray(json)).toBe(true); + }); + + it('should import from JSON', () => { + const json = chain.toJSON(); + const restored = PlantChain.fromJSON(json); + expect(restored.getChain().length).toBe(chain.getChain().length); + }); + }); +}); diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..3364109 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,25 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + [ + 'feat', // New feature + 'fix', // Bug fix + 'docs', // Documentation + 'style', // Code style (formatting, semicolons, etc.) + 'refactor', // Code refactoring + 'perf', // Performance improvement + 'test', // Adding or updating tests + 'build', // Build system or dependencies + 'ci', // CI configuration + 'chore', // Maintenance tasks + 'revert', // Revert a previous commit + ], + ], + 'subject-case': [2, 'always', 'lower-case'], + 'subject-max-length': [2, 'always', 72], + 'body-max-line-length': [2, 'always', 100], + }, +}; diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 0000000..e8456de --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:3001', + supportFile: 'cypress/support/e2e.ts', + specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}', + viewportWidth: 1280, + viewportHeight: 720, + video: true, + screenshotOnRunFailure: true, + defaultCommandTimeout: 10000, + requestTimeout: 10000, + responseTimeout: 30000, + retries: { + runMode: 2, + openMode: 0, + }, + setupNodeEvents(on, config) { + // implement node event listeners here + on('task', { + log(message) { + console.log(message); + return null; + }, + }); + }, + }, + component: { + devServer: { + framework: 'next', + bundler: 'webpack', + }, + }, +}); diff --git a/cypress/e2e/home.cy.ts b/cypress/e2e/home.cy.ts new file mode 100644 index 0000000..d08b62b --- /dev/null +++ b/cypress/e2e/home.cy.ts @@ -0,0 +1,32 @@ +/** + * Home Page E2E Tests + */ + +describe('Home Page', () => { + beforeEach(() => { + cy.visit('/'); + cy.waitForPageLoad(); + }); + + it('should load the home page', () => { + cy.url().should('eq', `${Cypress.config('baseUrl')}/`); + }); + + it('should display the main navigation', () => { + cy.get('nav').should('be.visible'); + }); + + it('should have proper page title', () => { + cy.title().should('not.be.empty'); + }); + + it('should be responsive on mobile viewport', () => { + cy.viewport('iphone-x'); + cy.get('nav').should('be.visible'); + }); + + it('should be responsive on tablet viewport', () => { + cy.viewport('ipad-2'); + cy.get('nav').should('be.visible'); + }); +}); diff --git a/cypress/e2e/plant-registration.cy.ts b/cypress/e2e/plant-registration.cy.ts new file mode 100644 index 0000000..6aab5e1 --- /dev/null +++ b/cypress/e2e/plant-registration.cy.ts @@ -0,0 +1,51 @@ +/** + * Plant Registration E2E Tests + */ + +describe('Plant Registration', () => { + beforeEach(() => { + cy.visit('/plants/register'); + cy.waitForPageLoad(); + }); + + it('should load the registration page', () => { + cy.url().should('include', '/plants/register'); + }); + + it('should display registration form', () => { + cy.get('form').should('be.visible'); + }); + + it('should have required form fields', () => { + // Check for common form fields + cy.get('input, select, textarea').should('have.length.at.least', 1); + }); + + it('should show validation errors for empty form submission', () => { + // Try to submit empty form + cy.get('form').within(() => { + cy.get('button[type="submit"]').click(); + }); + // Form should not navigate away without valid data + cy.url().should('include', '/plants/register'); + }); + + describe('Form Validation', () => { + it('should require plant name', () => { + cy.get('input[name="name"]').should('exist'); + }); + + it('should require plant species', () => { + cy.get('input[name="species"], select[name="species"]').should('exist'); + }); + }); + + describe('Anonymous Registration', () => { + it('should allow anonymous registration', () => { + cy.visit('/plants/register-anonymous'); + cy.waitForPageLoad(); + cy.url().should('include', '/plants/register-anonymous'); + cy.get('form').should('be.visible'); + }); + }); +}); diff --git a/cypress/e2e/transparency.cy.ts b/cypress/e2e/transparency.cy.ts new file mode 100644 index 0000000..cbb5a7e --- /dev/null +++ b/cypress/e2e/transparency.cy.ts @@ -0,0 +1,49 @@ +/** + * Transparency Dashboard E2E Tests + */ + +describe('Transparency Dashboard', () => { + beforeEach(() => { + cy.visit('/transparency'); + cy.waitForPageLoad(); + }); + + it('should load the transparency page', () => { + cy.url().should('include', '/transparency'); + }); + + it('should display dashboard content', () => { + cy.get('main').should('be.visible'); + }); + + it('should show transparency metrics', () => { + // Check for dashboard sections + cy.get('[data-testid="dashboard"], .dashboard, main').should('be.visible'); + }); + + describe('Data Display', () => { + it('should display charts or data visualizations', () => { + // Look for chart containers or data elements + cy.get('canvas, svg, [class*="chart"], [class*="graph"]').should( + 'have.length.at.least', + 0 + ); + }); + + it('should display audit information', () => { + // Check for audit-related content + cy.contains(/audit|log|record|history/i).should('exist'); + }); + }); + + describe('Accessibility', () => { + it('should have proper heading structure', () => { + cy.get('h1, h2, h3').should('have.length.at.least', 1); + }); + + it('should be keyboard navigable', () => { + cy.get('body').tab(); + cy.focused().should('exist'); + }); + }); +}); diff --git a/cypress/e2e/vertical-farm.cy.ts b/cypress/e2e/vertical-farm.cy.ts new file mode 100644 index 0000000..c11d15a --- /dev/null +++ b/cypress/e2e/vertical-farm.cy.ts @@ -0,0 +1,59 @@ +/** + * Vertical Farm E2E Tests + */ + +describe('Vertical Farm', () => { + describe('Farm List Page', () => { + beforeEach(() => { + cy.visit('/vertical-farm'); + cy.waitForPageLoad(); + }); + + it('should load the vertical farm page', () => { + cy.url().should('include', '/vertical-farm'); + }); + + it('should display farm management content', () => { + cy.get('main').should('be.visible'); + }); + + it('should have navigation to register new farm', () => { + cy.contains(/register|new|add|create/i).should('exist'); + }); + }); + + describe('Farm Registration', () => { + beforeEach(() => { + cy.visit('/vertical-farm/register'); + cy.waitForPageLoad(); + }); + + it('should load the registration page', () => { + cy.url().should('include', '/vertical-farm/register'); + }); + + it('should display registration form', () => { + cy.get('form').should('be.visible'); + }); + + it('should have required form fields', () => { + cy.get('input, select, textarea').should('have.length.at.least', 1); + }); + }); + + describe('Responsiveness', () => { + it('should display correctly on mobile', () => { + cy.viewport('iphone-x'); + cy.visit('/vertical-farm'); + cy.waitForPageLoad(); + cy.get('main').should('be.visible'); + }); + + it('should display correctly on tablet', () => { + cy.viewport('ipad-2'); + cy.visit('/vertical-farm'); + cy.waitForPageLoad(); + cy.get('main').should('be.visible'); + }); + }); +}); diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json index 02e4254..ef1562d 100644 --- a/cypress/fixtures/example.json +++ b/cypress/fixtures/example.json @@ -1,5 +1,25 @@ { - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" + "plants": [ + { + "id": "plant-1", + "name": "Cherry Tomato", + "species": "Tomato", + "variety": "Cherry", + "generation": 1, + "status": "healthy" + }, + { + "id": "plant-2", + "name": "Sweet Basil", + "species": "Basil", + "variety": "Genovese", + "generation": 1, + "status": "thriving" + } + ], + "user": { + "id": "user-1", + "email": "test@example.com", + "name": "Test User" + } } diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 0000000..796c2a2 --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,27 @@ +/** + * Cypress Custom Commands + */ + +// Wait for page to fully load +Cypress.Commands.add('waitForPageLoad', () => { + cy.document().its('readyState').should('eq', 'complete'); +}); + +// Login command (placeholder for auth implementation) +Cypress.Commands.add('login', (email: string, password: string) => { + // This will be implemented when auth is added + cy.log(`Login with ${email}`); + cy.session([email, password], () => { + // Placeholder for auth session + cy.visit('/'); + }); +}); + +// Navigate to a plant page +Cypress.Commands.add('visitPlant', (plantId: string) => { + cy.visit(`/plants/${plantId}`); + cy.waitForPageLoad(); +}); + +// Export empty object for module +export {}; diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 0000000..6c15a65 --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,45 @@ +/** + * Cypress E2E Support File + * This file is processed and loaded automatically before test files. + */ + +// Import commands +import './commands'; + +// Global hooks +beforeEach(() => { + // Clear local storage between tests + cy.clearLocalStorage(); +}); + +// Handle uncaught exceptions +Cypress.on('uncaught:exception', (err, runnable) => { + // Returning false prevents Cypress from failing the test + // This is useful for third-party scripts that may throw errors + if (err.message.includes('ResizeObserver loop')) { + return false; + } + return true; +}); + +// Add custom assertions if needed +declare global { + namespace Cypress { + interface Chainable { + /** + * Custom command to wait for page load + */ + waitForPageLoad(): Chainable; + + /** + * Custom command to login (placeholder for auth tests) + */ + login(email: string, password: string): Chainable; + + /** + * Custom command to navigate to a plant page + */ + visitPlant(plantId: string): Chainable; + } + } +} diff --git a/jest.config.js b/jest.config.js index 823a233..9f4e3fb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,14 +8,20 @@ const config = { '^@/(.*)$': '/$1', }, transform: { - '^.+\\.tsx?$': ['ts-jest', { - tsconfig: 'tsconfig.json', - }], + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + }, + ], }, collectCoverageFrom: [ 'lib/**/*.ts', '!lib/**/*.d.ts', + '!lib/**/types.ts', ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html', 'json-summary'], coverageThreshold: { global: { branches: 80, @@ -24,8 +30,11 @@ const config = { statements: 80, }, }, - setupFilesAfterEnv: [], + setupFilesAfterEnv: ['/__tests__/setup.ts'], + testPathIgnorePatterns: ['/node_modules/', '/.next/', '/cypress/'], verbose: true, + // Increase timeout for async tests + testTimeout: 10000, }; module.exports = config; diff --git a/package.json b/package.json index b1350a8..4d0f4aa 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,20 @@ "start": "next start -p 3001", "preview": "bun run build && bun run start", "lint": "next lint", + "lint:fix": "next lint --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "type-check": "tsc --noEmit", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:ci": "jest --ci --coverage --reporters=default --reporters=jest-junit", "cy:open": "cypress open", "cy:run": "cypress run", "test:e2e": "start-server-and-test 'bun run preview' http://localhost:3001 cy:open", - "test:e2e:ci": "start-server-and-test 'bun run preview' http://localhost:3001 cy:run" + "test:e2e:ci": "start-server-and-test 'bun run preview' http://localhost:3001 cy:run", + "prepare": "husky install", + "validate": "bun run type-check && bun run lint && bun run test" }, "dependencies": { "@tailwindcss/forms": "^0.4.0", @@ -34,15 +41,32 @@ }, "devDependencies": { "@babel/core": "^7.12.9", + "@commitlint/cli": "^18.4.3", + "@commitlint/config-conventional": "^18.4.3", "@types/jest": "^29.5.0", "@types/node": "^17.0.21", "@types/react": "^17.0.0", "autoprefixer": "^10.4.2", + "cypress": "^13.6.0", "eslint-config-next": "^12.0.10", + "husky": "^8.0.3", "jest": "^29.5.0", + "jest-junit": "^16.0.0", + "lint-staged": "^15.2.0", "postcss": "^8.4.5", + "prettier": "^3.1.0", + "start-server-and-test": "^2.0.3", "tailwindcss": "^3.0.15", "ts-jest": "^29.1.0", "typescript": "^4.5.5" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,md,css}": [ + "prettier --write" + ] } }