Files
n8n-mcp/tests/integration/mcp-protocol/performance.test.ts
Romuald Członkowski ff69e4ccca feat: Tool Consolidation - Reduce MCP Tools by 38% (v2.26.0) (#439)
* feat: Remove 9 low-value tools and consolidate n8n_health_check (v2.25.0)

Telemetry-driven tool cleanup to improve API clarity:

**Removed Tools (9):**
- list_nodes - Use search_nodes instead
- list_ai_tools - Use search_nodes with isAITool filter
- list_tasks - Low usage (0.02%)
- get_database_statistics - Use n8n_health_check
- list_templates - Use search_templates or get_templates_for_task
- get_node_as_tool_info - Documented in get_node
- validate_workflow_connections - Use validate_workflow
- validate_workflow_expressions - Use validate_workflow
- n8n_list_available_tools - Use n8n_health_check
- n8n_diagnostic - Merged into n8n_health_check

**Consolidated Tool:**
- n8n_health_check now supports mode='diagnostic' for detailed troubleshooting

**Tool Count:**
- Before: 38 tools
- After: 31 tools (18% reduction)

Concieved by Romuald Członkowski - www.aiadvisors.pl/en

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: cleanup stale references and update tests after tool removal

- Remove handleListAvailableTools dead code from handlers-n8n-manager.ts
- Update error messages to reference n8n_health_check(mode="diagnostic") instead of n8n_diagnostic
- Update tool counts in diagnostic messages (14 doc tools, 31 total)
- Fix error-handling.test.ts to use valid tools (search_nodes, tools_documentation)
- Remove obsolete list-tools.test.ts integration tests
- Remove unused ListToolsResponse type from response-types.ts
- Update tools.ts QUICK REFERENCE to remove list_nodes references
- Update tools-documentation.ts to remove references to removed tools
- Update tool-docs files to remove stale relatedTools references
- Fix tools.test.ts to not test removed tools (list_nodes, list_ai_tools, etc.)
- Fix parameter-validation.test.ts to not test removed tools
- Update handlers-n8n-manager.test.ts error message expectations

All 399 MCP unit tests now pass.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: update integration tests to use valid tools after v2.25.0 removal

Replaced all references to removed tools in integration tests:
- list_nodes -> search_nodes
- get_database_statistics -> tools_documentation
- list_ai_tools -> search_nodes/tools_documentation
- list_tasks -> tools_documentation
- get_node_as_tool_info -> removed test section

Updated test files:
- tests/integration/mcp-protocol/basic-connection.test.ts
- tests/integration/mcp-protocol/performance.test.ts
- tests/integration/mcp-protocol/session-management.test.ts
- tests/integration/mcp-protocol/test-helpers.ts
- tests/integration/mcp-protocol/tool-invocation.test.ts
- tests/integration/telemetry/mcp-telemetry.test.ts
- tests/unit/mcp/disabled-tools.test.ts
- tests/unit/mcp/tools-documentation.test.ts

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: Tool consolidation v2.26.0 - reduce tools by 38% (31 → 19)

Major consolidation of MCP tools using mode-based parameters for better
AI agent ergonomics:

Node Tools:
- get_node_documentation → get_node with mode='documentation'
- search_node_properties → get_node with mode='search_properties'
- get_property_dependencies → removed

Validation Tools:
- validate_node_operation + validate_node_minimal → validate_node with mode param

Template Tools:
- list_node_templates → search_templates with searchMode='nodes'
- search_templates_by_metadata → search_templates with searchMode='metadata'
- get_templates_for_task → search_templates with searchMode='task'

Workflow Getters:
- n8n_get_workflow_details/structure/minimal → n8n_get_workflow with mode param

Execution Tools:
- n8n_list/get/delete_execution → n8n_executions with action param

Test updates for all consolidated tools.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

* docs: comprehensive README update for v2.26.0 tool consolidation

- Quick Start: Added hosted service (dashboard.n8n-mcp.com) as primary option
- Self-hosting: Renamed options to A (npx), B (Docker), C (Local), D (Railway)
- Removed: "Memory Leak Fix (v2.20.2)" section (outdated)
- Removed: "Known Issues" section (outdated container management)
- Claude Project Setup: Updated all tool references to v2.26.0 consolidated tools
  - validate_node({mode: 'minimal'|'full'}) instead of separate tools
  - search_templates({searchMode: ...}) unified template search
  - get_node({mode: 'docs'|'search_properties'}) for documentation
  - n8n_executions({action: ...}) unified execution management
- Available MCP Tools: Updated to show 19 consolidated tools (7 core + 12 mgmt)
- Recent Updates: Simplified to just link to CHANGELOG.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

* fix: update tool count from 31 to 19 in diagnostic message

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): update tool count expectations for v2.26.0

