update
This commit is contained in:
238
test/helpers/corpus.loader.ts
Normal file
238
test/helpers/corpus.loader.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import * as path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as plugins from '../../ts/plugins.js';
|
||||
|
||||
/**
|
||||
* Corpus loader for managing test invoice files
|
||||
*/
|
||||
|
||||
export interface CorpusFile {
|
||||
path: string;
|
||||
format: string;
|
||||
category: string;
|
||||
size: number;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export class CorpusLoader {
|
||||
private static basePath = path.join(process.cwd(), 'test/assets/corpus');
|
||||
private static cache = new Map<string, Buffer>();
|
||||
|
||||
/**
|
||||
* Corpus categories with their paths
|
||||
*/
|
||||
static readonly CATEGORIES = {
|
||||
CII_XMLRECHNUNG: 'XML-Rechnung/CII',
|
||||
UBL_XMLRECHNUNG: 'XML-Rechnung/UBL',
|
||||
ZUGFERD_V1_CORRECT: 'ZUGFeRDv1/correct',
|
||||
ZUGFERD_V1_FAIL: 'ZUGFeRDv1/fail',
|
||||
ZUGFERD_V2_CORRECT: 'ZUGFeRDv2/correct',
|
||||
ZUGFERD_V2_FAIL: 'ZUGFeRDv2/fail',
|
||||
PEPPOL: 'PEPPOL/Valid/Qvalia',
|
||||
FATTURAPA_OFFICIAL: 'fatturaPA/official',
|
||||
FATTURAPA_EIGOR: 'fatturaPA/eigor',
|
||||
EN16931_CII: 'eInvoicing-EN16931/cii/examples',
|
||||
EN16931_UBL_EXAMPLES: 'eInvoicing-EN16931/ubl/examples',
|
||||
EN16931_UBL_INVOICE: 'eInvoicing-EN16931/test/Invoice-unit-UBL',
|
||||
EN16931_UBL_CREDITNOTE: 'eInvoicing-EN16931/test/CreditNote-unit-UBL',
|
||||
EDIFACT_EXAMPLES: 'eInvoicing-EN16931/edifact/examples',
|
||||
OTHER: 'other',
|
||||
INCOMING: 'incoming',
|
||||
UNSTRUCTURED: 'unstructured'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Load a single corpus file
|
||||
*/
|
||||
static async loadFile(filePath: string): Promise<Buffer> {
|
||||
const fullPath = path.join(this.basePath, filePath);
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(fullPath)) {
|
||||
return this.cache.get(fullPath)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await fs.readFile(fullPath);
|
||||
|
||||
// Cache files under 10MB
|
||||
if (buffer.length < 10 * 1024 * 1024) {
|
||||
this.cache.set(fullPath, buffer);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load corpus file ${filePath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all files from a category
|
||||
*/
|
||||
static async loadCategory(category: keyof typeof CorpusLoader.CATEGORIES): Promise<CorpusFile[]> {
|
||||
const categoryPath = this.CATEGORIES[category];
|
||||
const fullPath = path.join(this.basePath, categoryPath);
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||
const files: CorpusFile[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && this.isInvoiceFile(entry.name)) {
|
||||
const filePath = path.join(categoryPath, entry.name);
|
||||
const stat = await fs.stat(path.join(this.basePath, filePath));
|
||||
|
||||
files.push({
|
||||
path: filePath,
|
||||
format: this.detectFormatFromPath(filePath),
|
||||
category: category,
|
||||
size: stat.size,
|
||||
valid: !categoryPath.includes('fail')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load category ${category}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load files matching a pattern
|
||||
*/
|
||||
static async loadPattern(pattern: string, category?: keyof typeof CorpusLoader.CATEGORIES): Promise<CorpusFile[]> {
|
||||
const files: CorpusFile[] = [];
|
||||
const categoriesToSearch = category ? [category] : Object.keys(this.CATEGORIES) as Array<keyof typeof CorpusLoader.CATEGORIES>;
|
||||
|
||||
for (const cat of categoriesToSearch) {
|
||||
const categoryFiles = await this.loadCategory(cat);
|
||||
const matchingFiles = categoryFiles.filter(file =>
|
||||
path.basename(file.path).match(pattern.replace('*', '.*'))
|
||||
);
|
||||
files.push(...matchingFiles);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get corpus statistics
|
||||
*/
|
||||
static async getStatistics(): Promise<{
|
||||
totalFiles: number;
|
||||
totalSize: number;
|
||||
byFormat: Record<string, number>;
|
||||
byCategory: Record<string, number>;
|
||||
validFiles: number;
|
||||
invalidFiles: number;
|
||||
}> {
|
||||
const stats = {
|
||||
totalFiles: 0,
|
||||
totalSize: 0,
|
||||
byFormat: {} as Record<string, number>,
|
||||
byCategory: {} as Record<string, number>,
|
||||
validFiles: 0,
|
||||
invalidFiles: 0
|
||||
};
|
||||
|
||||
for (const category of Object.keys(this.CATEGORIES) as Array<keyof typeof CorpusLoader.CATEGORIES>) {
|
||||
const files = await this.loadCategory(category);
|
||||
|
||||
stats.totalFiles += files.length;
|
||||
stats.byCategory[category] = files.length;
|
||||
|
||||
for (const file of files) {
|
||||
stats.totalSize += file.size;
|
||||
stats.byFormat[file.format] = (stats.byFormat[file.format] || 0) + 1;
|
||||
|
||||
if (file.valid) {
|
||||
stats.validFiles++;
|
||||
} else {
|
||||
stats.invalidFiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the file cache
|
||||
*/
|
||||
static clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an invoice file
|
||||
*/
|
||||
private static isInvoiceFile(filename: string): boolean {
|
||||
const extensions = ['.xml', '.pdf', '.txt'];
|
||||
return extensions.some(ext => filename.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect format from file path
|
||||
*/
|
||||
private static detectFormatFromPath(filePath: string): string {
|
||||
const filename = path.basename(filePath).toLowerCase();
|
||||
|
||||
if (filename.includes('.cii.')) return 'CII';
|
||||
if (filename.includes('.ubl.')) return 'UBL';
|
||||
if (filename.includes('zugferd')) return 'ZUGFeRD';
|
||||
if (filename.includes('factur')) return 'Factur-X';
|
||||
if (filename.includes('xrechnung')) return 'XRechnung';
|
||||
if (filename.includes('fattura')) return 'FatturaPA';
|
||||
if (filename.includes('peppol')) return 'PEPPOL';
|
||||
if (filename.endsWith('.pdf')) return 'PDF';
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files from a category (alias for loadCategory for consistency)
|
||||
*/
|
||||
static async getFiles(category: keyof typeof CorpusLoader.CATEGORIES): Promise<string[]> {
|
||||
const files = await this.loadCategory(category);
|
||||
return files.map(f => path.join(this.basePath, f.path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test dataset from corpus files
|
||||
*/
|
||||
static async createTestDataset(options: {
|
||||
formats?: string[];
|
||||
categories?: Array<keyof typeof CorpusLoader.CATEGORIES>;
|
||||
maxFiles?: number;
|
||||
validOnly?: boolean;
|
||||
} = {}): Promise<CorpusFile[]> {
|
||||
let files: CorpusFile[] = [];
|
||||
|
||||
const categoriesToLoad = options.categories || Object.keys(this.CATEGORIES) as Array<keyof typeof CorpusLoader.CATEGORIES>;
|
||||
|
||||
for (const category of categoriesToLoad) {
|
||||
const categoryFiles = await this.loadCategory(category);
|
||||
files.push(...categoryFiles);
|
||||
}
|
||||
|
||||
// Filter by format if specified
|
||||
if (options.formats && options.formats.length > 0) {
|
||||
files = files.filter(f => options.formats!.includes(f.format));
|
||||
}
|
||||
|
||||
// Filter by validity if specified
|
||||
if (options.validOnly) {
|
||||
files = files.filter(f => f.valid);
|
||||
}
|
||||
|
||||
// Limit number of files if specified
|
||||
if (options.maxFiles && files.length > options.maxFiles) {
|
||||
// Shuffle and take first N files for variety
|
||||
files = files.sort(() => Math.random() - 0.5).slice(0, options.maxFiles);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
}
|
335
test/helpers/performance.tracker.ts
Normal file
335
test/helpers/performance.tracker.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import * as os from 'os';
|
||||
|
||||
/**
|
||||
* Performance tracking utilities for test suite
|
||||
*/
|
||||
|
||||
export interface PerformanceMetric {
|
||||
operation: string;
|
||||
duration: number;
|
||||
timestamp: number;
|
||||
memory: {
|
||||
used: number;
|
||||
total: number;
|
||||
external: number;
|
||||
};
|
||||
cpu?: {
|
||||
user: number;
|
||||
system: number;
|
||||
};
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface PerformanceStats {
|
||||
count: number;
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
median: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
stdDev: number;
|
||||
}
|
||||
|
||||
export class PerformanceTracker {
|
||||
private static metrics = new Map<string, PerformanceMetric[]>();
|
||||
private static thresholds = new Map<string, { target: number; acceptable: number; maximum: number }>();
|
||||
|
||||
/**
|
||||
* Set performance thresholds for an operation
|
||||
*/
|
||||
static setThreshold(operation: string, target: number, acceptable: number, maximum: number): void {
|
||||
this.thresholds.set(operation, { target, acceptable, maximum });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default thresholds based on test/readme.md
|
||||
*/
|
||||
static initializeDefaultThresholds(): void {
|
||||
this.setThreshold('format-detection', 5, 10, 50);
|
||||
this.setThreshold('xml-parsing-1mb', 50, 100, 500);
|
||||
this.setThreshold('validation-syntax', 20, 50, 200);
|
||||
this.setThreshold('validation-business', 100, 200, 1000);
|
||||
this.setThreshold('pdf-extraction', 200, 500, 2000);
|
||||
this.setThreshold('format-conversion', 100, 200, 1000);
|
||||
this.setThreshold('memory-per-invoice', 50, 100, 500); // MB
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a performance metric
|
||||
*/
|
||||
static async track<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<{ result: T; metric: PerformanceMetric }> {
|
||||
const startMemory = process.memoryUsage();
|
||||
const startCpu = process.cpuUsage();
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
|
||||
const endTime = performance.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
const endCpu = process.cpuUsage(startCpu);
|
||||
|
||||
const metric: PerformanceMetric = {
|
||||
operation,
|
||||
duration: endTime - startTime,
|
||||
timestamp: Date.now(),
|
||||
memory: {
|
||||
used: endMemory.heapUsed - startMemory.heapUsed,
|
||||
total: endMemory.heapTotal,
|
||||
external: endMemory.external
|
||||
},
|
||||
cpu: {
|
||||
user: endCpu.user / 1000, // Convert to milliseconds
|
||||
system: endCpu.system / 1000
|
||||
},
|
||||
metadata
|
||||
};
|
||||
|
||||
// Store metric
|
||||
if (!this.metrics.has(operation)) {
|
||||
this.metrics.set(operation, []);
|
||||
}
|
||||
this.metrics.get(operation)!.push(metric);
|
||||
|
||||
// Check threshold
|
||||
this.checkThreshold(operation, metric);
|
||||
|
||||
return { result, metric };
|
||||
} catch (error) {
|
||||
// Still track failed operations
|
||||
const endTime = performance.now();
|
||||
const metric: PerformanceMetric = {
|
||||
operation,
|
||||
duration: endTime - startTime,
|
||||
timestamp: Date.now(),
|
||||
memory: {
|
||||
used: 0,
|
||||
total: process.memoryUsage().heapTotal,
|
||||
external: process.memoryUsage().external
|
||||
},
|
||||
metadata: { ...metadata, error: error.message }
|
||||
};
|
||||
|
||||
if (!this.metrics.has(operation)) {
|
||||
this.metrics.set(operation, []);
|
||||
}
|
||||
this.metrics.get(operation)!.push(metric);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for an operation
|
||||
*/
|
||||
static getStats(operation: string): PerformanceStats | null {
|
||||
const metrics = this.metrics.get(operation);
|
||||
if (!metrics || metrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durations = metrics.map(m => m.duration).sort((a, b) => a - b);
|
||||
const sum = durations.reduce((a, b) => a + b, 0);
|
||||
const avg = sum / durations.length;
|
||||
|
||||
// Calculate standard deviation
|
||||
const squaredDiffs = durations.map(d => Math.pow(d - avg, 2));
|
||||
const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const stdDev = Math.sqrt(avgSquaredDiff);
|
||||
|
||||
return {
|
||||
count: durations.length,
|
||||
min: durations[0],
|
||||
max: durations[durations.length - 1],
|
||||
avg,
|
||||
median: durations[Math.floor(durations.length / 2)],
|
||||
p95: durations[Math.floor(durations.length * 0.95)],
|
||||
p99: durations[Math.floor(durations.length * 0.99)],
|
||||
stdDev
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics for an operation (alias for getStats)
|
||||
*/
|
||||
static async getSummary(operation: string): Promise<{
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
p95: number;
|
||||
} | null> {
|
||||
const stats = this.getStats(operation);
|
||||
if (!stats) return null;
|
||||
|
||||
return {
|
||||
average: stats.avg,
|
||||
min: stats.min,
|
||||
max: stats.max,
|
||||
p95: stats.p95
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory statistics
|
||||
*/
|
||||
static getMemoryStats(operation: string): {
|
||||
avgMemoryUsed: number;
|
||||
maxMemoryUsed: number;
|
||||
avgMemoryTotal: number;
|
||||
} | null {
|
||||
const metrics = this.metrics.get(operation);
|
||||
if (!metrics || metrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryUsed = metrics.map(m => m.memory.used);
|
||||
const memoryTotal = metrics.map(m => m.memory.total);
|
||||
|
||||
return {
|
||||
avgMemoryUsed: memoryUsed.reduce((a, b) => a + b, 0) / memoryUsed.length / 1024 / 1024, // MB
|
||||
maxMemoryUsed: Math.max(...memoryUsed) / 1024 / 1024, // MB
|
||||
avgMemoryTotal: memoryTotal.reduce((a, b) => a + b, 0) / memoryTotal.length / 1024 / 1024 // MB
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report
|
||||
*/
|
||||
static generateReport(): string {
|
||||
let report = '# Performance Report\n\n';
|
||||
report += `Generated at: ${new Date().toISOString()}\n`;
|
||||
report += `Platform: ${os.platform()} ${os.arch()}\n`;
|
||||
report += `Node.js: ${process.version}\n`;
|
||||
report += `CPUs: ${os.cpus().length}x ${os.cpus()[0].model}\n`;
|
||||
report += `Total Memory: ${(os.totalmem() / 1024 / 1024 / 1024).toFixed(2)} GB\n\n`;
|
||||
|
||||
for (const [operation, metrics] of this.metrics) {
|
||||
const stats = this.getStats(operation);
|
||||
const memStats = this.getMemoryStats(operation);
|
||||
const threshold = this.thresholds.get(operation);
|
||||
|
||||
if (stats) {
|
||||
report += `## ${operation}\n\n`;
|
||||
report += `- Executions: ${stats.count}\n`;
|
||||
report += `- Duration:\n`;
|
||||
report += ` - Min: ${stats.min.toFixed(2)}ms\n`;
|
||||
report += ` - Max: ${stats.max.toFixed(2)}ms\n`;
|
||||
report += ` - Average: ${stats.avg.toFixed(2)}ms\n`;
|
||||
report += ` - Median: ${stats.median.toFixed(2)}ms\n`;
|
||||
report += ` - P95: ${stats.p95.toFixed(2)}ms\n`;
|
||||
report += ` - P99: ${stats.p99.toFixed(2)}ms\n`;
|
||||
report += ` - Std Dev: ${stats.stdDev.toFixed(2)}ms\n`;
|
||||
|
||||
if (memStats) {
|
||||
report += `- Memory:\n`;
|
||||
report += ` - Avg Used: ${memStats.avgMemoryUsed.toFixed(2)} MB\n`;
|
||||
report += ` - Max Used: ${memStats.maxMemoryUsed.toFixed(2)} MB\n`;
|
||||
}
|
||||
|
||||
if (threshold) {
|
||||
report += `- Thresholds:\n`;
|
||||
report += ` - Target: <${threshold.target}ms ${stats.avg <= threshold.target ? '✓' : '✗'}\n`;
|
||||
report += ` - Acceptable: <${threshold.acceptable}ms ${stats.avg <= threshold.acceptable ? '✓' : '✗'}\n`;
|
||||
report += ` - Maximum: <${threshold.maximum}ms ${stats.avg <= threshold.maximum ? '✓' : '✗'}\n`;
|
||||
}
|
||||
|
||||
report += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a metric violates thresholds
|
||||
*/
|
||||
private static checkThreshold(operation: string, metric: PerformanceMetric): void {
|
||||
const threshold = this.thresholds.get(operation);
|
||||
if (!threshold) return;
|
||||
|
||||
if (metric.duration > threshold.maximum) {
|
||||
console.warn(`⚠️ Performance violation: ${operation} took ${metric.duration.toFixed(2)}ms (max: ${threshold.maximum}ms)`);
|
||||
} else if (metric.duration > threshold.acceptable) {
|
||||
console.log(`⚡ Performance warning: ${operation} took ${metric.duration.toFixed(2)}ms (acceptable: ${threshold.acceptable}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all metrics
|
||||
*/
|
||||
static reset(): void {
|
||||
this.metrics.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export metrics to JSON
|
||||
*/
|
||||
static exportMetrics(): Record<string, PerformanceMetric[]> {
|
||||
const result: Record<string, PerformanceMetric[]> = {};
|
||||
|
||||
for (const [operation, metrics] of this.metrics) {
|
||||
result[operation] = metrics;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import metrics from JSON
|
||||
*/
|
||||
static importMetrics(data: Record<string, PerformanceMetric[]>): void {
|
||||
for (const [operation, metrics] of Object.entries(data)) {
|
||||
this.metrics.set(operation, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track concurrent operations
|
||||
*/
|
||||
static async trackConcurrent<T>(
|
||||
operation: string,
|
||||
tasks: Array<() => Promise<T>>,
|
||||
concurrency: number = 10
|
||||
): Promise<{
|
||||
results: T[];
|
||||
totalDuration: number;
|
||||
avgDuration: number;
|
||||
throughput: number;
|
||||
}> {
|
||||
const startTime = performance.now();
|
||||
const results: T[] = [];
|
||||
const durations: number[] = [];
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < tasks.length; i += concurrency) {
|
||||
const batch = tasks.slice(i, i + concurrency);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (task) => {
|
||||
const { result, metric } = await this.track(`${operation}-concurrent`, task);
|
||||
durations.push(metric.duration);
|
||||
return result;
|
||||
})
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const totalDuration = performance.now() - startTime;
|
||||
const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const throughput = (tasks.length / totalDuration) * 1000; // ops/sec
|
||||
|
||||
return {
|
||||
results,
|
||||
totalDuration,
|
||||
avgDuration,
|
||||
throughput
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize default thresholds
|
||||
PerformanceTracker.initializeDefaultThresholds();
|
Reference in New Issue
Block a user