diff --git a/.eslintrc b/.eslintrc index a89f60e6..955c85be 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,6 +5,7 @@ "ignorePatterns": [ "dist/", + "benchmark/", ], "rules": { diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..8dffe86c --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,65 @@ +name: Performance Benchmark + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + latest: ${{ steps.set-matrix.outputs.requireds }} + nonlatest: ${{ steps.set-matrix.outputs.optionals }} + steps: + - uses: ljharb/actions/node/matrix@main + id: set-matrix + with: + versionsAsRoot: true + type: majors + preset: '>= 0.8' + + benchmark: + needs: [matrix] + name: Run Performance Benchmarks + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ${{ fromJson(needs.matrix.outputs.latest) }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install dependencies + uses: ljharb/actions/node/install@main + with: + node-version: ${{ matrix.node-version }} + + - name: Install benchmark dependencies + run: cd benchmark && npm install + + # For PRs: Run benchmarks and compare against committed baseline + - name: Run benchmarks and compare against baseline + if: ${{ github.event_name == 'pull_request' }} + run: | + echo "==== Running benchmarks and comparing against baseline ====" + # Run benchmarks using the compare script that uses the committed baseline + npm run benchmark:compare + + # Print the Node.js version for debugging + echo "Benchmark completed on Node.js version: $(node --version)" + + # For pushes to main: Run benchmarks against the committed baseline + - name: Run benchmarks on main branch + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + run: | + # Run benchmarks against the committed baseline + npm run benchmark:compare + + # Print the Node.js version for debugging + echo "Benchmark completed on Node.js version: $(node --version)" diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000..ec42b388 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,206 @@ +# QS Library Benchmarking Suite + +This directory contains a comprehensive benchmarking infrastructure for the `qs` library, designed to measure performance across different Node.js versions and detect performance regressions. + +## Features + +- πŸš€ **Comprehensive Test Coverage**: Parse and stringify operations across various scenarios +- πŸ“Š **Cross-Node Version Testing**: Automated testing from Node.js 0.8 through current versions +- πŸ“ˆ **Regression Detection**: Compare performance against baselines +- πŸ”„ **CI Integration**: Automated benchmarks on every PR and push +- πŸ“‹ **Detailed Reporting**: Visual tables with performance metrics +- πŸ’Ύ **Historical Tracking**: Track performance trends over time + +## Quick Start + +### Install Dependencies +```bash +cd benchmark +npm install +``` + +### Run All Benchmarks +```bash +# Run all benchmarks +node index.js + +# Run only parse benchmarks +node index.js --parse + +# Run only stringify benchmarks +node index.js --stringify +``` + +### Compare with Baseline +```bash +# Save current results as baseline +node index.js --save-baseline + +# Compare against baseline +node index.js --baseline baseline.json +``` + +## Test Scenarios + +### Parse Benchmarks +- **Simple queries**: `a=1&b=2&c=3` +- **Nested objects**: `user[profile][name]=John` +- **Arrays**: `colors[]=red&colors[]=green` +- **Real-world cases**: E-commerce, forms, APIs +- **Edge cases**: Empty values, special characters +- **Different options**: `allowDots`, `comma`, `depth` + +### Stringify Benchmarks +- **Simple objects**: `{a: '1', b: '2'}` +- **Nested objects**: `{user: {name: 'John'}}` +- **Arrays**: `{colors: ['red', 'green']}` +- **Array formats**: indices, brackets, repeat, comma +- **Options**: `allowDots`, encoding, delimiters +- **Edge cases**: null values, empty arrays + +## Performance Metrics + +Each benchmark reports: +- **Operations per second (Hz)**: Primary performance metric +- **Relative margin of error (RME)**: Statistical reliability +- **Sample size**: Number of test runs +- **Fastest/Slowest indicators**: Comparative performance + +## Example Output + +``` +πŸ“Š Benchmark Results + +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Test Name β”‚ Ops/sec β”‚ RME β”‚ Samples β”‚ Status β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Parse simple tiny query β”‚ 2,451,234 β”‚ Β±1.23% β”‚ 89 β”‚ πŸš€ Fastest β”‚ +β”‚ Parse simple small query β”‚ 1,876,543 β”‚ Β±0.87% β”‚ 92 β”‚ β”‚ +β”‚ Parse nested object β”‚ 923,456 β”‚ Β±1.45% β”‚ 85 β”‚ β”‚ +β”‚ Parse complex array β”‚ 654,321 β”‚ Β±2.12% β”‚ 78 β”‚ 🐌 Slowest β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Baseline Comparison + +When comparing against a baseline: + +``` +πŸ“ˆ Performance Comparison + +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Test Name β”‚ Current β”‚ Baseline β”‚ Change β”‚ Status β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Parse simple query β”‚ 2,451,234 β”‚ 2,234,567 β”‚ +9.70% β”‚ βœ… Improved β”‚ +β”‚ Parse nested object β”‚ 923,456 β”‚ 934,123 β”‚ -1.14% β”‚ βž– No changeβ”‚ +β”‚ Parse complex array β”‚ 654,321 β”‚ 724,891 β”‚ -9.73% β”‚ ⚠️ Regressionβ”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Command Line Options + +```bash +node benchmark/index.js [options] + +Options: + --parse Run only parse benchmarks + --stringify Run only stringify benchmarks + --baseline FILE Compare results against baseline file + --save-baseline Save current results as baseline + --no-summary Skip summary report + --help Show help + +Environment Variables: + STRESS_TEST=1 Include stress test scenarios +``` + +## Adding New Benchmarks + +### 1. Add Test Data +Edit `fixtures.js` to add new test scenarios: + +```javascript +const newScenarios = { + myNewTest: 'complex=query&string=here' +}; +``` + +### 2. Add Benchmark +In `parse.js` or `stringify.js`: + +```javascript +runner.add('My new test case', () => { + qs.parse(fixtures.newScenarios.myNewTest); +}); +``` + +### 3. Test Locally +```bash +node index.js --parse +``` + +## Performance Guidelines + +### What to Benchmark +- βœ… Core functionality (parse/stringify) +- βœ… Common real-world scenarios +- βœ… Edge cases that might be slow +- βœ… Different option combinations +- βœ… Large inputs (stress tests) + +### What NOT to Benchmark +- ❌ Trivial operations +- ❌ Error conditions (use unit tests) +- ❌ Platform-specific features +- ❌ Operations that vary by environment + +### Interpreting Results + +- **5%+ improvement**: Significant performance gain βœ… +- **Β±5% change**: No meaningful change βž– +- **5%+ regression**: Potential performance issue ⚠️ + +## Troubleshooting + +### Inconsistent Results +- Ensure system is not under load +- Run multiple times and average +- Check for background processes + +### Memory Issues +- Use `--max-old-space-size=4096` for large tests +- Monitor memory usage during stress tests + +### CI Failures +- Check Node version compatibility +- Verify all dependencies are installed +- Review benchmark timeout settings + +## File Structure + +``` +benchmark/ +β”œβ”€β”€ index.js # Main benchmark runner +β”œβ”€β”€ runner.js # Benchmark infrastructure +β”œβ”€β”€ fixtures.js # Test data generators +β”œβ”€β”€ parse.js # Parse function benchmarks +β”œβ”€β”€ stringify.js # Stringify function benchmarks +β”œβ”€β”€ package.json # Benchmark dependencies +β”œβ”€β”€ README.md # This documentation +└── results/ # Generated benchmark results + β”œβ”€β”€ benchmark-*.json + └── baseline.json +``` + +## Contributing + +When adding performance optimizations: + +1. **Run baseline first**: `node index.js --save-baseline` +2. **Make your changes**: Implement optimization +3. **Run comparison**: `node index.js --baseline baseline.json` +4. **Verify improvements**: Check for positive performance changes +5. **Include stress tests**: `STRESS_TEST=1 node index.js` + +This ensures your optimization actually improves performance and doesn't introduce regressions. + diff --git a/benchmark/baseline.json b/benchmark/baseline.json new file mode 100644 index 00000000..a9396ffb --- /dev/null +++ b/benchmark/baseline.json @@ -0,0 +1,169 @@ +{ + "timestamp": "2025-06-24T14:33:27.757Z", + "nodeVersion": "v20.10.0", + "platform": "linux-x64", + "results": [ + { + "name": "Parse simple tiny query", + "hz": 339368.57646296266, + "rme": 1.5877642145574582, + "mean": 0.000002946648774681518, + "sample": 93, + "fastest": true, + "slowest": false + }, + { + "name": "Parse simple small query", + "hz": 166303.78950936385, + "rme": 1.0841983813030203, + "mean": 0.000006013092082569137, + "sample": 89, + "fastest": false, + "slowest": false + }, + { + "name": "Parse simple medium query", + "hz": 38462.85790917391, + "rme": 1.4113066897602626, + "mean": 0.000025999108083996187, + "sample": 89, + "fastest": false, + "slowest": false + }, + { + "name": "Parse simple large query", + "hz": 8062.515205586793, + "rme": 1.057628921792131, + "mean": 0.00012403077383433221, + "sample": 93, + "fastest": false, + "slowest": true + }, + { + "name": "Parse shallow nested", + "hz": 169808.09843727067, + "rme": 0.8811370835416009, + "mean": 0.000005889000637795923, + "sample": 93, + "fastest": false, + "slowest": false + }, + { + "name": "Parse medium nested", + "hz": 132794.2803924927, + "rme": 0.6406034191302279, + "mean": 0.0000075304448131678215, + "sample": 92, + "fastest": false, + "slowest": false + }, + { + "name": "Parse deep nested", + "hz": 72472.98755695937, + "rme": 0.6823000589684686, + "mean": 0.00001379824447300535, + "sample": 97, + "fastest": false, + "slowest": false + }, + { + "name": "Parse simple array", + "hz": 193669.5397470639, + "rme": 0.5945380745133809, + "mean": 0.000005163434587111731, + "sample": 99, + "fastest": false, + "slowest": false + }, + { + "name": "Parse indexed array", + "hz": 111618.07377058547, + "rme": 1.389286676498194, + "mean": 0.00000895912253471918, + "sample": 92, + "fastest": false, + "slowest": false + }, + { + "name": "Parse mixed array/object", + "hz": 79964.95688603172, + "rme": 1.4153317502720602, + "mean": 0.000012505477886084874, + "sample": 92, + "fastest": false, + "slowest": false + }, + { + "name": "Parse e-commerce query", + "hz": 55869.88938147373, + "rme": 0.8232662184894164, + "mean": 0.00001789872883356015, + "sample": 86, + "fastest": false, + "slowest": false + }, + { + "name": "Parse form data", + "hz": 64758.34859642685, + "rme": 1.0831317727449514, + "mean": 0.000015442024413438744, + "sample": 92, + "fastest": false, + "slowest": false + }, + { + "name": "Parse API query", + "hz": 50796.577670369334, + "rme": 1.792982399893066, + "mean": 0.00001968636561481031, + "sample": 95, + "fastest": false, + "slowest": false + }, + { + "name": "Parse empty values", + "hz": 249045.98363261085, + "rme": 0.9984244240955832, + "mean": 0.000004015322734436007, + "sample": 92, + "fastest": false, + "slowest": false + }, + { + "name": "Parse special characters", + "hz": 326350.9418241071, + "rme": 0.8537521124373892, + "mean": 0.000003064186039760132, + "sample": 92, + "fastest": false, + "slowest": false + }, + { + "name": "Parse with allowDots", + "hz": 188545.89300983382, + "rme": 0.9810731436902679, + "mean": 0.0000053037485146804226, + "sample": 97, + "fastest": false, + "slowest": false + }, + { + "name": "Parse with comma arrays", + "hz": 324909.63376456907, + "rme": 0.6122238240837989, + "mean": 0.0000030777788531952375, + "sample": 93, + "fastest": false, + "slowest": false + }, + { + "name": "Parse with depth limit", + "hz": 77198.2278999816, + "rme": 1.1472932485238967, + "mean": 0.000012953665222673309, + "sample": 98, + "fastest": false, + "slowest": false + } + ] +} diff --git a/benchmark/fixtures.js b/benchmark/fixtures.js new file mode 100644 index 00000000..aa48ec77 --- /dev/null +++ b/benchmark/fixtures.js @@ -0,0 +1,249 @@ +'use strict'; + +/** + * Generates various test data patterns for benchmarking + */ + +// Load polyfills for older Node.js versions +require('./polyfills'); + +// Helper function to replace Array.from for older Node.js +function generateArray(length, mapFn) { + var arr = new Array(length); + for (var i = 0; i < length; i++) { + arr[i] = mapFn(null, i); + } + return arr; +} + +// Helper function for repeating strings +function repeatString(str, count) { + if (typeof String.prototype.repeat === 'function') { + return str.repeat(count); + } + + var result = ''; + while (count > 0) { + result += str; + count--; + } + return result; +} + +// Simple flat query strings +var simpleQueries = { + tiny: 'a=1&b=2&c=3', + small: 'name=John&age=30&city=NYC&country=USA&email=john@example.com', +}; + +// Generate medium and large query strings using a loop +simpleQueries.medium = ''; +for (var i = 0; i < 20; i++) { + if (i > 0) simpleQueries.medium += '&'; + simpleQueries.medium += 'param' + i + '=value' + i; +} + +simpleQueries.large = ''; +for (var i = 0; i < 100; i++) { + if (i > 0) simpleQueries.large += '&'; + simpleQueries.large += 'param' + i + '=value' + i; +} + +simpleQueries.xlarge = ''; +for (var i = 0; i < 500; i++) { + if (i > 0) simpleQueries.xlarge += '&'; + simpleQueries.xlarge += 'param' + i + '=value' + i; +} + +// Nested object queries +var nestedQueries = { + shallow: 'user[name]=John&user[age]=30&user[email]=john@example.com', + medium: 'user[profile][name]=John&user[profile][bio]=Developer&user[settings][theme]=dark', + deep: 'user[profile][personal][name][first]=John&user[profile][personal][name][last]=Doe&user[profile][personal][address][street]=123 Main St&user[profile][personal][address][city]=NYC', + veryDeep: 'a[b][c][d][e][f][g][h][i][j]=deep', +}; + +// Array queries +var arrayQueries = { + simple: 'colors[]=red&colors[]=green&colors[]=blue', + indexed: 'items[0]=first&items[1]=second&items[2]=third&items[3]=fourth&items[4]=fifth', + sparse: 'sparse[1]=second&sparse[5]=sixth&sparse[10]=tenth', + mixed: 'user[name]=John&user[hobbies][]=reading&user[hobbies][]=coding&user[addresses][0][street]=123 Main&user[addresses][0][city]=NYC', +}; + +// Generate large array query string +arrayQueries.large = ''; +for (var i = 0; i < 50; i++) { + if (i > 0) arrayQueries.large += '&'; + arrayQueries.large += 'items[]=' + i; +} + +// Edge case queries +var edgeCaseQueries = { + emptyValues: 'a=&b=&c=value&d=', + specialChars: 'special=%21%40%23%24%25%5E%26%2A%28%29&unicode=%E2%9C%93', +}; + +// Generate long values - repeat 'x' 1000 times +var xRepeated = repeatString('x', 1000); +edgeCaseQueries.longValues = 'long=' + xRepeated + '&short=y'; + +// Generate many params +edgeCaseQueries.manyParams = ''; +for (var i = 0; i < 200; i++) { + if (i > 0) edgeCaseQueries.manyParams += '&'; + edgeCaseQueries.manyParams += 'p' + i + '=v' + i; +} + +// Generate deep nesting +edgeCaseQueries.deepNesting = 'deep'; +for (var i = 0; i < 10; i++) { + edgeCaseQueries.deepNesting += '[level' + i + ']'; +} +edgeCaseQueries.deepNesting += '=value'; + +// Mixed complexity queries (realistic scenarios) +var realWorldQueries = { + ecommerce: + 'category=electronics&subcategory=phones&brand[]=apple&brand[]=samsung&price[min]=100&price[max]=1000&features[]=camera&features[]=5g&sort=price&order=asc&page=2&limit=20', + form: 'user[name]=John%20Doe&user[email]=john%40example.com&user[age]=30&preferences[newsletter]=true&preferences[theme]=dark&address[street]=123%20Main%20St&address[city]=New%20York&address[zip]=10001', + analytics: + 'event=pageview×tamp=1640995200&user[id]=12345&user[session]=abc123&page[url]=/products/123&page[title]=Product%20Details&utm[source]=google&utm[medium]=cpc&utm[campaign]=summer_sale', + api: 'fields[]=id&fields[]=name&fields[]=email&include[]=profile&include[]=settings&filter[status]=active&filter[created_after]=2023-01-01&sort[]=name&sort[]=-created_at&page[number]=1&page[size]=25', +}; + +// Performance stress tests +var stressTestQueries = { + deepNesting: 'a' + repeatString('[level]', 20) + '=deep', +}; + +// Generate manyKeys +stressTestQueries.manyKeys = ''; +for (var i = 0; i < 1000; i++) { + if (i > 0) stressTestQueries.manyKeys += '&'; + stressTestQueries.manyKeys += 'key' + i + '=value' + i; +} + +// Generate largeArrays +stressTestQueries.largeArrays = ''; +for (var i = 0; i < 100; i++) { + if (i > 0) stressTestQueries.largeArrays += '&'; + stressTestQueries.largeArrays += 'items[]=' + i; +} + +// Generate mixedComplexity +stressTestQueries.mixedComplexity = ''; +// Simple items +for (var i = 0; i < 50; i++) { + if (i > 0) stressTestQueries.mixedComplexity += '&'; + stressTestQueries.mixedComplexity += 'simple' + i + '=value' + i; +} +// Nested items +for (var i = 0; i < 20; i++) { + stressTestQueries.mixedComplexity += '&nested[' + i + '][prop]=value' + i; +} +// Array items +for (var i = 0; i < 30; i++) { + stressTestQueries.mixedComplexity += '&array[]=' + i; +} + +// Generate test objects for stringify benchmarks +var testObjects = { + simple: { a: '1', b: '2', c: '3' }, + nested: { + user: { + name: 'John', + profile: { + age: 30, + bio: 'Developer', + }, + }, + }, + arrays: { + colors: ['red', 'green', 'blue'], + numbers: [1, 2, 3, 4, 5], + }, + mixed: { + name: 'John', + hobbies: ['reading', 'coding'], + address: { + street: '123 Main St', + city: 'NYC', + coordinates: { + lat: 40.7128, + lng: -74.006, + }, + }, + preferences: { + theme: 'dark', + notifications: { + email: true, + push: false, + }, + }, + }, +}; + +// Generate large object without using Object.fromEntries +testObjects.large = {}; +for (var i = 0; i < 100; i++) { + testObjects.large['key' + i] = 'value' + i; +} + +module.exports = { + simpleQueries: simpleQueries, + nestedQueries: nestedQueries, + arrayQueries: arrayQueries, + edgeCaseQueries: edgeCaseQueries, + realWorldQueries: realWorldQueries, + stressTestQueries: stressTestQueries, + testObjects: testObjects, + + // Helper functions + generateQuery: function (type, size) { + size = size || 'medium'; + var queries = { + simple: simpleQueries, + nested: nestedQueries, + array: arrayQueries, + edge: edgeCaseQueries, + real: realWorldQueries, + stress: stressTestQueries, + }; + + return queries[type] && queries[type][size] ? queries[type][size] : simpleQueries.medium; + }, + + generateObject: function (type) { + return testObjects[type] || testObjects.simple; + }, +}; + +module.exports = { + simpleQueries: simpleQueries, + nestedQueries: nestedQueries, + arrayQueries: arrayQueries, + edgeCaseQueries: edgeCaseQueries, + realWorldQueries: realWorldQueries, + stressTestQueries: stressTestQueries, + testObjects: testObjects, + + // Helper functions + generateQuery: function (type, size) { + size = size || 'medium'; + var queries = { + simple: simpleQueries, + nested: nestedQueries, + array: arrayQueries, + edge: edgeCaseQueries, + real: realWorldQueries, + stress: stressTestQueries, + }; + + return queries[type] && queries[type][size] ? queries[type][size] : simpleQueries.medium; + }, + + generateObject: function (type) { + return testObjects[type] || testObjects.simple; + }, +}; diff --git a/benchmark/index.js b/benchmark/index.js new file mode 100644 index 00000000..85ee9865 --- /dev/null +++ b/benchmark/index.js @@ -0,0 +1,252 @@ +#!/usr/bin/env node +'use strict'; + +var runParseBenchmarks = require('./parse'); +var runStringifyBenchmarks = require('./stringify'); +var colors = require('colors'); +var fs = require('fs'); +var path = require('path'); + +// Load polyfills for older Node.js versions +require('./polyfills'); + +function main(callback) { + var args = process.argv.slice(2); + var options = parseArgs(args); + + console.log(colors.blue.bold('πŸš€ QS Library Benchmarks')); + console.log(colors.gray('Node.js ' + process.version + ' on ' + process.platform + '-' + process.arch + '\n')); + + if (options.baseline) { + console.log(colors.yellow('πŸ“Š Comparing against baseline: ' + options.baseline + '\n')); + } + + // Step 1: Run parse benchmarks if needed + function runParse() { + if (options.parse || options.all) { + runParseBenchmarks(function(err) { + if (err) { + return handleError(err); + } + console.log('\n' + repeatString('=', 60) + '\n'); + runStringify(); + }); + } else { + runStringify(); + } + } + + // Step 2: Run stringify benchmarks if needed + function runStringify() { + if (options.stringify || options.all) { + runStringifyBenchmarks(function(err) { + if (err) { + return handleError(err); + } + console.log('\n' + repeatString('=', 60) + '\n'); + finalize(); + }); + } else { + finalize(); + } + } + + // Step 3: Finalize benchmarks + function finalize() { + // Generate summary report + if (options.summary) { + generateSummaryReport(); + } + + console.log(colors.green('βœ… Benchmarks completed successfully!')); + + if (options.save) { + saveBaseline(); + } + + if (callback) callback(null); + } + + // Handle errors + function handleError(error) { + console.error(colors.red('❌ Benchmark failed:'), error.message); + if (callback) callback(error); + else process.exit(1); + } + + // Start the process + runParse(); +} + +// Helper function for repeating strings (replacement for String.repeat) +function repeatString(str, count) { + if (typeof String.prototype.repeat === 'function') { + return str.repeat(count); + } + + var result = ''; + while (count > 0) { + result += str; + count--; + } + return result; +} + +function parseArgs(args) { + var options = { + all: true, + parse: false, + stringify: false, + baseline: null, + summary: true, + save: false + }; + + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + switch (arg) { + case '--parse': + options.parse = true; + options.all = false; + break; + case '--stringify': + options.stringify = true; + options.all = false; + break; + case '--baseline': + if (i + 1 < args.length) { + options.baseline = args[i + 1]; + i++; + } + break; + case '--save-baseline': + options.save = true; + break; + case '--no-summary': + options.summary = false; + break; + case '--help': + printHelp(); + process.exit(0); + default: + if (arg.indexOf('--') === 0) { + console.error(colors.red('Unknown option: ' + arg)); + printHelp(); + process.exit(1); + } + } + } + + return options; +} + +function printHelp() { + console.log( + '\n' + colors.blue.bold('QS Benchmark Suite') + + '\n\n' + colors.yellow('Usage:') + + '\n node benchmark/index.js [options]' + + '\n\n' + colors.yellow('Options:') + + '\n --parse Run only parse benchmarks' + + '\n --stringify Run only stringify benchmarks' + + '\n --baseline FILE Compare results against baseline file' + + '\n --save-baseline Save current results as baseline' + + '\n --no-summary Skip summary report' + + '\n --help Show this help' + + '\n\n' + colors.yellow('Examples:') + + '\n node benchmark/index.js # Run all benchmarks' + + '\n node benchmark/index.js --parse # Run only parse benchmarks' + + '\n node benchmark/index.js --baseline baseline.json # Compare with baseline' + + '\n node benchmark/index.js --save-baseline # Save as new baseline' + + '\n STRESS_TEST=1 node benchmark/index.js # Include stress tests' + + '\n\n' + colors.yellow('Environment Variables:') + + '\n STRESS_TEST=1 Include stress test scenarios' + + '\n' + ); +} + +function generateSummaryReport() { + var resultsDir = path.join(__dirname, 'results'); + if (!fs.existsSync(resultsDir)) return; + + var files = fs.readdirSync(resultsDir) + .filter(function(f) { + return f.indexOf('benchmark-') === 0 && f.indexOf('.json') === f.length - 5; + }) + .sort(); + + // Get the last 5 runs + if (files.length > 5) { + files = files.slice(files.length - 5); + } + + if (files.length === 0) return; + + console.log(colors.blue('\nπŸ“ˆ Performance Trend (Last 5 Runs)\n')); + + var trends = {}; + for (var i = 0; i < files.length; i++) { + var file = files[i]; + var data = JSON.parse(fs.readFileSync(path.join(resultsDir, file), 'utf8')); + + for (var j = 0; j < data.results.length; j++) { + var result = data.results[j]; + if (!trends[result.name]) trends[result.name] = []; + trends[result.name].push(result.hz); + } + } + + for (var testName in trends) { + if (trends.hasOwnProperty(testName)) { + var values = trends[testName]; + if (values.length < 2) continue; + + var latest = values[values.length - 1]; + var previous = values[values.length - 2]; + var change = ((latest - previous) / previous * 100).toFixed(2); + var trend; + + if (change > 5) { + trend = 'πŸ“ˆ'; + } else if (change < -5) { + trend = 'πŸ“‰'; + } else { + trend = 'βž–'; + } + + var changeText = (change >= 0 ? '+' : '') + change + '%'; + console.log(trend + ' ' + testName + ': ' + changeText); + } + } +} + +function saveBaseline() { + var resultsDir = path.join(__dirname, 'results'); + var files = fs.readdirSync(resultsDir) + .filter(function(f) { + return f.indexOf('benchmark-') === 0 && f.indexOf('.json') === f.length - 5; + }) + .sort(); + + if (files.length === 0) return; + + var latest = files[files.length - 1]; + var baselinePath = path.join(__dirname, 'baseline.json'); + + // Copy file - Node.js 0.8 doesn't have fs.copyFileSync + var content = fs.readFileSync(path.join(resultsDir, latest)); + fs.writeFileSync(baselinePath, content); + + console.log(colors.green('πŸ’Ύ Baseline saved to ' + baselinePath)); +} + +if (require.main === module) { + main(function(err) { + if (err) { + console.error(err); + process.exit(1); + } + }); +} + +module.exports = { main: main }; + diff --git a/benchmark/package.json b/benchmark/package.json new file mode 100644 index 00000000..ce59ed3f --- /dev/null +++ b/benchmark/package.json @@ -0,0 +1,15 @@ +{ + "name": "qs-benchmarks", + "private": true, + "description": "Benchmarking suite for qs library", + "dependencies": { + "array-includes": "^3.1.9", + "array.from": "^1.1.6", + "benchmark": "^2.1.4", + "colors": "=1.4.0", + "es6-shim": "^0.35.8", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "string.prototype.repeat": "^1.0.0" + } +} diff --git a/benchmark/parse.js b/benchmark/parse.js new file mode 100644 index 00000000..14f19ad8 --- /dev/null +++ b/benchmark/parse.js @@ -0,0 +1,117 @@ +'use strict'; + +var BenchmarkRunner = require('./runner'); +var fixtures = require('./fixtures'); +var qs = require('../lib/index'); + +// Ensure polyfills are loaded +require('./polyfills'); + +function runParseBenchmarks(callback) { + console.log('πŸ” Running Parse Benchmarks...\n'); + + var runner = new BenchmarkRunner({ + verbose: true, + outputDir: './benchmark/results' + }); + + // Simple parsing benchmarks + runner + .add('Parse simple tiny query', function() { + qs.parse(fixtures.simpleQueries.tiny); + }) + .add('Parse simple small query', function() { + qs.parse(fixtures.simpleQueries.small); + }) + .add('Parse simple medium query', function() { + qs.parse(fixtures.simpleQueries.medium); + }) + .add('Parse simple large query', function() { + qs.parse(fixtures.simpleQueries.large); + }); + + // Nested object parsing + runner + .add('Parse shallow nested', function() { + qs.parse(fixtures.nestedQueries.shallow); + }) + .add('Parse medium nested', function() { + qs.parse(fixtures.nestedQueries.medium); + }) + .add('Parse deep nested', function() { + qs.parse(fixtures.nestedQueries.deep); + }); + + // Array parsing + runner + .add('Parse simple array', function() { + qs.parse(fixtures.arrayQueries.simple); + }) + .add('Parse indexed array', function() { + qs.parse(fixtures.arrayQueries.indexed); + }) + .add('Parse mixed array/object', function() { + qs.parse(fixtures.arrayQueries.mixed); + }); + + // Real-world scenarios + runner + .add('Parse e-commerce query', function() { + qs.parse(fixtures.realWorldQueries.ecommerce); + }) + .add('Parse form data', function() { + qs.parse(fixtures.realWorldQueries.form); + }) + .add('Parse API query', function() { + qs.parse(fixtures.realWorldQueries.api); + }); + + // Edge cases + runner + .add('Parse empty values', function() { + qs.parse(fixtures.edgeCaseQueries.emptyValues); + }) + .add('Parse special characters', function() { + qs.parse(fixtures.edgeCaseQueries.specialChars); + }); + + // Options testing + runner + .add('Parse with allowDots', function() { + qs.parse('user.name=John&user.age=30', { allowDots: true }); + }) + .add('Parse with comma arrays', function() { + qs.parse('colors=red,green,blue', { comma: true }); + }) + .add('Parse with depth limit', function() { + qs.parse(fixtures.nestedQueries.deep, { depth: 2 }); + }); + + // Stress tests (comment out for regular benchmarks) + if (process.env.STRESS_TEST) { + runner + .add('Parse many keys (stress)', function() { + qs.parse(fixtures.stressTestQueries.manyKeys); + }) + .add('Parse deep nesting (stress)', function() { + qs.parse(fixtures.stressTestQueries.deepNesting); + }) + .add('Parse large arrays (stress)', function() { + qs.parse(fixtures.stressTestQueries.largeArrays); + }); + } + + runner.run(callback); +} + +if (require.main === module) { + runParseBenchmarks(function(err) { + if (err) { + console.error(err); + process.exit(1); + } + }); +} + +module.exports = runParseBenchmarks; + diff --git a/benchmark/polyfills.js b/benchmark/polyfills.js new file mode 100644 index 00000000..b1e805f5 --- /dev/null +++ b/benchmark/polyfills.js @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Polyfills for older Node.js versions (0.8+) + * This file provides shims using well-maintained npm packages + */ + +// Load `Array.from` polyfill +require('array.from/auto'); + +// Load `String.prototype.repeat` polyfill +require('string.prototype.repeat/auto'); + +// Load `Object.entries` polyfill +require('object.entries/auto'); + +// Load `Object.fromEntries` polyfill +require('object.fromentries/auto'); + +// Load `Array.prototype.includes` polyfill +require('array-includes/auto'); + +// Load full ES6 shims (includes Promise and many more) +require('es6-shim'); + +// Patch `fs.mkdirSync` to support recursive (for Node <10) +var fs = require('fs'); +var path = require('path'); + +var originalMkdirSync = fs.mkdirSync; +fs.mkdirSync = function (dirPath, options) { + if (options && options.recursive) { + var parts = dirPath.split(path.sep); + var currentPath = ''; + + for (var i = 0; i < parts.length; i++) { + if (!parts[i] && i === 0) { + currentPath = path.sep; + continue; + } + + currentPath = path.join(currentPath, parts[i]); + + try { + fs.statSync(currentPath); + } catch (e) { + originalMkdirSync(currentPath); + } + } + return; + } + + return originalMkdirSync.apply(this, arguments); +}; diff --git a/benchmark/runner.js b/benchmark/runner.js new file mode 100644 index 00000000..76a143f5 --- /dev/null +++ b/benchmark/runner.js @@ -0,0 +1,212 @@ +'use strict'; + +var Benchmark = require('benchmark'); +var colors = require('colors'); +var fs = require('fs'); +var path = require('path'); + +// Load polyfills for older Node.js versions +require('./polyfills'); + +function table(data) { + return data + .map(function (row) { + return row.join('\t'); + }) + .join('\n'); +} +function BenchmarkRunner(options) { + options = options || {}; + + this.options = { + outputDir: options.outputDir || path.join(__dirname, 'results'), + baseline: options.baseline || null, + verbose: options.verbose || false, + }; + + // Copy any other options + for (var key in options) { + if (options.hasOwnProperty(key) && !this.options.hasOwnProperty(key)) { + this.options[key] = options[key]; + } + } + + this.results = []; + this.suite = new Benchmark.Suite(); + + // Ensure output directory exists + if (!fs.existsSync(this.options.outputDir)) { + fs.mkdirSync(this.options.outputDir, { recursive: true }); + } +} + +BenchmarkRunner.prototype.add = function (name, fn, options) { + options = options || {}; + var self = this; + + var completeOptions = { + onComplete: function (event) { + var benchmark = event.target; + self.results.push({ + name: benchmark.name, + hz: benchmark.hz, + rme: benchmark.stats.rme, + mean: benchmark.stats.mean, + sample: benchmark.stats.sample.length, + fastest: false, + slowest: false, + }); + }, + }; + + // Copy any other options + for (var key in options) { + if (options.hasOwnProperty(key)) { + completeOptions[key] = options[key]; + } + } + + this.suite.add(name, fn, completeOptions); + return this; +}; + +BenchmarkRunner.prototype.run = function (callback) { + var self = this; + + // For backward compatibility, if no callback is provided, return a Promise + if (!callback && typeof Promise !== 'undefined') { + return new Promise(function (resolve, reject) { + self._runWithCallbacks(function (err, results) { + if (err) return reject(err); + resolve(results); + }); + }); + } + + return this._runWithCallbacks(callback || function () {}); +}; + +BenchmarkRunner.prototype._runWithCallbacks = function (callback) { + var self = this; + + this.suite + .on('start', function () { + if (self.options.verbose) { + console.log(colors.blue('πŸš€ Starting benchmarks...')); + } + }) + .on('cycle', function (event) { + if (self.options.verbose) { + console.log(colors.gray(String(event.target))); + } + }) + .on('complete', function () { + self._markFastestSlowest(); + self._generateReport(); + callback(null, self.results); + }) + .on('error', function (err) { + callback(err); + }) + .run({ async: true }); + + return this; +}; + +BenchmarkRunner.prototype._markFastestSlowest = function () { + if (this.results.length === 0) return; + + var sorted = this.results.slice().sort(function (a, b) { + return b.hz - a.hz; + }); + sorted[0].fastest = true; + sorted[sorted.length - 1].slowest = true; +}; + +BenchmarkRunner.prototype._generateReport = function () { + var timestamp = new Date().toISOString(); + var nodeVersion = process.version; + var platform = process.platform + '-' + process.arch; + + // Console output + this._printConsoleReport(); + + // JSON output for CI/regression detection + var jsonReport = { + timestamp: timestamp, + nodeVersion: nodeVersion, + platform: platform, + results: this.results, + }; + + var jsonPath = path.join(this.options.outputDir, 'benchmark-' + Date.now() + '.json'); + fs.writeFileSync(jsonPath, JSON.stringify(jsonReport, null, 2)); + + // Baseline comparison if available + if (this.options.baseline) { + this._compareWithBaseline(jsonReport); + } +}; + +BenchmarkRunner.prototype._printConsoleReport = function () { + console.log(colors.green('\nπŸ“Š Benchmark Results\n')); + + var tableData = [['Test Name', 'Ops/sec', 'RME', 'Samples', 'Status']]; + + for (var i = 0; i < this.results.length; i++) { + var result = this.results[i]; + var opsPerSec = result.hz.toLocaleString('en-US', { maximumFractionDigits: 0 }); + var rme = 'Β±' + result.rme.toFixed(2) + '%'; + var status = result.fastest ? 'πŸš€ Fastest' : result.slowest ? '🐌 Slowest' : ''; + + tableData.push([result.name, opsPerSec, rme, result.sample.toString(), status]); + } + + console.log(table(tableData)); +}; + +BenchmarkRunner.prototype._compareWithBaseline = function (currentReport) { + if (!fs.existsSync(this.options.baseline)) { + console.log(colors.yellow('⚠️ Baseline file not found, skipping comparison')); + return; + } + + try { + var baseline = JSON.parse(fs.readFileSync(this.options.baseline, 'utf8')); + console.log(colors.blue('\nπŸ“ˆ Performance Comparison\n')); + + var comparisonData = [['Test Name', 'Current', 'Baseline', 'Change', 'Status']]; + + for (var i = 0; i < currentReport.results.length; i++) { + var current = currentReport.results[i]; + var baselineResult = null; + + for (var j = 0; j < baseline.results.length; j++) { + if (baseline.results[j].name === current.name) { + baselineResult = baseline.results[j]; + break; + } + } + + if (!baselineResult) continue; + + var change = (((current.hz - baselineResult.hz) / baselineResult.hz) * 100).toFixed(2); + var changeStr = (change >= 0 ? '+' : '') + change + '%'; + var status = Math.abs(change) < 5 ? 'βž– No change' : change > 5 ? 'βœ… Improved' : '⚠️ Regression'; + + comparisonData.push([ + current.name, + current.hz.toLocaleString('en-US', { maximumFractionDigits: 0 }), + baselineResult.hz.toLocaleString('en-US', { maximumFractionDigits: 0 }), + changeStr, + status, + ]); + } + + console.log(table(comparisonData)); + } catch (error) { + console.error(colors.red('❌ Error comparing with baseline: ' + error.message)); + } +}; + +module.exports = BenchmarkRunner; diff --git a/benchmark/stringify.js b/benchmark/stringify.js new file mode 100644 index 00000000..e4ec050b --- /dev/null +++ b/benchmark/stringify.js @@ -0,0 +1,101 @@ +'use strict'; + +var BenchmarkRunner = require('./runner'); +var fixtures = require('./fixtures'); +var qs = require('../lib'); + +// Ensure polyfills are loaded +require('./polyfills'); + +function runStringifyBenchmarks(callback) { + console.log('πŸ”— Running Stringify Benchmarks...\n'); + + var runner = new BenchmarkRunner({ + verbose: true, + outputDir: './benchmark/results' + }); + + // Simple object stringifying + runner + .add('Stringify simple object', function() { + qs.stringify(fixtures.testObjects.simple); + }) + .add('Stringify nested object', function() { + qs.stringify(fixtures.testObjects.nested); + }) + .add('Stringify arrays', function() { + qs.stringify(fixtures.testObjects.arrays); + }) + .add('Stringify mixed object', function() { + qs.stringify(fixtures.testObjects.mixed); + }) + .add('Stringify large object', function() { + qs.stringify(fixtures.testObjects.large); + }); + + // Different array formats + runner + .add('Stringify array indices format', function() { + qs.stringify(fixtures.testObjects.arrays, { arrayFormat: 'indices' }); + }) + .add('Stringify array brackets format', function() { + qs.stringify(fixtures.testObjects.arrays, { arrayFormat: 'brackets' }); + }) + .add('Stringify array repeat format', function() { + qs.stringify(fixtures.testObjects.arrays, { arrayFormat: 'repeat' }); + }) + .add('Stringify array comma format', function() { + qs.stringify(fixtures.testObjects.arrays, { arrayFormat: 'comma' }); + }); + + // Different options + runner + .add('Stringify with allowDots', function() { + qs.stringify(fixtures.testObjects.nested, { allowDots: true }); + }) + .add('Stringify without encoding', function() { + qs.stringify(fixtures.testObjects.mixed, { encode: false }); + }) + .add('Stringify with query prefix', function() { + qs.stringify(fixtures.testObjects.simple, { addQueryPrefix: true }); + }) + .add('Stringify with custom delimiter', function() { + qs.stringify(fixtures.testObjects.simple, { delimiter: ';' }); + }); + + // Edge cases + var edgeObjects = { + nullValues: { a: null, b: '', c: 'value' }, + undefinedValues: { a: undefined, b: 'value', c: null }, + emptyArrays: { arr: [], obj: {}, value: 'test' }, + specialChars: { special: '!@#$%^&*()', unicode: 'βœ“' } + }; + + runner + .add('Stringify null values', function() { + qs.stringify(edgeObjects.nullValues); + }) + .add('Stringify with strictNullHandling', function() { + qs.stringify(edgeObjects.nullValues, { strictNullHandling: true }); + }) + .add('Stringify with skipNulls', function() { + qs.stringify(edgeObjects.nullValues, { skipNulls: true }); + }) + .add('Stringify special characters', function() { + qs.stringify(edgeObjects.specialChars); + }); + + runner.run(callback); +} + +if (require.main === module) { + runStringifyBenchmarks(function(err) { + if (err) { + console.error(err); + process.exit(1); + } + }); +} + +module.exports = runStringifyBenchmarks; + diff --git a/package.json b/package.json index 78bdae36..a7cf839a 100644 --- a/package.json +++ b/package.json @@ -77,12 +77,19 @@ "readme": "evalmd README.md", "postlint": "eclint check $(git ls-files | xargs find 2> /dev/null | grep -vE 'node_modules|\\.git' | grep -v dist/)", "lint": "eslint --ext=js,mjs .", + "benchmark": "cd benchmark && npm install && node .", + "benchmark:parse": "npm run benchmark -- --parse", + "benchmark:stringify": "npm run benchmark -- --stringify", + "benchmark:baseline": "npm run benchmark -- --save-baseline", + "benchmark:compare": "npm run benchmark -- --baseline benchmark/baseline.json", + "benchmark:stress": "STRESS_TEST=1 npm run benchmark", "dist": "mkdirp dist && browserify --standalone Qs -g unassertify -g @browserify/envify -g [@browserify/uglifyify --mangle.keep_fnames --compress.keep_fnames --format.indent_level=1 --compress.arrows=false --compress.passes=4 --compress.typeofs=false] -p common-shakeify -p bundle-collapser/plugin lib/index.js > dist/qs.js" }, "license": "BSD-3-Clause", "publishConfig": { "ignore": [ "!dist/*", + "benchmark", "bower.json", "component.json", ".github/workflows",