Update handlers-n8n-manager.test.ts to expect new consolidated
tool counts (7/12/19) after v2.26.0 tool consolidation.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-25 18:39:00 +01:00

609 lines
21 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { TestableN8NMCPServer } from './test-helpers';
describe('MCP Performance Tests', () => {
let mcpServer: TestableN8NMCPServer;
let client: Client;
beforeEach(async () => {
mcpServer = new TestableN8NMCPServer();
await mcpServer.initialize();
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.connectToTransport(serverTransport);
client = new Client({
name: 'test-client',
version: '1.0.0'
}, {
capabilities: {}
});
await client.connect(clientTransport);
// Verify database is populated by searching for a common node
const searchResponse = await client.callTool({ name: 'search_nodes', arguments: { query: 'http', limit: 1 } });
if ((searchResponse as any).content && (searchResponse as any).content[0]) {
const searchResult = JSON.parse((searchResponse as any).content[0].text);
// Ensure database has nodes for testing
if (!searchResult.totalCount || searchResult.totalCount === 0) {
console.error('Search result:', searchResult);
throw new Error('Test database not properly populated');
}
}
});
afterEach(async () => {
await client.close();
await mcpServer.close();
});
describe('Response Time Benchmarks', () => {
it('should respond to simple queries quickly', async () => {
const iterations = 100;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
await client.callTool({ name: 'tools_documentation', arguments: {} });
}
const duration = performance.now() - start;
const avgTime = duration / iterations;
console.log(`Average response time for tools_documentation: ${avgTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Environment-aware threshold (relaxed +20% for type safety overhead)
const threshold = process.env.CI ? 20 : 12;
expect(avgTime).toBeLessThan(threshold);
});
it('should handle search operations efficiently', async () => {
const iterations = 50;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
await client.callTool({ name: 'search_nodes', arguments: { query: 'http', limit: 10 } });
}
const duration = performance.now() - start;
const avgTime = duration / iterations;
console.log(`Average response time for search_nodes: ${avgTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Environment-aware threshold
const threshold = process.env.CI ? 40 : 20;
expect(avgTime).toBeLessThan(threshold);
});
it('should perform searches efficiently', async () => {
const searches = ['http', 'webhook', 'slack', 'database', 'api'];
const iterations = 20;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
for (const query of searches) {
await client.callTool({ name: 'search_nodes', arguments: { query } });
}
}
const totalRequests = iterations * searches.length;
const duration = performance.now() - start;
const avgTime = duration / totalRequests;
console.log(`Average response time for search_nodes: ${avgTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Environment-aware threshold
const threshold = process.env.CI ? 60 : 30;
expect(avgTime).toBeLessThan(threshold);
});
it('should retrieve node info quickly', async () => {
const nodeTypes = [
'nodes-base.httpRequest',
'nodes-base.webhook',
'nodes-base.set',
'nodes-base.if',
'nodes-base.switch'
];
const start = performance.now();
for (const nodeType of nodeTypes) {
await client.callTool({ name: 'get_node', arguments: { nodeType } });
}
const duration = performance.now() - start;
const avgTime = duration / nodeTypes.length;
console.log(`Average response time for get_node: ${avgTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Environment-aware threshold (these are large responses)
const threshold = process.env.CI ? 100 : 50;
expect(avgTime).toBeLessThan(threshold);
});
});
describe('Concurrent Request Performance', () => {
it('should handle concurrent requests efficiently', async () => {
const concurrentRequests = 50;
const start = performance.now();
const promises = [];
for (let i = 0; i < concurrentRequests; i++) {
promises.push(
client.callTool({ name: 'search_nodes', arguments: { query: 'http', limit: 5 } })
);
}
await Promise.all(promises);
const duration = performance.now() - start;
const avgTime = duration / concurrentRequests;
console.log(`Average time for ${concurrentRequests} concurrent requests: ${avgTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Concurrent requests should be more efficient than sequential
const threshold = process.env.CI ? 25 : 10;
expect(avgTime).toBeLessThan(threshold);
});
it('should handle mixed concurrent operations', async () => {
const operations = [
{ tool: 'search_nodes', params: { query: 'http', limit: 10 } },
{ tool: 'search_nodes', params: { query: 'webhook' } },
{ tool: 'tools_documentation', params: {} },
{ tool: 'get_node', params: { nodeType: 'nodes-base.httpRequest' } },
{ tool: 'get_node', params: { nodeType: 'nodes-base.webhook' } }
];
const rounds = 10;
const start = performance.now();
for (let round = 0; round < rounds; round++) {
const promises = operations.map(op =>
client.callTool({ name: op.tool, arguments: op.params })
);
await Promise.all(promises);
}
const duration = performance.now() - start;
const totalRequests = rounds * operations.length;
const avgTime = duration / totalRequests;
console.log(`Average time for mixed operations: ${avgTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
const threshold = process.env.CI ? 40 : 20;
expect(avgTime).toBeLessThan(threshold);
});
});
describe('Large Data Performance', () => {
it('should handle large search results efficiently', async () => {
const start = performance.now();
const response = await client.callTool({ name: 'search_nodes', arguments: {
query: 'n8n', // Broad query to get many results
limit: 200
} });
const duration = performance.now() - start;
console.log(`Time to search 200 nodes: ${duration.toFixed(2)}ms`);
// Environment-aware threshold
const threshold = process.env.CI ? 200 : 100;
expect(duration).toBeLessThan(threshold);
// Check the response content
expect(response).toBeDefined();
let results;
if (response.content && Array.isArray(response.content) && response.content[0]) {
// MCP standard response format
expect(response.content[0].type).toBe('text');
expect(response.content[0].text).toBeDefined();
try {
const parsed = JSON.parse(response.content[0].text);
// search_nodes returns an object with results property
results = parsed.results || parsed;
} catch (e) {
console.error('Failed to parse JSON:', e);
console.error('Response text was:', response.content[0].text);
throw e;
}
} else if (Array.isArray(response)) {
// Direct array response
results = response;
} else if (response.results) {
// Object with results property
results = response.results;
} else {
console.error('Unexpected response format:', response);
throw new Error('Unexpected response format');
}
expect(results).toBeDefined();
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBeGreaterThan(50);
});
it('should handle large workflow validation efficiently', async () => {
// Create a large workflow
const nodeCount = 100;
const nodes = [];
const connections: any = {};
for (let i = 0; i < nodeCount; i++) {
nodes.push({
id: String(i),
name: `Node${i}`,
type: i % 3 === 0 ? 'nodes-base.httpRequest' : 'nodes-base.set',
typeVersion: 1,
position: [i * 100, 0],
parameters: i % 3 === 0 ?
{ method: 'GET', url: 'https://api.example.com' } :
{ values: { string: [{ name: 'test', value: 'value' }] } }
});
if (i > 0) {
connections[`Node${i-1}`] = {
'main': [[{ node: `Node${i}`, type: 'main', index: 0 }]]
};
}
}
const start = performance.now();
const response = await client.callTool({ name: 'validate_workflow', arguments: {
workflow: { nodes, connections }
} });
const duration = performance.now() - start;
console.log(`Time to validate ${nodeCount} node workflow: ${duration.toFixed(2)}ms`);
// Environment-aware threshold
const threshold = process.env.CI ? 1000 : 500;
expect(duration).toBeLessThan(threshold);
// Check the response content - MCP callTool returns content array with text
expect(response).toBeDefined();
expect((response as any).content).toBeDefined();
expect(Array.isArray((response as any).content)).toBe(true);
expect((response as any).content.length).toBeGreaterThan(0);
expect((response as any).content[0]).toBeDefined();
expect((response as any).content[0].type).toBe('text');
expect((response as any).content[0].text).toBeDefined();
// Parse the JSON response
const validation = JSON.parse((response as any).content[0].text);
expect(validation).toBeDefined();
expect(validation).toHaveProperty('valid');
});
});
describe('Memory Efficiency', () => {
it('should handle repeated operations without memory leaks', async () => {
const iterations = 1000;
const batchSize = 100;
// Measure initial memory if available
const initialMemory = process.memoryUsage();
for (let i = 0; i < iterations; i += batchSize) {
const promises = [];
for (let j = 0; j < batchSize; j++) {
promises.push(
client.callTool({ name: 'tools_documentation', arguments: {} })
);
}
await Promise.all(promises);
// Force garbage collection if available
if (global.gc) {
global.gc();
}
}
const finalMemory = process.memoryUsage();
const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed;
console.log(`Memory increase after ${iterations} operations: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB`);
// Memory increase should be reasonable (less than 50MB)
expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024);
});
it('should release memory after large operations', async () => {
const initialMemory = process.memoryUsage();
// Perform large operations
for (let i = 0; i < 10; i++) {
await client.callTool({ name: 'search_nodes', arguments: { query: 'n8n', limit: 200 } });
await client.callTool({ name: 'get_node', arguments: {
nodeType: 'nodes-base.httpRequest'
} });
}
// Force garbage collection if available
if (global.gc) {
global.gc();
await new Promise(resolve => setTimeout(resolve, 100));
}
const finalMemory = process.memoryUsage();
const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed;
console.log(`Memory increase after large operations: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB`);
// Should not retain excessive memory
expect(memoryIncrease).toBeLessThan(20 * 1024 * 1024);
});
});
describe('Scalability Tests', () => {
it('should maintain performance with increasing load', async () => {
const loadLevels = [10, 50, 100, 200];
const results: any[] = [];
for (const load of loadLevels) {
const start = performance.now();
const promises = [];
for (let i = 0; i < load; i++) {
promises.push(
client.callTool({ name: 'search_nodes', arguments: { query: 'http', limit: 1 } })
);
}
await Promise.all(promises);
const duration = performance.now() - start;
const avgTime = duration / load;
results.push({
load,
totalTime: duration,
avgTime
});
console.log(`Load ${load}: Total ${duration.toFixed(2)}ms, Avg ${avgTime.toFixed(2)}ms`);
}
// Average time should not increase dramatically with load
const firstAvg = results[0].avgTime;
const lastAvg = results[results.length - 1].avgTime;
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
console.log(`Performance scaling - First avg: ${firstAvg.toFixed(2)}ms, Last avg: ${lastAvg.toFixed(2)}ms`);
// Environment-aware scaling factor
const scalingFactor = process.env.CI ? 3 : 2;
expect(lastAvg).toBeLessThan(firstAvg * scalingFactor);
});
it('should handle burst traffic', async () => {
const burstSize = 100;
const start = performance.now();
// Simulate burst of requests
const promises = [];
for (let i = 0; i < burstSize; i++) {
const operation = i % 4;
switch (operation) {
case 0:
promises.push(client.callTool({ name: 'search_nodes', arguments: { query: 'http', limit: 5 } }));
break;
case 1:
promises.push(client.callTool({ name: 'search_nodes', arguments: { query: 'test' } }));
break;
case 2:
promises.push(client.callTool({ name: 'tools_documentation', arguments: {} }));
break;
case 3:
promises.push(client.callTool({ name: 'get_node', arguments: { nodeType: 'nodes-base.set' } }));
break;
}
}
await Promise.all(promises);
const duration = performance.now() - start;
console.log(`Burst of ${burstSize} requests completed in ${duration.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Should handle burst within reasonable time
const threshold = process.env.CI ? 2000 : 1000;
expect(duration).toBeLessThan(threshold);
});
});
describe('Critical Path Optimization', () => {
it('should optimize search performance', async () => {
// Warm up with multiple calls to ensure everything is initialized
for (let i = 0; i < 5; i++) {
await client.callTool({ name: 'search_nodes', arguments: { query: 'http', limit: 1 } });
}
const iterations = 100;
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
await client.callTool({ name: 'search_nodes', arguments: { query: 'http', limit: 20 } });
times.push(performance.now() - start);
}
// Remove outliers (first few runs might be slower)
times.sort((a, b) => a - b);
const trimmedTimes = times.slice(10, -10); // Remove top and bottom 10%
const avgTime = trimmedTimes.reduce((a, b) => a + b, 0) / trimmedTimes.length;
const minTime = Math.min(...trimmedTimes);
const maxTime = Math.max(...trimmedTimes);
console.log(`search_nodes performance - Avg: ${avgTime.toFixed(2)}ms, Min: ${minTime.toFixed(2)}ms, Max: ${maxTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Environment-aware thresholds
const threshold = process.env.CI ? 25 : 10;
expect(avgTime).toBeLessThan(threshold);
// Max should not be too much higher than average (no outliers)
// More lenient in CI due to resource contention
const maxMultiplier = process.env.CI ? 5 : 3;
expect(maxTime).toBeLessThan(avgTime * maxMultiplier);
});
it('should handle varied search queries efficiently', async () => {
// Warm up with multiple calls
for (let i = 0; i < 3; i++) {
await client.callTool({ name: 'search_nodes', arguments: { query: 'test' } });
}
const queries = ['http', 'webhook', 'database', 'api', 'slack'];
const times: number[] = [];
for (const query of queries) {
for (let i = 0; i < 20; i++) {
const start = performance.now();
await client.callTool({ name: 'search_nodes', arguments: { query } });
times.push(performance.now() - start);
}
}
// Remove outliers
times.sort((a, b) => a - b);
const trimmedTimes = times.slice(10, -10); // Remove top and bottom 10%
const avgTime = trimmedTimes.reduce((a, b) => a + b, 0) / trimmedTimes.length;
console.log(`search_nodes average performance: ${avgTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Environment-aware threshold
const threshold = process.env.CI ? 35 : 15;
expect(avgTime).toBeLessThan(threshold);
});
it('should cache effectively for repeated queries', async () => {
const nodeType = 'nodes-base.httpRequest';
// First call (cold)
const coldStart = performance.now();
await client.callTool({ name: 'get_node', arguments: { nodeType } });
const coldTime = performance.now() - coldStart;
// Give cache time to settle
await new Promise(resolve => setTimeout(resolve, 10));
// Subsequent calls (potentially cached)
const warmTimes: number[] = [];
for (let i = 0; i < 10; i++) {
const start = performance.now();
await client.callTool({ name: 'get_node', arguments: { nodeType } });
warmTimes.push(performance.now() - start);
}
// Remove outliers from warm times
warmTimes.sort((a, b) => a - b);
const trimmedWarmTimes = warmTimes.slice(1, -1); // Remove highest and lowest
const avgWarmTime = trimmedWarmTimes.reduce((a, b) => a + b, 0) / trimmedWarmTimes.length;
console.log(`Cold time: ${coldTime.toFixed(2)}ms, Avg warm time: ${avgWarmTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// In CI, caching might not be as effective due to resource constraints
const cacheMultiplier = process.env.CI ? 1.5 : 1.1;
// Warm calls should be faster or at least not significantly slower
expect(avgWarmTime).toBeLessThanOrEqual(coldTime * cacheMultiplier);
});
});
describe('Stress Tests', () => {
it('should handle sustained high load', async () => {
const duration = 5000; // 5 seconds
const start = performance.now();
let requestCount = 0;
let errorCount = 0;
while (performance.now() - start < duration) {
try {
await client.callTool({ name: 'tools_documentation', arguments: {} });
requestCount++;
} catch (error) {
errorCount++;
}
}
const actualDuration = performance.now() - start;
const requestsPerSecond = requestCount / (actualDuration / 1000);
console.log(`Sustained load test - Requests: ${requestCount}, RPS: ${requestsPerSecond.toFixed(2)}, Errors: ${errorCount}`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Environment-aware RPS threshold
// Relaxed to 75 RPS locally to account for parallel test execution overhead
const rpsThreshold = process.env.CI ? 50 : 75;
expect(requestsPerSecond).toBeGreaterThan(rpsThreshold);
// Error rate should be very low
expect(errorCount).toBe(0);
});
it('should recover from performance degradation', async () => {
// Create heavy load
const heavyPromises = [];
for (let i = 0; i < 200; i++) {
heavyPromises.push(
client.callTool({ name: 'validate_workflow', arguments: {
workflow: {
nodes: Array(20).fill(null).map((_, idx) => ({
id: String(idx),
name: `Node${idx}`,
type: 'nodes-base.set',
typeVersion: 1,
position: [idx * 100, 0],
parameters: {}
})),
connections: {}
}
} })
);
}
await Promise.all(heavyPromises);
// Measure performance after heavy load
const recoveryTimes: number[] = [];
for (let i = 0; i < 10; i++) {
const start = performance.now();
await client.callTool({ name: 'tools_documentation', arguments: {} });
recoveryTimes.push(performance.now() - start);
}
const avgRecoveryTime = recoveryTimes.reduce((a, b) => a + b, 0) / recoveryTimes.length;
console.log(`Average response time after heavy load: ${avgRecoveryTime.toFixed(2)}ms`);
console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
// Should recover to normal performance (relaxed +20% for type safety overhead)
const threshold = process.env.CI ? 25 : 12;
expect(avgRecoveryTime).toBeLessThan(threshold);
});
});
});