Merge: Testing and CI/CD infrastructure (Agent 5) - resolved conflicts

This commit is contained in:
Vinnie Esposito 2025-11-23 10:59:14 -06:00
commit 7ddad00f49
23 changed files with 1764 additions and 36 deletions

View file

@ -1,10 +1,13 @@
# LocalGreenChain CI Pipeline
# Agent 4: Production Deployment
# Combined: Agent 4 (Production Deployment) + Agent 5 (Testing)
#
# Runs on every push and pull request:
# - Linting and type checking
# - Linting, formatting, and type checking
# - Unit and integration tests
# - E2E tests with Cypress
# - Build verification
# - Docker build (main branch only)
# - Security scanning
name: CI
@ -20,19 +23,18 @@ concurrency:
env:
NODE_ENV: test
NODE_VERSION: '18'
jobs:
# ==========================================================================
# Lint and Type Check
# ==========================================================================
lint:
name: Lint & Type Check
name: Lint & Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
@ -45,20 +47,36 @@ jobs:
- name: Run ESLint
run: bun run lint
- name: Run TypeScript type check
run: bunx tsc --noEmit
- name: Check formatting
run: bun run format:check
type-check:
name: Type Check
runs-on: ubuntu-latest
timeout-minutes: 10
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
# ==========================================================================
# Unit Tests
# ==========================================================================
test:
name: Unit Tests
name: Unit & Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
@ -69,15 +87,17 @@ jobs:
run: bun install --frozen-lockfile
- name: Run tests with coverage
run: bun run test:coverage
run: bun run test:ci
- name: Upload coverage reports
uses: codecov/codecov-action@v3
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
files: ./coverage/lcov.info
fail_ci_if_error: false
verbose: true
name: test-results
path: |
coverage/
junit.xml
retention-days: 30
# ==========================================================================
# Build
@ -86,11 +106,9 @@ jobs:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [lint, test]
needs: [lint, type-check, test]
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
@ -112,6 +130,56 @@ jobs:
path: .next/
retention-days: 7
# ==========================================================================
# E2E Tests
# ==========================================================================
e2e:
name: E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 20
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-output
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
# ==========================================================================
# Docker Build (only on main branch)
# ==========================================================================
@ -121,10 +189,8 @@ jobs:
timeout-minutes: 20
needs: [build]
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -146,10 +212,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1

36
.husky/_/husky.sh Executable file
View file

@ -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

4
.husky/commit-msg Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit ${1}

4
.husky/pre-commit Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

10
.prettierignore Normal file
View file

@ -0,0 +1,10 @@
node_modules/
.next/
out/
coverage/
.git/
*.min.js
*.min.css
bun.lockb
package-lock.json
yarn.lock

11
.prettierrc Normal file
View file

@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"useTabs": false,
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

View file

@ -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<string, number> = {};
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);
});
});
});

48
__tests__/setup.ts Normal file
View file

@ -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<void> {
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)}`;
}

View file

@ -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);
});
});
});

View file

@ -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<AgentConfig>) {
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<AgentTask | null> {
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<string, any>) {
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');
});
});
});

View file

@ -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);
});
});
});

View file

@ -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>): 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);
});
});
});

25
commitlint.config.js Normal file
View file

@ -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],
},
};

35
cypress.config.ts Normal file
View file

@ -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',
},
},
});

32
cypress/e2e/home.cy.ts Normal file
View file

@ -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');
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});
});

View file

@ -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"
}
}

View file

@ -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 {};

45
cypress/support/e2e.ts Normal file
View file

@ -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<void>;
/**
* Custom command to login (placeholder for auth tests)
*/
login(email: string, password: string): Chainable<void>;
/**
* Custom command to navigate to a plant page
*/
visitPlant(plantId: string): Chainable<void>;
}
}
}

View file

@ -8,14 +8,20 @@ const config = {
'^@/(.*)$': '<rootDir>/$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: ['<rootDir>/__tests__/setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/.next/', '/cypress/'],
verbose: true,
// Increase timeout for async tests
testTimeout: 10000,
};
module.exports = config;

View file

@ -9,9 +9,14 @@
"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",
@ -21,7 +26,9 @@
"db:migrate": "prisma migrate dev",
"db:migrate:prod": "prisma migrate deploy",
"db:seed": "bun run prisma/seed.ts",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"prepare": "husky install",
"validate": "bun run type-check && bun run lint && bun run test"
},
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.7",
@ -44,17 +51,34 @@
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@commitlint/cli": "^18.4.3",
"@commitlint/config-conventional": "^18.4.3",
"@types/bcryptjs": "^3.0.0",
"@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",
"prisma": "^5.7.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"
]
}
}